Repository navigation
feat(spike): on-call demo extensions on the durable host (P4a) - #431
Conversation
Subagents, an approval hook, a failFast rollback with compensation, a background reminder, forks, and hot-reloadable investigation tools, with approval and document streams on the gateway.
| const existing = await harness.watchDoc(token, id, context); | ||
| if (existing !== undefined) return this.#forward(stream, existing); | ||
| await this.send({ type: "snapshot", stream, value: null }); | ||
| let inner: Subscription | undefined; | ||
| let stopped = false; | ||
| const unsubscribe = harness.subscribeCommits((publication) => { |
There was a problem hiding this comment.
🔴 New documents remain permanently invisible
When a document appears between watchDoc and subscribeCommits, the gateway misses its creation and keeps streaming null. Clients never see that document until they resubscribe.
Learn more
A document stream first tries to watch an existing document. If that returns undefined, the gateway sends null and only then registers for commit publications. A concurrent update_plan commit in that interval creates the document before the listener exists. The stream never installs a watch, so later updates cannot reach the client either.
Example: A client subscribes to doc:demo.plan:1 while the first update_plan runs. The document is absent when watchDoc checks; the plan is committed before subscribeCommits starts; the client stays on null even after the plan is updated again.
Recommended fix: Register the commit listener before checking for an existing document, then recheck and attach exactly one watch. Coordinate the listener and check so a commit during either step cannot be lost or attach duplicate watches.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (tools.includes("update_plan") && !messages.some((message) => message.role === "toolResult" && message.toolName === "update_plan")) { | ||
| return calls(call("update_plan", { items: PLAN.map((text) => ({ text, status: "todo" })) })); |
There was a problem hiding this comment.
🟡 Later investigations skip the demo playbook
After one investigation, oncallScript finds its old update_plan result and skips planning for later requests. A second investigation gets only Noted instead of running the investigation.
Learn more
The scripted model selects the latest user message at userAt, and last already considers only messages after it. The plan guard instead searches all messages, including earlier turns. Once an update_plan result exists, an unrelated new user prompt cannot start another scripted investigation.
Example: The user asks to investigate v2.3, receives a recommendation, then asks to investigate the release again. The second request contains the old update_plan result, so the faux model responds Noted: without delegating.
Recommended fix: Scope the update_plan result check to messages.slice(userAt + 1), as the other per-turn decisions are scoped.
Was this helpful? React with 👍 or 👎 to provide feedback.
| this.#waiters.delete(id); | ||
| for (const waiter of waiters) waiter.resolve(decision); | ||
| if (waiters.length > 0) this.#changed(); | ||
| return { decision, first: decision.by === candidate.by && decision.at === candidate.at }; |
There was a problem hiding this comment.
🟡 Losing approval reports a winning decision
When the same client name votes twice within one millisecond, first can be true for both calls. The later vote is ignored, but its caller sees a success notice.
Learn more
The transaction persists only the first decision, but the returned first flag is inferred from by and at. These are not unique: client names default to client, and two calls can share a millisecond. The second request can therefore report that it won when the persisted decision was different.
Example: Two controls named client approve and deny the same call at timestamp 1000. If approval commits first, the denial receives {decision: approved, first: true} and reports You approved instead of Already approved by client.
Recommended fix: Compute a boolean inside the transaction when the decision is inserted, and return that boolean alongside the stored decision.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (demo !== undefined) { | ||
| // Editors save in bursts; reload once the burst settles. | ||
| let timer: NodeJS.Timeout | undefined; | ||
| const watcher = watch(demo.investigationModule, () => { |
There was a problem hiding this comment.
🟡 Atomic saves stop investigation hot reload
When an editor saves by replacing the file, watch remains attached to the old inode on Linux. Later edits no longer reload the investigation tools.
Learn more
The demo watches the investigation module as an individual file. On Linux, an atomic-save editor writes a replacement and renames it over the watched path; the watch remains on the replaced inode. The first edit can trigger a reload, but subsequent edits to the new file no longer trigger the watcher.
Example: An editor atomically changes FORMAT to table, then atomically changes it back to plain. The first save can reload, but the second save is invisible to this watcher, leaving the host with table output.
Recommended fix: Watch the containing directory and filter events by the module's basename, or re-establish the file watch after a rename. Debounce the filtered events as now.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const decision = await harness.commit(async (tx) => { | ||
| const doc = await tx.doc(ApprovalsDoc, conversationId); | ||
| if (doc.decisions[id] === undefined && !this.#waiters.has(id)) throw new Error(`No pending approval ${id}`); | ||
| doc.decisions[id] ??= { ...candidate }; |
There was a problem hiding this comment.
🟥 Approval decision bypasses conversation binding
A client can send an approval ID with a different conversation ID. ApprovalBoard checks pending IDs globally, then records the decision in the supplied conversation while resolving the original waiter. The decision does not survive replay of the original conversation.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const { approvalId, approved, by } = args as CallMethods["approve"]["args"]; | ||
| if (this.#options.approvals === undefined) throw new Error("This host has no approvals"); | ||
| return this.#options.approvals.decide(conversation.id, approvalId, approved, by); |
There was a problem hiding this comment.
No linked issue: fifth stage (P4a) of the Pi Durable multi-client spike (
.scratch/durable-multiview/HANDOFF.md§14), stacked on #430. P4 is split in two PRs. This one adds the host-side demo extensions and the protocol they need. The three clients' UI for approvals, the plan, forks, thinking blocks, the narrow layout, and usage comes in P4b, along with a full scenario run. Spike-only: nothing underapps/orpackages/changes.Summary
spikes/durable-multiview/host/demo/,node host/main.ts --demoon the real model,--faux-demoon a scripted model). All tools are mocks; noCodingToolsor bash.oncall(main and its forks only): aplaybooksection that fixes the scenario's order, and aplansection that shows thedemo.plandocument to the model. Tools:update_planwrites the plan.subagentfollows upstream example 22 (replay: "safe"). Each child is offered only its area's investigation tool.rollbackandschedule_check.beforeToolholdsrollbackuntil a client decides. It reads the memo first, then thedemo.approvalsdocument, and only then waits on an in-memoryApprovalBoard. A decision is committed withdecisions[id] ??= ..., so the first click wins, and is then memoized. The wait is cancelled with the hook's context (Esc).demo.rollbackcreates three region child tasks and waits on them withfailFast.eu-westalways fails its health check, which aborts the other regions mid-switch. Each aborted region restores its traffic in its own abort handler (bottom-up). Progress goes to thedemo.rolloutdocument, which the tool streams as running output. The tool is replay-safe: it scans for a rollback it already owns before creating one.schedule_checkcreates a backgrounddemo.remindertask. It sleeps until an absolute time kept in its input, then posts a follow-up withrequestId: reminder:<taskId>.Demo.reload()re-imports the module with?v=Nand reinstalls it under the same name. With--demo, the host watches the file and reloads on save.faux-script.ts: a scripted model for tests. Requests from main and the parallel subagents interleave, so each answer depends only on the request itself: the tools it offers and the messages since the newest user input.approvals, anddoc:<kind>:<conversationId>for extension documents (listed inhello.docs). A document that doesn't exist yet streamsnulluntil the commit that creates it.approveandfork.forktakesremoveTools, matched by name against the parent's offered tools.fork N.DurableViewgainsdocsandapprovals.DurableControllergainsapprove(approval, approved)andfork(entryId, prompt, removeTools?).clientNameis recorded as the decider.openHostgainsapprovals,docs,onOpen, andnotify.Test plan
bun run typecheckbun run test: 35 tests. New indemo.test.ts, on the scripted model and an in-process host:eu-westfailed and the other two regions compensated.rollback, keeps the plan as of the fork, and is listed asfork N.crash-recovery.test.tsadds akill -9of the host process while all three subagents run their tools. After the restart the same three child conversations finish the investigation.radius/deepseek-v4.1-flashwith--demo:eu-westfailed and the other two regions compensated.bun run typecheck,bun run lint,bun run test(168 files, 1975 tests), andbun run buildChecklist
bun run typecheck,bun run test, andbun run buildare greendocs/research/in P5)CONTEXT.mdupdated if vocabulary changed (none changed)shared/ui/components are registered on/design(none)feat/; this is not a push tomain