From 6b71f09db74029e5d6d12dceb0b1997bae35394e Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 17 Sep 2026 23:09:26 -0600 Subject: [PATCH 1/6] fix(delegation): observe completed children as completed after a session stop --- .../delegationFollowThrough.logic.test.ts | 37 ++++++++++++++++++- .../delegationFollowThrough.logic.ts | 14 ++++--- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration/delegationFollowThrough.logic.test.ts b/apps/server/src/orchestration/delegationFollowThrough.logic.test.ts index c10644152d..be5a10b180 100644 --- a/apps/server/src/orchestration/delegationFollowThrough.logic.test.ts +++ b/apps/server/src/orchestration/delegationFollowThrough.logic.test.ts @@ -7,6 +7,7 @@ import { type OrchestrationThreadShell, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import { deriveDelegatedThreadState } from "../mcp/toolkits/delegation/logic.ts"; import { observeDelegatedChild, isActionableDelegationObservation, @@ -88,10 +89,29 @@ describe("delegated lifecycle observation", () => { observeDelegatedChild(shell({ session: { ...session, failedTurnRequestId: requestId } })), ).toMatchObject({ generation: "epoch:0:request:request-new", phase: "error" }); }); - it.each(["completed", "running"] as const)("stopped overrides stale %s turn", (state) => { + it("reports a completed turn as completed when the session was stopped afterwards", () => { + expect( + observeDelegatedChild(shell({ latestTurn: turn, session: { ...session, status: "stopped" } })) + ?.phase, + ).toBe("completed"); + }); + it("reports a running turn as interrupted when the session is stopped", () => { + expect( + observeDelegatedChild( + shell({ + latestTurn: { ...turn, state: "running", completedAt: null }, + session: { ...session, status: "stopped" }, + }), + )?.phase, + ).toBe("interrupted"); + }); + it("reports interrupted while a stop request is pending regardless of the turn", () => { expect( observeDelegatedChild( - shell({ latestTurn: { ...turn, state }, session: { ...session, status: "stopped" } }), + shell({ + latestTurn: turn, + session: { ...session, pendingStopRequestId: CommandId.make("stop-1") }, + }), )?.phase, ).toBe("interrupted"); }); @@ -137,6 +157,19 @@ describe("delegated lifecycle observation", () => { ).toBe("needs-approval"); expect(observeDelegatedChild(shell({ hasPendingUserInput: true }))?.phase).toBe("needs-input"); }); + it("agrees with deriveDelegatedThreadState on every completed child", () => { + const completedShells = [ + shell(), + shell({ session: { ...session, status: "stopped" } }), + shell({ session: { ...session, status: "idle" } }), + shell({ session: null }), + shell({ backgroundLiveness: "monitoring" }), + ]; + for (const candidate of completedShells) { + expect(deriveDelegatedThreadState(candidate)).toBe("completed"); + expect(observeDelegatedChild(candidate)?.phase).toBe("completed"); + } + }); }); describe("parent follow-through eligibility", () => { diff --git a/apps/server/src/orchestration/delegationFollowThrough.logic.ts b/apps/server/src/orchestration/delegationFollowThrough.logic.ts index 3666766a04..047e44fba2 100644 --- a/apps/server/src/orchestration/delegationFollowThrough.logic.ts +++ b/apps/server/src/orchestration/delegationFollowThrough.logic.ts @@ -19,12 +19,9 @@ export function observeDelegatedChild( // Rollback can reuse a provider turn ID under a new committed source generation. const generation = `epoch:${shell.sourceEpoch ?? 0}:${attempt}`; const phase = (() => { - if ( - session?.pendingStopRequestId !== undefined || - session?.status === "stopped" || - session?.status === "interrupted" - ) - return "interrupted" as const; + // A pending stop is an explicit interruption of whatever is running. + if (session?.pendingStopRequestId !== undefined) return "interrupted" as const; + // Admission in flight: the previous turn's terminal state is stale. if (session?.pendingTurnRequestId !== undefined || session?.status === "starting") return "running" as const; if (session?.status === "error" || session?.failedTurnRequestId !== undefined) @@ -34,9 +31,14 @@ export function observeDelegatedChild( if (session?.activeTurnId !== null && session?.activeTurnId !== undefined) return "running" as const; if (shell.backgroundLiveness === "working") return "running" as const; + // The turn's own terminal state wins over a session that was stopped + // afterwards by a restart or the idle reaper. A stopped session only + // means "interrupted" when the turn never reached a terminal state. if (turn?.state === "error") return "error" as const; if (turn?.state === "interrupted") return "interrupted" as const; if (turn?.state === "completed") return "completed" as const; + if (session?.status === "stopped" || session?.status === "interrupted") + return "interrupted" as const; return "running" as const; })(); const blockerIds = [ From 24b53b7254ca225f2a4d7e4df7b6c4c86c80b926 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 17 Sep 2026 23:10:35 -0600 Subject: [PATCH 2/6] fix(delegation): baseline changed child observations at startup --- .../DelegationFollowThroughReactor.test.ts | 25 +++++++++++++++++++ .../DelegationFollowThroughReactor.ts | 8 +++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/DelegationFollowThroughReactor.test.ts b/apps/server/src/orchestration/DelegationFollowThroughReactor.test.ts index 603e4f47fa..e8779508a2 100644 --- a/apps/server/src/orchestration/DelegationFollowThroughReactor.test.ts +++ b/apps/server/src/orchestration/DelegationFollowThroughReactor.test.ts @@ -462,4 +462,29 @@ describe("DelegationFollowThroughReactor", () => { }), ), ); + it.effect("baselines an observation whose key changed while the server was down", () => + Effect.scoped( + Effect.gen(function* () { + // First run: the child is running, so it is armed (not baselined). + const first = yield* makeHarness([shell(PARENT), shell(CHILD, true)]); + yield* first.emit(CHILD); + assert.strictEqual(first.wakes().length, 0); + // Restart: the child now reads as completed. Nothing observed that live. + const restored = yield* makeHarness([shell(PARENT), shell(CHILD)], true, first.persisted); + assert.strictEqual(restored.wakes().length, 0); + // A later live event on the same terminal state still does not wake. + yield* restored.emit(CHILD); + assert.strictEqual(restored.wakes().length, 0); + // A genuinely new turn after the restart does wake. + restored.replace(shell(CHILD, true)); + yield* restored.emit(CHILD); + restored.replace({ + ...shell(CHILD), + latestTurn: { ...shell(CHILD).latestTurn!, turnId: TurnId.make("after-restart") }, + }); + yield* restored.emit(CHILD); + assert.strictEqual(restored.wakes().length, 1); + }), + ), + ); }); diff --git a/apps/server/src/orchestration/DelegationFollowThroughReactor.ts b/apps/server/src/orchestration/DelegationFollowThroughReactor.ts index dc2914b448..765714fda8 100644 --- a/apps/server/src/orchestration/DelegationFollowThroughReactor.ts +++ b/apps/server/src/orchestration/DelegationFollowThroughReactor.ts @@ -167,10 +167,16 @@ export const make = Effect.gen(function* () { const old = previous.get(child.id); // Startup baselines old terminal children. Previously armed or pending work // recovers from its persisted observation; new lifecycle events are live. + // Children only run inside this process, so a state that differs from + // the persisted observation at startup changed while nothing could + // observe it live. Baseline it like a historical child. + const startupPass = work.causeId.startsWith("startup:"); const baseline = old?.noticeKey === observation.noticeKey ? old.baseline === true - : !old && work.liveChildId !== child.id && isActionableDelegationObservation(observation); + : (startupPass || !old) && + work.liveChildId !== child.id && + isActionableDelegationObservation(observation); const notificationId = EventId.make( `delegation-notice:${yield* digest(observation.noticeKey)}`, ); From 4d2ee570989e075c892e6e6ad556548515b818df Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 17 Sep 2026 23:12:52 -0600 Subject: [PATCH 3/6] fix(web): hide delegation bookkeeping rows from the work log --- apps/web/src/session-logic.test.ts | 28 ++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 5 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 9669e039b8..8e6b378a84 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2685,3 +2685,31 @@ describe("session activity performance", () => { expect(updateMs).toBeLessThan(fromScratchMs / 2); }); }); + +describe("deriveWorkLogEntries delegation bookkeeping", () => { + it("hides child-state observations and delivery receipts but keeps the pause notice", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + kind: "delegation.child-state", + tone: "info", + summary: "Pylon child completed", + payload: { childThreadId: "delegated:parent:0123456789abcdef" }, + }), + makeActivity({ + kind: "delegation.follow-through.delivered", + tone: "info", + summary: "Delegated child update delivered to parent", + payload: { notificationIds: [], messageId: "delegation-follow-through:abc" }, + }), + makeActivity({ + kind: "delegation.follow-through.paused", + tone: "info", + summary: "Automatic delegation follow-through paused", + payload: { detail: "Three automatic follow-through turns have run." }, + }), + ]); + expect(entries.map((entry) => entry.label)).toEqual([ + "Automatic delegation follow-through paused", + ]); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e1dc348c9e..15f231f496 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -579,7 +579,10 @@ export function deriveWorkLogEntries( activity.kind === "session.agent-depth.updated" || activity.kind === "session.input-queue.updated" || activity.kind === "turn.cost" || - activity.kind === "turn.plan.updated" + activity.kind === "turn.plan.updated" || + // Reactor bookkeeping for Pylon children; the Agents panel is the roster. + activity.kind === "delegation.child-state" || + activity.kind === "delegation.follow-through.delivered" ) { continue; } From ba2234b7443c7dbb22b9faa27744550a589aa990 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 17 Sep 2026 23:13:36 -0600 Subject: [PATCH 4/6] fix(mobile): hide delegation bookkeeping rows from the feed --- apps/mobile/src/lib/threadActivity.test.ts | 35 ++++++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 5 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index c4b5c951ef..3ba8b8936f 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3910,3 +3910,38 @@ it("keeps attachment-only question answers expandable outside mobile work groups expect(running[1]).toBe(group); expect(running[2]?.type).toBe("work-toggle"); }); + +describe("buildThreadFeed delegation bookkeeping", () => { + it("hides child-state observations and delivery receipts but keeps the pause notice", () => { + const feed = buildThreadFeed({ + messages: [], + activities: [ + makeActivity({ + id: EventId.make("obs-1"), + kind: "delegation.child-state", + summary: "Pylon child completed", + createdAt: "2026-04-01T00:00:01.000Z", + payload: { childThreadId: "delegated:parent:0123456789abcdef" }, + }), + makeActivity({ + id: EventId.make("delivered-1"), + kind: "delegation.follow-through.delivered", + summary: "Delegated child update delivered to parent", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { notificationIds: [], messageId: "delegation-follow-through:abc" }, + }), + makeActivity({ + id: EventId.make("paused-1"), + kind: "delegation.follow-through.paused", + summary: "Automatic delegation follow-through paused", + createdAt: "2026-04-01T00:00:03.000Z", + payload: { detail: "Three automatic follow-through turns have run." }, + }), + ], + }); + const summaries = feed.flatMap((entry) => + entry.type === "activity-group" ? entry.activities.map((item) => item.summary) : [], + ); + expect(summaries).toEqual(["Automatic delegation follow-through paused"]); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 5539fa8213..ec44e024f7 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -443,7 +443,10 @@ function deriveWorkLogEntries( activity.kind === "session.resources.updated" || activity.kind === "session.agent-depth.updated" || activity.kind === "session.input-queue.updated" || - activity.kind === "turn.cost" + activity.kind === "turn.cost" || + // Reactor bookkeeping for Pylon children; the Agents panel is the roster. + activity.kind === "delegation.child-state" || + activity.kind === "delegation.follow-through.delivered" ) { continue; } From 76aeefb0121c43b066d98303c3a6f8b203d65009 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 17 Sep 2026 23:13:59 -0600 Subject: [PATCH 5/6] docs(delegation): document observation precedence and durable fact --- .agents/durable-facts.jsonl | 1 + docs/internals/delegation.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/.agents/durable-facts.jsonl b/.agents/durable-facts.jsonl index e22d96a352..470221d932 100644 --- a/.agents/durable-facts.jsonl +++ b/.agents/durable-facts.jsonl @@ -19,3 +19,4 @@ {"id":"2026-09-11-runtime-home-default-development-runbook","recorded_at":"2026-09-11T08:01:50Z","scope":"server/runtime-home","fact":"Every Pylon server launch path defaults its runtime home to ~/.pylon-code: the t3 CLI's resolveBaseDir, the desktop's SSH remote launcher scripts, the WSL secondary backend, and the dev runner's implicit home. ~/.t3 is T3 Code's install and is never adopted as a fallback, because its database carries upstream migration numbering. --base-dir and T3CODE_HOME remain the unchanged, T3-compatible overrides. When a launch resolves the default base dir, finds no state there, and sees an older ~/.t3/userdata, warnAboutLegacyRuntimeHome writes one hint to stderr and never to the logger: auth, connect, and project resolve their base dir through the same helper and emit JSON payloads on the logger's stream, and their quietLogs guard is installed too late to suppress it. Migration moves the userdata state directory, not its parent: settings.json and secrets are derived from stateDir and live inside userdata, while the sibling caches and worktrees directories are disposable or referenced by absolute path and must stay. The SSH launch script names ~/.t3 only to retire a previous launcher's state directory once, and signals a recorded pid only after confirming its process arguments still carry serve --host 127.0.0.1 and --base-dir $HOME/.t3; where ps cannot report arguments it leaves the process running and still retires the directory. The maintainer database scripts keep their source home and their destructive-write guard separate: both read or seed from ~/.pylon-code but refuse to write to either runtime home, and migrate-dev-db applies that refusal before it checks whether a source exists.","source_commits":["a5cfa5da266c3e10ff5b7b049aea8035c222282a","8ec50a068a278b385428b5c131bf093ecaf7079c","ec31498eb69ebe7c6f15985ecb8320205a0ad872","5814f1a62a91d852b752562c39ad722323f999cc","fc7bda6e1fe7f058891d741ebea5116ce0ba4e37","1ace841de68d5e4563647182621d633a44025a81","8c298a75b153dba825114fe5601d26e2339c3f01","20bae41009f8cbb43ed7cb044ba024f5c6971ea5","a4ddf3266e6175de0cb76ffc5f8cc3ec3d180bac","66401f073eb502fd00e9719a18d6e8343cb500d9"],"source_paths":["apps/server/src/os-jank.ts","apps/server/src/os-jank.test.ts","apps/server/src/cli/config.ts","apps/server/src/cli/config.test.ts","apps/server/src/cli/triage.ts","packages/ssh/src/tunnel.ts","packages/ssh/src/tunnel.test.ts","apps/server/scripts/migrate-dev-db.ts","apps/server/scripts/t3-sqlite-state.ts","scripts/dev-runner.ts","docs/user/install.md","docs/user/remote-access.md","docs/operations/observability.md","docs/operations/development.md"],"supersedes":["2026-08-30-runtime-home-default"]} {"id": "2026-09-16-agent-delegation-toolkit", "recorded_at": "2026-09-16T22:43:16Z", "scope": "server/mcp-delegation", "fact": "Agent delegation is a sidecar MCP toolkit (delegate_thread, delegated_thread_status, delegated_thread_result, send_to_delegated_thread, interrupt_delegated_thread) over existing orchestration commands and projections, with no events, decider branches, projectors, or migrations. A child thread's id is delegated::; ownership, one-level depth, and the eight-live-children limit all derive from that id. The delegation capability is minted only when enableAgentDelegation is on and never for a delegated: thread. Creates, meta updates, and turn starts use deterministic command ids; deletes and interrupts use a unique id per call. Waits are capped at 45 seconds because Prime Agent's MCP client cancels tool calls at 60 seconds. Only a starting session counts as pending admission, and a child is discarded as a crash orphan only with no initial message, session, turn, or rollback.", "source_commits": ["c09309c94fac140d05d0f74c0fce88aa27d9cabf"], "source_paths": ["apps/server/src/mcp/toolkits/delegation/logic.ts", "apps/server/src/mcp/toolkits/delegation/handlers.ts", "apps/server/src/mcp/toolkits/delegation/tools.ts", "apps/server/src/provider/Layers/ProviderService.ts", "docs/internals/delegation.md", "docs/user/agent-delegation.md"], "supersedes": []} {"id":"2026-09-17-agent-delegation-defaults","recorded_at":"2026-09-17T05:19:42Z","scope":"server/mcp-delegation","fact":"Delegation defaults are project-scoped settings: delegationDefaultModelSelection (null by default) and delegationChildRuntimeMode (inherit or approval-required). An agent-named provider always wins and ignores the default; with no provider, the default supplies the provider, plus its model and options unless the agent named a model, in which case the default options are dropped. With neither, delegate_thread fails with DelegationDefaultMissingError instead of guessing, and an unavailable default provider is an error, never a fallback. The child mode default applies only when the agent passes no runtimeMode, and every mode still resolves to the parent mode or approval-required, never broader. Model selections are whole values: the server patch and the web project-scope write both replace them instead of deep-merging, so a stale options object cannot survive a model change. Harnesses whose session holds the delegation capability get a block in their runtime instructions; Prime Agent receives no Pylon runtime instructions and relies on the delegate_thread tool description for the same guidance.","source_commits":["3cfc8c3fbd0e4e4db10e7c6d2e79db86e0f0e748"],"source_paths":["packages/contracts/src/settings.ts","packages/shared/src/serverSettings.ts","apps/server/src/mcp/toolkits/delegation/logic.ts","apps/server/src/mcp/toolkits/delegation/handlers.ts","apps/server/src/mcp/toolkits/delegation/tools.ts","apps/server/src/provider/RuntimeInstructions.ts","apps/web/src/components/settings/scopedSettings.ts","apps/web/src/components/settings/ProjectDefaultsSettings.tsx","docs/internals/delegation.md","docs/user/agent-delegation.md"],"supersedes":[]} +{"id":"2026-09-18-delegation-observation-truth","recorded_at":"2026-09-18T05:14:00Z","scope":"server/delegation","fact":"When observing a delegated child thread, terminal turn states (completed, interrupted, error) take precedence over a post-turn stopped session because child sessions stop naturally after each turn finishes; an explicit pending stop request or a stopped session without a terminal turn reports interrupted. At startup, changed child observations are baselined like historical children so stale wake turns do not fire. Delegation bookkeeping rows (delegation.child-state and delegation.follow-through.delivered) are hidden from web and mobile work logs while delegation.follow-through.paused remains visible.","source_commits":["6b71f09db74029e5d6d12dceb0b1997bae35394e"],"source_paths":["docs/internals/delegation.md","apps/server/src/orchestration/delegationFollowThrough.logic.ts","apps/server/src/orchestration/DelegationFollowThroughReactor.ts","apps/web/src/session-logic.ts","apps/mobile/src/lib/threadActivity.ts"],"supersedes":[]} diff --git a/docs/internals/delegation.md b/docs/internals/delegation.md index 34280af998..18f5247215 100644 --- a/docs/internals/delegation.md +++ b/docs/internals/delegation.md @@ -51,6 +51,11 @@ The pending admission id alone is not used: interrupt recovery before the provid session but leaves that id set until the next turn start replaces it. A first turn stopped before admission leaves a session but no turn and is reported as interrupted, not queued forever. +When observing a delegated child's status, terminal turn states (`completed`, `interrupted`, `error`) +take precedence over a post-turn stopped session, because a child session naturally stops after each +turn finishes. An explicit pending stop request (`pendingStopRequestId !== null`) or a stopped session +_without_ a terminal turn still reports `interrupted`. + ## Defaults and agent guidance `delegationDefaultModelSelection` and `delegationChildRuntimeMode` are project-scoped settings resolved From 64000318e320e167622e24a96b2c39f0a273f328 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 17 Sep 2026 23:16:35 -0600 Subject: [PATCH 6/6] docs(delegation): align the internals doc and durable fact with the plan --- .agents/durable-facts.jsonl | 2 +- docs/internals/delegation.md | 23 +++++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.agents/durable-facts.jsonl b/.agents/durable-facts.jsonl index 470221d932..e3284063e5 100644 --- a/.agents/durable-facts.jsonl +++ b/.agents/durable-facts.jsonl @@ -19,4 +19,4 @@ {"id":"2026-09-11-runtime-home-default-development-runbook","recorded_at":"2026-09-11T08:01:50Z","scope":"server/runtime-home","fact":"Every Pylon server launch path defaults its runtime home to ~/.pylon-code: the t3 CLI's resolveBaseDir, the desktop's SSH remote launcher scripts, the WSL secondary backend, and the dev runner's implicit home. ~/.t3 is T3 Code's install and is never adopted as a fallback, because its database carries upstream migration numbering. --base-dir and T3CODE_HOME remain the unchanged, T3-compatible overrides. When a launch resolves the default base dir, finds no state there, and sees an older ~/.t3/userdata, warnAboutLegacyRuntimeHome writes one hint to stderr and never to the logger: auth, connect, and project resolve their base dir through the same helper and emit JSON payloads on the logger's stream, and their quietLogs guard is installed too late to suppress it. Migration moves the userdata state directory, not its parent: settings.json and secrets are derived from stateDir and live inside userdata, while the sibling caches and worktrees directories are disposable or referenced by absolute path and must stay. The SSH launch script names ~/.t3 only to retire a previous launcher's state directory once, and signals a recorded pid only after confirming its process arguments still carry serve --host 127.0.0.1 and --base-dir $HOME/.t3; where ps cannot report arguments it leaves the process running and still retires the directory. The maintainer database scripts keep their source home and their destructive-write guard separate: both read or seed from ~/.pylon-code but refuse to write to either runtime home, and migrate-dev-db applies that refusal before it checks whether a source exists.","source_commits":["a5cfa5da266c3e10ff5b7b049aea8035c222282a","8ec50a068a278b385428b5c131bf093ecaf7079c","ec31498eb69ebe7c6f15985ecb8320205a0ad872","5814f1a62a91d852b752562c39ad722323f999cc","fc7bda6e1fe7f058891d741ebea5116ce0ba4e37","1ace841de68d5e4563647182621d633a44025a81","8c298a75b153dba825114fe5601d26e2339c3f01","20bae41009f8cbb43ed7cb044ba024f5c6971ea5","a4ddf3266e6175de0cb76ffc5f8cc3ec3d180bac","66401f073eb502fd00e9719a18d6e8343cb500d9"],"source_paths":["apps/server/src/os-jank.ts","apps/server/src/os-jank.test.ts","apps/server/src/cli/config.ts","apps/server/src/cli/config.test.ts","apps/server/src/cli/triage.ts","packages/ssh/src/tunnel.ts","packages/ssh/src/tunnel.test.ts","apps/server/scripts/migrate-dev-db.ts","apps/server/scripts/t3-sqlite-state.ts","scripts/dev-runner.ts","docs/user/install.md","docs/user/remote-access.md","docs/operations/observability.md","docs/operations/development.md"],"supersedes":["2026-08-30-runtime-home-default"]} {"id": "2026-09-16-agent-delegation-toolkit", "recorded_at": "2026-09-16T22:43:16Z", "scope": "server/mcp-delegation", "fact": "Agent delegation is a sidecar MCP toolkit (delegate_thread, delegated_thread_status, delegated_thread_result, send_to_delegated_thread, interrupt_delegated_thread) over existing orchestration commands and projections, with no events, decider branches, projectors, or migrations. A child thread's id is delegated::; ownership, one-level depth, and the eight-live-children limit all derive from that id. The delegation capability is minted only when enableAgentDelegation is on and never for a delegated: thread. Creates, meta updates, and turn starts use deterministic command ids; deletes and interrupts use a unique id per call. Waits are capped at 45 seconds because Prime Agent's MCP client cancels tool calls at 60 seconds. Only a starting session counts as pending admission, and a child is discarded as a crash orphan only with no initial message, session, turn, or rollback.", "source_commits": ["c09309c94fac140d05d0f74c0fce88aa27d9cabf"], "source_paths": ["apps/server/src/mcp/toolkits/delegation/logic.ts", "apps/server/src/mcp/toolkits/delegation/handlers.ts", "apps/server/src/mcp/toolkits/delegation/tools.ts", "apps/server/src/provider/Layers/ProviderService.ts", "docs/internals/delegation.md", "docs/user/agent-delegation.md"], "supersedes": []} {"id":"2026-09-17-agent-delegation-defaults","recorded_at":"2026-09-17T05:19:42Z","scope":"server/mcp-delegation","fact":"Delegation defaults are project-scoped settings: delegationDefaultModelSelection (null by default) and delegationChildRuntimeMode (inherit or approval-required). An agent-named provider always wins and ignores the default; with no provider, the default supplies the provider, plus its model and options unless the agent named a model, in which case the default options are dropped. With neither, delegate_thread fails with DelegationDefaultMissingError instead of guessing, and an unavailable default provider is an error, never a fallback. The child mode default applies only when the agent passes no runtimeMode, and every mode still resolves to the parent mode or approval-required, never broader. Model selections are whole values: the server patch and the web project-scope write both replace them instead of deep-merging, so a stale options object cannot survive a model change. Harnesses whose session holds the delegation capability get a block in their runtime instructions; Prime Agent receives no Pylon runtime instructions and relies on the delegate_thread tool description for the same guidance.","source_commits":["3cfc8c3fbd0e4e4db10e7c6d2e79db86e0f0e748"],"source_paths":["packages/contracts/src/settings.ts","packages/shared/src/serverSettings.ts","apps/server/src/mcp/toolkits/delegation/logic.ts","apps/server/src/mcp/toolkits/delegation/handlers.ts","apps/server/src/mcp/toolkits/delegation/tools.ts","apps/server/src/provider/RuntimeInstructions.ts","apps/web/src/components/settings/scopedSettings.ts","apps/web/src/components/settings/ProjectDefaultsSettings.tsx","docs/internals/delegation.md","docs/user/agent-delegation.md"],"supersedes":[]} -{"id":"2026-09-18-delegation-observation-truth","recorded_at":"2026-09-18T05:14:00Z","scope":"server/delegation","fact":"When observing a delegated child thread, terminal turn states (completed, interrupted, error) take precedence over a post-turn stopped session because child sessions stop naturally after each turn finishes; an explicit pending stop request or a stopped session without a terminal turn reports interrupted. At startup, changed child observations are baselined like historical children so stale wake turns do not fire. Delegation bookkeeping rows (delegation.child-state and delegation.follow-through.delivered) are hidden from web and mobile work logs while delegation.follow-through.paused remains visible.","source_commits":["6b71f09db74029e5d6d12dceb0b1997bae35394e"],"source_paths":["docs/internals/delegation.md","apps/server/src/orchestration/delegationFollowThrough.logic.ts","apps/server/src/orchestration/DelegationFollowThroughReactor.ts","apps/web/src/session-logic.ts","apps/mobile/src/lib/threadActivity.ts"],"supersedes":[]} +{"id":"2026-09-18-delegation-follow-through-observation","recorded_at":"2026-09-18T05:16:26Z","scope":"server/mcp-delegation","fact":"Automatic delegation follow-through is not a pure sidecar: it adds the internal thread.delegation.follow-through command, a decider admission branch, and two projection queries, so removing delegation means removing those with the toolkit and reactor. The reactor's observeDelegatedChild must agree with the toolkit's deriveDelegatedThreadState for completed children: a completed turn is observed as completed even when the session was stopped afterwards by a restart or the idle reaper, and only a non-terminal turn on a stopped session reads as interrupted. At startup every child whose observation key differs from the persisted one is baselined, never delivered.","source_commits":["6b71f09db74029e5d6d12dceb0b1997bae35394e"],"source_paths":["apps/server/src/orchestration/delegationFollowThrough.logic.ts","apps/server/src/orchestration/DelegationFollowThroughReactor.ts","docs/internals/delegation.md"],"supersedes":["2026-09-16-agent-delegation-toolkit"]} diff --git a/docs/internals/delegation.md b/docs/internals/delegation.md index 18f5247215..f6e5fe814d 100644 --- a/docs/internals/delegation.md +++ b/docs/internals/delegation.md @@ -1,12 +1,14 @@ # Delegated threads The delegation MCP toolkit ([`apps/server/src/mcp/toolkits/delegation`](../../apps/server/src/mcp/toolkits/delegation)) -lets an agent start and manage child threads on other provider instances. It is a sidecar: it -dispatches only existing commands (`thread.create`, `thread.meta.update`, `thread.turn.start`, -`thread.turn.interrupt`, `thread.delete`) and reads only existing projections. It adds no events, -decider branches, projectors, or migrations, so it can be removed or remapped if Pylon adopts -upstream's orchestrator rewrite. It deliberately does not reuse upstream's reserved -`delegate_task`, `task_status`, or `task_cancel` tool names. +lets an agent start and manage child threads on other provider instances. The toolkit itself is a +sidecar: it dispatches only existing commands (`thread.create`, `thread.meta.update`, +`thread.turn.start`, `thread.turn.interrupt`, `thread.delete`) and reads only existing projections. +Automatic parent follow-through (PR #603) is not: it adds the internal +`thread.delegation.follow-through` command to the contracts, a decider branch that admits it, and +two projection queries. Removing delegation therefore means deleting the toolkit directory, the +reactor, and those three core touches together. The toolkit deliberately does not reuse upstream's +reserved `delegate_task`, `task_status`, or `task_cancel` tool names. ## The child id carries the parent @@ -51,10 +53,11 @@ The pending admission id alone is not used: interrupt recovery before the provid session but leaves that id set until the next turn start replaces it. A first turn stopped before admission leaves a session but no turn and is reported as interrupted, not queued forever. -When observing a delegated child's status, terminal turn states (`completed`, `interrupted`, `error`) -take precedence over a post-turn stopped session, because a child session naturally stops after each -turn finishes. An explicit pending stop request (`pendingStopRequestId !== null`) or a stopped session -_without_ a terminal turn still reports `interrupted`. +The follow-through reactor observes children with `observeDelegatedChild`, which must agree with +`deriveDelegatedThreadState` on completed children: a turn's own terminal state wins over a session +that was stopped afterwards by a restart or the idle reaper. At startup, any child whose observation +differs from the persisted one is baselined rather than delivered, because children only run inside +the server process and nothing could have observed that change live. ## Defaults and agent guidance