feat(turns): hard-cap durable turn wall clock at 1 hour (closes #923) - #926
feat(turns): hard-cap durable turn wall clock at 1 hour (closes #923)#926btipling wants to merge 7 commits into
Conversation
Enforce a 1-hour wall-clock cap inside the Workflow VM — the only trustworthy clock the runtime provides — so a durable /api/turns run can no longer eat unbounded cloud compute (the 4h retry evidence case). - turnWorkflow derives a deterministic deadlineAt ONCE from the SDK-pinned, replay-stable getWorkflowMetadata().workflowStartedAt + TURN_WALL_CLOCK_MAX_MS (3_600_000, Bjorn-authorized product lock). No signal/closure/Date ever crosses a step boundary. - runTurnLoop threads deadlineAt; wall-clock boundary checks catch the gap between an abort and the step returning; the model/tool step sentinel routes to a dedicated wall wrap-up terminal. - modelGenerateStep rebuilds AbortSignal.timeout(remaining) per attempt from the serialized deadlineAt — a 72-min retried model round aborts at the 60-min line on its final attempts. The terminal wrap-up round is deadline-exempt and gets TURN_WALL_CLOCK_WRAPUP_SYSTEM (distinct from the step-budget copy). - toolExecuteStep gates whole-batch dispatch on the deadline. - Clean terminal: tools-off wrap-up sees the wall error, terminal persist writes completed (C15 releases), ONE SSE error 'turn wall clock exceeded', writable closed once. reason: 'steps' | 'wall' on TurnLoopResult for operator diagnostics. - G22 cancel lock intact: a genuine user Stop stays 'cancelled'; only the deadline abort routes to the wall wrap-up. MAX_WORKFLOW_STEPS (512) and all existing caps unchanged — no human gate. - Caps table is additive in lib/sessionCloudCaps.ts; probe/TTL knobs are documented cache-only seams, never enforcement. - Tests: new wall-clock loop matrix (rows 1-8, 15), generateOneRound wall-abort classification, caps table lock + real-step deadline-exempt wrap-up regression. Living docs + AGENTS.md + .env.example updated. Closes #923
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #926
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main ← plan/turn-wall-clock · 15 files · durable-turn 1-hour wall-clock cap (plan #923 / #925)
Lenses run: L1, L2, L5, L6, L8 (skip: L3 no UI/bridge/Wasm; L4 no GHA/runner; L7 no tenant/host bind; L9 no palette)
AGENTS.md read: yes (plus docs/feature-divide.md, docs/agent-stream.md, plan #925)
Note: requested URL was /pull/927 (404). Reviewed the only open PR, #926.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L5 | Deadline-exempt wrap-up forwards the operator's reasoning token and has no substitute bound. modelGenerateStep skip-deadline on wrapUp === 'wall' and still spreads args.reasoning into generateOneRound. The 4h evidence class is a retried high-effort model round (~18 min × 5). After the 1h cap fires, wrap-up is another 'use step' modelGenerateStep with default Workflows retries and no AbortSignal. |
Operator on zai/glm-5.3-flash + xhigh. Turn hits deadlineAt. Loop calls wrap-up. Wrap-up CoT / "Unknown error" retries for another ~18–72 min. Product lock "kill the turn at 1h" is false: wall is 1h plus an unbounded model step. |
Defender: wrap-up is tools-off and copy says "briefly"; in-budget rounds are signal-capped. Fails: the evidence case was a tools-off-length model round (no tools needed to burn an hour), and the wrap-up path is the one place maxRetries + xhigh still apply with no deadline. Test at turnLoop.test.ts 2541 only asserts no signal, i.e. locks the hole. |
high |
| Major | L1 | toolExecuteStep does not inject a deadline AbortSignal and does not re-check between waves. Plan #925 Goal 2 / decision table: "threads AbortSignal.timeout(remaining) to generateOneRound and the tool-batch executor." Implementation only bails if the deadline is already elapsed at step entry (toolExecuteStep.ts ~232–240). assembleDurableToolWorld is called with no signal; executeTool gets the never-aborting default controller. exec is parallel but write_file/str_replace are serial separators, so [write_file, exec, write_file, exec] is two 30-min waves (MAX_EXEC_TIMEOUT_MS). |
Model emits write_file + exec cargo build + write_file + exec cargo test in one round at t≈50 min. Both execs use default/max timeout. Tool step started before the deadline so the entry gate passes; no between-wave check; no abort. Batch returns ok:true ~60 min later (t≈110 min). Cap missed by nearly an hour. executeTool has no wall_clock code — even a future signal would currently classify as G22 cancelled. No toolExecuteStep unit test covers deadlineAt. |
Defender: "≤ 1 wave past the deadline", exec ceiling 30 min, evidence was model retries. Fails: the locked plan required an injected tool signal; serial separators make "one wave" ≠ "one exec"; living docs (AGENTS.md, docs/agent-stream.md) claim every 'use step' rebuilds AbortSignal.timeout. Loop tests mock toolStep and never hit this shell. |
high |
| Major | L1 | wallStopped terminal-persists completed before wrap-up. turnLoop.ts ~912–923: persistOnce(true) then wallWrapUp which persistOnce(true) again. persistOverlayStatus(undefined) is completed. C15 409 is live-only — completed releases the next POST /api/turns. Wrap-up is a full model round (and, given finding 1, may run for a long time). |
Tab A hits the wall mid-batch. First persist overlays completed. Tab B (or queue drain keyed on envelope, not Busy) POSTs a new turn. Two workflows mutate the same session while wrap-up is still writing SSE. Cancel path also persist-then-fail, but fail is milliseconds; wrap-up is not. |
Defender: host Busy blocks Send; wrap-up persist is the real snapshot; plan wants terminal completed. Fails: C15 is server-side envelope status, not the SSE attach. Same-tab Busy does not stop another client / F5. The wrap-up persist already includes the tool rows — the extra completed overlay is unnecessary and opens the lock. |
high |
Residual risk
In-budget modelGenerateStep abort via AbortSignal.timeout(remaining) rebuilt per attempt is the right fix for the 4h retried-model evidence, and G22 mapping (cancelled vs wall_clock) is tested in generateOneRound. Date.now() in runTurnLoop (called from 'use workflow') is either patched-deterministic or a dead boundary check — the step VM is still the authority, so this is not scored. Unused TURN_WALL_CLOCK_*_TTL/PROBE knobs are documented as reserved. Wrap-up / tool-signal / C15 holes above are what still let a turn run past 1h or overlap a successor run.
Merge guidance
CONCERNS — do not merge until the three Majors are fixed (no "explicit accept" on a cap that does not cap). Owners: lib/workflows/modelGenerateStep.ts, lib/workflows/toolExecuteStep.ts, lib/workflows/turnLoop.ts.
What was not attacked
Live Vercel Workflows replay of Date.now() in the directive-free loop; production Gateway; DO runner; host Busy/error fold (harnessChat untouched); F5 hydrate (#924).
…926) Wrap-up is 1h-exempt but no longer unbounded: 5-min signal + reasoning none. Tool steps rebuild the deadline AbortSignal, skip later serial waves, and remap deadline cancel to wall_clock. Wall-stopped batches no longer terminal-persist completed before wrap-up (C15 409 stay held).
Adversarial-review follow-up (
|
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #926
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main ← plan/turn-wall-clock @ 7d4cbaf · 17 files · durable-turn 1-hour wall-clock cap (plan #923 / #925), including the first-review follow-up
Lenses run: L1, L5, L6, L8 (skip: L3 no UI/bridge/Wasm; L4 no GHA/runner; L2 no new trust boundary / secrets; L7 no tenant/host bind; L9 no palette)
AGENTS.md read: yes
Note: requested URL was /pull/927 (still 404). Re-reviewed the only open PR, #926, at HEAD 7d4cbaf (the commit that claimed to close the previous three Majors). Those three are closed. This pass is the leftover coupling they introduced.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L6 | roundAbort applies the wall wrap-up bound + reasoning: 'none' to ANY wrapUp, including the 512-step fold. modelGenerateStep.ts roundAbort gates on args.wrapUp !== undefined, not wrapUp === 'wall'. turnLoop.ts step-budget wrap-up calls modelStep({ disableTools: true, wrapUp: 'steps' }). A 5-min abort classifies as 'wall_clock' (TURN_WALL_CLOCK_ERROR). The steps path then fail('failed', wrap.error) — it does not special-case wall_clock/cancelled the way wallWrapUp does. Tests lock only wrapUp: 'wall' (turnLoop.test.ts "wall wrap-up round is 1h-exempt but bounded"); wrapUp: 'steps' has zero modelGenerateStep coverage. |
Operator hits the 512-step cap (not the 1h wall). Wrap-up generateOneRound runs with a 5-min AbortSignal + reasoning: 'none' (operator xhigh stripped). Gateway is slow / wrap-up exceeds 5 min → {ok:false, code:'wall_clock'}. Loop terminal: status:'failed', error:'turn wall clock exceeded', no reason:'steps', SSE error is the wall copy. User sees a wall-clock failure after a step-budget stop. Even without timeout, 512-step wrap-up silently loses operator reasoning. |
Defender: bounding every wrap-up is safer; 5 min is plenty for a tools-off summary; none prevents 4h CoT. Fails: the 4h evidence class was in-budget retried model rounds, already signal-capped; the steps wrap-up was a pre-existing unbounded path this PR was not authorized to re-cap. Caps table names TURN_WALL_CLOCK_WRAPUP_MAX_MS as the wall substitute bound. wallWrapUp treats wrap-up abort as clean capped/wall; the steps path treats the same code as failed. The coupling is live and untested. |
high |
Residual risk
Prior Majors (unbounded wall wrap-up / no tool-step deadline signal / C15 completed before wrap-up) are closed on this HEAD: wrap-up is 1h-exempt with a 5-min bound + reasoning: 'none' when wrapUp === 'wall'; toolExecuteStep rebuilds AbortSignal.timeout(remaining), skips later waves, remaps deadline cancelled → wall_clock; wall-stopped batches no longer terminal-persist before wrap-up. Date.now() inside the directive-free loop (called from 'use workflow') is still either patched-deterministic or a dead boundary check — the 'use step' VM remains the authority, not re-scored. Abort classification re-reads the clock rather than the aborting signal; a rare early AbortSignal.timeout fire would look like G22 cancel (timers typically fire late → safe). In-flight exec still depends on the sandbox client honoring the threaded signal.
Merge guidance
CONCERNS — do not merge until the Major is fixed (no "explicit accept" on a cap that fails the wrong turn). Owner: lib/workflows/modelGenerateStep.ts roundAbort — special-case wrapUp === 'wall' only; wrapUp === 'steps' must keep operator reasoning and must not inherit TURN_WALL_CLOCK_WRAPUP_MAX_MS. Add a modelGenerateStep test that wrapUp: 'steps' does not get the 5-min wall bound / reasoning: 'none' / wall system prompt.
What was not attacked
Live Vercel Workflows replay of Date.now() in the directive-free loop; production Gateway; DO runner; host Busy/error fold (harnessChat untouched); F5 hydrate (#924).
roundAbort gated on wrapUp !== undefined, so the step-budget wrap-up inherited TURN_WALL_CLOCK_WRAPUP_MAX_MS + reasoning none. A 5-min abort classifies as wall_clock and the steps path fail()s the turn as "turn wall clock exceeded". Restrict the substitute bound to wrapUp === 'wall' only; steps wrap-up keeps operator reasoning and no extra signal.
Adversarial-review follow-up (
|
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #926
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main ← plan/turn-wall-clock @ 0781678 · 17 files · durable-turn 1-hour wall-clock cap (plan #923 / #925), including both prior follow-ups
Lenses run: L1, L5, L6, L8 (skip: L3 no UI/bridge/Wasm; L4 no GHA/runner; L2 no new trust boundary / secrets; L7 no tenant/host bind; L9 no palette)
AGENTS.md read: yes (plus docs/feature-divide.md, docs/agent-stream.md, plan #925, Workflows VM Date.now seeding)
Note: requested URL was /pull/927 (still 404). Re-reviewed the only open PR, #926, at HEAD 0781678 (claimed to close the previous Major: roundAbort wall-bound leaking onto the 512-step fold). That Major is closed. This pass is the leftover coupling between the two wrap-up terminals.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L5+L6 | Step-budget while-exit never consults the wall deadline, and if (steps >= cap) break runs before deadlineElapsed(). turnLoop.ts prefers the unbounded 512-step wrap-up (wrapUp: 'steps', operator reasoning, no AbortSignal, SSE done) whenever the step cap trips, even if deadlineAt is already in the past. modelGenerateStep roundAbort for wrapUp === 'steps' is required not to inherit the 5-min wall bound (previous Major) — so this path is the 4h evidence class (open-ended CoT + default Workflows retries) with the 1h product lock already fired. No test combines maxSteps + elapsed deadlineAt on the while-exit / steps >= cap break. |
Durable coding turn burns ~512 steps over ~60 min (~7 s/step — model + batch + persist). Last in-budget model round starts with a few seconds of budget, returns tools successfully (short round, signal does not fire). User-line persist fills the step cap (steps >= cap break) or the last tool wave completes and while (steps < cap) fails. Loop takes wrapUp: 'steps' with xhigh and no signal. Wrap-up CoT / retries run another ~18–72 min. Operator sees SSE done / reason: 'steps', not error turn wall clock exceeded / reason: 'wall'. |
Defender: in-step AbortSignal.timeout(remaining) would have aborted that last model/tool step, so while-exit never runs past 1h. Fails: those steps can complete successfully in the last seconds of the budget (fast tools / fast model); the wrap-up is a new model round after deadlineAt with no signal. Defender: 512 steps in 1h is rare. Fails: 512 is the existing long-turn bound; ~7 s/step is a normal tool-using coding turn. The previous follow-up locked steps wrap-up as unbounded — correct before 1h, wrong after. |
high |
Residual risk
Prior Majors on this HEAD stay closed: wall wrap-up is 1h-exempt with TURN_WALL_CLOCK_WRAPUP_MAX_MS + reasoning: 'none' only when wrapUp === 'wall'; toolExecuteStep rebuilds AbortSignal.timeout(remaining), skips later waves, remaps deadline cancelled → wall_clock; wall-stopped batches no longer terminal-persist completed before wrap-up. Workflows VM Date.now() is seeded at workflowStartedAt and advanced via updateTimestamp(+event.createdAt) on consumed step events — the loop boundary check is replay-stable, not a live clock, and is not re-scored. In-flight exec still depends on the sandbox client honoring the threaded signal (documented best-effort in-wave). Abort classification re-reads the clock rather than the aborting signal; timers typically fire late → in-budget Stop stays G22. A no-tool model round that finishes a few hundred ms after deadlineAt still completed rather than capped — bounded overrun, not the 4h class.
Merge guidance
CONCERNS — do not merge until the Major is fixed (no "explicit accept" on a cap that does not cap). Owner: lib/workflows/turnLoop.ts — if deadlineElapsed() at the steps >= cap break and at while-exit, return wallWrapUp(...) (prefer wall when both caps fire). Add a turnLoop test: maxSteps trips after an in-budget model round that ran past deadlineAt → reason: 'wall', SSE error turn wall clock exceeded, wrap-up sees TURN_WALL_CLOCK_WRAPUP not STEP_BUDGET_WRAPUP.
What was not attacked
Live Vercel Workflows replay of a 512-step/1h coincidence; production Gateway; DO runner; host Busy/error fold (harnessChat untouched); F5 hydrate (#924).
When the 512-step while-exit / steps>=cap break ran after deadlineAt, wrapUp:'steps' (unbounded, operator reasoning) reintroduced the 4h evidence class. Check deadlineElapsed before the cap break and at while-exit; wall wins. Tests lock both paths. Living docs updated. Refs #923
Adversarial-review follow-up (
|
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #926
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main ← plan/turn-wall-clock @ 400663a · 17 files · durable-turn 1-hour wall-clock cap (plan #923 / #925), including three prior follow-ups
Lenses run: L1, L5, L6, L8 (skip: L3 no UI/bridge/Wasm; L4 no GHA/runner; L2 no new trust boundary / secrets; L7 no tenant/host bind; L9 no palette)
AGENTS.md read: yes (plus docs/feature-divide.md, docs/agent-stream.md, SECURITY.md workflows surface, plan #925, Workflows VM Date.now seeding)
Note: requested URL was /pull/927 (still 404). Re-reviewed the only open PR, #926, at HEAD 400663a (claimed to close the previous Major: step-cap while-exit / steps >= cap break ignored the wall deadline). That Major is closed. This pass is the leftover hole the "unbounded steps wrap-up" lock created.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L5+L6 | roundAbort for wrapUp === 'steps' returns no AbortSignal / no wallClockDeadlineAt, and the in-step early-return exempts any wrapUp (not just 'wall'). modelGenerateStep.ts roundAbort special-cases 'steps' to { reasoning } only. isDeadlineElapsed && args.wrapUp === undefined lets a steps wrap-up start even when deadlineAt is already past. turnLoop.ts while-exit now prefers wall if the deadline has already elapsed (rows 16–17) — but if remaining > 0 at that snapshot, the loop takes wrapUp: 'steps'. The steps path if (!wrap.ok) return fail('failed', …) does not special-case 'wall_clock'. The modelGenerateStep test locks this hole: wrapUp:'steps' + elapsed 1h → signal/wallClockDeadlineAt undefined, generate still runs. |
Durable coding turn hits 512 steps at T=59m59s (~7 s/step). deadlineElapsed() at while-exit is false. Loop starts unbounded steps wrap-up (xhigh, no signal, default Workflows retries). Wrap-up CoT / retries run another ~18–72 min past deadlineAt. Operator sees SSE done / reason:'steps', not error turn wall clock exceeded / reason:'wall'. Same class if remaining is 1 ms at the loop check and the step VM clock is already past deadlineAt by the time generateOneRound is called. |
Defender: the previous Major required steps wrap-up not to inherit the 5-min wall bound, because a 5-min abort classifies as 'wall_clock' and the steps path fail()s the turn as turn wall clock exceeded. Fails: that forbade the 5-min substitute + reasoning:'none', not the 1h deadlineAt signal. In-budget rounds already use deadlineSignal(args.deadlineAt). Steps wrap-up is a new model round that can cross deadlineAt; without that signal it is the 4h evidence class (open-ended CoT + default retries) with the 1h product lock already armed. Defender: 512 steps in 59m59s is rare. Fails: 512 is the existing long-turn bound; ~7 s/step is a normal tool-using coding turn; the coincidence is exactly the turn this PR exists to cap. Defender: while-exit already prefers wall. Fails: that check is a snapshot before wrap-up; remaining > 0 takes the unbounded path, and the step itself is documented as the authority. |
high |
Residual risk
Prior Majors on this HEAD stay closed: wall wrap-up is 1h-exempt with TURN_WALL_CLOCK_WRAPUP_MAX_MS + reasoning: 'none' only when wrapUp === 'wall'; toolExecuteStep rebuilds AbortSignal.timeout(remaining), skips later waves, remaps deadline cancelled → wall_clock; wall-stopped batches no longer terminal-persist completed before wrap-up; while-exit / steps >= cap prefer wall when deadlineAt is already past. Workflows VM Date.now() in the directive-free loop is seeded at workflowStartedAt and advanced via updateTimestamp(+event.createdAt) on consumed step events — the loop boundary check is replay-stable, not a live clock, and is not re-scored. In-flight exec still depends on the sandbox client honoring the closed-over signal (documented best-effort in-wave; executeTool checks deps.signal before/after tool.execute(input.args) but does not pass the signal as an execute option). Abort classification re-reads the clock rather than the aborting signal; timers typically fire late → in-budget Stop stays G22. A no-tool model round that finishes a few hundred ms after deadlineAt still completed rather than capped — bounded overrun, not the 4h class. User Stop during wall wrap-up is mapped to capped/wall (1h already fired).
Merge guidance
CONCERNS — do not merge until the Major is fixed (no "explicit accept" on a cap that does not cap). Owners: lib/workflows/modelGenerateStep.ts roundAbort + in-step early-return; lib/workflows/turnLoop.ts steps wrap-up !wrap.ok path.
wrapUp === 'steps'must keep operator reasoning and must not inheritTURN_WALL_CLOCK_WRAPUP_MAX_MS/reasoning: 'none'.- It must still get
deadlineSignal(args.deadlineAt)so a wrap-up that starts with remaining > 0 aborts at the 1h line. - In-step early-return: elapsed 1h fails closed unless
wrapUp === 'wall'. - Steps wrap-up
'wall_clock'must become the wall terminal (capped/reason:'wall'/ SSEerrorturn wall clock exceeded), notfail('failed')and not a second wrap-up round. - Tests:
modelGenerateStepwrapUp:'steps'+ future deadline has the 1h signal (not the 5-min bound) and keepsxhigh;wrapUp:'steps'+ elapsed deadline returns'wall_clock'.turnLoop: step cap trips with remaining > 0, wrap-up then returns'wall_clock'→reason:'wall', notfailed/ not'steps'.
What was not attacked
Live Vercel Workflows replay of a 512-step wrap-up that starts 1 s before deadlineAt; production Gateway abort honoring; DO runner; host Busy/error fold (harnessChat untouched); F5 hydrate (#924).
wrapUp:'steps' must not inherit the 5-min wall bound / reasoning none, but it still carries deadlineSignal(deadlineAt). An elapsed 1h fails closed unless wrapUp==='wall'. A steps wrap-up wall_clock is the wall terminal (capped/reason wall), not fail(failed). Closes the 4h evidence class when the step cap trips with remaining > 0.
Adversarial-review follow-up (
|
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #926
Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: main ← plan/turn-wall-clock @ 04174b6 · 17 files · durable-turn 1-hour wall-clock cap (plan #923 / #925), including four prior follow-ups
Lenses run: L1, L2, L5, L6, L8 (skip: L3 no UI/bridge/Wasm; L4 no GHA/runner; L7 no tenant/host bind; L9 no palette)
AGENTS.md read: yes (plus docs/feature-divide.md, docs/agent-stream.md, SECURITY.md workflows/secrets surface, plan #925)
Note: requested URL was /pull/926. Re-reviewed HEAD 04174b6 (claimed to close the previous Major: 512-step wrap-up had no 1h deadlineAt signal). That Major is closed. This pass is leftover coupling, not a new 4h-class hole.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Minor | L1 | modelGenerateStep does not thread deadlineSignal into assembleDurableToolWorld. toolExecuteStep does (signal: wallSignal). The model path assembles schemas with the helper's never-aborting default (args.signal ?? new AbortController().signal) before deadlineSignal(args.deadlineAt) is built for generateOneRound. |
Durable turn at T≈59m50s starts an in-budget model round. deadlineElapsed at step entry is false. Assemble then Promise.allSettleds up to 32 MCP connects (MCP_CONNECT_TIMEOUT_MS = 60s, parallel) and/or a sandbox resolve (DEFAULT_TIMEOUT_MS = 45s) with no deadline signal. Generate only sees an already-aborted signal afterwards. Cap is 1h plus one assemble budget (~45–60s), every model round. |
Defender: MCP/sandbox already have their own timeouts; the 4h evidence was retried streamText, not handshake; wrap-up is disableTools and skips assemble. Fails: this PR's own contract is that every 'use step' rebuilds AbortSignal.timeout(remaining) and threads it — the tool step does, the model step's assemble does not. Parallel MCP is still a 60s post-deadline hang per round. |
high |
| Minor | L1 | Wall wrap-up bound is Date.now() + TURN_WALL_CLOCK_WRAPUP_MAX_MS per attempt, and wrapUp === 'wall' skips the elapsed-deadline early-return. roundAbort minting a fresh 5-min window means a runtime-killed wrap-up retry (default 'use step' retries; modelGenerateStep does not set maxRetries = 0) gets another 5 min. deadlineAt + WRAPUP_MAX would be a single 1h05 window. |
Wrap-up abort is treated as a step failure by the Workflows runtime (JS already returns 'wall_clock', but an isolate kill / uncaught write abort still retries). Attempt 2: wrapUp === 'wall' bypasses the 1h early-return; Date.now() + 5min starts a new bound. Four attempts → up to ~20 min of wrap-up after the product lock. |
Defender: generateOneRound returns values, so JS-level retries should not fire; 20 min is not the 4h class. Fails: the wrap-up bound was added specifically so wrap-up is not unbounded-retry-shaped; a per-attempt Date.now() clock reopens that shape at 5-min granularity. Pinning to deadlineAt + WRAPUP_MAX + fail-closed when that epoch is past is the same pattern the 1h cap already uses. |
medium |
| Minor | L1 | generateOneRound abort classification is name-only (AbortError / ResponseAborted). executeTool also treats deps.signal?.aborted as cancel. A deadline abort that surfaces as "Unknown error" (the #923 evidence display string) becomes model_error → loop fail('failed'), no wall wrap-up, SSE is not turn wall clock exceeded. |
Gateway/SDK wraps the abort. fullStream throws Error('Unknown error') with signal.aborted === true and wallClockDeadlineAt past. Result is {ok:false, code:'model_error'}. Loop terminal-persists completed and fail('failed', …). Operator sees failed / Unknown error, not capped / reason:'wall'. Turn still ends (not 4h). |
Defender: AI SDK streamText({abortSignal}) throws AbortError; tests lock that name; "Unknown error" in the Workflows UI was a failed-step display, not err.name. Fails: this PR's tool path already refused to trust the name; the model path should match. Goal 1's terminal copy is then a lie on the exact evidence error string. |
medium |
Residual risk
Prior Majors on this HEAD stay closed: wall wrap-up is 1h-exempt with TURN_WALL_CLOCK_WRAPUP_MAX_MS + reasoning: 'none' only when wrapUp === 'wall'; wrapUp === 'steps' carries deadlineSignal(deadlineAt) and an elapsed 1h fails closed; a steps wrap-up 'wall_clock' is the wall terminal; toolExecuteStep rebuilds the deadline signal, skips later waves, remaps deadline cancelled → wall_clock; wall-stopped batches no longer terminal-persist completed before wrap-up; while-exit / steps >= cap prefer wall when deadlineAt is already past. Workflows VM Date.now() in the directive-free loop is seeded at workflowStartedAt and advanced on consumed step events — belt-and-suspenders only; the step VM + AbortSignal.timeout is the authority. In-flight exec still depends on the sandbox client honoring the closed-over signal (documented best-effort in-wave; client.exec(..., { signal }) is wired). A no-tool model round that finishes a few hundred ms after deadlineAt still completed rather than capped — bounded overrun, not the 4h class.
Merge guidance
PASS WITH NOTES — safe to merge from this attack; the three Minors should be fixed in this PR (they are local to modelGenerateStep / generateOneRound / wrap-up deadline arithmetic) but they do not restore the 4h evidence class.
Suggested fixes:
modelGenerateStep: buildroundAbortonce after the fail-closed gate; passabort.signalintoassembleDurableToolWorldon the tools-on path (same astoolExecuteStep).- Wall wrap-up:
wrapDeadline = deadlineAt + TURN_WALL_CLOCK_WRAPUP_MAX_MS(fallbacknow + WRAPUP_MAXwhen no deadline); fail closed when that epoch is past even forwrapUp === 'wall'. generateOneRound: treatdeps.signal?.abortedlikeexecuteTooldoes; elapsedwallClockDeadlineAtstill wins over user-Stop.
What was not attacked
Live Vercel Workflows replay of AbortSignal.timeout inside a 'use step' isolate; production Gateway abort error shape; DO runner; host Busy/error fold (harnessChat untouched); F5 hydrate (#924); whether default 'use step' retries fire on a returned {ok:false, code:'wall_clock'}.
Adversarial-review #926 PASS WITH NOTES: - modelGenerateStep threads deadlineSignal into assembleDurableToolWorld (parity with toolExecuteStep) so MCP/sandbox handshake cannot run the full 45–60s timeout past deadlineAt. - Wall wrap-up bound is deadlineAt + TURN_WALL_CLOCK_WRAPUP_MAX_MS (a single 1h05 window), not Date.now()+5min per attempt. An elapsed wrap bound fails closed even for wrapUp === 'wall'. - generateOneRound treats signal.aborted like executeTool, so a Gateway "Unknown error" wrapper on an aborted stream maps to wall_clock. Refs #923
Adversarial-review follow-up (
|
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #926
Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: main ← plan/turn-wall-clock @ 64f5b1a · 17 files · durable-turn 1-hour wall-clock cap (plan #923 / #925), including five prior follow-ups
Lenses run: L1, L2, L5, L6, L8 (skip: L3 no UI/bridge/Wasm; L4 no GHA/runner; L7 no tenant/host bind; L9 no palette)
AGENTS.md read: yes (plus docs/feature-divide.md, docs/agent-stream.md, SECURITY.md workflows/secrets surface, plan #925)
Note: requested URL was /pull/926. Re-reviewed HEAD 64f5b1a (claimed to close the previous three Minors: assemble signal, wrap-up bound pin, signal.aborted classification). Those three are closed. This pass is leftover coupling on the abort terminal + the production adapter wiring, not a new 4h-class hole.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Minor | L1 | Abort classification is still incomplete outside the fullStream catch. generateOneRound maps AbortError / signal.aborted → 'wall_clock' only around fullStream. onEvent write failures become 'write_error' (failClosed) with no abort check; result.text settlement rejects become 'model_error' with no abort check. modelGenerateStep then remaps only cancelled → 'wall_clock' when abort.wallClockDeadlineAt is elapsed — a 'model_error' / 'write_error' after deadlineAt is fail('failed'), no wrap-up, SSE is not turn wall clock exceeded. |
Durable turn at T≥1h. Gateway/SDK ends fullStream without throw, then result.text rejects Error('Unknown error') with signal.aborted === true (the #923 evidence string). Or a live SSE writer.write AbortError races the deadline abort (withDefaultStreamWriter rethrows abort; onEvent catch returns write_error). Loop terminal-persists completed and fail('failed', …). Operator sees failed / Unknown error, not capped / reason:'wall'. Turn still ends (not 4h). |
Defender: AI SDK abort rejects fullStream first so settlement is unreachable; writer AbortError is the Workflows sink, not the deadline signal; pass-5 already locked signal.aborted on the stream catch. Fails: this PR's own contract is that an elapsed deadline is the wall terminal even when the SDK wraps the abort. The tool path and the stream catch already refused to trust the name; the write/settlement paths and the step remap should match. Goal 1's terminal copy is then a lie on the evidence error string. |
medium |
| Minor | L6 | turnWorkflow adapter wiring for deadlineAt / wrapUp is untested. turnLoop.test.ts injects deadlineAt into runTurnLoop deps; modelGenerateStep tests call the step directly. The only production path that threads the serialized deadline into 'use step' is turnWorkflow.ts's closures. turnWorkflow.test.ts source-locks disableTools forwarding but not deadlineAt or wrapUp. |
A future destructure drops deadlineAt from the modelStep/toolStep adapter (the exact persist-fold bug class this file already exists to catch). Loop unit tests stay green. Mid-step AbortSignal.timeout never builds. The 4h evidence class (18 min × 5 retried modelGenerateStep) returns: loop boundary is belt-and-suspenders and cannot abort an in-flight round. |
Defender: the source currently forwards deadlineAt and wrapUp; this is coverage, not a live hole. Fails: that is what the persist-fold adapter test is for — silent drop of a serializable field that every other test injects around. Plan #925 row 10 (deadlineAt derivation) is also unenforced (inlined workflowStartedAt.getTime() + TURN_WALL_CLOCK_MAX_MS, no helper, no source lock). |
high |
Residual risk
Prior Majors on this HEAD stay closed: wall wrap-up is 1h-exempt with TURN_WALL_CLOCK_WRAPUP_MAX_MS + reasoning: 'none' only when wrapUp === 'wall'; wrap bound is deadlineAt + WRAPUP_MAX (not Date.now() + WRAPUP_MAX per attempt) and fail-closes when that epoch is past; wrapUp === 'steps' carries deadlineSignal(deadlineAt) and an elapsed 1h fails closed; a steps wrap-up 'wall_clock' is the wall terminal; toolExecuteStep rebuilds the deadline signal, skips later waves, remaps deadline cancelled → wall_clock; wall-stopped batches no longer terminal-persist completed before wrap-up; while-exit / steps >= cap prefer wall when deadlineAt is already past; tools-on modelGenerateStep threads abort.signal into assembleDurableToolWorld. Workflows VM Date.now() in the directive-free loop is seeded at workflowStartedAt and advanced on consumed step events — belt-and-suspenders only; the step VM + AbortSignal.timeout is the authority. In-flight exec still depends on the sandbox client honoring the closed-over signal (documented best-effort in-wave; cap MAX_EXEC_TIMEOUT_MS = 30 min). A no-tool model round that finishes a few hundred ms after deadlineAt still completed rather than capped — bounded overrun, not the 4h class. modelGenerateStep still uses default 'use step' retries (not maxRetries = 0); that is safe if abort returns a value, and the elapsed entry-gate makes a post-1h retry instant.
Merge guidance
PASS WITH NOTES — safe to merge from this attack; the two Minors should be fixed in this PR (they are local to generateOneRound / modelGenerateStep remap + a turnWorkflow.test.ts source lock) but they do not restore the 4h evidence class.
Suggested fixes:
generateOneRound: one abort classifier used by thefullStreamcatch, theonEventwrite catch, and theresult.textsettlement catch;signal.abortedafter a "successful" stream still wins whenwallClockDeadlineAtis elapsed.modelGenerateStep: any!okafterisDeadlineElapsed(abort.wallClockDeadlineAt)is'wall_clock'(not onlycancelled).turnWorkflow.test.ts: source-lockdeadlineAton both step adapters,wrapUpforwarding, andworkflowStartedAt.getTime() + TURN_WALL_CLOCK_MAX_MS.
What was not attacked
Live Vercel Workflows replay of AbortSignal.timeout inside a 'use step' isolate; production Gateway abort error shape; DO runner; host Busy/error fold (harnessChat untouched); F5 hydrate (#924); whether default 'use step' retries fire on a returned {ok:false, code:'wall_clock'}.
…ine wiring Adversarial-review #926 sixth pass (PASS WITH NOTES): - generateOneRound uses one abort classifier for fullStream, onEvent writes, stream() throw, result.text settlement, and a silent abort after a "successful" stream. Elapsed wallClockDeadlineAt still wins over user-Stop. A finished round with a pre-deadline abort keeps the answer (G22). - modelGenerateStep remaps any !ok after the deadline/wrap bound to wall_clock, not only cancelled — a leaked model_error/write_error after deadlineAt is the wall terminal. - turnWorkflow.test.ts source-locks deadlineAt on both step adapters, wrapUp forwarding, and workflowStartedAt.getTime() + TURN_WALL_CLOCK_MAX_MS.
Adversarial-review follow-up (
|
Plan #923 — hard 1-hour wall-clock cap for durable turns
Closes #923. Branched off
origin/main@9960b90.Problem
A durable
/api/turnsrun has no wall-clock bound — the 4h evidence case showed a single retried model round (~18 min × 5) only terminates at the 60/72-min platform line, eating unbounded cloud compute.Fix
Enforce the cap inside the Workflow VM — the only trustworthy clock the runtime provides:
'use workflow'entry from the SDK-pinned, replay-stablegetWorkflowMetadata().workflowStartedAt+TURN_WALL_CLOCK_MAX_MS = 3_600_000(Bjorn-authorized). Never a signal/closure/Date across a step boundary.AbortSignal.timeout(remaining)from the serializeddeadlineAtand aborts — a retried 72-min model round dies at the 60-min line.Error: turn wall clock exceeded, terminal persist writescompleted(C15 releases next prompt), one SSEerror turn wall clock exceeded, writable closed once, harness leaves Busy.'cancelled'; only the deadline abort routes to the wall wrap-up.MAX_WORKFLOW_STEPS(512) and all existing caps unchanged → no human gate.Caps (all NEW in
lib/sessionCloudCaps.ts)TURN_WALL_CLOCK_MAX_MSTURN_WALL_CLOCK_DEADLINE_TTL_MSTURN_WALL_CLOCK_PROBE_EVERY_MSChanges (15 files, +1072/−25)
lib/workflows/turnWorkflow.ts— derivedeadlineAtfromworkflowStartedAt; forward taglib/workflows/turnLoop.ts— boundary checks, wall sentinel routing,wallWrapUpterminal,reason: 'steps' \| 'wall', additiveinvincible.turn.looploglib/workflows/modelGenerateStep.ts— per-attempt deadline signal,'wall_clock'sentinel, deadline-exempt wrap-up round +TURN_WALL_CLOCK_WRAPUP_SYSTEMlib/workflows/toolExecuteStep.ts— whole-batch deadlin…