Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/durable-facts.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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:<parentThreadId>:<first 16 hex of sha256(parent + newline + key)>; 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 <pylon_delegation> 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-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"]}
35 changes: 35 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
5 changes: 4 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
),
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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", () => {
Expand Down
14 changes: 8 additions & 6 deletions apps/server/src/orchestration/delegationFollowThrough.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 = [
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
});
});
5 changes: 4 additions & 1 deletion apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
20 changes: 14 additions & 6 deletions docs/internals/delegation.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -51,6 +53,12 @@ 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.

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

`delegationDefaultModelSelection` and `delegationChildRuntimeMode` are project-scoped settings resolved
Expand Down
Loading