feat: attempt ledger + resume preamble (v6)

Foundry 'platform restarts with original input' gap, after
reading the existing mechanisms: creation idempotency
(UNIQUE project_id+idempotency_key), goal checkpoints
(task_checkpoints per round, same-thread breakpoint resume
with no-progress fuse), merge/cost/notification CAS, and
coordinator child reuse already exist. The uncovered segment
is non-goal platform-interrupted tasks: auto_resume requeues
them fresh in the same worktree after clearing result/error/
interrupted_reason, so the new run gets only the original
prompt and cannot tell what the previous attempt had done.

This track adds, in worker/server layer only (no host.py or
codex thread change): task_attempts ledger, recorded by
auto_resume_interrupted in the same transaction before
clearing (attempt_no = old auto_resume_count + 1, kind
goal_breakpoint|platform_fresh, interrupted_reason,
checkpoint_round, result/error truncated to 2000 chars,
INSERT OR IGNORE keeps the first record on duplicate);
resume preamble injected in-memory only at claim for
platform_fresh reruns (previous attempt facts, truncated to
1000 chars, plus check-worktree-first and verify-before-
recreating-external-artifacts instructions; skipped for
coordinator/_resume/breakpoint tasks and when no record
exists, never fabricated); attempts surfaced read-only in
GET /tasks/{id}/timeline under existing project-read
permission. Fresh PG schema, migrations table list, and
server/worker ensure paths share one DDL.

Deliberately NOT claimed: the preamble is an instruction,
not a hard guarantee like creation idempotency or merge
CAS; the ledger does not track worktree files or external
artifacts (no worktree snapshot, executor-dependent paths;
hard dedup for those still rests on their existing gates);
manual retry paths do not go through auto_resume, so they
get no ledger record or preamble. Tests:
test_attempts.py (7 tests).
This commit is contained in:
host committed 2026-11-04 18:02:10 +00:00
1 parent c15c224570
commit 19fd326a73
6 files changed
+541 -5

No files matched your search

+22 -4
View File
@@ -61,6 +61,7 @@ from cloud.agent_def import default_agents_dir as _agent_default_dir
from cloud.agent_def import list_known_skills as _agent_known_skills
from cloud.agent_def import parse_agent_definition as _parse_agent_definition
from cloud.agent_def import resolve_registry_agent as _resolve_agent
from cloud.attempts import ensure_attempts_table as _ensure_attempts
from cloud.auth import ROLES as _MEMBER_ROLES
from cloud.auth import AuthStore
from cloud.coordinator import parse_parts as _coord_parse_parts
@@ -1717,6 +1718,7 @@ def init_db(db_path):
_ensure_uploads_tables(conn)
_ensure_task_checkpoints_table(conn)
_ensure_task_steps_table(conn)
_ensure_attempts(conn)
conn.commit()
return
conn.execute(
@@ -1753,6 +1755,7 @@ def init_db(db_path):
_ensure_uploads_tables(conn)
_ensure_task_checkpoints_table(conn)
_ensure_task_steps_table(conn)
_ensure_attempts(conn)
ensure_kill_requests_table(conn)
# 常驻监听表(P0 S3 的 SQLite 侧):启动时建一次,
# 热路径(claim/run_due/批量)不再逐条 ensure,
@@ -5390,10 +5393,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"followups": [_followup_to_dict(r) for r in rows]})
def _task_timeline(self, user_id, task_id):
"""GET /tasks/{id}/timeline(P0 三件其三):一个任务一张
时间线。steps 直读 task_steps(claim/resume/round/final),
checkpoints 直读 task_checkpoints(每轮断点快照);旧
任务(部署前跑完、无步骤记录)返回空数组,不编造。
"""GET /tasks/{id}/timeline(P0 三件其三 + v6 尝试账本):
一个任务一张时间线。steps 直读 task_steps
(claim/resume/round/final),checkpoints 直读
task_checkpoints(每轮断点快照),attempts 直读
task_attempts(自动续跑前记下的正结束尝试);旧任务
(部署前跑完、无步骤记录)返回空数组,不编造。
"""
row = self._fetch_task(task_id)
if row is None:
@@ -5409,6 +5414,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
try:
_ensure_task_checkpoints_table(conn)
_ensure_task_steps_table(conn)
_ensure_attempts(conn)
step_rows = conn.execute(
"SELECT step_no, kind, summary, state_sha, created_at"
" FROM task_steps WHERE task_id = ?"
@@ -5419,6 +5425,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" verify_out, created_at FROM task_checkpoints"
" WHERE task_id = ? ORDER BY round_no",
(task_id,)).fetchall()
attempt_rows = conn.execute(
"SELECT attempt_no, kind, interrupted_reason,"
" checkpoint_round, result_text, error_text, created_at"
" FROM task_attempts WHERE task_id = ?"
" ORDER BY attempt_no",
(task_id,)).fetchall()
finally:
conn.close()
self._send_json(200, {
@@ -5430,6 +5442,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"goal_status": r[2], "state_sha": r[3],
"verify_out": r[4], "created_at": r[5]}
for r in cp_rows],
"attempts": [{"attempt_no": r[0], "kind": r[1],
"interrupted_reason": r[2],
"checkpoint_round": r[3],
"result_text": r[4], "error_text": r[5],
"created_at": r[6]}
for r in attempt_rows],
})
def _task_files(self, user_id, task_id, query):