Skip to content

fix(agent): make turn lifecycles explicit state machines - #2311

Merged
Makisuo merged 4 commits into
mainfrom
fix/agent-turn-lifecycles
Nov 6, 2026
Merged

Makisuo merged 4 commits into
mainfrom
fix/agent-turn-lifecycles

Conversation

@Makisuo

@Makisuo Makisuo commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Seven implicit state machines in the agent runtime, each made explicit.

1. Relay timeout lost the final answer. The driver's final render ran as a sequential step, so the relay's Effect.timeout skipped it and the platform message stayed mid-stream; the relay then forgot the checkpoint, so nothing settled it later. Now the driver renders on interrupt too (editing only messages already posted, with a "still working" notice), and on timeout the relay hands the checkpoint to the scheduled settle (handOffTurn) instead of dropping it. RELAY_TIMEOUT is 27 min, past the session's 25 min TURN_STALE_MS, so they no longer race; the checkpoint TTL moves to 35 min to match.

2/3. ChatSession turn slot. Reads were side-effecting (expiring a turn and appending turn-end from holdsTurn on every streamed event), legacy rows with a null running_since never went stale, and turn-end was written from five places in the DO and four in the runner, some unguarded. Now slot() is a pure read (Idle | Held | Stale, null start time or id counts as Stale) and one transition() (Begin, End, Abort, Expire, Resume) is a compare-and-set and the only writer of a turn's turn-end. The runner ends turns with session.endTurn(messageId, ending); the DO ends a runner that returned without one as failed. The alarm expires a stale claim instead of re-arming forever.

4. turn-runner phases. failure, held and submitted were overwritten per phase, so an earlier max-steps vanished from the final reason. autonomous-outcome.ts records each phase's outcome; the final turn-end and the PR's failure reason are derived from all phases. An abort (not a supersede, which owns its row) or an interrupt now still runs the fail writes; settling runs once, uninterruptible, with bounded writes.

5. ConnectorSocket. A heartbeat stored by an evicted activation fired before the new socket's onOpen, and Discord's stale awaitingAck killed the fresh connection. connect clears it, no heartbeat runs before the socket opens, and a dialled socket that does not open in OPEN_TIMEOUT_MS is given up and backed off. A socket that throws on close is still dropped.

6. Executor prepare. A claim whose process was never spawned (DO evicted in between) read running forever. Like the checkout script, it is lost after a two minute grace and reclaimed; failed is now reset as it is reported, matching checkout.

7. Progress feed. resume after close reopened a feed with no writer fiber. The feed is now Open | Paused | Closed and Closed is terminal.

Tests

  • apps/ai: ChatSession.test.ts, autonomous-outcome.test.ts, progress.test.ts, ChatSessionObject.test.ts, budgets.test.ts: 86 passed. New: stale read expires nothing, legacy null-start row is stale and admits a turn, alarm stops on a legacy claim, one turn-end under end+abort either order, earlier max-steps kept, resume after close is a no-op.
  • apps/chat-bot: src/relay + src/socket: 103 passed. New: relay timeout posts the final render and hands off the checkpoint, handed-off checkpoint settles on its next job, stale heartbeat not fired after eviction, pre-open timeout reconnects, refused close still redials.
  • packages/chat-platform driver.test.ts: 16 passed (new: interrupt edits only posted messages, ending on the last).
  • apps/sandbox executor scripts/session: 35 passed (new: unspawned claim lost after grace, run against bash; failed prepare reset).
  • Typecheck clean for all four packages; oxlint -c .oxlintrc.effect.json clean on touched files.

verify:image not run: the executor change is a status read in the existing script shape, exercised against bash in the unit test.

🤖 Generated with Claude Code


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

Relay final render on timeout with checkpoint hand-off, one ChatSession slot
transition writing each turn-end once, per-phase autonomous outcomes, socket
open deadline and stale heartbeat clearing, executor unspawned-claim grace,
and a terminal Closed progress feed.
…socket close

Review follow-ups: interrupted renders keep their ending on the last posted
message, autonomous rows settle in one uninterruptible step with bounded writes,
and a socket that throws on close is still dropped and redialled.
@coderabbitai

coderabbitai Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 16 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3394bcbe-a967-41b9-8ab6-38f4cb8f4556
Commits

Reviewing files that changed from the base of the PR and between dca0495 and 12c1c94.

Files selected for processing: 24

  • apps/ai/src/chat/ChatSession.test.ts
  • apps/ai/src/chat/ChatSession.ts
  • apps/ai/src/chat/autonomous-outcome.test.ts
  • apps/ai/src/chat/autonomous-outcome.ts
  • apps/ai/src/chat/autonomous-settle.test.ts
  • apps/ai/src/chat/autonomous-settle.ts
  • apps/ai/src/chat/progress.test.ts
  • apps/ai/src/chat/progress.ts
  • apps/ai/src/chat/turn-runner.ts
  • apps/chat-bot/src/relay/ConnectorRelay.test.ts
  • apps/chat-bot/src/relay/ConnectorRelay.ts
  • apps/chat-bot/src/relay/run.ts
  • apps/chat-bot/src/relay/settle.test.ts
  • apps/chat-bot/src/relay/settle.ts
  • apps/chat-bot/src/relay/turn.test.ts
  • apps/chat-bot/src/relay/turn.ts
  • apps/chat-bot/src/socket/ConnectorSocket.test.ts
  • apps/chat-bot/src/socket/ConnectorSocket.ts
  • apps/sandbox/src/executor-scripts.test.ts
  • apps/sandbox/src/executor-scripts.ts
  • apps/sandbox/src/executor-session.test.ts
  • apps/sandbox/src/executor-session.ts
  • packages/chat-platform/src/driver.test.ts
  • packages/chat-platform/src/driver.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@maple-review-bot

This comment has been minimized.

…rt settle path

ConnectorSocket takes its connector registry as an optional constructor
argument, so its test drives a fake heartbeat protocol instead of naming a
vendor. The autonomous turn's abort and settle wiring moves to
autonomous-settle.ts, tested for user abort, supersede, stale expiry and an
abort that lands mid-settle.
@maple-review-bot

maple-review-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Maple review

🟡 Confidence 6/10 · needs attention. Held at 6 because a correctness warning is open.
1 warning −1 · tests covered · risk high −1 · 1/1 new units observable

Reworks the autonomous turn into explicit settle/abort paths with coherent failure status, and makes the socket reconnect on a stalled handshake. The phase/settle logic reads correctly, but the new settle deadlines cannot cut a hung write.

  • runAutonomousTurn runs the phases, then settles once via settleTurn or aborted.
  • phase records each ending; the turn-end and PR failure reason derive from all of them.
  • Runner turn-ends now go through session.endTurn, gated by the turn slot.
  • ConnectorSocket gives up a dial that has not opened within OPEN_TIMEOUT_MS and reconnects.

Findings

🟠 Warning · F1 · Effect.timeout on the settle's submit cannot cut an uninterruptible write

correctness · apps/ai/src/chat/turn-runner.ts:792

submitReview runs inside runAutonomousTurn's uninterruptible settle, so Effect.timeout(SUBMIT_SAVED_TIMEOUT) cannot interrupt it: a hung submit holds the settle — and with it the session's turn slot, since endTurn is below it — for as long as the provider call runs. progress.ts:178 makes the write interruptible before its timeout for exactly this reason.

Thread `restore` from `runAutonomousTurn`'s mask into `settleTurn` and run each write as `restore(write).pipe(Effect.timeout(...))`, keeping only the bookkeeping uninterruptible, as `progress.ts` does.
🤖 Prompt to fix this finding with an AI agent
Findings from an automated review of commit 12c1c94ed98aa9a397c83751a5d9dd9b434d0426. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F1 · Warning · correctness · apps/ai/src/chat/turn-runner.ts:792
`Effect.timeout` on the settle's submit cannot cut an uninterruptible write
`submitReview` runs inside `runAutonomousTurn`'s uninterruptible settle, so `Effect.timeout(SUBMIT_SAVED_TIMEOUT)` cannot interrupt it: a hung submit holds the settle — and with it the session's turn slot, since `endTurn` is below it — for as long as the provider call runs. `progress.ts:178` makes the write interruptible before its timeout for exactly this reason.
Suggested fix: Thread `restore` from `runAutonomousTurn`'s mask into `settleTurn` and run each write as `restore(write).pipe(Effect.timeout(...))`, keeping only the bookkeeping uninterruptible, as `progress.ts` does.

12c1c94 · What was checked, coverage and telemetry details are on the Maple / review check. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread apps/ai/src/chat/turn-runner.ts Resolved
@Makisuo Makisuo added the preview:web Deploy a Workers-only PR preview (no ingest/Electric); see docs/pr-previews.md label Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

🍁 Maple PR preview

Note

Preview resources were removed when the preview label came off.

Final commit 12c1c94 · View workflow run

@Makisuo

Makisuo commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

Preview test: turn lifecycles (12c1c94, preview:web)

Tested on app-pr-2311 / api-pr-2311, signed in as the preview reviewer in the Preview org. AI works there: investigations and follow-ups ran against the real model.

Limitation first. On preview:web the investigation detail page, which is where the in-app chat lives, cannot load. It reads the investigation through Electric live sync, which preview:web does not deploy, so after the 30s watchdog it shows "Couldn't load this investigation" (05). PR #2310's preview:web shows the same thing, so this PR did not cause it. To get around it I drove the chat through the endpoints the chat panel calls (/api/chat/sessions/<org>:inv-<id>/messages|events|abort) from the signed-in page, in headless Chrome. A small overlay on the page prints the live SSE events so they show up in the screenshots and video. The investigations list does load, through the API.

There were also no error issues to start from: /errors shows 17 error events and 0 issues. I started investigations as ad-hoc questions from /investigations instead.

# Check Result
1a Send a message: the turn streams and ends PASS. Turns ran tool calls and text and ended with one turn-end reason=stop each
1b Send while a turn runs PASS. 409 A turn is already running for this conversation. There were never two turns at once
1c Stop mid-stream PASS. POST /abort 204 → one turn-end reason=aborted ~3s later. The next message got 202 right away and completed (ready)
1d Reload mid-turn PASS. I dropped the stream after 5s and reloaded the page. A send probe got 409 (the turn was still held). The tail resumed from the cursor and got exactly one turn-end, and the next message worked
2a Investigation runs to a diagnosis PASS. A went investigating → diagnosed in ~38s. progress.step_count rose live (13 → 25 → 37 on B)
2b Stop an investigation PASS. Abort during B's autonomous pass → failed within 3s, error interrupted: the run was stopped before it submitted a diagnosis; retry. It did not hang in investigating. A follow-up afterwards worked
2c Second start while one runs PASS for the turn slot: only one run. A restart on running B was refused. The status code is wrong, see below
3 Stuck states, console, requests No turn or investigation was left running. Every turn-start on both sessions has exactly one turn-end: A 7/7, B 2/2, no duplicates, no orphans

Evidence:

Problems found (none caused by this PR)

  1. GET /api/chat/sessions/<id>/history returns 503 "The chat session is unavailable" on every investigation session that has events. It returns 200 on an empty session. PR fix(backend): guarded status transitions for error issues and investigations #2310's preview, which does not touch apps/ai, does the same, and history() and the route are unchanged in this diff. So it is pre-existing, but the chat panel's loadHistory hits it on every open. Repro: start any investigation, then GET /api/chat/sessions/<org>%3Ainv-<id>/history. The trace shows no failing child span (for example c5af5f9e78865a9a4fb8f60af6441db0), so it is probably the DO RPC returning history()'s messages.
  2. POST /v2/investigations/:id/restart while a run is in flight answers 503 investigation_start_failed "Retry in a few seconds", with an Error span "This investigation already has a turn in flight" and a "Route answered with a server error" log. It also runs UPDATE investigations before it finds the turn in flight. That should be a 409, with no write before the check (InvestigationService.restartInvestigation, trace 316b558de5f148223b24776c93eb84ea).

Console: apart from the 503s above, only replay/ingest noise (session-replay POSTs aborted on navigation, an rrweb insertRule warning, Canvas2D warnings).

Traces (deployment.environment = pr-2311, via the maple CLI)

  • 9 chat.turn spans, one per turn I ran (2 autonomous + 7 follow-ups), all Ok. The aborted chat turn (299e78007404f9cfda807d38407fe4a7) is 12.2s and ends at the abort, with no Error spans.
  • Investigation A (d324b06b…): chat.turn 38.4s > invoke_agent investigate 37.8s, 4× recordProgress, exactly one InvestigationService.submitDiagnosis + ApplyDiagnosis.applyDiagnosisWrites.
  • Investigation B, stopped (e5c2e39f…): invoke_agent investigate 30.4s, 4× recordProgress, exactly one InvestigationService.failInvestigation (35ms), no submitDiagnosis. One settle per turn.
  • Error spans in pr-2311: only the /history 503s and the restart 503 above.

Test data

  • In the Preview org on pr-2311: investigations A (inv_21LjELPTT9tFCU3Pbf7NtEd, diagnosed) and B (inv_h7b6NAAEyo2vPi5rjKwPGs, failed), with ~9 chat turns tagged [pr-2311 …].
  • On pr-2310's preview: one baseline investigation tagged [baseline for pr-2311 history check].
  • All of it is in preview Neon branches and goes away on teardown.

🤖 Generated with Claude Code

@Makisuo Makisuo removed the preview:web Deploy a Workers-only PR preview (no ingest/Electric); see docs/pr-previews.md label Nov 6, 2026
@Makisuo
Makisuo merged commit b8246c6 into main Nov 6, 2026
64 of 66 checks passed
@Makisuo
Makisuo deleted the fix/agent-turn-lifecycles branch November 6, 2026 19:21

This branch was previously deployed

1 inactive deployment
pr-preview — 12c1c94e Deployed Nov 6, 2026 by Makisuo via deploy-pr-preview #9965
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.

1 participant