复用 master CI 产物自动更新编译缓存
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

仅由 master CI 导出新增缓存对象,宿主合并去重并组装限额快照,避免重复预热编译。
跟踪任务领取与最终上报,仅在安全空闲时切换镜像,移除管理员 API 依赖。
排除取消及不完整产物,定向清理过期镜像、归档和中断上传残块。
同步部署文档、共享记忆及缓存发布、合并和清理的定向测试。
This commit is contained in:
2026-09-22 08:49:58 +00:00
parent e3c2c1b732
commit b1ef9eef81
19 changed files with 2473 additions and 87 deletions
+145 -9
View File
@@ -2,6 +2,7 @@
import http.client
import http.server
import gzip
import importlib.util
import json
import os
@@ -21,6 +22,29 @@ MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
FETCH = "/api/actions/runner.v1.RunnerService/FetchTask"
UPDATE = "/api/actions/runner.v1.RunnerService/UpdateTask"
LOG = "/api/actions/runner.v1.RunnerService/UpdateLog"
PING = "/api/actions/ping.v1.PingService/Ping"
def varint(value):
data = bytearray()
while value > 127:
data.append((value & 127) | 128)
value >>= 7
data.append(value)
return bytes(data)
def field(number, value):
# actions-proto-go v0.4.1: task/state ID=1; result=2; log task_id=1,
# index=2, rows=3, no_more=4; log response ack_index=1.
if isinstance(value, bytes):
return varint(number * 8 + 2) + varint(len(value)) + value
return varint(number * 8) + varint(value)
def state(task_id, result=0):
return field(1, field(1, task_id) + field(2, result))
def core_state(state):
@@ -50,8 +74,17 @@ class Upstream(http.server.BaseHTTPRequestHandler):
self.wfile.write(b"incomplete")
self.close_connection = True
return
body = b'{"task":null}'
self.send_response(200)
body = self.server.responses.get(self.path, b"")
if self.path == UPDATE and self.path not in self.server.responses:
body = state(*map(int, MODULE.task_state(MODULE.protobuf_fields(data))))
if self.path == LOG and self.path not in self.server.responses:
fields = MODULE.protobuf_fields(data)
body = field(1, MODULE.single(fields, 2, 0) + len(fields.get(3, [])))
self.send_response(self.server.statuses.get(self.path, 200))
self.send_header("Content-Type", self.server.content_type)
if self.server.compressed:
body = gzip.compress(body)
self.send_header("Content-Encoding", "gzip")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
@@ -66,6 +99,10 @@ class GateTests(unittest.TestCase):
self.upstream.entered = threading.Event()
self.upstream.release = threading.Event()
self.upstream.truncated = False
self.upstream.responses = {}
self.upstream.statuses = {}
self.upstream.content_type = "application/proto"
self.upstream.compressed = False
self.proxy = MODULE.create_proxy(("127.0.0.1", 0),
f"http://127.0.0.1:{self.upstream.server_port}", self.gate)
self.servers = [self.upstream, self.proxy]
@@ -79,13 +116,15 @@ class GateTests(unittest.TestCase):
server.server_close()
self.temp.cleanup()
def request(self, path, body=b"{}", chunked=False):
def request(self, path, body=b"", chunked=False):
connection = http.client.HTTPConnection("127.0.0.1", self.proxy.server_port, timeout=5)
try:
if chunked:
connection.request("POST", path, body=[body], encode_chunked=True)
connection.request("POST", path, body=[body], encode_chunked=True,
headers={"Content-Type": "application/proto"})
else:
connection.request("POST", path, body=body)
connection.request("POST", path, body=body,
headers={"Content-Type": "application/proto"})
response = connection.getresponse()
result = response.status, response.read()
return result
@@ -112,7 +151,7 @@ class GateTests(unittest.TestCase):
self.assertEqual(core_state(self.gate.control("pause")),
{"paused": True, "inflight": 1, "uncertain": False})
self.assertEqual(self.request(FETCH)[0], 503)
self.assertEqual(self.request(UPDATE)[0], 200)
self.assertEqual(self.request(PING)[0], 200)
self.assertEqual(sum(path == FETCH for path, _ in self.upstream.requests), 1)
self.upstream.release.set()
thread.join(3)
@@ -159,8 +198,8 @@ class GateTests(unittest.TestCase):
self.assertFalse(MODULE.Gate(self.temp.name).control("status")["paused"])
def test_chunked_body_is_decoded_and_non_rpc_path_rejected(self):
self.assertEqual(self.request(UPDATE, b'{"state":"running"}', chunked=True)[0], 200)
self.assertEqual(self.upstream.requests[-1], (UPDATE, b'{"state":"running"}'))
self.assertEqual(self.request(PING, b"ping", chunked=True)[0], 200)
self.assertEqual(self.upstream.requests[-1], (PING, b"ping"))
self.assertEqual(self.request("/api/v1/repos")[0], 404)
self.assertEqual(self.request("/api/actions/../v1/repos")[0], 404)
@@ -200,12 +239,109 @@ class GateTests(unittest.TestCase):
self.assertEqual(state["last_fetch_peer"], "127.0.0.1")
self.assertGreaterEqual(state["last_fetch_at"], before)
self.assertEqual(self.upstream.requests, [])
self.assertEqual(self.request(UPDATE)[0], 200)
self.assertEqual(self.request(PING)[0], 200)
self.assertEqual(self.gate.control("status")["last_fetch_at"], state["last_fetch_at"])
restarted = MODULE.Gate(self.temp.name).control("status")
self.assertIsNone(restarted["last_fetch_peer"])
self.assertIsNone(restarted["last_fetch_at"])
def fetch_task(self, task_id=42):
self.upstream.responses[FETCH] = field(1, field(1, task_id)) + field(2, 10)
self.upstream.release.set()
self.assertEqual(self.request(FETCH)[0], 200)
self.assertEqual(self.gate.control("status")["task_ids"], [str(task_id)])
def finalize_log(self, task_id=42):
self.assertEqual(self.request(LOG, field(1, task_id) + field(2, 3)
+ field(3, b"log row") + field(4, 1))[0], 200)
self.wait_for(lambda: self.gate.tasks.get(str(task_id)) is True)
def test_assigned_task_survives_pause_until_runner_finished_reporting(self):
self.fetch_task()
self.assertEqual(self.gate.control("pause")["active_tasks"], 1)
self.assertEqual(self.request(FETCH)[0], 503)
self.assertEqual(self.request(UPDATE, state(42, 1))[0], 200)
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
self.finalize_log()
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
self.assertEqual(self.request(UPDATE, state(42, 1))[0], 200)
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
self.assertFalse(self.gate.control("status")["uncertain"])
# 终态响应在客户端超时后重试,不应再次阻断后续任务领取。
self.request(UPDATE, state(42, 1))
self.assertFalse(self.gate.control("status")["uncertain"])
def test_compressed_fetch_and_task_ledger_survive_restart(self):
self.upstream.compressed = True
self.fetch_task()
restored = MODULE.Gate(self.temp.name)
self.assertEqual(restored.control("status")["task_ids"], ["42"])
self.assertFalse(restored.control("status")["uncertain"])
self.proxy.gate = self.gate = restored
self.finalize_log()
self.request(UPDATE, state(42, 2))
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
self.assertEqual(MODULE.Gate(self.temp.name).control("status")["active_tasks"], 0)
def test_server_cancellation_does_not_retire_executing_task(self):
self.fetch_task()
self.upstream.responses[UPDATE] = state(42, 3)
self.request(UPDATE, state(42))
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
self.finalize_log()
self.request(UPDATE, state(42))
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
self.request(UPDATE, state(42, 3))
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
def test_partial_final_log_acknowledgement_keeps_task_active(self):
self.fetch_task()
self.upstream.responses[LOG] = field(1, 3)
self.request(LOG, field(1, 42) + field(2, 3) + field(3, b"row") + field(4, 1))
self.request(UPDATE, state(42, 1))
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
del self.upstream.responses[LOG]
self.finalize_log()
self.request(UPDATE, state(42, 1))
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
def test_final_state_waits_for_success_and_output_acknowledgement(self):
self.fetch_task()
self.finalize_log()
self.upstream.statuses[UPDATE] = 500
self.request(UPDATE, state(42, 1))
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
self.upstream.statuses[UPDATE] = 200
output = field(2, field(1, b"key") + field(2, b"value"))
self.request(UPDATE, state(42, 1) + output)
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
self.upstream.responses[UPDATE] = state(42, 1) + field(2, b"key")
self.request(UPDATE, state(42, 1) + output)
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
def test_unknown_or_invalid_fetch_result_prevents_idle(self):
self.upstream.release.set()
self.upstream.responses[FETCH] = b"not protobuf"
self.assertEqual(self.request(FETCH)[0], 200)
self.assertTrue(self.gate.control("status")["uncertain"])
self.assertTrue(MODULE.Gate(self.temp.name).control("status")["uncertain"])
def test_task_report_without_observed_assignment_requires_idle_bootstrap(self):
self.assertEqual(self.request(UPDATE, state(42))[0], 200)
self.wait_for(lambda: self.gate.control("status")["uncertain"])
self.assertIn("error", self.gate.control("resume"))
def test_disconnected_fetch_client_still_leaves_assigned_task_busy(self):
self.upstream.responses[FETCH] = field(1, field(1, 42))
client = socket.create_connection(self.proxy.server_address, timeout=3)
client.sendall(f"POST {FETCH} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n".encode())
self.assertTrue(self.upstream.entered.wait(3))
client.close()
self.gate.control("pause")
self.upstream.release.set()
self.wait_for(lambda: self.gate.control("status")["inflight"] == 0)
self.assertEqual(self.gate.control("status")["task_ids"], ["42"])
if __name__ == "__main__":
unittest.main()