Repository navigation
fix(engine): replay downtime-queued pending messages and retry infra failures - #8
Conversation
…failures ework#5: a message queued while the daemon was down was expired by the stale-pending guard (age measured from created_at) and never ran again. - age pending messages from pending_since instead of created_at; recover() shifts pending_since to now for this daemon's owned issues so time spent down does not age the queue (multi-machine safe: other daemons' queues keep their original clock) - new infra-failure class (web unreachable, ENOSPC, child killed by signal): dedicated infra_attempts budget (WORK_INFRA_RETRY_MAX, default 3) with exponential backoff (WORK_INFRA_RETRY_BASE_MS * 2^attempt); content-failure attempts stay untouched; terminal failure only after the budget is exhausted; retry_after hold enforced atomically in claim paths - migration + backfill for pending_since / infra_attempts / retry_after - tests: restart-replay scenario (stale-pending.test.ts) and the full infra-retry ladder: budget exhaustion, success-after-retry, disabled budget, web-unreachable gate path (infra-retry.test.ts) - test harness hermeticity: per-PID temp DB under os.tmpdir() pinned in tests/setup.ts regardless of inherited WORK_DB_PATH/DAEMON_DB_PATH, drop inherited WORK_WAKE_LOGINS, replace hardcoded /tmp fixtures with tmpdir(), pin a dead gitea url in reopen-heal so the wake-logins fetch fails closed deterministically
…; hermetic web test db Review fixes for the pending-replay / infra-retry change: - op.ts/opencode.ts: requeue infra failures with a single atomic UPDATE that sets status=pending, pending_since and retry_after together. The previous two-statement sequence (updateMessageStatus then setRetryAfter) left a window where the message was pending but its hold was not yet visible (status transitions clear retry_after), so a racing daemon or the global drain could claim it before the backoff elapsed. - db.ts: restrict the boot-time pending_since backfill to rows still in pending state; transitions out of pending null the clock on purpose, so historical done/failed rows must not be re-stamped on every boot. - web/tests/setup.ts: pin the per-PID temp DB under os.tmpdir() instead of hardcoded /tmp (read-only on some hosts), mirroring the daemon-side setup.ts fix already in this PR. No behavior change on runners where tmpdir() is /tmp.
Review complete — I found 3 real problems, fixed them directly on the branch (commit What I verified against the PR description
Original CI failure — transient, not caused by this PRThe failed job died in Fixes I committed directly (
|
… web reconciliation - recover(): boot-time scan of all non-terminal messages (running/interrupted/pending), age-blind, requeued per session (restart = clock break) - hasRecoveryDelivery: only bot replies posted strictly after promptTime and free of in-progress wording count as delivery; used by both boot recovery and the stranded-interrupted sweep (fixes the acp-kernel#310 misjudged-done class) - reconcileWebComments: web comment stream is the source of truth; comments consumed during the failure window but never persisted are requeued, deduped by comment id, floored at the newest recorded message, wake policy mirrored (fixes the bc#853 consumed-not-delivered class) - per-boot recovery report (interrupted/requeued/delivered/backfilled/deferred/queued_now) as a [system] admin notice, WORK_RECOVERY_REPORT=0 mutes the prefix - tests: restart-recovery harness simulating hard death via lease expiry; covers zero-touch respawn with delivery, misjudged-done, downtime-pending replay (composed with #5), consumed-not-delivered backfill; unit tests for looksLikeInProgress and hasRecoveryDelivery
…nified daemon: unified restart recovery — boot reconciliation, event replay, strict delivery check
Closes #5
Problem (verified)
The ework#4 message was queued at 00:15Z while the VM was down for disk expansion. After reboot, the stale-pending guard measured age from
created_at(32 min over the 30-min limit), marked the messagefailedwithexpired: stale pending message not replayed, and the user input was lost forever. Separately, infrastructure-class failures (web unreachable / ENOSPC / child killed by signal) were treated as terminal content failures.Changes
1. Pending age on the engine's active clock (
opencode.ts,op.ts,db.ts)messages.pending_since: when the message (re-)entered pending; the stale guard now ages frompending_since ?? created_at.recover()shiftspending_sinceto now for this daemon's owned issues only at boot — downtime does not age the queue; other daemons' queues keep their original clock (multi-machine safe).2. Infra-class failures retry on a dedicated budget (
opencode.ts,config.ts)infra_attemptsbudget (WORK_INFRA_RETRY_MAX, default 3) with exponential backoffWORK_INFRA_RETRY_BASE_MS * 2^(attempt-1)(default base 15s); theretry_afterhold is enforced atomically in claim/pickup paths.attemptsstays untouched by infra retries; terminalfailedonly after the budget is exhausted. A visible notice comment is posted on the first requeue.3. Tests (
stale-pending.test.ts, newinfra-retry.test.ts)infraRetryMax=0disables retries; web-unreachable gate path retries before failing withgate:.Test harness hermeticity (prevents recurrence of the incident that blocked this requeue):
tests/setup.tspins a per-PID temp DB underos.tmpdir()regardless of inheritedWORK_DB_PATH/DAEMON_DB_PATH, drops inheritedWORK_WAKE_LOGINS, replaces hardcoded/tmpfixtures withtmpdir(), and pins a dead gitea url in reopen-heal so its wake-logins fetch fails closed deterministically.Verification
bun run check(tsc --noEmit): cleanbun test(packages/daemon): 241 pass / 0 fail across 30 files, run against the temp DB pinned by setup.ts中文摘要:修复停机期间排队的 pending 消息被 stale-expiry 吞掉的问题(重启时只把本 daemon 名下队列的计时器拨回现在,停机时间不计入老化),并新增基础设施类失败(网关不可达 / ENOSPC / 子进程被信号杀死)的独立重试预算与指数退避(默认 3 次),重试耗尽才终态失败;已补重启重放与完整重试阶梯测试,全套 241 个测试通过。