Skip to content

feat(turns): hard-cap durable turn wall clock at 1 hour (closes #923) - #926

Open
btipling wants to merge 7 commits into
mainfrom
plan/turn-wall-clock
Open

feat(turns): hard-cap durable turn wall clock at 1 hour (closes #923)#926
btipling wants to merge 7 commits into
mainfrom
plan/turn-wall-clock

Conversation

@btipling

@btipling btipling commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Plan #923 — hard 1-hour wall-clock cap for durable turns

Closes #923. Branched off origin/main @ 9960b90.

Problem

A durable /api/turns run 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:

  • Deadline derived ONCE in the 'use workflow' entry from the SDK-pinned, replay-stable getWorkflowMetadata().workflowStartedAt + TURN_WALL_CLOCK_MAX_MS = 3_600_000 (Bjorn-authorized). Never a signal/closure/Date across a step boundary.
  • Authoritative mid-step: each model/tool step rebuilds AbortSignal.timeout(remaining) from the serialized deadlineAt and aborts — a retried 72-min model round dies at the 60-min line.
  • Clean terminal: tools-off wrap-up sees Error: turn wall clock exceeded, terminal persist writes completed (C15 releases next prompt), one SSE error turn wall clock exceeded, writable closed once, harness leaves Busy.
  • 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 (all NEW in lib/sessionCloudCaps.ts)

Cap Value Kind
TURN_WALL_CLOCK_MAX_MS 1h enforcement (product lock)
TURN_WALL_CLOCK_DEADLINE_TTL_MS 60s cache-only seam
TURN_WALL_CLOCK_PROBE_EVERY_MS 2s cache-only seam

Changes (15 files, +1072/−25)

  • lib/workflows/turnWorkflow.ts — derive deadlineAt from workflowStartedAt; forward tag
  • lib/workflows/turnLoop.ts — boundary checks, wall sentinel routing, wallWrapUp terminal, reason: 'steps' \| 'wall', additive invincible.turn.loop log
  • lib/workflows/modelGenerateStep.ts — per-attempt deadline signal, 'wall_clock' sentinel, deadline-exempt wrap-up round + TURN_WALL_CLOCK_WRAPUP_SYSTEM
  • lib/workflows/toolExecuteStep.ts — whole-batch deadlin…

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
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
invincible Ignored Ignored Sep 2, 2026 9:13am UTC

Request Review

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #926

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainplan/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).

btipling commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Adversarial-review follow-up (7d4cbaf)

PR #927 does not exist; review + fixes landed on this PR (#926, plan/turn-wall-clock).

The three Major findings are addressed:

  1. Wrap-up unbounded + xhigh CoT — wrap-up stays 1h-exempt so it can complete after the cap, but now gets TURN_WALL_CLOCK_WRAPUP_MAX_MS (5 min) + reasoning: 'none'. A wrap-up bound abort still ends capped / reason: 'wall', not failed.
  2. Tool step had no deadline signal / no between-wave checktoolExecuteStep rebuilds AbortSignal.timeout(remaining) per attempt, threads it into executeTool / the assembled world, skips later serial waves once the deadline has elapsed, and remaps deadline cancelledwall_clock. In-budget user Stop stays cancelled (G22).
  3. C15 409 released before wrap-up — wall-stopped batches no longer terminal-persist completed before wrap-up. Tool rows stay in messages; wrap-up persistOnce(true) is the first completed overlay.

Shared helpers live in lib/workflows/turnDeadline.ts (directive-free). Caps/docs: TURN_WALL_CLOCK_WRAPUP_MAX_MS in lib/sessionCloudCaps.ts, AGENTS.md, docs/agent-stream.md, .env.example.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #926

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainplan/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 cancelledwall_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.

btipling commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Adversarial-review follow-up (0781678)

PR #927 does not exist; second-pass review + fix landed on this PR (#926, plan/turn-wall-clock).

The remaining Major is addressed:

  1. roundAbort applied the wall wrap-up bound to the 512-step foldroundAbort now special-cases wrapUp === 'wall' only (TURN_WALL_CLOCK_WRAPUP_MAX_MS + reasoning: 'none'). wrapUp === 'steps' keeps operator reasoning and gets no extra abort signal, so a slow step-budget wrap-up cannot fail the turn as turn wall clock exceeded. New modelGenerateStep test locks the split.

Living docs (AGENTS.md, docs/agent-stream.md, .env.example, caps comment) now say the substitute bound is wall wrap-up only.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #926

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainplan/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 cancelledwall_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 deadlineAtreason: '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

btipling commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Adversarial-review follow-up (400663a)

PR #927 does not exist; third-pass review + fix landed on this PR (#926, plan/turn-wall-clock).

The remaining Major is addressed:

  1. Step-cap while-exit / steps >= cap break ignored the wall deadlinedeadlineElapsed() now runs before the step-cap break and at while-exit. When both caps fire, wall wins (reason: 'wall', SSE error turn wall clock exceeded, wall wrap-up copy). The unbounded 512-step wrap-up (wrapUp: 'steps', operator reasoning, no signal) no longer runs after deadlineAt. New turnLoop rows 16–17 lock both paths.

Living docs (AGENTS.md, docs/agent-stream.md) now say wall wins when both caps fire.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #926

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainplan/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 cancelledwall_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 inherit TURN_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' / SSE error turn wall clock exceeded), not fail('failed') and not a second wrap-up round.
  • Tests: modelGenerateStep wrapUp:'steps' + future deadline has the 1h signal (not the 5-min bound) and keeps xhigh; wrapUp:'steps' + elapsed deadline returns 'wall_clock'. turnLoop: step cap trips with remaining > 0, wrap-up then returns 'wall_clock'reason:'wall', not failed / 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.

btipling commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Adversarial-review follow-up (04174b6)

PR #927 does not exist; fourth-pass review + fix landed on this PR (#926, plan/turn-wall-clock).

The remaining Major is addressed:

  1. 512-step wrap-up had no 1h deadline signalwrapUp === 'steps' still does not inherit TURN_WALL_CLOCK_WRAPUP_MAX_MS / reasoning: 'none' (that abort used to fail the turn as turn wall clock exceeded). It does now carry deadlineSignal(deadlineAt). An already-elapsed 1h fails closed unless wrapUp === 'wall'. A steps wrap-up 'wall_clock' is the wall terminal (capped / reason: 'wall' / SSE error turn wall clock exceeded), not failed and not a second wrap-up round. New modelGenerateStep assertions + turnLoop row 18 lock both paths.

Living docs (AGENTS.md, docs/agent-stream.md, .env.example, caps comment) now say the 512-step wrap-up is still subject to the 1h signal.

Verification: npm run typecheck exit 0; vitest run lib/workflows/turnLoop.test.ts 106 passed (row 18 included).

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #926

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainplan/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 cancelledwall_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: build roundAbort once after the fail-closed gate; pass abort.signal into assembleDurableToolWorld on the tools-on path (same as toolExecuteStep).
  • Wall wrap-up: wrapDeadline = deadlineAt + TURN_WALL_CLOCK_WRAPUP_MAX_MS (fallback now + WRAPUP_MAX when no deadline); fail closed when that epoch is past even for wrapUp === 'wall'.
  • generateOneRound: treat deps.signal?.aborted like executeTool does; elapsed wallClockDeadlineAt still 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

btipling commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Adversarial-review follow-up (64f5b1a)

Fifth-pass review at 04174b6 was PASS WITH NOTES. The three Minor findings are addressed on this PR:

  1. modelGenerateStep assemble had no deadline signal — tools-on path now builds roundAbort once and threads abort.signal into assembleDurableToolWorld (same signal generateOneRound sees). MCP/sandbox handshake aborts at the 1h line instead of running the full 45–60s timeout past deadlineAt.
  2. Wall wrap-up bound was Date.now() + WRAPUP_MAX per attempt — now deadlineAt + TURN_WALL_CLOCK_WRAPUP_MAX_MS (a single 1h05 window). An elapsed wrap bound fails closed even for wrapUp === 'wall', so a retried wrap-up cannot mint a fresh 5 min.
  3. generateOneRound abort classification was name-onlydeps.signal?.aborted is now treated like executeTool. A Gateway "Unknown error" wrapper on an aborted stream with elapsed wallClockDeadlineAt maps to 'wall_clock', not model_error.

Verification: vitest run of turnDeadline / turnLoop / generateOneRound / sessionCloudCaps — 185 passed (turnLoop 108, including the two new rows).

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #926

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainplan/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 cancelledwall_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 the fullStream catch, the onEvent write catch, and the result.text settlement catch; signal.aborted after a "successful" stream still wins when wallClockDeadlineAt is elapsed.
  • modelGenerateStep: any !ok after isDeadlineElapsed(abort.wallClockDeadlineAt) is 'wall_clock' (not only cancelled).
  • turnWorkflow.test.ts: source-lock deadlineAt on both step adapters, wrapUp forwarding, and workflowStartedAt.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.

btipling commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Adversarial-review follow-up (15546d5)

Sixth-pass review at 64f5b1a was PASS WITH NOTES. The two Minor findings are addressed on this PR:

  1. Abort classification was incomplete outside the fullStream catchgenerateOneRound now uses one abortResult classifier for fullStream, onEvent writes (incl. provider events), stream() throw, and result.text settlement. A silent abort after a "successful" stream with elapsed wallClockDeadlineAt is 'wall_clock'; 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.

  2. turnWorkflow adapter wiring for deadlineAt / wrapUp was untested — source-lock covers workflowStartedAt.getTime() + TURN_WALL_CLOCK_MAX_MS, deadlineAt on both step adapters, and wrapUp forwarding (the persist-fold silent-drop class).

Verification: npm run typecheck exit 0; vitest run of generateOneRound / turnWorkflow / turnLoop — 150 passed (turnLoop 109, including the new remap row).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

harness: hard-cap durable turn wall clock at 1 hour (Bjorn authorized)

1 participant