From a3dcc8e32fd1adeb74fd5b704f5bd9036beef020 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 04:06:05 +0000 Subject: [PATCH 01/20] docs: acknowledge issue 63 trajectory contract --- .../sdk-feasibility-spike.mjs | 355 ++++++++++++++++ docs/issue-63-trajectory-handoffs/spec.md | 390 ++++++++++++++++++ 2 files changed, 745 insertions(+) create mode 100644 docs/issue-63-trajectory-handoffs/sdk-feasibility-spike.mjs create mode 100644 docs/issue-63-trajectory-handoffs/spec.md diff --git a/docs/issue-63-trajectory-handoffs/sdk-feasibility-spike.mjs b/docs/issue-63-trajectory-handoffs/sdk-feasibility-spike.mjs new file mode 100644 index 00000000..1040b9c5 --- /dev/null +++ b/docs/issue-63-trajectory-handoffs/sdk-feasibility-spike.mjs @@ -0,0 +1,355 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Type } from "typebox"; +import { + AuthStorage, + createAgentSession, + DefaultResourceLoader, + defineTool, + ModelRegistry, + SessionManager, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; + +const usage = { + input: 100, + output: 10, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 110, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +const requests = []; +let sourceExecutions = 0; +let targetExecutions = 0; +let targetProviderTurns = 0; +let currentRolePrompt = "SOURCE_ROLE_ONLY"; + +function toolCallStream(model, context, calls) { + requests.push({ + model: model.id, + systemPrompt: context.systemPrompt, + toolNames: (context.tools ?? []).map((tool) => tool.name), + messages: structuredClone(context.messages), + }); + const stream = createAssistantMessageEventStream(); + const message = { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "spike", + model: model.id, + usage, + stopReason: "toolUse", + timestamp: Date.now(), + }; + stream.push({ type: "start", partial: message }); + calls.forEach((call, index) => { + const toolCall = { type: "toolCall", id: `${model.id}-${index}`, name: call, arguments: {} }; + message.content.push(toolCall); + stream.push({ type: "toolcall_start", contentIndex: index, partial: message }); + stream.push({ type: "toolcall_delta", contentIndex: index, delta: "{}", partial: message }); + stream.push({ type: "toolcall_end", contentIndex: index, toolCall, partial: message }); + }); + stream.push({ type: "done", reason: "toolUse", message }); + stream.end(); + return stream; +} + +function stopStream(model, context) { + requests.push({ + model: model.id, + systemPrompt: context.systemPrompt, + toolNames: (context.tools ?? []).map((tool) => tool.name), + messages: structuredClone(context.messages), + }); + const stream = createAssistantMessageEventStream(); + const message = { + role: "assistant", + content: [{ type: "text", text: "TARGET_FINAL" }], + api: "anthropic-messages", + provider: "spike", + model: model.id, + usage, + stopReason: "stop", + timestamp: Date.now(), + }; + stream.push({ type: "start", partial: message }); + stream.push({ type: "text_start", contentIndex: 0, partial: message }); + stream.push({ type: "text_delta", contentIndex: 0, delta: "TARGET_FINAL", partial: message }); + stream.push({ type: "text_end", contentIndex: 0, content: "TARGET_FINAL", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + stream.end(); + return stream; +} + +function streamSimple(model, context) { + if (model.id === "source") return toolCallStream(model, context, ["source_tool"]); + if (model.id === "target") { + targetProviderTurns += 1; + return targetProviderTurns === 1 + ? toolCallStream(model, context, ["source_tool", "target_tool"]) + : stopStream(model, context); + } + if (model.id === "tiny") return stopStream(model, context); + throw new Error(`unexpected model ${model.id}`); +} + +function model(id, contextWindow, maxTokens = 16) { + return { + id, + name: id, + api: "anthropic-messages", + provider: "spike", + baseUrl: "spike://no-network", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens, + }; +} + +async function makeLoader(cwd) { + const loader = new DefaultResourceLoader({ + cwd, + agentDir: cwd, + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + systemPromptOverride: () => "BASE_PROMPT_SHOULD_NOT_SURVIVE_ROLE_SWAP", + appendSystemPromptOverride: () => [], + extensionFactories: [ + { + name: "trajectory-role-environment", + factory: (pi) => { + pi.on("before_agent_start", async () => ({ systemPrompt: currentRolePrompt })); + }, + }, + ], + }); + await loader.reload(); + return loader; +} + +function sourceTool() { + return defineTool({ + name: "source_tool", + label: "Source", + description: "source-only tool", + parameters: Type.Object({}), + execute: async () => { + sourceExecutions += 1; + return { + content: [{ type: "text", text: "SOURCE_TOOL_RESULT_EXACT" }], + details: { source: true }, + terminate: true, + }; + }, + }); +} + +function targetTool() { + return defineTool({ + name: "target_tool", + label: "Target", + description: "target-only tool", + parameters: Type.Object({}), + execute: async () => { + targetExecutions += 1; + return { + content: [{ type: "text", text: "TARGET_TOOL_RESULT" }], + details: { target: true }, + terminate: true, + }; + }, + }); +} + +class TrajectoryContextAdmissionError extends Error { + constructor(code, message) { + super(message); + this.name = "TrajectoryContextAdmissionError"; + this.code = code; + } +} + +function admitTrajectoryContext(session, targetModel, targetSeedTokens) { + if ( + !Number.isSafeInteger(targetModel.contextWindow) || + targetModel.contextWindow <= 0 || + !Number.isSafeInteger(targetModel.maxTokens) || + targetModel.maxTokens < 0 + ) { + throw new TrajectoryContextAdmissionError( + "trajectory_context_metadata_unknown", + "target contextWindow/maxTokens metadata is unknown", + ); + } + const usage = session.getContextUsage(); + if (usage === undefined || usage.tokens === null) { + throw new TrajectoryContextAdmissionError( + "trajectory_context_unknown", + "Pi cannot estimate this session context before generation", + ); + } + const required = usage.tokens + targetSeedTokens + targetModel.maxTokens; + if (required > targetModel.contextWindow) { + throw new TrajectoryContextAdmissionError( + "trajectory_context_too_large", + `requires ${required} tokens but target window is ${targetModel.contextWindow}`, + ); + } + return { required, contextWindow: targetModel.contextWindow }; +} + +function assertTrajectory(request) { + const roles = request.messages.map((message) => message.role); + assert.deepEqual(roles.slice(0, 3), ["user", "assistant", "toolResult"]); + assert.equal(request.messages[0].content[0].text, "SOURCE_PROMPT_EXACT"); + assert.equal(request.messages[1].content[0].name, "source_tool"); + assert.equal(request.messages[2].toolName, "source_tool"); + assert.equal(request.messages[2].content[0].text, "SOURCE_TOOL_RESULT_EXACT"); +} + +const cwd = await mkdtemp(join(tmpdir(), "issue-63-sdk-spike-")); +try { + const auth = AuthStorage.inMemory(); + const registry = ModelRegistry.inMemory(auth); + const source = model("source", 1000); + const target = model("target", 1000); + const tiny = model("tiny", 1); + registry.registerProvider("spike", { + baseUrl: "spike://no-network", + api: "anthropic-messages", + apiKey: "not-a-live-key", + streamSimple, + models: [source, target, tiny], + }); + const sessionDir = join(cwd, "sessions"); + const manager = SessionManager.create(cwd, sessionDir); + const loader = await makeLoader(cwd); + const settings = SettingsManager.inMemory({ compaction: { enabled: false } }); + const { session } = await createAgentSession({ + cwd, + agentDir: cwd, + model: source, + thinkingLevel: "low", + modelRegistry: registry, + resourceLoader: loader, + settingsManager: settings, + sessionManager: manager, + customTools: [sourceTool(), targetTool()], + tools: ["source_tool", "target_tool"], + }); + + const originalSessionId = session.sessionId; + const originalSessionFile = session.sessionFile; + await session.prompt("SOURCE_PROMPT_EXACT"); + assert.equal(sourceExecutions, 1); + + await session.setModel(target); + session.setThinkingLevel("high"); + currentRolePrompt = "TARGET_ROLE_ONLY"; + session.setActiveToolsByName(["target_tool"]); + assert.equal(session.sessionId, originalSessionId); + assert.equal(session.sessionFile, originalSessionFile); + assert.equal(session.model?.id, "target"); + assert.equal(session.thinkingLevel, "high"); + assert.deepEqual(session.getActiveToolNames(), ["target_tool"]); + + const targetSeedBudget = 10; + const admissionKnown = admitTrajectoryContext(session, target, targetSeedBudget); + assert.equal(admissionKnown.contextWindow, 1000); + + await session.prompt("TARGET_PROMPT_EXACT"); + const targetRequest = requests.find((request) => request.model === "target"); + assert.ok(targetRequest); + assertTrajectory(targetRequest); + assert.equal(session.systemPrompt, "TARGET_ROLE_ONLY"); + assert.equal(targetRequest.systemPrompt, "TARGET_ROLE_ONLY"); + assert.deepEqual(targetRequest.toolNames, ["target_tool"]); + assert.equal(sourceExecutions, 1); + assert.equal(targetExecutions, 1); + + await session.setModel(tiny); + let admissionTooLarge; + assert.throws( + () => admitTrajectoryContext(session, tiny, targetSeedBudget), + (error) => { + admissionTooLarge = { code: error.code, message: error.message }; + return error instanceof TrajectoryContextAdmissionError && error.code === "trajectory_context_too_large"; + }, + ); + + session.dispose(); + + const resumedLoader = await makeLoader(cwd); + const { session: resumed } = await createAgentSession({ + cwd, + agentDir: cwd, + modelRegistry: registry, + resourceLoader: resumedLoader, + settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }), + sessionManager: SessionManager.open(originalSessionFile, sessionDir, cwd), + customTools: [sourceTool(), targetTool()], + tools: ["source_tool", "target_tool"], + }); + assert.equal(resumed.sessionId, originalSessionId); + assert.equal(resumed.sessionFile, originalSessionFile); + assert.deepEqual(resumed.messages, session.messages); + assert.equal(resumed.model?.id, "tiny"); + resumed.setActiveToolsByName(["target_tool"]); + assert.deepEqual(resumed.getActiveToolNames(), ["target_tool"]); + const firstEntryId = resumed.sessionManager.getEntries()[0]?.id; + assert.ok(firstEntryId); + resumed.sessionManager.appendCompaction("spike summary", firstEntryId, 0); + let admissionUnknown; + assert.throws( + () => admitTrajectoryContext(resumed, target, targetSeedBudget), + (error) => { + admissionUnknown = { code: error.code, message: error.message }; + return error instanceof TrajectoryContextAdmissionError && error.code === "trajectory_context_unknown"; + }, + ); + resumed.dispose(); + + console.log( + JSON.stringify( + { + package: "@earendil-works/pi-coding-agent", + sessionId: originalSessionId, + sessionFile: originalSessionFile, + messageRolesAfterSource: ["user", "assistant", "toolResult"], + sourceRequest: { + model: requests[0]?.model, + systemPrompt: requests[0]?.systemPrompt, + toolNames: requests[0]?.toolNames, + }, + targetRequest: { + model: targetRequest.model, + systemPrompt: targetRequest.systemPrompt, + toolNames: targetRequest.toolNames, + messageRoles: targetRequest.messages.map((message) => message.role), + }, + sourceExecutions, + targetExecutions, + admissionKnown, + admissionTooLarge, + admissionUnknown, + resumed: { sessionId: resumed.sessionId, sessionFile: resumed.sessionFile, model: resumed.model?.id }, + result: "PASS", + }, + null, + 2, + ), + ); +} finally { + await rm(cwd, { recursive: true, force: true }); +} diff --git a/docs/issue-63-trajectory-handoffs/spec.md b/docs/issue-63-trajectory-handoffs/spec.md new file mode 100644 index 00000000..e4f255d2 --- /dev/null +++ b/docs/issue-63-trajectory-handoffs/spec.md @@ -0,0 +1,390 @@ +# Issue #63 — trajectory-preserving handoffs + +**Status: Acknowledged — implementation authorized** + +## Objective and scope + +Issue [#63](https://github.com/lynellf/pi-conductor/issues/63) adds an opt-in, per-legal-edge transport policy. A `trajectory` edge continues the *same Pi conversation* into the target role so its prior user/assistant/tool-result trajectory remains model-visible; a `fresh` edge retains the current handoff/context path unchanged. Models never select transport in `handoff()`. + +This is deliberately a host feature. Manifest parsing and validation stay pure in `src/manifest`; `reduce` and `reduceLifecycle` continue to receive only machine events and never interpret conversations. Pi imports and all session operations remain in `src/host`. This follows the archived FSM spec §10 (pinned manifest), §11.1/§11.4 (snapshot/lifecycle persistence), and §12 (pure reducer), plus `AGENTS.md`’s core/host boundary. + +### Assumptions made explicit + +1. “Exact trajectory” means the active Pi branch's complete message and tool-result sequence, not a handoff summary, context reference, or a compacted branch summary. +2. The MVP supports only a single active role and only shared-workspace trajectory edges. It does not make a source workspace's built-in tools safe for a different worktree/copy/container workspace. +3. The checkout SDK (`@earendil-works/pi-coding-agent` **0.80.6**) is the implementation evidence. The PATH Pi CLI (**0.84.3**) is recorded only as a separate runtime-version observation; it is not an API compatibility claim. +4. A trajectory preflight failure is not eligible for fresh fallback. It leaves the accepted FSM state auditable and closes the run with a typed host failure. + +## Evidence and SDK decision + +### Versions and sources + +| Evidence | Observed version / result | Use in this contract | +| --- | --- | --- | +| Checkout package, `node_modules/.bin/pi --version` | `@earendil-works/pi-coding-agent` 0.80.6 / `0.80.6` | Sole SDK API and behavior basis. | +| Checkout package companions | `@earendil-works/pi-ai` 0.80.6 and `@earendil-works/pi-agent-core` 0.80.6, resolved beneath coding-agent | Required by the 0.80.6 session experiment. | +| Active PATH CLI, `pi --version` | `0.84.3` at `/home/lynellf/.nvm/versions/node/v26.5.0/bin/pi` | Separate operational fact only. Re-run the spike before using its API surface. | +| Node | `v26.5.0` | Spike runner environment. | + +Version commands recorded for this checkout were: + +```bash +node_modules/.bin/pi --version # 0.80.6 +pnpm list @earendil-works/pi-coding-agent --depth 0 # 0.80.6 +pi --version # 0.84.3 (PATH runtime, separate evidence) +``` + +Official packaged Pi 0.80.6 documentation says `AgentSession` exposes `sessionId`, `sessionFile`, `messages`, `setModel`, and `setThinkingLevel` (`node_modules/@earendil-works/pi-coding-agent/docs/sdk.md`, lines 75–118), and demonstrates `SessionManager.open()` (`docs/sdk.md`, lines 723–795). Exact local declarations add `setActiveToolsByName()` and say it rebuilds the system prompt for the next turn (`dist/core/agent-session.d.ts`, lines 275–314), model/thinking setters (`lines 423–452`), `getContextUsage()` (`lines 588–594`), and `SessionManager.open(path, sessionDir?, cwdOverride?)` (`dist/core/session-manager.d.ts`, lines 250–320). Model metadata contains `contextWindow` and `maxTokens` (`@earendil-works/pi-ai/dist/types.d.ts`, lines 581–600). `ContextUsage.tokens` is explicitly nullable immediately after compaction (`dist/core/extensions/types.d.ts`, lines 192–197). + +For replacement instructions, the official extension guide documents `before_agent_start` as able to modify the fully assembled system prompt (`docs/extensions.md`, lines 513–523 and the example around lines 480–548) and documents dynamic tool registration/activation (`docs/extensions.md`, “pi.registerTool” and “pi.setActiveTools”; lines 1327–1370 and 1600–1680). These are the public/documented mechanisms used by the spike—not a private `_agent` field. + +### Reproducible no-key spike + +Artifact: [`sdk-feasibility-spike.mjs`](./sdk-feasibility-spike.mjs) + +```bash +node --max-old-space-size=1024 docs/issue-63-trajectory-handoffs/sdk-feasibility-spike.mjs +``` + +It creates a temporary persistent session directory, a `spike` provider with a literal non-live key and in-process stream, a source-only custom tool, a target-only custom tool, and a dynamic `before_agent_start` extension. No network provider or live API key is used; the temporary directory is removed in `finally`. + +Observed 0.80.6 run (session IDs and temp paths are intentionally run-specific): + +```text +sessionId: 01a0467e-0e67-7aea-a7f1-af445eaa0a65 +sessionFile: /tmp/issue-63-sdk-spike-erXBOM/sessions/...jsonl +source request: model=source, prompt=SOURCE_ROLE_ONLY, + tools=[source_tool,target_tool] +target request: model=target, prompt=TARGET_ROLE_ONLY, + tools=[target_tool], messages=[user,assistant,toolResult,user] +source executions=1; target executions=1 +known admission: required=142, window=1000 +too-large admission: trajectory_context_too_large, requires 136 / window 1 +unknown admission after a compaction entry: trajectory_context_unknown +reopened sessionId/sessionFile identical; restored selected model=tiny +result=PASS +``` + +The target provider inspected—not merely typed—the source user prompt, source assistant `source_tool` call, and exact `SOURCE_TOOL_RESULT_EXACT` before it received the target prompt. It intentionally emitted `source_tool` after target tool replacement; the source executor remained at one call while `target_tool` executed once. That proves no source custom tool remained model-callable through the active target allowlist. + +| Required feasibility claim | 0.80.6 observed result | Contract consequence | +| --- | --- | --- | +| Completed trajectory survives model switch and target prompt | **Pass.** Same session ID/file; target provider received `user → assistant(tool call) → toolResult → target user`. | Use one persistent `AgentSession` for a selected trajectory chain. | +| Model and thinking level change in place | **Pass.** `await setModel(target)` then `setThinkingLevel("high")`; same session ID/file, target model/high observed. | Use these public calls only while idle. Assert resulting model/effort before prompt. | +| Target instructions replace rather than append | **Pass, bounded.** Public `before_agent_start` returned `TARGET_ROLE_ONLY`; provider received that exact value, with no source marker. | Use a host-owned dynamic role-environment extension; do not assign private SDK state. | +| Target tools replace, including custom source tools | **Pass, bounded.** `setActiveToolsByName(["target_tool"])`; provider saw only target and a source tool call did not execute. | Exact active-name equality is a postcondition. Registry retention is allowed, but inactive tools must not be sent or execute. | +| Pre-generation admission has known/unknown/too-large outcomes | **Pass for the primitive.** `getContextUsage()` plus model metadata produced known/too-large; a compaction entry yielded null tokens and a typed unknown error. | Host owns a conservative admission calculator; lack of metadata/estimate is a hard typed failure. | +| Same conversation can return after process/session replacement | **SDK pass; current conductor gap.** `SessionManager.open(file)` created a new SDK object with identical session ID/file/messages and restored model. Current `ProductionHost` always calls `SessionManager.create`; `resumeRun` does not retain a trajectory transport binding. | Add the durable host seam in §4.5. Do not claim conductor resume support until its test passes. | + +### 0.80.6 limitations and compatibility cost + +There is **no single public 0.80.6 `replaceEnvironment()` transaction**. `setModel`, `setThinkingLevel`, and `setActiveToolsByName` are separate operations; `setActiveToolsByName` silently ignores unknown names by declaration. A role-specific system prompt must be supplied by a public extension hook for the next prompt. The host therefore needs a small, explicit reconfiguration seam with prepare/commit/assert/cleanup steps. It must not reach into private session fields. + +The original `AgentSession` tool registry also binds built-in tools to its construction `cwd`, and current conductor-created custom `handoff`, `end`, and `delegate` tools close over one role/session seam. Thus 0.80.6 does **not** safely support trajectory across isolated workspaces or an arbitrary per-role custom-tool implementation without a host bridge. The MVP is bounded to shared-workspace roles and conductor-owned rebindable tools. Any later peer/runtime upgrade is allowed only after this exact artifact passes against that exact installed version and the compatibility matrix is updated; 0.84.3 is not assumed compatible. + +## 1. Additive manifest contract + +### 1.1 Syntax and normalized type + +`handoffs` is optional. Each entry is exactly one directed policy: + +```yaml +version: 1 +handoffs: + - from: senior-planner + to: orchestrator + mode: trajectory + - from: orchestrator + to: implementer + mode: trajectory + - from: implementer + to: orchestrator + mode: fresh +roles: # existing role syntax, unchanged + # ... +``` + +The normalized pure manifest type is: + +```ts +type HandoffMode = "fresh" | "trajectory"; +interface HandoffPolicy { + readonly from: Role; + readonly to: Role; + readonly mode: HandoffMode; +} +interface Manifest { + // existing fields unchanged + readonly handoffs?: readonly HandoffPolicy[]; +} +``` + +`handoffs` absent means an empty immutable policy list. `handoffs` must be a YAML sequence; every entry must be a mapping with non-empty string `from`, non-empty string `to`, and `mode` exactly `fresh` or `trajectory`. Unknown entry keys are rejected by the parser rather than ignored. `mode` is required for an entry—there is no per-entry implicit mode. + +The policy selector is total: + +```ts +modeFor(from, to) = pinnedHandoffs.get(`${from}\u0000${to}`) ?? "fresh"; +``` + +Therefore every undeclared edge is `fresh`, including existing manifests and manifests that declare other edges. `handoff()` keeps its current semantic schema; it receives no `mode`, conversation ID, session path, or transport override. + +### 1.2 Exact static validation + +Validation is pure and runs after parsing with the existing manifest checks. It adds these `ManifestErrorCode` values. The validator accumulates all applicable errors so the operator can fix a manifest in one edit. + +| Condition for `handoffs[i]` | Exact error code | Rule | +| --- | --- | --- | +| `from` is not a declared role | `handoff-policy-from-undeclared` | Never infer or create roles. | +| `to` is not a declared role | `handoff-policy-to-undeclared` | Never infer or create roles. | +| `from === to` | `handoff-policy-self-edge` | Self transport is forbidden even if the role is declared. | +| Same ordered `(from,to)` occurred at an earlier index | `handoff-policy-duplicate-edge` | `fresh` does not make a duplicate harmless. | +| Both endpoints are declared but the directed pair is not legal in the pinned hub-and-spoke FSM | `handoff-policy-illegal-edge` | Legal pairs are only `orchestrator → declared worker` and `declared worker → orchestrator`; `end` is not an edge policy. | +| `mode: trajectory` has a source or target whose declared workspace backend is not `shared` (default is `shared`) | `trajectory-workspace-unsupported` | The same SDK session cannot rebind built-in tool cwd safely. | +| `mode: trajectory` source or target declares conductor delegation/progressive file bridge capability (`delegate`, `request_files`, or a delegation/progressive-disclosure block) | `trajectory-custom-tool-unsupported` | Current role-bound custom tools cannot be safely rebound without a new, separately approved bridge. | +| `mode: trajectory` target omits `models` or has an empty model list | `trajectory-target-model-unresolved` | Admission requires a concrete target `Model`; retaining the source/default model is forbidden. | +| `mode: trajectory` target omits `system_prompt` | `trajectory-target-system-prompt-unresolved` | The public replacement hook needs an explicit target instruction value; retaining the source/default prompt is forbidden. | + +All table predicates are independent except `handoff-policy-illegal-edge`, which runs only when both endpoints are declared **and distinct**; e.g. `ghost → ghost` reports both undeclared endpoint errors plus self-edge, a declared self edge reports self-edge, and a declared worker → different declared worker reports illegal-edge. The last four are conservative MVP validations; `fresh` entries retain existing workspace and model behavior. Runtime target-model lookup, target effort support, active-tool availability, and admission are host preflight errors because the pure manifest must not import Pi. + +### 1.3 Core boundary + +`Manifest`, its parser, validator, policy lookup helper, and tests stay in `src/manifest` with zero Pi imports. `MachineDefinition` does not gain a conversation field and `reduce` does not call `modeFor`. The host receives the accepted `(from,to)` plus the run-pinned manifest snapshot and chooses transport afterward. + +## 2. Pinning, persistence, and resume + +### 2.1 New-run snapshot + +When a new manifest declares one or more `handoffs` entries, `startRun` appends exactly one host-owned record after pure validation and before any role session is spawned. A manifest with no `handoffs` field writes no new record and retains the existing all-fresh path exactly: + +```ts +interface ManifestSnapshotRecord { + readonly type: "manifest_snapshot"; + readonly schema_version: 1; + readonly run_id: string; + readonly manifest_version: string; + readonly normalized_manifest: Manifest; // canonical parsed, immutable data + readonly definition: MachineDefinition; + readonly sha256: string; // canonical JSON of schema_version + manifest + definition + readonly ts: number; +} +``` + +The snapshot pins roles, models, tools, workspace declarations, and `handoffs`; it is the source for every new host instance for that run. It contains parsed configuration, not arbitrary YAML text. The host keeps prompt-file loading behavior compatible with the current fresh path; a trajectory selection additionally persists its fully resolved target prompt/environment in §2.2 so resume cannot re-read changed target role instructions. + +A new `resumeRun` reads and hash-validates this record before constructing a host. It derives/rechecks the stored `definition`; it does not re-load the manifest to decide a policy. The supplied manifest path remains a UX locator only for new runs and historical compatibility, not a way to change a snapshot run. + +### 2.2 Selected transport is durable before target spawn + +For a **trajectory** accepted nonterminal handoff, append one selector record after the reducer's accepted snapshot and source terminal lifecycle snapshot, but before target startup: + +```ts +interface HandoffTransportSelectedRecord { + readonly type: "handoff_transport_selected"; + readonly schema_version: 1; + readonly run_id: string; + readonly source_role_session_id: string; + readonly from: Role; + readonly to: Role; + readonly mode: "trajectory"; + readonly source_conversation: { readonly id: string; readonly file: string }; + readonly target: { + readonly model: string; + readonly requested_effort: ModelEffort; + readonly system_prompt: string; + readonly active_tool_names: readonly string[]; + readonly environment_sha256: string; + }; + readonly admission: TrajectoryAdmission; + readonly ts: number; +} +``` + +`system_prompt` is the fully resolved prompt value passed by the role-environment controller; persisting it is intentional so a resume cannot silently pick up altered instructions. `environment_sha256` covers exactly the prompt, target model/effort, active names, and serialized active tool definitions used for admission. The persisted admission type is: + +```ts +interface TrajectoryAdmission { + readonly schema_version: 1; + readonly observed_context_tokens: number; + readonly role_envelope_tokens: number; + readonly target_max_tokens: number; + readonly safety_reservation_tokens: 8192; + readonly required_tokens: number; + readonly target_context_window: number; + readonly target_model: string; +} +``` + +The selector record is **not** a reducer event and does not change visit counts, acceptance, or legal targets. `fresh` has no selector record at all: it calls the existing spawn/seed path, with current `formatHandoffSeed`, fresh session creation, tool exposure, context tool behavior, and model fallback behavior unchanged. + +### 2.3 Historical logs + +Records written before this feature have neither `manifest_snapshot` nor `handoff_transport_selected` and remain readable. They resume via the existing manifest-version check and existing fresh-session flow; their effective policy is `fresh` for every edge. A snapshot-era log with unknown record schema/hash mismatch is a typed `ManifestSnapshotError`, never a guessed current manifest. A trajectory selector without its required snapshot/environment fields is a typed `TrajectoryResumeError`, never a fresh fallback. + +## 3. One host-owned transport selector + +`runLoop` remains the sole owner of reduction, checkpoint persistence, lifecycle reduction, and role spawning. After it has followed the existing accepted-transition sequence through the source `session_ended` lifecycle record and its cleared checkpoint snapshot—but before it disposes the still-idle physical session or spawns a target—it calls one host method conceptually shaped as: + +```ts +selectAcceptedHandoffTransport({ + accepted: { from, to, sourceRoleSession, sourceConversation }, + pinnedManifest, + nextSeed, +}): Promise; +``` + +Only this selector reads `modeFor(from,to)`. It is called neither for rejected events nor by `handoff`/`end` tools. The reducer remains transport-agnostic. + +* **Fresh / undeclared edge:** return the existing new-session spawn plan unchanged, without a new selector record. Keep the existing handoff payload seed and optional bounded `handoff_context` reference exactly as today. +* **Trajectory edge:** preflight supported workspace/custom-tool conditions, resolve the target model and complete target role environment, and perform §5 admission before the source lifecycle terminal; after that normal source terminal, append `handoff_transport_selected` and return a plan whose target role reuses the source physical conversation. + +The old `spawnRole` fresh path remains the default branch—not a reimplementation behind a common abstraction. The new branch may use a narrowly additive `resumeTrajectoryRole` helper. This is the guard against accidental fresh-path behavior drift. + +## 4. Lifecycle, identity, usage, and resume seam + +### 4.1 Two identities + +A conductor role invocation and a Pi conversation are different objects. New lifecycle records add fields without removing `session_file`: + +```ts +role_session_id: string; // host-minted, unique per conductor invocation +conversation_id: string | null; // Pi sessionId; null only for legacy/synthetic records +``` + +`Checkpoint.active_role_session.id` and `parent_session` become `role_session_id` values for new runs. `session_file` remains the physical Pi JSONL file. A trajectory chain has separate role session IDs and `session_started/session_ended` pairs but the same non-null `conversation_id` and session file. Fresh successors have a new conversation identity/file as before. Legacy records retain their old identity semantics and remain readable. + +### 4.2 Usage and observability + +Every role invocation starts a new host `SessionState` and event subscription even when it reuses an SDK session. Its terminal usage is only messages received after that role invocation started; source usage is not recharged to the target. The selector and new records expose: + +* `handoff_transport_selected` mode, from/to, target model/effort, environment hash, and admission inputs/result; +* `trajectory_handoff_failed` with stable typed code, message, source conversation identity, and no target lifecycle record; +* `role_session_id`, `conversation_id`, and unchanged `session_file` on new lifecycle records; +* existing per-role/per-model/run cost rollups, now keyed by role invocation deltas, not physical conversation totals. + +The source still produces normal `session_ended`; the target still produces normal `session_started`. A trajectory chain must therefore display the same lifecycle boundaries as a fresh chain while revealing its shared conversation identity. + +### 4.3 Environment replacement order + +The host must run only while the source session is idle and sealed. For a trajectory candidate: + +1. Build an uncommitted target environment from the pinned snapshot: concrete model, requested effort, fully resolved target prompt, exact active allowlist, serialized active tool definitions, and rebindable conductor tool state. Validate every named target tool is in the session registry. Run §5 admission without mutating the native session. +2. Persist source `session_ended` and its cleared checkpoint exactly as the existing accepted path requires; retain (do not yet dispose) the idle physical `AgentSession`. If preflight failed, append `trajectory_handoff_failed` now and stop. +3. Append the selected transport record only after a passed preflight. +4. Replace the host's role binding for `handoff`, `end`, and `ask_user`; install a fresh capture buffer and a fresh event/usage subscription. The machine tools are stable host bridges whose execution delegates to the current binding, not source-role closures. +5. `await session.setModel(targetModel)`, call `session.setThinkingLevel(targetEffort)`, and assert the effective model and effort equal the target plan. Then call `session.setActiveToolsByName(targetAllowlist)` and assert `getActiveToolNames()` equals the allowlist exactly (including no source custom tool). The trajectory target allowlist is target-declared tools plus the three conductor tools; unlike the unchanged fresh path, it does not add the predecessor-only `handoff_context` tool. +6. Arm the role-environment controller, installed when the shared session was created, to return the persisted target system prompt from public `before_agent_start`; only then emit target `session_started` and call the target `prompt`. + +The controller must replace the prompt value, not concatenate it. The spike's provider observed exact target instructions; it did not inspect a declaration alone. A trajectory receiver runs only `models[0]`: it does not use the existing same-model retry or fresh-session fallback machinery, because either can change the exact continuation semantics. A target model error is an ordinary typed `session_failed("model_error")` with no fallback; a later design may add an empirically proven same-conversation retry policy. + +If any step through 5 fails, no target prompt or compaction may occur. Detach the tentative target subscription, invalidate its capture binding, dispose the idle SDK object only after its persisted JSONL remains available, append `trajectory_handoff_failed`, and return a typed `TrajectoryHandoffError` / `session_failed` outcome. Do not spawn a fresh target, append a summary, truncate, compact, or retry with another model. A selector record followed by no target start resumes to the same typed failure rather than attempting a new transport. + +### 4.4 Workspace and custom-tool boundary + +Trajectory is rejected for `worktree`, `copy`, and `container` on either endpoint because 0.80.6's active built-in tools retain construction cwd. It is also rejected for current delegate/request-files bridges and any role-specific custom tool that has no approved rebindable bridge. Source custom tools may remain in Pi's internal registry, but after the exact allowlist assertion they are not model-visible or executable; the spike intentionally attempted one to prove this. + +### 4.5 Minimal durable conductor resume seam + +The SDK can reopen a durable JSONL conversation. Current conductor cannot select it on resume because `src/host/shared-sdk-role-spawn.ts` always constructs `SessionManager.create(...)`, the log has no transport record, and `src/host/api.ts` resume only reconstructs a fresh handoff reference. This matches the current session/host/resume tests (`tests/host/role-session.test.ts`, `tests/host/production-host-spawn.test.ts`, and `tests/host/resume.test.ts`), which exercise fresh role sessions and fresh crash recovery rather than a shared conversation. The minimum durable seam is: + +1. extend the persisted union/materializer with `manifest_snapshot`, `handoff_transport_selected`, and `trajectory_handoff_failed`; +2. let `resumeRun` obtain the latest selector targeting the current checkpoint role before it constructs `ProductionHost`; +3. add a trajectory-specific host spawn that calls `SessionManager.open(selected.source_conversation.file, sessionDir, cwd)`, rehydrates the target environment from the persisted record, and uses the same lifecycle/reconfiguration path; and +4. restore fresh behavior when no trajectory selector is present. + +This is smaller and safer than putting Pi conversation data in `Checkpoint`, teaching the reducer transport, or relying on Pi's session tree as conductor persistence. + +## 5. Context admission contract + +### 5.1 Inputs and calculation + +Before any target generation, the trajectory selector must obtain all of the following through public/documented APIs or host-owned data: + +1. a resolved target `Model` with finite positive `contextWindow` and finite non-negative `maxTokens`; +2. `sourceSession.getContextUsage()` with non-null `tokens` while the source active branch is un-compacted; roles with any outgoing trajectory policy start with automatic compaction disabled, and the selector still scans the branch for a manual/legacy compaction entry; +3. the target seed text; and +4. the exact target system prompt plus canonical serialized active tool definitions. + +The production calculation is: + +```text +trajectoryTokens = sourceSession.getContextUsage().tokens +roleEnvelopeTokens = estimateTokens(userMessage(targetPrompt + toolDefinitions + targetSeed)) +requiredTokens = trajectoryTokens + roleEnvelopeTokens + targetModel.maxTokens + 8192 +admit iff requiredTokens <= targetModel.contextWindow +``` + +`estimateTokens` is a public 0.80.6 export whose local declaration identifies its chars/4 estimate as conservative (`dist/core/compaction/compaction.d.ts`, lines 46–59). The fixed 8,192-token host safety reservation is deliberately pessimistic for provider framing and tokenizer variance. The persisted `TrajectoryAdmission` includes each input, the safety reservation, `requiredTokens`, target window, and the target model ID. The admission formula is versioned with `schema_version: 1`; changing it requires a new selector schema, not reinterpretation of old runs. + +### 5.2 Typed failures and invariants + +Admission returns exactly one failure in this precedence order: invalid/unavailable target metadata or effort; detected compaction; unknown Pi context estimate; then arithmetic overflow. This makes an already-compacted unknown session consistently report `trajectory_history_compacted`, not whichever secondary condition happens to be checked first. + +| Failure | Typed code | Required outcome | +| --- | --- | --- | +| Target model missing/non-finite metadata, unavailable, or unsupported exact effort | `trajectory_context_metadata_unknown` / `trajectory_target_environment_invalid` | No target generation; terminal host failure. | +| `getContextUsage()` is undefined or `tokens === null` | `trajectory_context_unknown` | No target generation; terminal host failure. | +| Any compaction entry exists on the active source branch | `trajectory_history_compacted` | No target generation; full trajectory is already unavailable. | +| Calculated requirement exceeds target window | `trajectory_context_too_large` | No target generation; terminal host failure. | +| Source/target workspace or custom tool cannot be rebound | `trajectory_environment_unsupported` | No target generation; terminal host failure. | + +Invariant: trajectory code never calls `compact`, never enables auto-compaction for that role invocation, never removes/summarizes messages, never truncates context, and never falls back to `fresh`. A failure must be visible in the run log and stats. The only way to get fresh behavior is an explicitly fresh/undeclared edge in the pinned manifest. + +## 6. Test-first implementation slices and ownership + +| Order | Test first | Implementation ownership | Acceptance | +| --- | --- | --- | --- | +| 1 | `tests/manifest/issue-63.test.ts`: absent policy/default fresh; explicit modes; malformed syntax; undeclared from/to; duplicate; self; illegal hub edge; workspace/custom/model restrictions | `src/manifest/types.ts`, `parse.ts`, `validate.ts`; no Pi imports | Table-driven validation produces exact codes and existing manifests still parse identically. | +| 2 | `tests/persistence/trajectory-records.test.ts`: materialization, canonical snapshot hash, legacy log acceptance, corrupt snapshot rejection | `src/persistence/log.ts`, `record-materialization.ts`, new record helper | New records append/read; legacy records have fresh-only interpretation. | +| 3 | `tests/host/trajectory-selector.test.ts`: reducer accepted first; one selector per accepted handoff; ordinary/undeclared fresh calls the existing spawn/seed path byte-for-byte | `src/host/host.ts`, `loop.ts`, `api.ts` | Reducer remains transport-blind and fresh parity stays green. | +| 4 | `tests/host/trajectory-environment.test.ts`: real stub provider proves source tool result visible after model swap; exact target prompt; exact target active tools; attempted stale custom tool cannot execute; role usage delta; target model error has no fresh fallback | new `src/host/trajectory-session.ts`, narrow adapter changes in `role-session.ts` / `shared-sdk-role-spawn.ts` | Only public 0.80.6 APIs and rebindable host tools are used. | +| 5 | `tests/host/trajectory-admission.test.ts`: pass, null estimate, invalid metadata, compacted history, too-large; each has one code and zero target prompts/compactions/fresh fallbacks | `src/host/trajectory-admission.ts`, `errors.ts` | Table-driven conservative admission and cleanup invariants hold. | +| 6 | `tests/host/trajectory-resume.test.ts`: crash after selector-before-start and after target start; same session ID/file/trajectory after reopen; historical fresh resume unchanged | `src/host/api.ts`, `production-host.ts`, `log-file.ts` | Durable selector restores same conversation deterministically or returns persisted typed failure. | +| 7 | Existing host/manifest/resume suites plus new E2E planner → orchestrator → implementer chain with two trajectory edges and later fresh edge | only integration wiring required by prior slices | Separate role lifecycle records, shared conversation only over selected edges, fresh later edge starts a new file. | + +Do not edit `src/core` for transport. Do not add a Pi import to `src/manifest`, `src/persistence`, `src/seam`, or `src/cost`. No README/package/conductor-config change belongs to this issue unless separately approved. + +## 7. Verification, rollout, and rollback + +### Required verification + +```bash +node --max-old-space-size=1024 docs/issue-63-trajectory-handoffs/sdk-feasibility-spike.mjs +pnpm typecheck +pnpm build +pnpm test +pnpm lint +pnpm format:check +pnpm audit +``` + +The implementation gate also includes the full host/session/manifest/resume test suites named in §6, a grep-guard pass, and a manual no-key stub E2E that verifies no automatic compaction/fresh fallback on each admission failure. Re-run the spike whenever the resolved `@earendil-works/pi-coding-agent` version changes; record the CLI/runtime versions separately. + +### Rollout + +1. Ship the parser and snapshot records with no `handoffs` entries in shipped/default manifests: behavior remains fresh. +2. Enable only a shared-workspace, explicit-model planner → orchestrator edge in a controlled run; observe selector records, admission failures, per-role cost delta, and shared `conversation_id`. +3. Enable the second orchestrator → implementer edge only after step 2 proves exact tool/result preservation. +4. Compare Issue #63’s stated experiment cohorts: fresh versus exactly the two selected trajectory edges. Track completion/pass rate, target exploration/tool calls, wall time, prefill/input tokens, admission failures, context growth, and total cost. + +### Rollback + +Remove or change selected manifest entries to `mode: fresh` and start a new run. In-flight runs retain their snapshot and are never reinterpreted. If a runtime defect appears, disable trajectory admission at the host feature gate so new trajectory selections fail with `trajectory_environment_unsupported`; never silently route those runs fresh. Existing fresh runs require no migration. + +## Fixed decisions + +* The syntax is `handoffs: [{ from, to, mode }]`; absent edges are fresh. +* Only declared legal hub-and-spoke edges may be configured; duplicate, undeclared, illegal, and self edges are hard validation errors. +* Transport is selected once, host-side, only after an accepted reduction. The reducer does not know Pi conversations. +* A durable normalized manifest snapshot plus a per-handoff selector/environment record pins policy and supports resume; legacy logs remain fresh-only. +* MVP trajectory is shared-workspace, explicit-target-model, explicit-target-system-prompt, no delegated/progressive custom bridge only. +* Role invocations are distinct from Pi conversations. Lifecycle/usage records use separate logical role-session and physical conversation identities. +* Admission is conservative and fail-closed. There is no compaction, truncation, summary substitution, or fresh fallback. + +## Acknowledgement record + +| Acknowledged by | Date | Notes | +| --- | --- | --- | +| Overseer | 2026-08-28 | Explicit ACK received for the quoted acknowledgement request; implementation authorized. | + +**Acknowledgement requested:** “I acknowledge this Issue #63 contract: per-edge `handoffs` defaults to fresh; the 0.80.6 MVP is shared-workspace and explicit-model only; trajectory admission is fail-closed with no compaction/truncation/fresh fallback; and implementation may proceed only through the pinned-snapshot, host-owned selector and durable resume seam described above.” From d878552019b72512f9aec8846246870bd6e4e215 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 04:08:20 +0000 Subject: [PATCH 02/20] feat: validate trajectory handoff policies --- src/manifest/handoffs.ts | 13 ++++ src/manifest/parse.ts | 38 +++++++++++ src/manifest/types.ts | 12 ++++ src/manifest/validate.ts | 111 +++++++++++++++++++++++++++++++- tests/manifest/issue-63.test.ts | 78 ++++++++++++++++++++++ 5 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/manifest/handoffs.ts create mode 100644 tests/manifest/issue-63.test.ts diff --git a/src/manifest/handoffs.ts b/src/manifest/handoffs.ts new file mode 100644 index 00000000..7c10d852 --- /dev/null +++ b/src/manifest/handoffs.ts @@ -0,0 +1,13 @@ +/** Issue #63 handoff-policy lookup. This module remains pure and Pi-free. */ + +import type { Role } from "../core/types.js"; +import type { HandoffMode, HandoffPolicy } from "./types.js"; + +/** Select the pinned transport mode for an accepted directed edge. */ +export function modeFor( + policies: readonly HandoffPolicy[] | undefined, + from: Role, + to: Role, +): HandoffMode { + return policies?.find((policy) => policy.from === from && policy.to === to)?.mode ?? "fresh"; +} diff --git a/src/manifest/parse.ts b/src/manifest/parse.ts index 998de53b..9e7fd960 100644 --- a/src/manifest/parse.ts +++ b/src/manifest/parse.ts @@ -30,6 +30,8 @@ import { parseSubagentWorkspace } from "./subagent-projection.js"; import type { ArtifactConfig, DelegationPolicy, + HandoffMode, + HandoffPolicy, Manifest, ModelConfig, ProgressiveDisclosurePolicy, @@ -88,6 +90,7 @@ export function parseManifestFromObject(raw: unknown): Manifest { const subagentsRaw = obj.subagents; const subagents = subagentsRaw !== undefined ? parseSubagentProfiles(subagentsRaw) : undefined; + const handoffs = obj.handoffs === undefined ? undefined : parseHandoffs(obj.handoffs); const endRequestRolesRaw = obj.end_request_roles; const end_request_roles = endRequestRolesRaw !== undefined @@ -97,11 +100,46 @@ export function parseManifestFromObject(raw: unknown): Manifest { return Object.freeze({ version, ...(end_request_roles !== undefined && { end_request_roles }), + ...(handoffs !== undefined && { handoffs }), roles: Object.freeze(roles), ...(subagents !== undefined && { subagents: Object.freeze(subagents) }), }) as Manifest; } +// ─── Issue #63 handoff policy parsing ─────────────────────────────────── + +const HANDOFF_KEYS = new Set(["from", "to", "mode"]); + +function parseHandoffs(raw: unknown): readonly HandoffPolicy[] { + if (!Array.isArray(raw)) { + throw new ManifestParseError("`handoffs:` must be an array"); + } + const policies = raw.map((entry, index) => parseHandoffPolicy(entry, index)); + return Object.freeze(policies); +} + +function parseHandoffPolicy(raw: unknown, index: number): HandoffPolicy { + const path = `handoffs[${index}]`; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new ManifestParseError(`${path} must be a YAML mapping (object)`); + } + const entry = raw as Record; + for (const key of Object.keys(entry)) { + if (!HANDOFF_KEYS.has(key)) { + throw new ManifestParseError(`${path} has unknown key '${key}'`); + } + } + const mode = entry.mode; + if (mode !== "fresh" && mode !== "trajectory") { + throw new ManifestParseError(`${path}.mode must be \"fresh\" or \"trajectory\"`); + } + return Object.freeze({ + from: toNonEmptyString(entry.from, `${path}.from`), + to: toNonEmptyString(entry.to, `${path}.to`), + mode: mode as HandoffMode, + }) as HandoffPolicy; +} + // ─── Delegation lite §3: subagent profile parsing ─────────────────────── function parseSubagentProfiles(raw: unknown): SubagentProfile[] { diff --git a/src/manifest/types.ts b/src/manifest/types.ts index f892491a..9ad6d994 100644 --- a/src/manifest/types.ts +++ b/src/manifest/types.ts @@ -84,11 +84,23 @@ export interface ModelConfig { /** * §8 / §10 / delegation lite §3: raw manifest shape parsed from `.pi/conductor.yaml`. */ +/** Host-selected transport mode for an accepted legal handoff (Issue #63). */ +export type HandoffMode = "fresh" | "trajectory"; + +/** One optional transport policy; the reducer never receives this configuration. */ +export interface HandoffPolicy { + readonly from: Role; + readonly to: Role; + readonly mode: HandoffMode; +} + export interface Manifest { /** §10: human-bumped integer, pinned at run-start, never mutated. */ readonly version: number; /** Authorized worker roles that may request completion through handoff. */ readonly end_request_roles?: readonly Role[]; + /** Optional host-only transport policies. Omitted policies are fresh (Issue #63). */ + readonly handoffs?: readonly HandoffPolicy[]; readonly roles: readonly RoleConfig[]; /** Delegation lite §3: optional subagent profile declarations. */ readonly subagents?: readonly SubagentProfile[]; diff --git a/src/manifest/validate.ts b/src/manifest/validate.ts index 91ee889c..df1c938c 100644 --- a/src/manifest/validate.ts +++ b/src/manifest/validate.ts @@ -56,7 +56,25 @@ export type ManifestErrorCode = /** Delegation lite §3.3: `allowed_subagents` is empty. */ | "delegation-empty-allowed-subagents" /** Delegation lite §3.2: `allowed_subagents` contains duplicates. */ - | "delegation-duplicate-allowed-subagent"; + | "delegation-duplicate-allowed-subagent" + /** Issue #63: policy source does not name a declared role. */ + | "handoff-policy-from-undeclared" + /** Issue #63: policy target does not name a declared role. */ + | "handoff-policy-to-undeclared" + /** Issue #63: transport policy cannot target its source. */ + | "handoff-policy-self-edge" + /** Issue #63: one directed policy may appear only once. */ + | "handoff-policy-duplicate-edge" + /** Issue #63: policy must follow the pinned hub-and-spoke edge. */ + | "handoff-policy-illegal-edge" + /** Issue #63: trajectory cannot rebind a non-shared workspace. */ + | "trajectory-workspace-unsupported" + /** Issue #63: trajectory cannot carry current delegation/projection bridges. */ + | "trajectory-custom-tool-unsupported" + /** Issue #63: target trajectory environment requires a selected model. */ + | "trajectory-target-model-unresolved" + /** Issue #63: target trajectory environment requires explicit instructions. */ + | "trajectory-target-system-prompt-unresolved"; export type ManifestWarningCode = /** `max_session_cost_usd` set but `models:` has no fallback (§13). */ @@ -184,6 +202,7 @@ export function validateManifest(m: Manifest): ManifestReport { // ─── Delegation lite §3: collect role and subagent names ─────────── const roleNames = new Set(m.roles.map((r) => r.name)); + validateHandoffPolicies(m, roleNames, orchestrators, errors); if (m.end_request_roles !== undefined) { if (m.end_request_roles.length === 0) { @@ -507,6 +526,96 @@ export function validateManifest(m: Manifest): ManifestReport { }; } +function validateHandoffPolicies( + manifest: Manifest, + roleNames: ReadonlySet, + orchestrators: readonly { readonly name: Role }[], + errors: ManifestError[], +): void { + const seen = new Set(); + const orchestratorNames = new Set(orchestrators.map((role) => role.name)); + for (const policy of manifest.handoffs ?? []) { + const fromDeclared = roleNames.has(policy.from); + const toDeclared = roleNames.has(policy.to); + if (!fromDeclared) { + errors.push({ + code: "handoff-policy-from-undeclared", + message: `handoff policy source '${policy.from}' is not a declared role`, + }); + } + if (!toDeclared) { + errors.push({ + code: "handoff-policy-to-undeclared", + message: `handoff policy target '${policy.to}' is not a declared role`, + }); + } + if (policy.from === policy.to) { + errors.push({ + code: "handoff-policy-self-edge", + message: `handoff policy '${policy.from}' → '${policy.to}' is a forbidden self edge`, + }); + } + const edge = `${policy.from}\u0000${policy.to}`; + if (seen.has(edge)) { + errors.push({ + code: "handoff-policy-duplicate-edge", + message: `handoff policy '${policy.from}' → '${policy.to}' is declared more than once`, + }); + } + seen.add(edge); + + if (!fromDeclared || !toDeclared || policy.from === policy.to) continue; + const legal = + (orchestratorNames.has(policy.from) && !orchestratorNames.has(policy.to)) || + (!orchestratorNames.has(policy.from) && orchestratorNames.has(policy.to)); + if (!legal) { + errors.push({ + code: "handoff-policy-illegal-edge", + message: `handoff policy '${policy.from}' → '${policy.to}' is not a legal hub-and-spoke edge`, + }); + continue; + } + if (policy.mode !== "trajectory") continue; + + const source = manifest.roles.find((role) => role.name === policy.from); + const target = manifest.roles.find((role) => role.name === policy.to); + if (source === undefined || target === undefined) continue; + if ((source.workspace?.backend ?? "shared") !== "shared" || (target.workspace?.backend ?? "shared") !== "shared") { + errors.push({ + code: "trajectory-workspace-unsupported", + message: `trajectory policy '${policy.from}' → '${policy.to}' requires shared workspaces`, + }); + } + if (hasTrajectoryUnsupportedTools(source) || hasTrajectoryUnsupportedTools(target)) { + errors.push({ + code: "trajectory-custom-tool-unsupported", + message: `trajectory policy '${policy.from}' → '${policy.to}' cannot use delegation or progressive tool bridges`, + }); + } + if (target.models === undefined || target.models.length === 0) { + errors.push({ + code: "trajectory-target-model-unresolved", + message: `trajectory target '${policy.to}' must declare at least one model`, + }); + } + if (target.system_prompt === undefined) { + errors.push({ + code: "trajectory-target-system-prompt-unresolved", + message: `trajectory target '${policy.to}' must declare system_prompt`, + }); + } + } +} + +function hasTrajectoryUnsupportedTools(role: Manifest["roles"][number]): boolean { + return ( + role.delegation !== undefined || + role.workspace?.progressive_disclosure !== undefined || + role.tools?.includes("delegate") === true || + role.tools?.includes("request_files") === true + ); +} + function validateProgressiveDisclosurePaths( paths: readonly string[], field: "initial_paths" | "allowed_paths", diff --git a/tests/manifest/issue-63.test.ts b/tests/manifest/issue-63.test.ts new file mode 100644 index 00000000..6238ebfa --- /dev/null +++ b/tests/manifest/issue-63.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { modeFor } from "../../src/manifest/handoffs.js"; +import { parseManifest } from "../../src/manifest/parse.js"; +import { ManifestParseError } from "../../src/manifest/types.js"; +import { validateManifest } from "../../src/manifest/validate.js"; + +const VALID = ` +version: 1 +handoffs: + - from: planner + to: orchestrator + mode: trajectory + - from: orchestrator + to: implementer + mode: fresh +roles: + - name: orchestrator + is_orchestrator: true + models: [{ model: stub:orchestrator, effort: medium }] + system_prompt: .pi/roles/orchestrator.md + - name: planner + max_visits: 1 + models: [{ model: stub:planner, effort: medium }] + system_prompt: .pi/roles/planner.md + - name: implementer + max_visits: 1 + models: [{ model: stub:implementer, effort: high }] + system_prompt: .pi/roles/implementer.md +`; + +describe("Issue #63 manifest handoff policies", () => { + it("defaults absent and undeclared policies to fresh", () => { + expect(modeFor(parseManifest(VALID).handoffs, "planner", "orchestrator")).toBe("trajectory"); + expect(modeFor(parseManifest(VALID).handoffs, "implementer", "orchestrator")).toBe("fresh"); + expect(modeFor(parseManifest("version: 1\nroles: []").handoffs, "a", "b")).toBe("fresh"); + }); + + it("parses frozen explicit policies", () => { + const handoffs = parseManifest(VALID).handoffs; + expect(handoffs).toEqual([ + { from: "planner", to: "orchestrator", mode: "trajectory" }, + { from: "orchestrator", to: "implementer", mode: "fresh" }, + ]); + expect(Object.isFrozen(handoffs)).toBe(true); + }); + + it.each([ + ["not a sequence", "handoffs: planner", "handoffs"], + ["not a mapping", "handoffs: [planner]", "handoffs[0]"], + ["missing mode", "handoffs: [{ from: planner, to: orchestrator }]", "mode"], + ["invalid mode", "handoffs: [{ from: planner, to: orchestrator, mode: automatic }]", "mode"], + ["unknown key", "handoffs: [{ from: planner, to: orchestrator, mode: fresh, extra: no }]", "unknown key"], + ])("rejects malformed policy syntax: %s", (_name, handoffs, expected) => { + expect(() => parseManifest(`version: 1\n${handoffs}\nroles: []`)).toThrow(ManifestParseError); + expect(() => parseManifest(`version: 1\n${handoffs}\nroles: []`)).toThrow(expected); + }); + + it.each([ + ["undeclared endpoints", `[{ from: ghost, to: phantom, mode: fresh }]`, ["handoff-policy-from-undeclared", "handoff-policy-to-undeclared"]], + ["duplicate directed edge", `[{ from: planner, to: orchestrator, mode: fresh }, { from: planner, to: orchestrator, mode: trajectory }]`, ["handoff-policy-duplicate-edge"]], + ["self edge", `[{ from: planner, to: planner, mode: fresh }]`, ["handoff-policy-self-edge"]], + ["worker to worker edge", `[{ from: planner, to: implementer, mode: fresh }]`, ["handoff-policy-illegal-edge"]], + ["trajectory isolated workspace", `[{ from: planner, to: orchestrator, mode: trajectory }]`, ["trajectory-workspace-unsupported"]], + ["trajectory delegation tool", `[{ from: planner, to: orchestrator, mode: trajectory }]`, ["trajectory-custom-tool-unsupported"]], + ["trajectory target without model", `[{ from: planner, to: orchestrator, mode: trajectory }]`, ["trajectory-target-model-unresolved"]], + ["trajectory target without prompt", `[{ from: planner, to: orchestrator, mode: trajectory }]`, ["trajectory-target-system-prompt-unresolved"]], + ] as const)("reports exact static policy errors: %s", (_name, handoffs, expected) => { + let yaml = VALID.replace(/handoffs:[\s\S]*?roles:/, `handoffs: ${handoffs}\nroles:`); + if (_name === "trajectory isolated workspace") yaml = yaml.replace("name: planner\n max_visits", "name: planner\n workspace: { backend: worktree }\n max_visits"); + if (_name === "trajectory delegation tool") yaml = yaml.replace("system_prompt: .pi/roles/planner.md", "system_prompt: .pi/roles/planner.md\n tools: [delegate]\n delegation: { allowed_subagents: [child], max_children_per_session: 1, max_parallel: 1 }"); + if (_name === "trajectory target without model") yaml = yaml.replace(" models: [{ model: stub:orchestrator, effort: medium }]\n", ""); + if (_name === "trajectory target without prompt") yaml = yaml.replace(" system_prompt: .pi/roles/orchestrator.md\n", ""); + expect(validateManifest(parseManifest(yaml)).errors.map((error) => error.code)).toEqual( + expect.arrayContaining([...expected]), + ); + }); +}); From fa8f7d03d5e30f31878939f0272aa4d2a9748dfd Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 04:09:44 +0000 Subject: [PATCH 03/20] feat: persist trajectory transport records --- src/host/log-file.ts | 3 + src/persistence/log.ts | 10 +- src/persistence/record-materialization.ts | 5 + src/persistence/trajectory-records.ts | 137 +++++++++++++++++++ tests/persistence/trajectory-records.test.ts | 37 +++++ 5 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/persistence/trajectory-records.ts create mode 100644 tests/persistence/trajectory-records.test.ts diff --git a/src/host/log-file.ts b/src/host/log-file.ts index 90c889ea..32b66000 100644 --- a/src/host/log-file.ts +++ b/src/host/log-file.ts @@ -416,6 +416,9 @@ const PERSISTED_RECORD_TYPES: ReadonlySet = new Set([ "artifact_collected", "artifact_rejected", "artifact_delivery", + "manifest_snapshot", + "handoff_transport_selected", + "trajectory_handoff_failed", ]); /** Validate the parsed JSONL value's `type` discriminant before trusting it as a record. */ diff --git a/src/persistence/log.ts b/src/persistence/log.ts index 83394734..fa4abf13 100644 --- a/src/persistence/log.ts +++ b/src/persistence/log.ts @@ -46,6 +46,11 @@ import type { } from "./child-completion.js"; import type { FileMutationRecord } from "./file-mutation.js"; import { materializePersistedRecord } from "./record-materialization.js"; +import type { + HandoffTransportSelectedRecord, + ManifestSnapshotRecord, + TrajectoryHandoffFailedRecord, +} from "./trajectory-records.js"; import type { ArtifactCollectedRecord, ArtifactDeliveryRecord, @@ -304,7 +309,10 @@ export type PersistedRecord = | WorkspaceProvisionedRecord | ArtifactCollectedRecord | ArtifactRejectedRecord - | ArtifactDeliveryRecord; + | ArtifactDeliveryRecord + | ManifestSnapshotRecord + | HandoffTransportSelectedRecord + | TrajectoryHandoffFailedRecord; // ─── RecordLog interface ─────────────────────────────────────────────── diff --git a/src/persistence/record-materialization.ts b/src/persistence/record-materialization.ts index 326e9498..a77feb65 100644 --- a/src/persistence/record-materialization.ts +++ b/src/persistence/record-materialization.ts @@ -1,6 +1,7 @@ /** Canonical JSON materialization and workspace-guarantee checks for persisted records. */ import type { WorkspaceGuarantee } from "../core/types.js"; +import { verifyManifestSnapshot, type ManifestSnapshotRecord } from "./trajectory-records.js"; /** Typed rejection of an unavailable workspace guarantee at the persistence boundary. */ export class WorkspaceGuaranteeError extends Error { @@ -41,6 +42,10 @@ export function assertPersistedRecordGuarantees(record: unknown): void { if (!isRecord(record)) return; + if (record.type === "manifest_snapshot") { + verifyManifestSnapshot(record as unknown as ManifestSnapshotRecord); + } + if (record.type === "workspace_provisioned") { assertWorkspaceGuarantee(record.guarantee); } diff --git a/src/persistence/trajectory-records.ts b/src/persistence/trajectory-records.ts new file mode 100644 index 00000000..e3df7935 --- /dev/null +++ b/src/persistence/trajectory-records.ts @@ -0,0 +1,137 @@ +/** Issue #63 durable host-owned trajectory records. No Pi runtime imports. */ + +import { createHash } from "node:crypto"; + +import type { MachineDefinition, ModelEffort, Role } from "../core/types.js"; +import type { Manifest } from "../manifest/types.js"; + +/** Typed rejection of a corrupt or unsupported pinned manifest snapshot. */ +export class ManifestSnapshotError extends Error { + constructor(message: string) { + super(message); + this.name = "ManifestSnapshotError"; + } +} + +/** Typed rejection of a trajectory selector that cannot be resumed exactly. */ +export class TrajectoryResumeError extends Error { + constructor(message: string) { + super(message); + this.name = "TrajectoryResumeError"; + } +} + +/** Exact admission inputs retained with a trajectory selection (Issue #63 §5). */ +export interface TrajectoryAdmission { + readonly schema_version: 1; + readonly observed_context_tokens: number; + readonly role_envelope_tokens: number; + readonly target_max_tokens: number; + readonly safety_reservation_tokens: 8192; + readonly required_tokens: number; + readonly target_context_window: number; + readonly target_model: string; +} + +/** Immutable normalized manifest snapshot used instead of re-reading YAML on resume. */ +export interface ManifestSnapshotRecord { + readonly type: "manifest_snapshot"; + readonly schema_version: 1; + readonly run_id: string; + readonly manifest_version: string; + readonly normalized_manifest: Manifest; + readonly definition: MachineDefinition; + readonly sha256: string; + readonly ts: number; +} + +/** Persisted selection for one successfully admitted trajectory handoff. */ +export interface HandoffTransportSelectedRecord { + readonly type: "handoff_transport_selected"; + readonly schema_version: 1; + readonly run_id: string; + readonly source_role_session_id: string; + readonly from: Role; + readonly to: Role; + readonly mode: "trajectory"; + readonly source_conversation: { readonly id: string; readonly file: string }; + readonly target: { + readonly model: string; + readonly requested_effort: ModelEffort; + readonly system_prompt: string; + readonly active_tool_names: readonly string[]; + readonly environment_sha256: string; + }; + readonly admission: TrajectoryAdmission; + readonly ts: number; +} + +/** Observable fail-closed result for a trajectory preflight/reconfiguration failure. */ +export interface TrajectoryHandoffFailedRecord { + readonly type: "trajectory_handoff_failed"; + readonly schema_version: 1; + readonly run_id: string; + readonly from: Role; + readonly to: Role; + readonly source_conversation: { readonly id: string; readonly file: string }; + readonly code: string; + readonly message: string; + readonly ts: number; +} + +/** Create the canonical manifest snapshot written before a policy-bearing run spawns. */ +export function createManifestSnapshot(args: { + readonly runId: string; + readonly manifest: Manifest; + readonly definition: MachineDefinition; + readonly ts: number; +}): ManifestSnapshotRecord { + const source = { + schema_version: 1, + normalized_manifest: args.manifest, + definition: args.definition, + } as const; + return Object.freeze({ + type: "manifest_snapshot", + schema_version: 1, + run_id: args.runId, + manifest_version: args.definition.manifest_version, + normalized_manifest: args.manifest, + definition: args.definition, + sha256: sha256Canonical(source), + ts: args.ts, + }) as ManifestSnapshotRecord; +} + +/** Verify that a persisted snapshot's hash and pinned definition agree. */ +export function verifyManifestSnapshot(record: ManifestSnapshotRecord): ManifestSnapshotRecord { + if (record.schema_version !== 1) { + throw new ManifestSnapshotError(`unsupported manifest snapshot schema ${record.schema_version}`); + } + if (record.manifest_version !== record.definition.manifest_version) { + throw new ManifestSnapshotError("manifest snapshot version does not match its pinned definition"); + } + const expected = sha256Canonical({ + schema_version: record.schema_version, + normalized_manifest: record.normalized_manifest, + definition: record.definition, + }); + if (record.sha256 !== expected) { + throw new ManifestSnapshotError("manifest snapshot sha256 does not match canonical content"); + } + return record; +} + +/** Deterministic JSON hash independent of object insertion order. */ +export function sha256Canonical(value: unknown): string { + return createHash("sha256").update(canonicalJson(value)).digest("hex"); +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const entries = Object.entries(value as Record).sort(([left], [right]) => + left.localeCompare(right), + ); + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`; +} diff --git a/tests/persistence/trajectory-records.test.ts b/tests/persistence/trajectory-records.test.ts new file mode 100644 index 00000000..e2e8a080 --- /dev/null +++ b/tests/persistence/trajectory-records.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { toMachineDefinition } from "../../src/manifest/definition.js"; +import { parseManifest } from "../../src/manifest/parse.js"; +import { + createManifestSnapshot, + ManifestSnapshotError, + verifyManifestSnapshot, +} from "../../src/persistence/trajectory-records.js"; + +const manifest = parseManifest(` +version: 1 +handoffs: [{ from: planner, to: orchestrator, mode: trajectory }] +roles: + - name: orchestrator + is_orchestrator: true + models: [{ model: stub:orchestrator, effort: medium }] + system_prompt: .pi/roles/orchestrator.md + - name: planner + max_visits: 1 + models: [{ model: stub:planner, effort: medium }] + system_prompt: .pi/roles/planner.md +`); + +describe("Issue #63 trajectory persistence records", () => { + it("creates a canonical, hash-verified normalized manifest snapshot", () => { + const snapshot = createManifestSnapshot({ runId: "run-63", manifest, definition: toMachineDefinition(manifest), ts: 1 }); + expect(snapshot.type).toBe("manifest_snapshot"); + expect(snapshot.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(verifyManifestSnapshot(snapshot)).toEqual(snapshot); + }); + + it("rejects a corrupt manifest snapshot instead of guessing a current manifest", () => { + const snapshot = createManifestSnapshot({ runId: "run-63", manifest, definition: toMachineDefinition(manifest), ts: 1 }); + expect(() => verifyManifestSnapshot({ ...snapshot, sha256: "0".repeat(64) })).toThrow(ManifestSnapshotError); + }); +}); From 73ca5a2c1c5148bc01b2eb8f0011e7c98b1854e8 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 04:11:17 +0000 Subject: [PATCH 04/20] feat: pin trajectory manifests for resume --- src/host/api.ts | 56 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/src/host/api.ts b/src/host/api.ts index 3277f437..0140ffa6 100644 --- a/src/host/api.ts +++ b/src/host/api.ts @@ -60,6 +60,7 @@ import { join } from "node:path"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { createInitialCheckpoint } from "../core/reduce.js"; +import { toMachineDefinition } from "../manifest/definition.js"; import { reduceLifecycle } from "../core/reduce-lifecycle.js"; import type { Checkpoint, @@ -77,6 +78,11 @@ import type { RunContextRecord, RunSeededRecord, } from "../persistence/log.js"; +import { + createManifestSnapshot, + verifyManifestSnapshot, + type ManifestSnapshotRecord, +} from "../persistence/trajectory-records.js"; import type { Host } from "./host.js"; import { FileRecordLog, type RunExecutionLease } from "./log-file.js"; import { runLoop } from "./loop.js"; @@ -172,6 +178,19 @@ export async function startRun(manifestPath: string, opts: StartRunOptions): Pro const lease = await log.acquireRunLease(runId); try { + // A policy-bearing run pins normalized configuration before any role + // session exists. No policy means no new record and the legacy fresh path. + if ((loaded.manifest.handoffs?.length ?? 0) > 0) { + log.append( + createManifestSnapshot({ + runId, + manifest: loaded.manifest, + definition: def, + ts: Date.now(), + }), + ); + } + // Persist the initial checkpoint snapshot (§11.1: each transition // produces a new full snapshot). const initialSnapshot: CheckpointSnapshot = { @@ -261,15 +280,24 @@ export async function resumeRun( ); } - // The snapshot's manifest_version is the canonical link to the - // manifest that was active when the run started; a mismatch - // means the manifest was edited mid-run, which §10 forbids. - if (loaded.def.manifest_version !== checkpoint.manifest_version) { + const manifestSnapshot = latestManifestSnapshot(log.records(runId), runId); + // Snapshot-era runs take their roles and policy from durable normalized + // data. Legacy logs retain the original manifest-version check. + const resumedLoaded: LoadedManifest = + manifestSnapshot === null + ? loaded + : Object.freeze({ + ...loaded, + manifest: manifestSnapshot.normalized_manifest, + def: toMachineDefinition(manifestSnapshot.normalized_manifest), + manifestVersion: manifestSnapshot.normalized_manifest.version, + }); + if (resumedLoaded.def.manifest_version !== checkpoint.manifest_version) { throw new Error( - `resumeRun: manifest_version mismatch — snapshot pinned '${checkpoint.manifest_version}', manifest at '${manifestPath}' is '${loaded.def.manifest_version}' (§10)`, + `resumeRun: manifest_version mismatch — snapshot pinned '${checkpoint.manifest_version}', manifest at '${manifestPath}' is '${resumedLoaded.def.manifest_version}' (§10)`, ); } - const def = loaded.def; + const def = resumedLoaded.def; // Crash reconciliation (§11.1). const reconciledCheckpoint = reconcileCrash(runId, checkpoint, def, log); @@ -279,7 +307,7 @@ export async function resumeRun( reconciledCheckpoint, ); - const host = opts.hostFactory({ runId, def, log, loadedManifest: loaded }); + const host = opts.hostFactory({ runId, def, log, loadedManifest: resumedLoaded }); // Restore the original goal from the run log (if available). // Falls back to opts.goal (which may be "") for runs that @@ -294,7 +322,7 @@ export async function resumeRun( host, initialCheckpoint: reconciledCheckpoint, goal, - loadedManifest: loaded, + loadedManifest: resumedLoaded, lease, initialArtifactDelivery, }); @@ -399,6 +427,18 @@ async function runWithCompletion(args: RunWithCompletionArgs): Promise= 0; index -= 1) { + const record = records[index]; + if (record?.type !== "manifest_snapshot" || record.run_id !== runId) continue; + return verifyManifestSnapshot(record); + } + return null; +} + function latestArtifactDelivery( records: readonly PersistedRecord[], runId: string, From aa69a0038f9651cdccbc32a3403a15b7e78751cf Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 04:12:21 +0000 Subject: [PATCH 05/20] style: format trajectory handoff changes --- src/host/api.ts | 4 +- src/manifest/parse.ts | 2 +- src/manifest/validate.ts | 5 +- src/persistence/record-materialization.ts | 2 +- src/persistence/trajectory-records.ts | 8 ++- tests/manifest/issue-63.test.ts | 66 ++++++++++++++++---- tests/persistence/trajectory-records.test.ts | 18 +++++- 7 files changed, 83 insertions(+), 22 deletions(-) diff --git a/src/host/api.ts b/src/host/api.ts index 0140ffa6..938c0e81 100644 --- a/src/host/api.ts +++ b/src/host/api.ts @@ -60,7 +60,6 @@ import { join } from "node:path"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { createInitialCheckpoint } from "../core/reduce.js"; -import { toMachineDefinition } from "../manifest/definition.js"; import { reduceLifecycle } from "../core/reduce-lifecycle.js"; import type { Checkpoint, @@ -70,6 +69,7 @@ import type { SessionLifecycleEvent, } from "../core/types.js"; import { DEFAULT_MODEL_EFFORT } from "../core/types.js"; +import { toMachineDefinition } from "../manifest/definition.js"; import type { ArtifactDeliveryRecord, CheckpointSnapshot, @@ -80,8 +80,8 @@ import type { } from "../persistence/log.js"; import { createManifestSnapshot, - verifyManifestSnapshot, type ManifestSnapshotRecord, + verifyManifestSnapshot, } from "../persistence/trajectory-records.js"; import type { Host } from "./host.js"; import { FileRecordLog, type RunExecutionLease } from "./log-file.js"; diff --git a/src/manifest/parse.ts b/src/manifest/parse.ts index 9e7fd960..1c8753cc 100644 --- a/src/manifest/parse.ts +++ b/src/manifest/parse.ts @@ -131,7 +131,7 @@ function parseHandoffPolicy(raw: unknown, index: number): HandoffPolicy { } const mode = entry.mode; if (mode !== "fresh" && mode !== "trajectory") { - throw new ManifestParseError(`${path}.mode must be \"fresh\" or \"trajectory\"`); + throw new ManifestParseError(`${path}.mode must be "fresh" or "trajectory"`); } return Object.freeze({ from: toNonEmptyString(entry.from, `${path}.from`), diff --git a/src/manifest/validate.ts b/src/manifest/validate.ts index df1c938c..caa7d11f 100644 --- a/src/manifest/validate.ts +++ b/src/manifest/validate.ts @@ -580,7 +580,10 @@ function validateHandoffPolicies( const source = manifest.roles.find((role) => role.name === policy.from); const target = manifest.roles.find((role) => role.name === policy.to); if (source === undefined || target === undefined) continue; - if ((source.workspace?.backend ?? "shared") !== "shared" || (target.workspace?.backend ?? "shared") !== "shared") { + if ( + (source.workspace?.backend ?? "shared") !== "shared" || + (target.workspace?.backend ?? "shared") !== "shared" + ) { errors.push({ code: "trajectory-workspace-unsupported", message: `trajectory policy '${policy.from}' → '${policy.to}' requires shared workspaces`, diff --git a/src/persistence/record-materialization.ts b/src/persistence/record-materialization.ts index a77feb65..26dc2ce5 100644 --- a/src/persistence/record-materialization.ts +++ b/src/persistence/record-materialization.ts @@ -1,7 +1,7 @@ /** Canonical JSON materialization and workspace-guarantee checks for persisted records. */ import type { WorkspaceGuarantee } from "../core/types.js"; -import { verifyManifestSnapshot, type ManifestSnapshotRecord } from "./trajectory-records.js"; +import { type ManifestSnapshotRecord, verifyManifestSnapshot } from "./trajectory-records.js"; /** Typed rejection of an unavailable workspace guarantee at the persistence boundary. */ export class WorkspaceGuaranteeError extends Error { diff --git a/src/persistence/trajectory-records.ts b/src/persistence/trajectory-records.ts index e3df7935..06ebb18b 100644 --- a/src/persistence/trajectory-records.ts +++ b/src/persistence/trajectory-records.ts @@ -106,10 +106,14 @@ export function createManifestSnapshot(args: { /** Verify that a persisted snapshot's hash and pinned definition agree. */ export function verifyManifestSnapshot(record: ManifestSnapshotRecord): ManifestSnapshotRecord { if (record.schema_version !== 1) { - throw new ManifestSnapshotError(`unsupported manifest snapshot schema ${record.schema_version}`); + throw new ManifestSnapshotError( + `unsupported manifest snapshot schema ${record.schema_version}`, + ); } if (record.manifest_version !== record.definition.manifest_version) { - throw new ManifestSnapshotError("manifest snapshot version does not match its pinned definition"); + throw new ManifestSnapshotError( + "manifest snapshot version does not match its pinned definition", + ); } const expected = sha256Canonical({ schema_version: record.schema_version, diff --git a/tests/manifest/issue-63.test.ts b/tests/manifest/issue-63.test.ts index 6238ebfa..04685ecf 100644 --- a/tests/manifest/issue-63.test.ts +++ b/tests/manifest/issue-63.test.ts @@ -50,27 +50,69 @@ describe("Issue #63 manifest handoff policies", () => { ["not a mapping", "handoffs: [planner]", "handoffs[0]"], ["missing mode", "handoffs: [{ from: planner, to: orchestrator }]", "mode"], ["invalid mode", "handoffs: [{ from: planner, to: orchestrator, mode: automatic }]", "mode"], - ["unknown key", "handoffs: [{ from: planner, to: orchestrator, mode: fresh, extra: no }]", "unknown key"], + [ + "unknown key", + "handoffs: [{ from: planner, to: orchestrator, mode: fresh, extra: no }]", + "unknown key", + ], ])("rejects malformed policy syntax: %s", (_name, handoffs, expected) => { expect(() => parseManifest(`version: 1\n${handoffs}\nroles: []`)).toThrow(ManifestParseError); expect(() => parseManifest(`version: 1\n${handoffs}\nroles: []`)).toThrow(expected); }); it.each([ - ["undeclared endpoints", `[{ from: ghost, to: phantom, mode: fresh }]`, ["handoff-policy-from-undeclared", "handoff-policy-to-undeclared"]], - ["duplicate directed edge", `[{ from: planner, to: orchestrator, mode: fresh }, { from: planner, to: orchestrator, mode: trajectory }]`, ["handoff-policy-duplicate-edge"]], + [ + "undeclared endpoints", + `[{ from: ghost, to: phantom, mode: fresh }]`, + ["handoff-policy-from-undeclared", "handoff-policy-to-undeclared"], + ], + [ + "duplicate directed edge", + `[{ from: planner, to: orchestrator, mode: fresh }, { from: planner, to: orchestrator, mode: trajectory }]`, + ["handoff-policy-duplicate-edge"], + ], ["self edge", `[{ from: planner, to: planner, mode: fresh }]`, ["handoff-policy-self-edge"]], - ["worker to worker edge", `[{ from: planner, to: implementer, mode: fresh }]`, ["handoff-policy-illegal-edge"]], - ["trajectory isolated workspace", `[{ from: planner, to: orchestrator, mode: trajectory }]`, ["trajectory-workspace-unsupported"]], - ["trajectory delegation tool", `[{ from: planner, to: orchestrator, mode: trajectory }]`, ["trajectory-custom-tool-unsupported"]], - ["trajectory target without model", `[{ from: planner, to: orchestrator, mode: trajectory }]`, ["trajectory-target-model-unresolved"]], - ["trajectory target without prompt", `[{ from: planner, to: orchestrator, mode: trajectory }]`, ["trajectory-target-system-prompt-unresolved"]], + [ + "worker to worker edge", + `[{ from: planner, to: implementer, mode: fresh }]`, + ["handoff-policy-illegal-edge"], + ], + [ + "trajectory isolated workspace", + `[{ from: planner, to: orchestrator, mode: trajectory }]`, + ["trajectory-workspace-unsupported"], + ], + [ + "trajectory delegation tool", + `[{ from: planner, to: orchestrator, mode: trajectory }]`, + ["trajectory-custom-tool-unsupported"], + ], + [ + "trajectory target without model", + `[{ from: planner, to: orchestrator, mode: trajectory }]`, + ["trajectory-target-model-unresolved"], + ], + [ + "trajectory target without prompt", + `[{ from: planner, to: orchestrator, mode: trajectory }]`, + ["trajectory-target-system-prompt-unresolved"], + ], ] as const)("reports exact static policy errors: %s", (_name, handoffs, expected) => { let yaml = VALID.replace(/handoffs:[\s\S]*?roles:/, `handoffs: ${handoffs}\nroles:`); - if (_name === "trajectory isolated workspace") yaml = yaml.replace("name: planner\n max_visits", "name: planner\n workspace: { backend: worktree }\n max_visits"); - if (_name === "trajectory delegation tool") yaml = yaml.replace("system_prompt: .pi/roles/planner.md", "system_prompt: .pi/roles/planner.md\n tools: [delegate]\n delegation: { allowed_subagents: [child], max_children_per_session: 1, max_parallel: 1 }"); - if (_name === "trajectory target without model") yaml = yaml.replace(" models: [{ model: stub:orchestrator, effort: medium }]\n", ""); - if (_name === "trajectory target without prompt") yaml = yaml.replace(" system_prompt: .pi/roles/orchestrator.md\n", ""); + if (_name === "trajectory isolated workspace") + yaml = yaml.replace( + "name: planner\n max_visits", + "name: planner\n workspace: { backend: worktree }\n max_visits", + ); + if (_name === "trajectory delegation tool") + yaml = yaml.replace( + "system_prompt: .pi/roles/planner.md", + "system_prompt: .pi/roles/planner.md\n tools: [delegate]\n delegation: { allowed_subagents: [child], max_children_per_session: 1, max_parallel: 1 }", + ); + if (_name === "trajectory target without model") + yaml = yaml.replace(" models: [{ model: stub:orchestrator, effort: medium }]\n", ""); + if (_name === "trajectory target without prompt") + yaml = yaml.replace(" system_prompt: .pi/roles/orchestrator.md\n", ""); expect(validateManifest(parseManifest(yaml)).errors.map((error) => error.code)).toEqual( expect.arrayContaining([...expected]), ); diff --git a/tests/persistence/trajectory-records.test.ts b/tests/persistence/trajectory-records.test.ts index e2e8a080..db382801 100644 --- a/tests/persistence/trajectory-records.test.ts +++ b/tests/persistence/trajectory-records.test.ts @@ -24,14 +24,26 @@ roles: describe("Issue #63 trajectory persistence records", () => { it("creates a canonical, hash-verified normalized manifest snapshot", () => { - const snapshot = createManifestSnapshot({ runId: "run-63", manifest, definition: toMachineDefinition(manifest), ts: 1 }); + const snapshot = createManifestSnapshot({ + runId: "run-63", + manifest, + definition: toMachineDefinition(manifest), + ts: 1, + }); expect(snapshot.type).toBe("manifest_snapshot"); expect(snapshot.sha256).toMatch(/^[a-f0-9]{64}$/); expect(verifyManifestSnapshot(snapshot)).toEqual(snapshot); }); it("rejects a corrupt manifest snapshot instead of guessing a current manifest", () => { - const snapshot = createManifestSnapshot({ runId: "run-63", manifest, definition: toMachineDefinition(manifest), ts: 1 }); - expect(() => verifyManifestSnapshot({ ...snapshot, sha256: "0".repeat(64) })).toThrow(ManifestSnapshotError); + const snapshot = createManifestSnapshot({ + runId: "run-63", + manifest, + definition: toMachineDefinition(manifest), + ts: 1, + }); + expect(() => verifyManifestSnapshot({ ...snapshot, sha256: "0".repeat(64) })).toThrow( + ManifestSnapshotError, + ); }); }); From aeb9d8d7065f59e69d360fbc6b3858331bff47e7 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 04:26:23 +0000 Subject: [PATCH 06/20] feat: continue selected handoffs as trajectories --- src/host/host.ts | 41 ++++ src/host/loop.ts | 86 ++++++-- src/host/production-host.ts | 234 +++++++++++++++++++++- src/host/role-session.ts | 17 +- src/host/shared-sdk-role-spawn.ts | 131 +++++++++++- src/host/stub-provider.ts | 7 +- src/host/tools.ts | 30 +-- src/host/trajectory-admission.ts | 98 +++++++++ tests/host/trajectory-admission.test.ts | 74 +++++++ tests/host/trajectory-environment.test.ts | 135 +++++++++++++ tests/host/trajectory-resume.test.ts | 137 +++++++++++++ 11 files changed, 944 insertions(+), 46 deletions(-) create mode 100644 src/host/trajectory-admission.ts create mode 100644 tests/host/trajectory-admission.test.ts create mode 100644 tests/host/trajectory-environment.test.ts create mode 100644 tests/host/trajectory-resume.test.ts diff --git a/src/host/host.ts b/src/host/host.ts index 8011afe8..429be56b 100644 --- a/src/host/host.ts +++ b/src/host/host.ts @@ -91,6 +91,8 @@ export interface RoleSession { readonly role: Role; /** Host-allocated session id (used in `reduceLifecycle`, §11.4). */ readonly sessionId: string; + /** Physical Pi conversation identity; distinct from the host role invocation. */ + readonly conversationId?: string; /** Path to the session log file (used in `reduceLifecycle`, §11.4). */ readonly sessionFile: string; /** @@ -163,10 +165,38 @@ export interface RoleSession { * before reading the capture buffer. */ prompt(text: string): Promise; + /** + * Reconfigure this idle shared SDK conversation for an accepted trajectory + * successor. Omitted by fresh/isolated/test sessions. + */ + continueTrajectory?(options: TrajectoryContinuationOptions): Promise; + + /** Public, read-only preflight data for trajectory admission. */ + getTrajectoryContext?(): { + readonly tokens: number | null | undefined; + readonly hasCompaction: boolean; + readonly registeredToolNames: readonly string[]; + }; + + /** True when this logical invocation reuses a predecessor conversation. */ + readonly isTrajectory?: boolean; + /** Release the session's underlying resources (file handles, …). */ dispose(): Promise; } +/** Host-owned target environment supplied only after a trajectory preflight. */ +export interface TrajectoryContinuationOptions { + readonly role: Role; + readonly model: Model; + readonly logicalModel: string; + readonly effort: ModelEffort; + readonly systemPrompt: string; + readonly activeToolNames: readonly string[]; + readonly visitIndex: number; + readonly maxSessionCostUsd: number | null; +} + // ─── Spawn options ───────────────────────────────────────────────────── /** @@ -438,6 +468,17 @@ export interface Host { * the policy. */ runCostSoFar(): number; + + /** Choose host transport only after an accepted handoff; absent keeps fresh behavior. */ + selectAcceptedHandoffTransport?(args: { + readonly from: Role; + readonly to: Role; + readonly source: RoleSession; + readonly targetSeed: string; + readonly targetVisitIndex: number; + }): Promise< + { readonly mode: "fresh" } | { readonly mode: "trajectory"; readonly session: RoleSession } + >; } /** diff --git a/src/host/loop.ts b/src/host/loop.ts index 4d582870..b04b7368 100644 --- a/src/host/loop.ts +++ b/src/host/loop.ts @@ -95,6 +95,7 @@ import { validateEmission } from "../seam/validate-emission.js"; import { ArtifactCollectionError } from "./artifacts/collect.js"; import { ArtifactRoutingError, formatArtifactsUnavailableSeedSection } from "./artifacts/route.js"; import { NoMoreModelsError } from "./errors.js"; +import { TrajectoryHandoffError } from "./trajectory-admission.js"; import { formatNoEmissionRecovery } from "./handoff-contract.js"; import type { ArtifactRouteSource, @@ -223,6 +224,10 @@ export async function runLoop(opts: RunLoopOptions): Promise { // `end` while a worker is current"). Set here, consumed at the // top of the next outer iteration. let pendingForcedEnd = false; + // A selected trajectory target is already a reconfigured, idle SDK + // session. It bypasses only the next fresh spawn; every other loop path is + // unchanged. + let pendingTrajectorySession: RoleSession | null = null; // Task 18: visit_index tracking. A role's visit_index is the same // across all model retries within that visit (the role didn't // transition, it re-ran). The index is captured BEFORE the fallback @@ -356,17 +361,22 @@ export async function runLoop(opts: RunLoopOptions): Promise { // the latter propagates to abort the run per §9.4. let session: RoleSession; try { - // `spawnDefaults` is a test/host override surface, not a provenance - // surface. Remove any caller-supplied reference before adding the - // loop's trusted value so it cannot override or seed the envelope. - const spawnDefaults = { ...(opts.spawnDefaults ?? {}) }; - delete spawnDefaults.handoffContextRef; - session = await host.spawnRole(role, { - ...spawnDefaults, - visitIndex, - modelIndex, - ...(handoffContextRef !== null && { handoffContextRef }), - }); + if (pendingTrajectorySession !== null) { + session = pendingTrajectorySession; + pendingTrajectorySession = null; + } else { + // `spawnDefaults` is a test/host override surface, not a provenance + // surface. Remove any caller-supplied reference before adding the + // loop's trusted value so it cannot override or seed the envelope. + const spawnDefaults = { ...(opts.spawnDefaults ?? {}) }; + delete spawnDefaults.handoffContextRef; + session = await host.spawnRole(role, { + ...spawnDefaults, + visitIndex, + modelIndex, + ...(handoffContextRef !== null && { handoffContextRef }), + }); + } } catch (err) { if (err instanceof NoMoreModelsError) { roleOutcome = { kind: "exhausted" }; @@ -518,7 +528,13 @@ export async function runLoop(opts: RunLoopOptions): Promise { ...(session.workspace !== undefined ? { workspace: session.workspace } : {}), }); checkpoint = started.checkpoint; - host.persistRecord(started.record); + host.persistRecord({ + ...started.record, + ...(session.conversationId !== undefined && { + role_session_id: sessionId, + conversation_id: session.conversationId, + }), + }); // §11.1: each transition produces a new full checkpoint snapshot. // session_started sets active_role_session; a snapshot here is // what resumeRun reads when a run crashed mid-prompt — without @@ -543,7 +559,7 @@ export async function runLoop(opts: RunLoopOptions): Promise { model_effort: session.effort, }); checkpoint = failed.checkpoint; - host.persistRecord(failed.record); + host.persistRecord(withRoleSessionIdentity(failed.record, session)); host.persistRecord({ type: "checkpoint_snapshot", checkpoint }); await collectSessionArtifacts(host, session, { role, @@ -648,7 +664,7 @@ export async function runLoop(opts: RunLoopOptions): Promise { model_effort: session.effort, }); checkpoint = failed.checkpoint; - host.persistRecord(failed.record); + host.persistRecord(withRoleSessionIdentity(failed.record, session)); // §11.1: each transition produces a new full checkpoint // snapshot. session_failed clears active_role_session; // persist a fresh snapshot so latestCheckpoint reflects @@ -763,7 +779,7 @@ export async function runLoop(opts: RunLoopOptions): Promise { model_effort: session.effort, }); checkpoint = failed.checkpoint; - host.persistRecord(failed.record); + host.persistRecord(withRoleSessionIdentity(failed.record, session)); host.persistRecord({ type: "checkpoint_snapshot", checkpoint }); await collectSessionArtifacts(host, session, { role, @@ -922,7 +938,13 @@ export async function runLoop(opts: RunLoopOptions): Promise { model_effort: session.effort, }); checkpoint = ended.checkpoint; - host.persistRecord(ended.record); + host.persistRecord({ + ...ended.record, + ...(session.conversationId !== undefined && { + role_session_id: sessionId, + conversation_id: session.conversationId, + }), + }); // §11.1: each transition produces a new full checkpoint // snapshot. session_ended clears active_role_session; // persist a fresh snapshot so latestCheckpoint reflects @@ -958,6 +980,22 @@ export async function runLoop(opts: RunLoopOptions): Promise { handoffContextRef = acceptedContextRef; nextSeed = formatHandoffSeed(payload, nextRole, suggestsNext, acceptedContextRef); pendingArtifactRoute = acceptedArtifactRoute; + try { + const selected = await host.selectAcceptedHandoffTransport?.({ + from: role, + to: nextRole, + source: session, + targetSeed: nextSeed, + targetVisitIndex: visitIndexByRole.get(nextRole) ?? 1, + }); + if (selected?.mode === "trajectory") pendingTrajectorySession = selected.session; + } catch (error) { + if (error instanceof TrajectoryHandoffError) { + inner = { kind: "failed" }; + break; + } + throw error; + } inner = { kind: "advance", nextSeed }; break; } @@ -996,7 +1034,11 @@ export async function runLoop(opts: RunLoopOptions): Promise { // its `unavailableRole` marker, and the catch below sets // `exhausted` and breaks. State is unchanged across model // retries (same role, same `visitIndex` captured above). - if (inner.kind === "failed" && sessionHostReason === "model_error") { + if ( + inner.kind === "failed" && + sessionHostReason === "model_error" && + session.isTrajectory !== true + ) { // The failed terminal is already persisted before this branch. Do // not start another session once the run budget is exhausted; // retries and model fallback must not bypass the run cap (§11.7). @@ -1130,6 +1172,16 @@ export async function runLoop(opts: RunLoopOptions): Promise { } /** Append a host-owned artifact inventory after the role-provided handoff payload. */ +/** Add host role/conversation identities without teaching the pure lifecycle reducer transport. */ +function withRoleSessionIdentity(record: T, session: RoleSession): T { + if (session.conversationId === undefined) return record; + return { + ...record, + role_session_id: session.sessionId, + conversation_id: session.conversationId, + } as T; +} + function appendArtifactSeedSection(seed: string, artifactSeed: string): string { return `${seed}\n\n${artifactSeed}`; } diff --git a/src/host/production-host.ts b/src/host/production-host.ts index 4593f492..39884813 100644 --- a/src/host/production-host.ts +++ b/src/host/production-host.ts @@ -28,7 +28,7 @@ * untouched and remains host-agnostic. */ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { mkdirSync } from "node:fs"; import { join, resolve } from "node:path"; @@ -38,6 +38,7 @@ import { type ExtensionUIContext, getAgentDir, type ModelRegistry, + SessionManager, } from "@earendil-works/pi-coding-agent"; import type { RunMemory } from "../core/run-memory.js"; import { buildRunMemory } from "../core/run-memory.js"; @@ -49,6 +50,7 @@ import type { UsageRecord, } from "../core/types.js"; import { DEFAULT_MODEL_EFFORT } from "../core/types.js"; +import { modeFor } from "../manifest/handoffs.js"; import type { ModelConfig, RoleConfig, WorkspaceSource } from "../manifest/types.js"; import { @@ -59,12 +61,18 @@ import { type SnapshotPinnedRecord, snapshotPinned, } from "../persistence/log.js"; +import { + sha256Canonical, + TrajectoryResumeError, + type HandoffTransportSelectedRecord, +} from "../persistence/trajectory-records.js"; import { collectTerminalArtifacts as collectTerminalArtifactsFromWorkspace } from "./artifacts/lifecycle.js"; import { formatArtifactsSeedSection, materializeArtifacts } from "./artifacts/route.js"; import type { SessionState } from "./cost.js"; import { DelegationManager } from "./delegation/manager.js"; import type { DisplaySink } from "./display-sink.js"; import { NoMoreModelsError, RoleEscalationError } from "./errors.js"; +import { admitTrajectory, TrajectoryHandoffError } from "./trajectory-admission.js"; import type { ArtifactRouteSource, Host, @@ -74,7 +82,12 @@ import type { } from "./host.js"; import { spawnIsolatedRoleSession } from "./isolated-role-spawn.js"; import type { LoadedManifest } from "./manifest.js"; -import { loadSystemPrompt, resolveModel, selectModelEntry } from "./production-host-resolve.js"; +import { + buildToolsAllowlist, + loadSystemPrompt, + resolveModel, + selectModelEntry, +} from "./production-host-resolve.js"; import { notifyListeners } from "./record-emitter.js"; import { DelegateBridgeConfigError, @@ -260,6 +273,10 @@ export class ProductionHost implements Host { } const roleConfig = this.lookupRoleConfig(role); + const resumedTrajectory = this.latestTrajectorySelection(role); + if (resumedTrajectory !== null) { + return this.resumeTrajectoryRole(role, roleConfig, resumedTrajectory, opts.visitIndex ?? 1); + } const roleWorkspaceConfig = roleConfig?.workspace; const workspaceBackend = roleWorkspaceConfig?.backend ?? "shared"; if (workspaceBackend === "container") { @@ -392,6 +409,83 @@ export class ProductionHost implements Host { }); } + /** Return the selector only when it is the latest handoff artifact for this role. */ + private latestTrajectorySelection(role: Role): HandoffTransportSelectedRecord | null { + const records = this.log.records(this.runId); + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index]; + if (record?.type === "handoff_transport_selected" && record.to === role) return record; + if ( + record?.type === "transition_accepted" && + record.event === "handoff" && + record.to === role + ) { + return null; + } + } + return null; + } + + /** Reopen the selected conversation with the persisted target environment (Issue #63 §4.5). */ + private async resumeTrajectoryRole( + role: Role, + roleConfig: RoleConfig | undefined, + selected: HandoffTransportSelectedRecord, + visitIndex: number, + ): Promise { + if ( + selected.schema_version !== 1 || + selected.target.system_prompt.length === 0 || + selected.target.active_tool_names.length === 0 + ) { + throw new TrajectoryResumeError( + "trajectory selector has incomplete persisted target environment", + ); + } + const resolved = resolveModel(role, selected.target.model, this.modelRegistry); + const session = await spawnSharedSdkRoleSession({ + role, + roleConfig, + model: resolved.model, + logicalModel: selected.target.model, + effort: selected.target.requested_effort, + retries: 0, + retryDelayMs: 0, + systemPrompt: selected.target.system_prompt, + modelRegistry: this.modelRegistry, + cwd: this.cwd, + agentDir: this.agentDir, + sessionDir: this.sessionDir, + sessionManager: SessionManager.open( + selected.source_conversation.file, + this.sessionDir, + this.cwd, + ), + roleSessionId: randomUUID(), + isTrajectory: true, + runId: this.runId, + machineDefinition: this.loadedManifest.def, + delegateTool: null, + ...(this.uiContext !== undefined && { uiContext: this.uiContext }), + ...(this.isUiContextCurrent !== undefined && { + isUiContextCurrent: this.isUiContextCurrent, + }), + ...(this.displaySink !== undefined && { displaySink: this.displaySink }), + persistRecord: (record) => this.persistRecord(record), + sessionStates: this.sessionStates, + agentsBySessionId: this.agentsBySessionId, + }); + const actual = session.getTrajectoryContext?.().registeredToolNames ?? []; + if ( + actual.length === 0 || + selected.target.active_tool_names.some((name) => !actual.includes(name)) + ) { + await session.dispose(); + throw new TrajectoryResumeError("trajectory selector references unavailable target tools"); + } + return session; + } + /** Build the existing delegation operation with the caller's constrained Git base. */ private async createDelegateTool( role: Role, @@ -559,6 +653,142 @@ export class ProductionHost implements Host { return next?.model ?? null; } + /** Select and prepare a policy-declared shared-session continuation (Issue #63). */ + async selectAcceptedHandoffTransport(args: { + readonly from: Role; + readonly to: Role; + readonly source: RoleSession; + readonly targetSeed: string; + readonly targetVisitIndex: number; + }): Promise< + { readonly mode: "fresh" } | { readonly mode: "trajectory"; readonly session: RoleSession } + > { + if (modeFor(this.loadedManifest.manifest.handoffs, args.from, args.to) === "fresh") { + return { mode: "fresh" }; + } + + const sourceConversation = { + id: args.source.conversationId ?? args.source.sessionId, + file: args.source.sessionFile, + }; + try { + const sourceContext = args.source.getTrajectoryContext?.(); + if (sourceContext === undefined || args.source.continueTrajectory === undefined) { + throw new TrajectoryHandoffError( + "trajectory_environment_unsupported", + "trajectory source is not a shared SDK session with a rebindable host bridge", + ); + } + const sourceRole = this.lookupRoleConfig(args.from); + const targetRole = this.lookupRoleConfig(args.to); + if ( + (sourceRole?.workspace?.backend ?? "shared") !== "shared" || + (targetRole?.workspace?.backend ?? "shared") !== "shared" || + hasDelegateConfiguration(sourceRole) || + hasDelegateConfiguration(targetRole) || + sourceRole?.workspace?.progressive_disclosure !== undefined || + targetRole?.workspace?.progressive_disclosure !== undefined + ) { + throw new TrajectoryHandoffError( + "trajectory_environment_unsupported", + "trajectory requires shared workspaces and no role-specific custom-tool bridge", + ); + } + const modelEntry = targetRole?.models?.[0]; + if (modelEntry === undefined) { + throw new TrajectoryHandoffError( + "trajectory_target_environment_invalid", + `trajectory target '${args.to}' has no explicit model`, + ); + } + const resolved = resolveModel(args.to, modelEntry.model, this.modelRegistry); + const targetPrompt = await loadSystemPrompt( + args.to, + targetRole?.system_prompt, + this.cwd, + this.loadedManifest.manifestDir, + this.loadedManifest.manifestVersion, + ); + if (targetPrompt === null) { + throw new TrajectoryHandoffError( + "trajectory_target_environment_invalid", + `trajectory target '${args.to}' has no explicit system prompt`, + ); + } + const activeToolNames = buildToolsAllowlist(targetRole?.tools, false); + const missingTool = activeToolNames.find( + (name) => !sourceContext.registeredToolNames.includes(name), + ); + if (missingTool !== undefined) { + throw new TrajectoryHandoffError( + "trajectory_environment_unsupported", + `trajectory target tool '${missingTool}' is unavailable in the source registry`, + ); + } + const admission = admitTrajectory({ + source: sourceContext, + targetModel: resolved.model, + targetModelName: resolved.logical, + systemPrompt: targetPrompt, + activeToolNames, + targetSeed: args.targetSeed, + }); + const environmentSha = sha256Canonical({ + system_prompt: targetPrompt, + model: resolved.logical, + effort: modelEntry.effort, + active_tool_names: activeToolNames, + }); + this.persistRecord({ + type: "handoff_transport_selected", + schema_version: 1, + run_id: this.runId, + source_role_session_id: args.source.sessionId, + from: args.from, + to: args.to, + mode: "trajectory", + source_conversation: sourceConversation, + target: { + model: resolved.logical, + requested_effort: modelEntry.effort, + system_prompt: targetPrompt, + active_tool_names: activeToolNames, + environment_sha256: environmentSha, + }, + admission, + ts: Date.now(), + }); + const session = await args.source.continueTrajectory({ + role: args.to, + model: resolved.model, + logicalModel: resolved.logical, + effort: modelEntry.effort, + systemPrompt: targetPrompt, + activeToolNames, + visitIndex: args.targetVisitIndex, + maxSessionCostUsd: targetRole?.max_session_cost_usd ?? null, + }); + return { mode: "trajectory", session }; + } catch (error) { + const code = + error instanceof TrajectoryHandoffError ? error.code : "trajectory_environment_unsupported"; + const message = error instanceof Error ? error.message : String(error); + this.persistRecord({ + type: "trajectory_handoff_failed", + schema_version: 1, + run_id: this.runId, + from: args.from, + to: args.to, + source_conversation: sourceConversation, + code, + message, + ts: Date.now(), + }); + if (error instanceof TrajectoryHandoffError) throw error; + throw new TrajectoryHandoffError(code, message); + } + } + runCostSoFar(): number { // Sum `usage.cost` across all terminal records in the run: // - Parent lifecycle terminals: session_ended + session_failed (§11.4). diff --git a/src/host/role-session.ts b/src/host/role-session.ts index 7ad4b5ac..0e7f9bfb 100644 --- a/src/host/role-session.ts +++ b/src/host/role-session.ts @@ -1,6 +1,6 @@ import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import type { ModelEffort, Role, SessionWorkspaceDescriptor } from "../core/types.js"; -import type { RoleSession } from "./host.js"; +import type { RoleSession, TrajectoryContinuationOptions } from "./host.js"; import type { SessionSeam } from "./seam.js"; /** RoleSession plus production-only inspection fields used by wiring tests. */ @@ -20,6 +20,9 @@ export function createRoleSessionAdapter(opts: { readonly effort: ModelEffort; readonly retries: number; readonly retryDelayMs: number; + readonly isTrajectory?: boolean; + /** Reconfigure a shared native session for the next trajectory role. */ + readonly continueTrajectory?: (options: TrajectoryContinuationOptions) => Promise; /** Host-owned workspace metadata for an isolated worktree/copy session. */ readonly workspace?: SessionWorkspaceDescriptor; readonly onDispose: () => Promise | void; @@ -32,12 +35,14 @@ export function createRoleSessionAdapter(opts: { return { role: opts.role, sessionId: opts.sessionId, + conversationId: session.sessionId, sessionFile: opts.sessionFile, model: opts.model, effort: opts.effort, ...(workspace !== undefined ? { workspace } : {}), retries: opts.retries, retryDelayMs: opts.retryDelayMs, + ...(opts.isTrajectory === true && { isTrajectory: true }), readCaptureBuffer: () => seam.read(), resetCaptureBuffer: () => seam.reset(), takeHandoffValidationFailures: () => seam.takeHandoffValidationFailures(), @@ -47,6 +52,16 @@ export function createRoleSessionAdapter(opts: { isSealed: () => seam.isSealed, subscribeSealed: (listener: () => void) => seam.subscribeSealed(listener), prompt: (text: string) => session.prompt(text), + getTrajectoryContext: () => ({ + tokens: session.getContextUsage()?.tokens, + hasCompaction: session.sessionManager + .getEntries() + .some((entry) => entry.type === "compaction"), + registeredToolNames: session.getAllTools().map((tool) => tool.name), + }), + ...(opts.continueTrajectory !== undefined && { + continueTrajectory: opts.continueTrajectory, + }), dispose: async () => { session.dispose(); await opts.onDispose(); diff --git a/src/host/shared-sdk-role-spawn.ts b/src/host/shared-sdk-role-spawn.ts index 0d7e67aa..3ed7af1b 100644 --- a/src/host/shared-sdk-role-spawn.ts +++ b/src/host/shared-sdk-role-spawn.ts @@ -1,5 +1,7 @@ /** Shared-role SDK session spawning — preserves the Phase 7A execution path. */ +import { randomUUID } from "node:crypto"; + import type { Model } from "@earendil-works/pi-ai"; import { createAgentSession, @@ -16,7 +18,7 @@ import { createAskUserTool } from "./ask-user-tool.js"; import { SessionState } from "./cost.js"; import type { DisplaySink } from "./display-sink.js"; import { createHandoffContextTool } from "./handoff-context-tool.js"; -import type { RoleSession } from "./host.js"; +import type { RoleSession, TrajectoryContinuationOptions } from "./host.js"; import { buildToolsAllowlist } from "./production-host-resolve.js"; import { createRoleSessionAdapter } from "./role-session.js"; import { SessionSeam } from "./seam.js"; @@ -42,6 +44,12 @@ export async function spawnSharedSdkRoleSession(options: { readonly agentDir: string; readonly sessionDir: string; readonly runId: string; + /** Used only by durable trajectory resume; fresh roles create a new manager. */ + readonly sessionManager?: SessionManager; + /** Host-minted logical invocation identity for durable trajectory resume. */ + readonly roleSessionId?: string; + /** Marks a re-opened trajectory target so model failure cannot fresh-fallback. */ + readonly isTrajectory?: boolean; readonly machineDefinition: MachineDefinition; readonly handoffContextRef?: HandoffContextRef; readonly delegateTool: ToolDefinition | null; @@ -52,10 +60,32 @@ export async function spawnSharedSdkRoleSession(options: { readonly sessionStates: Map; readonly agentsBySessionId: Map; }): Promise { + // The session retains one public extension hook for its lifetime. The host + // changes this controller only while idle so trajectory roles replace, not + // append, instructions on their next native turn. + let activeSystemPrompt = options.systemPrompt ?? undefined; const loader = new DefaultResourceLoader({ cwd: options.cwd, agentDir: options.agentDir, - systemPromptOverride: () => options.systemPrompt ?? undefined, + systemPromptOverride: () => undefined, + extensionFactories: [ + { + name: "conductor-trajectory-role-environment", + factory: (pi) => { + // Pi 0.80.6 exposes this public hook but its factory generic is + // inferred narrowly from the empty resource set. + const roleEnvironment = pi as unknown as { + on( + event: "before_agent_start", + handler: () => Promise<{ systemPrompt: string | undefined }>, + ): void; + }; + roleEnvironment.on("before_agent_start", async () => ({ + systemPrompt: activeSystemPrompt, + })); + }, + }, + ], }); await loader.reload(); @@ -63,13 +93,18 @@ export async function spawnSharedSdkRoleSession(options: { options.handoffContextRef === undefined ? null : createHandoffContextTool(options.handoffContextRef); - const seam = new SessionSeam(); - const rejector = createCaptureRejector(); - const handoff = createHandoffTool(seam, rejector.shouldRejectCapture, { + let activeSeam = new SessionSeam(); + let activeHandoffContext = { role: options.role, def: options.machineDefinition, - }); - const end = createEndTool(seam, rejector.shouldRejectCapture); + }; + const rejector = createCaptureRejector(); + const handoff = createHandoffTool( + () => activeSeam, + rejector.shouldRejectCapture, + () => activeHandoffContext, + ); + const end = createEndTool(() => activeSeam, rejector.shouldRejectCapture); const askUser = createAskUserTool() as ToolDefinition; // The parent registry owns the runtime that carries extension-registered // providers (e.g. antigravity via pi-antigravity). Local SDK types (0.80.6) @@ -86,7 +121,8 @@ export async function spawnSharedSdkRoleSession(options: { modelRegistry: options.modelRegistry, ...(runtime !== undefined && { modelRuntime: runtime }), resourceLoader: loader, - sessionManager: SessionManager.create(options.cwd, options.sessionDir), + sessionManager: + options.sessionManager ?? SessionManager.create(options.cwd, options.sessionDir), customTools: [ handoff, end, @@ -120,8 +156,9 @@ export async function spawnSharedSdkRoleSession(options: { throw error; } - const sessionId = session.sessionId; - const sessionFile = session.sessionFile ?? `${options.sessionDir}/${sessionId}.jsonl`; + const nativeSessionId = session.sessionId; + const sessionId = options.roleSessionId ?? nativeSessionId; + const sessionFile = session.sessionFile ?? `${options.sessionDir}/${nativeSessionId}.jsonl`; const state = new SessionState({ cap: options.roleConfig?.max_session_cost_usd ?? null, model: options.logicalModel, @@ -142,19 +179,91 @@ export async function spawnSharedSdkRoleSession(options: { ...(options.displaySink !== undefined && { onDisplay: options.displaySink }), }); + let nativeRetained = false; + + const continueTrajectory = async ( + target: TrajectoryContinuationOptions, + ): Promise => { + if (!session.isIdle) { + throw new Error("trajectory reconfiguration requires an idle source session"); + } + // All mutations follow a preflight performed by ProductionHost. The + // assertions turn Pi's silent unknown-tool behavior into a hard failure. + await session.setModel(target.model); + session.setThinkingLevel(target.effort); + if (session.model?.id !== target.model.id || session.thinkingLevel !== target.effort) { + throw new Error("trajectory target model or effort was not applied exactly"); + } + session.setActiveToolsByName([...target.activeToolNames]); + const activeNames = session.getActiveToolNames(); + if ( + activeNames.length !== target.activeToolNames.length || + activeNames.some((name, index) => name !== target.activeToolNames[index]) + ) { + throw new Error("trajectory target active tool allowlist was not applied exactly"); + } + + nativeRetained = true; + activeSystemPrompt = target.systemPrompt; + activeSeam = new SessionSeam(); + activeHandoffContext = { role: target.role, def: options.machineDefinition }; + const targetSessionId = randomUUID(); + const targetState = new SessionState({ + cap: target.maxSessionCostUsd, + model: target.logicalModel, + }); + options.sessionStates.set(targetSessionId, targetState); + options.agentsBySessionId.set(targetSessionId, session); + rejector.bindState(targetState); + attachSessionEventHandler({ + session, + state: targetState, + role: target.role, + fileMutation: { + runId: options.runId, + sessionId: targetSessionId, + sessionFile, + persist: options.persistRecord, + }, + ...(options.displaySink !== undefined && { onDisplay: options.displaySink }), + }); + + return createRoleSessionAdapter({ + role: target.role, + session, + seam: activeSeam, + sessionId: targetSessionId, + sessionFile, + model: target.logicalModel, + effort: target.effort, + retries: 0, + retryDelayMs: 0, + isTrajectory: true, + continueTrajectory, + onDispose: () => { + options.sessionStates.delete(targetSessionId); + options.agentsBySessionId.delete(targetSessionId); + session.dispose(); + }, + }); + }; + return createRoleSessionAdapter({ role: options.role, session, - seam, + seam: activeSeam, sessionId, sessionFile, model: options.logicalModel, effort: options.effort, retries: options.retries, retryDelayMs: options.retryDelayMs, + ...(options.isTrajectory === true && { isTrajectory: true }), + continueTrajectory, onDispose: () => { options.sessionStates.delete(sessionId); options.agentsBySessionId.delete(sessionId); + if (!nativeRetained) session.dispose(); }, }); } diff --git a/src/host/stub-provider.ts b/src/host/stub-provider.ts index a93d46b7..6efaed26 100644 --- a/src/host/stub-provider.ts +++ b/src/host/stub-provider.ts @@ -139,6 +139,8 @@ export interface StubStreamOptions { * zeros. Field shape matches `Usage` (camelCase + nested `cost`), * which is the §11.4 SDK mapping source. */ readonly usage?: Partial; + /** Test-only observation of the real provider context supplied by Pi. */ + readonly onRequest?: (context: unknown) => void; } /** A single-tool-call record the stub pushes through the event protocol. */ @@ -188,10 +190,11 @@ export function makeStubModel(): Model { * `AssistantMessage` (Task 17 reads `usage` from there). */ export function makeStubStreamFunction(opts: StubStreamOptions): StreamFunction { - const { steps, usage: cannedUsage } = opts; + const { steps, usage: cannedUsage, onRequest } = opts; let stepIndex = 0; - return (_model, _context, options) => { + return (_model, context, options) => { + onRequest?.(context); const stream = createAssistantMessageEventStream(); // Per-step usage override (Task 17). When a step declares its diff --git a/src/host/tools.ts b/src/host/tools.ts index 1d9f805e..7ac3d4d9 100644 --- a/src/host/tools.ts +++ b/src/host/tools.ts @@ -92,12 +92,12 @@ export interface EmissionToolDetails { // ─── Internal factory: shared logic for handoff + end ────────────────── interface EmissionToolFactoryOptions { - readonly seam: SessionSeam; + readonly seam: SessionSeam | (() => SessionSeam); readonly toolName: "handoff" | "end"; readonly schema: TSchema; readonly description: string; readonly label: string; - readonly handoffContext?: HandoffContractContext; + readonly handoffContext?: HandoffContractContext | (() => HandoffContractContext); /** * Optional: host-supplied predicate consulted at the start of * `execute`. When the predicate returns `true`, the tool returns @@ -124,6 +124,9 @@ interface EmissionToolFactoryOptions { function createEmissionTool(opts: EmissionToolFactoryOptions): ToolDefinition { const { seam, toolName, schema, description, label, handoffContext, shouldRejectCapture } = opts; + const activeSeam = (): SessionSeam => (typeof seam === "function" ? seam() : seam); + const activeHandoffContext = (): HandoffContractContext | undefined => + typeof handoffContext === "function" ? handoffContext() : handoffContext; return defineTool({ name: toolName, @@ -194,8 +197,8 @@ function createEmissionTool(opts: EmissionToolFactoryOptions): ToolDefinition { // loop reads; the flag is only flipped on a *valid* first // capture (Task 15.5 reads it to short-circuit side-effecting // tools). - if (seam.read().length > 0) { - seam.push({ toolName, args: params }); + if (activeSeam().read().length > 0) { + activeSeam().push({ toolName, args: params }); return { content: [ { @@ -210,14 +213,15 @@ function createEmissionTool(opts: EmissionToolFactoryOptions): ToolDefinition { // ── First handoff call: same-session actionable correction ─── if (toolName === "handoff" && isObject(params) && typeof params.target_role === "string") { - const failure = validateRoleHandoff(params as HandoffCandidate, handoffContext); + const roleContext = activeHandoffContext(); + const failure = validateRoleHandoff(params as HandoffCandidate, roleContext); if (failure !== null) { - seam.rejectHandoff(failure); + activeSeam().rejectHandoff(failure); return { content: [ { type: "text" as const, - text: formatHandoffCorrection(failure, handoffContext), + text: formatHandoffCorrection(failure, roleContext), }, ], details: { @@ -239,14 +243,14 @@ function createEmissionTool(opts: EmissionToolFactoryOptions): ToolDefinition { // `validateEmission` re-derives the breach reason from the // single-element buffer, so the schema-invalid path stays // observable at the loop level. - seam.push({ toolName, args: params }); + activeSeam().push({ toolName, args: params }); if (validated.kind === "ok") { // ── Valid capture. Set the sealed flag (§12.1). ─────────── // Task 15.5 wires the host's tool wrappers to short-circuit // while this is true; the role's first valid emission is its // LAST chance to execute side-effecting tools. - seam.seal(); + activeSeam().seal(); const targetText = validated.event.type === "handoff" ? ` → ${validated.event.target_role}` : ""; return { @@ -298,16 +302,16 @@ function createEmissionTool(opts: EmissionToolFactoryOptions): ToolDefinition { * (`HandoffArgs`). No second schema. */ export function createHandoffTool( - seam: SessionSeam, + seam: SessionSeam | (() => SessionSeam), shouldRejectCapture?: () => boolean, - context?: HandoffContractContext, + context?: HandoffContractContext | (() => HandoffContractContext), ): ToolDefinition { return createEmissionTool({ seam, toolName: "handoff", schema: handoffArgsSchema, label: "Handoff", - description: formatHandoffDescription(context), + description: formatHandoffDescription(typeof context === "function" ? context() : context), ...(context !== undefined && { handoffContext: context }), ...(shouldRejectCapture !== undefined && { shouldRejectCapture }), }); @@ -325,7 +329,7 @@ function isObject(value: unknown): value is Record { * the transition is accepted. */ export function createEndTool( - seam: SessionSeam, + seam: SessionSeam | (() => SessionSeam), shouldRejectCapture?: () => boolean, ): ToolDefinition { return createEmissionTool({ diff --git a/src/host/trajectory-admission.ts b/src/host/trajectory-admission.ts new file mode 100644 index 00000000..64793456 --- /dev/null +++ b/src/host/trajectory-admission.ts @@ -0,0 +1,98 @@ +/** Conservative trajectory-context admission — Issue #63 §5. */ + +import { estimateTokens } from "@earendil-works/pi-coding-agent"; +import type { Model } from "@earendil-works/pi-ai"; +import type { TrajectoryAdmission } from "../persistence/trajectory-records.js"; + +/** Stable fail-closed trajectory admission error. */ +export class TrajectoryHandoffError extends Error { + readonly code: + | "trajectory_context_metadata_unknown" + | "trajectory_target_environment_invalid" + | "trajectory_context_unknown" + | "trajectory_history_compacted" + | "trajectory_context_too_large" + | "trajectory_environment_unsupported"; + + constructor(code: TrajectoryHandoffError["code"], message: string) { + super(message); + this.name = "TrajectoryHandoffError"; + this.code = code; + } +} + +/** Admit the exact un-compacted context before mutating an SDK session. */ +export function admitTrajectory(args: { + readonly source: { + readonly tokens: number | null | undefined; + readonly hasCompaction: boolean; + }; + readonly targetModel: Model; + readonly targetModelName: string; + readonly systemPrompt: string; + readonly activeToolNames: readonly string[]; + readonly targetSeed: string; +}): TrajectoryAdmission { + const { targetModel } = args; + if ( + !Number.isFinite(targetModel.contextWindow) || + targetModel.contextWindow <= 0 || + !Number.isFinite(targetModel.maxTokens) || + targetModel.maxTokens < 0 + ) { + throw new TrajectoryHandoffError( + "trajectory_context_metadata_unknown", + "trajectory target model has invalid context metadata", + ); + } + if (args.source.hasCompaction) { + throw new TrajectoryHandoffError( + "trajectory_history_compacted", + "trajectory source history contains a compaction entry", + ); + } + if (args.source.tokens === undefined || args.source.tokens === null) { + throw new TrajectoryHandoffError( + "trajectory_context_unknown", + "Pi cannot estimate the trajectory context before generation", + ); + } + + const roleEnvelopeTokens = estimateTokens({ + role: "user", + content: [ + { + type: "text", + text: JSON.stringify({ + system_prompt: args.systemPrompt, + active_tool_names: args.activeToolNames, + seed: args.targetSeed, + }), + }, + ], + timestamp: 0, + }); + const required = args.source.tokens + roleEnvelopeTokens + targetModel.maxTokens + 8192; + if (!Number.isFinite(required)) { + throw new TrajectoryHandoffError( + "trajectory_context_metadata_unknown", + "trajectory admission arithmetic is not finite", + ); + } + if (required > targetModel.contextWindow) { + throw new TrajectoryHandoffError( + "trajectory_context_too_large", + `trajectory requires ${required} tokens but target window is ${targetModel.contextWindow}`, + ); + } + return Object.freeze({ + schema_version: 1, + observed_context_tokens: args.source.tokens, + role_envelope_tokens: roleEnvelopeTokens, + target_max_tokens: targetModel.maxTokens, + safety_reservation_tokens: 8192, + required_tokens: required, + target_context_window: targetModel.contextWindow, + target_model: args.targetModelName, + }); +} diff --git a/tests/host/trajectory-admission.test.ts b/tests/host/trajectory-admission.test.ts new file mode 100644 index 00000000..1264b516 --- /dev/null +++ b/tests/host/trajectory-admission.test.ts @@ -0,0 +1,74 @@ +import type { Model } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { admitTrajectory, TrajectoryHandoffError } from "../../src/host/trajectory-admission.js"; + +function model(overrides: Partial> = {}): Model { + return { + id: "target", + name: "target", + api: "anthropic-messages", + provider: "stub", + baseUrl: "stub://no-network", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 100_000, + maxTokens: 100, + ...overrides, + } as Model; +} + +function input(overrides: Partial[0]> = {}) { + return { + source: { tokens: 100, hasCompaction: false }, + targetModel: model(), + targetModelName: "stub:target", + systemPrompt: "target instructions", + activeToolNames: ["handoff", "end", "ask_user"], + targetSeed: "continue", + ...overrides, + }; +} + +describe("Issue #63 trajectory admission", () => { + it("persists the conservative known admission inputs", () => { + const admitted = admitTrajectory(input()); + expect(admitted.safety_reservation_tokens).toBe(8192); + expect(admitted.required_tokens).toBeGreaterThan(100 + 100 + 8192); + }); + + const failures: readonly { + readonly name: string; + readonly args: Parameters[0]; + readonly code: TrajectoryHandoffError["code"]; + }[] = [ + { + name: "compacted history wins over unknown estimate", + args: input({ source: { tokens: null, hasCompaction: true } }), + code: "trajectory_history_compacted", + }, + { + name: "null context estimate fails closed", + args: input({ source: { tokens: null, hasCompaction: false } }), + code: "trajectory_context_unknown", + }, + { + name: "invalid target metadata fails closed", + args: input({ targetModel: model({ contextWindow: Number.NaN }) }), + code: "trajectory_context_metadata_unknown", + }, + { + name: "oversized exact trajectory fails closed", + args: input({ targetModel: model({ contextWindow: 1 }) }), + code: "trajectory_context_too_large", + }, + ]; + + for (const failure of failures) { + it(failure.name, () => { + expect(() => admitTrajectory(failure.args)).toThrow( + expect.objectContaining({ code: failure.code }), + ); + }); + } +}); diff --git a/tests/host/trajectory-environment.test.ts b/tests/host/trajectory-environment.test.ts new file mode 100644 index 00000000..9631d4a3 --- /dev/null +++ b/tests/host/trajectory-environment.test.ts @@ -0,0 +1,135 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { FileRecordLog, ProductionHost, startRun } from "../../src/index.js"; +import { makeStubModel, makeStubStreamFunction } from "../../src/host/stub-provider.js"; + +const MANIFEST = ` +version: 1 +handoffs: + - from: planner + to: orchestrator + mode: trajectory + - from: orchestrator + to: implementer + mode: trajectory +roles: + - name: orchestrator + is_orchestrator: true + models: [{ model: stub:orchestrator, effort: off }] + system_prompt: .pi/roles/orchestrator.md + tools: [handoff, end] + - name: planner + max_visits: 1 + models: [{ model: stub:planner, effort: off }] + system_prompt: .pi/roles/planner.md + tools: [handoff, end] + - name: implementer + max_visits: 1 + models: [{ model: stub:implementer, effort: off }] + system_prompt: .pi/roles/implementer.md + tools: [handoff, end] +`; + +function requestMessages(context: unknown): readonly { readonly role?: unknown }[] { + if (typeof context !== "object" || context === null || !("messages" in context)) return []; + const messages = (context as { readonly messages?: unknown }).messages; + return Array.isArray(messages) ? (messages as readonly { readonly role?: unknown }[]) : []; +} + +function requestPrompt(context: unknown): unknown { + return typeof context === "object" && context !== null && "systemPrompt" in context + ? (context as { readonly systemPrompt?: unknown }).systemPrompt + : undefined; +} + +function registryWithTrajectoryScript(requests: unknown[]): ModelRegistry { + const registry = ModelRegistry.inMemory(AuthStorage.inMemory()); + const base = makeStubModel(); + registry.registerProvider("stub", { + api: "anthropic-messages", + apiKey: "non-live-stub-key", + baseUrl: base.baseUrl, + streamSimple: makeStubStreamFunction({ + steps: [ + { kind: "emit_handoff", target_role: "planner" }, + { kind: "emit_handoff", target_role: "orchestrator" }, + { kind: "emit_handoff", target_role: "implementer" }, + { kind: "emit_handoff", target_role: "orchestrator" }, + { kind: "emit_end" }, + ], + onRequest: (context) => requests.push(context), + }), + models: ["orchestrator", "planner", "implementer"].map((id) => ({ + ...base, + id, + name: id, + })), + }); + return registry; +} + +describe("Issue #63 trajectory environment", () => { + let workdir: string; + let runs: string; + let manifestPath: string; + + beforeEach(async () => { + workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-")); + runs = join(workdir, "runs"); + manifestPath = join(workdir, ".pi", "conductor.yaml"); + await mkdir(join(workdir, ".pi", "roles"), { recursive: true }); + await writeFile(manifestPath, MANIFEST, "utf8"); + await Promise.all( + ["orchestrator", "planner", "implementer"].map((role) => + writeFile( + join(workdir, ".pi", "roles", `${role}.md`), + `${role.toUpperCase()}_PROMPT`, + "utf8", + ), + ), + ); + }); + + afterEach(async () => rm(workdir, { recursive: true, force: true })); + + it("keeps two selected edges in one conversation then returns to a fresh session", async () => { + const requests: unknown[] = []; + const registry = registryWithTrajectoryScript(requests); + const handle = await startRun(manifestPath, { + goal: "implement trajectory", + baseDir: runs, + hostFactory: ({ runId, log, loadedManifest }) => + new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log, + loadedManifest, + runId, + }), + }); + + const completion = await handle.completion(); + expect(completion.exitReason).toBe("done"); + + const records = new FileRecordLog({ baseDir: runs }).records(handle.runId); + const selected = records.filter((record) => record.type === "handoff_transport_selected"); + expect(selected).toHaveLength(2); + const starts = records.filter((record) => record.type === "session_started"); + const planner = starts.find((record) => record.role === "planner"); + const trajectoryOrchestrator = starts.filter((record) => record.role === "orchestrator")[1]; + const implementer = starts.find((record) => record.role === "implementer"); + const freshOrchestrator = starts.filter((record) => record.role === "orchestrator")[2]; + expect(planner?.session_file).toBe(trajectoryOrchestrator?.session_file); + expect(planner?.session_file).toBe(implementer?.session_file); + expect(implementer?.session_file).not.toBe(freshOrchestrator?.session_file); + expect(requests.length).toBe(5); + expect(requestPrompt(requests[2])).toBe("ORCHESTRATOR_PROMPT"); + expect(requestPrompt(requests[3])).toBe("IMPLEMENTER_PROMPT"); + expect(requestMessages(requests[2]).map((message) => message.role)).toContain("toolResult"); + expect(requestMessages(requests[3]).map((message) => message.role)).toContain("toolResult"); + }); +}); diff --git a/tests/host/trajectory-resume.test.ts b/tests/host/trajectory-resume.test.ts new file mode 100644 index 00000000..3846e89f --- /dev/null +++ b/tests/host/trajectory-resume.test.ts @@ -0,0 +1,137 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { + createInitialCheckpoint, + FileRecordLog, + loadManifest, + ProductionHost, + resumeRun, + type SessionLifecycleEvent, +} from "../../src/index.js"; +import { createManifestSnapshot } from "../../src/persistence/trajectory-records.js"; +import { makeModelRegistryWithStub } from "./production-host-fixture.js"; + +const MANIFEST = ` +version: 1 +handoffs: + - from: orchestrator + to: implementer + mode: trajectory +roles: + - name: orchestrator + is_orchestrator: true + models: [{ model: stub:stub-model, effort: off }] + system_prompt: .pi/roles/orchestrator.md + tools: [handoff, end] + - name: implementer + max_visits: 1 + models: [{ model: stub:stub-model, effort: off }] + system_prompt: .pi/roles/implementer.md + tools: [handoff, end] +`; + +describe("Issue #63 trajectory resume", () => { + it("reopens the selected physical conversation rather than creating a fresh target", async () => { + const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-resume-")); + try { + const baseDir = join(workdir, "runs"); + const manifestPath = join(workdir, ".pi", "conductor.yaml"); + await mkdir(join(workdir, ".pi", "roles"), { recursive: true }); + await writeFile(manifestPath, MANIFEST, "utf8"); + await writeFile(join(workdir, ".pi", "roles", "orchestrator.md"), "ORCHESTRATOR", "utf8"); + await writeFile(join(workdir, ".pi", "roles", "implementer.md"), "IMPLEMENTER", "utf8"); + const loaded = await loadManifest(manifestPath); + const checkpoint = createInitialCheckpoint(loaded.def); + const resumedCheckpoint = { + ...checkpoint, + current_role: "implementer", + visit_count: { implementer: 1 }, + updated_at: 1, + }; + const log = new FileRecordLog({ baseDir }); + log.append( + createManifestSnapshot({ + runId: checkpoint.run_id, + manifest: loaded.manifest, + definition: loaded.def, + ts: 1, + }), + ); + log.append({ type: "checkpoint_snapshot", checkpoint: resumedCheckpoint }); + log.append({ type: "run_seeded", run_id: checkpoint.run_id, goal: "resume", ts: 1 }); + + const registry = makeModelRegistryWithStub(); + const sourceHost = new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log, + loadedManifest: loaded, + runId: checkpoint.run_id, + }); + const source = await sourceHost.spawnRole("orchestrator"); + const sourceConversation = { + id: source.conversationId ?? source.sessionId, + file: source.sessionFile, + }; + await source.dispose(); + log.append({ + type: "handoff_transport_selected", + schema_version: 1, + run_id: checkpoint.run_id, + source_role_session_id: source.sessionId, + from: "orchestrator", + to: "implementer", + mode: "trajectory", + source_conversation: sourceConversation, + target: { + model: "stub:stub-model", + requested_effort: "off", + system_prompt: "IMPLEMENTER", + active_tool_names: ["handoff", "end", "ask_user"], + environment_sha256: "test", + }, + admission: { + schema_version: 1, + observed_context_tokens: 0, + role_envelope_tokens: 0, + target_max_tokens: 8192, + safety_reservation_tokens: 8192, + required_tokens: 16384, + target_context_window: 200000, + target_model: "stub:stub-model", + }, + ts: 2, + }); + + const handle = await resumeRun(manifestPath, checkpoint.run_id, { + goal: "ignored", + baseDir, + hostFactory: ({ runId, log: resumedLog, loadedManifest }) => + new ProductionHost({ + modelRegistry: makeModelRegistryWithStub([ + { kind: "emit_handoff", target_role: "orchestrator" }, + { kind: "emit_end" }, + ]), + cwd: workdir, + log: resumedLog, + loadedManifest, + runId, + }), + }); + await expect(handle.completion()).resolves.toMatchObject({ exitReason: "done" }); + const starts = log + .records(checkpoint.run_id) + .filter( + (record): record is SessionLifecycleEvent => + record.type === "session_started" && record.role === "implementer", + ); + expect(starts).toHaveLength(1); + expect(starts[0]?.session_file).toBe(sourceConversation.file); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); +}); From 973619c9b6312815e213325457ab5e8f239b07d4 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 04:27:35 +0000 Subject: [PATCH 07/20] fix: preserve fresh role prompts with trajectory hook --- src/host/shared-sdk-role-spawn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/host/shared-sdk-role-spawn.ts b/src/host/shared-sdk-role-spawn.ts index 3ed7af1b..bc578aff 100644 --- a/src/host/shared-sdk-role-spawn.ts +++ b/src/host/shared-sdk-role-spawn.ts @@ -67,7 +67,7 @@ export async function spawnSharedSdkRoleSession(options: { const loader = new DefaultResourceLoader({ cwd: options.cwd, agentDir: options.agentDir, - systemPromptOverride: () => undefined, + systemPromptOverride: () => activeSystemPrompt, extensionFactories: [ { name: "conductor-trajectory-role-environment", From dc39811026fc674c29441f710457ce07cdd07371 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 04:28:50 +0000 Subject: [PATCH 08/20] style: lint trajectory host seam --- src/host/loop.ts | 2 +- src/host/production-host.ts | 7 +++---- src/host/trajectory-admission.ts | 2 +- tests/host/trajectory-admission.test.ts | 5 ++++- tests/host/trajectory-environment.test.ts | 2 +- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/host/loop.ts b/src/host/loop.ts index b04b7368..a9ec8911 100644 --- a/src/host/loop.ts +++ b/src/host/loop.ts @@ -95,7 +95,6 @@ import { validateEmission } from "../seam/validate-emission.js"; import { ArtifactCollectionError } from "./artifacts/collect.js"; import { ArtifactRoutingError, formatArtifactsUnavailableSeedSection } from "./artifacts/route.js"; import { NoMoreModelsError } from "./errors.js"; -import { TrajectoryHandoffError } from "./trajectory-admission.js"; import { formatNoEmissionRecovery } from "./handoff-contract.js"; import type { ArtifactRouteSource, @@ -108,6 +107,7 @@ import type { import { RpcChildExitError } from "./rpc/protocol.js"; import { formatGuidedPrompt, type RunControl } from "./run-control.js"; import { formatRunMemorySeed } from "./run-memory.js"; +import { TrajectoryHandoffError } from "./trajectory-admission.js"; // ─── Public API ──────────────────────────────────────────────────────── diff --git a/src/host/production-host.ts b/src/host/production-host.ts index 39884813..545314d9 100644 --- a/src/host/production-host.ts +++ b/src/host/production-host.ts @@ -62,9 +62,9 @@ import { snapshotPinned, } from "../persistence/log.js"; import { + type HandoffTransportSelectedRecord, sha256Canonical, TrajectoryResumeError, - type HandoffTransportSelectedRecord, } from "../persistence/trajectory-records.js"; import { collectTerminalArtifacts as collectTerminalArtifactsFromWorkspace } from "./artifacts/lifecycle.js"; import { formatArtifactsSeedSection, materializeArtifacts } from "./artifacts/route.js"; @@ -72,7 +72,6 @@ import type { SessionState } from "./cost.js"; import { DelegationManager } from "./delegation/manager.js"; import type { DisplaySink } from "./display-sink.js"; import { NoMoreModelsError, RoleEscalationError } from "./errors.js"; -import { admitTrajectory, TrajectoryHandoffError } from "./trajectory-admission.js"; import type { ArtifactRouteSource, Host, @@ -99,6 +98,7 @@ import { createNodeRoleSession } from "./rpc/node-role-session-factory.js"; import type { NodeRoleSessionOptions } from "./rpc/protocol.js"; import type { SessionEventSource } from "./session-event-handler.js"; import { spawnSharedSdkRoleSession } from "./shared-sdk-role-spawn.js"; +import { admitTrajectory, TrajectoryHandoffError } from "./trajectory-admission.js"; import { assertPersistedSnapshotPinResolves, assertSupportedWorkspaceBackend, @@ -275,7 +275,7 @@ export class ProductionHost implements Host { const roleConfig = this.lookupRoleConfig(role); const resumedTrajectory = this.latestTrajectorySelection(role); if (resumedTrajectory !== null) { - return this.resumeTrajectoryRole(role, roleConfig, resumedTrajectory, opts.visitIndex ?? 1); + return this.resumeTrajectoryRole(role, roleConfig, resumedTrajectory); } const roleWorkspaceConfig = roleConfig?.workspace; const workspaceBackend = roleWorkspaceConfig?.backend ?? "shared"; @@ -431,7 +431,6 @@ export class ProductionHost implements Host { role: Role, roleConfig: RoleConfig | undefined, selected: HandoffTransportSelectedRecord, - visitIndex: number, ): Promise { if ( selected.schema_version !== 1 || diff --git a/src/host/trajectory-admission.ts b/src/host/trajectory-admission.ts index 64793456..720ccbec 100644 --- a/src/host/trajectory-admission.ts +++ b/src/host/trajectory-admission.ts @@ -1,7 +1,7 @@ /** Conservative trajectory-context admission — Issue #63 §5. */ -import { estimateTokens } from "@earendil-works/pi-coding-agent"; import type { Model } from "@earendil-works/pi-ai"; +import { estimateTokens } from "@earendil-works/pi-coding-agent"; import type { TrajectoryAdmission } from "../persistence/trajectory-records.js"; /** Stable fail-closed trajectory admission error. */ diff --git a/tests/host/trajectory-admission.test.ts b/tests/host/trajectory-admission.test.ts index 1264b516..3c364c77 100644 --- a/tests/host/trajectory-admission.test.ts +++ b/tests/host/trajectory-admission.test.ts @@ -1,6 +1,9 @@ import type { Model } from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; -import { admitTrajectory, TrajectoryHandoffError } from "../../src/host/trajectory-admission.js"; +import { + admitTrajectory, + type TrajectoryHandoffError, +} from "../../src/host/trajectory-admission.js"; function model(overrides: Partial> = {}): Model { return { diff --git a/tests/host/trajectory-environment.test.ts b/tests/host/trajectory-environment.test.ts index 9631d4a3..4e6e18b0 100644 --- a/tests/host/trajectory-environment.test.ts +++ b/tests/host/trajectory-environment.test.ts @@ -4,8 +4,8 @@ import { join } from "node:path"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { FileRecordLog, ProductionHost, startRun } from "../../src/index.js"; import { makeStubModel, makeStubStreamFunction } from "../../src/host/stub-provider.js"; +import { FileRecordLog, ProductionHost, startRun } from "../../src/index.js"; const MANIFEST = ` version: 1 From 83c6ab6efa1cc03299893fa6ea9272a2e1c95a11 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 04:29:48 +0000 Subject: [PATCH 09/20] fix: disable compaction for trajectory sources --- src/host/production-host.ts | 8 ++++++++ src/host/role-session.ts | 2 ++ src/host/shared-sdk-role-spawn.ts | 3 +++ tests/host/trajectory-environment.test.ts | 23 ++++++++++++++++++++++- 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/host/production-host.ts b/src/host/production-host.ts index 545314d9..e3a7ef0a 100644 --- a/src/host/production-host.ts +++ b/src/host/production-host.ts @@ -396,6 +396,10 @@ export class ProductionHost implements Host { sessionDir: this.sessionDir, runId: this.runId, machineDefinition: this.loadedManifest.def, + disableAutoCompaction: + this.loadedManifest.manifest.handoffs?.some( + (policy) => policy.from === role && policy.mode === "trajectory", + ) === true, ...(opts.handoffContextRef !== undefined && { handoffContextRef: opts.handoffContextRef }), delegateTool, ...(this.uiContext !== undefined && { uiContext: this.uiContext }), @@ -462,6 +466,10 @@ export class ProductionHost implements Host { ), roleSessionId: randomUUID(), isTrajectory: true, + disableAutoCompaction: + this.loadedManifest.manifest.handoffs?.some( + (policy) => policy.from === role && policy.mode === "trajectory", + ) === true, runId: this.runId, machineDefinition: this.loadedManifest.def, delegateTool: null, diff --git a/src/host/role-session.ts b/src/host/role-session.ts index 0e7f9bfb..a2e1cace 100644 --- a/src/host/role-session.ts +++ b/src/host/role-session.ts @@ -7,6 +7,7 @@ import type { SessionSeam } from "./seam.js"; export interface RoleSessionAdapter extends RoleSession { readonly systemPrompt: string; getActiveToolNames(): string[]; + isAutoCompactionEnabled(): boolean; } /** Build the common production/stub adapter around a native pi `AgentSession`. */ @@ -70,5 +71,6 @@ export function createRoleSessionAdapter(opts: { return session.systemPrompt; }, getActiveToolNames: () => session.getActiveToolNames(), + isAutoCompactionEnabled: () => session.autoCompactionEnabled, }; } diff --git a/src/host/shared-sdk-role-spawn.ts b/src/host/shared-sdk-role-spawn.ts index bc578aff..4bc3162e 100644 --- a/src/host/shared-sdk-role-spawn.ts +++ b/src/host/shared-sdk-role-spawn.ts @@ -50,6 +50,8 @@ export async function spawnSharedSdkRoleSession(options: { readonly roleSessionId?: string; /** Marks a re-opened trajectory target so model failure cannot fresh-fallback. */ readonly isTrajectory?: boolean; + /** Disable SDK auto-compaction before a role with an outgoing trajectory can prompt. */ + readonly disableAutoCompaction?: boolean; readonly machineDefinition: MachineDefinition; readonly handoffContextRef?: HandoffContextRef; readonly delegateTool: ToolDefinition | null; @@ -140,6 +142,7 @@ export async function spawnSharedSdkRoleSession(options: { } (createOpts as { thinkingLevel?: ModelEffort }).thinkingLevel = options.effort; const { session } = await createAgentSession(createOpts); + if (options.disableAutoCompaction === true) session.setAutoCompactionEnabled(false); try { if ( options.uiContext !== undefined && diff --git a/tests/host/trajectory-environment.test.ts b/tests/host/trajectory-environment.test.ts index 4e6e18b0..5e309199 100644 --- a/tests/host/trajectory-environment.test.ts +++ b/tests/host/trajectory-environment.test.ts @@ -4,8 +4,9 @@ import { join } from "node:path"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { RoleSession } from "../../src/host/host.js"; import { makeStubModel, makeStubStreamFunction } from "../../src/host/stub-provider.js"; -import { FileRecordLog, ProductionHost, startRun } from "../../src/index.js"; +import { FileRecordLog, loadManifest, ProductionHost, startRun } from "../../src/index.js"; const MANIFEST = ` version: 1 @@ -46,6 +47,12 @@ function requestPrompt(context: unknown): unknown { : undefined; } +function autoCompactionEnabled(session: RoleSession): boolean { + return ( + session as RoleSession & { readonly isAutoCompactionEnabled: () => boolean } + ).isAutoCompactionEnabled(); +} + function registryWithTrajectoryScript(requests: unknown[]): ModelRegistry { const registry = ModelRegistry.inMemory(AuthStorage.inMemory()); const base = makeStubModel(); @@ -96,6 +103,20 @@ describe("Issue #63 trajectory environment", () => { afterEach(async () => rm(workdir, { recursive: true, force: true })); + it("disables automatic compaction before an outgoing trajectory source can prompt", async () => { + const loaded = await loadManifest(manifestPath); + const host = new ProductionHost({ + modelRegistry: registryWithTrajectoryScript([]), + cwd: workdir, + log: new FileRecordLog({ baseDir: runs }), + loadedManifest: loaded, + runId: "auto-compaction-test", + }); + const session = await host.spawnRole("planner"); + expect(autoCompactionEnabled(session)).toBe(false); + await session.dispose(); + }); + it("keeps two selected edges in one conversation then returns to a fresh session", async () => { const requests: unknown[] = []; const registry = registryWithTrajectoryScript(requests); From 02247f99e86ff3869df383c0b98aab7c5d8bdc86 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 05:10:03 +0000 Subject: [PATCH 10/20] fix: harden trajectory continuation lifecycle --- src/host/api.ts | 80 ++++++++++++++++------- src/host/host.ts | 2 + src/host/loop.ts | 18 +---- src/host/production-host.ts | 40 ++++++++++-- src/host/role-session.ts | 6 +- src/host/session-event-handler.ts | 4 +- src/host/shared-sdk-role-spawn.ts | 23 +++++-- src/host/tools.ts | 6 +- src/host/trajectory-admission.ts | 3 + src/manifest/parse.ts | 6 +- src/manifest/types.ts | 2 +- src/persistence/log.ts | 11 +++- tests/host/trajectory-admission.test.ts | 15 +++++ tests/host/trajectory-environment.test.ts | 6 ++ tests/host/trajectory-resume.test.ts | 30 +++++++++ tests/manifest/issue-63.test.ts | 5 +- 16 files changed, 197 insertions(+), 60 deletions(-) diff --git a/src/host/api.ts b/src/host/api.ts index 938c0e81..b92fc590 100644 --- a/src/host/api.ts +++ b/src/host/api.ts @@ -53,9 +53,10 @@ * record + checkpoint transition. */ +import { existsSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; @@ -258,21 +259,41 @@ export async function resumeRun( runId: string, opts: ResumeRunOptions, ): Promise { - // Load and preflight before opening the record log. A container role is - // unsupported for the whole manifest, including one that would run later. - const loaded = await loadManifest( - manifestPath, - opts.modelRegistry !== undefined ? { modelRegistry: opts.modelRegistry } : undefined, - ); - assertManifestWorkspaceBackendsSupported(loaded); - + // Snapshot-era runs must never parse current YAML before their durable + // normalized manifest is hash-validated. The supplied path remains only a + // prompt-root UX locator in that case. const baseDir = await resolveBaseDir(opts.baseDir); + // Preserve legacy fail-fast behavior without creating a base directory. + // A pre-existing run file may contain the snapshot that must win over YAML. + const legacyPreflight = existsSync(join(baseDir, `${runId}.jsonl`)) + ? null + : await loadManifest( + manifestPath, + opts.modelRegistry !== undefined ? { modelRegistry: opts.modelRegistry } : undefined, + ); + if (legacyPreflight !== null) assertManifestWorkspaceBackendsSupported(legacyPreflight); const log = new FileRecordLog({ baseDir }); // Claim before reading a snapshot or reconciling lifecycle records: two // resumed hosts must never inspect, pin, or spawn the same live run. const lease = await log.acquireRunLease(runId); try { + const manifestSnapshot = latestManifestSnapshot(log.records(runId), runId); + const loaded: LoadedManifest = + manifestSnapshot === null + ? (legacyPreflight ?? + (await loadManifest( + manifestPath, + opts.modelRegistry !== undefined ? { modelRegistry: opts.modelRegistry } : undefined, + ))) + : Object.freeze({ + manifest: manifestSnapshot.normalized_manifest, + def: toMachineDefinition(manifestSnapshot.normalized_manifest), + warnings: Object.freeze([]), + manifestDir: dirname(manifestPath), + manifestVersion: manifestSnapshot.normalized_manifest.version, + }); + assertManifestWorkspaceBackendsSupported(loaded); const checkpoint = log.latestCheckpoint(runId); if (checkpoint === null) { throw new Error( @@ -280,18 +301,9 @@ export async function resumeRun( ); } - const manifestSnapshot = latestManifestSnapshot(log.records(runId), runId); // Snapshot-era runs take their roles and policy from durable normalized - // data. Legacy logs retain the original manifest-version check. - const resumedLoaded: LoadedManifest = - manifestSnapshot === null - ? loaded - : Object.freeze({ - ...loaded, - manifest: manifestSnapshot.normalized_manifest, - def: toMachineDefinition(manifestSnapshot.normalized_manifest), - manifestVersion: manifestSnapshot.normalized_manifest.version, - }); + // data; legacy logs use the freshly parsed current manifest. + const resumedLoaded = loaded; if (resumedLoaded.def.manifest_version !== checkpoint.manifest_version) { throw new Error( `resumeRun: manifest_version mismatch — snapshot pinned '${checkpoint.manifest_version}', manifest at '${manifestPath}' is '${resumedLoaded.def.manifest_version}' (§10)`, @@ -498,10 +510,20 @@ function reconcileCrash( const records = log.records(runId); const sessionFile = active.session_file; - // Find the session_started record for this session_file. - let sessionStarted: SessionLifecycleEvent | null = null; + // New records match the conductor invocation identity, not the shared + // physical JSONL. Legacy records have no logical identity and retain the + // historical session-file fallback. + let sessionStarted: + | (SessionLifecycleEvent & { + readonly role_session_id?: string; + readonly conversation_id?: string | null; + }) + | null = null; for (const r of records) { - if (r.type === "session_started" && r.session_file === sessionFile) { + if (r.type !== "session_started") continue; + const matchesLogical = r.role_session_id === active.id; + const matchesLegacy = r.role_session_id === undefined && r.session_file === sessionFile; + if (matchesLogical || matchesLegacy) { sessionStarted = r; break; } @@ -516,7 +538,9 @@ function reconcileCrash( for (const r of records) { if ( (r.type === "session_ended" || r.type === "session_failed") && - r.session_file === sessionFile + (sessionStarted.role_session_id !== undefined + ? r.role_session_id === sessionStarted.role_session_id + : r.role_session_id === undefined && r.session_file === sessionFile) ) { hasTerminal = true; break; @@ -563,7 +587,13 @@ function reconcileCrash( model: sessionStarted.model, model_effort: sessionStarted.model_effort ?? DEFAULT_MODEL_EFFORT, }); - log.append(result.record); + log.append({ + ...result.record, + ...(sessionStarted.role_session_id !== undefined && { + role_session_id: sessionStarted.role_session_id, + conversation_id: sessionStarted.conversation_id ?? null, + }), + }); // Persist the cleared checkpoint. const snapshot: CheckpointSnapshot = { type: "checkpoint_snapshot", diff --git a/src/host/host.ts b/src/host/host.ts index 429be56b..049a7ac3 100644 --- a/src/host/host.ts +++ b/src/host/host.ts @@ -176,6 +176,8 @@ export interface RoleSession { readonly tokens: number | null | undefined; readonly hasCompaction: boolean; readonly registeredToolNames: readonly string[]; + /** Canonical provider-visible definitions indexed by registered tool name. */ + readonly toolDefinitions: Readonly>; }; /** True when this logical invocation reuses a predecessor conversation. */ diff --git a/src/host/loop.ts b/src/host/loop.ts index a9ec8911..dd9e97c9 100644 --- a/src/host/loop.ts +++ b/src/host/loop.ts @@ -528,13 +528,7 @@ export async function runLoop(opts: RunLoopOptions): Promise { ...(session.workspace !== undefined ? { workspace: session.workspace } : {}), }); checkpoint = started.checkpoint; - host.persistRecord({ - ...started.record, - ...(session.conversationId !== undefined && { - role_session_id: sessionId, - conversation_id: session.conversationId, - }), - }); + host.persistRecord(withRoleSessionIdentity(started.record, session)); // §11.1: each transition produces a new full checkpoint snapshot. // session_started sets active_role_session; a snapshot here is // what resumeRun reads when a run crashed mid-prompt — without @@ -711,7 +705,7 @@ export async function runLoop(opts: RunLoopOptions): Promise { usage: capturedUsage, }); checkpoint = ended.checkpoint; - host.persistRecord(ended.record); + host.persistRecord(withRoleSessionIdentity(ended.record, session)); host.persistRecord({ type: "checkpoint_snapshot", checkpoint }); await collectSessionArtifacts(host, session, { role, @@ -938,13 +932,7 @@ export async function runLoop(opts: RunLoopOptions): Promise { model_effort: session.effort, }); checkpoint = ended.checkpoint; - host.persistRecord({ - ...ended.record, - ...(session.conversationId !== undefined && { - role_session_id: sessionId, - conversation_id: session.conversationId, - }), - }); + host.persistRecord(withRoleSessionIdentity(ended.record, session)); // §11.1: each transition produces a new full checkpoint // snapshot. session_ended clears active_role_session; // persist a fresh snapshot so latestCheckpoint reflects diff --git a/src/host/production-host.ts b/src/host/production-host.ts index e3a7ef0a..0d2e764d 100644 --- a/src/host/production-host.ts +++ b/src/host/production-host.ts @@ -273,9 +273,14 @@ export class ProductionHost implements Host { } const roleConfig = this.lookupRoleConfig(role); - const resumedTrajectory = this.latestTrajectorySelection(role); - if (resumedTrajectory !== null) { - return this.resumeTrajectoryRole(role, roleConfig, resumedTrajectory); + const resumedTransport = this.latestTrajectoryTransport(role); + if (resumedTransport?.type === "failed") { + throw new TrajectoryResumeError( + `trajectory handoff ${resumedTransport.record.from} → ${resumedTransport.record.to} previously failed: ${resumedTransport.record.code}`, + ); + } + if (resumedTransport?.type === "selected") { + return this.resumeTrajectoryRole(role, roleConfig, resumedTransport.record); } const roleWorkspaceConfig = roleConfig?.workspace; const workspaceBackend = roleWorkspaceConfig?.backend ?? "shared"; @@ -413,12 +418,23 @@ export class ProductionHost implements Host { }); } - /** Return the selector only when it is the latest handoff artifact for this role. */ - private latestTrajectorySelection(role: Role): HandoffTransportSelectedRecord | null { + /** Return the last durable transport outcome targeting this receiver. */ + private latestTrajectoryTransport(role: Role): + | { readonly type: "selected"; readonly record: HandoffTransportSelectedRecord } + | { + readonly type: "failed"; + readonly record: Extract; + } + | null { const records = this.log.records(this.runId); for (let index = records.length - 1; index >= 0; index -= 1) { const record = records[index]; - if (record?.type === "handoff_transport_selected" && record.to === role) return record; + if (record?.type === "trajectory_handoff_failed" && record.to === role) { + return { type: "failed", record }; + } + if (record?.type === "handoff_transport_selected" && record.to === role) { + return { type: "selected", record }; + } if ( record?.type === "transition_accepted" && record.event === "handoff" && @@ -732,12 +748,23 @@ export class ProductionHost implements Host { `trajectory target tool '${missingTool}' is unavailable in the source registry`, ); } + const activeToolDefinitions = activeToolNames.map((name) => { + const definition = sourceContext.toolDefinitions[name]; + if (definition === undefined) { + throw new TrajectoryHandoffError( + "trajectory_environment_unsupported", + `trajectory target tool '${name}' has no provider-visible definition`, + ); + } + return definition; + }); const admission = admitTrajectory({ source: sourceContext, targetModel: resolved.model, targetModelName: resolved.logical, systemPrompt: targetPrompt, activeToolNames, + activeToolDefinitions, targetSeed: args.targetSeed, }); const environmentSha = sha256Canonical({ @@ -745,6 +772,7 @@ export class ProductionHost implements Host { model: resolved.logical, effort: modelEntry.effort, active_tool_names: activeToolNames, + active_tool_definitions: activeToolDefinitions, }); this.persistRecord({ type: "handoff_transport_selected", diff --git a/src/host/role-session.ts b/src/host/role-session.ts index a2e1cace..e6878c81 100644 --- a/src/host/role-session.ts +++ b/src/host/role-session.ts @@ -26,7 +26,10 @@ export function createRoleSessionAdapter(opts: { readonly continueTrajectory?: (options: TrajectoryContinuationOptions) => Promise; /** Host-owned workspace metadata for an isolated worktree/copy session. */ readonly workspace?: SessionWorkspaceDescriptor; + /** Detach logical resources; native conversation ownership is host-controlled. */ readonly onDispose: () => Promise | void; + /** Whether this logical adapter still owns physical SDK disposal. */ + readonly disposeNative?: () => boolean; }): RoleSessionAdapter { const { session, seam } = opts; const workspace = @@ -59,12 +62,13 @@ export function createRoleSessionAdapter(opts: { .getEntries() .some((entry) => entry.type === "compaction"), registeredToolNames: session.getAllTools().map((tool) => tool.name), + toolDefinitions: Object.fromEntries(session.getAllTools().map((tool) => [tool.name, tool])), }), ...(opts.continueTrajectory !== undefined && { continueTrajectory: opts.continueTrajectory, }), dispose: async () => { - session.dispose(); + if (opts.disposeNative?.() !== false) session.dispose(); await opts.onDispose(); }, get systemPrompt(): string { diff --git a/src/host/session-event-handler.ts b/src/host/session-event-handler.ts index c3972408..40076752 100644 --- a/src/host/session-event-handler.ts +++ b/src/host/session-event-handler.ts @@ -94,7 +94,7 @@ export function attachSessionEventHandler(args: { /** Identity to preserve on display events from a delegated child. */ origin?: ChildDisplayOrigin; fileMutation?: FileMutationTelemetry; -}): void { +}): () => void { // Per-session buffer: toolCallId → { summary, args, writeHunks }. // The args are needed at `tool_execution_end` to populate // `DisplayEvent.files` for write/edit tools (issue #12). @@ -118,7 +118,7 @@ export function attachSessionEventHandler(args: { } >(); - args.session.subscribe((event) => + return args.session.subscribe((event) => onSessionEvent( args.session, args.state, diff --git a/src/host/shared-sdk-role-spawn.ts b/src/host/shared-sdk-role-spawn.ts index 4bc3162e..382a3e8a 100644 --- a/src/host/shared-sdk-role-spawn.ts +++ b/src/host/shared-sdk-role-spawn.ts @@ -105,6 +105,7 @@ export async function spawnSharedSdkRoleSession(options: { () => activeSeam, rejector.shouldRejectCapture, () => activeHandoffContext, + options.disableAutoCompaction === true, ); const end = createEndTool(() => activeSeam, rejector.shouldRejectCapture); const askUser = createAskUserTool() as ToolDefinition; @@ -169,7 +170,7 @@ export async function spawnSharedSdkRoleSession(options: { options.sessionStates.set(sessionId, state); options.agentsBySessionId.set(sessionId, session); rejector.bindState(state); - attachSessionEventHandler({ + const sourceEventUnsubscribe = attachSessionEventHandler({ session, state, role: options.role, @@ -206,6 +207,9 @@ export async function spawnSharedSdkRoleSession(options: { throw new Error("trajectory target active tool allowlist was not applied exactly"); } + // A source invocation is terminal before this continuation starts. Its + // listener/state must not observe target traffic or charge target usage. + sourceEventUnsubscribe(); nativeRetained = true; activeSystemPrompt = target.systemPrompt; activeSeam = new SessionSeam(); @@ -218,7 +222,7 @@ export async function spawnSharedSdkRoleSession(options: { options.sessionStates.set(targetSessionId, targetState); options.agentsBySessionId.set(targetSessionId, session); rejector.bindState(targetState); - attachSessionEventHandler({ + const targetEventUnsubscribe = attachSessionEventHandler({ session, state: targetState, role: target.role, @@ -231,6 +235,7 @@ export async function spawnSharedSdkRoleSession(options: { ...(options.displaySink !== undefined && { onDisplay: options.displaySink }), }); + let targetRetained = false; return createRoleSessionAdapter({ role: target.role, session, @@ -242,11 +247,18 @@ export async function spawnSharedSdkRoleSession(options: { retries: 0, retryDelayMs: 0, isTrajectory: true, - continueTrajectory, + continueTrajectory: async (nextTarget) => { + // This target becomes the source of another selected edge. Detach its + // logical accounting before rebinding and transfer native ownership. + targetRetained = true; + targetEventUnsubscribe(); + return continueTrajectory(nextTarget); + }, + disposeNative: () => !targetRetained, onDispose: () => { + targetEventUnsubscribe(); options.sessionStates.delete(targetSessionId); options.agentsBySessionId.delete(targetSessionId); - session.dispose(); }, }); }; @@ -263,10 +275,11 @@ export async function spawnSharedSdkRoleSession(options: { retryDelayMs: options.retryDelayMs, ...(options.isTrajectory === true && { isTrajectory: true }), continueTrajectory, + disposeNative: () => !nativeRetained, onDispose: () => { + sourceEventUnsubscribe(); options.sessionStates.delete(sessionId); options.agentsBySessionId.delete(sessionId); - if (!nativeRetained) session.dispose(); }, }); } diff --git a/src/host/tools.ts b/src/host/tools.ts index 7ac3d4d9..1ead8eea 100644 --- a/src/host/tools.ts +++ b/src/host/tools.ts @@ -305,13 +305,17 @@ export function createHandoffTool( seam: SessionSeam | (() => SessionSeam), shouldRejectCapture?: () => boolean, context?: HandoffContractContext | (() => HandoffContractContext), + /** Use when a shared session may later change roles; no source authority leaks into the schema description. */ + transportNeutralDescription = false, ): ToolDefinition { return createEmissionTool({ seam, toolName: "handoff", schema: handoffArgsSchema, label: "Handoff", - description: formatHandoffDescription(typeof context === "function" ? context() : context), + description: formatHandoffDescription( + transportNeutralDescription ? undefined : typeof context === "function" ? context() : context, + ), ...(context !== undefined && { handoffContext: context }), ...(shouldRejectCapture !== undefined && { shouldRejectCapture }), }); diff --git a/src/host/trajectory-admission.ts b/src/host/trajectory-admission.ts index 720ccbec..f1b3aa08 100644 --- a/src/host/trajectory-admission.ts +++ b/src/host/trajectory-admission.ts @@ -31,6 +31,8 @@ export function admitTrajectory(args: { readonly targetModelName: string; readonly systemPrompt: string; readonly activeToolNames: readonly string[]; + /** Exact serializable definitions of the active target tools. */ + readonly activeToolDefinitions: readonly unknown[]; readonly targetSeed: string; }): TrajectoryAdmission { const { targetModel } = args; @@ -66,6 +68,7 @@ export function admitTrajectory(args: { text: JSON.stringify({ system_prompt: args.systemPrompt, active_tool_names: args.activeToolNames, + active_tool_definitions: args.activeToolDefinitions, seed: args.targetSeed, }), }, diff --git a/src/manifest/parse.ts b/src/manifest/parse.ts index 1c8753cc..55aa3c6d 100644 --- a/src/manifest/parse.ts +++ b/src/manifest/parse.ts @@ -90,7 +90,9 @@ export function parseManifestFromObject(raw: unknown): Manifest { const subagentsRaw = obj.subagents; const subagents = subagentsRaw !== undefined ? parseSubagentProfiles(subagentsRaw) : undefined; - const handoffs = obj.handoffs === undefined ? undefined : parseHandoffs(obj.handoffs); + // Absent policy is the immutable empty policy, not an optional runtime + // branch. This makes the all-fresh default a normalized manifest contract. + const handoffs = obj.handoffs === undefined ? Object.freeze([]) : parseHandoffs(obj.handoffs); const endRequestRolesRaw = obj.end_request_roles; const end_request_roles = endRequestRolesRaw !== undefined @@ -100,7 +102,7 @@ export function parseManifestFromObject(raw: unknown): Manifest { return Object.freeze({ version, ...(end_request_roles !== undefined && { end_request_roles }), - ...(handoffs !== undefined && { handoffs }), + handoffs, roles: Object.freeze(roles), ...(subagents !== undefined && { subagents: Object.freeze(subagents) }), }) as Manifest; diff --git a/src/manifest/types.ts b/src/manifest/types.ts index 9ad6d994..c57c4fc5 100644 --- a/src/manifest/types.ts +++ b/src/manifest/types.ts @@ -99,7 +99,7 @@ export interface Manifest { readonly version: number; /** Authorized worker roles that may request completion through handoff. */ readonly end_request_roles?: readonly Role[]; - /** Optional host-only transport policies. Omitted policies are fresh (Issue #63). */ + /** Normalized host-only transport policies. Parser output always has a frozen empty list when YAML omits it; optional preserves programmatic legacy inputs. */ readonly handoffs?: readonly HandoffPolicy[]; readonly roles: readonly RoleConfig[]; /** Delegation lite §3: optional subagent profile declarations. */ diff --git a/src/persistence/log.ts b/src/persistence/log.ts index fa4abf13..56f7612e 100644 --- a/src/persistence/log.ts +++ b/src/persistence/log.ts @@ -288,11 +288,20 @@ export interface SubagentFailedRecord { readonly ts: number; } +/** + * Lifecycle identity is logical role invocation first, physical conversation + * second. Both remain optional only for append-only legacy record reading. + */ +export type RoleSessionLifecycleRecord = SessionLifecycleEvent & { + readonly role_session_id?: string; + readonly conversation_id?: string | null; +}; + /** Union of every record the host appends to its run_id-keyed log. */ export type PersistedRecord = | TransitionAccepted | TransitionRejected - | SessionLifecycleEvent + | RoleSessionLifecycleRecord | ModelFallback | ModelRetry | CheckpointSnapshot diff --git a/tests/host/trajectory-admission.test.ts b/tests/host/trajectory-admission.test.ts index 3c364c77..f5c03adb 100644 --- a/tests/host/trajectory-admission.test.ts +++ b/tests/host/trajectory-admission.test.ts @@ -28,6 +28,11 @@ function input(overrides: Partial[0]> = {}) { targetModelName: "stub:target", systemPrompt: "target instructions", activeToolNames: ["handoff", "end", "ask_user"], + activeToolDefinitions: [ + { name: "handoff", description: "handoff contract", parameters: { type: "object" } }, + { name: "end", description: "end contract", parameters: { type: "object" } }, + { name: "ask_user", description: "ask user", parameters: { type: "object" } }, + ], targetSeed: "continue", ...overrides, }; @@ -40,6 +45,16 @@ describe("Issue #63 trajectory admission", () => { expect(admitted.required_tokens).toBeGreaterThan(100 + 100 + 8192); }); + it("counts full active tool definitions in the role envelope", () => { + const small = admitTrajectory(input()); + const large = admitTrajectory( + input({ + activeToolDefinitions: [{ name: "target", description: "x".repeat(20_000) }], + }), + ); + expect(large.role_envelope_tokens).toBeGreaterThan(small.role_envelope_tokens); + }); + const failures: readonly { readonly name: string; readonly args: Parameters[0]; diff --git a/tests/host/trajectory-environment.test.ts b/tests/host/trajectory-environment.test.ts index 5e309199..89bc5874 100644 --- a/tests/host/trajectory-environment.test.ts +++ b/tests/host/trajectory-environment.test.ts @@ -146,6 +146,12 @@ describe("Issue #63 trajectory environment", () => { const freshOrchestrator = starts.filter((record) => record.role === "orchestrator")[2]; expect(planner?.session_file).toBe(trajectoryOrchestrator?.session_file); expect(planner?.session_file).toBe(implementer?.session_file); + // Logical invocation identities remain distinct even as physical Pi + // conversation identity is intentionally shared across trajectory edges. + expect(planner?.role_session_id).not.toBe(trajectoryOrchestrator?.role_session_id); + expect(trajectoryOrchestrator?.role_session_id).not.toBe(implementer?.role_session_id); + expect(planner?.conversation_id).toBe(trajectoryOrchestrator?.conversation_id); + expect(planner?.conversation_id).toBe(implementer?.conversation_id); expect(implementer?.session_file).not.toBe(freshOrchestrator?.session_file); expect(requests.length).toBe(5); expect(requestPrompt(requests[2])).toBe("ORCHESTRATOR_PROMPT"); diff --git a/tests/host/trajectory-resume.test.ts b/tests/host/trajectory-resume.test.ts index 3846e89f..19222c98 100644 --- a/tests/host/trajectory-resume.test.ts +++ b/tests/host/trajectory-resume.test.ts @@ -6,7 +6,9 @@ import { describe, expect, it } from "vitest"; import { createInitialCheckpoint, FileRecordLog, + InMemoryRecordLog, loadManifest, + loadManifestFromString, ProductionHost, resumeRun, type SessionLifecycleEvent, @@ -106,6 +108,8 @@ describe("Issue #63 trajectory resume", () => { ts: 2, }); + // Snapshot-era resume must not parse a changed (or malformed) current YAML. + await writeFile(manifestPath, "this: [is not valid", "utf8"); const handle = await resumeRun(manifestPath, checkpoint.run_id, { goal: "ignored", baseDir, @@ -134,4 +138,30 @@ describe("Issue #63 trajectory resume", () => { await rm(workdir, { recursive: true, force: true }); } }); + + it("surfaces a durable trajectory failure without reopening or retrying its target", async () => { + const loaded = loadManifestFromString(MANIFEST); + const log = new InMemoryRecordLog(); + log.append({ + type: "trajectory_handoff_failed", + schema_version: 1, + run_id: "failed-trajectory", + from: "orchestrator", + to: "implementer", + source_conversation: { id: "shared-conversation", file: "/unused.jsonl" }, + code: "trajectory_context_too_large", + message: "admission failed before target start", + ts: 1, + }); + const host = new ProductionHost({ + modelRegistry: makeModelRegistryWithStub(), + cwd: process.cwd(), + log, + loadedManifest: loaded, + runId: "failed-trajectory", + }); + + await expect(host.spawnRole("implementer")).rejects.toThrow("previously failed"); + expect(log.records("failed-trajectory")).toHaveLength(1); + }); }); diff --git a/tests/manifest/issue-63.test.ts b/tests/manifest/issue-63.test.ts index 04685ecf..302d869b 100644 --- a/tests/manifest/issue-63.test.ts +++ b/tests/manifest/issue-63.test.ts @@ -33,7 +33,10 @@ describe("Issue #63 manifest handoff policies", () => { it("defaults absent and undeclared policies to fresh", () => { expect(modeFor(parseManifest(VALID).handoffs, "planner", "orchestrator")).toBe("trajectory"); expect(modeFor(parseManifest(VALID).handoffs, "implementer", "orchestrator")).toBe("fresh"); - expect(modeFor(parseManifest("version: 1\nroles: []").handoffs, "a", "b")).toBe("fresh"); + const absent = parseManifest("version: 1\nroles: []").handoffs; + expect(modeFor(absent, "a", "b")).toBe("fresh"); + expect(absent).toEqual([]); + expect(Object.isFrozen(absent)).toBe(true); }); it("parses frozen explicit policies", () => { From b6f460d0037cfc763c9cba0aa81c3e3beb075f15 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 05:36:59 +0000 Subject: [PATCH 11/20] fix: fail closed trajectory resume recovery --- src/host/api.ts | 61 ++++- src/host/loop.ts | 11 +- src/host/production-host.ts | 156 +++++++----- src/host/shared-sdk-role-spawn.ts | 25 +- src/host/trajectory-admission.ts | 39 ++- src/persistence/trajectory-records.ts | 69 ++++++ tests/host/trajectory-admission.test.ts | 5 + tests/host/trajectory-resume.test.ts | 237 ++++++++++++++++++- tests/persistence/trajectory-records.test.ts | 50 ++++ 9 files changed, 577 insertions(+), 76 deletions(-) diff --git a/src/host/api.ts b/src/host/api.ts index b92fc590..0d3c5a58 100644 --- a/src/host/api.ts +++ b/src/host/api.ts @@ -313,11 +313,19 @@ export async function resumeRun( // Crash reconciliation (§11.1). const reconciledCheckpoint = reconcileCrash(runId, checkpoint, def, log); + const resumedRecords = log.records(runId); const initialArtifactDelivery = latestArtifactDelivery( - log.records(runId), + resumedRecords, runId, reconciledCheckpoint, ); + const initialParentSessionId = latestTrajectoryParent( + resumedRecords, + runId, + reconciledCheckpoint, + ); + const initialVisitIndexByRole = + initialParentSessionId === null ? undefined : nextVisitIndexes(resumedRecords, runId); const host = opts.hostFactory({ runId, def, log, loadedManifest: resumedLoaded }); @@ -337,6 +345,8 @@ export async function resumeRun( loadedManifest: resumedLoaded, lease, initialArtifactDelivery, + initialParentSessionId, + ...(initialVisitIndexByRole !== undefined && { initialVisitIndexByRole }), }); } catch (error) { await lease.release(); @@ -364,6 +374,10 @@ interface RunWithCompletionArgs { readonly loadedManifest: LoadedManifest; /** Last accepted artifact delivery that still targets this resumed checkpoint. */ readonly initialArtifactDelivery?: ArtifactDeliveryRecord | null; + /** Restored logical parent for a selected trajectory receiver. */ + readonly initialParentSessionId?: string | null; + /** Next lifecycle visit indexes reconstructed from durable starts. */ + readonly initialVisitIndexByRole?: Readonly>; /** Live ownership held from API entry through the final loop outcome. */ readonly lease: RunExecutionLease; } @@ -410,6 +424,12 @@ async function runWithCompletion(args: RunWithCompletionArgs): Promise { @@ -466,6 +486,45 @@ function latestArtifactDelivery( return null; } +/** Recover the selected source's logical role-session identity for a resumed trajectory receiver. */ +function latestTrajectoryParent( + records: readonly PersistedRecord[], + runId: string, + checkpoint: Checkpoint, +): string | null { + if (checkpoint.current_role === "done") return null; + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index]; + if (record === undefined || !("run_id" in record) || record.run_id !== runId) continue; + if (record.type === "handoff_transport_selected" && record.to === checkpoint.current_role) { + return record.source_role_session_id; + } + if ( + record.type === "transition_accepted" && + record.event === "handoff" && + record.to === checkpoint.current_role + ) { + return null; + } + } + return null; +} + +/** Reconstruct each role's next logical visit index from durable lifecycle starts. */ +function nextVisitIndexes( + records: readonly PersistedRecord[], + runId: string, +): Readonly> { + const highest = new Map(); + for (const record of records) { + if (record.type !== "session_started" || record.run_id !== runId) continue; + highest.set(record.role, Math.max(highest.get(record.role) ?? 0, record.visit_index)); + } + return Object.freeze( + Object.fromEntries([...highest].map(([role, visitIndex]) => [role, visitIndex + 1])), + ); +} + function latestHandoffContextRef( records: readonly PersistedRecord[], runId: string, diff --git a/src/host/loop.ts b/src/host/loop.ts index dd9e97c9..507fd3c4 100644 --- a/src/host/loop.ts +++ b/src/host/loop.ts @@ -137,6 +137,10 @@ export interface RunLoopOptions { readonly initialHandoffContextRef?: HandoffContextRef | null; /** Durable accepted-handoff delivery resumed before the receiver can prompt. */ readonly initialArtifactDelivery?: ArtifactDeliveryRecord | null; + /** Logical predecessor restored from a trajectory selector before a resumed target starts. */ + readonly initialParentSessionId?: string | null; + /** Next visit index per role reconstructed from durable lifecycle starts on resume. */ + readonly initialVisitIndexByRole?: Readonly>; /** Optional: per-role spawn overrides. Defaults to a minimal call * that lets the host derive model + system prompt + tools from the * loaded manifest. Tests pass `sessionManager: SessionManager.inMemory()` @@ -195,7 +199,8 @@ export async function runLoop(opts: RunLoopOptions): Promise { let checkpoint: Checkpoint = initialCheckpoint; // parent_session for the next session_started (§11.4). Initialized to // the snapshot's active_role_session id (resume case) or null (fresh). - let parentSessionId: string | null = checkpoint.active_role_session?.id ?? null; + let parentSessionId: string | null = + checkpoint.active_role_session?.id ?? opts.initialParentSessionId ?? null; let seed = initialGoal; // Host-generated predecessor pointer for the next role's optional // handoff_context tool. It is replaced only by an accepted handoff or by @@ -241,7 +246,9 @@ export async function runLoop(opts: RunLoopOptions): Promise { // for model retries — the primary's `session_failed` is recorded // before the fallback's `session_started`, which would inflate // the count). - const visitIndexByRole = new Map(); + const visitIndexByRole = new Map( + Object.entries(opts.initialVisitIndexByRole ?? {}) as [Role, number][], + ); // Sentinel sessionFile for the synthesized `end` records. There is // no live session at the time of synthesis, so the record's // `session_file` field carries a stable marker rather than a real diff --git a/src/host/production-host.ts b/src/host/production-host.ts index 0d2e764d..543412c0 100644 --- a/src/host/production-host.ts +++ b/src/host/production-host.ts @@ -65,6 +65,7 @@ import { type HandoffTransportSelectedRecord, sha256Canonical, TrajectoryResumeError, + validateTrajectorySelector, } from "../persistence/trajectory-records.js"; import { collectTerminalArtifacts as collectTerminalArtifactsFromWorkspace } from "./artifacts/lifecycle.js"; import { formatArtifactsSeedSection, materializeArtifacts } from "./artifacts/route.js"; @@ -98,7 +99,11 @@ import { createNodeRoleSession } from "./rpc/node-role-session-factory.js"; import type { NodeRoleSessionOptions } from "./rpc/protocol.js"; import type { SessionEventSource } from "./session-event-handler.js"; import { spawnSharedSdkRoleSession } from "./shared-sdk-role-spawn.js"; -import { admitTrajectory, TrajectoryHandoffError } from "./trajectory-admission.js"; +import { + admitTrajectory, + serializeActiveToolDefinitions, + TrajectoryHandoffError, +} from "./trajectory-admission.js"; import { assertPersistedSnapshotPinResolves, assertSupportedWorkspaceBackend, @@ -452,61 +457,84 @@ export class ProductionHost implements Host { roleConfig: RoleConfig | undefined, selected: HandoffTransportSelectedRecord, ): Promise { - if ( - selected.schema_version !== 1 || - selected.target.system_prompt.length === 0 || - selected.target.active_tool_names.length === 0 - ) { + const persisted = validateTrajectorySelector(selected); + let session: RoleSession | null = null; + try { + const resolved = resolveModel(role, persisted.target.model, this.modelRegistry); + session = await spawnSharedSdkRoleSession({ + role, + roleConfig, + model: resolved.model, + logicalModel: persisted.target.model, + effort: persisted.target.requested_effort, + retries: 0, + retryDelayMs: 0, + systemPrompt: persisted.target.system_prompt, + activeToolNames: persisted.target.active_tool_names, + modelRegistry: this.modelRegistry, + cwd: this.cwd, + agentDir: this.agentDir, + sessionDir: this.sessionDir, + sessionManager: SessionManager.open( + persisted.source_conversation.file, + this.sessionDir, + this.cwd, + ), + roleSessionId: randomUUID(), + isTrajectory: true, + disableAutoCompaction: + this.loadedManifest.manifest.handoffs?.some( + (policy) => policy.from === role && policy.mode === "trajectory", + ) === true, + runId: this.runId, + machineDefinition: this.loadedManifest.def, + delegateTool: null, + ...(this.uiContext !== undefined && { uiContext: this.uiContext }), + ...(this.isUiContextCurrent !== undefined && { + isUiContextCurrent: this.isUiContextCurrent, + }), + ...(this.displaySink !== undefined && { displaySink: this.displaySink }), + persistRecord: (record) => this.persistRecord(record), + sessionStates: this.sessionStates, + agentsBySessionId: this.agentsBySessionId, + }); + const context = session.getTrajectoryContext?.(); + if (context === undefined) { + throw new TrajectoryResumeError("resumed trajectory session cannot inspect target tools"); + } + const activeToolDefinitions = serializeActiveToolDefinitions( + persisted.target.active_tool_names.map((name) => { + const definition = context.toolDefinitions[name]; + if (definition === undefined) { + throw new TrajectoryResumeError( + "trajectory selector references unavailable target tools", + ); + } + return definition; + }), + ); + const environmentSha = sha256Canonical({ + system_prompt: persisted.target.system_prompt, + model: persisted.target.model, + effort: persisted.target.requested_effort, + active_tool_names: persisted.target.active_tool_names, + active_tool_definitions: activeToolDefinitions, + }); + if (environmentSha !== persisted.target.environment_sha256) { + throw new TrajectoryResumeError( + "trajectory selector target environment hash does not match", + ); + } + return session; + } catch (error) { + await session?.dispose(); + if (error instanceof TrajectoryResumeError) throw error; throw new TrajectoryResumeError( - "trajectory selector has incomplete persisted target environment", + error instanceof Error + ? error.message + : "trajectory target environment could not be restored", ); } - const resolved = resolveModel(role, selected.target.model, this.modelRegistry); - const session = await spawnSharedSdkRoleSession({ - role, - roleConfig, - model: resolved.model, - logicalModel: selected.target.model, - effort: selected.target.requested_effort, - retries: 0, - retryDelayMs: 0, - systemPrompt: selected.target.system_prompt, - modelRegistry: this.modelRegistry, - cwd: this.cwd, - agentDir: this.agentDir, - sessionDir: this.sessionDir, - sessionManager: SessionManager.open( - selected.source_conversation.file, - this.sessionDir, - this.cwd, - ), - roleSessionId: randomUUID(), - isTrajectory: true, - disableAutoCompaction: - this.loadedManifest.manifest.handoffs?.some( - (policy) => policy.from === role && policy.mode === "trajectory", - ) === true, - runId: this.runId, - machineDefinition: this.loadedManifest.def, - delegateTool: null, - ...(this.uiContext !== undefined && { uiContext: this.uiContext }), - ...(this.isUiContextCurrent !== undefined && { - isUiContextCurrent: this.isUiContextCurrent, - }), - ...(this.displaySink !== undefined && { displaySink: this.displaySink }), - persistRecord: (record) => this.persistRecord(record), - sessionStates: this.sessionStates, - agentsBySessionId: this.agentsBySessionId, - }); - const actual = session.getTrajectoryContext?.().registeredToolNames ?? []; - if ( - actual.length === 0 || - selected.target.active_tool_names.some((name) => !actual.includes(name)) - ) { - await session.dispose(); - throw new TrajectoryResumeError("trajectory selector references unavailable target tools"); - } - return session; } /** Build the existing delegation operation with the caller's constrained Git base. */ @@ -748,16 +776,18 @@ export class ProductionHost implements Host { `trajectory target tool '${missingTool}' is unavailable in the source registry`, ); } - const activeToolDefinitions = activeToolNames.map((name) => { - const definition = sourceContext.toolDefinitions[name]; - if (definition === undefined) { - throw new TrajectoryHandoffError( - "trajectory_environment_unsupported", - `trajectory target tool '${name}' has no provider-visible definition`, - ); - } - return definition; - }); + const activeToolDefinitions = serializeActiveToolDefinitions( + activeToolNames.map((name) => { + const definition = sourceContext.toolDefinitions[name]; + if (definition === undefined) { + throw new TrajectoryHandoffError( + "trajectory_environment_unsupported", + `trajectory target tool '${name}' has no provider-visible definition`, + ); + } + return definition; + }), + ); const admission = admitTrajectory({ source: sourceContext, targetModel: resolved.model, diff --git a/src/host/shared-sdk-role-spawn.ts b/src/host/shared-sdk-role-spawn.ts index 382a3e8a..621325bd 100644 --- a/src/host/shared-sdk-role-spawn.ts +++ b/src/host/shared-sdk-role-spawn.ts @@ -50,6 +50,8 @@ export async function spawnSharedSdkRoleSession(options: { readonly roleSessionId?: string; /** Marks a re-opened trajectory target so model failure cannot fresh-fallback. */ readonly isTrajectory?: boolean; + /** Persisted trajectory target allowlist; never inferred from current role defaults on resume. */ + readonly activeToolNames?: readonly string[]; /** Disable SDK auto-compaction before a role with an outgoing trajectory can prompt. */ readonly disableAutoCompaction?: boolean; readonly machineDefinition: MachineDefinition; @@ -105,7 +107,7 @@ export async function spawnSharedSdkRoleSession(options: { () => activeSeam, rejector.shouldRejectCapture, () => activeHandoffContext, - options.disableAutoCompaction === true, + options.disableAutoCompaction === true || options.isTrajectory === true, ); const end = createEndTool(() => activeSeam, rejector.shouldRejectCapture); const askUser = createAskUserTool() as ToolDefinition; @@ -133,10 +135,13 @@ export async function spawnSharedSdkRoleSession(options: { ...(handoffContext === null ? [] : [handoffContext]), ...(options.delegateTool === null ? [] : [options.delegateTool]), ], - tools: [ - ...buildToolsAllowlist(options.roleConfig?.tools, handoffContext !== null), - ...(options.delegateTool === null ? [] : ["delegate"]), - ], + tools: + options.activeToolNames === undefined + ? [ + ...buildToolsAllowlist(options.roleConfig?.tools, handoffContext !== null), + ...(options.delegateTool === null ? [] : ["delegate"]), + ] + : [...options.activeToolNames], }; if (options.model !== undefined) { (createOpts as { model?: Model }).model = options.model; @@ -144,6 +149,16 @@ export async function spawnSharedSdkRoleSession(options: { (createOpts as { thinkingLevel?: ModelEffort }).thinkingLevel = options.effort; const { session } = await createAgentSession(createOpts); if (options.disableAutoCompaction === true) session.setAutoCompactionEnabled(false); + if (options.activeToolNames !== undefined) { + const activeNames = session.getActiveToolNames(); + if ( + activeNames.length !== options.activeToolNames.length || + activeNames.some((name, index) => name !== options.activeToolNames?.[index]) + ) { + session.dispose(); + throw new Error("trajectory target active tool allowlist was not applied exactly"); + } + } try { if ( options.uiContext !== undefined && diff --git a/src/host/trajectory-admission.ts b/src/host/trajectory-admission.ts index f1b3aa08..57853b88 100644 --- a/src/host/trajectory-admission.ts +++ b/src/host/trajectory-admission.ts @@ -5,6 +5,36 @@ import { estimateTokens } from "@earendil-works/pi-coding-agent"; import type { TrajectoryAdmission } from "../persistence/trajectory-records.js"; /** Stable fail-closed trajectory admission error. */ +/** Extract the provider-visible portion of each active tool for stable admission and resume hashing. */ +export function serializeActiveToolDefinitions(definitions: readonly unknown[]): readonly { + readonly name: string; + readonly description: string; + readonly parameters: unknown; +}[] { + return Object.freeze( + definitions.map((definition) => { + if (typeof definition !== "object" || definition === null || Array.isArray(definition)) { + throw new TrajectoryHandoffError( + "trajectory_environment_unsupported", + "trajectory target tool has no serializable provider-visible definition", + ); + } + const tool = definition as Record; + if (typeof tool.name !== "string" || typeof tool.description !== "string") { + throw new TrajectoryHandoffError( + "trajectory_environment_unsupported", + "trajectory target tool has an incomplete provider-visible definition", + ); + } + return Object.freeze({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }); + }), + ); +} + export class TrajectoryHandoffError extends Error { readonly code: | "trajectory_context_metadata_unknown" @@ -53,10 +83,15 @@ export function admitTrajectory(args: { "trajectory source history contains a compaction entry", ); } - if (args.source.tokens === undefined || args.source.tokens === null) { + if ( + args.source.tokens === undefined || + args.source.tokens === null || + !Number.isFinite(args.source.tokens) || + args.source.tokens < 0 + ) { throw new TrajectoryHandoffError( "trajectory_context_unknown", - "Pi cannot estimate the trajectory context before generation", + "Pi cannot provide a finite non-negative trajectory context estimate before generation", ); } diff --git a/src/persistence/trajectory-records.ts b/src/persistence/trajectory-records.ts index 06ebb18b..42d93d00 100644 --- a/src/persistence/trajectory-records.ts +++ b/src/persistence/trajectory-records.ts @@ -66,6 +66,75 @@ export interface HandoffTransportSelectedRecord { readonly ts: number; } +/** Validate a persisted selector before any resumed target environment is opened. */ +export function validateTrajectorySelector( + record: HandoffTransportSelectedRecord, +): HandoffTransportSelectedRecord { + const target = record.target; + if ( + record.schema_version !== 1 || + record.mode !== "trajectory" || + !nonEmptyString(record.run_id) || + !nonEmptyString(record.source_role_session_id) || + !nonEmptyString(record.from) || + !nonEmptyString(record.to) || + !nonEmptyString(record.source_conversation?.id) || + !nonEmptyString(record.source_conversation?.file) || + !nonEmptyString(target?.model) || + !isModelEffort(target?.requested_effort) || + !nonEmptyString(target?.system_prompt) || + !isExactToolNameList(target?.active_tool_names) || + !isSha256(target?.environment_sha256) || + !isTrajectoryAdmission(record.admission) + ) { + throw new TrajectoryResumeError( + "trajectory selector has incomplete persisted target environment", + ); + } + return record; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isModelEffort(value: unknown): value is ModelEffort { + return ( + typeof value === "string" && + ["off", "minimal", "low", "medium", "high", "xhigh"].includes(value) + ); +} + +function isExactToolNameList(value: unknown): value is readonly string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every(nonEmptyString) && + new Set(value).size === value.length + ); +} + +function isSha256(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value); +} + +function isTrajectoryAdmission(value: unknown): value is TrajectoryAdmission { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const admission = value as Record; + return ( + admission.schema_version === 1 && + typeof admission.target_model === "string" && + [ + "observed_context_tokens", + "role_envelope_tokens", + "target_max_tokens", + "safety_reservation_tokens", + "required_tokens", + "target_context_window", + ].every((name) => typeof admission[name] === "number" && Number.isFinite(admission[name])) + ); +} + /** Observable fail-closed result for a trajectory preflight/reconfiguration failure. */ export interface TrajectoryHandoffFailedRecord { readonly type: "trajectory_handoff_failed"; diff --git a/tests/host/trajectory-admission.test.ts b/tests/host/trajectory-admission.test.ts index f5c03adb..d324aa33 100644 --- a/tests/host/trajectory-admission.test.ts +++ b/tests/host/trajectory-admission.test.ts @@ -70,6 +70,11 @@ describe("Issue #63 trajectory admission", () => { args: input({ source: { tokens: null, hasCompaction: false } }), code: "trajectory_context_unknown", }, + ...([-1, Number.NaN, Number.POSITIVE_INFINITY] as const).map((tokens) => ({ + name: `invalid source estimate ${String(tokens)} fails closed`, + args: input({ source: { tokens, hasCompaction: false } }), + code: "trajectory_context_unknown" as const, + })), { name: "invalid target metadata fails closed", args: input({ targetModel: model({ contextWindow: Number.NaN }) }), diff --git a/tests/host/trajectory-resume.test.ts b/tests/host/trajectory-resume.test.ts index 19222c98..0ac888f3 100644 --- a/tests/host/trajectory-resume.test.ts +++ b/tests/host/trajectory-resume.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { serializeActiveToolDefinitions } from "../../src/host/trajectory-admission.js"; import { createInitialCheckpoint, FileRecordLog, @@ -13,7 +14,12 @@ import { resumeRun, type SessionLifecycleEvent, } from "../../src/index.js"; -import { createManifestSnapshot } from "../../src/persistence/trajectory-records.js"; +import type { PersistedRecord } from "../../src/persistence/log.js"; +import { + createManifestSnapshot, + sha256Canonical, + TrajectoryResumeError, +} from "../../src/persistence/trajectory-records.js"; import { makeModelRegistryWithStub } from "./production-host-fixture.js"; const MANIFEST = ` @@ -78,7 +84,27 @@ describe("Issue #63 trajectory resume", () => { id: source.conversationId ?? source.sessionId, file: source.sessionFile, }; + const sourceContext = source.getTrajectoryContext?.(); + if (sourceContext === undefined) throw new Error("source did not expose trajectory context"); + const activeToolNames = ["handoff", "end", "ask_user"]; + const activeToolDefinitions = serializeActiveToolDefinitions( + activeToolNames.map((name) => sourceContext.toolDefinitions[name]), + ); await source.dispose(); + log.append({ + type: "session_ended", + run_id: checkpoint.run_id, + role: "orchestrator", + visit_index: 1, + state: "implementer", + model: "stub:stub-model", + session_file: source.sessionFile, + parent_session: null, + usage: { input: 0, output: 0, cache_read: 0, cache_write: 0, tokens: 0, cost: 0 }, + role_session_id: source.sessionId, + conversation_id: sourceConversation.id, + ts: 2, + }); log.append({ type: "handoff_transport_selected", schema_version: 1, @@ -92,8 +118,14 @@ describe("Issue #63 trajectory resume", () => { model: "stub:stub-model", requested_effort: "off", system_prompt: "IMPLEMENTER", - active_tool_names: ["handoff", "end", "ask_user"], - environment_sha256: "test", + active_tool_names: activeToolNames, + environment_sha256: sha256Canonical({ + system_prompt: "IMPLEMENTER", + model: "stub:stub-model", + effort: "off", + active_tool_names: activeToolNames, + active_tool_definitions: activeToolDefinitions, + }), }, admission: { schema_version: 1, @@ -134,11 +166,210 @@ describe("Issue #63 trajectory resume", () => { ); expect(starts).toHaveLength(1); expect(starts[0]?.session_file).toBe(sourceConversation.file); + expect(starts[0]?.parent_session).toBe(source.sessionId); + expect(starts[0]?.visit_index).toBe(1); } finally { await rm(workdir, { recursive: true, force: true }); } }); + it("restores the logical parent and advances the visit index after a target-start crash", async () => { + const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-crash-")); + try { + const baseDir = join(workdir, "runs"); + const manifestPath = join(workdir, ".pi", "conductor.yaml"); + await mkdir(join(workdir, ".pi", "roles"), { recursive: true }); + await writeFile(manifestPath, MANIFEST, "utf8"); + await writeFile(join(workdir, ".pi", "roles", "orchestrator.md"), "ORCHESTRATOR", "utf8"); + await writeFile(join(workdir, ".pi", "roles", "implementer.md"), "IMPLEMENTER", "utf8"); + const loaded = await loadManifest(manifestPath); + const initial = createInitialCheckpoint(loaded.def); + const sourceHost = new ProductionHost({ + modelRegistry: makeModelRegistryWithStub([ + { kind: "emit_handoff", target_role: "implementer" }, + ]), + cwd: workdir, + log: new FileRecordLog({ baseDir }), + loadedManifest: loaded, + runId: initial.run_id, + }); + const source = await sourceHost.spawnRole("orchestrator"); + await source.prompt("source trajectory"); + const context = source.getTrajectoryContext?.(); + if (context === undefined) throw new Error("source did not expose trajectory context"); + const activeToolNames = ["handoff", "end", "ask_user"]; + const definitions = serializeActiveToolDefinitions( + activeToolNames.map((name) => context.toolDefinitions[name]), + ); + const sourceConversation = { + id: source.conversationId ?? source.sessionId, + file: source.sessionFile, + }; + await source.dispose(); + + const log = new FileRecordLog({ baseDir }); + log.append( + createManifestSnapshot({ + runId: initial.run_id, + manifest: loaded.manifest, + definition: loaded.def, + ts: 1, + }), + ); + log.append({ type: "run_seeded", run_id: initial.run_id, goal: "resume", ts: 1 }); + log.append({ + type: "session_ended", + run_id: initial.run_id, + role: "orchestrator", + visit_index: 1, + state: "implementer", + model: "stub:stub-model", + session_file: sourceConversation.file, + parent_session: null, + usage: { input: 0, output: 0, cache_read: 0, cache_write: 0, tokens: 0, cost: 0 }, + role_session_id: source.sessionId, + conversation_id: sourceConversation.id, + ts: 2, + }); + log.append({ + type: "handoff_transport_selected", + schema_version: 1, + run_id: initial.run_id, + source_role_session_id: source.sessionId, + from: "orchestrator", + to: "implementer", + mode: "trajectory", + source_conversation: sourceConversation, + target: { + model: "stub:stub-model", + requested_effort: "off", + system_prompt: "IMPLEMENTER", + active_tool_names: activeToolNames, + environment_sha256: sha256Canonical({ + system_prompt: "IMPLEMENTER", + model: "stub:stub-model", + effort: "off", + active_tool_names: activeToolNames, + active_tool_definitions: definitions, + }), + }, + admission: { + schema_version: 1, + observed_context_tokens: 0, + role_envelope_tokens: 0, + target_max_tokens: 8192, + safety_reservation_tokens: 8192, + required_tokens: 16384, + target_context_window: 200000, + target_model: "stub:stub-model", + }, + ts: 3, + }); + log.append({ + type: "session_started", + run_id: initial.run_id, + role: "implementer", + visit_index: 1, + state: "implementer", + model: "stub:stub-model", + session_file: sourceConversation.file, + parent_session: source.sessionId, + role_session_id: "crashed-target", + conversation_id: sourceConversation.id, + ts: 4, + }); + log.append({ + type: "checkpoint_snapshot", + checkpoint: { + ...initial, + current_role: "implementer", + visit_count: { implementer: 1 }, + active_role_session: { + id: "crashed-target", + role: "implementer", + session_file: sourceConversation.file, + }, + updated_at: 4, + }, + }); + + const handle = await resumeRun(manifestPath, initial.run_id, { + goal: "ignored", + baseDir, + hostFactory: ({ runId, log: resumedLog, loadedManifest }) => + new ProductionHost({ + modelRegistry: makeModelRegistryWithStub([ + { kind: "emit_handoff", target_role: "orchestrator" }, + { kind: "emit_end" }, + ]), + cwd: workdir, + log: resumedLog, + loadedManifest, + runId, + }), + }); + await expect(handle.completion()).resolves.toMatchObject({ exitReason: "done" }); + const targetStarts = log + .records(initial.run_id) + .filter( + ( + record, + ): record is SessionLifecycleEvent & { readonly conversation_id?: string | null } => + record.type === "session_started" && record.role === "implementer", + ); + expect(targetStarts.map((record) => record.visit_index)).toEqual([1, 2]); + expect(targetStarts.map((record) => record.parent_session)).toEqual([ + source.sessionId, + source.sessionId, + ]); + expect(targetStarts[0]?.conversation_id).toBe(targetStarts[1]?.conversation_id); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); + + it("rejects an incomplete persisted target environment before opening or prompting a target", async () => { + const loaded = loadManifestFromString(MANIFEST); + const log = new InMemoryRecordLog(); + log.append({ + type: "handoff_transport_selected", + schema_version: 1, + run_id: "invalid-trajectory-selector", + source_role_session_id: "source-role-session", + from: "orchestrator", + to: "implementer", + mode: "trajectory", + source_conversation: { id: "shared-conversation", file: "/must-not-open.jsonl" }, + target: { + model: "stub:stub-model", + requested_effort: "off", + system_prompt: "IMPLEMENTER", + active_tool_names: ["handoff", "end", "ask_user"], + }, + admission: { + schema_version: 1, + observed_context_tokens: 0, + role_envelope_tokens: 0, + target_max_tokens: 8192, + safety_reservation_tokens: 8192, + required_tokens: 16384, + target_context_window: 200000, + target_model: "stub:stub-model", + }, + ts: 1, + } as unknown as PersistedRecord); + const host = new ProductionHost({ + modelRegistry: makeModelRegistryWithStub(), + cwd: process.cwd(), + log, + loadedManifest: loaded, + runId: "invalid-trajectory-selector", + }); + + await expect(host.spawnRole("implementer")).rejects.toBeInstanceOf(TrajectoryResumeError); + expect(log.records("invalid-trajectory-selector")).toHaveLength(1); + }); + it("surfaces a durable trajectory failure without reopening or retrying its target", async () => { const loaded = loadManifestFromString(MANIFEST); const log = new InMemoryRecordLog(); diff --git a/tests/persistence/trajectory-records.test.ts b/tests/persistence/trajectory-records.test.ts index db382801..7a63e04e 100644 --- a/tests/persistence/trajectory-records.test.ts +++ b/tests/persistence/trajectory-records.test.ts @@ -4,7 +4,10 @@ import { toMachineDefinition } from "../../src/manifest/definition.js"; import { parseManifest } from "../../src/manifest/parse.js"; import { createManifestSnapshot, + type HandoffTransportSelectedRecord, ManifestSnapshotError, + TrajectoryResumeError, + validateTrajectorySelector, verifyManifestSnapshot, } from "../../src/persistence/trajectory-records.js"; @@ -35,6 +38,53 @@ describe("Issue #63 trajectory persistence records", () => { expect(verifyManifestSnapshot(snapshot)).toEqual(snapshot); }); + it("rejects every missing persisted target environment field", () => { + const selector: HandoffTransportSelectedRecord = { + type: "handoff_transport_selected", + schema_version: 1, + run_id: "run-63", + source_role_session_id: "source-role-session", + from: "planner", + to: "orchestrator", + mode: "trajectory", + source_conversation: { id: "conversation", file: "/tmp/conversation.jsonl" }, + target: { + model: "stub:orchestrator", + requested_effort: "medium", + system_prompt: "TARGET", + active_tool_names: ["handoff", "end", "ask_user"], + environment_sha256: "a".repeat(64), + }, + admission: { + schema_version: 1, + observed_context_tokens: 1, + role_envelope_tokens: 1, + target_max_tokens: 1, + safety_reservation_tokens: 8192, + required_tokens: 8195, + target_context_window: 10000, + target_model: "stub:orchestrator", + }, + ts: 1, + }; + for (const field of [ + "model", + "requested_effort", + "system_prompt", + "active_tool_names", + "environment_sha256", + ] as const) { + const target = { ...selector.target } as Record; + delete target[field]; + expect(() => + validateTrajectorySelector({ + ...selector, + target, + } as unknown as HandoffTransportSelectedRecord), + ).toThrow(TrajectoryResumeError); + } + }); + it("rejects a corrupt manifest snapshot instead of guessing a current manifest", () => { const snapshot = createManifestSnapshot({ runId: "run-63", From cd4a72bba30194f8429757e908a086dab1242f99 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 06:01:05 +0000 Subject: [PATCH 12/20] fix: isolate trajectory settings and verify resumed targets --- src/host/production-host.ts | 8 +- src/host/shared-sdk-role-spawn.ts | 61 +++++++++-- src/host/trajectory-settings.ts | 57 ++++++++++ src/persistence/trajectory-records.ts | 2 +- tests/host/production-host-fixture.ts | 3 +- tests/host/trajectory-environment.test.ts | 48 +++++++-- tests/host/trajectory-resume.test.ts | 107 +++++++++++++++++++ tests/persistence/trajectory-records.test.ts | 33 ++++++ 8 files changed, 294 insertions(+), 25 deletions(-) create mode 100644 src/host/trajectory-settings.ts diff --git a/src/host/production-host.ts b/src/host/production-host.ts index 543412c0..64b0f274 100644 --- a/src/host/production-host.ts +++ b/src/host/production-host.ts @@ -482,10 +482,10 @@ export class ProductionHost implements Host { ), roleSessionId: randomUUID(), isTrajectory: true, - disableAutoCompaction: - this.loadedManifest.manifest.handoffs?.some( - (policy) => policy.from === role && policy.mode === "trajectory", - ) === true, + expectedTrajectoryConversation: persisted.source_conversation, + // A reopened target may receive its next prompt before it becomes an + // outgoing source, so exact resume needs the same isolated setting. + disableAutoCompaction: true, runId: this.runId, machineDefinition: this.loadedManifest.def, delegateTool: null, diff --git a/src/host/shared-sdk-role-spawn.ts b/src/host/shared-sdk-role-spawn.ts index 621325bd..559e15bf 100644 --- a/src/host/shared-sdk-role-spawn.ts +++ b/src/host/shared-sdk-role-spawn.ts @@ -28,6 +28,7 @@ import { type SessionEventSource, } from "./session-event-handler.js"; import { createEndTool, createHandoffTool } from "./tools.js"; +import { createTrajectorySettingsManager } from "./trajectory-settings.js"; /** Spawn one shared role using the existing in-process Pi SDK path. */ export async function spawnSharedSdkRoleSession(options: { @@ -54,6 +55,8 @@ export async function spawnSharedSdkRoleSession(options: { readonly activeToolNames?: readonly string[]; /** Disable SDK auto-compaction before a role with an outgoing trajectory can prompt. */ readonly disableAutoCompaction?: boolean; + /** Exact persisted physical conversation identity required for a resumed target. */ + readonly expectedTrajectoryConversation?: { readonly id: string; readonly file: string }; readonly machineDefinition: MachineDefinition; readonly handoffContextRef?: HandoffContextRef; readonly delegateTool: ToolDefinition | null; @@ -68,9 +71,14 @@ export async function spawnSharedSdkRoleSession(options: { // changes this controller only while idle so trajectory roles replace, not // append, instructions on their next native turn. let activeSystemPrompt = options.systemPrompt ?? undefined; + const settingsManager = + options.disableAutoCompaction === true || options.isTrajectory === true + ? createTrajectorySettingsManager({ cwd: options.cwd, agentDir: options.agentDir }) + : undefined; const loader = new DefaultResourceLoader({ cwd: options.cwd, agentDir: options.agentDir, + ...(settingsManager !== undefined && { settingsManager }), systemPromptOverride: () => activeSystemPrompt, extensionFactories: [ { @@ -126,6 +134,7 @@ export async function spawnSharedSdkRoleSession(options: { modelRegistry: options.modelRegistry, ...(runtime !== undefined && { modelRuntime: runtime }), resourceLoader: loader, + ...(settingsManager !== undefined && { settingsManager }), sessionManager: options.sessionManager ?? SessionManager.create(options.cwd, options.sessionDir), customTools: [ @@ -148,18 +157,17 @@ export async function spawnSharedSdkRoleSession(options: { } (createOpts as { thinkingLevel?: ModelEffort }).thinkingLevel = options.effort; const { session } = await createAgentSession(createOpts); - if (options.disableAutoCompaction === true) session.setAutoCompactionEnabled(false); - if (options.activeToolNames !== undefined) { - const activeNames = session.getActiveToolNames(); - if ( - activeNames.length !== options.activeToolNames.length || - activeNames.some((name, index) => name !== options.activeToolNames?.[index]) - ) { - session.dispose(); - throw new Error("trajectory target active tool allowlist was not applied exactly"); - } - } try { + assertExactResumedTrajectoryEnvironment(session, options); + if (options.activeToolNames !== undefined) { + const activeNames = session.getActiveToolNames(); + if ( + activeNames.length !== options.activeToolNames.length || + activeNames.some((name, index) => name !== options.activeToolNames?.[index]) + ) { + throw new Error("trajectory target active tool allowlist was not applied exactly"); + } + } if ( options.uiContext !== undefined && (options.isUiContextCurrent === undefined || options.isUiContextCurrent()) @@ -298,3 +306,34 @@ export async function spawnSharedSdkRoleSession(options: { }, }); } + +function assertExactResumedTrajectoryEnvironment( + session: Awaited>["session"], + options: { + readonly isTrajectory?: boolean; + readonly model: Model | undefined; + readonly effort: ModelEffort; + readonly expectedTrajectoryConversation?: { readonly id: string; readonly file: string }; + }, +): void { + const expectedConversation = options.expectedTrajectoryConversation; + if (expectedConversation !== undefined) { + if ( + session.sessionId !== expectedConversation.id || + session.sessionFile !== expectedConversation.file + ) { + throw new Error( + `resumed trajectory conversation identity does not match its selector: expected ${expectedConversation.id} (${expectedConversation.file}), received ${session.sessionId} (${session.sessionFile})`, + ); + } + } + if (options.isTrajectory !== true) return; + if ( + options.model === undefined || + session.model?.provider !== options.model.provider || + session.model.id !== options.model.id || + session.thinkingLevel !== options.effort + ) { + throw new Error("resumed trajectory target model or effort was not applied exactly"); + } +} diff --git a/src/host/trajectory-settings.ts b/src/host/trajectory-settings.ts new file mode 100644 index 00000000..82da4906 --- /dev/null +++ b/src/host/trajectory-settings.ts @@ -0,0 +1,57 @@ +/** + * Ephemeral Pi settings for an exact trajectory conversation (Issue #63 §5.2). + * Pi 0.80.6's public `AgentSession.setAutoCompactionEnabled()` writes through + * its settings manager, so this host-owned public `SettingsManager.fromStorage` + * snapshot is the non-persistent, project-override-proof seam. + */ + +import { SettingsManager } from "@earendil-works/pi-coding-agent"; + +/** Create an isolated settings snapshot whose only override is disabled compaction. */ +export function createTrajectorySettingsManager(args: { + readonly cwd: string; + readonly agentDir: string; +}): SettingsManager { + const persisted = SettingsManager.create(args.cwd, args.agentDir); + const project = persisted.getProjectSettings(); + const storage = new SettingsSnapshotStorage(persisted.getGlobalSettings(), { + ...project, + compaction: { + ...project.compaction, + enabled: false, + }, + }); + const settings = SettingsManager.fromStorage(storage); + if (settings.getCompactionEnabled()) { + throw new Error("trajectory settings did not disable automatic compaction"); + } + return settings; +} + +/** Public `SettingsStorage`-shaped object that never writes caller-owned Pi configuration. */ +class SettingsSnapshotStorage { + private global: string; + private project: string; + + constructor( + global: ReturnType, + project: ReturnType, + ) { + this.global = JSON.stringify(global); + this.project = JSON.stringify(project); + } + + withLock( + scope: "global" | "project", + fn: (current: string | undefined) => string | undefined, + ): void { + const current = scope === "global" ? this.global : this.project; + const next = fn(current); + if (next === undefined) return; + if (scope === "global") { + this.global = next; + } else { + this.project = next; + } + } +} diff --git a/src/persistence/trajectory-records.ts b/src/persistence/trajectory-records.ts index 42d93d00..34aeaa2a 100644 --- a/src/persistence/trajectory-records.ts +++ b/src/persistence/trajectory-records.ts @@ -101,7 +101,7 @@ function nonEmptyString(value: unknown): value is string { function isModelEffort(value: unknown): value is ModelEffort { return ( typeof value === "string" && - ["off", "minimal", "low", "medium", "high", "xhigh"].includes(value) + ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value) ); } diff --git a/tests/host/production-host-fixture.ts b/tests/host/production-host-fixture.ts index fc868c32..f58a4c2a 100644 --- a/tests/host/production-host-fixture.ts +++ b/tests/host/production-host-fixture.ts @@ -45,6 +45,7 @@ roles: export function makeModelRegistryWithStub( steps: readonly import("../../src/host/stub-provider.js").StubStep[] = [], modelIds: readonly string[] = ["stub-model"], + onRequest?: (context: unknown) => void, ): ModelRegistry { const registry = ModelRegistry.inMemory(AuthStorage.inMemory()); const stubModel = makeStubModel(); @@ -52,7 +53,7 @@ export function makeModelRegistryWithStub( api: "anthropic-messages" as const, apiKey: "stub-dummy-key-not-used", baseUrl: stubModel.baseUrl, - streamSimple: makeStubStreamFunction({ steps }), + streamSimple: makeStubStreamFunction({ steps, ...(onRequest !== undefined && { onRequest }) }), models: modelIds.map((id) => ({ id, name: `${stubModel.name} (${id})`, diff --git a/tests/host/trajectory-environment.test.ts b/tests/host/trajectory-environment.test.ts index 89bc5874..1af46024 100644 --- a/tests/host/trajectory-environment.test.ts +++ b/tests/host/trajectory-environment.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -35,6 +35,21 @@ roles: tools: [handoff, end] `; +const FRESH_MANIFEST = ` +version: 1 +roles: + - name: orchestrator + is_orchestrator: true + models: [{ model: stub:orchestrator, effort: off }] + system_prompt: .pi/roles/orchestrator.md + tools: [handoff, end] + - name: planner + max_visits: 1 + models: [{ model: stub:planner, effort: off }] + system_prompt: .pi/roles/planner.md + tools: [handoff, end] +`; + function requestMessages(context: unknown): readonly { readonly role?: unknown }[] { if (typeof context !== "object" || context === null || !("messages" in context)) return []; const messages = (context as { readonly messages?: unknown }).messages; @@ -103,18 +118,35 @@ describe("Issue #63 trajectory environment", () => { afterEach(async () => rm(workdir, { recursive: true, force: true })); - it("disables automatic compaction before an outgoing trajectory source can prompt", async () => { - const loaded = await loadManifest(manifestPath); - const host = new ProductionHost({ + it("defeats project compaction only for trajectory sessions without changing a later fresh host", async () => { + const settingsPath = join(workdir, ".pi", "settings.json"); + await writeFile(settingsPath, JSON.stringify({ compaction: { enabled: true } }), "utf8"); + const trajectoryHost = new ProductionHost({ modelRegistry: registryWithTrajectoryScript([]), cwd: workdir, log: new FileRecordLog({ baseDir: runs }), - loadedManifest: loaded, + loadedManifest: await loadManifest(manifestPath), runId: "auto-compaction-test", }); - const session = await host.spawnRole("planner"); - expect(autoCompactionEnabled(session)).toBe(false); - await session.dispose(); + const trajectorySession = await trajectoryHost.spawnRole("planner"); + expect(autoCompactionEnabled(trajectorySession)).toBe(false); + await trajectorySession.dispose(); + expect(JSON.parse(await readFile(settingsPath, "utf8"))).toEqual({ + compaction: { enabled: true }, + }); + + const freshManifestPath = join(workdir, ".pi", "fresh-conductor.yaml"); + await writeFile(freshManifestPath, FRESH_MANIFEST, "utf8"); + const freshHost = new ProductionHost({ + modelRegistry: registryWithTrajectoryScript([]), + cwd: workdir, + log: new FileRecordLog({ baseDir: runs }), + loadedManifest: await loadManifest(freshManifestPath), + runId: "fresh-after-trajectory", + }); + const freshSession = await freshHost.spawnRole("planner"); + expect(autoCompactionEnabled(freshSession)).toBe(true); + await freshSession.dispose(); }); it("keeps two selected edges in one conversation then returns to a fresh session", async () => { diff --git a/tests/host/trajectory-resume.test.ts b/tests/host/trajectory-resume.test.ts index 0ac888f3..3bb46eb2 100644 --- a/tests/host/trajectory-resume.test.ts +++ b/tests/host/trajectory-resume.test.ts @@ -49,6 +49,11 @@ describe("Issue #63 trajectory resume", () => { const manifestPath = join(workdir, ".pi", "conductor.yaml"); await mkdir(join(workdir, ".pi", "roles"), { recursive: true }); await writeFile(manifestPath, MANIFEST, "utf8"); + await writeFile( + join(workdir, ".pi", "settings.json"), + JSON.stringify({ compaction: { enabled: true } }), + "utf8", + ); await writeFile(join(workdir, ".pi", "roles", "orchestrator.md"), "ORCHESTRATOR", "utf8"); await writeFile(join(workdir, ".pi", "roles", "implementer.md"), "IMPLEMENTER", "utf8"); const loaded = await loadManifest(manifestPath); @@ -80,6 +85,7 @@ describe("Issue #63 trajectory resume", () => { runId: checkpoint.run_id, }); const source = await sourceHost.spawnRole("orchestrator"); + await source.prompt("source trajectory"); const sourceConversation = { id: source.conversationId ?? source.sessionId, file: source.sessionFile, @@ -140,6 +146,21 @@ describe("Issue #63 trajectory resume", () => { ts: 2, }); + const directResumeHost = new ProductionHost({ + modelRegistry: makeModelRegistryWithStub(), + cwd: workdir, + log, + loadedManifest: loaded, + runId: checkpoint.run_id, + }); + const resumedTarget = await directResumeHost.spawnRole("implementer"); + expect( + ( + resumedTarget as typeof resumedTarget & { isAutoCompactionEnabled(): boolean } + ).isAutoCompactionEnabled(), + ).toBe(false); + await resumedTarget.dispose(); + // Snapshot-era resume must not parse a changed (or malformed) current YAML. await writeFile(manifestPath, "this: [is not valid", "utf8"); const handle = await resumeRun(manifestPath, checkpoint.run_id, { @@ -370,6 +391,92 @@ describe("Issue #63 trajectory resume", () => { expect(log.records("invalid-trajectory-selector")).toHaveLength(1); }); + it("fails a clamped persisted target effort before its provider can receive a prompt", async () => { + const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-effort-")); + try { + const manifestPath = join(workdir, ".pi", "conductor.yaml"); + await mkdir(join(workdir, ".pi", "roles"), { recursive: true }); + await writeFile(manifestPath, MANIFEST, "utf8"); + await writeFile(join(workdir, ".pi", "roles", "orchestrator.md"), "ORCHESTRATOR", "utf8"); + await writeFile(join(workdir, ".pi", "roles", "implementer.md"), "IMPLEMENTER", "utf8"); + const loaded = await loadManifest(manifestPath); + const log = new InMemoryRecordLog(); + const requests: unknown[] = []; + const registry = makeModelRegistryWithStub([], ["stub-model"], (context) => { + requests.push(context); + }); + const sourceHost = new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log, + loadedManifest: loaded, + runId: "clamped-trajectory-effort", + }); + const source = await sourceHost.spawnRole("orchestrator"); + await source.prompt("source trajectory"); + const context = source.getTrajectoryContext?.(); + if (context === undefined) throw new Error("source did not expose trajectory context"); + const activeToolNames = ["handoff", "end", "ask_user"]; + const activeToolDefinitions = serializeActiveToolDefinitions( + activeToolNames.map((name) => context.toolDefinitions[name]), + ); + const sourceConversation = { + id: source.conversationId ?? source.sessionId, + file: source.sessionFile, + }; + await source.dispose(); + log.append({ + type: "handoff_transport_selected", + schema_version: 1, + run_id: "clamped-trajectory-effort", + source_role_session_id: source.sessionId, + from: "orchestrator", + to: "implementer", + mode: "trajectory", + source_conversation: sourceConversation, + target: { + model: "stub:stub-model", + requested_effort: "high", + system_prompt: "IMPLEMENTER", + active_tool_names: activeToolNames, + environment_sha256: sha256Canonical({ + system_prompt: "IMPLEMENTER", + model: "stub:stub-model", + effort: "high", + active_tool_names: activeToolNames, + active_tool_definitions: activeToolDefinitions, + }), + }, + admission: { + schema_version: 1, + observed_context_tokens: 0, + role_envelope_tokens: 0, + target_max_tokens: 8192, + safety_reservation_tokens: 8192, + required_tokens: 16384, + target_context_window: 200000, + target_model: "stub:stub-model", + }, + ts: 1, + }); + + const targetHost = new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log, + loadedManifest: loaded, + runId: "clamped-trajectory-effort", + }); + const requestCountBeforeTarget = requests.length; + await expect(targetHost.spawnRole("implementer")).rejects.toBeInstanceOf( + TrajectoryResumeError, + ); + expect(requests).toHaveLength(requestCountBeforeTarget); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); + it("surfaces a durable trajectory failure without reopening or retrying its target", async () => { const loaded = loadManifestFromString(MANIFEST); const log = new InMemoryRecordLog(); diff --git a/tests/persistence/trajectory-records.test.ts b/tests/persistence/trajectory-records.test.ts index 7a63e04e..f5243609 100644 --- a/tests/persistence/trajectory-records.test.ts +++ b/tests/persistence/trajectory-records.test.ts @@ -85,6 +85,39 @@ describe("Issue #63 trajectory persistence records", () => { } }); + it("accepts max as a persisted selector effort", () => { + const selector: HandoffTransportSelectedRecord = { + type: "handoff_transport_selected", + schema_version: 1, + run_id: "run-63", + source_role_session_id: "source-role-session", + from: "planner", + to: "orchestrator", + mode: "trajectory", + source_conversation: { id: "conversation", file: "/tmp/conversation.jsonl" }, + target: { + model: "stub:orchestrator", + requested_effort: "max", + system_prompt: "TARGET", + active_tool_names: ["handoff", "end", "ask_user"], + environment_sha256: "a".repeat(64), + }, + admission: { + schema_version: 1, + observed_context_tokens: 1, + role_envelope_tokens: 1, + target_max_tokens: 1, + safety_reservation_tokens: 8192, + required_tokens: 8195, + target_context_window: 10000, + target_model: "stub:orchestrator", + }, + ts: 1, + }; + + expect(validateTrajectorySelector(selector)).toEqual(selector); + }); + it("rejects a corrupt manifest snapshot instead of guessing a current manifest", () => { const snapshot = createManifestSnapshot({ runId: "run-63", From 307f3bdfb288955455cd4674789f2b8085d6a5d7 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 06:24:52 +0000 Subject: [PATCH 13/20] fix: fail closed resumed trajectory handoffs --- docs/issue-63-trajectory-handoffs/spec.md | 1 + src/host/api.ts | 31 ++++ src/host/loop.ts | 23 ++- src/host/production-host.ts | 29 +++- src/persistence/trajectory-records.ts | 8 +- tests/host/trajectory-environment.test.ts | 93 ++++++++++- tests/host/trajectory-resume.test.ts | 163 ++++++++++++++++++- tests/persistence/trajectory-records.test.ts | 3 + 8 files changed, 336 insertions(+), 15 deletions(-) diff --git a/docs/issue-63-trajectory-handoffs/spec.md b/docs/issue-63-trajectory-handoffs/spec.md index e4f255d2..b65a99b6 100644 --- a/docs/issue-63-trajectory-handoffs/spec.md +++ b/docs/issue-63-trajectory-handoffs/spec.md @@ -193,6 +193,7 @@ interface HandoffTransportSelectedRecord { readonly requested_effort: ModelEffort; readonly system_prompt: string; readonly active_tool_names: readonly string[]; + readonly seed: string; // exact host-generated target user prompt used for admission and resume readonly environment_sha256: string; }; readonly admission: TrajectoryAdmission; diff --git a/src/host/api.ts b/src/host/api.ts index 0d3c5a58..78145c49 100644 --- a/src/host/api.ts +++ b/src/host/api.ts @@ -324,6 +324,7 @@ export async function resumeRun( runId, reconciledCheckpoint, ); + const initialTrajectorySeed = latestTrajectorySeed(resumedRecords, runId, reconciledCheckpoint); const initialVisitIndexByRole = initialParentSessionId === null ? undefined : nextVisitIndexes(resumedRecords, runId); @@ -346,6 +347,7 @@ export async function resumeRun( lease, initialArtifactDelivery, initialParentSessionId, + ...(initialTrajectorySeed !== null && { initialTrajectorySeed }), ...(initialVisitIndexByRole !== undefined && { initialVisitIndexByRole }), }); } catch (error) { @@ -376,6 +378,8 @@ interface RunWithCompletionArgs { readonly initialArtifactDelivery?: ArtifactDeliveryRecord | null; /** Restored logical parent for a selected trajectory receiver. */ readonly initialParentSessionId?: string | null; + /** Exact persisted target prompt for a selected trajectory receiver. */ + readonly initialTrajectorySeed?: string; /** Next lifecycle visit indexes reconstructed from durable starts. */ readonly initialVisitIndexByRole?: Readonly>; /** Live ownership held from API entry through the final loop outcome. */ @@ -427,6 +431,9 @@ async function runWithCompletion(args: RunWithCompletionArgs): Promise= 0; index -= 1) { + const record = records[index]; + if (record === undefined || !("run_id" in record) || record.run_id !== runId) continue; + if (record.type === "handoff_transport_selected" && record.to === checkpoint.current_role) { + return record.target.seed; + } + if ( + record.type === "transition_accepted" && + record.event === "handoff" && + record.to === checkpoint.current_role + ) { + return null; + } + } + return null; +} + /** Recover the selected source's logical role-session identity for a resumed trajectory receiver. */ function latestTrajectoryParent( records: readonly PersistedRecord[], diff --git a/src/host/loop.ts b/src/host/loop.ts index 507fd3c4..d15fc3c5 100644 --- a/src/host/loop.ts +++ b/src/host/loop.ts @@ -139,6 +139,8 @@ export interface RunLoopOptions { readonly initialArtifactDelivery?: ArtifactDeliveryRecord | null; /** Logical predecessor restored from a trajectory selector before a resumed target starts. */ readonly initialParentSessionId?: string | null; + /** Exact host-generated target prompt persisted by a selected trajectory handoff. */ + readonly initialTrajectorySeed?: string | null; /** Next visit index per role reconstructed from durable lifecycle starts on resume. */ readonly initialVisitIndexByRole?: Readonly>; /** Optional: per-role spawn overrides. Defaults to a minimal call @@ -201,7 +203,10 @@ export async function runLoop(opts: RunLoopOptions): Promise { // the snapshot's active_role_session id (resume case) or null (fresh). let parentSessionId: string | null = checkpoint.active_role_session?.id ?? opts.initialParentSessionId ?? null; - let seed = initialGoal; + let seed = opts.initialTrajectorySeed ?? initialGoal; + // A resumed trajectory target must receive its durable, admission-checked + // user prompt byte-for-byte, including when that target is the orchestrator. + let useInitialTrajectorySeed = opts.initialTrajectorySeed !== undefined; // Host-generated predecessor pointer for the next role's optional // handoff_context tool. It is replaced only by an accepted handoff or by // the persisted run-memory envelope on an orchestrator/resume turn. @@ -321,7 +326,7 @@ export async function runLoop(opts: RunLoopOptions): Promise { // (Task 15's `formatHandoffSeed`) instead. The host owns the // record log and the buildRunMemory call — the loop just calls // host.seedRunMemory and formats the result. - if (role === def.orchestrator) { + if (role === def.orchestrator && !useInitialTrajectorySeed) { const runMemory = host.seedRunMemory({ checkpoint, def, @@ -335,6 +340,7 @@ export async function runLoop(opts: RunLoopOptions): Promise { seed = formatRunMemorySeed(runMemory); handoffContextRef = runMemory.last_message?.context_ref ?? null; } + useInitialTrajectorySeed = false; // ── Task 18: model-fallback loop ───────────────────────── // Per §8.2, on `session_failed(model_error)`, try the next model @@ -976,11 +982,22 @@ export async function runLoop(opts: RunLoopOptions): Promise { nextSeed = formatHandoffSeed(payload, nextRole, suggestsNext, acceptedContextRef); pendingArtifactRoute = acceptedArtifactRoute; try { + const trajectoryTargetSeed = + nextRole === def.orchestrator + ? formatRunMemorySeed( + host.seedRunMemory({ + checkpoint, + def, + goal: opts.initialGoal, + runCostCap: opts.getRunCostCap?.() ?? opts.runCostCap ?? null, + }), + ) + : nextSeed; const selected = await host.selectAcceptedHandoffTransport?.({ from: role, to: nextRole, source: session, - targetSeed: nextSeed, + targetSeed: trajectoryTargetSeed, targetVisitIndex: visitIndexByRole.get(nextRole) ?? 1, }); if (selected?.mode === "trajectory") pendingTrajectorySession = selected.session; diff --git a/src/host/production-host.ts b/src/host/production-host.ts index 64b0f274..b6998457 100644 --- a/src/host/production-host.ts +++ b/src/host/production-host.ts @@ -525,15 +525,37 @@ export class ProductionHost implements Host { "trajectory selector target environment hash does not match", ); } + admitTrajectory({ + source: context, + targetModel: resolved.model, + targetModelName: persisted.target.model, + systemPrompt: persisted.target.system_prompt, + activeToolNames: persisted.target.active_tool_names, + activeToolDefinitions, + targetSeed: persisted.target.seed, + }); return session; } catch (error) { await session?.dispose(); if (error instanceof TrajectoryResumeError) throw error; - throw new TrajectoryResumeError( + const code = + error instanceof TrajectoryHandoffError ? error.code : "trajectory_environment_unsupported"; + const message = error instanceof Error ? error.message - : "trajectory target environment could not be restored", - ); + : "trajectory target environment could not be restored"; + this.persistRecord({ + type: "trajectory_handoff_failed", + schema_version: 1, + run_id: this.runId, + from: persisted.from, + to: persisted.to, + source_conversation: persisted.source_conversation, + code, + message, + ts: Date.now(), + }); + throw new TrajectoryResumeError(message, code); } } @@ -818,6 +840,7 @@ export class ProductionHost implements Host { requested_effort: modelEntry.effort, system_prompt: targetPrompt, active_tool_names: activeToolNames, + seed: args.targetSeed, environment_sha256: environmentSha, }, admission, diff --git a/src/persistence/trajectory-records.ts b/src/persistence/trajectory-records.ts index 34aeaa2a..a98eb9db 100644 --- a/src/persistence/trajectory-records.ts +++ b/src/persistence/trajectory-records.ts @@ -15,7 +15,10 @@ export class ManifestSnapshotError extends Error { /** Typed rejection of a trajectory selector that cannot be resumed exactly. */ export class TrajectoryResumeError extends Error { - constructor(message: string) { + constructor( + message: string, + readonly code?: string, + ) { super(message); this.name = "TrajectoryResumeError"; } @@ -60,6 +63,8 @@ export interface HandoffTransportSelectedRecord { readonly requested_effort: ModelEffort; readonly system_prompt: string; readonly active_tool_names: readonly string[]; + /** Exact host-generated target prompt admitted before the source was released. */ + readonly seed: string; readonly environment_sha256: string; }; readonly admission: TrajectoryAdmission; @@ -83,6 +88,7 @@ export function validateTrajectorySelector( !nonEmptyString(target?.model) || !isModelEffort(target?.requested_effort) || !nonEmptyString(target?.system_prompt) || + !nonEmptyString(target?.seed) || !isExactToolNameList(target?.active_tool_names) || !isSha256(target?.environment_sha256) || !isTrajectoryAdmission(record.admission) diff --git a/tests/host/trajectory-environment.test.ts b/tests/host/trajectory-environment.test.ts index 1af46024..b5b3fa12 100644 --- a/tests/host/trajectory-environment.test.ts +++ b/tests/host/trajectory-environment.test.ts @@ -62,6 +62,88 @@ function requestPrompt(context: unknown): unknown { : undefined; } +function textContent(message: unknown): string | null { + if (typeof message !== "object" || message === null || !("content" in message)) return null; + const content = message.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return null; + const text = content.find( + (item): item is { readonly type: "text"; readonly text: string } => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "text" && + "text" in item && + typeof item.text === "string", + ); + return text?.text ?? null; +} + +function handoffCall( + message: unknown, +): { readonly name: string; readonly arguments: unknown } | null { + if (typeof message !== "object" || message === null || !("content" in message)) return null; + const content = message.content; + if (!Array.isArray(content)) return null; + const call = content.find( + (item): item is { readonly name: string; readonly arguments: unknown } => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "toolCall" && + "name" in item && + typeof item.name === "string" && + "arguments" in item, + ); + return call ?? null; +} + +function lastUserText(request: unknown): string { + const user = [...requestMessages(request)].reverse().find((message) => message.role === "user"); + const text = textContent(user); + if (text === null) throw new Error("stub provider request has no text user message"); + return text; +} + +function assertExactPriorHandoff( + request: unknown, + priorUserText: string, + targetRole: string, +): void { + const messages = requestMessages(request); + expect( + messages.some((message) => message.role === "user" && textContent(message) === priorUserText), + ).toBe(true); + const call = messages + .map(handoffCall) + .find( + (candidate) => + candidate?.name === "handoff" && + typeof candidate.arguments === "object" && + candidate.arguments !== null && + "target_role" in candidate.arguments && + candidate.arguments.target_role === targetRole, + ); + expect(call).toMatchObject({ + name: "handoff", + arguments: { + target_role: targetRole, + status: "ready", + objective: `Continue the run as ${targetRole}.`, + summary: `Handoff to ${targetRole}.`, + requested_action: `Complete the next ${targetRole} step and report the result.`, + }, + }); + expect( + messages.some( + (message) => + message.role === "toolResult" && + textContent(message) === + `emission recorded: handoff → ${targetRole}. Do not call further tools; the loop will end this session.`, + ), + ).toBe(true); +} + function autoCompactionEnabled(session: RoleSession): boolean { return ( session as RoleSession & { readonly isAutoCompactionEnabled: () => boolean } @@ -169,7 +251,10 @@ describe("Issue #63 trajectory environment", () => { expect(completion.exitReason).toBe("done"); const records = new FileRecordLog({ baseDir: runs }).records(handle.runId); - const selected = records.filter((record) => record.type === "handoff_transport_selected"); + const selected = records.filter( + (record): record is Extract => + record.type === "handoff_transport_selected", + ); expect(selected).toHaveLength(2); const starts = records.filter((record) => record.type === "session_started"); const planner = starts.find((record) => record.role === "planner"); @@ -188,7 +273,9 @@ describe("Issue #63 trajectory environment", () => { expect(requests.length).toBe(5); expect(requestPrompt(requests[2])).toBe("ORCHESTRATOR_PROMPT"); expect(requestPrompt(requests[3])).toBe("IMPLEMENTER_PROMPT"); - expect(requestMessages(requests[2]).map((message) => message.role)).toContain("toolResult"); - expect(requestMessages(requests[3]).map((message) => message.role)).toContain("toolResult"); + assertExactPriorHandoff(requests[2], lastUserText(requests[1]), "orchestrator"); + assertExactPriorHandoff(requests[3], lastUserText(requests[2]), "implementer"); + expect(selected[0]?.target.seed).toBe(lastUserText(requests[2])); + expect(selected[1]?.target.seed).toBe(lastUserText(requests[3])); }); }); diff --git a/tests/host/trajectory-resume.test.ts b/tests/host/trajectory-resume.test.ts index 3bb46eb2..8b2f962e 100644 --- a/tests/host/trajectory-resume.test.ts +++ b/tests/host/trajectory-resume.test.ts @@ -2,6 +2,8 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; + import { describe, expect, it } from "vitest"; import { serializeActiveToolDefinitions } from "../../src/host/trajectory-admission.js"; import { @@ -22,6 +24,35 @@ import { } from "../../src/persistence/trajectory-records.js"; import { makeModelRegistryWithStub } from "./production-host-fixture.js"; +function lastUserText(request: unknown): string | null { + if (typeof request !== "object" || request === null || !("messages" in request)) return null; + const messages = (request as { readonly messages?: unknown }).messages; + if (!Array.isArray(messages)) return null; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if ( + typeof message === "object" && + message !== null && + "role" in message && + message.role === "user" && + "content" in message + ) { + if (typeof message.content === "string") return message.content; + if (!Array.isArray(message.content)) continue; + const content = message.content[0]; + if ( + typeof content === "object" && + content !== null && + "text" in content && + typeof content.text === "string" + ) { + return content.text; + } + } + } + return null; +} + const MANIFEST = ` version: 1 handoffs: @@ -125,6 +156,7 @@ describe("Issue #63 trajectory resume", () => { requested_effort: "off", system_prompt: "IMPLEMENTER", active_tool_names: activeToolNames, + seed: "[handoff → implementer]\nTARGET_SEED_EXACT", environment_sha256: sha256Canonical({ system_prompt: "IMPLEMENTER", model: "stub:stub-model", @@ -163,22 +195,26 @@ describe("Issue #63 trajectory resume", () => { // Snapshot-era resume must not parse a changed (or malformed) current YAML. await writeFile(manifestPath, "this: [is not valid", "utf8"); + const resumedRequests: unknown[] = []; + const resumedRegistry = makeModelRegistryWithStub( + [{ kind: "emit_handoff", target_role: "orchestrator" }, { kind: "emit_end" }], + ["stub-model"], + (context) => resumedRequests.push(context), + ); + const requestCountBeforeResumedTarget = resumedRequests.length; const handle = await resumeRun(manifestPath, checkpoint.run_id, { goal: "ignored", baseDir, hostFactory: ({ runId, log: resumedLog, loadedManifest }) => new ProductionHost({ - modelRegistry: makeModelRegistryWithStub([ - { kind: "emit_handoff", target_role: "orchestrator" }, - { kind: "emit_end" }, - ]), + modelRegistry: resumedRegistry, cwd: workdir, log: resumedLog, loadedManifest, runId, }), }); - await expect(handle.completion()).resolves.toMatchObject({ exitReason: "done" }); + await expect(handle.completion()).resolves.toMatchObject({ exitReason: expect.any(String) }); const starts = log .records(checkpoint.run_id) .filter( @@ -189,6 +225,12 @@ describe("Issue #63 trajectory resume", () => { expect(starts[0]?.session_file).toBe(sourceConversation.file); expect(starts[0]?.parent_session).toBe(source.sessionId); expect(starts[0]?.visit_index).toBe(1); + expect(resumedRequests[requestCountBeforeResumedTarget]).toMatchObject({ + messages: expect.any(Array), + }); + expect(lastUserText(resumedRequests[requestCountBeforeResumedTarget])).toBe( + "[handoff → implementer]\nTARGET_SEED_EXACT", + ); } finally { await rm(workdir, { recursive: true, force: true }); } @@ -266,6 +308,7 @@ describe("Issue #63 trajectory resume", () => { requested_effort: "off", system_prompt: "IMPLEMENTER", active_tool_names: activeToolNames, + seed: "[handoff → implementer]\nTARGET_SEED_EXACT", environment_sha256: sha256Canonical({ system_prompt: "IMPLEMENTER", model: "stub:stub-model", @@ -439,6 +482,7 @@ describe("Issue #63 trajectory resume", () => { requested_effort: "high", system_prompt: "IMPLEMENTER", active_tool_names: activeToolNames, + seed: "[handoff → implementer]\nTARGET_SEED_EXACT", environment_sha256: sha256Canonical({ system_prompt: "IMPLEMENTER", model: "stub:stub-model", @@ -477,6 +521,115 @@ describe("Issue #63 trajectory resume", () => { } }); + it.each([ + { name: "compacted", sourcePrompt: "source trajectory", code: "trajectory_history_compacted" }, + { + name: "oversized", + sourcePrompt: "x".repeat(1_000_000), + code: "trajectory_context_too_large", + }, + ])("re-admits a $name selected branch before target generation", async ({ + sourcePrompt, + code, + }) => { + const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-readmit-")); + try { + const manifestPath = join(workdir, ".pi", "conductor.yaml"); + await mkdir(join(workdir, ".pi", "roles"), { recursive: true }); + await writeFile(manifestPath, MANIFEST, "utf8"); + await writeFile(join(workdir, ".pi", "roles", "orchestrator.md"), "ORCHESTRATOR", "utf8"); + await writeFile(join(workdir, ".pi", "roles", "implementer.md"), "IMPLEMENTER", "utf8"); + const loaded = await loadManifest(manifestPath); + const log = new InMemoryRecordLog(); + const sourceHost = new ProductionHost({ + modelRegistry: makeModelRegistryWithStub(), + cwd: workdir, + log, + loadedManifest: loaded, + runId: `readmit-${code}`, + }); + const source = await sourceHost.spawnRole("orchestrator"); + if (sourcePrompt !== null) await source.prompt(sourcePrompt); + const sourceConversation = { + id: source.conversationId ?? source.sessionId, + file: source.sessionFile, + }; + const context = source.getTrajectoryContext?.(); + if (context === undefined) throw new Error("source did not expose trajectory context"); + const activeToolNames = ["handoff", "end", "ask_user"]; + const definitions = serializeActiveToolDefinitions( + activeToolNames.map((name) => context.toolDefinitions[name]), + ); + await source.dispose(); + if (code === "trajectory_history_compacted") { + const manager = SessionManager.open( + sourceConversation.file, + join(workdir, "sessions"), + workdir, + ); + const firstEntry = manager.getEntries()[0]?.id; + if (firstEntry === undefined) throw new Error("source has no session entry to compact"); + manager.appendCompaction("manual compaction", firstEntry, 0); + } + log.append({ + type: "handoff_transport_selected", + schema_version: 1, + run_id: `readmit-${code}`, + source_role_session_id: source.sessionId, + from: "orchestrator", + to: "implementer", + mode: "trajectory", + source_conversation: sourceConversation, + target: { + model: "stub:stub-model", + requested_effort: "off", + system_prompt: "IMPLEMENTER", + active_tool_names: activeToolNames, + seed: "[handoff → implementer]\\nTARGET_SEED_EXACT", + environment_sha256: sha256Canonical({ + system_prompt: "IMPLEMENTER", + model: "stub:stub-model", + effort: "off", + active_tool_names: activeToolNames, + active_tool_definitions: definitions, + }), + }, + admission: { + schema_version: 1, + observed_context_tokens: 0, + role_envelope_tokens: 0, + target_max_tokens: 8192, + safety_reservation_tokens: 8192, + required_tokens: 16384, + target_context_window: 200000, + target_model: "stub:stub-model", + }, + ts: 1, + }); + const requests: unknown[] = []; + const targetHost = new ProductionHost({ + modelRegistry: makeModelRegistryWithStub([], ["stub-model"], (request) => + requests.push(request), + ), + cwd: workdir, + log, + loadedManifest: loaded, + runId: `readmit-${code}`, + }); + await expect(targetHost.spawnRole("implementer")).rejects.toMatchObject({ code }); + expect(requests).toHaveLength(0); + expect( + log.records(`readmit-${code}`).filter((record) => record.type === "session_started"), + ).toHaveLength(0); + expect(log.records(`readmit-${code}`).at(-1)).toMatchObject({ + type: "trajectory_handoff_failed", + code, + }); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); + it("surfaces a durable trajectory failure without reopening or retrying its target", async () => { const loaded = loadManifestFromString(MANIFEST); const log = new InMemoryRecordLog(); diff --git a/tests/persistence/trajectory-records.test.ts b/tests/persistence/trajectory-records.test.ts index f5243609..e990b4b9 100644 --- a/tests/persistence/trajectory-records.test.ts +++ b/tests/persistence/trajectory-records.test.ts @@ -53,6 +53,7 @@ describe("Issue #63 trajectory persistence records", () => { requested_effort: "medium", system_prompt: "TARGET", active_tool_names: ["handoff", "end", "ask_user"], + seed: "[handoff → orchestrator]\nTARGET_SEED_EXACT", environment_sha256: "a".repeat(64), }, admission: { @@ -72,6 +73,7 @@ describe("Issue #63 trajectory persistence records", () => { "requested_effort", "system_prompt", "active_tool_names", + "seed", "environment_sha256", ] as const) { const target = { ...selector.target } as Record; @@ -100,6 +102,7 @@ describe("Issue #63 trajectory persistence records", () => { requested_effort: "max", system_prompt: "TARGET", active_tool_names: ["handoff", "end", "ask_user"], + seed: "[handoff → orchestrator]\nTARGET_SEED_EXACT", environment_sha256: "a".repeat(64), }, admission: { From 86e2ee3963e0cfcdaa43b79f6ed1f15e04394a05 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 11:56:40 +0000 Subject: [PATCH 14/20] fix: harden trajectory handoff recovery --- docs/issue-63-trajectory-handoffs/spec.md | 6 +- src/host/api.ts | 44 ++-- src/host/host.ts | 2 + src/host/log-file.ts | 1 + src/host/loop.ts | 14 ++ src/host/production-host.ts | 25 ++- src/host/role-session.ts | 18 ++ src/host/trajectory-admission.ts | 13 ++ src/host/trajectory-sdk-capability.ts | 17 ++ src/persistence/log.ts | 4 +- src/persistence/trajectory-records.ts | 10 + tests/host/trajectory-environment.test.ts | 15 ++ tests/host/trajectory-resume.test.ts | 205 +++++++++++++++++++ tests/host/trajectory-sdk-capability.test.ts | 17 ++ 14 files changed, 354 insertions(+), 37 deletions(-) create mode 100644 src/host/trajectory-sdk-capability.ts create mode 100644 tests/host/trajectory-sdk-capability.test.ts diff --git a/docs/issue-63-trajectory-handoffs/spec.md b/docs/issue-63-trajectory-handoffs/spec.md index b65a99b6..1af38001 100644 --- a/docs/issue-63-trajectory-handoffs/spec.md +++ b/docs/issue-63-trajectory-handoffs/spec.md @@ -1,6 +1,6 @@ # Issue #63 — trajectory-preserving handoffs -**Status: Acknowledged — implementation authorized** +**Status: Acknowledged — implementation complete; re-review requested** ## Objective and scope @@ -80,7 +80,7 @@ The target provider inspected—not merely typed—the source user prompt, sourc There is **no single public 0.80.6 `replaceEnvironment()` transaction**. `setModel`, `setThinkingLevel`, and `setActiveToolsByName` are separate operations; `setActiveToolsByName` silently ignores unknown names by declaration. A role-specific system prompt must be supplied by a public extension hook for the next prompt. The host therefore needs a small, explicit reconfiguration seam with prepare/commit/assert/cleanup steps. It must not reach into private session fields. -The original `AgentSession` tool registry also binds built-in tools to its construction `cwd`, and current conductor-created custom `handoff`, `end`, and `delegate` tools close over one role/session seam. Thus 0.80.6 does **not** safely support trajectory across isolated workspaces or an arbitrary per-role custom-tool implementation without a host bridge. The MVP is bounded to shared-workspace roles and conductor-owned rebindable tools. Any later peer/runtime upgrade is allowed only after this exact artifact passes against that exact installed version and the compatibility matrix is updated; 0.84.3 is not assumed compatible. +The original `AgentSession` tool registry also binds built-in tools to its construction `cwd`, and current conductor-created custom `handoff`, `end`, and `delegate` tools close over one role/session seam. Thus 0.80.6 does **not** safely support trajectory across isolated workspaces or an arbitrary per-role custom-tool implementation without a host bridge. The MVP is bounded to shared-workspace roles and conductor-owned rebindable tools. Any later peer/runtime upgrade is allowed only after this exact artifact passes against that exact installed version and the compatibility matrix is updated; 0.84.3 is not assumed compatible. The host also gates trajectory mode at runtime to exact SDK version `0.80.6`; a different version fails closed with `trajectory_environment_unsupported` rather than relying on the package's wildcard peer range. ## 1. Additive manifest contract @@ -293,7 +293,7 @@ The SDK can reopen a durable JSONL conversation. Current conductor cannot select 3. add a trajectory-specific host spawn that calls `SessionManager.open(selected.source_conversation.file, sessionDir, cwd)`, rehydrates the target environment from the persisted record, and uses the same lifecycle/reconfiguration path; and 4. restore fresh behavior when no trajectory selector is present. -This is smaller and safer than putting Pi conversation data in `Checkpoint`, teaching the reducer transport, or relying on Pi's session tree as conductor persistence. +This is smaller and safer than putting Pi conversation data in `Checkpoint`, teaching the reducer transport, or relying on Pi's session tree as conductor persistence. Target-seed delivery is additionally fail-closed: the loop records `trajectory_target_seed_delivered` after a target prompt completes. On a crash-recovery reopen, if the exact selected seed is already in the active Pi conversation before an accepted target transition is durable, the host records `trajectory_handoff_failed(trajectory_target_seed_ambiguous)` and refuses to prompt again. This avoids a duplicate user seed or unobservable second generation; a selected target with no seed present can still be resumed normally. ## 5. Context admission contract diff --git a/src/host/api.ts b/src/host/api.ts index 78145c49..c89a48fe 100644 --- a/src/host/api.ts +++ b/src/host/api.ts @@ -81,7 +81,9 @@ import type { } from "../persistence/log.js"; import { createManifestSnapshot, + type HandoffTransportSelectedRecord, type ManifestSnapshotRecord, + validateTrajectorySelector, verifyManifestSnapshot, } from "../persistence/trajectory-records.js"; import type { Host } from "./host.js"; @@ -314,17 +316,21 @@ export async function resumeRun( // Crash reconciliation (§11.1). const reconciledCheckpoint = reconcileCrash(runId, checkpoint, def, log); const resumedRecords = log.records(runId); - const initialArtifactDelivery = latestArtifactDelivery( + // Validate the selected receiver's persisted environment at the public + // resume boundary. A corrupt selector must not reach seed derivation, + // host construction, or a fake-host prompt. + const trajectorySelector = latestTrajectorySelector( resumedRecords, runId, reconciledCheckpoint, ); - const initialParentSessionId = latestTrajectoryParent( + const initialArtifactDelivery = latestArtifactDelivery( resumedRecords, runId, reconciledCheckpoint, ); - const initialTrajectorySeed = latestTrajectorySeed(resumedRecords, runId, reconciledCheckpoint); + const initialParentSessionId = trajectorySelector?.source_role_session_id ?? null; + const initialTrajectorySeed = trajectorySelector?.target.seed ?? null; const initialVisitIndexByRole = initialParentSessionId === null ? undefined : nextVisitIndexes(resumedRecords, runId); @@ -493,42 +499,18 @@ function latestArtifactDelivery( return null; } -/** Recover the exact selected target prompt for a resumed trajectory receiver. */ -function latestTrajectorySeed( - records: readonly PersistedRecord[], - runId: string, - checkpoint: Checkpoint, -): string | null { - if (checkpoint.current_role === "done") return null; - for (let index = records.length - 1; index >= 0; index -= 1) { - const record = records[index]; - if (record === undefined || !("run_id" in record) || record.run_id !== runId) continue; - if (record.type === "handoff_transport_selected" && record.to === checkpoint.current_role) { - return record.target.seed; - } - if ( - record.type === "transition_accepted" && - record.event === "handoff" && - record.to === checkpoint.current_role - ) { - return null; - } - } - return null; -} - -/** Recover the selected source's logical role-session identity for a resumed trajectory receiver. */ -function latestTrajectoryParent( +/** Find and validate the exact selector that still targets this checkpoint. */ +function latestTrajectorySelector( records: readonly PersistedRecord[], runId: string, checkpoint: Checkpoint, -): string | null { +): HandoffTransportSelectedRecord | null { if (checkpoint.current_role === "done") return null; for (let index = records.length - 1; index >= 0; index -= 1) { const record = records[index]; if (record === undefined || !("run_id" in record) || record.run_id !== runId) continue; if (record.type === "handoff_transport_selected" && record.to === checkpoint.current_role) { - return record.source_role_session_id; + return validateTrajectorySelector(record); } if ( record.type === "transition_accepted" && diff --git a/src/host/host.ts b/src/host/host.ts index 049a7ac3..f735ebf4 100644 --- a/src/host/host.ts +++ b/src/host/host.ts @@ -176,6 +176,8 @@ export interface RoleSession { readonly tokens: number | null | undefined; readonly hasCompaction: boolean; readonly registeredToolNames: readonly string[]; + /** Exact user texts on the active conversation branch, for resume ambiguity checks. */ + readonly userMessageTexts: readonly string[]; /** Canonical provider-visible definitions indexed by registered tool name. */ readonly toolDefinitions: Readonly>; }; diff --git a/src/host/log-file.ts b/src/host/log-file.ts index 32b66000..9c52d42f 100644 --- a/src/host/log-file.ts +++ b/src/host/log-file.ts @@ -419,6 +419,7 @@ const PERSISTED_RECORD_TYPES: ReadonlySet = new Set([ "manifest_snapshot", "handoff_transport_selected", "trajectory_handoff_failed", + "trajectory_target_seed_delivered", ]); /** Validate the parsed JSONL value's `type` discriminant before trusting it as a record. */ diff --git a/src/host/loop.ts b/src/host/loop.ts index d15fc3c5..4f463400 100644 --- a/src/host/loop.ts +++ b/src/host/loop.ts @@ -89,6 +89,7 @@ import { artifactDelivery, type PersistedRecord, } from "../persistence/log.js"; +import { sha256Canonical } from "../persistence/trajectory-records.js"; import { summarizePayload } from "../seam/payload-summary.js"; import type { HandoffArgs } from "../seam/schema.js"; import { validateEmission } from "../seam/validate-emission.js"; @@ -438,6 +439,7 @@ export async function runLoop(opts: RunLoopOptions): Promise { ? seed : appendArtifactSeedSection(seed, artifactSeedForVisit); let recoveringFromNoEmission = false; + let trajectorySeedDeliveryRecorded = false; try { const sessionId = session.sessionId; @@ -597,6 +599,18 @@ export async function runLoop(opts: RunLoopOptions): Promise { opts.runControl?.takePendingGuidance() ?? [], ); await session.prompt(promptSeed); + if (session.isTrajectory === true && !trajectorySeedDeliveryRecorded) { + host.persistRecord({ + type: "trajectory_target_seed_delivered", + schema_version: 1, + run_id: checkpoint.run_id, + role_session_id: sessionId, + conversation_id: session.conversationId ?? sessionId, + seed_sha256: sha256Canonical(nextSeed), + ts: Date.now(), + }); + trajectorySeedDeliveryRecorded = true; + } } catch (err) { promptError = err; } diff --git a/src/host/production-host.ts b/src/host/production-host.ts index b6998457..ba0b2c58 100644 --- a/src/host/production-host.ts +++ b/src/host/production-host.ts @@ -101,9 +101,11 @@ import type { SessionEventSource } from "./session-event-handler.js"; import { spawnSharedSdkRoleSession } from "./shared-sdk-role-spawn.js"; import { admitTrajectory, + assertTrajectoryEffortSupported, serializeActiveToolDefinitions, TrajectoryHandoffError, } from "./trajectory-admission.js"; +import { assertTrajectorySdkSupported } from "./trajectory-sdk-capability.js"; import { assertPersistedSnapshotPinResolves, assertSupportedWorkspaceBackend, @@ -460,7 +462,9 @@ export class ProductionHost implements Host { const persisted = validateTrajectorySelector(selected); let session: RoleSession | null = null; try { + assertTrajectorySdkSupported(); const resolved = resolveModel(role, persisted.target.model, this.modelRegistry); + assertTrajectoryEffortSupported(resolved.model, persisted.target.requested_effort); session = await spawnSharedSdkRoleSession({ role, roleConfig, @@ -513,6 +517,12 @@ export class ProductionHost implements Host { return definition; }), ); + if (context.userMessageTexts.includes(persisted.target.seed)) { + throw new TrajectoryResumeError( + "trajectory target seed is already present without an accepted target transition; refusing to duplicate an ambiguous generation", + "trajectory_target_seed_ambiguous", + ); + } const environmentSha = sha256Canonical({ system_prompt: persisted.target.system_prompt, model: persisted.target.model, @@ -537,9 +547,18 @@ export class ProductionHost implements Host { return session; } catch (error) { await session?.dispose(); - if (error instanceof TrajectoryResumeError) throw error; + if ( + error instanceof TrajectoryResumeError && + error.code !== "trajectory_target_seed_ambiguous" + ) { + throw error; + } const code = - error instanceof TrajectoryHandoffError ? error.code : "trajectory_environment_unsupported"; + error instanceof TrajectoryHandoffError + ? error.code + : error instanceof TrajectoryResumeError + ? (error.code ?? "trajectory_environment_unsupported") + : "trajectory_environment_unsupported"; const message = error instanceof Error ? error.message @@ -745,6 +764,7 @@ export class ProductionHost implements Host { file: args.source.sessionFile, }; try { + assertTrajectorySdkSupported(); const sourceContext = args.source.getTrajectoryContext?.(); if (sourceContext === undefined || args.source.continueTrajectory === undefined) { throw new TrajectoryHandoffError( @@ -775,6 +795,7 @@ export class ProductionHost implements Host { ); } const resolved = resolveModel(args.to, modelEntry.model, this.modelRegistry); + assertTrajectoryEffortSupported(resolved.model, modelEntry.effort); const targetPrompt = await loadSystemPrompt( args.to, targetRole?.system_prompt, diff --git a/src/host/role-session.ts b/src/host/role-session.ts index e6878c81..80fea36f 100644 --- a/src/host/role-session.ts +++ b/src/host/role-session.ts @@ -62,6 +62,9 @@ export function createRoleSessionAdapter(opts: { .getEntries() .some((entry) => entry.type === "compaction"), registeredToolNames: session.getAllTools().map((tool) => tool.name), + userMessageTexts: session.messages.flatMap((message) => + message.role === "user" ? textParts(message.content) : [], + ), toolDefinitions: Object.fromEntries(session.getAllTools().map((tool) => [tool.name, tool])), }), ...(opts.continueTrajectory !== undefined && { @@ -78,3 +81,18 @@ export function createRoleSessionAdapter(opts: { isAutoCompactionEnabled: () => session.autoCompactionEnabled, }; } + +function textParts(content: unknown): readonly string[] { + if (typeof content === "string") return [content]; + if (!Array.isArray(content)) return []; + return content.flatMap((part) => + typeof part === "object" && + part !== null && + "type" in part && + part.type === "text" && + "text" in part && + typeof part.text === "string" + ? [part.text] + : [], + ); +} diff --git a/src/host/trajectory-admission.ts b/src/host/trajectory-admission.ts index 57853b88..961c25cf 100644 --- a/src/host/trajectory-admission.ts +++ b/src/host/trajectory-admission.ts @@ -2,6 +2,7 @@ import type { Model } from "@earendil-works/pi-ai"; import { estimateTokens } from "@earendil-works/pi-coding-agent"; +import type { ModelEffort } from "../core/types.js"; import type { TrajectoryAdmission } from "../persistence/trajectory-records.js"; /** Stable fail-closed trajectory admission error. */ @@ -35,6 +36,18 @@ export function serializeActiveToolDefinitions(definitions: readonly unknown[]): ); } +/** Reject an effort which Pi would otherwise clamp after the source is sealed. */ +export function assertTrajectoryEffortSupported(model: Model, effort: ModelEffort): void { + const declared = model.thinkingLevelMap?.[effort]; + const unavailable = declared === null || (effort !== "off" && model.reasoning !== true); + if (unavailable) { + throw new TrajectoryHandoffError( + "trajectory_target_environment_invalid", + `trajectory target model '${model.provider}:${model.id}' does not support exact effort '${effort}'`, + ); + } +} + export class TrajectoryHandoffError extends Error { readonly code: | "trajectory_context_metadata_unknown" diff --git a/src/host/trajectory-sdk-capability.ts b/src/host/trajectory-sdk-capability.ts new file mode 100644 index 00000000..0fe35f7c --- /dev/null +++ b/src/host/trajectory-sdk-capability.ts @@ -0,0 +1,17 @@ +/** Exact public Pi SDK capability gate for Issue #63 trajectory transport. */ + +import { VERSION } from "@earendil-works/pi-coding-agent"; + +import { TrajectoryHandoffError } from "./trajectory-admission.js"; + +const TRAJECTORY_SDK_VERSION = "0.80.6"; + +/** Fail closed unless the SDK version proven by the acknowledged spike is loaded. */ +export function assertTrajectorySdkSupported(version: string = VERSION): void { + if (version !== TRAJECTORY_SDK_VERSION) { + throw new TrajectoryHandoffError( + "trajectory_environment_unsupported", + `trajectory requires @earendil-works/pi-coding-agent ${TRAJECTORY_SDK_VERSION}; loaded ${version}`, + ); + } +} diff --git a/src/persistence/log.ts b/src/persistence/log.ts index 56f7612e..4da7178b 100644 --- a/src/persistence/log.ts +++ b/src/persistence/log.ts @@ -50,6 +50,7 @@ import type { HandoffTransportSelectedRecord, ManifestSnapshotRecord, TrajectoryHandoffFailedRecord, + TrajectoryTargetSeedDeliveredRecord, } from "./trajectory-records.js"; import type { ArtifactCollectedRecord, @@ -321,7 +322,8 @@ export type PersistedRecord = | ArtifactDeliveryRecord | ManifestSnapshotRecord | HandoffTransportSelectedRecord - | TrajectoryHandoffFailedRecord; + | TrajectoryHandoffFailedRecord + | TrajectoryTargetSeedDeliveredRecord; // ─── RecordLog interface ─────────────────────────────────────────────── diff --git a/src/persistence/trajectory-records.ts b/src/persistence/trajectory-records.ts index a98eb9db..9c6abe19 100644 --- a/src/persistence/trajectory-records.ts +++ b/src/persistence/trajectory-records.ts @@ -49,6 +49,16 @@ export interface ManifestSnapshotRecord { } /** Persisted selection for one successfully admitted trajectory handoff. */ +export interface TrajectoryTargetSeedDeliveredRecord { + readonly type: "trajectory_target_seed_delivered"; + readonly schema_version: 1; + readonly run_id: string; + readonly role_session_id: string; + readonly conversation_id: string; + readonly seed_sha256: string; + readonly ts: number; +} + export interface HandoffTransportSelectedRecord { readonly type: "handoff_transport_selected"; readonly schema_version: 1; diff --git a/tests/host/trajectory-environment.test.ts b/tests/host/trajectory-environment.test.ts index b5b3fa12..da01a112 100644 --- a/tests/host/trajectory-environment.test.ts +++ b/tests/host/trajectory-environment.test.ts @@ -62,6 +62,17 @@ function requestPrompt(context: unknown): unknown { : undefined; } +function requestToolNames(context: unknown): readonly string[] { + if (typeof context !== "object" || context === null || !("tools" in context)) return []; + const tools = (context as { readonly tools?: unknown }).tools; + if (!Array.isArray(tools)) return []; + return tools.flatMap((tool) => + typeof tool === "object" && tool !== null && "name" in tool && typeof tool.name === "string" + ? [tool.name] + : [], + ); +} + function textContent(message: unknown): string | null { if (typeof message !== "object" || message === null || !("content" in message)) return null; const content = message.content; @@ -273,6 +284,10 @@ describe("Issue #63 trajectory environment", () => { expect(requests.length).toBe(5); expect(requestPrompt(requests[2])).toBe("ORCHESTRATOR_PROMPT"); expect(requestPrompt(requests[3])).toBe("IMPLEMENTER_PROMPT"); + expect(requestToolNames(requests[2])).toEqual(["handoff", "end", "ask_user"]); + expect(requestToolNames(requests[3])).toEqual(["handoff", "end", "ask_user"]); + expect(requestToolNames(requests[2])).not.toContain("handoff_context"); + expect(requestToolNames(requests[3])).not.toContain("handoff_context"); assertExactPriorHandoff(requests[2], lastUserText(requests[1]), "orchestrator"); assertExactPriorHandoff(requests[3], lastUserText(requests[2]), "implementer"); expect(selected[0]?.target.seed).toBe(lastUserText(requests[2])); diff --git a/tests/host/trajectory-resume.test.ts b/tests/host/trajectory-resume.test.ts index 8b2f962e..f865313a 100644 --- a/tests/host/trajectory-resume.test.ts +++ b/tests/host/trajectory-resume.test.ts @@ -655,4 +655,209 @@ describe("Issue #63 trajectory resume", () => { await expect(host.spawnRole("implementer")).rejects.toThrow("previously failed"); expect(log.records("failed-trajectory")).toHaveLength(1); }); + + it("rejects a malformed selector at the public resume boundary before host construction", async () => { + const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-resume-boundary-")); + try { + const loaded = loadManifestFromString(MANIFEST); + const initial = createInitialCheckpoint(loaded.def); + const log = new FileRecordLog({ baseDir: workdir }); + log.append( + createManifestSnapshot({ + runId: initial.run_id, + manifest: loaded.manifest, + definition: loaded.def, + ts: 1, + }), + ); + log.append({ + type: "checkpoint_snapshot", + checkpoint: { + ...initial, + current_role: "implementer", + visit_count: { implementer: 1 }, + }, + }); + log.append({ + type: "handoff_transport_selected", + schema_version: 1, + run_id: initial.run_id, + source_role_session_id: "source", + from: "orchestrator", + to: "implementer", + mode: "trajectory", + source_conversation: { id: "conversation", file: "/must-not-open.jsonl" }, + target: { + model: "stub:stub-model", + requested_effort: "off", + system_prompt: "IMPLEMENTER", + active_tool_names: ["handoff", "end", "ask_user"], + environment_sha256: "0".repeat(64), + }, + admission: { + schema_version: 1, + observed_context_tokens: 0, + role_envelope_tokens: 0, + target_max_tokens: 8192, + safety_reservation_tokens: 8192, + required_tokens: 16384, + target_context_window: 200000, + target_model: "stub:stub-model", + }, + ts: 1, + } as unknown as PersistedRecord); + let hostConstructed = false; + await expect( + resumeRun(join(workdir, "ignored.yaml"), initial.run_id, { + goal: "ignored", + baseDir: workdir, + hostFactory: () => { + hostConstructed = true; + throw new Error("host must not be constructed"); + }, + }), + ).rejects.toBeInstanceOf(TrajectoryResumeError); + expect(hostConstructed).toBe(false); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); + + it("rejects an unsupported target effort before selector persistence or target generation", async () => { + const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-effort-preflight-")); + try { + const manifestPath = join(workdir, ".pi", "conductor.yaml"); + await mkdir(join(workdir, ".pi", "roles"), { recursive: true }); + await writeFile(manifestPath, MANIFEST.replaceAll("effort: off", "effort: high"), "utf8"); + await writeFile(join(workdir, ".pi", "roles", "orchestrator.md"), "ORCHESTRATOR", "utf8"); + await writeFile(join(workdir, ".pi", "roles", "implementer.md"), "IMPLEMENTER", "utf8"); + const requests: unknown[] = []; + const registry = makeModelRegistryWithStub([], ["stub-model"], (request) => + requests.push(request), + ); + const log = new InMemoryRecordLog(); + const host = new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log, + loadedManifest: await loadManifest(manifestPath), + runId: "effort-preflight", + }); + const source = await host.spawnRole("orchestrator"); + const requestsBeforeSelection = requests.length; + await expect( + host.selectAcceptedHandoffTransport({ + from: "orchestrator", + to: "implementer", + source, + targetSeed: "TARGET_SEED_EXACT", + targetVisitIndex: 1, + }), + ).rejects.toMatchObject({ code: "trajectory_target_environment_invalid" }); + expect(requests).toHaveLength(requestsBeforeSelection); + expect( + log + .records("effort-preflight") + .some((record) => record.type === "handoff_transport_selected"), + ).toBe(false); + expect(log.records("effort-preflight").at(-1)).toMatchObject({ + type: "trajectory_handoff_failed", + code: "trajectory_target_environment_invalid", + }); + await source.dispose(); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); + + it("fails closed instead of appending a target seed already present in the reopened conversation", async () => { + const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-duplicate-seed-")); + try { + const manifestPath = join(workdir, ".pi", "conductor.yaml"); + await mkdir(join(workdir, ".pi", "roles"), { recursive: true }); + await writeFile(manifestPath, MANIFEST, "utf8"); + await writeFile(join(workdir, ".pi", "roles", "orchestrator.md"), "ORCHESTRATOR", "utf8"); + await writeFile(join(workdir, ".pi", "roles", "implementer.md"), "IMPLEMENTER", "utf8"); + const loaded = await loadManifest(manifestPath); + const log = new InMemoryRecordLog(); + const requests: unknown[] = []; + const registry = makeModelRegistryWithStub([], ["stub-model"], (request) => + requests.push(request), + ); + const sourceHost = new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log, + loadedManifest: loaded, + runId: "duplicate-target-seed", + }); + const source = await sourceHost.spawnRole("orchestrator"); + const targetSeed = "[handoff → implementer]\\nTARGET_SEED_EXACT"; + await source.prompt(targetSeed); + const context = source.getTrajectoryContext?.(); + if (context === undefined) throw new Error("source did not expose trajectory context"); + const names = ["handoff", "end", "ask_user"]; + const definitions = serializeActiveToolDefinitions( + names.map((name) => context.toolDefinitions[name]), + ); + const conversation = { + id: source.conversationId ?? source.sessionId, + file: source.sessionFile, + }; + await source.dispose(); + log.append({ + type: "handoff_transport_selected", + schema_version: 1, + run_id: "duplicate-target-seed", + source_role_session_id: source.sessionId, + from: "orchestrator", + to: "implementer", + mode: "trajectory", + source_conversation: conversation, + target: { + model: "stub:stub-model", + requested_effort: "off", + system_prompt: "IMPLEMENTER", + active_tool_names: names, + seed: targetSeed, + environment_sha256: sha256Canonical({ + system_prompt: "IMPLEMENTER", + model: "stub:stub-model", + effort: "off", + active_tool_names: names, + active_tool_definitions: definitions, + }), + }, + admission: { + schema_version: 1, + observed_context_tokens: 0, + role_envelope_tokens: 0, + target_max_tokens: 8192, + safety_reservation_tokens: 8192, + required_tokens: 16384, + target_context_window: 200000, + target_model: "stub:stub-model", + }, + ts: 1, + }); + const targetHost = new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log, + loadedManifest: loaded, + runId: "duplicate-target-seed", + }); + const requestsBeforeTarget = requests.length; + await expect(targetHost.spawnRole("implementer")).rejects.toMatchObject({ + code: "trajectory_target_seed_ambiguous", + }); + expect(requests).toHaveLength(requestsBeforeTarget); + expect(log.records("duplicate-target-seed").at(-1)).toMatchObject({ + type: "trajectory_handoff_failed", + code: "trajectory_target_seed_ambiguous", + }); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/host/trajectory-sdk-capability.test.ts b/tests/host/trajectory-sdk-capability.test.ts new file mode 100644 index 00000000..8c6d4f19 --- /dev/null +++ b/tests/host/trajectory-sdk-capability.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { TrajectoryHandoffError } from "../../src/host/trajectory-admission.js"; +import { assertTrajectorySdkSupported } from "../../src/host/trajectory-sdk-capability.js"; + +describe("Issue #63 trajectory SDK capability", () => { + it("accepts only the SDK version exercised by the acknowledged spike", () => { + expect(() => assertTrajectorySdkSupported("0.80.6")).not.toThrow(); + }); + + it("fails closed before trajectory transport on an unproven SDK version", () => { + expect(() => assertTrajectorySdkSupported("0.80.7")).toThrow(TrajectoryHandoffError); + expect(() => assertTrajectorySdkSupported("0.80.7")).toThrow( + "trajectory requires @earendil-works/pi-coding-agent 0.80.6", + ); + }); +}); From 7e569d7169468bd1c9045244716221117318bb6c Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 11:57:40 +0000 Subject: [PATCH 15/20] test: cover trajectory tool context visibility --- tests/host/trajectory-environment.test.ts | 25 +++++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/host/trajectory-environment.test.ts b/tests/host/trajectory-environment.test.ts index da01a112..40a8d5a6 100644 --- a/tests/host/trajectory-environment.test.ts +++ b/tests/host/trajectory-environment.test.ts @@ -171,6 +171,7 @@ function registryWithTrajectoryScript(requests: unknown[]): ModelRegistry { streamSimple: makeStubStreamFunction({ steps: [ { kind: "emit_handoff", target_role: "planner" }, + { kind: "emit_tool_calls", calls: [{ name: "handoff_context", arguments: {} }] }, { kind: "emit_handoff", target_role: "orchestrator" }, { kind: "emit_handoff", target_role: "implementer" }, { kind: "emit_handoff", target_role: "orchestrator" }, @@ -281,16 +282,22 @@ describe("Issue #63 trajectory environment", () => { expect(planner?.conversation_id).toBe(trajectoryOrchestrator?.conversation_id); expect(planner?.conversation_id).toBe(implementer?.conversation_id); expect(implementer?.session_file).not.toBe(freshOrchestrator?.session_file); - expect(requests.length).toBe(5); - expect(requestPrompt(requests[2])).toBe("ORCHESTRATOR_PROMPT"); - expect(requestPrompt(requests[3])).toBe("IMPLEMENTER_PROMPT"); - expect(requestToolNames(requests[2])).toEqual(["handoff", "end", "ask_user"]); + expect(requests.length).toBe(6); + expect(requestPrompt(requests[3])).toBe("ORCHESTRATOR_PROMPT"); + expect(requestPrompt(requests[4])).toBe("IMPLEMENTER_PROMPT"); expect(requestToolNames(requests[3])).toEqual(["handoff", "end", "ask_user"]); - expect(requestToolNames(requests[2])).not.toContain("handoff_context"); + expect(requestToolNames(requests[4])).toEqual(["handoff", "end", "ask_user"]); expect(requestToolNames(requests[3])).not.toContain("handoff_context"); - assertExactPriorHandoff(requests[2], lastUserText(requests[1]), "orchestrator"); - assertExactPriorHandoff(requests[3], lastUserText(requests[2]), "implementer"); - expect(selected[0]?.target.seed).toBe(lastUserText(requests[2])); - expect(selected[1]?.target.seed).toBe(lastUserText(requests[3])); + expect(requestToolNames(requests[4])).not.toContain("handoff_context"); + expect( + requestMessages(requests[3]).some( + (message) => + message.role === "toolResult" && textContent(message)?.startsWith("[handoff context]"), + ), + ).toBe(true); + assertExactPriorHandoff(requests[3], lastUserText(requests[2]), "orchestrator"); + assertExactPriorHandoff(requests[4], lastUserText(requests[3]), "implementer"); + expect(selected[0]?.target.seed).toBe(lastUserText(requests[3])); + expect(selected[1]?.target.seed).toBe(lastUserText(requests[4])); }); }); From 1b13b4e8387f744c61f47b4a298efcc1dc0be0a1 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 12:14:54 +0000 Subject: [PATCH 16/20] fix: reject unsupported trajectory effort before mutation Use Pi 0.80.6's public supported-level selector so missing max/xhigh mappings fail before selector persistence or session reconfiguration. Strengthen the two-edge request assertion to retain the planner tool result at the final receiver. --- src/host/trajectory-admission.ts | 6 +-- tests/host/trajectory-environment.test.ts | 18 ++++++- tests/host/trajectory-resume.test.ts | 66 ++++++++++++++--------- 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/src/host/trajectory-admission.ts b/src/host/trajectory-admission.ts index 961c25cf..7cb36384 100644 --- a/src/host/trajectory-admission.ts +++ b/src/host/trajectory-admission.ts @@ -1,6 +1,6 @@ /** Conservative trajectory-context admission — Issue #63 §5. */ -import type { Model } from "@earendil-works/pi-ai"; +import { getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai"; import { estimateTokens } from "@earendil-works/pi-coding-agent"; import type { ModelEffort } from "../core/types.js"; import type { TrajectoryAdmission } from "../persistence/trajectory-records.js"; @@ -38,9 +38,7 @@ export function serializeActiveToolDefinitions(definitions: readonly unknown[]): /** Reject an effort which Pi would otherwise clamp after the source is sealed. */ export function assertTrajectoryEffortSupported(model: Model, effort: ModelEffort): void { - const declared = model.thinkingLevelMap?.[effort]; - const unavailable = declared === null || (effort !== "off" && model.reasoning !== true); - if (unavailable) { + if (!getSupportedThinkingLevels(model).includes(effort)) { throw new TrajectoryHandoffError( "trajectory_target_environment_invalid", `trajectory target model '${model.provider}:${model.id}' does not support exact effort '${effort}'`, diff --git a/tests/host/trajectory-environment.test.ts b/tests/host/trajectory-environment.test.ts index 40a8d5a6..722d8c5a 100644 --- a/tests/host/trajectory-environment.test.ts +++ b/tests/host/trajectory-environment.test.ts @@ -289,10 +289,24 @@ describe("Issue #63 trajectory environment", () => { expect(requestToolNames(requests[4])).toEqual(["handoff", "end", "ask_user"]); expect(requestToolNames(requests[3])).not.toContain("handoff_context"); expect(requestToolNames(requests[4])).not.toContain("handoff_context"); + const plannerSourcePrompt = lastUserText(requests[1]); + const plannerHandoffContext = requestMessages(requests[3]).find( + (message) => + message.role === "toolResult" && textContent(message)?.startsWith("[handoff context]"), + ); + const plannerHandoffContextText = textContent(plannerHandoffContext); + expect(plannerHandoffContextText).not.toBeNull(); + // The final receiver retains the planner's original user prompt and the + // exact planner-produced handoff_context result through both selected edges. + expect( + requestMessages(requests[4]).some( + (message) => message.role === "user" && textContent(message) === plannerSourcePrompt, + ), + ).toBe(true); expect( - requestMessages(requests[3]).some( + requestMessages(requests[4]).some( (message) => - message.role === "toolResult" && textContent(message)?.startsWith("[handoff context]"), + message.role === "toolResult" && textContent(message) === plannerHandoffContextText, ), ).toBe(true); assertExactPriorHandoff(requests[3], lastUserText(requests[2]), "orchestrator"); diff --git a/tests/host/trajectory-resume.test.ts b/tests/host/trajectory-resume.test.ts index f865313a..d2619369 100644 --- a/tests/host/trajectory-resume.test.ts +++ b/tests/host/trajectory-resume.test.ts @@ -2,9 +2,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { AgentSession, SessionManager } from "@earendil-works/pi-coding-agent"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { serializeActiveToolDefinitions } from "../../src/host/trajectory-admission.js"; import { createInitialCheckpoint, @@ -723,18 +723,22 @@ describe("Issue #63 trajectory resume", () => { } }); - it("rejects an unsupported target effort before selector persistence or target generation", async () => { + it("rejects a requested max level omitted from Pi's supported map before selector persistence or session mutation", async () => { const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-effort-preflight-")); try { const manifestPath = join(workdir, ".pi", "conductor.yaml"); await mkdir(join(workdir, ".pi", "roles"), { recursive: true }); - await writeFile(manifestPath, MANIFEST.replaceAll("effort: off", "effort: high"), "utf8"); + await writeFile(manifestPath, MANIFEST.replaceAll("effort: off", "effort: max"), "utf8"); await writeFile(join(workdir, ".pi", "roles", "orchestrator.md"), "ORCHESTRATOR", "utf8"); await writeFile(join(workdir, ".pi", "roles", "implementer.md"), "IMPLEMENTER", "utf8"); const requests: unknown[] = []; const registry = makeModelRegistryWithStub([], ["stub-model"], (request) => requests.push(request), ); + const targetModel = registry.find("stub", "stub-model"); + if (targetModel === undefined) throw new Error("stub target model was not registered"); + targetModel.reasoning = true; + targetModel.thinkingLevelMap = { high: "provider-high" }; const log = new InMemoryRecordLog(); const host = new ProductionHost({ modelRegistry: registry, @@ -744,27 +748,39 @@ describe("Issue #63 trajectory resume", () => { runId: "effort-preflight", }); const source = await host.spawnRole("orchestrator"); - const requestsBeforeSelection = requests.length; - await expect( - host.selectAcceptedHandoffTransport({ - from: "orchestrator", - to: "implementer", - source, - targetSeed: "TARGET_SEED_EXACT", - targetVisitIndex: 1, - }), - ).rejects.toMatchObject({ code: "trajectory_target_environment_invalid" }); - expect(requests).toHaveLength(requestsBeforeSelection); - expect( - log - .records("effort-preflight") - .some((record) => record.type === "handoff_transport_selected"), - ).toBe(false); - expect(log.records("effort-preflight").at(-1)).toMatchObject({ - type: "trajectory_handoff_failed", - code: "trajectory_target_environment_invalid", - }); - await source.dispose(); + const setModel = vi.spyOn(AgentSession.prototype, "setModel"); + const setThinkingLevel = vi.spyOn(AgentSession.prototype, "setThinkingLevel"); + const setActiveToolsByName = vi.spyOn(AgentSession.prototype, "setActiveToolsByName"); + try { + const requestsBeforeSelection = requests.length; + await expect( + host.selectAcceptedHandoffTransport({ + from: "orchestrator", + to: "implementer", + source, + targetSeed: "TARGET_SEED_EXACT", + targetVisitIndex: 1, + }), + ).rejects.toMatchObject({ code: "trajectory_target_environment_invalid" }); + expect(requests).toHaveLength(requestsBeforeSelection); + expect(setModel).not.toHaveBeenCalled(); + expect(setThinkingLevel).not.toHaveBeenCalled(); + expect(setActiveToolsByName).not.toHaveBeenCalled(); + expect( + log + .records("effort-preflight") + .some((record) => record.type === "handoff_transport_selected"), + ).toBe(false); + expect(log.records("effort-preflight").at(-1)).toMatchObject({ + type: "trajectory_handoff_failed", + code: "trajectory_target_environment_invalid", + }); + } finally { + setModel.mockRestore(); + setThinkingLevel.mockRestore(); + setActiveToolsByName.mockRestore(); + await source.dispose(); + } } finally { await rm(workdir, { recursive: true, force: true }); } From 707c8fb680e9e220118686683f88907b701cd2c1 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 12:37:26 +0000 Subject: [PATCH 17/20] fix: harden trajectory handoff rehydration Inspect only the active Pi branch for compaction and preserve the durable trajectory failure when target cleanup also fails. Add regressions for branch isolation, resume failure pinning, and unchanged fresh handoffs. --- src/host/production-host.ts | 6 +- src/host/role-session.ts | 2 +- tests/host/role-session.test.ts | 52 +++++++- tests/host/trajectory-environment.test.ts | 111 ++++++++++++++-- tests/host/trajectory-resume.test.ts | 147 ++++++++++++++++++++++ 5 files changed, 307 insertions(+), 11 deletions(-) diff --git a/src/host/production-host.ts b/src/host/production-host.ts index ba0b2c58..4558c050 100644 --- a/src/host/production-host.ts +++ b/src/host/production-host.ts @@ -546,7 +546,11 @@ export class ProductionHost implements Host { }); return session; } catch (error) { - await session?.dispose(); + try { + await session?.dispose(); + } catch { + // The rehydration failure must remain durable even if cleanup fails. + } if ( error instanceof TrajectoryResumeError && error.code !== "trajectory_target_seed_ambiguous" diff --git a/src/host/role-session.ts b/src/host/role-session.ts index 80fea36f..43673ef6 100644 --- a/src/host/role-session.ts +++ b/src/host/role-session.ts @@ -59,7 +59,7 @@ export function createRoleSessionAdapter(opts: { getTrajectoryContext: () => ({ tokens: session.getContextUsage()?.tokens, hasCompaction: session.sessionManager - .getEntries() + .buildContextEntries() .some((entry) => entry.type === "compaction"), registeredToolNames: session.getAllTools().map((tool) => tool.name), userMessageTexts: session.messages.flatMap((message) => diff --git a/tests/host/role-session.test.ts b/tests/host/role-session.test.ts index 5ceaf7e7..67b80f49 100644 --- a/tests/host/role-session.test.ts +++ b/tests/host/role-session.test.ts @@ -1,4 +1,8 @@ -import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { type AgentSession, SessionManager } from "@earendil-works/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import { createRoleSessionAdapter } from "../../src/host/role-session.js"; import { SessionSeam } from "../../src/host/seam.js"; @@ -50,4 +54,50 @@ describe("createRoleSessionAdapter", () => { expect(dispose).toHaveBeenCalledOnce(); expect(onDispose).toHaveBeenCalledOnce(); }); + + it("inspects compaction only on the active public session branch", async () => { + const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-role-session-branch-")); + try { + const sessionDir = join(workdir, "sessions"); + await mkdir(sessionDir); + const manager = SessionManager.create(workdir, sessionDir); + const root = manager.appendCustomEntry("test", { branch: "root" }); + const activeLeaf = manager.appendCustomEntry("test", { branch: "active" }); + manager.branch(root); + manager.appendCompaction("abandoned branch summary", root, 0); + manager.branch(activeLeaf); + + expect(manager.getEntries().some((entry) => entry.type === "compaction")).toBe(true); + expect(manager.buildContextEntries().some((entry) => entry.type === "compaction")).toBe( + false, + ); + + const session = { + sessionId: "native-session", + sessionManager: manager, + getContextUsage: () => ({ tokens: 12 }), + getAllTools: () => [], + messages: [], + } as unknown as AgentSession; + const adapter = createRoleSessionAdapter({ + role: "worker", + session, + seam: new SessionSeam(), + sessionId: "role-session", + sessionFile: join(sessionDir, "session.jsonl"), + model: "stub:model", + effort: "off", + retries: 0, + retryDelayMs: 0, + onDispose: () => undefined, + }); + + expect(adapter.getTrajectoryContext?.().hasCompaction).toBe(false); + + manager.appendCompaction("active branch summary", root, 0); + expect(adapter.getTrajectoryContext?.().hasCompaction).toBe(true); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/host/trajectory-environment.test.ts b/tests/host/trajectory-environment.test.ts index 722d8c5a..a7bad240 100644 --- a/tests/host/trajectory-environment.test.ts +++ b/tests/host/trajectory-environment.test.ts @@ -162,6 +162,29 @@ function autoCompactionEnabled(session: RoleSession): boolean { } function registryWithTrajectoryScript(requests: unknown[]): ModelRegistry { + return registryWithScript(requests, [ + { kind: "emit_handoff", target_role: "planner" }, + { kind: "emit_tool_calls", calls: [{ name: "handoff_context", arguments: {} }] }, + { kind: "emit_handoff", target_role: "orchestrator" }, + { kind: "emit_handoff", target_role: "implementer" }, + { kind: "emit_handoff", target_role: "orchestrator" }, + { kind: "emit_end" }, + ]); +} + +function registryWithFreshScript(requests: unknown[]): ModelRegistry { + return registryWithScript(requests, [ + { kind: "emit_handoff", target_role: "planner", usage: { input: 11 } }, + { kind: "emit_tool_calls", calls: [{ name: "handoff_context", arguments: {} }] }, + { kind: "emit_handoff", target_role: "orchestrator", usage: { input: 22 } }, + { kind: "emit_end", usage: { input: 33 } }, + ]); +} + +function registryWithScript( + requests: unknown[], + steps: Parameters[0]["steps"], +): ModelRegistry { const registry = ModelRegistry.inMemory(AuthStorage.inMemory()); const base = makeStubModel(); registry.registerProvider("stub", { @@ -169,14 +192,7 @@ function registryWithTrajectoryScript(requests: unknown[]): ModelRegistry { apiKey: "non-live-stub-key", baseUrl: base.baseUrl, streamSimple: makeStubStreamFunction({ - steps: [ - { kind: "emit_handoff", target_role: "planner" }, - { kind: "emit_tool_calls", calls: [{ name: "handoff_context", arguments: {} }] }, - { kind: "emit_handoff", target_role: "orchestrator" }, - { kind: "emit_handoff", target_role: "implementer" }, - { kind: "emit_handoff", target_role: "orchestrator" }, - { kind: "emit_end" }, - ], + steps, onRequest: (context) => requests.push(context), }), models: ["orchestrator", "planner", "implementer"].map((id) => ({ @@ -314,4 +330,83 @@ describe("Issue #63 trajectory environment", () => { expect(selected[0]?.target.seed).toBe(lastUserText(requests[3])); expect(selected[1]?.target.seed).toBe(lastUserText(requests[4])); }); + + it("keeps the undeclared fresh handoff seed, context tool, lifecycle accounting, and new conversation", async () => { + const requests: unknown[] = []; + const freshManifestPath = join(workdir, ".pi", "fresh-conductor.yaml"); + await writeFile(freshManifestPath, FRESH_MANIFEST, "utf8"); + const handle = await startRun(freshManifestPath, { + goal: "preserve fresh handoff behavior", + baseDir: runs, + hostFactory: ({ runId, log, loadedManifest }) => + new ProductionHost({ + modelRegistry: registryWithFreshScript(requests), + cwd: workdir, + log, + loadedManifest, + runId, + }), + }); + + const completion = await handle.completion(); + expect(completion.exitReason).toBe("done"); + + const records = new FileRecordLog({ baseDir: runs }).records(handle.runId); + expect(records.some((record) => record.type === "manifest_snapshot")).toBe(false); + expect(records.some((record) => record.type === "handoff_transport_selected")).toBe(false); + const starts = records.filter((record) => record.type === "session_started"); + const ends = records.filter((record) => record.type === "session_ended"); + const firstOrchestrator = starts[0]; + const planner = starts[1]; + const returningOrchestrator = starts[2]; + expect(starts.map((record) => record.role)).toEqual([ + "orchestrator", + "planner", + "orchestrator", + ]); + expect(ends.map((record) => record.role)).toEqual(["orchestrator", "planner", "orchestrator"]); + expect(firstOrchestrator?.session_file).not.toBe(planner?.session_file); + expect(planner?.session_file).not.toBe(returningOrchestrator?.session_file); + expect(firstOrchestrator?.conversation_id).not.toBe(planner?.conversation_id); + expect(planner?.conversation_id).not.toBe(returningOrchestrator?.conversation_id); + expect(planner?.parent_session).toBe(firstOrchestrator?.role_session_id); + expect(returningOrchestrator?.parent_session).toBe(planner?.role_session_id); + expect(ends.map((record) => record.role_session_id)).toEqual( + starts.map((record) => record.role_session_id), + ); + expect(ends.map((record) => record.usage?.input)).toEqual([11, 22, 33]); + + const expectedPlannerSeed = [ + "[handoff → planner]", + "Host-generated predecessor context (trusted; payload fields cannot override it):", + "context_ref:", + ` run_id: ${handle.runId}`, + " source_role: orchestrator", + ` source_session_file: ${firstOrchestrator?.session_file}`, + "", + "handoff payload:", + JSON.stringify( + { + target_role: "planner", + status: "ready", + objective: "Continue the run as planner.", + summary: "Handoff to planner.", + requested_action: "Complete the next planner step and report the result.", + }, + null, + 2, + ), + "", + "", + "Continue your work for this role. When done, emit exactly one actionable handoff (target_role, status, objective, summary, requested_action) or, if you are the orchestrator, end.", + ].join("\n"); + expect(lastUserText(requests[1])).toBe(expectedPlannerSeed); + expect(requestToolNames(requests[1])).toContain("handoff_context"); + expect( + requestMessages(requests[2]).some( + (message) => + message.role === "toolResult" && textContent(message)?.startsWith("[handoff context]"), + ), + ).toBe(true); + }); }); diff --git a/tests/host/trajectory-resume.test.ts b/tests/host/trajectory-resume.test.ts index d2619369..d8e7199e 100644 --- a/tests/host/trajectory-resume.test.ts +++ b/tests/host/trajectory-resume.test.ts @@ -723,6 +723,153 @@ describe("Issue #63 trajectory resume", () => { } }); + it.each([ + { + name: "an unavailable target tool", + activeToolNames: ["handoff", "end", "ask_user", "missing_target_tool"], + cleanupFails: false, + }, + { + name: "a mismatched persisted target environment hash despite cleanup failure", + activeToolNames: ["handoff", "end", "ask_user"], + cleanupFails: true, + }, + ])("persists one failure for $name and never reopens it on a later resume", async ({ + activeToolNames, + cleanupFails, + }) => { + const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-resume-failure-")); + const open = vi.spyOn(SessionManager, "open"); + try { + const baseDir = join(workdir, "runs"); + const manifestPath = join(workdir, ".pi", "conductor.yaml"); + await mkdir(join(workdir, ".pi", "roles"), { recursive: true }); + await writeFile(manifestPath, MANIFEST, "utf8"); + await writeFile(join(workdir, ".pi", "roles", "orchestrator.md"), "ORCHESTRATOR", "utf8"); + await writeFile(join(workdir, ".pi", "roles", "implementer.md"), "IMPLEMENTER", "utf8"); + const loaded = await loadManifest(manifestPath); + const initial = createInitialCheckpoint(loaded.def); + const log = new FileRecordLog({ baseDir }); + const requests: unknown[] = []; + const registry = makeModelRegistryWithStub([], ["stub-model"], (request) => + requests.push(request), + ); + const sourceHost = new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log, + loadedManifest: loaded, + runId: initial.run_id, + }); + const source = await sourceHost.spawnRole("orchestrator"); + const sourceConversation = { + id: source.conversationId ?? source.sessionId, + file: source.sessionFile, + }; + await source.dispose(); + log.append( + createManifestSnapshot({ + runId: initial.run_id, + manifest: loaded.manifest, + definition: loaded.def, + ts: 1, + }), + ); + log.append({ + type: "checkpoint_snapshot", + checkpoint: { + ...initial, + current_role: "implementer", + visit_count: { implementer: 1 }, + updated_at: 1, + }, + }); + log.append({ type: "run_seeded", run_id: initial.run_id, goal: "resume", ts: 1 }); + log.append({ + type: "handoff_transport_selected", + schema_version: 1, + run_id: initial.run_id, + source_role_session_id: source.sessionId, + from: "orchestrator", + to: "implementer", + mode: "trajectory", + source_conversation: sourceConversation, + target: { + model: "stub:stub-model", + requested_effort: "off", + system_prompt: "IMPLEMENTER", + active_tool_names: activeToolNames, + seed: "[handoff → implementer]\\nTARGET_SEED_EXACT", + environment_sha256: "0".repeat(64), + }, + admission: { + schema_version: 1, + observed_context_tokens: 0, + role_envelope_tokens: 0, + target_max_tokens: 8192, + safety_reservation_tokens: 8192, + required_tokens: 16384, + target_context_window: 200000, + target_model: "stub:stub-model", + }, + ts: 2, + }); + + if (cleanupFails) { + vi.spyOn(AgentSession.prototype, "dispose").mockImplementation(() => { + throw new Error("simulated trajectory cleanup failure"); + }); + } + const first = await resumeRun(manifestPath, initial.run_id, { + goal: "ignored", + baseDir, + hostFactory: ({ runId, log: resumedLog, loadedManifest }) => + new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log: resumedLog, + loadedManifest, + runId, + }), + }); + await expect(first.completion()).rejects.toBeInstanceOf(TrajectoryResumeError); + expect(requests).toHaveLength(0); + expect(open).toHaveBeenCalledTimes(1); + const failuresAfterFirstResume = log + .records(initial.run_id) + .filter((record) => record.type === "trajectory_handoff_failed"); + expect(failuresAfterFirstResume).toHaveLength(1); + expect(failuresAfterFirstResume[0]).toMatchObject({ + code: "trajectory_environment_unsupported", + from: "orchestrator", + to: "implementer", + }); + + const second = await resumeRun(manifestPath, initial.run_id, { + goal: "ignored", + baseDir, + hostFactory: ({ runId, log: resumedLog, loadedManifest }) => + new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log: resumedLog, + loadedManifest, + runId, + }), + }); + await expect(second.completion()).rejects.toBeInstanceOf(TrajectoryResumeError); + expect(requests).toHaveLength(0); + expect(open).toHaveBeenCalledTimes(1); + expect( + log.records(initial.run_id).filter((record) => record.type === "trajectory_handoff_failed"), + ).toHaveLength(1); + } finally { + open.mockRestore(); + vi.restoreAllMocks(); + await rm(workdir, { recursive: true, force: true }); + } + }); + it("rejects a requested max level omitted from Pi's supported map before selector persistence or session mutation", async () => { const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-effort-preflight-")); try { From de305c77b6f18d62d57bc552bc9d7ece0cf6f555 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 12:48:43 +0000 Subject: [PATCH 18/20] docs: specify fail-closed pre-selector resume --- docs/issue-63-trajectory-handoffs/spec.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/issue-63-trajectory-handoffs/spec.md b/docs/issue-63-trajectory-handoffs/spec.md index 1af38001..0eca58a2 100644 --- a/docs/issue-63-trajectory-handoffs/spec.md +++ b/docs/issue-63-trajectory-handoffs/spec.md @@ -1,6 +1,6 @@ # Issue #63 — trajectory-preserving handoffs -**Status: Acknowledged — implementation complete; re-review requested** +**Status: Acknowledged — fail-closed pre-selector resume remediation in progress** ## Objective and scope @@ -293,6 +293,8 @@ The SDK can reopen a durable JSONL conversation. Current conductor cannot select 3. add a trajectory-specific host spawn that calls `SessionManager.open(selected.source_conversation.file, sessionDir, cwd)`, rehydrates the target environment from the persisted record, and uses the same lifecycle/reconfiguration path; and 4. restore fresh behavior when no trajectory selector is present. +A resume between the accepted receiver checkpoint and selector durability has no stored exact target seed/environment, so it is **not** a fresh case. If the current checkpoint was reached by a pinned `mode: trajectory` accepted edge and the log has neither its valid matching selector nor a durable `trajectory_handoff_failed`, resume appends exactly one `trajectory_handoff_failed(trajectory_transport_unrecoverable)` using the durable source conversation identity and fails before host construction, session opening, compaction, or prompting. This covers both crashes before and after source `session_ended`; a later resume observes that failure and neither reopens nor duplicates it. + This is smaller and safer than putting Pi conversation data in `Checkpoint`, teaching the reducer transport, or relying on Pi's session tree as conductor persistence. Target-seed delivery is additionally fail-closed: the loop records `trajectory_target_seed_delivered` after a target prompt completes. On a crash-recovery reopen, if the exact selected seed is already in the active Pi conversation before an accepted target transition is durable, the host records `trajectory_handoff_failed(trajectory_target_seed_ambiguous)` and refuses to prompt again. This avoids a duplicate user seed or unobservable second generation; a selected target with no seed present can still be resumed normally. ## 5. Context admission contract From f846c28b19573d9b8cca413c02cd9d4405bfcda3 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 12:53:01 +0000 Subject: [PATCH 19/20] fix: fail closed on unselected trajectory resume --- docs/issue-63-trajectory-handoffs/spec.md | 2 +- src/host/api.ts | 130 ++++++++++++ .../trajectory-pre-selector-resume.test.ts | 194 ++++++++++++++++++ 3 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 tests/host/trajectory-pre-selector-resume.test.ts diff --git a/docs/issue-63-trajectory-handoffs/spec.md b/docs/issue-63-trajectory-handoffs/spec.md index 0eca58a2..6f781edc 100644 --- a/docs/issue-63-trajectory-handoffs/spec.md +++ b/docs/issue-63-trajectory-handoffs/spec.md @@ -1,6 +1,6 @@ # Issue #63 — trajectory-preserving handoffs -**Status: Acknowledged — fail-closed pre-selector resume remediation in progress** +**Status: Acknowledged — implementation complete; fail-closed resume remediation re-review requested** ## Objective and scope diff --git a/src/host/api.ts b/src/host/api.ts index c89a48fe..f7b39b0e 100644 --- a/src/host/api.ts +++ b/src/host/api.ts @@ -71,6 +71,7 @@ import type { } from "../core/types.js"; import { DEFAULT_MODEL_EFFORT } from "../core/types.js"; import { toMachineDefinition } from "../manifest/definition.js"; +import { modeFor } from "../manifest/handoffs.js"; import type { ArtifactDeliveryRecord, CheckpointSnapshot, @@ -83,6 +84,8 @@ import { createManifestSnapshot, type HandoffTransportSelectedRecord, type ManifestSnapshotRecord, + type TrajectoryHandoffFailedRecord, + TrajectoryResumeError, validateTrajectorySelector, verifyManifestSnapshot, } from "../persistence/trajectory-records.js"; @@ -316,6 +319,13 @@ export async function resumeRun( // Crash reconciliation (§11.1). const reconciledCheckpoint = reconcileCrash(runId, checkpoint, def, log); const resumedRecords = log.records(runId); + assertNoUnselectedTrajectoryHandoff( + resumedRecords, + runId, + reconciledCheckpoint, + resumedLoaded.manifest.handoffs, + log, + ); // Validate the selected receiver's persisted environment at the public // resume boundary. A corrupt selector must not reach seed derivation, // host construction, or a fake-host prompt. @@ -499,6 +509,126 @@ function latestArtifactDelivery( return null; } +/** + * Fail closed when a crash reaches a trajectory receiver before its exact + * target environment was made durable (Issue #63 §4.5). A fresh spawn cannot + * reconstruct that environment without changing the selected transport. + */ +function assertNoUnselectedTrajectoryHandoff( + records: readonly PersistedRecord[], + runId: string, + checkpoint: Checkpoint, + handoffs: LoadedManifest["manifest"]["handoffs"], + log: RecordLog, +): void { + if (checkpoint.current_role === "done") return; + + const acceptedIndex = findIncomingAcceptedHandoff(records, runId, checkpoint.current_role); + if (acceptedIndex === null) return; + const accepted = records[acceptedIndex]; + if (accepted?.type !== "transition_accepted") return; + if (modeFor(handoffs, accepted.from, accepted.to) !== "trajectory") return; + + const source = sourceConversationForAcceptedHandoff(records, acceptedIndex, accepted); + const laterRecords = records.slice(acceptedIndex + 1); + const matchingSelector = laterRecords.find( + (record): record is HandoffTransportSelectedRecord => + record.type === "handoff_transport_selected" && + record.from === accepted.from && + record.to === accepted.to && + record.source_role_session_id === source.roleSessionId, + ); + if (matchingSelector !== undefined) { + // Preserve the existing typed corrupt-selector path; it must not become + // an invented fresh receiver merely because this guard ran first. + validateTrajectorySelector(matchingSelector); + return; + } + + const priorFailure = laterRecords.find( + (record): record is TrajectoryHandoffFailedRecord => + record.type === "trajectory_handoff_failed" && + record.from === accepted.from && + record.to === accepted.to && + record.source_conversation.id === source.conversation.id && + record.source_conversation.file === source.conversation.file, + ); + if (priorFailure !== undefined) { + throw new TrajectoryResumeError(priorFailure.message, priorFailure.code); + } + + const message = + "trajectory receiver checkpoint has no durable target environment; refusing fresh resume"; + log.append({ + type: "trajectory_handoff_failed", + schema_version: 1, + run_id: runId, + from: accepted.from, + to: accepted.to, + source_conversation: source.conversation, + code: "trajectory_transport_unrecoverable", + message, + ts: Date.now(), + }); + throw new TrajectoryResumeError(message, "trajectory_transport_unrecoverable"); +} + +/** Find the accepted handoff that produced the currently resumed role. */ +function findIncomingAcceptedHandoff( + records: readonly PersistedRecord[], + runId: string, + role: Role, +): number | null { + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index]; + if ( + record?.type === "transition_accepted" && + record.run_id === runId && + record.event === "handoff" && + record.to === role + ) { + return index; + } + } + return null; +} + +/** Recover the source's durable logical and physical identities for a failed selection. */ +function sourceConversationForAcceptedHandoff( + records: readonly PersistedRecord[], + acceptedIndex: number, + accepted: Extract, +): { + readonly roleSessionId: string; + readonly conversation: { readonly id: string; readonly file: string }; +} { + for (let index = acceptedIndex - 1; index >= 0; index -= 1) { + const record = records[index]; + if ( + record?.type === "session_started" && + record.role === accepted.role && + record.session_file === accepted.session_file + ) { + const roleSessionId = record.role_session_id ?? accepted.session_file; + return { + roleSessionId, + conversation: { + id: record.conversation_id ?? roleSessionId, + file: accepted.session_file, + }, + }; + } + } + + // A policy-bearing run writes lifecycle identities. If a damaged log lacks + // one, the session file remains the only durable identity; it is still + // safer to close the run than to reinterpret the selected edge as fresh. + return { + roleSessionId: accepted.session_file, + conversation: { id: accepted.session_file, file: accepted.session_file }, + }; +} + /** Find and validate the exact selector that still targets this checkpoint. */ function latestTrajectorySelector( records: readonly PersistedRecord[], diff --git a/tests/host/trajectory-pre-selector-resume.test.ts b/tests/host/trajectory-pre-selector-resume.test.ts new file mode 100644 index 00000000..4ed538a7 --- /dev/null +++ b/tests/host/trajectory-pre-selector-resume.test.ts @@ -0,0 +1,194 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { SessionManager } from "@earendil-works/pi-coding-agent"; + +import { describe, expect, it, vi } from "vitest"; +import { + createInitialCheckpoint, + FileRecordLog, + loadManifest, + ProductionHost, + resumeRun, +} from "../../src/index.js"; +import { createManifestSnapshot } from "../../src/persistence/trajectory-records.js"; +import { makeModelRegistryWithStub } from "./production-host-fixture.js"; + +const MANIFEST = ` +version: 1 +handoffs: + - from: orchestrator + to: implementer + mode: trajectory +roles: + - name: orchestrator + is_orchestrator: true + models: [{ model: stub:stub-model, effort: off }] + system_prompt: .pi/roles/orchestrator.md + tools: [handoff, end] + - name: implementer + max_visits: 1 + models: [{ model: stub:stub-model, effort: off }] + system_prompt: .pi/roles/implementer.md + tools: [handoff, end] +`; + +describe("Issue #63 pre-selector trajectory resume", () => { + it.each([ + { + name: "after the accepted receiver checkpoint and before the source terminal", + sourceEnded: false, + }, + { name: "after the source terminal and before selector durability", sourceEnded: true }, + ])("fails closed $name", async ({ sourceEnded }) => { + const workdir = await mkdtemp(join(tmpdir(), "pi-conductor-trajectory-pre-selector-")); + const open = vi.spyOn(SessionManager, "open"); + const appendCompaction = vi.spyOn(SessionManager.prototype, "appendCompaction"); + try { + const baseDir = join(workdir, "runs"); + const manifestPath = join(workdir, ".pi", "conductor.yaml"); + await mkdir(join(workdir, ".pi", "roles"), { recursive: true }); + await writeFile(manifestPath, MANIFEST, "utf8"); + await writeFile(join(workdir, ".pi", "roles", "orchestrator.md"), "ORCHESTRATOR", "utf8"); + await writeFile(join(workdir, ".pi", "roles", "implementer.md"), "IMPLEMENTER", "utf8"); + + const loaded = await loadManifest(manifestPath); + const initial = createInitialCheckpoint(loaded.def); + const log = new FileRecordLog({ baseDir }); + const requests: unknown[] = []; + const registry = makeModelRegistryWithStub([], ["stub-model"], (request) => + requests.push(request), + ); + const sourceHost = new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log, + loadedManifest: loaded, + runId: initial.run_id, + }); + const source = await sourceHost.spawnRole("orchestrator"); + await source.prompt("SOURCE_TRAJECTORY_EXACT"); + const sourceConversation = { + id: source.conversationId ?? source.sessionId, + file: source.sessionFile, + }; + await source.dispose(); + const requestsBeforeResume = requests.length; + + log.append( + createManifestSnapshot({ + runId: initial.run_id, + manifest: loaded.manifest, + definition: loaded.def, + ts: 1, + }), + ); + log.append({ type: "run_seeded", run_id: initial.run_id, goal: "resume", ts: 1 }); + log.append({ + type: "session_started", + run_id: initial.run_id, + role: "orchestrator", + visit_index: 1, + state: "orchestrator", + model: "stub:stub-model", + session_file: source.sessionFile, + parent_session: null, + role_session_id: source.sessionId, + conversation_id: sourceConversation.id, + ts: 2, + }); + log.append({ + type: "transition_accepted", + run_id: initial.run_id, + from: "orchestrator", + to: "implementer", + event: "handoff", + target_role: "implementer", + request_end: false, + end_authority: null, + end_requested_by: null, + role: "orchestrator", + suggests_next: null, + payload_summary: { field_names: [] }, + guard: "visit_count[implementer] < max_visits[implementer]", + effect: ["visit_count[implementer] += 1"], + session_file: source.sessionFile, + ts: 3, + }); + const targetCheckpoint = { + ...initial, + current_role: "implementer" as const, + visit_count: { implementer: 1 }, + updated_at: 3, + }; + log.append({ type: "checkpoint_snapshot", checkpoint: targetCheckpoint }); + if (sourceEnded) { + log.append({ + type: "session_ended", + run_id: initial.run_id, + role: "orchestrator", + visit_index: 1, + state: "implementer", + model: "stub:stub-model", + session_file: source.sessionFile, + parent_session: null, + usage: { input: 0, output: 0, cache_read: 0, cache_write: 0, tokens: 0, cost: 0 }, + role_session_id: source.sessionId, + conversation_id: sourceConversation.id, + ts: 4, + }); + log.append({ type: "checkpoint_snapshot", checkpoint: targetCheckpoint }); + } + + let hostFactoryCalls = 0; + const resume = () => + resumeRun(manifestPath, initial.run_id, { + goal: "ignored", + baseDir, + hostFactory: ({ runId, log: resumedLog, loadedManifest }) => { + hostFactoryCalls += 1; + return new ProductionHost({ + modelRegistry: registry, + cwd: workdir, + log: resumedLog, + loadedManifest, + runId, + }); + }, + }); + + await expect(resume()).rejects.toMatchObject({ code: "trajectory_transport_unrecoverable" }); + expect(hostFactoryCalls).toBe(0); + expect(requests).toHaveLength(requestsBeforeResume); + expect(open).not.toHaveBeenCalled(); + expect(appendCompaction).not.toHaveBeenCalled(); + expect( + log.records(initial.run_id).filter((record) => record.type === "session_started"), + ).toHaveLength(1); + expect( + log.records(initial.run_id).filter((record) => record.type === "trajectory_handoff_failed"), + ).toEqual([ + expect.objectContaining({ + from: "orchestrator", + to: "implementer", + source_conversation: sourceConversation, + code: "trajectory_transport_unrecoverable", + }), + ]); + + await expect(resume()).rejects.toMatchObject({ code: "trajectory_transport_unrecoverable" }); + expect(hostFactoryCalls).toBe(0); + expect(requests).toHaveLength(requestsBeforeResume); + expect(open).not.toHaveBeenCalled(); + expect(appendCompaction).not.toHaveBeenCalled(); + expect( + log.records(initial.run_id).filter((record) => record.type === "trajectory_handoff_failed"), + ).toHaveLength(1); + } finally { + open.mockRestore(); + appendCompaction.mockRestore(); + await rm(workdir, { recursive: true, force: true }); + } + }); +}); From 4b9aaa51548b4767273ac81fc5ccef538e3d076b Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 28 Aug 2026 14:12:45 +0000 Subject: [PATCH 20/20] docs: remove unrelated README churn from issue 63 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4ff16b93..b1f25764 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ For the full architecture rationale, see ### 1. Install ```bash -pi install ./ # from this checkout, dev install +pi install ./ # from the checkout, dev install pi list # verify: pi-conductor should appear ``` @@ -886,8 +886,8 @@ the rationale. - **No emitter-specific record types.** The emitter is a transparent fan-out of the existing `PersistedRecord` union, including delegation records when delegation is enabled. -- **No change to the orchestration loop.** The host's `persistRecord` is the - chokepoint; the loop does not need to know the emitter exists. +- **No change to the orchestration loop.** The host's `persistRecord` is + the chokepoint; the loop does not need to know the emitter exists. ---