Skip to content

feat(server): Prism stream clock for worker liveness, plus prism_cancel - #56

Merged
lukemaj merged 3 commits into
mainfrom
feat/55-stream-clock
Sep 28, 2026
Merged

lukemaj merged 3 commits into
mainfrom
feat/55-stream-clock

Conversation

@lukemaj

@lukemaj lukemaj commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #55

Problem

Model Router could only see worker silence through ~100-char message segments with a fixed 60 s window. A dead turn went unnoticed for minutes, and a slow but healthy model could be killed. Prism also had no cancel, so cancelled T3 jobs stayed cancelling.

Fix

  • Stream clock (apps/server/src/prism/streamClock.ts, new): every adapter already writes each native provider event (every token, reasoning and tool output delta, Claude thinking_tokens, Codex item/*Delta, OpenCode message.part.delta) to the shared native event logger. ProviderService writes each canonical event to the canonical logger. The clock wraps both loggers (one layer swap in server.ts), stamps lastStreamAt per thread, and tracks open tool calls from canonical item.started/item.completed. It is in memory only. File logging behaves as before: deltas are still dropped from the log, and the clock keeps running when logging is off.
  • Liveness (GET /api/prism/liveness?threadId=, orchestration:read): returns now, lastStreamAt, silenceMs, openTool and the running turn. It is a separate route rather than an addition to the snapshot, because the snapshot contract stays untouched.
  • Turn statistics: at turn.completed/turn.aborted, one prism.stream-stats thread activity records time to first token (first content delta), max inter-event gap while no tool was open, native event count, provider, model and outcome (completed/aborted/error, plus the raw state). Web and mobile hide it from the work log (one line each; allowlisted and mapped).
  • prism_cancel: reads the job's planner_t3_thread from Router status and refuses unless it is the calling thread. It then runs cancel and recover with a Prism-issued token (1 h TTL, orchestration scopes) and returns the final job status.

Not included: stall decisions in T3 (Router owns policy). Pending approvals and user-input requests do not count as open tools.

Proof

  • vp test run for src/prism, src/mcp, src/server.test.ts, ProviderService.test.ts and entrypoint.test.ts (server), plus the web session-logic and mobile threadActivity tests: all pass.
  • Fake provider streams in streamClock.test.ts cover: native-only deltas advancing the clock, tool silence left out of the max gap, abort and error outcomes, an unknown thread, and logger tapping with logging off. handlers.test.ts checks the cancel sequence and token use with a fake router, and the refusal for another thread's job.
  • tsc --noEmit passes for contracts, server, web and mobile. vp lint and vp fmt are clean on the changed files.
  • scripts/fork-check.sh fails only on the stack size: main already has 20 commits on upstream, the limit is 20, and squash-merging this PR makes 21. With --max-commits 21 it reports fork-features: OK (17 features) and fork-check: OK. Raising the limit or consolidating the stack is a fork-maintenance decision and is not part of this PR.

Live check (user, after installing this build)

  1. Build and install Chromeria from this branch as in docs/fork.md. Quit Chromeria first. Do not run it on the same T3 home as T3 Code (Alpha).
  2. From the checkout, issue a short-lived token against the installed app's T3 home: TOKEN=$(node apps/server/src/bin.ts auth session issue --ttl 1h --label liveness-check --token-only). Note the backend port (3773 unless another app took it).
  3. Start a long reasoning turn in a thread, for example a hard question on a high-effort model. Copy the thread id from the URL.
  4. Poll: while :; do curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3773/api/prism/liveness?threadId=$THREAD" | jq -c '{now,lastStreamAt,silenceMs,openTool:.openTool.itemType,turn:.turn.eventCount}'; sleep 1; done
    • Expected: lastStreamAt advances every second or so while it reasons, and silenceMs stays small. During a shell command, openTool shows command_execution.
  5. Press Stop in the thread. Expected: lastStreamAt stops advancing, silenceMs keeps growing, and turn becomes null.
  6. Expected in the database: one prism.stream-stats activity per finished turn, not shown in the work log. Check with sqlite3 ~/.t3/userdata/state.sqlite "select payload_json from orchestration_events where event_type='thread.activity-appended' and payload_json like '%prism.stream-stats%' order by sequence desc limit 3" (read-only).
  7. prism_cancel: from a planner thread, submit a small Prism job, then ask the agent to call prism_cancel with its request id. Expected: status cancelled (or cancelling if something is still alive; calling again retries), and the job's child threads stop. Calling it from another thread is refused.

Model and harness: Claude Opus 5.5 (1M context) in Claude Code, run from T3 Code.

🤖 Generated with Claude Code

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL labels Sep 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.5 KiB 13.5 KiB +14 B (+0.1%) 15.1 KiB ✅
Codex Thread snapshot wire 7.1 KiB 7.1 KiB +9 B (+0.1%) 7.3 KiB ✅
Codex Live turn WebSocket wire 6.5 KiB 6.5 KiB +5 B (+0.1%) 7.8 KiB ✅
Codex Live turn WebSocket decoded 56.2 KiB 56.3 KiB +44 B (+0.1%) 66.4 KiB ✅
Codex Live turn messages 9 10 +1 (+11.1%) 21 ✅
Claude Total thread wire 13.5 KiB 13.5 KiB +4 B (+0.0%) 15.1 KiB ✅
Claude Thread snapshot wire 7.1 KiB 7.1 KiB −4 B (−0.1%) 7.3 KiB ✅
Claude Live turn WebSocket wire 6.4 KiB 6.4 KiB +8 B (+0.1%) 7.8 KiB ✅
Claude Live turn WebSocket decoded 57.0 KiB 57.0 KiB 0 B (0.0%) 66.4 KiB ✅
Claude Live turn messages 9 9 0 (0.0%) 21 ✅

Baseline: dcf22be · PR result: 191cafb · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 113.9 KiB
  • Claude decoded thread snapshot: 114.6 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

lukemaj and others added 3 commits September 28, 2026 18:32
Model Router measured worker silence from ~100-char message segments with a
fixed window, so a dead turn went unnoticed for minutes and a slow but
healthy model risked being killed. Prism also had no way to cancel a job.

Every provider event already passes through the shared native and canonical
event loggers, including each token, reasoning and tool output delta and
Claude thinking_tokens. A new in-memory StreamClock wraps those loggers and
stamps each thread's lastStreamAt, tracks open tool calls, and at each turn
end records one hidden prism.stream-stats activity with time to first token,
max gap while no tool was open, event count, provider, model and outcome.
GET /api/prism/liveness?threadId= returns it with the server's now.

prism_cancel checks the job belongs to the calling thread, then runs the
Router CLI cancel and recover with a Prism token and returns the final state.

Closes #55

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…op unused exports (#55)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stream clock for Prism: exact-second worker liveness from every token, plus prism_cancel

1 participant