From 7ef826e660c93383a81cd0087db375644d7a56ee Mon Sep 17 00:00:00 2001 From: Yaroslav Porodko Date: Wed, 2 Sep 2026 11:39:55 +0300 Subject: [PATCH] fix(core): let answers land on a failed-then-resumed run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The answer path decides whether a run is terminal by folding the whole journal, but nothing in that fold clears a historical run.failed when the run is resumed. Once a run has failed once (e.g. a step timeout), every subsequent gate answer is refused with "run is already failed — resume it before answering" forever — including after resuming. A resumed step that parks on a permission gate then sits unanswerable until it hits its own step timeout, which fails the run again: a guaranteed deadlock loop (timeout -> resume -> gate -> unanswerable -> timeout). Fix: in both fold sites (live-engine and suspended-run), a run.status: "executing" event — which resume appends — resets the terminal flag, so the answer guard reflects the run's actual liveness instead of its history. Observed in the wild: two long agent workflows deadlocked exactly this way; with this patch both accepted their gate answers and ran to completion. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DSa5GP2q5WNtNJ1Mt4V2z4 --- packages/core/src/engine.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index 00c3f8c..1d3c289 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -1775,6 +1775,11 @@ export class Engine implements EngineHost { else if (rec.ev.type === "run.completed") terminal = "complete"; else if (rec.ev.type === "run.failed") terminal = "failed"; else if (rec.ev.type === "run.cancelled") terminal = "cancelled"; + // A resume re-opens the run: a run.status back to executing after a + // terminal event means the run is live again and answers must land — + // otherwise a failed-then-resumed run can never have a gate answered + // and any step waiting on one deadlocks into its timeout. + else if (rec.ev.type === "run.status" && rec.ev.status === "executing") terminal = undefined; } // An external process can land a TERMINAL event (a CLI's cancel beside // this daemon) between the pending check and the append: the lost CAS @@ -1824,6 +1829,10 @@ export class Engine implements EngineHost { if (r.ev.type === "run.completed") terminal = "complete"; else if (r.ev.type === "run.failed") terminal = "failed"; else if (r.ev.type === "run.cancelled") terminal = "cancelled"; + // Mirror the live-run fold: a resume (run.status back to executing) + // re-opens the run, so a historical terminal event must not block + // answers forever. + else if (r.ev.type === "run.status" && r.ev.status === "executing") terminal = undefined; } if (terminal !== undefined) { throw new Error(`run ${runId} is already ${terminal} — resume it before answering`);