diff --git a/.changeset/atomic-project-state-migration.md b/.changeset/atomic-project-state-migration.md new file mode 100644 index 000000000..7da17d2ab --- /dev/null +++ b/.changeset/atomic-project-state-migration.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Migrate persisted project maps atomically to immutable version histories and role-neutral proposal attribution, with storage for shared build plans. diff --git a/.changeset/bootstrap-coordinator.md b/.changeset/bootstrap-coordinator.md new file mode 100644 index 000000000..049678f4b --- /dev/null +++ b/.changeset/bootstrap-coordinator.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Stage the complete bootstrap and user-input coordinator with recovery, FIFO delivery, and shutdown handling. Automatic bootstrap remains inactive until server integration. diff --git a/.changeset/bootstrap-storage.md b/.changeset/bootstrap-storage.md new file mode 100644 index 000000000..751d95690 --- /dev/null +++ b/.changeset/bootstrap-storage.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Stage internal durable bootstrap state, first-session claims, and project outbox storage. Automatic bootstrap remains inactive until the coordinator is connected. diff --git a/.changeset/durable-delegation-state.md b/.changeset/durable-delegation-state.md new file mode 100644 index 000000000..470a14129 --- /dev/null +++ b/.changeset/durable-delegation-state.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Add internal durable delegation storage for reservations, exact child bindings, request receipts and release history. Retain unfinished private cleanup proof until cleanup completes. diff --git a/.changeset/durable-project-root-compatibility.md b/.changeset/durable-project-root-compatibility.md new file mode 100644 index 000000000..3f77d7606 --- /dev/null +++ b/.changeset/durable-project-root-compatibility.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Resolve and retain durable project roots consistently for Studio and System Graph, including descendant sessions after a recent directory is evicted. Compare Windows drive and UNC roots without case sensitivity and migrate legacy case aliases while preserving durable project identities. diff --git a/.changeset/immutable-project-version-contracts.md b/.changeset/immutable-project-version-contracts.md new file mode 100644 index 000000000..54083ffba --- /dev/null +++ b/.changeset/immutable-project-version-contracts.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Add immutable Agent Map, build-plan and brief contracts with strict codecs and canonical digest helpers. These additive contracts prepare versioned project state for later activation. diff --git a/.changeset/one-studio-dialog-shell.md b/.changeset/one-studio-dialog-shell.md new file mode 100644 index 000000000..95bfa7b71 --- /dev/null +++ b/.changeset/one-studio-dialog-shell.md @@ -0,0 +1,6 @@ +--- +"@sapiom/harness": patch +"@sapiom/harness-desktop": patch +--- + +Studio's dialogs now behave the same as each other. Add existing agents, Add a project, Create an agent, Use a template, Remove project, End session and Clone agent share one shell, so Tab stays inside the open dialog instead of walking into the rail behind it, the page behind the dialog stops taking clicks and screen-reader attention while it is up, and focus opens on the dialog's subject rather than its close button. Closing a dialog hands focus back to the control that opened it — through the close button, the backdrop and Cancel, not only Escape — wherever that control is still on screen to receive it. Enter submits from a single-line field and Cmd/Ctrl+Enter from a text area, one rule everywhere, and the three confirmation dialogs take neither, so a removal is never one stray Return away. Dialog titles are one size instead of the two they had drifted into. diff --git a/.changeset/ordinary-project-session-identity.md b/.changeset/ordinary-project-session-identity.md new file mode 100644 index 000000000..465f3d944 --- /dev/null +++ b/.changeset/ordinary-project-session-identity.md @@ -0,0 +1,21 @@ +--- +"@sapiom/harness": minor +--- + +Give every Studio project session the same writable coding-agent prompt and +map capabilities, with durable project ownership revalidated before launch +and resume. + +**Breaking for embedders** (minor while `@sapiom/harness` is pre-1.0): +`HarnessSession.agentMapIdentity` now exposes only +`ProjectAgentSession { projectId, userId, sessionId }`. Replace branches on +`role` and `assignment` with neutral project identity. Optional +`projectBootstrap` carries startup status without granting authority. Valid +persisted legacy metadata is normalized while session/provider IDs, cwd, +title, transcript, and Canvas are preserved. Malformed or conflicting +authority fails closed; unavailable project scope prevents resume until the +current owner and root binding are valid again. + +Persisted bootstrap failures with `scope_unavailable` are recognized on +restart, so valid conversation metadata is retained and can resume after +scope is restored. diff --git a/.changeset/ordinary-session-input.md b/.changeset/ordinary-session-input.md new file mode 100644 index 000000000..5b1ba697b --- /dev/null +++ b/.changeset/ordinary-session-input.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Track ordinary session input delivery and runtime ownership so partial input, preemption, stale ingest events, and shutdown are handled consistently. diff --git a/.changeset/owned-child-session-lifecycle.md b/.changeset/owned-child-session-lifecycle.md new file mode 100644 index 000000000..7d872ed27 --- /dev/null +++ b/.changeset/owned-child-session-lifecycle.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Add trusted child-session creation, recovery and closure with exact binding checks, plus exclusive Codex rollout attribution for simultaneous runtimes. This internal lifecycle support prepares project delegation. diff --git a/.changeset/project-bootstrap-activation.md b/.changeset/project-bootstrap-activation.md new file mode 100644 index 000000000..d30d0f33a --- /dev/null +++ b/.changeset/project-bootstrap-activation.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Activate one recoverable first-session map bootstrap for newly opened Studio projects. Durable project intent, input receipts, readiness, preemption, restart recovery, and shutdown now share the ordinary session lifecycle. Retire planner-session routes and metadata after migrating their persisted input queues; embedders should use the ordinary session create, resume, and input routes. diff --git a/.changeset/studio-created-agent-membership.md b/.changeset/studio-created-agent-membership.md new file mode 100644 index 000000000..e4d617197 --- /dev/null +++ b/.changeset/studio-created-agent-membership.md @@ -0,0 +1,8 @@ +--- +"@sapiom/harness": patch +--- + +Register successfully scaffolded agents immediately under their creating Studio +project, including folders beside its root. Preserve the project's existing +conversation when selecting those agents, and restore membership from recorded +scaffold completions on restart without moving files or creating extra sessions. diff --git a/.changeset/studio-onboarding-followups.md b/.changeset/studio-onboarding-followups.md new file mode 100644 index 000000000..2c62359da --- /dev/null +++ b/.changeset/studio-onboarding-followups.md @@ -0,0 +1,12 @@ +--- +"@sapiom/harness": patch +--- + +Deliver a new-agent request directly to Claude Code or Codex at startup after +scaffolding and attachment preparation, without requiring a second Enter or +placing internal authoring instructions in the user's prompt. Preserve project +scope during launch and retain the composer draft when preparation fails. + +Strengthen shared Agent Map, build-plan, and writable subsession guidance while +keeping authoring and runtime capabilities primary. Document project-tool +contracts and replace the known stale Studio orientation at prompt delivery. diff --git a/.changeset/writable-project-subsessions.md b/.changeset/writable-project-subsessions.md index 71e3b271e..925aea3fb 100644 --- a/.changeset/writable-project-subsessions.md +++ b/.changeset/writable-project-subsessions.md @@ -44,4 +44,6 @@ atomically create one new binding and real session for the same delegation key. Dormant eviction emits content-free release telemetry when it commits. If later private-marker cleanup fails, the result remains truthfully `released`, includes the bounded cleanup error, and retains exact proof for idempotent cleanup after -the indicated recovery. +the indicated recovery. Unfinished private cleanup remains discoverable by a +fresh bounded dormant sweep even after the original request receipt expires; +its proof becomes eligible for compaction only after the exact close succeeds. diff --git a/packages/harness/docs/shared-build-plan.md b/packages/harness/docs/shared-build-plan.md index f989b1c71..dbf3ab75d 100644 --- a/packages/harness/docs/shared-build-plan.md +++ b/packages/harness/docs/shared-build-plan.md @@ -6,6 +6,47 @@ map and build-plan MCP tools. Trusted `{ projectId, userId, sessionId }` scope comes only from the private session capability; tool input cannot select or override it. +## Coding-agent guidance and discovery + +Agent delivery remains the priority: scaffold/check/local testing and runtime +capability guidance are not replaced by project coordination. The shared +`profiles/project-agent.ts` appendix teaches all nine project tools to every +ordinary writable project session, including focused children. MCP descriptions +and schema field descriptions carry the detailed request/reference contracts so +the startup prompt need not reproduce full schemas. + +The Agent Map is distinct from the automatic per-agent Canvas. During +implementation, establish a real initial map once boundaries are clear and keep +meaningful agents, contracts, artifacts, and data flow current. Saving a new +`summary.md`, for example, warrants an artifact and writes/reads relationships +even without adding an agent. Read, validate, propose, and verify the saved map +before claiming it is current; validation alone does not persist anything. +Read-only requests still authorize no project-state mutation. + +Use a build plan when coordination needs assignments, sequencing, or acceptance +criteria, not as a prerequisite for a small edit. Obtain exact references from +`build_plan_read({kind: "current"})`, not the compatibility map snapshot. Plan +tool input references omit `projectId`; delegation focus references include it. +After a map-version change, rebase an existing plan before applying plan edits. +Canonical brief refresh is already attempted after apply/rebase and can be +retried independently of the accepted plan. + +Delegated sessions are writable execution contexts sharing the parent's cwd, +not automatically isolated worktrees. Specify disjoint ownership and written +deliverables. A ready session or acknowledged kickoff is not proof the task is +finished; inspect/test its deliverables. Creating a map node, plan, or brief +does not automatically delegate work. + +Startup materialization upgrades only the exact known legacy base-prompt +revision pinned by `profiles/fixtures/legacy-system-prompt.md`; unknown/custom +remote prompts remain untouched. This prevents the old two-MCP, sample-project, +and stop-for-an-invitation orientation from returning while a backend still +serves that revision. The common project appendix also states current +orientation precedence. Keep the current fallback digest and backend served +copy synchronized when publishing; the fixture's historical digest must not +move with the current prompt pin. Local delivery tests do not prove a backend +deployment or that a model consistently follows the instructions. + ## Digests and exact references `GraphContentDigest` identifies canonical graph semantics without project, @@ -147,8 +188,10 @@ capacity errors expose the explicit `release_dormant` recovery code; an all-active live cap continues to require session inspection instead of suggesting an inapplicable dormant cleanup. A bounded private-marker cleanup error may accompany an already-`released` result because eviction is durable first. Exact -cleanup proof remains available so the same sweep can finish after the indicated -recovery or inspection without changing the release outcome. +cleanup proof remains available until the private close completes. After the +original sweep receipt expires, a fresh bounded `release-dormant` request also +retries unfinished cleanup without changing the release outcome or emitting a +second release event. The coordinator waits for canonical adapter readiness and exact transcript identity, then uses fenced spawn and delivery epochs to submit one kickoff. diff --git a/packages/harness/src/core/adapters/claude-code.test.ts b/packages/harness/src/core/adapters/claude-code.test.ts index c0372cf92..f1448996d 100644 --- a/packages/harness/src/core/adapters/claude-code.test.ts +++ b/packages/harness/src/core/adapters/claude-code.test.ts @@ -27,6 +27,8 @@ describe("ClaudeCodeAdapter", () => { adapter.detectBlockingPrompt("\x1b[1mDo you trust the files in this folder?\x1b[0m"), ).toBe(true); expect(adapter.detectBlockingPrompt("Do you trust the files in this directory?")).toBe(true); + expect(adapter.detectBlockingPrompt("Quick safety check: Is this a project you created or one you trust?")).toBe(true); + expect(adapter.detectBlockingPrompt("Yes, I trust this folder")).toBe(true); expect(adapter.detectBlockingPrompt("Choose the text style that looks best")).toBe(true); expect(adapter.detectBlockingPrompt("Select login method:")).toBe(true); expect(adapter.detectBlockingPrompt("> welcome, composer is ready")).toBe(false); @@ -66,6 +68,17 @@ describe("ClaudeCodeAdapter", () => { }); describe("launch/resume", () => { + it("hands the initial user task to the CLI once, separately from startup instructions", () => { + const adapter = new ClaudeCodeAdapter({ binary: "fake-claude" }); + const opts = { + harnessSessionId: "first-task", + cwd: "/tmp/proj", + initialPrompt: "--help\nBuild my ticket triage agent", + }; + expect(adapter.launch(opts).args.slice(-2)).toEqual(["--", opts.initialPrompt]); + expect(adapter.resume("native-id", opts).args).not.toContain(opts.initialPrompt); + }); + it("builds a launch SpawnSpec with settings/mcp-config/system-prompt flags and unsets CLAUDECODE", async () => { const promptDir = await mkdtemp(join(tmpdir(), "harness-claude-test-")); const promptFile = join(promptDir, "prompt.txt"); diff --git a/packages/harness/src/core/adapters/claude-code.ts b/packages/harness/src/core/adapters/claude-code.ts index 0f13b5991..bef40c740 100644 --- a/packages/harness/src/core/adapters/claude-code.ts +++ b/packages/harness/src/core/adapters/claude-code.ts @@ -388,6 +388,7 @@ function buildInteractiveConfigArgs(opts: LaunchOpts): string[] { export const CLAUDE_BLOCKING_PROMPT_PATTERNS: readonly RegExp[] = [ // First-run / new-directory trust dialog. /do\s+you\s+trust\s+the\s+files\s+in\s+this\s+(folder|directory)/i, + /quick\s*safety\s*check|yes,?\s*i\s*trust\s*this\s*folder/i, // First-run theme picker. /choose\s+the\s+text\s+style/i, // Signed-out login flow. @@ -475,6 +476,9 @@ export class ClaudeCodeAdapter implements HarnessAdapter { if (opts.systemPromptFile) { args.push("--append-system-prompt", readPromptFile(opts.systemPromptFile)); } + // A positional initial task is consumed by Claude after onboarding/trust. + // Do not paste it into the PTY or interpret leading dashes as CLI options. + if (opts.initialPrompt) args.push("--", opts.initialPrompt); return { command: this.binary, args, diff --git a/packages/harness/src/core/adapters/codex.test.ts b/packages/harness/src/core/adapters/codex.test.ts index b371fa9c0..c452b6ab2 100644 --- a/packages/harness/src/core/adapters/codex.test.ts +++ b/packages/harness/src/core/adapters/codex.test.ts @@ -26,6 +26,13 @@ function userMessageLine(message: string): string { describe("CodexAdapter", () => { describe("launch/resume", () => { + it("passes the initial user task as one positional argument on fresh launch only", () => { + const adapter = new CodexAdapter({ binary: "fake-codex" }); + const opts = { harnessSessionId: "first-task", cwd: "/tmp/proj", initialPrompt: "--help\nBuild a ticket triage agent" }; + expect(adapter.launch(opts).args.slice(-2)).toEqual(["--", opts.initialPrompt]); + expect(adapter.resume("native-id", opts).args).not.toContain(opts.initialPrompt); + }); + it("builds a launch SpawnSpec with update check off, never-ask approvals, workspace-write sandbox, and no env overrides", () => { const adapter = new CodexAdapter({ binary: "fake-codex" }); const spec = adapter.launch({ harnessSessionId: "h1", cwd: "/tmp/proj" }); diff --git a/packages/harness/src/core/adapters/codex.ts b/packages/harness/src/core/adapters/codex.ts index 215e352e2..f9cc0d5c1 100644 --- a/packages/harness/src/core/adapters/codex.ts +++ b/packages/harness/src/core/adapters/codex.ts @@ -300,9 +300,11 @@ export class CodexAdapter implements HarnessAdapter { } launch(opts: LaunchOpts): SpawnSpec { + const args = buildConfigArgs(opts); + if (opts.initialPrompt) args.push("--", opts.initialPrompt); return { command: this.binary, - args: buildConfigArgs(opts), + args, // Codex has no analog to Claude's CLAUDECODE nested-agent guard; no env // overrides are needed for a fresh launch. env: opts.agentMapMcp diff --git a/packages/harness/src/core/agent-map-proposal-schema.ts b/packages/harness/src/core/agent-map-proposal-schema.ts index 3e7740eaf..3847026a8 100644 --- a/packages/harness/src/core/agent-map-proposal-schema.ts +++ b/packages/harness/src/core/agent-map-proposal-schema.ts @@ -54,7 +54,7 @@ const stripUndefinedProperties = >( export const nodeRefSchema = z.union([ z.object({ nodeId: planNodeIdSchema }).strict(), z.object({ draftRef: draftRefSchema }).strict(), -]); +]).describe("Use nodeId from a prior read/propose for existing nodes; draftRef points to a node added in this same atomic batch."); const nodeChangesSchema = z .object({ @@ -79,14 +79,14 @@ const relationshipChangesSchema = z const addNodeSchema = z .object({ kind: z.literal("add-node"), - draftRef: draftRefSchema, + draftRef: draftRefSchema.describe("Caller-chosen local name for this new node; use the returned allocated node ID in later batches."), node: z .object({ - kind: z.enum(PLAN_NODE_KINDS), + kind: z.enum(PLAN_NODE_KINDS).describe("Architectural role: deployable agent, meaningful subagent, shared resource, connector, or input/output artifact. Not every internal function or file is a node."), name: boundedText(160), purpose: boundedText(2_000), - ownerAgent: nodeRefSchema.nullable(), - contractRefs: contractRefsSchema, + ownerAgent: nodeRefSchema.nullable().describe("Semantic owning agent when applicable; this does not grant coding authority or bind a runtime session."), + contractRefs: contractRefsSchema.describe("Relevant input/output/interface contracts; [] when none are established."), }) .strict(), }) @@ -113,7 +113,7 @@ const addRelationshipSchema = z from: nodeRefSchema, to: nodeRefSchema, kind: z.enum(RELATIONSHIP_KINDS), - executionMode: z.enum(EXECUTION_MODES).nullable(), + executionMode: z.enum(EXECUTION_MODES).nullable().describe("Actual sequencing when applicable; use human-triggered for a manual handoff, not an invented automatic pipeline. null for non-execution relationships."), contractRef: contractRefSchema.nullable(), description: boundedText(2_000, true), }) diff --git a/packages/harness/src/core/build-plan-schema.ts b/packages/harness/src/core/build-plan-schema.ts index 669005369..7038d380e 100644 --- a/packages/harness/src/core/build-plan-schema.ts +++ b/packages/harness/src/core/build-plan-schema.ts @@ -30,12 +30,12 @@ const identityKey = (value: string | { clientRef: string }) => export const toolMapVersionRefSchema = z.object({ versionId: generatedId("mapv"), contentDigest: digest, -}).strict(); +}).strict().describe("Exact map version from build_plan_read: copy versionId and contentDigest only, omitting projectId. Never derive a digest from the numeric proposal version."); export const toolPlanVersionRefSchema = z.object({ planId: generatedId("plan"), versionId: generatedId("planv"), semanticDigest: digest, -}).strict(); +}).strict().describe("Exact plan reference from build_plan_read: copy planId, versionId, and semanticDigest only, omitting projectId."); const focusedBriefSelectionSchema = z.object({ focusScope: z.object({ @@ -52,7 +52,7 @@ const focusedBriefSelectionSchema = z.object({ export const agentBriefRefreshRequestSchema = z.object({ schemaVersion: z.literal(1), - requestId: opaque, + requestId: opaque.describe("Caller-chosen identity: identical retries reuse this ID; changed request content needs a fresh ID."), expectedMap: toolMapVersionRefSchema, expectedPlan: toolPlanVersionRefSchema, focus: z.discriminatedUnion("mode", [ @@ -61,7 +61,7 @@ export const agentBriefRefreshRequestSchema = z.object({ selections: unique(focusedBriefSelectionSchema, (selection) => `${selection.focusScope.delegationKey}\0${selection.focusScope.parentScopeKey ?? ""}`), }).strict(), - ]), + ]).describe("canonical compiles workstream briefs; focused compiles explicit ad-hoc selections. Does not inject a new prompt into a running child."), }).strict(); const milestone = z.object({ @@ -129,14 +129,14 @@ export const buildPlanContentInputSchema = z.object({ const replaceContentOperation = z.object({ op: z.literal("replace-content"), - content: buildPlanContentInputSchema, + content: buildPlanContentInputSchema.describe("Complete replacement, not a patch: supply every collection, preserve unrelated intent, reuse existing IDs, and use {clientRef:'local-name'} for new plan-owned IDs."), }).strict(); export const buildPlanApplyRequestSchema = z.object({ schemaVersion: z.literal(1), - requestId: opaque, + requestId: opaque.describe("Reuse for the same validate/apply request and identical retries; changed content needs a new ID."), expectedMap: toolMapVersionRefSchema, - expectedPlan: toolPlanVersionRefSchema.nullable(), + expectedPlan: toolPlanVersionRefSchema.nullable().describe("Current buildPlan reference from build_plan_read, omitting projectId; null only if creating the first plan."), operations: z.tuple([replaceContentOperation]), }).strict(); @@ -149,11 +149,12 @@ const rebaseResolution = z.discriminatedUnion("kind", [ export const buildPlanRebaseRequestSchema = z.object({ schemaVersion: z.literal(1), - requestId: opaque, + requestId: opaque.describe("Stable ID for identical rebase retries; changed resolutions or source expectations need a new ID."), expectedPlan: toolPlanVersionRefSchema, - fromMap: toolMapVersionRefSchema, - toMap: toolMapVersionRefSchema, - resolutions: unique(rebaseResolution, (resolution) => JSON.stringify(resolution)), + fromMap: toolMapVersionRefSchema.describe("The current plan's bound map: read plan.map and omit projectId."), + toMap: toolMapVersionRefSchema.describe("The latest map: read current.map and omit projectId, even when its content digest is unchanged."), + resolutions: unique(rebaseResolution, (resolution) => JSON.stringify(resolution)) + .describe("Use [] when references remain valid; explicitly resolve invalidated node/assignment/dependency/repository references, preserving unrelated intent."), }).strict(); export const buildPlanReadRequestSchema = z.discriminatedUnion("kind", [ @@ -168,7 +169,7 @@ export const buildPlanReadRequestSchema = z.discriminatedUnion("kind", [ * the request through `buildPlanReadRequestSchema` again. */ export const buildPlanReadToolInputSchema = z.object({ - kind: z.enum(["current", "exact"]), + kind: z.enum(["current", "exact"]).describe("current: send only kind. exact: also supply planId, versionId, and semanticDigest for one immutable historical version."), planId: generatedId("plan").optional(), versionId: generatedId("planv").optional(), semanticDigest: digest.optional(), diff --git a/packages/harness/src/core/created-agent-registration.test.ts b/packages/harness/src/core/created-agent-registration.test.ts new file mode 100644 index 000000000..4776bcec3 --- /dev/null +++ b/packages/harness/src/core/created-agent-registration.test.ts @@ -0,0 +1,205 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AnalyticsEvent } from "../shared/types.js"; +import { createEventStore } from "./collector/store.js"; +import { + CreatedAgentRegistration, + scaffoldCompletion, +} from "./created-agent-registration.js"; +import { StudioWorkspacePreferenceStore } from "./studio-workspace-preferences.js"; + +function completion( + dir: string, + changes: Record = {}, +): AnalyticsEvent { + return { + eventId: "event-a", + seq: 1, + ts: new Date().toISOString(), + userId: null, + tenantId: null, + machineId: "machine-a", + harness: "claude-code", + agentSessionId: null, + type: "tool.call", + harnessSessionId: "session-a", + payload: { + toolName: "mcp__sapiom__sapiom_dev_agents_scaffold", + toolInput: JSON.stringify({ dir }), + toolResponseSummary: JSON.stringify([ + { + type: "text", + text: JSON.stringify({ + targetDir: dir, + projectName: "reviewer", + dependenciesInstalled: true, + gitInitialized: true, + }), + }, + ]), + ...changes, + }, + }; +} + +describe("scaffold completion evidence", () => { + it("reads Claude content and Codex MCP envelopes", () => { + const event = completion("/tmp/reviewer"); + expect(scaffoldCompletion(event)).toEqual({ + dir: "/tmp/reviewer", + targetDir: "/tmp/reviewer", + }); + expect( + scaffoldCompletion( + completion("/tmp/reviewer", { + toolName: "sapiom_dev_agents_scaffold", + toolResponseSummary: JSON.stringify({ + isError: false, + content: JSON.parse(event.payload.toolResponseSummary as string), + }), + }), + ), + ).not.toBeNull(); + }); + it.each([ + { toolName: "Bash" }, + { toolName: "mcp__evil__not_sapiom_dev_agents_scaffold" }, + { toolInput: "{truncated" }, + { toolResponseSummary: "Scaffold succeeded" }, + { toolResponseSummary: JSON.stringify({ targetDir: "/tmp/reviewer" }) }, + { + toolResponseSummary: JSON.stringify({ + isError: true, + content: JSON.parse( + completion("/tmp/reviewer").payload.toolResponseSummary as string, + ), + }), + }, + ])("ignores incomplete or failed evidence: %j", (changes) => { + expect(scaffoldCompletion(completion("/tmp/reviewer", changes))).toBeNull(); + }); +}); + +describe("created agent registration", () => { + const temporary: string[] = []; + afterEach(async () => { + await Promise.all( + temporary + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + async function fixture() { + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "created-agent-")), + ); + temporary.push(root); + const cwd = path.join(root, "original"); + const target = path.join(root, "reviewer"); + await fs.mkdir(cwd); + await fs.mkdir(target); + await fs.writeFile(path.join(target, "sapiom.json"), "{}"); + const preferences = new StudioWorkspacePreferenceStore( + path.join(root, "prefs.json"), + ); + const events = createEventStore(path.join(root, "events.ndjson")); + const projectId = "project_00000000-0000-4000-8000-000000000001"; + const authorize = vi.fn( + async () => + ({ projectId, cwd }) as { projectId: string; cwd: string } | null, + ); + const projectForPath = vi.fn(async () => null as string | null); + const watch = vi.fn(); + const scan = vi.fn(async (agentPath: string) => { + // Publication must already see membership, with no reload or parent scan. + expect( + await preferences.agentIds( + projectId, + [cwd], + [{ path: agentPath, name: "reviewer", definitionId: null }], + true, + ), + ).toHaveProperty("size", 1); + }); + const options = { + preferences, + events, + authorize, + projectForPath, + scan, + watch, + }; + return { + ...options, + cwd, + target, + projectId, + registrar: new CreatedAgentRegistration(options), + options, + }; + } + it("registers before immediate targeted discovery and idempotently recovers after restart", async () => { + const f = await fixture(); + const event = completion(f.target); + await f.events.append(event); + await f.registrar.onEventPersisted(event, "runtime-a"); + expect(f.authorize).toHaveBeenCalledWith(event, "runtime-a"); + expect(f.scan).toHaveBeenCalledWith(f.target); + expect(f.watch).toHaveBeenCalledWith(f.target); + const before = await f.preferences.createdAgents(); + await f.registrar.close(); + await new CreatedAgentRegistration(f.options).recover(["session-a"]); + expect(await f.preferences.createdAgents()).toEqual(before); + }); + it("repairs an already-created sibling using recorded completion evidence", async () => { + const f = await fixture(); + await f.events.append(completion(f.target)); + await f.registrar.recover(["session-a"]); + expect(await f.preferences.createdAgents()).toMatchObject([ + { projectId: f.projectId, path: f.target }, + ]); + }); + + it("does not persist malformed marker metadata into the membership store", async () => { + const f = await fixture(); + await fs.writeFile( + path.join(f.target, "sapiom.json"), + JSON.stringify({ name: {}, definitionId: "bad-id" }), + ); + await f.registrar.onEventPersisted(completion(f.target), "runtime-a"); + expect(await f.preferences.createdAgents()).toMatchObject([ + { name: "reviewer", definitionId: null }, + ]); + const restarted = new StudioWorkspacePreferenceStore( + path.join(path.dirname(f.target), "prefs.json"), + ); + expect(await restarted.createdAgents()).toHaveLength(1); + }); + it.each([ + "foreign-project", + "stale-runtime", + "principal-changed", + "missing-marker", + "mismatched-result", + ])("rejects %s without discovery or ownership writes", async (reason) => { + const f = await fixture(); + const event = completion(f.target); + if (reason === "foreign-project") + f.projectForPath.mockResolvedValue("another-project"); + if (reason === "stale-runtime") f.authorize.mockResolvedValue(null); + if (reason === "principal-changed") + f.authorize + .mockResolvedValueOnce({ projectId: f.projectId, cwd: f.cwd }) + .mockResolvedValue(null); + if (reason === "missing-marker") + await fs.unlink(path.join(f.target, "sapiom.json")); + if (reason === "mismatched-result") + event.payload.toolInput = JSON.stringify({ dir: f.cwd }); + await f.registrar.onEventPersisted(event, "runtime-a"); + expect(await f.preferences.createdAgents()).toEqual([]); + expect(f.scan).not.toHaveBeenCalled(); + expect(f.watch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/harness/src/core/created-agent-registration.ts b/packages/harness/src/core/created-agent-registration.ts new file mode 100644 index 000000000..dd9c53287 --- /dev/null +++ b/packages/harness/src/core/created-agent-registration.ts @@ -0,0 +1,167 @@ +import { realpath } from "node:fs/promises"; +import { basename, isAbsolute, resolve } from "node:path"; + +import type { AnalyticsEvent } from "../shared/types.js"; +import { inspectAgentProjectMarker } from "./agent-project-discovery.js"; +import type { EventStore } from "./collector/store.js"; +import type { StudioWorkspacePreferenceStore } from "./studio-workspace-preferences.js"; + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function json(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +} + +/** Recognize a completed scaffold, not a request, log line, or failed result. */ +export function scaffoldCompletion( + event: AnalyticsEvent, +): { dir: string; targetDir: string } | null { + if (event.type !== "tool.call") return null; + const { toolName, toolInput, toolResponseSummary } = event.payload; + if ( + typeof toolName !== "string" || + !/^(?:mcp__[a-zA-Z0-9_-]+__)?sapiom_dev_agents_scaffold$/.test(toolName) + ) + return null; + const input = json(toolInput); + if (!record(input) || typeof input.dir !== "string" || !input.dir.trim()) + return null; + let output = json(toolResponseSummary); + // Claude's hook carries the MCP content array. Codex can retain the envelope + // (including isError) or the structured JSON result. + if (record(output) && (output.isError === true || output.error != null)) + return null; + if (record(output) && Array.isArray(output.content)) output = output.content; + if (Array.isArray(output)) { + if (output.length !== 1 || !record(output[0]) || output[0].type !== "text") + return null; + output = json(output[0].text); + } + if ( + !record(output) || + output.isError === true || + output.error != null || + typeof output.targetDir !== "string" || + !isAbsolute(output.targetDir) || + typeof output.projectName !== "string" || + !output.projectName || + typeof output.dependenciesInstalled !== "boolean" || + typeof output.gitInitialized !== "boolean" + ) + return null; + return { dir: input.dir, targetDir: output.targetDir }; +} + +interface Creator { + projectId: string; + cwd: string; +} + +export interface CreatedAgentRegistrationOptions { + preferences: StudioWorkspacePreferenceStore; + events: EventStore; + /** Revalidate the server-owned session/principal, and live runtime if given. */ + authorize: ( + event: AnalyticsEvent, + runtimeEpoch?: string, + ) => Promise; + /** Null means unclaimed; a foreign project must never be absorbed. */ + projectForPath: (path: string) => Promise; + scan: (path: string) => Promise; + watch: (path: string) => void; +} + +/** Successful creation -> durable ownership -> exact scan -> live observation. */ +export class CreatedAgentRegistration { + private queue: Promise = Promise.resolve(); + private closed = false; + private observed = new Set(); + + constructor(private readonly options: CreatedAgentRegistrationOptions) {} + + onEventPersisted( + event: AnalyticsEvent, + runtimeEpoch?: string, + ): Promise { + const completion = scaffoldCompletion(event); + if (this.closed || !completion) return Promise.resolve(); + const result = this.queue.then(async () => { + if (this.closed) return; + const creator = await this.options.authorize(event, runtimeEpoch); + if (!creator) return; + let target: string; + try { + // Fresh realpath (not the graph cache): aliases must match, and a + // historical completion is not proof that a folder still exists. + target = await realpath(completion.targetDir); + if (target !== (await realpath(resolve(creator.cwd, completion.dir)))) + return; + } catch { + return; + } + const inspection = await inspectAgentProjectMarker(target); + if (inspection.status !== "valid") return; + const owner = await this.options.projectForPath(target); + if (owner && owner !== creator.projectId) return; + // Filesystem/catalog checks yielded: logout, resume, or close can race. + const current = await this.options.authorize(event, runtimeEpoch); + if ( + this.closed || + current?.projectId !== creator.projectId || + current.cwd !== creator.cwd + ) + return; + const registered = await this.options.preferences.registerCreatedAgent( + creator.projectId, + event.harnessSessionId, + { + path: target, + name: + typeof inspection.marker.name === "string" && inspection.marker.name + ? inspection.marker.name + : basename(target), + definitionId: Number.isSafeInteger(inspection.marker.definitionId) + ? inspection.marker.definitionId! + : null, + }, + ); + if (!registered || this.closed) return; + this.options.watch(target); + await this.options.scan(target); + this.observed.add(target); + }); + this.queue = result.catch(() => {}); + return result; + } + + /** Backfill only recorded successful creations, never infer from siblings. */ + async recover(sessionIds: readonly string[]): Promise { + for await (const event of this.options.events.read({ + harnessSessionId: sessionIds, + types: ["tool.call"], + })) { + await this.onEventPersisted(event); + } + // Membership outlives both analytics retention and its creating session. + for (const binding of await this.options.preferences.createdAgents()) { + if (this.closed) return; + if (this.observed.has(binding.path)) continue; + const owner = await this.options.projectForPath(binding.path); + if (owner && owner !== binding.projectId) continue; + this.options.watch(binding.path); + await this.options.scan(binding.path); + } + } + + async close(): Promise { + this.closed = true; + await this.queue; + } +} diff --git a/packages/harness/src/core/inject/system-prompt.ts b/packages/harness/src/core/inject/system-prompt.ts index 6d1dc7011..261b6aa90 100644 --- a/packages/harness/src/core/inject/system-prompt.ts +++ b/packages/harness/src/core/inject/system-prompt.ts @@ -9,7 +9,7 @@ import * as path from "node:path"; import { HARNESS_PATHS } from "../../shared/types.js"; import { expandHome } from "../paths.js"; -import { DEFAULT_SYSTEM_PROMPT } from "../../profiles/default.js"; +import { DEFAULT_SYSTEM_PROMPT, resolveKnownSystemPrompt } from "../../profiles/default.js"; export interface GenerateSystemPromptFileOptions { /** Root directory generated configs live under. Defaults to @@ -37,7 +37,7 @@ export async function generateSystemPromptFile( const dir = path.join(root, harnessSessionId); await fs.mkdir(dir, { recursive: true }); - const base = options.prompt ?? DEFAULT_SYSTEM_PROMPT; + const base = resolveKnownSystemPrompt(options.prompt ?? DEFAULT_SYSTEM_PROMPT); const appendix = options.appendix?.trim(); const filePath = path.join(dir, "system-prompt.txt"); await fs.writeFile(filePath, appendix ? `${base}\n\n${appendix}\n` : base, "utf8"); diff --git a/packages/harness/src/core/project-bootstrap-store.test.ts b/packages/harness/src/core/project-bootstrap-store.test.ts new file mode 100644 index 000000000..59bf40af4 --- /dev/null +++ b/packages/harness/src/core/project-bootstrap-store.test.ts @@ -0,0 +1,529 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { + ProjectAgentSession, + ProjectBootstrapMetadata, + ProjectBootstrapState, +} from "../shared/agent-map.js"; +import type { HarnessSession } from "../shared/types.js"; +import { + ProjectBootstrapStore, + ProjectBootstrapDispatchForbiddenError, + type ProjectBootstrapStoreOptions, +} from "./project-bootstrap-store.js"; + +class TestBootstrapStore extends ProjectBootstrapStore { + read(session: HarnessSession, emptyProject = true) { + return this.load(session, emptyProject); + } +} + +const PROJECT_ID = "project_00000000-0000-7000-8000-000000000001"; + +const USER_ID = "user-1"; + +const NOW = "2026-09-01T00:00:00.000Z"; + +interface DurableBootstrapState { + schemaVersion: number; + metadata: ProjectBootstrapMetadata; + inputs: Array<{ + id: string; + sessionId: string; + text: string; + acceptedAt: string; + }>; + dispatchingInputId: string | null; + retryCount: number; + emptyProject: boolean; + attempts: Array<{ + attemptId: string; + retryOrdinal: number; + status: "active" | "retired" | "completed"; + phase?: "claimed" | "dispatching" | "not-submitted" | "submitted"; + }>; + uncertainInputIds?: string[]; + uncertainInputs?: Array<{ + id: string; + sessionId: string; + text: string; + acceptedAt: string; + }>; + receipts?: Array<{ + requestId: string | null; + inputId: string; + status: "queued" | "submitted" | "uncertain" | "completed"; + acceptedAt: string; + payloadDigest: string; + }>; +} + +function projectSession( + id = "session-1", + bootstrap: ProjectBootstrapState = { status: "pending" }, +): HarnessSession { + const identity: ProjectAgentSession = { + projectId: PROJECT_ID, + sessionId: id, + userId: USER_ID, + }; + return { + id, + agentSessionId: "provider-conversation-1", + harness: "codex", + cwd: "/private/project", + title: "Plan Agents", + status: "running", + createdAt: NOW, + lastActiveAt: NOW, + exitCode: null, + boundWorkflowPath: null, + ready: true, + agentMapIdentity: identity, + projectBootstrap: { + projectId: identity.projectId, + userId: identity.userId, + targetSessionId: identity.sessionId, + bootstrap: structuredClone(bootstrap), + queuedInputIds: [], + }, + }; +} + +function stateFile(root: string, sessionId: string): string { + return path.join(root, sessionId, "input-queue.json"); +} + +async function readState( + root: string, + sessionId: string, +): Promise { + return JSON.parse( + await fs.readFile(stateFile(root, sessionId), "utf8"), + ) as DurableBootstrapState; +} + +async function writeState( + root: string, + sessionId: string, + state: DurableBootstrapState, +): Promise { + await fs.mkdir(path.dirname(stateFile(root, sessionId)), { recursive: true }); + await fs.writeFile(stateFile(root, sessionId), `${JSON.stringify(state)}\n`); +} +describe("ProjectBootstrapStore", () => { + let root: string; + let legacyRoot: string; + let session: HarnessSession; + let sessions: Map; + let manager: ProjectBootstrapStoreOptions["sessionManager"]; + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "project-bootstrap-store-")); + legacyRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "project-bootstrap-store-legacy-"), + ); + session = projectSession(); + sessions = new Map([[session.id, session]]); + manager = { + get: (id: string) => sessions.get(id), + setProjectBootstrapMetadata: async ( + id: string, + metadata: ProjectBootstrapMetadata, + ) => { + const target = sessions.get(id); + if (!target) throw new Error("session missing"); + target.projectBootstrap = structuredClone(metadata); + }, + }; + }); + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + await fs.rm(legacyRoot, { recursive: true, force: true }); + }); + + it("durably schedules one project lifecycle and atomically claims its first ordinary session", async () => { + const first = new TestBootstrapStore({ + root, + sessionManager: manager, + }); + + await expect(first.scheduleProject(PROJECT_ID, USER_ID)).resolves.toBe( + true, + ); + await expect(first.scheduleProject(PROJECT_ID, USER_ID)).resolves.toBe( + false, + ); + + const restarted = new TestBootstrapStore({ + root, + sessionManager: manager, + }); + await expect( + restarted.needsProjectSession(PROJECT_ID, USER_ID), + ).resolves.toBe(true); + + const claimed = await restarted.claimProject(session.agentMapIdentity!); + expect(claimed).toEqual({ + projectId: PROJECT_ID, + userId: USER_ID, + targetSessionId: session.id, + bootstrap: { status: "pending" }, + queuedInputIds: [], + }); + session.projectBootstrap = claimed!; + await expect( + restarted.needsProjectSession(PROJECT_ID, USER_ID), + ).resolves.toBe(false); + + const second = projectSession("session-2"); + sessions.set(second.id, second); + await expect( + restarted.claimProject(second.agentMapIdentity!), + ).resolves.toBeNull(); + + const intent = JSON.parse( + await fs.readFile( + path.join(root, "projects", `${PROJECT_ID}.json`), + "utf8", + ), + ) as Record; + expect(intent).toMatchObject({ + schemaVersion: 1, + projectId: PROJECT_ID, + userId: USER_ID, + targetSessionId: session.id, + status: "claimed", + }); + expect(JSON.stringify(intent)).not.toContain(session.cwd); + }); + + it("does not let a concurrent create steal a claim before SessionManager publishes its session", async () => { + sessions.delete(session.id); + const coordinator = new TestBootstrapStore({ + root, + sessionManager: manager, + }); + await coordinator.scheduleProject(PROJECT_ID, USER_ID); + + const first = await coordinator.claimProject(session.agentMapIdentity!); + expect(first?.targetSessionId).toBe(session.id); + + const racing = projectSession("session-racing-create"); + expect(await coordinator.claimProject(racing.agentMapIdentity!)).toBeNull(); + await expect( + coordinator.needsProjectSession(PROJECT_ID, USER_ID), + ).resolves.toBe(false); + + // A proven pre-spawn failure releases only the volatile claim. The durable + // project intent remains available for a replacement ordinary session. + await coordinator.releaseSessionClaim(session.id); + expect( + await coordinator.claimProject(racing.agentMapIdentity!), + ).toMatchObject({ targetSessionId: racing.id }); + }); + + it("rejects a foreign project-intent claimant and can recover a missing claimed target", async () => { + const coordinator = new TestBootstrapStore({ + root, + sessionManager: manager, + }); + await coordinator.scheduleProject(PROJECT_ID, USER_ID); + await expect( + coordinator.claimProject({ + ...session.agentMapIdentity!, + userId: "foreign-user", + }), + ).rejects.toBeInstanceOf(ProjectBootstrapDispatchForbiddenError); + + const first = await coordinator.claimProject(session.agentMapIdentity!); + session.projectBootstrap = first!; + await coordinator.releaseSessionClaim(session.id); + sessions.delete(session.id); + const replacement = projectSession("session-replacement"); + sessions.set(replacement.id, replacement); + + const recovered = await coordinator.claimProject( + replacement.agentMapIdentity!, + ); + expect(recovered?.targetSessionId).toBe(replacement.id); + expect(recovered?.bootstrap).toEqual({ status: "pending" }); + }); + + it("keeps a published pre-provider exit as the bounded project failure tombstone", async () => { + const coordinator = new TestBootstrapStore({ + root, + sessionManager: manager, + }); + await coordinator.scheduleProject(PROJECT_ID, USER_ID); + const first = await coordinator.claimProject(session.agentMapIdentity!); + session.projectBootstrap = first!; + session.status = "exited"; + session.agentSessionId = null; + + await expect( + coordinator.needsProjectSession(PROJECT_ID, USER_ID), + ).resolves.toBe(false); + + const replacement = projectSession("session-replacement"); + sessions.set(replacement.id, replacement); + await expect( + coordinator.claimProject(replacement.agentMapIdentity!), + ).resolves.toBeNull(); + + const restarted = new TestBootstrapStore({ + root, + sessionManager: manager, + }); + for (let read = 0; read < 10; read += 1) { + await expect( + restarted.needsProjectSession(PROJECT_ID, USER_ID), + ).resolves.toBe(false); + } + + expect(sessions.get(session.id)).toBe(session); + await expect( + fs + .readFile(path.join(root, "projects", `${PROJECT_ID}.json`), "utf8") + .then(JSON.parse), + ).resolves.toMatchObject({ + schemaVersion: 1, + projectId: PROJECT_ID, + targetSessionId: session.id, + status: "claimed", + }); + }); + + it("refuses replacement when an abandoned target still owns durable input", async () => { + const coordinator = new TestBootstrapStore({ + root, + sessionManager: manager, + }); + await coordinator.scheduleProject(PROJECT_ID, USER_ID); + const first = await coordinator.claimProject(session.agentMapIdentity!); + session.projectBootstrap = first!; + await writeState(root, session.id, { + schemaVersion: 2, + metadata: { + ...structuredClone(first!), + bootstrap: { status: "skipped", reason: "user-proceeded" }, + queuedInputIds: ["durable-user-input"], + }, + inputs: [ + { + id: "durable-user-input", + sessionId: session.id, + text: "preserve this exact request", + acceptedAt: NOW, + }, + ], + dispatchingInputId: null, + retryCount: 0, + emptyProject: true, + attempts: [], + }); + session.status = "exited"; + session.agentSessionId = null; + + await expect( + coordinator.needsProjectSession(PROJECT_ID, USER_ID), + ).resolves.toBe(false); + const replacement = projectSession("session-replacement-refused"); + sessions.set(replacement.id, replacement); + await expect( + coordinator.claimProject(replacement.agentMapIdentity!), + ).resolves.toBeNull(); + + expect((await readState(root, session.id)).inputs).toEqual([ + expect.objectContaining({ + id: "durable-user-input", + sessionId: session.id, + text: "preserve this exact request", + }), + ]); + const intent = JSON.parse( + await fs.readFile( + path.join(root, "projects", `${PROJECT_ID}.json`), + "utf8", + ), + ) as { targetSessionId: string }; + expect(intent.targetSessionId).toBe(session.id); + }); + + it("records real input already pending at claim time as higher priority", async () => { + const coordinator = new TestBootstrapStore({ + root, + sessionManager: manager, + }); + await coordinator.scheduleProject(PROJECT_ID, USER_ID); + + const claimed = await coordinator.claimProject( + session.agentMapIdentity!, + true, + ); + + expect(claimed?.bootstrap).toEqual({ + status: "skipped", + reason: "user-proceeded", + }); + }); + + it("fails closed on malformed primary state without deleting, replacing, or quarantining it", async () => { + const directory = path.join(root, session.id); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile( + stateFile(root, session.id), + "{private-undelivered-input", + ); + session.projectBootstrap!.queuedInputIds = ["unknown-undelivered-input"]; + const coordinator = new TestBootstrapStore({ + root, + sessionManager: manager, + }); + + await expect(coordinator.read(session, true)).rejects.toThrow( + "project bootstrap state is unavailable", + ); + expect(await fs.readFile(stateFile(root, session.id), "utf8")).toBe( + "{private-undelivered-input", + ); + expect(await fs.readdir(directory)).toEqual(["input-queue.json"]); + expect(session.projectBootstrap?.queuedInputIds).toEqual([ + "unknown-undelivered-input", + ]); + }); + + it("rejects a persisted receipt request ID beyond the public 200-character bound", async () => { + session.projectBootstrap!.bootstrap = { + status: "delivered", + messageId: "bootstrap-complete", + }; + await writeState(root, session.id, { + schemaVersion: 3, + metadata: structuredClone(session.projectBootstrap!), + inputs: [], + dispatchingInputId: null, + retryCount: 0, + emptyProject: false, + attempts: [], + uncertainInputIds: [], + uncertainInputs: [], + receipts: [ + { + requestId: "r".repeat(201), + inputId: "completed-input", + status: "completed", + acceptedAt: NOW, + payloadDigest: "a".repeat(64), + }, + ], + }); + const original = await fs.readFile(stateFile(root, session.id), "utf8"); + const coordinator = new TestBootstrapStore({ + root, + sessionManager: manager, + }); + + await expect(coordinator.read(session, false)).rejects.toThrow( + "project bootstrap state is unavailable", + ); + expect(await fs.readFile(stateFile(root, session.id), "utf8")).toBe( + original, + ); + }); + + it("rejects a session identity that could escape the bootstrap root", async () => { + session = projectSession("../escape"); + sessions = new Map([[session.id, session]]); + const coordinator = new TestBootstrapStore({ + root, + sessionManager: manager, + }); + + await expect(coordinator.read(session, true)).rejects.toThrow( + "project bootstrap state is unavailable", + ); + await expect(fs.readdir(root)).resolves.toEqual([]); + }); + + it("migrates a planner-era schema-1 FIFO in place without quarantine or input loss", async () => { + session.ready = false; + const legacyDirectory = path.join(legacyRoot, session.id); + await fs.mkdir(legacyDirectory, { recursive: true }); + await fs.writeFile( + path.join(legacyDirectory, "input-queue.json"), + `${JSON.stringify({ + schemaVersion: 1, + metadata: { + identity: { + projectId: PROJECT_ID, + userId: USER_ID, + sessionId: session.id, + role: "map-planner", + }, + greeting: { status: "delivered", messageId: "legacy-greeting" }, + queuedInputIds: ["legacy-input-1", "legacy-input-2"], + }, + inputs: [ + { + id: "legacy-input-1", + sessionId: session.id, + text: "first durable user request", + acceptedAt: NOW, + }, + { + id: "legacy-input-2", + sessionId: session.id, + text: "second durable user request", + acceptedAt: NOW, + }, + ], + dispatchingInputId: null, + retryCount: 0, + emptyProject: true, + // Schema 1 never defined this field. Migration must ignore it rather + // than accepting forged keyed receipt authority. + receipts: [ + { + requestId: "forged-legacy-key", + inputId: "legacy-input-1", + status: "queued", + acceptedAt: NOW, + payloadDigest: "f".repeat(64), + }, + ], + })}\n`, + ); + + const coordinator = new TestBootstrapStore({ + root, + legacyStateRoot: legacyRoot, + sessionManager: manager, + }); + await coordinator.read(session); + + const migrated = await readState(root, session.id); + expect(migrated).toMatchObject({ + schemaVersion: 3, + metadata: { + projectId: PROJECT_ID, + userId: USER_ID, + targetSessionId: session.id, + bootstrap: { status: "delivered", messageId: "legacy-greeting" }, + queuedInputIds: ["legacy-input-1", "legacy-input-2"], + }, + }); + expect(migrated.metadata).not.toHaveProperty("identity"); + expect(migrated.metadata).not.toHaveProperty("greeting"); + expect(migrated.receipts).toHaveLength(2); + expect(migrated.receipts?.map((receipt) => receipt.requestId)).toEqual([ + null, + null, + ]); + expect( + new Set(migrated.receipts?.map((receipt) => receipt.inputId)).size, + ).toBe(2); + expect(await fs.readdir(legacyDirectory)).toEqual(["input-queue.json"]); + }); +}); diff --git a/packages/harness/src/core/project-bootstrap-store.ts b/packages/harness/src/core/project-bootstrap-store.ts new file mode 100644 index 000000000..8e37d3139 --- /dev/null +++ b/packages/harness/src/core/project-bootstrap-store.ts @@ -0,0 +1,1290 @@ +import { createHash, randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import type { + ProjectBootstrapLifecycleEvent, + ProjectBootstrapInputReceipt, + ProjectBootstrapQueuedInput, + ProjectBootstrapMetadata, + ProjectAgentSession, +} from "../shared/agent-map.js"; +import type { HarnessSession } from "../shared/types.js"; +import type { SessionManager } from "./session-manager.js"; + +export type ProjectBootstrapAttemptPhase = + | "claimed" + | "dispatching" + | "not-submitted" + | "submitted"; + +export interface PersistedProjectBootstrapInputReceipt extends ProjectBootstrapInputReceipt { + payloadDigest: string; +} + +export interface PersistedProjectBootstrapState { + schemaVersion: 3; + metadata: ProjectBootstrapMetadata; + inputs: ProjectBootstrapQueuedInput[]; + /** + * Durable write-ahead intent for the one FIFO head that may be crossing the + * PTY boundary. An unresolved intent is never replayed automatically after a + * restart because the process cannot prove whether the PTY accepted it. + */ + dispatchingInputId: string | null; + retryCount: number; + emptyProject: boolean; + attempts: Array<{ + attemptId: string; + retryOrdinal: number; + status: "active" | "retired" | "completed"; + /** `dispatching` is written before the first PTY byte and is therefore + * conservatively uncertain after process loss. `not-submitted` is written + * only when SessionManager positively proves Enter was never attempted. */ + phase: ProjectBootstrapAttemptPhase; + }>; + /** IDs retained for schema-2 compatibility and bounded inspection. */ + uncertainInputIds: string[]; + /** + * Durable, content-bearing tombstones for FIFO entries whose PTY acceptance + * could not be proven. They are removed from the dispatchable FIFO so later + * user input can progress, but are never replayed or discarded. + */ + uncertainInputs: ProjectBootstrapQueuedInput[]; + /** Session-scoped idempotency receipts for the bounded bootstrap FIFO. */ + receipts: PersistedProjectBootstrapInputReceipt[]; +} + +export interface AcceptedInputLedger { + schemaVersion: 1; + inputIds: string[]; +} + +export interface PersistedProjectBootstrapIntent { + schemaVersion: 1; + projectId: string; + userId: string; + targetSessionId: string | null; + status: "scheduled" | "claimed"; + createdAt: string; + updatedAt: string; +} + +export class ProjectBootstrapDispatchForbiddenError extends Error { + readonly code = "project_bootstrap_dispatch_forbidden"; + + constructor() { + super("project bootstrap is no longer authorized for this session"); + this.name = "ProjectBootstrapDispatchForbiddenError"; + } +} + +export class ProjectBootstrapCoordinatorClosedError extends Error { + readonly code = "project_bootstrap_coordinator_closed"; + + constructor() { + super("project bootstrap coordinator is closed"); + this.name = "ProjectBootstrapCoordinatorClosedError"; + } +} + +export class ProjectBootstrapInputCapacityError extends Error { + readonly code = "project_bootstrap_input_capacity"; + + constructor() { + super("project bootstrap input receipt capacity is temporarily full"); + this.name = "ProjectBootstrapInputCapacityError"; + } +} + +export const MAX_RETRIES = 2; + +export const MAX_INPUT_RECEIPTS = 128; + +/** + * Keep a bounded recent idempotency window. Entries that still own queued or + * submitted work are never evicted. Only completed unkeyed bookkeeping is + * retired; keyed receipts remain stable until the bounded store reaches + * capacity, at which point a new logical request fails before mutation. + */ +export function compactInputReceipts( + receipts: readonly PersistedProjectBootstrapInputReceipt[], + reserveSlots = 0, +): PersistedProjectBootstrapInputReceipt[] { + const limit = Math.max(0, MAX_INPUT_RECEIPTS - reserveSlots); + const compacted: PersistedProjectBootstrapInputReceipt[] = receipts.map( + (receipt) => structuredClone(receipt), + ); + while (compacted.length > limit) { + const index = compacted.findIndex( + (receipt) => receipt.status === "completed" && receipt.requestId === null, + ); + if (index < 0) break; + compacted.splice(index, 1); + } + if (compacted.length > limit) { + throw new ProjectBootstrapInputCapacityError(); + } + return compacted; +} + +export function projectBootstrapInputDigest(text: string): string { + return createHash("sha256") + .update(JSON.stringify({ schemaVersion: 1, submit: true, text })) + .digest("hex"); +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isTerminal(metadata: ProjectBootstrapMetadata): boolean { + return ( + metadata.bootstrap.status === "delivered" || + metadata.bootstrap.status === "skipped" + ); +} + +export function validBootstrapState(value: unknown): boolean { + if (!isRecord(value) || typeof value.status !== "string") return false; + switch (value.status) { + case "pending": + return true; + case "generating": + return typeof value.attemptId === "string" && value.attemptId !== ""; + case "delivered": + return typeof value.messageId === "string" && value.messageId !== ""; + case "failed": + return ( + typeof value.retryable === "boolean" && + typeof value.errorCode === "string" && + [ + "session_not_ready", + "session_exited", + "injection_failed", + "model_turn_failed", + "delivery_timeout", + "persistence_failed", + "scope_unavailable", + ].includes(value.errorCode) + ); + case "skipped": + return ( + value.reason === "user-proceeded" || value.reason === "map-not-empty" + ); + default: + return false; + } +} + +export function parsePersistedProjectBootstrapState( + value: unknown, + session: HarnessSession, +): PersistedProjectBootstrapState | null { + if (!isRecord(value) || !session.projectBootstrap) return null; + const metadata = value.metadata; + if (!isRecord(metadata)) return null; + const expected = session.projectBootstrap; + const legacyIdentity = isRecord(metadata.identity) ? metadata.identity : null; + const projectId = legacyIdentity?.projectId ?? metadata.projectId; + const userId = legacyIdentity?.userId ?? metadata.userId; + const targetSessionId = legacyIdentity?.sessionId ?? metadata.targetSessionId; + const bootstrap = metadata.bootstrap ?? metadata.greeting; + if ( + (value.schemaVersion !== 1 && + value.schemaVersion !== 2 && + value.schemaVersion !== 3) || + projectId !== expected.projectId || + userId !== expected.userId || + targetSessionId !== expected.targetSessionId || + !Array.isArray(metadata.queuedInputIds) || + !metadata.queuedInputIds.every((id) => typeof id === "string") || + !validBootstrapState(bootstrap) || + !Array.isArray(value.inputs) || + (value.dispatchingInputId !== undefined && + value.dispatchingInputId !== null && + typeof value.dispatchingInputId !== "string") || + !Number.isSafeInteger(value.retryCount) || + (value.retryCount as number) < 0 || + (value.retryCount as number) > MAX_RETRIES || + typeof value.emptyProject !== "boolean" + ) { + return null; + } + const inputs = value.inputs; + const storedUncertainInputs = Array.isArray(value.uncertainInputs) + ? value.uncertainInputs + : []; + const validInput = (input: unknown): input is ProjectBootstrapQueuedInput => + isRecord(input) && + typeof input.id === "string" && + input.id !== "" && + input.sessionId === session.id && + typeof input.text === "string" && + input.text.length <= 100_000 && + typeof input.acceptedAt === "string"; + if ( + !inputs.every(validInput) || + !storedUncertainInputs.every(validInput) || + new Set( + [...inputs, ...storedUncertainInputs].map( + (input) => (input as ProjectBootstrapQueuedInput).id, + ), + ).size !== + inputs.length + storedUncertainInputs.length || + metadata.queuedInputIds.length !== inputs.length || + metadata.queuedInputIds.some((id, index) => id !== inputs[index]?.id) || + (typeof value.dispatchingInputId === "string" && + value.dispatchingInputId !== inputs[0]?.id) + ) { + return null; + } + const sourceSchemaVersion = Number(value.schemaVersion); + const storedAttempts = Array.isArray(value.attempts) ? value.attempts : []; + const validAttempt = ( + attempt: unknown, + ): attempt is PersistedProjectBootstrapState["attempts"][number] => + isRecord(attempt) && + typeof attempt.attemptId === "string" && + attempt.attemptId !== "" && + Number.isSafeInteger(attempt.retryOrdinal) && + Number(attempt.retryOrdinal) >= 0 && + Number(attempt.retryOrdinal) <= MAX_RETRIES && + ["active", "retired", "completed"].includes(String(attempt.status)) && + (sourceSchemaVersion < 3 || + ["claimed", "dispatching", "not-submitted", "submitted"].includes( + String(attempt.phase), + )); + if ( + sourceSchemaVersion >= 3 && + (!Array.isArray(value.attempts) || + storedAttempts.length > 8 || + !storedAttempts.every(validAttempt) || + new Set( + storedAttempts.filter(validAttempt).map((attempt) => attempt.attemptId), + ).size !== storedAttempts.length || + new Set( + storedAttempts + .filter(validAttempt) + .map((attempt) => attempt.retryOrdinal), + ).size !== storedAttempts.length) + ) { + // Current-schema attempt evidence is retry authority. Never turn a missing, + // malformed, or duplicate entry into an apparently safe empty history. + return null; + } + const attempts = Array.isArray(value.attempts) + ? value.attempts.filter(validAttempt).map((attempt) => ({ + attemptId: attempt.attemptId, + retryOrdinal: attempt.retryOrdinal, + status: attempt.status, + // Schema 1/2 could already have crossed Enter and therefore migrates + // conservatively. Only schema 3 can prove a pre-PTY claim. + phase: + sourceSchemaVersion >= 3 + ? attempt.phase + : attempt.status === "completed" + ? "submitted" + : "dispatching", + })) + : []; + const persistedUncertainIds = Array.isArray(value.uncertainInputIds) + ? value.uncertainInputIds.filter( + (inputId): inputId is string => typeof inputId === "string", + ) + : []; + const legacyUncertainIds = new Set( + persistedUncertainIds.filter((inputId) => + inputs.some((input) => isRecord(input) && input.id === inputId), + ), + ); + const normalizedInputs = ( + structuredClone(value.inputs) as ProjectBootstrapQueuedInput[] + ).filter((input) => !legacyUncertainIds.has(input.id)); + const normalizedUncertainInputs = [ + ...(structuredClone( + storedUncertainInputs, + ) as ProjectBootstrapQueuedInput[]), + ...(structuredClone(value.inputs) as ProjectBootstrapQueuedInput[]).filter( + (input) => legacyUncertainIds.has(input.id), + ), + ]; + const uncertainInputIds = normalizedUncertainInputs.map((input) => input.id); + let normalizedBootstrap = structuredClone( + bootstrap, + ) as ProjectBootstrapMetadata["bootstrap"]; + if ( + sourceSchemaVersion < 3 && + normalizedBootstrap.status === "failed" && + normalizedBootstrap.retryable && + normalizedBootstrap.errorCode !== "session_not_ready" + ) { + // Schema 1/2 had no durable phase evidence. A legacy retryable flag cannot + // prove that an injection/persistence failure preceded Enter. + normalizedBootstrap = { ...normalizedBootstrap, retryable: false }; + } + if ( + sourceSchemaVersion < 3 && + attempts.length === 0 && + normalizedBootstrap.status === "generating" + ) { + attempts.push({ + attemptId: normalizedBootstrap.attemptId, + retryOrdinal: Math.max(0, Number(value.retryCount) || 0), + status: "active", + phase: "dispatching", + }); + } + if ( + sourceSchemaVersion >= 3 && + normalizedBootstrap.status === "generating" && + !attempts.some((attempt) => { + const activeAttemptId = + normalizedBootstrap.status === "generating" + ? normalizedBootstrap.attemptId + : null; + return ( + attempt.attemptId === activeAttemptId && attempt.status === "active" + ); + }) + ) { + return null; + } + if ( + normalizedInputs.length > 0 && + !isTerminal({ + projectId: expected.projectId, + userId: expected.userId, + targetSessionId: expected.targetSessionId, + bootstrap: normalizedBootstrap, + queuedInputIds: normalizedInputs.map((input) => input.id), + }) + ) { + if (sourceSchemaVersion >= 3) return null; + // Legacy planner queues could persist the FIFO before their greeting skip. + // The user input is authoritative, so migration completes that transition + // without changing IDs or message bodies. + normalizedBootstrap = { status: "skipped", reason: "user-proceeded" }; + } + if (sourceSchemaVersion >= 3 && !Array.isArray(value.receipts)) return null; + // Schema 1/2 never owned receipt authority. Ignore any injected property and + // derive canonical unkeyed receipts solely from the migrated FIFO/tombstone + // state so legacy data cannot mint an idempotency key. + const storedReceipts = + sourceSchemaVersion >= 3 && Array.isArray(value.receipts) + ? value.receipts + : []; + const validReceipts = storedReceipts.filter( + (receipt): receipt is Record => + isRecord(receipt) && + (receipt.requestId === null || + (typeof receipt.requestId === "string" && + receipt.requestId !== "" && + receipt.requestId.length <= 200)) && + typeof receipt.inputId === "string" && + receipt.inputId !== "" && + ["queued", "submitted", "uncertain", "completed"].includes( + String(receipt.status), + ) && + typeof receipt.acceptedAt === "string" && + typeof receipt.payloadDigest === "string" && + /^[0-9a-f]{64}$/.test(receipt.payloadDigest), + ); + const receipts: PersistedProjectBootstrapInputReceipt[] = validReceipts.map( + (receipt) => ({ + requestId: receipt.requestId as string | null, + inputId: receipt.inputId as string, + status: receipt.status as ProjectBootstrapInputReceipt["status"], + acceptedAt: receipt.acceptedAt as string, + payloadDigest: receipt.payloadDigest as string, + }), + ); + if (sourceSchemaVersion < 3) { + for (const input of normalizedInputs) { + receipts.push({ + requestId: null, + inputId: input.id, + status: "queued", + acceptedAt: input.acceptedAt, + payloadDigest: projectBootstrapInputDigest(input.text), + }); + } + for (const input of normalizedUncertainInputs) { + receipts.push({ + requestId: null, + inputId: input.id, + status: "uncertain", + acceptedAt: input.acceptedAt, + payloadDigest: projectBootstrapInputDigest(input.text), + }); + } + } + if ( + sourceSchemaVersion >= 3 && + (validReceipts.length !== storedReceipts.length || + new Set(receipts.map((receipt) => receipt.inputId)).size !== + receipts.length || + new Set( + receipts + .filter((receipt) => receipt.requestId !== null) + .map((receipt) => receipt.requestId), + ).size !== + receipts.filter((receipt) => receipt.requestId !== null).length) + ) { + return null; + } + if ( + sourceSchemaVersion >= 3 && + (normalizedInputs.some((input) => { + const receipt = receipts.find( + (candidate) => candidate.inputId === input.id, + ); + return ( + !receipt || + receipt.acceptedAt !== input.acceptedAt || + receipt.payloadDigest !== projectBootstrapInputDigest(input.text) || + receipt.status === "completed" || + receipt.status === "uncertain" + ); + }) || + normalizedUncertainInputs.some((input) => { + const receipt = receipts.find( + (candidate) => candidate.inputId === input.id, + ); + return ( + !receipt || + receipt.acceptedAt !== input.acceptedAt || + receipt.payloadDigest !== projectBootstrapInputDigest(input.text) || + receipt.status !== "uncertain" + ); + }) || + receipts.some( + (receipt) => + receipt.status === "queued" && + !normalizedInputs.some((input) => input.id === receipt.inputId), + ) || + // Receipt order is the durable logical-arrival order. Live FIFO rows may + // have terminal receipt-only predecessors after a crash, but the rows + // themselves must remain a monotonic subsequence so boot recovery can + // conservatively terminalize the causal prefix without guessing. + normalizedInputs.some((input, index) => { + if (index === 0) return false; + const priorIndex = receipts.findIndex( + (receipt) => receipt.inputId === normalizedInputs[index - 1]?.id, + ); + const currentIndex = receipts.findIndex( + (receipt) => receipt.inputId === input.id, + ); + return priorIndex < 0 || currentIndex <= priorIndex; + }) || + normalizedInputs.some( + (input, index) => + receipts.at(index - normalizedInputs.length)?.inputId !== input.id, + )) + ) { + return null; + } + let compactedReceipts: PersistedProjectBootstrapInputReceipt[]; + try { + compactedReceipts = compactInputReceipts(receipts); + } catch { + return null; + } + return { + schemaVersion: 3, + metadata: { + projectId: expected.projectId, + userId: expected.userId, + targetSessionId: expected.targetSessionId, + bootstrap: normalizedBootstrap, + queuedInputIds: normalizedInputs.map((input) => input.id), + }, + inputs: normalizedInputs, + dispatchingInputId: + typeof value.dispatchingInputId === "string" && + legacyUncertainIds.has(value.dispatchingInputId) + ? null + : (value.dispatchingInputId ?? null), + retryCount: Number(value.retryCount), + emptyProject: Boolean(value.emptyProject), + attempts: attempts.slice(-8), + uncertainInputIds, + uncertainInputs: normalizedUncertainInputs, + receipts: compactedReceipts, + }; +} +export interface ProjectBootstrapStoreOptions { + root: string; + legacyStateRoot?: string; + now?: () => string; + sessionManager: Pick; + writeState?: (file: string, state: unknown) => Promise; + writeAcceptedLedger?: (file: string, state: unknown) => Promise; + onEvent?: (event: ProjectBootstrapLifecycleEvent) => Promise | void; +} + +/** Durable project enrollment and input state shared by the bootstrap lifecycle. + * This storage boundary does not start sessions, timers, or model turns. */ +export class ProjectBootstrapStore { + constructor(protected readonly storageOptions: ProjectBootstrapStoreOptions) { + this.root = path.resolve(storageOptions.root); + this.legacyStateRoot = storageOptions.legacyStateRoot + ? path.resolve(storageOptions.legacyStateRoot) + : null; + this.now = storageOptions.now ?? (() => new Date().toISOString()); + } + + protected readonly root: string; + + protected readonly legacyStateRoot: string | null; + + protected readonly now: () => string; + + protected readonly states = new Map(); + + protected readonly writes = new Map>(); + + /** Project claims made before SessionManager publishes the new session. */ + protected readonly provisionalProjectClaims = new Map(); + + protected readonly provisionalSessionClaims = new Map(); + + protected closed = false; + + protected sessionDirectory(sessionId: string): string { + const directory = path.resolve(this.root, sessionId); + const rootPrefix = `${this.root}${path.sep}`; + if (!directory.startsWith(rootPrefix)) { + throw new Error("invalid project bootstrap storage identity"); + } + return directory; + } + + protected file(sessionId: string): string { + return path.join(this.sessionDirectory(sessionId), "input-queue.json"); + } + + protected legacyFile(sessionId: string, name: string): string | null { + if (!this.legacyStateRoot) return null; + const directory = path.resolve(this.legacyStateRoot, sessionId); + if (!directory.startsWith(`${this.legacyStateRoot}${path.sep}`)) { + throw new Error("invalid legacy project bootstrap storage identity"); + } + return path.join(directory, name); + } + + protected acceptedFile(sessionId: string): string { + return path.join(this.sessionDirectory(sessionId), "accepted-inputs.json"); + } + + protected projectIntentFile(projectId: string): string { + if (!/^project_[0-9a-f-]+$/.test(projectId)) { + throw new Error("invalid project bootstrap identity"); + } + const directory = path.resolve(this.root, "projects"); + const file = path.resolve(directory, `${projectId}.json`); + if (!file.startsWith(`${directory}${path.sep}`)) { + throw new Error("invalid project bootstrap identity"); + } + return file; + } + + protected emit(event: ProjectBootstrapLifecycleEvent): void { + try { + void Promise.resolve(this.storageOptions.onEvent?.(event)).catch( + () => {}, + ); + } catch { + // Telemetry is best effort and must never change bootstrap semantics. + } + } + + protected assertOpen(): void { + if (this.closed) throw new ProjectBootstrapCoordinatorClosedError(); + } + + protected receiptForInput( + state: PersistedProjectBootstrapState, + inputId: string, + ): PersistedProjectBootstrapInputReceipt | undefined { + return state.receipts.find((receipt) => receipt.inputId === inputId); + } + + protected updateReceiptStatus( + state: PersistedProjectBootstrapState, + inputId: string, + status: ProjectBootstrapInputReceipt["status"], + ): void { + const receipt = this.receiptForInput(state, inputId); + if (!receipt) return; + // `completed` and `uncertain` are distinct terminal evidence. Neither may + // be weakened or rewritten by later boot reconciliation or a late hook. + if (receipt.status === "completed" || receipt.status === "uncertain") + return; + if (receipt.status === "submitted" && status === "queued") return; + receipt.status = status; + } + + protected serialize( + sessionId: string, + operation: () => Promise, + ): Promise { + const prior = this.writes.get(sessionId) ?? Promise.resolve(); + const next = prior.catch(() => {}).then(operation); + this.writes.set(sessionId, next); + void next.then( + () => { + if (this.writes.get(sessionId) === next) this.writes.delete(sessionId); + }, + () => { + if (this.writes.get(sessionId) === next) this.writes.delete(sessionId); + }, + ); + return next; + } + + protected newState( + session: HarnessSession, + emptyProject: boolean, + ): PersistedProjectBootstrapState { + if (!session.projectBootstrap) { + throw new Error("project bootstrap metadata missing"); + } + return { + schemaVersion: 3, + metadata: { + ...structuredClone(session.projectBootstrap), + // The queue file owns FIFO membership. If that file is missing or was + // quarantined, stale registry IDs cannot resurrect content we no + // longer possess or make the replacement state invalid on next boot. + queuedInputIds: [], + }, + inputs: [], + dispatchingInputId: null, + retryCount: 0, + emptyProject, + attempts: [], + uncertainInputIds: [], + uncertainInputs: [], + receipts: [], + }; + } + + protected async load( + session: HarnessSession, + emptyProject = true, + ): Promise { + const cached = this.states.get(session.id); + // Every transition works on an isolated snapshot. Nothing may mutate the + // authoritative cache until persist() commits the primary queue file. + if (cached) return structuredClone(cached); + let state: PersistedProjectBootstrapState; + try { + const parsed: unknown = JSON.parse( + await fs.readFile(this.file(session.id), "utf8"), + ); + const normalized = parsePersistedProjectBootstrapState(parsed, session); + if (!normalized) throw new Error("invalid project bootstrap state"); + state = normalized; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + // Keep malformed legacy files in place for explicit recovery. Never + // rename away or overwrite a file that may contain undelivered input. + throw new Error("project bootstrap state is unavailable"); + } + const legacyFile = this.legacyFile(session.id, "input-queue.json"); + if (legacyFile) { + try { + const legacy: unknown = JSON.parse( + await fs.readFile(legacyFile, "utf8"), + ); + const normalized = parsePersistedProjectBootstrapState( + legacy, + session, + ); + if (!normalized) { + throw new Error("invalid legacy project bootstrap state"); + } + const legacyAccepted = this.legacyFile( + session.id, + "accepted-inputs.json", + ); + if (legacyAccepted) { + try { + const accepted = await fs.readFile(legacyAccepted, "utf8"); + const decoded: unknown = JSON.parse(accepted); + if ( + !isRecord(decoded) || + decoded.schemaVersion !== 1 || + !Array.isArray(decoded.inputIds) || + !decoded.inputIds.every( + (inputId) => typeof inputId === "string" && inputId !== "", + ) + ) { + throw new Error("invalid legacy accepted-input ledger"); + } + await this.writeAcceptedInputIds( + session.id, + decoded.inputIds as string[], + ); + } catch (legacyAcceptedError) { + if ( + (legacyAcceptedError as NodeJS.ErrnoException).code !== "ENOENT" + ) { + throw legacyAcceptedError; + } + } + } + await this.writeState(this.file(session.id), normalized); + state = normalized; + } catch (legacyError) { + if ((legacyError as NodeJS.ErrnoException).code !== "ENOENT") { + // Preserve the only copy and fail closed. Never quarantine or + // overwrite a planner-era FIFO that may contain user input. + throw new Error("legacy project bootstrap state is unavailable"); + } + state = this.newState(session, emptyProject); + } + } else { + state = this.newState(session, emptyProject); + } + } + this.states.set(session.id, structuredClone(state)); + return state; + } + + protected async writeState( + file: string, + state: PersistedProjectBootstrapState, + ): Promise { + if (this.storageOptions.writeState) { + await this.storageOptions.writeState(file, structuredClone(state)); + return; + } + await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.tmp-${process.pid}-${randomUUID()}`; + await fs.writeFile(tmp, JSON.stringify(state, null, 2) + "\n", { + encoding: "utf8", + mode: 0o600, + }); + await fs.rename(tmp, file); + } + + protected async writeIntent( + file: string, + intent: PersistedProjectBootstrapIntent, + ): Promise { + await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; + try { + await fs.writeFile(temporary, `${JSON.stringify(intent, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await fs.rename(temporary, file); + } finally { + await fs.rm(temporary, { force: true }).catch(() => {}); + } + } + + protected async readIntent( + projectId: string, + ): Promise { + let decoded: unknown; + try { + decoded = JSON.parse( + await fs.readFile(this.projectIntentFile(projectId), "utf8"), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new Error("project bootstrap intent is unavailable"); + } + if ( + !isRecord(decoded) || + decoded.schemaVersion !== 1 || + decoded.projectId !== projectId || + typeof decoded.userId !== "string" || + decoded.userId === "" || + (decoded.targetSessionId !== null && + (typeof decoded.targetSessionId !== "string" || + decoded.targetSessionId === "")) || + (decoded.status !== "scheduled" && decoded.status !== "claimed") || + (decoded.status === "scheduled" && decoded.targetSessionId !== null) || + (decoded.status === "claimed" && decoded.targetSessionId === null) || + typeof decoded.createdAt !== "string" || + typeof decoded.updatedAt !== "string" + ) { + throw new Error("project bootstrap intent is malformed"); + } + return structuredClone( + decoded, + ) as unknown as PersistedProjectBootstrapIntent; + } + + /** Durably schedules the lifecycle before a project has a launchable root. */ + scheduleProject(projectId: string, userId: string): Promise { + return this.serialize(`project:${projectId}`, async () => { + this.assertOpen(); + const existing = await this.readIntent(projectId); + if (existing) { + if (existing.userId !== userId) { + throw new ProjectBootstrapDispatchForbiddenError(); + } + return false; + } + const timestamp = this.now(); + await this.writeIntent(this.projectIntentFile(projectId), { + schemaVersion: 1, + projectId, + userId, + targetSessionId: null, + status: "scheduled", + createdAt: timestamp, + updatedAt: timestamp, + }); + return true; + }); + } + + /** A replacement session may only claim an abandoned pre-provider target + * when doing so cannot strand content in that target's durable FIFO. Missing + * or malformed state is treated conservatively whenever a queue file exists. */ + protected async targetHasUnresolvedInput( + sessionId: string, + ): Promise { + const target = this.storageOptions.sessionManager.get(sessionId); + if ((target?.projectBootstrap?.queuedInputIds.length ?? 0) > 0) return true; + + const files = [ + this.file(sessionId), + this.legacyFile(sessionId, "input-queue.json"), + ].filter((file): file is string => file !== null); + for (const file of files) { + let decoded: unknown; + try { + decoded = JSON.parse(await fs.readFile(file, "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + return true; + } + if (!isRecord(decoded) || !Array.isArray(decoded.inputs)) return true; + const metadata = isRecord(decoded.metadata) ? decoded.metadata : null; + if (!metadata || !Array.isArray(metadata.queuedInputIds)) return true; + if (metadata.queuedInputIds.length > 0) return true; + if (decoded.inputs.length > 0) return true; + if ( + decoded.dispatchingInputId !== null && + decoded.dispatchingInputId !== undefined + ) { + return true; + } + if ( + (decoded.uncertainInputs !== undefined && + !Array.isArray(decoded.uncertainInputs)) || + (decoded.uncertainInputIds !== undefined && + !Array.isArray(decoded.uncertainInputIds)) || + (Array.isArray(decoded.uncertainInputs) && + decoded.uncertainInputs.length > 0) || + (Array.isArray(decoded.uncertainInputIds) && + decoded.uncertainInputIds.length > 0) + ) { + return true; + } + } + return false; + } + + /** Whether a scheduled project still needs its one ordinary first session. */ + needsProjectSession(projectId: string, userId: string): Promise { + return this.serialize(`project:${projectId}`, async () => { + this.assertOpen(); + const intent = await this.readIntent(projectId); + if (!intent) return false; + if (intent.userId !== userId) { + throw new ProjectBootstrapDispatchForbiddenError(); + } + if (intent.status === "scheduled") return true; + const target = intent.targetSessionId + ? this.storageOptions.sessionManager.get(intent.targetSessionId) + : undefined; + if ( + intent.targetSessionId && + this.provisionalProjectClaims.get(projectId) === intent.targetSessionId + ) { + // A published row is already the durable first-session outcome, even + // when its process exited before a provider session ID was observed. + // A missing row can only be replaced after create() releases this + // provisional fence, proving publication never committed. + return false; + } + return ( + !target && + !(await this.targetHasUnresolvedInput(intent.targetSessionId!)) + ); + }); + } + + /** Atomically binds a scheduled project lifecycle to its first real session. */ + claimProject( + identity: ProjectAgentSession, + initialUserInputPending = false, + ): Promise { + return this.serialize(`project:${identity.projectId}`, async () => { + this.assertOpen(); + const intent = await this.readIntent(identity.projectId); + if (!intent) return null; + if (intent.userId !== identity.userId) { + throw new ProjectBootstrapDispatchForbiddenError(); + } + if ( + intent.status === "claimed" && + intent.targetSessionId !== identity.sessionId + ) { + const target = this.storageOptions.sessionManager.get( + intent.targetSessionId!, + ); + if ( + this.provisionalProjectClaims.get(identity.projectId) === + intent.targetSessionId + ) { + return null; + } + if (target) return null; + if (await this.targetHasUnresolvedInput(intent.targetSessionId!)) { + return null; + } + } + const claimed: PersistedProjectBootstrapIntent = { + ...intent, + targetSessionId: identity.sessionId, + status: "claimed", + updatedAt: this.now(), + }; + await this.writeIntent( + this.projectIntentFile(identity.projectId), + claimed, + ); + this.provisionalProjectClaims.set(identity.projectId, identity.sessionId); + this.provisionalSessionClaims.set(identity.sessionId, identity.projectId); + return { + projectId: identity.projectId, + userId: identity.userId, + targetSessionId: identity.sessionId, + bootstrap: initialUserInputPending + ? { status: "skipped", reason: "user-proceeded" } + : { status: "pending" }, + queuedInputIds: [], + }; + }); + } + + /** Release only an unpublished/failed create claim. Durable intent remains + * available for the next proven session and is never deleted. */ + releaseSessionClaim(sessionId: string): Promise { + const projectId = this.provisionalSessionClaims.get(sessionId); + if (!projectId) return Promise.resolve(); + return this.serialize(`project:${projectId}`, async () => { + if (this.provisionalProjectClaims.get(projectId) === sessionId) { + this.provisionalProjectClaims.delete(projectId); + } + this.provisionalSessionClaims.delete(sessionId); + }); + } + + protected async acceptedInputIds( + sessionId: string, + ): Promise | null> { + const file = this.acceptedFile(sessionId); + try { + const decoded: unknown = JSON.parse(await fs.readFile(file, "utf8")); + if ( + !isRecord(decoded) || + decoded.schemaVersion !== 1 || + !Array.isArray(decoded.inputIds) || + decoded.inputIds.length > MAX_INPUT_RECEIPTS || + !decoded.inputIds.every( + (inputId) => typeof inputId === "string" && inputId !== "", + ) || + new Set(decoded.inputIds).size !== decoded.inputIds.length + ) { + throw new Error("invalid accepted-input ledger"); + } + return new Set(decoded.inputIds); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Set(); + // An unreadable acknowledgement is safety-significant. Keep the queue's + // write-ahead intent unresolved instead of guessing and replaying it. + // The artifact stays in place until its FIFO is durably terminalized; + // otherwise a crash after quarantine would turn unknown proof into + // ENOENT and authorize replay on the next process. + return null; + } + } + + protected async quarantineAcceptedLedger(sessionId: string): Promise { + const file = this.acceptedFile(sessionId); + const quarantine = path.join( + path.dirname(file), + `accepted-inputs.corrupt-${this.now().replace(/[^0-9A-Za-z]/g, "-")}-${randomUUID()}.json`, + ); + await fs.rename(file, quarantine).catch(() => {}); + } + + protected async terminalizeUnreadableAcceptedLedger( + state: PersistedProjectBootstrapState, + ): Promise { + const sessionId = state.metadata.targetSessionId; + const terminal = structuredClone(state); + const uncertainById = new Map( + terminal.uncertainInputs.map((input) => [input.id, input]), + ); + for (const input of terminal.inputs) { + uncertainById.set(input.id, structuredClone(input)); + const receipt = this.receiptForInput(terminal, input.id); + if (receipt && receipt.status !== "completed") + receipt.status = "uncertain"; + } + for (const receipt of terminal.receipts) { + if (receipt.status === "submitted") receipt.status = "uncertain"; + } + terminal.inputs = []; + terminal.metadata.queuedInputIds = []; + terminal.dispatchingInputId = null; + terminal.uncertainInputs = [...uncertainById.values()]; + terminal.uncertainInputIds = terminal.uncertainInputs.map( + (input) => input.id, + ); + await this.persist(sessionId, terminal); + // Safe state is authoritative before the unreadable artifact moves. A + // failed rename merely causes the same idempotent normalization next time. + await this.quarantineAcceptedLedger(sessionId); + return terminal; + } + + protected async writeAcceptedInputIds( + sessionId: string, + inputIds: readonly string[], + ): Promise { + const file = this.acceptedFile(sessionId); + const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; + const ledger: AcceptedInputLedger = { + schemaVersion: 1, + inputIds: [...inputIds], + }; + if (this.storageOptions.writeAcceptedLedger) { + await this.storageOptions.writeAcceptedLedger( + file, + structuredClone(ledger), + ); + return; + } + try { + await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + await fs.writeFile(temporary, `${JSON.stringify(ledger, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await fs.rename(temporary, file); + } finally { + await fs.rm(temporary, { force: true }).catch(() => {}); + } + } + + protected async recordAcceptedInput( + state: PersistedProjectBootstrapState, + inputId: string, + ): Promise { + const sessionId = state.metadata.targetSessionId; + const accepted = await this.acceptedInputIds(sessionId); + if (accepted === null) { + throw new Error("project bootstrap input acceptance ledger unavailable"); + } + // IDs whose queue entries were already durably removed are stale cleanup + // residue and can be compacted. The active FIFO is bounded by the request + // body limit, and only its IDs are retained here (never input content). + const queuedIds = new Set(state.inputs.map((input) => input.id)); + const retained = [...accepted].filter((id) => queuedIds.has(id)); + if (!retained.includes(inputId)) retained.push(inputId); + await this.writeAcceptedInputIds(sessionId, retained); + } + + protected async reconcileAcceptedInputs( + state: PersistedProjectBootstrapState, + ): Promise { + const sessionId = state.metadata.targetSessionId; + const accepted = await this.acceptedInputIds(sessionId); + if (accepted === null) + return this.terminalizeUnreadableAcceptedLedger(state); + if (accepted.size === 0) return state; + const remaining = state.inputs.filter((input) => !accepted.has(input.id)); + const uncertainById = new Map( + state.uncertainInputs.map((input) => [input.id, structuredClone(input)]), + ); + const receipts = structuredClone(state.receipts); + for (const inputId of accepted) { + const receipt = receipts.find( + (candidate) => candidate.inputId === inputId, + ); + // The side ledger is written only after SessionManager has positively + // acknowledged the PTY submission. In the live coordinator it is + // stronger evidence than the stale FIFO/dequeue marker and removes that + // row without writing Enter again while the active completion barrier + // remains authoritative. Boot normalization is deliberately separate. + if ( + receipt && + receipt.status !== "completed" && + receipt.status !== "uncertain" + ) { + receipt.status = "submitted"; + } + const queued = state.inputs.find((input) => input.id === inputId); + if (queued && receipt?.status === "uncertain") { + uncertainById.set(inputId, structuredClone(queued)); + } + } + const remainingUncertain = [...uncertainById.values()]; + const remainingUncertainIds = remainingUncertain.map((input) => input.id); + const changed = + remaining.length !== state.inputs.length || + remainingUncertain.length !== state.uncertainInputs.length || + remainingUncertain.some( + (input, index) => input.id !== state.uncertainInputs[index]?.id, + ) || + receipts.some( + (receipt, index) => receipt.status !== state.receipts[index]?.status, + ) || + remainingUncertainIds.length !== state.uncertainInputIds.length || + remainingUncertainIds.some( + (inputId, index) => inputId !== state.uncertainInputIds[index], + ) || + (state.dispatchingInputId !== null && + accepted.has(state.dispatchingInputId)); + const reconciled: PersistedProjectBootstrapState = { + ...structuredClone(state), + inputs: remaining, + dispatchingInputId: + state.dispatchingInputId && accepted.has(state.dispatchingInputId) + ? null + : state.dispatchingInputId, + metadata: { + ...structuredClone(state.metadata), + queuedInputIds: remaining.map((input) => input.id), + }, + uncertainInputIds: remainingUncertainIds, + uncertainInputs: remainingUncertain, + receipts, + }; + if (changed) await this.persist(sessionId, reconciled); + // Only after the authoritative queue/receipt transition is durable may its + // acknowledgement be removed. A cleanup failure leaves harmless positive + // proof that the next boot can reconcile again; it never makes Enter + // replayable. + await this.writeAcceptedInputIds( + sessionId, + [...accepted].filter((id) => remaining.some((input) => input.id === id)), + ).catch(() => {}); + return reconciled; + } + + protected async persist( + sessionId: string, + state: PersistedProjectBootstrapState, + ): Promise { + try { + await this.writeState(this.file(sessionId), state); + } catch { + if (!isTerminal(state.metadata)) { + const previousBootstrap = state.metadata.bootstrap; + const attemptId = + previousBootstrap.status === "generating" + ? previousBootstrap.attemptId + : undefined; + const latestAttempt = state.attempts.at(-1); + const positivelyPreSubmit = + !latestAttempt || + latestAttempt.phase === "claimed" || + latestAttempt.phase === "not-submitted"; + const retryable = + positivelyPreSubmit && + (previousBootstrap.status !== "failed" || + previousBootstrap.retryable); + if (retryable && latestAttempt) latestAttempt.status = "retired"; + state.metadata.bootstrap = state.inputs.length + ? { status: "skipped", reason: "user-proceeded" } + : { + status: "failed", + retryable, + errorCode: "persistence_failed", + }; + // At least one of the two stores may still be available. Keep the + // bounded classification wherever possible; never persist raw errors. + let fallbackCommitted = false; + try { + await this.storageOptions.sessionManager.setProjectBootstrapMetadata( + sessionId, + state.metadata, + ); + fallbackCommitted = true; + } catch { + // The primary queue fallback below may still retain the bounded + // terminal classification. + } + try { + await this.writeState(this.file(sessionId), state); + fallbackCommitted = true; + } catch { + // If sessions.json committed, mergeRegistration treats that terminal + // projection as authoritative on restart. Only a total two-store + // outage retains the last committed cache. + } + if (fallbackCommitted) + this.states.set(sessionId, structuredClone(state)); + this.emit({ + name: "project_bootstrap.failed", + projectId: state.metadata.projectId, + sessionId, + ...(attemptId ? { attemptId } : {}), + errorCode: "persistence_failed", + retryable, + queueDepth: state.inputs.length, + }); + } + throw new Error("project bootstrap state persistence failed"); + } + + // The queue file contains the full coordinator state and is authoritative. + // Publish its clone only after that primary write commits: a transient + // failure before this point must not leave a phantom dispatch intent in + // memory. SessionManager's sessions.json metadata is a UI/list projection, + // not a second commit prerequisite. If that projection write fails after + // the queue commit, aborting here would strand a durable pre-PTY intent + // that restart must conservatively drop even though submitInput was never + // called. Keep dispatch moving and retry the projection on every later + // transition/registration instead. + this.states.set(sessionId, structuredClone(state)); + await this.storageOptions.sessionManager + .setProjectBootstrapMetadata(sessionId, state.metadata) + .catch(() => {}); + } + + /** + * Merge the two durable stores under a single serialized registration CAS. + * Queue-file inputs are authoritative. A terminal manager greeting is newer + * than a non-terminal queue greeting (resume suppression), while a terminal + * queue greeting is newer than a stale non-terminal manager snapshot. + */ + protected mergeRegistration( + state: PersistedProjectBootstrapState, + session: HarnessSession, + ): void { + if (!session.projectBootstrap) return; + const managerTerminal = isTerminal(session.projectBootstrap); + const queueTerminal = isTerminal(state.metadata); + if (managerTerminal && !queueTerminal) { + state.metadata.bootstrap = structuredClone( + session.projectBootstrap.bootstrap, + ); + } + state.metadata.queuedInputIds = state.inputs.map((input) => input.id); + } +} diff --git a/packages/harness/src/core/project-bootstrap.test.ts b/packages/harness/src/core/project-bootstrap.test.ts index c861fe5c8..649349959 100644 --- a/packages/harness/src/core/project-bootstrap.test.ts +++ b/packages/harness/src/core/project-bootstrap.test.ts @@ -292,232 +292,6 @@ describe("ProjectBootstrapCoordinator", () => { await fs.rm(legacyRoot, { recursive: true, force: true }); }); - it("durably schedules one project lifecycle and atomically claims its first ordinary session", async () => { - const first = new ProjectBootstrapCoordinator({ - root, - sessionManager: manager, - }); - - await expect(first.scheduleProject(PROJECT_ID, USER_ID)).resolves.toBe( - true, - ); - await expect(first.scheduleProject(PROJECT_ID, USER_ID)).resolves.toBe( - false, - ); - - const restarted = new ProjectBootstrapCoordinator({ - root, - sessionManager: manager, - }); - await expect( - restarted.needsProjectSession(PROJECT_ID, USER_ID), - ).resolves.toBe(true); - - const claimed = await restarted.claimProject(session.agentMapIdentity!); - expect(claimed).toEqual({ - projectId: PROJECT_ID, - userId: USER_ID, - targetSessionId: session.id, - bootstrap: { status: "pending" }, - queuedInputIds: [], - }); - session.projectBootstrap = claimed!; - await expect( - restarted.needsProjectSession(PROJECT_ID, USER_ID), - ).resolves.toBe(false); - - const second = projectSession("session-2"); - sessions.set(second.id, second); - await expect( - restarted.claimProject(second.agentMapIdentity!), - ).resolves.toBeNull(); - - const intent = JSON.parse( - await fs.readFile( - path.join(root, "projects", `${PROJECT_ID}.json`), - "utf8", - ), - ) as Record; - expect(intent).toMatchObject({ - schemaVersion: 1, - projectId: PROJECT_ID, - userId: USER_ID, - targetSessionId: session.id, - status: "claimed", - }); - expect(JSON.stringify(intent)).not.toContain(session.cwd); - }); - - it("does not let a concurrent create steal a claim before SessionManager publishes its session", async () => { - sessions.delete(session.id); - const coordinator = new ProjectBootstrapCoordinator({ - root, - sessionManager: manager, - }); - await coordinator.scheduleProject(PROJECT_ID, USER_ID); - - const first = await coordinator.claimProject(session.agentMapIdentity!); - expect(first?.targetSessionId).toBe(session.id); - - const racing = projectSession("session-racing-create"); - expect(await coordinator.claimProject(racing.agentMapIdentity!)).toBeNull(); - await expect( - coordinator.needsProjectSession(PROJECT_ID, USER_ID), - ).resolves.toBe(false); - - // A proven pre-spawn failure releases only the volatile claim. The durable - // project intent remains available for a replacement ordinary session. - await coordinator.releaseSessionClaim(session.id); - expect( - await coordinator.claimProject(racing.agentMapIdentity!), - ).toMatchObject({ targetSessionId: racing.id }); - }); - - it("rejects a foreign project-intent claimant and can recover a missing claimed target", async () => { - const coordinator = new ProjectBootstrapCoordinator({ - root, - sessionManager: manager, - }); - await coordinator.scheduleProject(PROJECT_ID, USER_ID); - await expect( - coordinator.claimProject({ - ...session.agentMapIdentity!, - userId: "foreign-user", - }), - ).rejects.toBeInstanceOf(ProjectBootstrapDispatchForbiddenError); - - const first = await coordinator.claimProject(session.agentMapIdentity!); - session.projectBootstrap = first!; - await coordinator.releaseSessionClaim(session.id); - sessions.delete(session.id); - const replacement = projectSession("session-replacement"); - sessions.set(replacement.id, replacement); - - const recovered = await coordinator.claimProject( - replacement.agentMapIdentity!, - ); - expect(recovered?.targetSessionId).toBe(replacement.id); - expect(recovered?.bootstrap).toEqual({ status: "pending" }); - }); - - it("keeps a published pre-provider exit as the bounded project failure tombstone", async () => { - const coordinator = new ProjectBootstrapCoordinator({ - root, - sessionManager: manager, - }); - await coordinator.scheduleProject(PROJECT_ID, USER_ID); - const first = await coordinator.claimProject(session.agentMapIdentity!); - session.projectBootstrap = first!; - session.status = "exited"; - session.agentSessionId = null; - - await expect( - coordinator.needsProjectSession(PROJECT_ID, USER_ID), - ).resolves.toBe(false); - - const replacement = projectSession("session-replacement"); - sessions.set(replacement.id, replacement); - await expect( - coordinator.claimProject(replacement.agentMapIdentity!), - ).resolves.toBeNull(); - - const restarted = new ProjectBootstrapCoordinator({ - root, - sessionManager: manager, - }); - for (let read = 0; read < 10; read += 1) { - await expect( - restarted.needsProjectSession(PROJECT_ID, USER_ID), - ).resolves.toBe(false); - } - - expect(sessions.get(session.id)).toBe(session); - await expect( - fs - .readFile(path.join(root, "projects", `${PROJECT_ID}.json`), "utf8") - .then(JSON.parse), - ).resolves.toMatchObject({ - schemaVersion: 1, - projectId: PROJECT_ID, - targetSessionId: session.id, - status: "claimed", - }); - }); - - it("refuses replacement when an abandoned target still owns durable input", async () => { - const coordinator = new ProjectBootstrapCoordinator({ - root, - sessionManager: manager, - }); - await coordinator.scheduleProject(PROJECT_ID, USER_ID); - const first = await coordinator.claimProject(session.agentMapIdentity!); - session.projectBootstrap = first!; - await writeState(root, session.id, { - schemaVersion: 2, - metadata: { - ...structuredClone(first!), - bootstrap: { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: ["durable-user-input"], - }, - inputs: [ - { - id: "durable-user-input", - sessionId: session.id, - text: "preserve this exact request", - acceptedAt: NOW, - }, - ], - dispatchingInputId: null, - retryCount: 0, - emptyProject: true, - attempts: [], - }); - session.status = "exited"; - session.agentSessionId = null; - - await expect( - coordinator.needsProjectSession(PROJECT_ID, USER_ID), - ).resolves.toBe(false); - const replacement = projectSession("session-replacement-refused"); - sessions.set(replacement.id, replacement); - await expect( - coordinator.claimProject(replacement.agentMapIdentity!), - ).resolves.toBeNull(); - - expect((await readState(root, session.id)).inputs).toEqual([ - expect.objectContaining({ - id: "durable-user-input", - sessionId: session.id, - text: "preserve this exact request", - }), - ]); - const intent = JSON.parse( - await fs.readFile( - path.join(root, "projects", `${PROJECT_ID}.json`), - "utf8", - ), - ) as { targetSessionId: string }; - expect(intent.targetSessionId).toBe(session.id); - }); - - it("records real input already pending at claim time as higher priority", async () => { - const coordinator = new ProjectBootstrapCoordinator({ - root, - sessionManager: manager, - }); - await coordinator.scheduleProject(PROJECT_ID, USER_ID); - - const claimed = await coordinator.claimProject( - session.agentMapIdentity!, - true, - ); - - expect(claimed?.bootstrap).toEqual({ - status: "skipped", - reason: "user-proceeded", - }); - }); - it("keeps pending readiness and model-turn deadlines distinct", async () => { vi.useFakeTimers(); session.ready = false; @@ -1593,84 +1367,6 @@ describe("ProjectBootstrapCoordinator", () => { ).toHaveLength(1); }); - it("fails closed on malformed primary state without deleting, replacing, or quarantining it", async () => { - const directory = path.join(root, session.id); - await fs.mkdir(directory, { recursive: true }); - await fs.writeFile( - stateFile(root, session.id), - "{private-undelivered-input", - ); - session.projectBootstrap!.queuedInputIds = ["unknown-undelivered-input"]; - const coordinator = new ProjectBootstrapCoordinator({ - root, - sessionManager: manager, - }); - - await expect( - coordinator.register(session, { emptyProject: true, mode: "boot" }), - ).rejects.toThrow("project bootstrap state is unavailable"); - expect(await fs.readFile(stateFile(root, session.id), "utf8")).toBe( - "{private-undelivered-input", - ); - expect(await fs.readdir(directory)).toEqual(["input-queue.json"]); - expect(session.projectBootstrap?.queuedInputIds).toEqual([ - "unknown-undelivered-input", - ]); - }); - - it("rejects a persisted receipt request ID beyond the public 200-character bound", async () => { - session.projectBootstrap!.bootstrap = { - status: "delivered", - messageId: "bootstrap-complete", - }; - await writeState(root, session.id, { - schemaVersion: 3, - metadata: structuredClone(session.projectBootstrap!), - inputs: [], - dispatchingInputId: null, - retryCount: 0, - emptyProject: false, - attempts: [], - uncertainInputIds: [], - uncertainInputs: [], - receipts: [ - { - requestId: "r".repeat(201), - inputId: "completed-input", - status: "completed", - acceptedAt: NOW, - payloadDigest: "a".repeat(64), - }, - ], - }); - const original = await fs.readFile(stateFile(root, session.id), "utf8"); - const coordinator = new ProjectBootstrapCoordinator({ - root, - sessionManager: manager, - }); - - await expect( - coordinator.register(session, { emptyProject: false, mode: "boot" }), - ).rejects.toThrow("project bootstrap state is unavailable"); - expect(await fs.readFile(stateFile(root, session.id), "utf8")).toBe( - original, - ); - }); - - it("rejects a session identity that could escape the bootstrap root", async () => { - session = projectSession("../escape"); - sessions = new Map([[session.id, session]]); - const coordinator = new ProjectBootstrapCoordinator({ - root, - sessionManager: manager, - }); - - await expect( - coordinator.register(session, { emptyProject: true, mode: "created" }), - ).rejects.toThrow("project bootstrap state is unavailable"); - await expect(fs.readdir(root)).resolves.toEqual([]); - }); - it("classifies false dispatch, provider rejection, empty model output, and session exit", async () => { manager.submitInput = async () => false; const falseDispatch = new ProjectBootstrapCoordinator({ diff --git a/packages/harness/src/core/project-bootstrap.ts b/packages/harness/src/core/project-bootstrap.ts index df2e44a9b..7e3e6001a 100644 --- a/packages/harness/src/core/project-bootstrap.ts +++ b/packages/harness/src/core/project-bootstrap.ts @@ -1,6 +1,24 @@ -import { createHash, randomUUID } from "node:crypto"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; +import { + ProjectBootstrapStore, + ProjectBootstrapDispatchForbiddenError, + ProjectBootstrapCoordinatorClosedError, + MAX_RETRIES, + compactInputReceipts, + projectBootstrapInputDigest, + isRecord, + isTerminal, +} from "./project-bootstrap-store.js"; +import type { + ProjectBootstrapAttemptPhase, + PersistedProjectBootstrapInputReceipt, + PersistedProjectBootstrapState, +} from "./project-bootstrap-store.js"; +export { + ProjectBootstrapDispatchForbiddenError, + ProjectBootstrapCoordinatorClosedError, + ProjectBootstrapInputCapacityError, +} from "./project-bootstrap-store.js"; +import { randomUUID } from "node:crypto"; import type { ProjectBootstrapErrorCode, @@ -8,7 +26,6 @@ import type { ProjectBootstrapInputReceipt, ProjectBootstrapQueuedInput, ProjectBootstrapMetadata, - ProjectAgentSession, } from "../shared/agent-map.js"; import type { AnalyticsEvent, HarnessSession } from "../shared/types.js"; import { @@ -18,64 +35,6 @@ import { SessionNotReadyError, } from "./session-manager.js"; -type ProjectBootstrapAttemptPhase = - | "claimed" - | "dispatching" - | "not-submitted" - | "submitted"; - -interface PersistedProjectBootstrapInputReceipt extends ProjectBootstrapInputReceipt { - payloadDigest: string; -} - -interface PersistedProjectBootstrapState { - schemaVersion: 3; - metadata: ProjectBootstrapMetadata; - inputs: ProjectBootstrapQueuedInput[]; - /** - * Durable write-ahead intent for the one FIFO head that may be crossing the - * PTY boundary. An unresolved intent is never replayed automatically after a - * restart because the process cannot prove whether the PTY accepted it. - */ - dispatchingInputId: string | null; - retryCount: number; - emptyProject: boolean; - attempts: Array<{ - attemptId: string; - retryOrdinal: number; - status: "active" | "retired" | "completed"; - /** `dispatching` is written before the first PTY byte and is therefore - * conservatively uncertain after process loss. `not-submitted` is written - * only when SessionManager positively proves Enter was never attempted. */ - phase: ProjectBootstrapAttemptPhase; - }>; - /** IDs retained for schema-2 compatibility and bounded inspection. */ - uncertainInputIds: string[]; - /** - * Durable, content-bearing tombstones for FIFO entries whose PTY acceptance - * could not be proven. They are removed from the dispatchable FIFO so later - * user input can progress, but are never replayed or discarded. - */ - uncertainInputs: ProjectBootstrapQueuedInput[]; - /** Session-scoped idempotency receipts for the bounded bootstrap FIFO. */ - receipts: PersistedProjectBootstrapInputReceipt[]; -} - -interface AcceptedInputLedger { - schemaVersion: 1; - inputIds: string[]; -} - -interface PersistedProjectBootstrapIntent { - schemaVersion: 1; - projectId: string; - userId: string; - targetSessionId: string | null; - status: "scheduled" | "claimed"; - createdAt: string; - updatedAt: string; -} - interface ExpectedPrompt { kind: "bootstrap" | "user"; id: string; @@ -118,10 +77,7 @@ interface BootstrapFailureTransitionObligation { attemptId: string; errorCode: ProjectBootstrapErrorCode; retryable: boolean; - correlationRelease: - | "remove" - | "tombstone" - | "consume-observed-or-tombstone"; + correlationRelease: "remove" | "tombstone" | "consume-observed-or-tombstone"; } interface PendingBootstrapFailureTransitionObligation { @@ -171,24 +127,6 @@ export class ProjectBootstrapRetryUnavailableError extends Error { } } -export class ProjectBootstrapDispatchForbiddenError extends Error { - readonly code = "project_bootstrap_dispatch_forbidden"; - - constructor() { - super("project bootstrap is no longer authorized for this session"); - this.name = "ProjectBootstrapDispatchForbiddenError"; - } -} - -export class ProjectBootstrapCoordinatorClosedError extends Error { - readonly code = "project_bootstrap_coordinator_closed"; - - constructor() { - super("project bootstrap coordinator is closed"); - this.name = "ProjectBootstrapCoordinatorClosedError"; - } -} - export class ProjectBootstrapRequestIdConflictError extends Error { readonly code = "project_bootstrap_request_id_reused"; @@ -197,54 +135,9 @@ export class ProjectBootstrapRequestIdConflictError extends Error { this.name = "ProjectBootstrapRequestIdConflictError"; } } - -export class ProjectBootstrapInputCapacityError extends Error { - readonly code = "project_bootstrap_input_capacity"; - - constructor() { - super("project bootstrap input receipt capacity is temporarily full"); - this.name = "ProjectBootstrapInputCapacityError"; - } -} - -const MAX_RETRIES = 2; -const MAX_INPUT_RECEIPTS = 128; const MAX_CORRELATION_BARRIERS = 256; const MAX_COMPLETION_EVENT_RECEIPTS = 256; -/** - * Keep a bounded recent idempotency window. Entries that still own queued or - * submitted work are never evicted. Only completed unkeyed bookkeeping is - * retired; keyed receipts remain stable until the bounded store reaches - * capacity, at which point a new logical request fails before mutation. - */ -function compactInputReceipts( - receipts: readonly PersistedProjectBootstrapInputReceipt[], - reserveSlots = 0, -): PersistedProjectBootstrapInputReceipt[] { - const limit = Math.max(0, MAX_INPUT_RECEIPTS - reserveSlots); - const compacted: PersistedProjectBootstrapInputReceipt[] = receipts.map( - (receipt) => structuredClone(receipt), - ); - while (compacted.length > limit) { - const index = compacted.findIndex( - (receipt) => receipt.status === "completed" && receipt.requestId === null, - ); - if (index < 0) break; - compacted.splice(index, 1); - } - if (compacted.length > limit) { - throw new ProjectBootstrapInputCapacityError(); - } - return compacted; -} - -function projectBootstrapInputDigest(text: string): string { - return createHash("sha256") - .update(JSON.stringify({ schemaVersion: 1, submit: true, text })) - .digest("hex"); -} - function hasSafeBootstrapRetryEvidence( state: PersistedProjectBootstrapState, ): boolean { @@ -280,17 +173,6 @@ function hasSafeBootstrapRetryEvidence( ); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isTerminal(metadata: ProjectBootstrapMetadata): boolean { - return ( - metadata.bootstrap.status === "delivered" || - metadata.bootstrap.status === "skipped" - ); -} - /** * Whether the bootstrap coordinator still owns submitted user input. Once its * FIFO is empty, the ordinary SessionManager input path resumes ownership. @@ -303,376 +185,6 @@ export function projectBootstrapOwnsInput( ); } -function validBootstrapState(value: unknown): boolean { - if (!isRecord(value) || typeof value.status !== "string") return false; - switch (value.status) { - case "pending": - return true; - case "generating": - return typeof value.attemptId === "string" && value.attemptId !== ""; - case "delivered": - return typeof value.messageId === "string" && value.messageId !== ""; - case "failed": - return ( - typeof value.retryable === "boolean" && - typeof value.errorCode === "string" && - [ - "session_not_ready", - "session_exited", - "injection_failed", - "model_turn_failed", - "delivery_timeout", - "persistence_failed", - "scope_unavailable", - ].includes(value.errorCode) - ); - case "skipped": - return ( - value.reason === "user-proceeded" || value.reason === "map-not-empty" - ); - default: - return false; - } -} - -function parsePersistedProjectBootstrapState( - value: unknown, - session: HarnessSession, -): PersistedProjectBootstrapState | null { - if (!isRecord(value) || !session.projectBootstrap) return null; - const metadata = value.metadata; - if (!isRecord(metadata)) return null; - const expected = session.projectBootstrap; - const legacyIdentity = isRecord(metadata.identity) ? metadata.identity : null; - const projectId = legacyIdentity?.projectId ?? metadata.projectId; - const userId = legacyIdentity?.userId ?? metadata.userId; - const targetSessionId = legacyIdentity?.sessionId ?? metadata.targetSessionId; - const bootstrap = metadata.bootstrap ?? metadata.greeting; - if ( - (value.schemaVersion !== 1 && - value.schemaVersion !== 2 && - value.schemaVersion !== 3) || - projectId !== expected.projectId || - userId !== expected.userId || - targetSessionId !== expected.targetSessionId || - !Array.isArray(metadata.queuedInputIds) || - !metadata.queuedInputIds.every((id) => typeof id === "string") || - !validBootstrapState(bootstrap) || - !Array.isArray(value.inputs) || - (value.dispatchingInputId !== undefined && - value.dispatchingInputId !== null && - typeof value.dispatchingInputId !== "string") || - !Number.isSafeInteger(value.retryCount) || - (value.retryCount as number) < 0 || - (value.retryCount as number) > MAX_RETRIES || - typeof value.emptyProject !== "boolean" - ) { - return null; - } - const inputs = value.inputs; - const storedUncertainInputs = Array.isArray(value.uncertainInputs) - ? value.uncertainInputs - : []; - const validInput = (input: unknown): input is ProjectBootstrapQueuedInput => - isRecord(input) && - typeof input.id === "string" && - input.id !== "" && - input.sessionId === session.id && - typeof input.text === "string" && - input.text.length <= 100_000 && - typeof input.acceptedAt === "string"; - if ( - !inputs.every(validInput) || - !storedUncertainInputs.every(validInput) || - new Set( - [...inputs, ...storedUncertainInputs].map( - (input) => (input as ProjectBootstrapQueuedInput).id, - ), - ).size !== - inputs.length + storedUncertainInputs.length || - metadata.queuedInputIds.length !== inputs.length || - metadata.queuedInputIds.some((id, index) => id !== inputs[index]?.id) || - (typeof value.dispatchingInputId === "string" && - value.dispatchingInputId !== inputs[0]?.id) - ) { - return null; - } - const sourceSchemaVersion = Number(value.schemaVersion); - const storedAttempts = Array.isArray(value.attempts) ? value.attempts : []; - const validAttempt = ( - attempt: unknown, - ): attempt is PersistedProjectBootstrapState["attempts"][number] => - isRecord(attempt) && - typeof attempt.attemptId === "string" && - attempt.attemptId !== "" && - Number.isSafeInteger(attempt.retryOrdinal) && - Number(attempt.retryOrdinal) >= 0 && - Number(attempt.retryOrdinal) <= MAX_RETRIES && - ["active", "retired", "completed"].includes(String(attempt.status)) && - (sourceSchemaVersion < 3 || - ["claimed", "dispatching", "not-submitted", "submitted"].includes( - String(attempt.phase), - )); - if ( - sourceSchemaVersion >= 3 && - (!Array.isArray(value.attempts) || - storedAttempts.length > 8 || - !storedAttempts.every(validAttempt) || - new Set( - storedAttempts - .filter(validAttempt) - .map((attempt) => attempt.attemptId), - ).size !== storedAttempts.length || - new Set( - storedAttempts - .filter(validAttempt) - .map((attempt) => attempt.retryOrdinal), - ).size !== storedAttempts.length) - ) { - // Current-schema attempt evidence is retry authority. Never turn a missing, - // malformed, or duplicate entry into an apparently safe empty history. - return null; - } - const attempts = Array.isArray(value.attempts) - ? value.attempts - .filter(validAttempt) - .map((attempt) => ({ - attemptId: attempt.attemptId, - retryOrdinal: attempt.retryOrdinal, - status: attempt.status, - // Schema 1/2 could already have crossed Enter and therefore migrates - // conservatively. Only schema 3 can prove a pre-PTY claim. - phase: - sourceSchemaVersion >= 3 - ? attempt.phase - : attempt.status === "completed" - ? "submitted" - : "dispatching", - })) - : []; - const persistedUncertainIds = Array.isArray(value.uncertainInputIds) - ? value.uncertainInputIds.filter( - (inputId): inputId is string => typeof inputId === "string", - ) - : []; - const legacyUncertainIds = new Set( - persistedUncertainIds.filter((inputId) => - inputs.some((input) => isRecord(input) && input.id === inputId), - ), - ); - const normalizedInputs = ( - structuredClone(value.inputs) as ProjectBootstrapQueuedInput[] - ).filter((input) => !legacyUncertainIds.has(input.id)); - const normalizedUncertainInputs = [ - ...(structuredClone( - storedUncertainInputs, - ) as ProjectBootstrapQueuedInput[]), - ...(structuredClone(value.inputs) as ProjectBootstrapQueuedInput[]).filter( - (input) => legacyUncertainIds.has(input.id), - ), - ]; - const uncertainInputIds = normalizedUncertainInputs.map((input) => input.id); - let normalizedBootstrap = structuredClone( - bootstrap, - ) as ProjectBootstrapMetadata["bootstrap"]; - if ( - sourceSchemaVersion < 3 && - normalizedBootstrap.status === "failed" && - normalizedBootstrap.retryable && - normalizedBootstrap.errorCode !== "session_not_ready" - ) { - // Schema 1/2 had no durable phase evidence. A legacy retryable flag cannot - // prove that an injection/persistence failure preceded Enter. - normalizedBootstrap = { ...normalizedBootstrap, retryable: false }; - } - if ( - sourceSchemaVersion < 3 && - attempts.length === 0 && - normalizedBootstrap.status === "generating" - ) { - attempts.push({ - attemptId: normalizedBootstrap.attemptId, - retryOrdinal: Math.max(0, Number(value.retryCount) || 0), - status: "active", - phase: "dispatching", - }); - } - if ( - sourceSchemaVersion >= 3 && - normalizedBootstrap.status === "generating" && - !attempts.some((attempt) => { - const activeAttemptId = - normalizedBootstrap.status === "generating" - ? normalizedBootstrap.attemptId - : null; - return ( - attempt.attemptId === activeAttemptId && attempt.status === "active" - ); - }) - ) { - return null; - } - if (normalizedInputs.length > 0 && !isTerminal({ - projectId: expected.projectId, - userId: expected.userId, - targetSessionId: expected.targetSessionId, - bootstrap: normalizedBootstrap, - queuedInputIds: normalizedInputs.map((input) => input.id), - })) { - if (sourceSchemaVersion >= 3) return null; - // Legacy planner queues could persist the FIFO before their greeting skip. - // The user input is authoritative, so migration completes that transition - // without changing IDs or message bodies. - normalizedBootstrap = { status: "skipped", reason: "user-proceeded" }; - } - if (sourceSchemaVersion >= 3 && !Array.isArray(value.receipts)) return null; - // Schema 1/2 never owned receipt authority. Ignore any injected property and - // derive canonical unkeyed receipts solely from the migrated FIFO/tombstone - // state so legacy data cannot mint an idempotency key. - const storedReceipts = - sourceSchemaVersion >= 3 && Array.isArray(value.receipts) - ? value.receipts - : []; - const validReceipts = storedReceipts.filter( - (receipt): receipt is Record => - isRecord(receipt) && - (receipt.requestId === null || - (typeof receipt.requestId === "string" && - receipt.requestId !== "" && - receipt.requestId.length <= 200)) && - typeof receipt.inputId === "string" && - receipt.inputId !== "" && - ["queued", "submitted", "uncertain", "completed"].includes( - String(receipt.status), - ) && - typeof receipt.acceptedAt === "string" && - typeof receipt.payloadDigest === "string" && - /^[0-9a-f]{64}$/.test(receipt.payloadDigest), - ); - const receipts: PersistedProjectBootstrapInputReceipt[] = validReceipts.map( - (receipt) => ({ - requestId: receipt.requestId as string | null, - inputId: receipt.inputId as string, - status: receipt.status as ProjectBootstrapInputReceipt["status"], - acceptedAt: receipt.acceptedAt as string, - payloadDigest: receipt.payloadDigest as string, - }), - ); - if (sourceSchemaVersion < 3) { - for (const input of normalizedInputs) { - receipts.push({ - requestId: null, - inputId: input.id, - status: "queued", - acceptedAt: input.acceptedAt, - payloadDigest: projectBootstrapInputDigest(input.text), - }); - } - for (const input of normalizedUncertainInputs) { - receipts.push({ - requestId: null, - inputId: input.id, - status: "uncertain", - acceptedAt: input.acceptedAt, - payloadDigest: projectBootstrapInputDigest(input.text), - }); - } - } - if ( - sourceSchemaVersion >= 3 && - (validReceipts.length !== storedReceipts.length || - new Set(receipts.map((receipt) => receipt.inputId)).size !== - receipts.length || - new Set( - receipts - .filter((receipt) => receipt.requestId !== null) - .map((receipt) => receipt.requestId), - ).size !== - receipts.filter((receipt) => receipt.requestId !== null).length) - ) { - return null; - } - if ( - sourceSchemaVersion >= 3 && - (normalizedInputs.some((input) => { - const receipt = receipts.find( - (candidate) => candidate.inputId === input.id, - ); - return ( - !receipt || - receipt.acceptedAt !== input.acceptedAt || - receipt.payloadDigest !== projectBootstrapInputDigest(input.text) || - receipt.status === "completed" || - receipt.status === "uncertain" - ); - }) || - normalizedUncertainInputs.some((input) => { - const receipt = receipts.find( - (candidate) => candidate.inputId === input.id, - ); - return ( - !receipt || - receipt.acceptedAt !== input.acceptedAt || - receipt.payloadDigest !== projectBootstrapInputDigest(input.text) || - receipt.status !== "uncertain" - ); - }) || - receipts.some( - (receipt) => - receipt.status === "queued" && - !normalizedInputs.some((input) => input.id === receipt.inputId), - ) || - // Receipt order is the durable logical-arrival order. Live FIFO rows may - // have terminal receipt-only predecessors after a crash, but the rows - // themselves must remain a monotonic subsequence so boot recovery can - // conservatively terminalize the causal prefix without guessing. - normalizedInputs.some((input, index) => { - if (index === 0) return false; - const priorIndex = receipts.findIndex( - (receipt) => receipt.inputId === normalizedInputs[index - 1]?.id, - ); - const currentIndex = receipts.findIndex( - (receipt) => receipt.inputId === input.id, - ); - return priorIndex < 0 || currentIndex <= priorIndex; - }) || - normalizedInputs.some( - (input, index) => - receipts.at(index - normalizedInputs.length)?.inputId !== input.id, - )) - ) { - return null; - } - let compactedReceipts: PersistedProjectBootstrapInputReceipt[]; - try { - compactedReceipts = compactInputReceipts(receipts); - } catch { - return null; - } - return { - schemaVersion: 3, - metadata: { - projectId: expected.projectId, - userId: expected.userId, - targetSessionId: expected.targetSessionId, - bootstrap: normalizedBootstrap, - queuedInputIds: normalizedInputs.map((input) => input.id), - }, - inputs: normalizedInputs, - dispatchingInputId: - typeof value.dispatchingInputId === "string" && - legacyUncertainIds.has(value.dispatchingInputId) - ? null - : (value.dispatchingInputId ?? null), - retryCount: Number(value.retryCount), - emptyProject: Boolean(value.emptyProject), - attempts: attempts.slice(-8), - uncertainInputIds, - uncertainInputs: normalizedUncertainInputs, - receipts: compactedReceipts, - }; -} - export function projectBootstrapPrompt( retryOrdinal = 0, attemptId?: string, @@ -758,15 +270,10 @@ function telemetryPayload(event: AnalyticsEvent): Record { } } -export class ProjectBootstrapCoordinator { - private readonly root: string; - private readonly legacyStateRoot: string | null; - private readonly now: () => string; +export class ProjectBootstrapCoordinator extends ProjectBootstrapStore { private readonly generateId: () => string; private readonly readinessTimeoutMs: number; private readonly deliveryTimeoutMs: number; - private readonly states = new Map(); - private readonly writes = new Map>(); /** Exact server-issued PTY generation currently allowed to own volatile * correlation, input holds, timers, and completion dedupe state. */ private readonly runtimeEpochs = new Map(); @@ -817,9 +324,6 @@ export class ProjectBootstrapCoordinator { * local persistence/load failure. Only this classification may poll on a * bounded timer; authorization denial explicitly clears it. */ private readonly inputRedrainNeeded = new Set(); - /** Project claims made before SessionManager publishes the new session. */ - private readonly provisionalProjectClaims = new Map(); - private readonly provisionalSessionClaims = new Map(); /** Synchronous API-arrival signal used to cancel a staged background Enter * before the durable FIFO operation reaches this coordinator's lock. */ private readonly pendingApiPreemptions = new Map< @@ -843,67 +347,16 @@ export class ProjectBootstrapCoordinator { ReturnType >(); private readonly reportedTerminalPreemptions = new Set(); - private closed = false; private admissionGeneration = 0; constructor(private readonly options: ProjectBootstrapCoordinatorOptions) { - this.root = path.resolve(options.root); - this.legacyStateRoot = options.legacyStateRoot - ? path.resolve(options.legacyStateRoot) - : null; - this.now = options.now ?? (() => new Date().toISOString()); + super(options); this.generateId = options.generateId ?? randomUUID; this.readinessTimeoutMs = options.readinessTimeoutMs ?? options.deliveryTimeoutMs ?? 45_000; this.deliveryTimeoutMs = options.deliveryTimeoutMs ?? 300_000; } - private sessionDirectory(sessionId: string): string { - const directory = path.resolve(this.root, sessionId); - const rootPrefix = `${this.root}${path.sep}`; - if (!directory.startsWith(rootPrefix)) { - throw new Error("invalid project bootstrap storage identity"); - } - return directory; - } - - private file(sessionId: string): string { - return path.join(this.sessionDirectory(sessionId), "input-queue.json"); - } - - private legacyFile(sessionId: string, name: string): string | null { - if (!this.legacyStateRoot) return null; - const directory = path.resolve(this.legacyStateRoot, sessionId); - if (!directory.startsWith(`${this.legacyStateRoot}${path.sep}`)) { - throw new Error("invalid legacy project bootstrap storage identity"); - } - return path.join(directory, name); - } - - private acceptedFile(sessionId: string): string { - return path.join(this.sessionDirectory(sessionId), "accepted-inputs.json"); - } - - private projectIntentFile(projectId: string): string { - if (!/^project_[0-9a-f-]+$/.test(projectId)) { - throw new Error("invalid project bootstrap identity"); - } - const directory = path.resolve(this.root, "projects"); - const file = path.resolve(directory, `${projectId}.json`); - if (!file.startsWith(`${directory}${path.sep}`)) { - throw new Error("invalid project bootstrap identity"); - } - return file; - } - - private emit(event: ProjectBootstrapLifecycleEvent): void { - try { - void Promise.resolve(this.options.onEvent?.(event)).catch(() => {}); - } catch { - // Telemetry is best effort and must never change bootstrap semantics. - } - } - private isAdmissionCurrent(generation: number): boolean { return !this.closed && this.admissionGeneration === generation; } @@ -921,17 +374,11 @@ export class ProjectBootstrapCoordinator { } } - private assertOpen(): void { - if (this.closed) throw new ProjectBootstrapCoordinatorClosedError(); - } - private isRuntimeEpochCurrent( sessionId: string, runtimeEpoch: string, ): boolean { - return ( - !this.closed && this.runtimeEpochs.get(sessionId) === runtimeEpoch - ); + return !this.closed && this.runtimeEpochs.get(sessionId) === runtimeEpoch; } /** Current trusted live epoch for server-originated API/retry actions. */ @@ -950,8 +397,8 @@ export class ProjectBootstrapCoordinator { const pending = this.pendingApiPreemptions.get(sessionId); return Boolean( runtimeEpoch && - pending?.runtimeEpoch === runtimeEpoch && - pending.count > 0, + pending?.runtimeEpoch === runtimeEpoch && + pending.count > 0, ); } @@ -960,15 +407,11 @@ export class ProjectBootstrapCoordinator { const pending = this.pendingApiPreemptions.get(sessionId); this.pendingApiPreemptions.set(sessionId, { runtimeEpoch, - count: - pending?.runtimeEpoch === runtimeEpoch ? pending.count + 1 : 1, + count: pending?.runtimeEpoch === runtimeEpoch ? pending.count + 1 : 1, }); } - private clearPendingApiInput( - sessionId: string, - runtimeEpoch: string, - ): void { + private clearPendingApiInput(sessionId: string, runtimeEpoch: string): void { const pending = this.pendingApiPreemptions.get(sessionId); if (pending?.runtimeEpoch !== runtimeEpoch) return; const remaining = pending.count - 1; @@ -1026,27 +469,6 @@ export class ProjectBootstrapCoordinator { }; } - private receiptForInput( - state: PersistedProjectBootstrapState, - inputId: string, - ): PersistedProjectBootstrapInputReceipt | undefined { - return state.receipts.find((receipt) => receipt.inputId === inputId); - } - - private updateReceiptStatus( - state: PersistedProjectBootstrapState, - inputId: string, - status: ProjectBootstrapInputReceipt["status"], - ): void { - const receipt = this.receiptForInput(state, inputId); - if (!receipt) return; - // `completed` and `uncertain` are distinct terminal evidence. Neither may - // be weakened or rewritten by later boot reconciliation or a late hook. - if (receipt.status === "completed" || receipt.status === "uncertain") return; - if (receipt.status === "submitted" && status === "queued") return; - receipt.status = status; - } - private clearActiveTurn( sessionId: string, kind: ActiveCoordinatorTurn["kind"], @@ -1063,24 +485,6 @@ export class ProjectBootstrapCoordinator { } } - private serialize( - sessionId: string, - operation: () => Promise, - ): Promise { - const prior = this.writes.get(sessionId) ?? Promise.resolve(); - const next = prior.catch(() => {}).then(operation); - this.writes.set(sessionId, next); - void next.then( - () => { - if (this.writes.get(sessionId) === next) this.writes.delete(sessionId); - }, - () => { - if (this.writes.get(sessionId) === next) this.writes.delete(sessionId); - }, - ); - return next; - } - /** Drop only volatile ownership belonging to one proven PTY generation. */ private clearRuntimeEpochState( sessionId: string, @@ -1116,544 +520,6 @@ export class ProjectBootstrapCoordinator { this.runtimeEpochs.delete(sessionId); } - private newState( - session: HarnessSession, - emptyProject: boolean, - ): PersistedProjectBootstrapState { - if (!session.projectBootstrap) { - throw new Error("project bootstrap metadata missing"); - } - return { - schemaVersion: 3, - metadata: { - ...structuredClone(session.projectBootstrap), - // The queue file owns FIFO membership. If that file is missing or was - // quarantined, stale registry IDs cannot resurrect content we no - // longer possess or make the replacement state invalid on next boot. - queuedInputIds: [], - }, - inputs: [], - dispatchingInputId: null, - retryCount: 0, - emptyProject, - attempts: [], - uncertainInputIds: [], - uncertainInputs: [], - receipts: [], - }; - } - - private async load( - session: HarnessSession, - emptyProject = true, - ): Promise { - const cached = this.states.get(session.id); - // Every transition works on an isolated snapshot. Nothing may mutate the - // authoritative cache until persist() commits the primary queue file. - if (cached) return structuredClone(cached); - let state: PersistedProjectBootstrapState; - try { - const parsed: unknown = JSON.parse( - await fs.readFile(this.file(session.id), "utf8"), - ); - const normalized = parsePersistedProjectBootstrapState(parsed, session); - if (!normalized) throw new Error("invalid project bootstrap state"); - state = normalized; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - // Keep malformed legacy files in place for explicit recovery. Never - // rename away or overwrite a file that may contain undelivered input. - throw new Error("project bootstrap state is unavailable"); - } - const legacyFile = this.legacyFile(session.id, "input-queue.json"); - if (legacyFile) { - try { - const legacy: unknown = JSON.parse( - await fs.readFile(legacyFile, "utf8"), - ); - const normalized = parsePersistedProjectBootstrapState( - legacy, - session, - ); - if (!normalized) { - throw new Error("invalid legacy project bootstrap state"); - } - const legacyAccepted = this.legacyFile( - session.id, - "accepted-inputs.json", - ); - if (legacyAccepted) { - try { - const accepted = await fs.readFile(legacyAccepted, "utf8"); - const decoded: unknown = JSON.parse(accepted); - if ( - !isRecord(decoded) || - decoded.schemaVersion !== 1 || - !Array.isArray(decoded.inputIds) || - !decoded.inputIds.every( - (inputId) => typeof inputId === "string" && inputId !== "", - ) - ) { - throw new Error("invalid legacy accepted-input ledger"); - } - await this.writeAcceptedInputIds( - session.id, - decoded.inputIds as string[], - ); - } catch (legacyAcceptedError) { - if ( - (legacyAcceptedError as NodeJS.ErrnoException).code !== "ENOENT" - ) { - throw legacyAcceptedError; - } - } - } - await this.writeState(this.file(session.id), normalized); - state = normalized; - } catch (legacyError) { - if ((legacyError as NodeJS.ErrnoException).code !== "ENOENT") { - // Preserve the only copy and fail closed. Never quarantine or - // overwrite a planner-era FIFO that may contain user input. - throw new Error("legacy project bootstrap state is unavailable"); - } - state = this.newState(session, emptyProject); - } - } else { - state = this.newState(session, emptyProject); - } - } - this.states.set(session.id, structuredClone(state)); - return state; - } - - private async writeState( - file: string, - state: PersistedProjectBootstrapState, - ): Promise { - if (this.options.writeState) { - await this.options.writeState(file, structuredClone(state)); - return; - } - await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); - const tmp = `${file}.tmp-${process.pid}-${randomUUID()}`; - await fs.writeFile(tmp, JSON.stringify(state, null, 2) + "\n", { - encoding: "utf8", - mode: 0o600, - }); - await fs.rename(tmp, file); - } - - private async writeIntent( - file: string, - intent: PersistedProjectBootstrapIntent, - ): Promise { - await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); - const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; - try { - await fs.writeFile(temporary, `${JSON.stringify(intent, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); - await fs.rename(temporary, file); - } finally { - await fs.rm(temporary, { force: true }).catch(() => {}); - } - } - - private async readIntent( - projectId: string, - ): Promise { - let decoded: unknown; - try { - decoded = JSON.parse( - await fs.readFile(this.projectIntentFile(projectId), "utf8"), - ); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; - throw new Error("project bootstrap intent is unavailable"); - } - if ( - !isRecord(decoded) || - decoded.schemaVersion !== 1 || - decoded.projectId !== projectId || - typeof decoded.userId !== "string" || - decoded.userId === "" || - (decoded.targetSessionId !== null && - (typeof decoded.targetSessionId !== "string" || - decoded.targetSessionId === "")) || - (decoded.status !== "scheduled" && decoded.status !== "claimed") || - (decoded.status === "scheduled" && decoded.targetSessionId !== null) || - (decoded.status === "claimed" && decoded.targetSessionId === null) || - typeof decoded.createdAt !== "string" || - typeof decoded.updatedAt !== "string" - ) { - throw new Error("project bootstrap intent is malformed"); - } - return structuredClone( - decoded, - ) as unknown as PersistedProjectBootstrapIntent; - } - - /** Durably schedules the lifecycle before a project has a launchable root. */ - scheduleProject(projectId: string, userId: string): Promise { - return this.serialize(`project:${projectId}`, async () => { - this.assertOpen(); - const existing = await this.readIntent(projectId); - if (existing) { - if (existing.userId !== userId) { - throw new ProjectBootstrapDispatchForbiddenError(); - } - return false; - } - const timestamp = this.now(); - await this.writeIntent(this.projectIntentFile(projectId), { - schemaVersion: 1, - projectId, - userId, - targetSessionId: null, - status: "scheduled", - createdAt: timestamp, - updatedAt: timestamp, - }); - return true; - }); - } - - /** A replacement session may only claim an abandoned pre-provider target - * when doing so cannot strand content in that target's durable FIFO. Missing - * or malformed state is treated conservatively whenever a queue file exists. */ - private async targetHasUnresolvedInput(sessionId: string): Promise { - const target = this.options.sessionManager.get(sessionId); - if ((target?.projectBootstrap?.queuedInputIds.length ?? 0) > 0) return true; - - const files = [ - this.file(sessionId), - this.legacyFile(sessionId, "input-queue.json"), - ].filter((file): file is string => file !== null); - for (const file of files) { - let decoded: unknown; - try { - decoded = JSON.parse(await fs.readFile(file, "utf8")); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; - return true; - } - if (!isRecord(decoded) || !Array.isArray(decoded.inputs)) return true; - const metadata = isRecord(decoded.metadata) ? decoded.metadata : null; - if (!metadata || !Array.isArray(metadata.queuedInputIds)) return true; - if (metadata.queuedInputIds.length > 0) return true; - if (decoded.inputs.length > 0) return true; - if ( - decoded.dispatchingInputId !== null && - decoded.dispatchingInputId !== undefined - ) { - return true; - } - if ( - (decoded.uncertainInputs !== undefined && - !Array.isArray(decoded.uncertainInputs)) || - (decoded.uncertainInputIds !== undefined && - !Array.isArray(decoded.uncertainInputIds)) || - (Array.isArray(decoded.uncertainInputs) && - decoded.uncertainInputs.length > 0) || - (Array.isArray(decoded.uncertainInputIds) && - decoded.uncertainInputIds.length > 0) - ) { - return true; - } - } - return false; - } - - /** Whether a scheduled project still needs its one ordinary first session. */ - needsProjectSession(projectId: string, userId: string): Promise { - return this.serialize(`project:${projectId}`, async () => { - this.assertOpen(); - const intent = await this.readIntent(projectId); - if (!intent) return false; - if (intent.userId !== userId) { - throw new ProjectBootstrapDispatchForbiddenError(); - } - if (intent.status === "scheduled") return true; - const target = intent.targetSessionId - ? this.options.sessionManager.get(intent.targetSessionId) - : undefined; - if ( - intent.targetSessionId && - this.provisionalProjectClaims.get(projectId) === intent.targetSessionId - ) { - // A published row is already the durable first-session outcome, even - // when its process exited before a provider session ID was observed. - // A missing row can only be replaced after create() releases this - // provisional fence, proving publication never committed. - return false; - } - return ( - !target && - !(await this.targetHasUnresolvedInput(intent.targetSessionId!)) - ); - }); - } - - /** Atomically binds a scheduled project lifecycle to its first real session. */ - claimProject( - identity: ProjectAgentSession, - initialUserInputPending = false, - ): Promise { - return this.serialize(`project:${identity.projectId}`, async () => { - this.assertOpen(); - const intent = await this.readIntent(identity.projectId); - if (!intent) return null; - if (intent.userId !== identity.userId) { - throw new ProjectBootstrapDispatchForbiddenError(); - } - if ( - intent.status === "claimed" && - intent.targetSessionId !== identity.sessionId - ) { - const target = this.options.sessionManager.get(intent.targetSessionId!); - if ( - this.provisionalProjectClaims.get(identity.projectId) === - intent.targetSessionId - ) { - return null; - } - if (target) return null; - if (await this.targetHasUnresolvedInput(intent.targetSessionId!)) { - return null; - } - } - const claimed: PersistedProjectBootstrapIntent = { - ...intent, - targetSessionId: identity.sessionId, - status: "claimed", - updatedAt: this.now(), - }; - await this.writeIntent( - this.projectIntentFile(identity.projectId), - claimed, - ); - this.provisionalProjectClaims.set(identity.projectId, identity.sessionId); - this.provisionalSessionClaims.set(identity.sessionId, identity.projectId); - return { - projectId: identity.projectId, - userId: identity.userId, - targetSessionId: identity.sessionId, - bootstrap: initialUserInputPending - ? { status: "skipped", reason: "user-proceeded" } - : { status: "pending" }, - queuedInputIds: [], - }; - }); - } - - /** Release only an unpublished/failed create claim. Durable intent remains - * available for the next proven session and is never deleted. */ - releaseSessionClaim(sessionId: string): Promise { - const projectId = this.provisionalSessionClaims.get(sessionId); - if (!projectId) return Promise.resolve(); - return this.serialize(`project:${projectId}`, async () => { - if (this.provisionalProjectClaims.get(projectId) === sessionId) { - this.provisionalProjectClaims.delete(projectId); - } - this.provisionalSessionClaims.delete(sessionId); - }); - } - - private async acceptedInputIds( - sessionId: string, - ): Promise | null> { - const file = this.acceptedFile(sessionId); - try { - const decoded: unknown = JSON.parse(await fs.readFile(file, "utf8")); - if ( - !isRecord(decoded) || - decoded.schemaVersion !== 1 || - !Array.isArray(decoded.inputIds) || - decoded.inputIds.length > MAX_INPUT_RECEIPTS || - !decoded.inputIds.every( - (inputId) => typeof inputId === "string" && inputId !== "", - ) || - new Set(decoded.inputIds).size !== decoded.inputIds.length - ) { - throw new Error("invalid accepted-input ledger"); - } - return new Set(decoded.inputIds); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Set(); - // An unreadable acknowledgement is safety-significant. Keep the queue's - // write-ahead intent unresolved instead of guessing and replaying it. - // The artifact stays in place until its FIFO is durably terminalized; - // otherwise a crash after quarantine would turn unknown proof into - // ENOENT and authorize replay on the next process. - return null; - } - } - - private async quarantineAcceptedLedger(sessionId: string): Promise { - const file = this.acceptedFile(sessionId); - const quarantine = path.join( - path.dirname(file), - `accepted-inputs.corrupt-${this.now().replace(/[^0-9A-Za-z]/g, "-")}-${randomUUID()}.json`, - ); - await fs.rename(file, quarantine).catch(() => {}); - } - - private async terminalizeUnreadableAcceptedLedger( - state: PersistedProjectBootstrapState, - ): Promise { - const sessionId = state.metadata.targetSessionId; - const terminal = structuredClone(state); - const uncertainById = new Map( - terminal.uncertainInputs.map((input) => [input.id, input]), - ); - for (const input of terminal.inputs) { - uncertainById.set(input.id, structuredClone(input)); - const receipt = this.receiptForInput(terminal, input.id); - if (receipt && receipt.status !== "completed") receipt.status = "uncertain"; - } - for (const receipt of terminal.receipts) { - if (receipt.status === "submitted") receipt.status = "uncertain"; - } - terminal.inputs = []; - terminal.metadata.queuedInputIds = []; - terminal.dispatchingInputId = null; - terminal.uncertainInputs = [...uncertainById.values()]; - terminal.uncertainInputIds = terminal.uncertainInputs.map( - (input) => input.id, - ); - await this.persist(sessionId, terminal); - // Safe state is authoritative before the unreadable artifact moves. A - // failed rename merely causes the same idempotent normalization next time. - await this.quarantineAcceptedLedger(sessionId); - return terminal; - } - - private async writeAcceptedInputIds( - sessionId: string, - inputIds: readonly string[], - ): Promise { - const file = this.acceptedFile(sessionId); - const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; - const ledger: AcceptedInputLedger = { - schemaVersion: 1, - inputIds: [...inputIds], - }; - if (this.options.writeAcceptedLedger) { - await this.options.writeAcceptedLedger(file, structuredClone(ledger)); - return; - } - try { - await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); - await fs.writeFile(temporary, `${JSON.stringify(ledger, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); - await fs.rename(temporary, file); - } finally { - await fs.rm(temporary, { force: true }).catch(() => {}); - } - } - - private async recordAcceptedInput( - state: PersistedProjectBootstrapState, - inputId: string, - ): Promise { - const sessionId = state.metadata.targetSessionId; - const accepted = await this.acceptedInputIds(sessionId); - if (accepted === null) { - throw new Error("project bootstrap input acceptance ledger unavailable"); - } - // IDs whose queue entries were already durably removed are stale cleanup - // residue and can be compacted. The active FIFO is bounded by the request - // body limit, and only its IDs are retained here (never input content). - const queuedIds = new Set(state.inputs.map((input) => input.id)); - const retained = [...accepted].filter((id) => queuedIds.has(id)); - if (!retained.includes(inputId)) retained.push(inputId); - await this.writeAcceptedInputIds(sessionId, retained); - } - - private async reconcileAcceptedInputs( - state: PersistedProjectBootstrapState, - ): Promise { - const sessionId = state.metadata.targetSessionId; - const accepted = await this.acceptedInputIds(sessionId); - if (accepted === null) - return this.terminalizeUnreadableAcceptedLedger(state); - if (accepted.size === 0) return state; - const remaining = state.inputs.filter((input) => !accepted.has(input.id)); - const uncertainById = new Map( - state.uncertainInputs.map((input) => [input.id, structuredClone(input)]), - ); - const receipts = structuredClone(state.receipts); - for (const inputId of accepted) { - const receipt = receipts.find( - (candidate) => candidate.inputId === inputId, - ); - // The side ledger is written only after SessionManager has positively - // acknowledged the PTY submission. In the live coordinator it is - // stronger evidence than the stale FIFO/dequeue marker and removes that - // row without writing Enter again while the active completion barrier - // remains authoritative. Boot normalization is deliberately separate. - if ( - receipt && - receipt.status !== "completed" && - receipt.status !== "uncertain" - ) { - receipt.status = "submitted"; - } - const queued = state.inputs.find((input) => input.id === inputId); - if ( - queued && - receipt?.status === "uncertain" - ) { - uncertainById.set(inputId, structuredClone(queued)); - } - } - const remainingUncertain = [...uncertainById.values()]; - const remainingUncertainIds = remainingUncertain.map((input) => input.id); - const changed = - remaining.length !== state.inputs.length || - remainingUncertain.length !== state.uncertainInputs.length || - remainingUncertain.some( - (input, index) => input.id !== state.uncertainInputs[index]?.id, - ) || - receipts.some( - (receipt, index) => receipt.status !== state.receipts[index]?.status, - ) || - remainingUncertainIds.length !== state.uncertainInputIds.length || - remainingUncertainIds.some( - (inputId, index) => inputId !== state.uncertainInputIds[index], - ) || - (state.dispatchingInputId !== null && - accepted.has(state.dispatchingInputId)); - const reconciled: PersistedProjectBootstrapState = { - ...structuredClone(state), - inputs: remaining, - dispatchingInputId: - state.dispatchingInputId && accepted.has(state.dispatchingInputId) - ? null - : state.dispatchingInputId, - metadata: { - ...structuredClone(state.metadata), - queuedInputIds: remaining.map((input) => input.id), - }, - uncertainInputIds: remainingUncertainIds, - uncertainInputs: remainingUncertain, - receipts, - }; - if (changed) await this.persist(sessionId, reconciled); - // Only after the authoritative queue/receipt transition is durable may its - // acknowledgement be removed. A cleanup failure leaves harmless positive - // proof that the next boot can reconcile again; it never makes Enter - // replayable. - await this.writeAcceptedInputIds( - sessionId, - [...accepted].filter((id) => remaining.some((input) => input.id === id)), - ).catch(() => {}); - return reconciled; - } - private async resolveUncertainDispatch( state: PersistedProjectBootstrapState, ): Promise { @@ -2191,9 +1057,7 @@ export class ProjectBootstrapCoordinator { ? "injection_failed" : "delivery_timeout", retryable: positivelyNotSubmitted, - correlationRelease: positivelyNotSubmitted - ? "remove" - : "tombstone", + correlationRelease: positivelyNotSubmitted ? "remove" : "tombstone", }; if (positivelyNotSubmitted) this.removeExpectedGreeting(sessionId, turn.id); @@ -2551,108 +1415,6 @@ export class ProjectBootstrapCoordinator { else this.processedCompletionEvents.set(sessionId, remaining); } - private async persist( - sessionId: string, - state: PersistedProjectBootstrapState, - ): Promise { - try { - await this.writeState(this.file(sessionId), state); - } catch { - if (!isTerminal(state.metadata)) { - const previousBootstrap = state.metadata.bootstrap; - const attemptId = - previousBootstrap.status === "generating" - ? previousBootstrap.attemptId - : undefined; - const latestAttempt = state.attempts.at(-1); - const positivelyPreSubmit = - !latestAttempt || - latestAttempt.phase === "claimed" || - latestAttempt.phase === "not-submitted"; - const retryable = - positivelyPreSubmit && - (previousBootstrap.status !== "failed" || - previousBootstrap.retryable); - if (retryable && latestAttempt) latestAttempt.status = "retired"; - state.metadata.bootstrap = state.inputs.length - ? { status: "skipped", reason: "user-proceeded" } - : { - status: "failed", - retryable, - errorCode: "persistence_failed", - }; - // At least one of the two stores may still be available. Keep the - // bounded classification wherever possible; never persist raw errors. - let fallbackCommitted = false; - try { - await this.options.sessionManager.setProjectBootstrapMetadata( - sessionId, - state.metadata, - ); - fallbackCommitted = true; - } catch { - // The primary queue fallback below may still retain the bounded - // terminal classification. - } - try { - await this.writeState(this.file(sessionId), state); - fallbackCommitted = true; - } catch { - // If sessions.json committed, mergeRegistration treats that terminal - // projection as authoritative on restart. Only a total two-store - // outage retains the last committed cache. - } - if (fallbackCommitted) - this.states.set(sessionId, structuredClone(state)); - this.emit({ - name: "project_bootstrap.failed", - projectId: state.metadata.projectId, - sessionId, - ...(attemptId ? { attemptId } : {}), - errorCode: "persistence_failed", - retryable, - queueDepth: state.inputs.length, - }); - } - throw new Error("project bootstrap state persistence failed"); - } - - // The queue file contains the full coordinator state and is authoritative. - // Publish its clone only after that primary write commits: a transient - // failure before this point must not leave a phantom dispatch intent in - // memory. SessionManager's sessions.json metadata is a UI/list projection, - // not a second commit prerequisite. If that projection write fails after - // the queue commit, aborting here would strand a durable pre-PTY intent - // that restart must conservatively drop even though submitInput was never - // called. Keep dispatch moving and retry the projection on every later - // transition/registration instead. - this.states.set(sessionId, structuredClone(state)); - await this.options.sessionManager - .setProjectBootstrapMetadata(sessionId, state.metadata) - .catch(() => {}); - } - - /** - * Merge the two durable stores under a single serialized registration CAS. - * Queue-file inputs are authoritative. A terminal manager greeting is newer - * than a non-terminal queue greeting (resume suppression), while a terminal - * queue greeting is newer than a stale non-terminal manager snapshot. - */ - private mergeRegistration( - state: PersistedProjectBootstrapState, - session: HarnessSession, - ): void { - if (!session.projectBootstrap) return; - const managerTerminal = isTerminal(session.projectBootstrap); - const queueTerminal = isTerminal(state.metadata); - if (managerTerminal && !queueTerminal) { - state.metadata.bootstrap = structuredClone( - session.projectBootstrap.bootstrap, - ); - } - state.metadata.queuedInputIds = state.inputs.map((input) => input.id); - } - async register( session: HarnessSession, context: ProjectBootstrapRegistrationContext, @@ -2683,8 +1445,9 @@ export class ProjectBootstrapCoordinator { let state = await this.load(session, context.emptyProject); this.mergeRegistration(state, session); - const pendingFailure = - this.pendingBootstrapFailureTransitions.get(session.id); + const pendingFailure = this.pendingBootstrapFailureTransitions.get( + session.id, + ); if (pendingFailure) { state = await this.commitPendingBootstrapFailureTransition( state, @@ -2821,12 +1584,7 @@ export class ProjectBootstrapCoordinator { else if (allowed && runtimeEpoch !== null) this.armTimer(session.id, "pending", runtimeEpoch); else - await this.setFailure( - state, - "pending", - "scope_unavailable", - false, - ); + await this.setFailure(state, "pending", "scope_unavailable", false); } } else if (isTerminal(state.metadata)) { shouldDrain = @@ -2921,13 +1679,7 @@ export class ProjectBootstrapCoordinator { await this.persist(session.id, state); } } else { - await this.setFailure( - state, - expectedKey, - "session_exited", - false, - false, - ); + await this.setFailure(state, expectedKey, "session_exited", false, false); } // All fallible state transitions have committed. Volatile correlation is // cleared by the caller only after this returns successfully. @@ -2998,8 +1750,9 @@ export class ProjectBootstrapCoordinator { return; } let state = await this.load(session); - const pendingFailure = - this.pendingBootstrapFailureTransitions.get(session.id); + const pendingFailure = this.pendingBootstrapFailureTransitions.get( + session.id, + ); if (pendingFailure) { state = await this.commitPendingBootstrapFailureTransition( state, @@ -3188,9 +1941,7 @@ export class ProjectBootstrapCoordinator { await this.setFailure( state, attemptId, - session.status === "exited" - ? "session_exited" - : "scope_unavailable", + session.status === "exited" ? "session_exited" : "scope_unavailable", false, ); if (retry) throw new ProjectBootstrapDispatchForbiddenError(); @@ -3420,9 +2171,7 @@ export class ProjectBootstrapCoordinator { error instanceof SessionNotReadyError || (durableNotSubmitted && !(error instanceof SessionInputGuardRejectedError)), - correlationRelease: removeUnsubmittedBarrier - ? "remove" - : "tombstone", + correlationRelease: removeUnsubmittedBarrier ? "remove" : "tombstone", }; if (removeUnsubmittedBarrier) this.removeExpectedGreeting(sessionId, attemptId); @@ -3534,8 +2283,7 @@ export class ProjectBootstrapCoordinator { if (this.terminalPreemptionObligations.has(sessionId)) { await this.commitTerminalPreemption(state); } else if (expectedKey === "pending") { - const existing = - this.pendingBootstrapFailureTransitions.get(sessionId); + const existing = this.pendingBootstrapFailureTransitions.get(sessionId); if (!existing && state.metadata.bootstrap.status !== "pending") return; const transition = existing ?? { errorCode, retryable }; this.pendingBootstrapFailureTransitions.set(sessionId, transition); @@ -3578,8 +2326,7 @@ export class ProjectBootstrapCoordinator { metadata: ProjectBootstrapMetadata; receipt: ProjectBootstrapInputReceipt; }> { - if (this.closed) - throw new ProjectBootstrapCoordinatorClosedError(); + if (this.closed) throw new ProjectBootstrapCoordinatorClosedError(); const known = this.options.sessionManager.get(sessionId); if (!known?.projectBootstrap) { throw new Error("project bootstrap session not found"); @@ -3593,9 +2340,7 @@ export class ProjectBootstrapCoordinator { const payloadDigest = this.inputPayloadDigest(text); const cachedState = this.states.get(sessionId); const cachedReceipt = requestId - ? cachedState?.receipts.find( - (receipt) => receipt.requestId === requestId, - ) + ? cachedState?.receipts.find((receipt) => receipt.requestId === requestId) : undefined; let pendingInputInstalled = false; @@ -3727,8 +2472,7 @@ export class ProjectBootstrapCoordinator { if ( active?.kind === "bootstrap" && active.id === attemptId && - (attempt?.phase === "claimed" || - attempt?.phase === "not-submitted") + (attempt?.phase === "claimed" || attempt?.phase === "not-submitted") ) { this.removeExpectedGreeting(sessionId, attemptId); this.bootstrapFailureTransitions.delete(sessionId); @@ -3819,8 +2563,7 @@ export class ProjectBootstrapCoordinator { let attemptId: string | undefined; if (!isTerminal(current.metadata)) { state = structuredClone(current); - attemptId = - prior.status === "generating" ? prior.attemptId : undefined; + attemptId = prior.status === "generating" ? prior.attemptId : undefined; if (attemptId) this.markAttempt(state, attemptId, "retired"); state.metadata.bootstrap = { status: "skipped", @@ -3894,8 +2637,7 @@ export class ProjectBootstrapCoordinator { if (!this.isRuntimeEpochCurrent(sessionId, runtimeEpoch)) return; this.terminalPreemptionObligations.add(sessionId); this.terminalPreemptions.add(sessionId); - if (context.blockingPrompt) - this.blockingTerminalPreemptions.add(sessionId); + if (context.blockingPrompt) this.blockingTerminalPreemptions.add(sessionId); else this.blockingTerminalPreemptions.delete(sessionId); void this.serialize(sessionId, async () => { if ( @@ -4308,9 +3050,7 @@ export class ProjectBootstrapCoordinator { const session = this.options.sessionManager.get(event.harnessSessionId); if (!session?.projectBootstrap) return; let state = await this.load(session); - if ( - this.terminalPreemptionObligations.has(event.harnessSessionId) - ) { + if (this.terminalPreemptionObligations.has(event.harnessSessionId)) { state = await this.commitTerminalPreemption(state); } @@ -4372,9 +3112,7 @@ export class ProjectBootstrapCoordinator { } if (state.metadata.bootstrap.status !== "generating") { - const activeBootstrap = this.activeTurns.get( - event.harnessSessionId, - ); + const activeBootstrap = this.activeTurns.get(event.harnessSessionId); if ( completedTurn?.kind === "bootstrap" && !completedTurn.retired && @@ -4388,16 +3126,14 @@ export class ProjectBootstrapCoordinator { typeof assistantText !== "string" || assistantText.trim() === "" ) { - const transition = - this.bootstrapFailureTransitions.get( - event.harnessSessionId, - ) ?? { - attemptId: completedTurn.id, - errorCode: "model_turn_failed" as const, - retryable: true, - correlationRelease: - "consume-observed-or-tombstone" as const, - }; + const transition = this.bootstrapFailureTransitions.get( + event.harnessSessionId, + ) ?? { + attemptId: completedTurn.id, + errorCode: "model_turn_failed" as const, + retryable: true, + correlationRelease: "consume-observed-or-tombstone" as const, + }; this.bootstrapFailureTransitions.set( event.harnessSessionId, transition, @@ -4507,11 +3243,7 @@ export class ProjectBootstrapCoordinator { completionCommitted = true; this.bootstrapFailureTransitions.delete(event.harnessSessionId); this.consumeObservedTurn(event.harnessSessionId, completedTurn); - this.clearActiveTurn( - event.harnessSessionId, - "bootstrap", - attemptId, - ); + this.clearActiveTurn(event.harnessSessionId, "bootstrap", attemptId); this.clearTimer(event.harnessSessionId, attemptId); this.emit({ name: "project_bootstrap.delivered", diff --git a/packages/harness/src/core/project-session-legacy-migration.ts b/packages/harness/src/core/project-session-legacy-migration.ts index a09e112d3..3ca07f24e 100644 --- a/packages/harness/src/core/project-session-legacy-migration.ts +++ b/packages/harness/src/core/project-session-legacy-migration.ts @@ -92,6 +92,7 @@ function parseBootstrapState( "model_turn_failed", "delivery_timeout", "persistence_failed", + "scope_unavailable", ].includes(value.errorCode) ? { status: "failed", diff --git a/packages/harness/src/core/session-manager.test.ts b/packages/harness/src/core/session-manager.test.ts index 80d0c5468..c29559ca0 100644 --- a/packages/harness/src/core/session-manager.test.ts +++ b/packages/harness/src/core/session-manager.test.ts @@ -1,3 +1,4 @@ +import type { FocusedSessionContextProjection } from "./focused-session-context.js"; import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -13,22 +14,21 @@ import type { import { CodexAdapter } from "./adapters/codex.js"; import { ExternalHarnessError, SessionNotResumeableError } from "./errors.js"; import { - ProjectBootstrapClaimUnavailableError, - ProjectSessionScopeUnavailableError, - SessionBackgroundInputPreemptedError, SessionInputGuardRejectedError, + SessionBackgroundInputPreemptedError, SessionInputIsolationError, - SessionManager, SessionManagerClosingError, SubsessionBindingMismatchError, SubsessionFreshRestartForbiddenError, + type TrustedSubsessionBindingMarker, + ProjectSessionScopeUnavailableError, + ProjectBootstrapClaimUnavailableError, + SessionManager, sanitizeExitTail, type PtySpawnFn, type SessionManagerOptions, - type TrustedSubsessionBindingMarker, } from "./session-manager.js"; import { IngestCredentialRegistry } from "./ingest-credentials.js"; -import type { FocusedSessionContextProjection } from "./focused-session-context.js"; /** Minimal fake IPty: lets tests drive onData/onExit and observe write/resize/kill. * `pid` is only set when a test passes one explicitly — sweep tests need a @@ -37,9 +37,7 @@ import type { FocusedSessionContextProjection } from "./focused-session-context. * leaves it undefined, which the sweep must treat as "can't tell, hands off". */ function createFakePty(pid?: number) { const dataListeners: Array<(chunk: string) => void> = []; - const exitListeners: Array< - (e: { exitCode: number; signal?: number }) => void - > = []; + const exitListeners: Array<(e: { exitCode: number; signal?: number }) => void> = []; const pty = { pid, onData: (cb: (chunk: string) => void) => { @@ -61,9 +59,7 @@ function createFakePty(pid?: number) { }; } -function createFakeAdapter( - overrides: Partial = {}, -): HarnessAdapter { +function createFakeAdapter(overrides: Partial = {}): HarnessAdapter { return { id: "claude-code", eventSource: "hooks", @@ -103,6 +99,7 @@ function deferred(): { return { promise, resolve }; } + describe("SessionManager", () => { let dir: string; let sessionsPath: string; @@ -561,75 +558,6 @@ describe("SessionManager", () => { expect(manager.wasSubsessionClosedByUser(marker(sessionId))).toBe(true); }); - it("reports exact input write phases and kills only an exact runtime", async () => { - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - const runtime = manager.getRuntimeEpoch(session.id)!; - manager.setReady(session.id, runtime); - - const submitted = await manager.submitInputTracked( - session.id, - "Implement the scoped task", - ); - expect(submitted).toEqual({ accepted: true, phase: "enter-written" }); - expect(await manager.killIfRuntime(session.id, "foreign-runtime")).toBe( - false, - ); - expect(spawns[0]!.pty.kill).not.toHaveBeenCalled(); - - spawns[0]!.pty.write.mockImplementationOnce(() => { - throw new Error("ambiguous write"); - }); - const ambiguous = await manager.submitInputTracked( - session.id, - "Retry-sensitive task", - ); - expect(ambiguous).toMatchObject({ - accepted: false, - phase: "text-staged", - error: expect.any(Error), - }); - }); - - it("closes PTY admission before shutdown and rejects creates and resumes", async () => { - let releaseLaunchOptions!: () => void; - const launchOptionsReady = new Promise((resolve) => { - releaseLaunchOptions = resolve; - }); - const spawnPty = vi.fn(() => { - return createFakePty().pty as unknown as ReturnType; - }); - const { manager } = makeManager({ - spawnPty, - buildLaunchOpts: async () => { - await launchOptionsReady; - return { prompt: "not-used-by-interactive-launch" }; - }, - }); - - const creating = manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - manager.beginShutdown(); - releaseLaunchOptions(); - - await expect(creating).rejects.toBeInstanceOf(SessionManagerClosingError); - expect(spawnPty).not.toHaveBeenCalled(); - expect(manager.list()).toEqual([ - expect.objectContaining({ status: "exited" }), - ]); - await expect( - manager.create({ cwd: "/tmp/second", harness: "claude-code" }), - ).rejects.toBeInstanceOf(SessionManagerClosingError); - await expect(manager.resume(manager.list()[0]!.id)).rejects.toBeInstanceOf( - SessionManagerClosingError, - ); - }); - it("persists sessions to disk and reconciles non-exited sessions to exited on reload", async () => { const { manager } = makeManager(); const session = await manager.create({ @@ -781,6 +709,60 @@ describe("SessionManager", () => { expect(persisted[1]?.agentSessionId).toBe(manual.agentSessionId); }); + it("restores a scope-unavailable bootstrap failure and resumes once authority is valid", async () => { + const identity = { + projectId: "project-1", + userId: "user-1", + sessionId: "bootstrap-scope-failure", + }; + const session: HarnessSession = { + id: identity.sessionId, + agentSessionId: "provider-scope-failure", + harness: "claude-code", + cwd: "/tmp/project", + title: "Keep my conversation", + status: "exited", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-02T00:00:00.000Z", + exitCode: 0, + boundWorkflowPath: null, + ready: false, + agentMapIdentity: identity, + projectBootstrap: { + projectId: identity.projectId, + userId: identity.userId, + targetSessionId: identity.sessionId, + bootstrap: { + status: "failed", + errorCode: "scope_unavailable", + retryable: false, + }, + queuedInputIds: ["retained-input"], + }, + }; + await writeFile(sessionsPath, JSON.stringify([session]), "utf8"); + const migrations = vi.fn(); + const { manager, adapter, spawns } = makeManager({ + resolveAgentMapIdentity: async () => identity, + onProjectAgentIdentityMigration: migrations, + }); + + await manager.init(); + + expect(manager.get(session.id)?.projectBootstrap).toEqual(session.projectBootstrap); + expect(migrations).not.toHaveBeenCalled(); + await expect(manager.resume(session.id)).resolves.toMatchObject({ + id: session.id, + agentSessionId: session.agentSessionId, + title: session.title, + agentMapIdentity: identity, + projectBootstrap: session.projectBootstrap, + status: "running", + }); + expect(adapter.resume).toHaveBeenCalledTimes(1); + expect(spawns).toHaveLength(1); + }); + it("preserves malformed or conflicting legacy identity records without deleting or duplicating them", async () => { const malformed = { id: "malformed-session", @@ -1157,86 +1139,6 @@ describe("SessionManager", () => { expect(spawns[0]?.pty.write).toHaveBeenCalledTimes(1); }); - it("lets raw user input preempt a staged background turn without combining either prompt", async () => { - const onTerminalInput = vi.fn(); - const { manager, spawns } = makeManager({ onTerminalInput }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - manager.setReady(session.id); - - const background = manager.submitInput( - session.id, - "automatic map bootstrap", - true, - undefined, - true, - ); - expect(spawns[0]?.pty.write).toHaveBeenCalledTimes(1); - expect(spawns[0]?.pty.write).toHaveBeenCalledWith( - "automatic map bootstrap", - ); - - expect(manager.write(session.id, "implement the API now\r")).toBe(true); - expect(onTerminalInput).toHaveBeenCalledWith(session.id, { - blockingPrompt: false, - runtimeEpoch: manager.getRuntimeEpoch(session.id), - }); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith( - 3, - "implement the API now\r", - ); - - const assertion = expect(background).rejects.toMatchObject({ - code: "SESSION_BACKGROUND_INPUT_PREEMPTED", - staged: true, - }); - await vi.advanceTimersByTimeAsync(300); - await assertion; - - expect(spawns[0]?.pty.write).toHaveBeenCalledTimes(3); - expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); - await expect(background).rejects.toBeInstanceOf( - SessionBackgroundInputPreemptedError, - ); - }); - - it("lets durable API input cancel only a staged background turn", async () => { - const onTerminalInput = vi.fn(); - const { manager, spawns } = makeManager({ onTerminalInput }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - manager.setReady(session.id); - - const background = manager.submitInput( - session.id, - "automatic map bootstrap", - true, - undefined, - true, - ); - expect(spawns[0]?.pty.write).toHaveBeenCalledWith( - "automatic map bootstrap", - ); - - expect(manager.preemptBackgroundInput(session.id)).toBe(true); - expect(manager.preemptBackgroundInput(session.id)).toBe(false); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); - expect(onTerminalInput).not.toHaveBeenCalled(); - - const assertion = expect(background).rejects.toMatchObject({ - code: "SESSION_BACKGROUND_INPUT_PREEMPTED", - staged: true, - }); - await vi.advanceTimersByTimeAsync(300); - await assertion; - expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); - }); - it("rechecks a scoped guard before Enter and leaves staged text unsubmitted after a rebind", async () => { const { manager, spawns } = makeManager(); const session = await manager.create({ @@ -1267,424 +1169,340 @@ describe("SessionManager", () => { expect(spawns[0]?.pty.write).toHaveBeenLastCalledWith("\x15"); expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); }); + }); - it("waits for the durable pre-write hook before crossing the PTY boundary", async () => { + describe("exit output capture (exitTail)", () => { + it("preserves the tail of output when a session exits abnormally (non-zero code)", async () => { const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - manager.setReady(session.id); - const phase = deferred(); - const beforeFirstWrite = vi.fn(() => phase.promise); - const submitting = manager.submitInput( - session.id, - "durable turn", - true, - undefined, - false, - { beforeFirstWrite, canWriteNow: () => true }, + spawns[0]?.emitData( + "\x1b[31merror\x1b[0m: unknown option '--plugin-dir'\n", ); - await Promise.resolve(); - expect(beforeFirstWrite).toHaveBeenCalledOnce(); - expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); + spawns[0]?.emitExit(1); + await manager.flush(); - phase.resolve(); - await vi.advanceTimersByTimeAsync(300); - await expect(submitting).resolves.toBe(true); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "durable turn"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\r"); + const exited = manager.get(session.id); + expect(exited?.status).toBe("exited"); + expect(exited?.exitCode).toBe(1); + // The agent's own error line survives — with ANSI stripped — which is the + // whole point: a startup crash is no longer an opaque exit code. + expect(exited?.exitTail).toContain( + "error: unknown option '--plugin-dir'", + ); + expect(exited?.exitTail).not.toContain("\x1b"); }); - it("records positive not-submitted evidence when the PTY rejects text before Enter", async () => { + it("captures nothing for a clean exit (code 0)", async () => { const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - manager.setReady(session.id); - const beforeFirstWrite = vi.fn(async () => {}); - const onNotSubmitted = vi.fn(async () => {}); - spawns[0]?.pty.write.mockImplementationOnce(() => { - throw new Error("PTY rejected text"); - }); - await expect( - manager.submitInput(session.id, "durable turn", true, undefined, true, { - beforeFirstWrite, - onNotSubmitted, - }), - ).rejects.toThrow("PTY rejected text"); + spawns[0]?.emitData("all good, bye\n"); + spawns[0]?.emitExit(0); + await manager.flush(); - expect(beforeFirstWrite).toHaveBeenCalledOnce(); - expect(onNotSubmitted).toHaveBeenCalledOnce(); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "durable turn"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); - expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); + expect(manager.get(session.id)?.exitTail ?? null).toBeNull(); }); - it("withholds not-submitted evidence when a partial text line cannot be cleared", async () => { + it("captures nothing when an abnormal exit produced no readable output", async () => { const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - manager.setReady(session.id); - const onNotSubmitted = vi.fn(async () => {}); - spawns[0]?.pty.write - .mockImplementationOnce(() => { - throw new Error("PTY rejected text after a possible prefix"); - }) - .mockImplementationOnce(() => { - throw new Error("PTY also rejected line cleanup"); - }); - await expect( - manager.submitInput(session.id, "durable turn", true, undefined, true, { - onNotSubmitted, - }), - ).rejects.toThrow("PTY rejected text after a possible prefix"); + // Only cursor/clear control noise — nothing a human could read. + spawns[0]?.emitData("\x1b[2J\x1b[H"); + spawns[0]?.emitExit(1); + await manager.flush(); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "durable turn"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); - expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); - expect(onNotSubmitted).not.toHaveBeenCalled(); + expect(manager.get(session.id)?.exitTail ?? null).toBeNull(); }); - it("closes a partial bracketed paste before proving the composer line was cleared", async () => { + it("captures nothing for a user-initiated kill, even when the pty reports a non-zero signal code", async () => { const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - manager.setReady(session.id); - spawns[0]?.emitData("\x1b[?2004h"); - const onNotSubmitted = vi.fn(async () => {}); - spawns[0]?.pty.write.mockImplementationOnce(() => { - throw new Error("partial bracketed paste"); - }); - - await expect( - manager.submitInput(session.id, "line one\nline two", true, undefined, true, { - onNotSubmitted, - }), - ).rejects.toThrow("partial bracketed paste"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith( - 1, - "\x1b[200~line one\nline two\x1b[201~", + spawns[0]?.emitData( + "some normal session output the user was looking at\n", ); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x1b[201~"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(3, "\x15"); - expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); - expect(onNotSubmitted).toHaveBeenCalledOnce(); + // kill() marks the handle; node-pty then reports 143 (128 + SIGTERM) on + // some platforms — a non-zero code, but NOT a crash to diagnose. + const killed = manager.kill(session.id); + spawns[0]?.emitExit(143); + await killed; + await manager.flush(); + + const exited = manager.get(session.id); + expect(exited?.exitCode).toBe(143); + expect(exited?.exitTail ?? null).toBeNull(); }); + }); - it("withholds retry proof when a partial bracketed paste cannot be closed", async () => { - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - manager.setReady(session.id); - spawns[0]?.emitData("\x1b[?2004h"); - const onNotSubmitted = vi.fn(async () => {}); - spawns[0]?.pty.write - .mockImplementationOnce(() => { - throw new Error("partial bracketed paste"); - }) - .mockImplementationOnce(() => { - throw new Error("paste closer rejected"); - }); + describe("sanitizeExitTail", () => { + it("strips ANSI and trailing control noise, keeping readable text", () => { + expect(sanitizeExitTail("\x1b[31mboom\x1b[0m\r\n")).toBe("boom"); + }); - await expect( - manager.submitInput(session.id, "unsafe paste", true, undefined, true, { - onNotSubmitted, - }), - ).rejects.toThrow("partial bracketed paste"); + it("returns null when nothing readable remains", () => { + expect(sanitizeExitTail("\x1b[2J\x1b[H")).toBeNull(); + expect(sanitizeExitTail("")).toBeNull(); + }); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x1b[201~"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(3, "\x15"); - expect(onNotSubmitted).not.toHaveBeenCalled(); + it("keeps only the final window of a large buffer", () => { + const out = sanitizeExitTail("x".repeat(10_000) + "TAIL_MARKER"); + expect(out?.endsWith("TAIL_MARKER")).toBe(true); + expect((out ?? "").length).toBeLessThanOrEqual(4_096); + }); + }); + + describe("readiness gating (SessionNotReadyError / setReady / detectBlockingPrompt)", () => { + beforeEach(() => { + vi.useFakeTimers(); }); - it("withholds retry proof when line cleanup fails after closing a partial paste", async () => { - const { manager, spawns } = makeManager(); + afterEach(() => { + vi.useRealTimers(); + }); + + it('a fresh session starts not-ready even though its pty is already "running"', async () => { + const { manager } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - manager.setReady(session.id); - spawns[0]?.emitData("\x1b[?2004h"); - const onNotSubmitted = vi.fn(async () => {}); - spawns[0]?.pty.write - .mockImplementationOnce(() => { - throw new Error("partial bracketed paste"); - }) - .mockImplementationOnce(() => {}) - .mockImplementationOnce(() => { - throw new Error("line cleanup rejected"); - }); - - await expect( - manager.submitInput(session.id, "unsafe paste", true, undefined, true, { - onNotSubmitted, - }), - ).rejects.toThrow("partial bracketed paste"); - - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x1b[201~"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(3, "\x15"); - expect(onNotSubmitted).not.toHaveBeenCalled(); + expect(session.status).toBe("running"); + expect(session.ready).toBe(false); }); - it("blocks later raw input on a poisoned composer until a reset succeeds", async () => { - const { manager, spawns } = makeManager(); + it("write() (raw keystrokes) is never gated on readiness — a human must be able to answer a blocking prompt themselves", async () => { + const onTerminalInput = vi.fn(); + const { manager, spawns } = makeManager({ onTerminalInput }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - manager.setReady(session.id); - let call = 0; - spawns[0]?.pty.write.mockImplementation(() => { - call += 1; - if (call <= 3) throw new Error(`injected write failure ${call}`); + expect(session.ready).toBe(false); + + expect(manager.write(session.id, "1\r")).toBe(true); + expect(spawns[0]?.pty.write).toHaveBeenCalledWith("1\r"); + expect(onTerminalInput).toHaveBeenCalledOnce(); + expect(onTerminalInput).toHaveBeenCalledWith(session.id, { + blockingPrompt: false, + runtimeEpoch: manager.getRuntimeEpoch(session.id), }); + }); - await expect( - manager.submitInput(session.id, "partial A", true, undefined, true), - ).rejects.toThrow("injected write failure 1"); - expect(() => manager.write(session.id, "must not append to A")).toThrow( - SessionInputIsolationError, - ); - expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith( - "must not append to A", - ); + it( + "THE RACE REPRO: submitInput() against a not-yet-ready session queues and succeeds once " + + "setReady() fires before the grace period elapses (macro fired a beat before onboarding finished)", + async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); - expect(manager.write(session.id, "safe after reset")).toBe(true); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(4, "\x15"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith( - 5, - "safe after reset", - ); - }); + const submitPromise = manager.submitInput(session.id, "hello", true); + // Not ready yet — must NOT have written anything, this is exactly the + // bug: input landing on a TUI that isn't listening yet. + expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); - it("uses Ctrl-C only as a fallback after a complete staged paste", async () => { + // The real SessionStart hook lands a moment later. + await vi.advanceTimersByTimeAsync(500); + manager.setReady(session.id); + + // Generous, not tightly matched to SUBMIT_DELAY_MS: the readiness + // poll loop's own in-flight tick can eat into part of a + // precisely-sized advance before SUBMIT_DELAY_MS's sleep even + // starts, since setReady() above only flips a flag — the loop still + // has to wake up and notice it on its own schedule. + await vi.advanceTimersByTimeAsync(1_000); + const ok = await submitPromise; + + expect(ok).toBe(true); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "hello"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\r"); + }, + ); + + it("rechecks a scoped guard after readiness and writes zero bytes after a queued planner is rebound", async () => { const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - manager.setReady(session.id); - spawns[0]?.emitData("\x1b[?2004h"); - const onNotSubmitted = vi.fn(async () => {}); - const background = manager.submitInput( + let authorized = true; + const submitPromise = manager.submitInput( session.id, - "complete staged paste", - true, - undefined, + "planner message", true, - { onNotSubmitted }, + () => authorized, ); - const rejected = expect(background).rejects.toBeInstanceOf( - SessionBackgroundInputPreemptedError, + expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); + + authorized = false; + manager.setReady(session.id); + const assertion = expect(submitPromise).rejects.toBeInstanceOf( + SessionInputGuardRejectedError, ); - spawns[0]?.pty.write.mockImplementationOnce(() => { - throw new Error("Ctrl-U rejected"); - }); + await vi.advanceTimersByTimeAsync(500); + await assertion; - expect(manager.preemptBackgroundInput(session.id)).toBe(true); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(3, "\x03"); - await vi.advanceTimersByTimeAsync(300); - await rejected; - expect(onNotSubmitted).toHaveBeenCalledOnce(); + expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); }); - it("poisons arbitrary submit:false text after a partial write", async () => { + it("throws SessionNotReadyError (never silently proceeds) when a session never becomes ready within the grace period", async () => { const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - manager.setReady(session.id); - spawns[0]?.pty.write.mockImplementationOnce(() => { - throw new Error("partial draft"); - }); - await expect( - manager.submitInput(session.id, "multi-byte draft", false), - ).rejects.toThrow("partial draft"); - expect(manager.write(session.id, "new raw input")).toBe(true); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(3, "new raw input"); + const submitPromise = manager.submitInput(session.id, "hello", true); + const assertion = expect(submitPromise).rejects.toThrow(/not ready yet/i); + await vi.advanceTimersByTimeAsync(8_000); + await assertion; + + // The whole point: nothing was ever written into the not-listening TUI. + expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); }); - it("does not claim not-submitted proof when the Enter write is ambiguous", async () => { + it("resuming resets ready back to false, even for a session that was ready before its pty exited", async () => { const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); + await manager.setAgentSessionId(session.id, "agent-1"); manager.setReady(session.id); - const onNotSubmitted = vi.fn(async () => {}); - spawns[0]?.pty.write - .mockImplementationOnce(() => {}) - .mockImplementationOnce(() => { - throw new Error("PTY Enter outcome unknown"); - }); + expect(manager.get(session.id)?.ready).toBe(true); - const submission = manager.submitInput( - session.id, - "durable turn", - true, - undefined, - true, - { onNotSubmitted }, - ); - const rejected = expect(submission).rejects.toThrow( - "PTY Enter outcome unknown", - ); - await vi.advanceTimersByTimeAsync(300); - await rejected; + spawns[0]?.emitExit(0); + expect(manager.get(session.id)?.status).toBe("exited"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "durable turn"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\r"); - expect(onNotSubmitted).not.toHaveBeenCalled(); + await manager.resume(session.id); + expect(manager.get(session.id)?.status).toBe("running"); + // Trust dialogs can reappear on resume (e.g. different sandbox flags) + // — a fresh pty hasn't proven itself interactive yet either way. + expect(manager.get(session.id)?.ready).toBe(false); }); - it("clears staged text and never writes Enter when shutdown wins the final admission boundary", async () => { - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - manager.setReady(session.id); - const finalAuthorization = deferred(); - const canWrite = vi - .fn<() => boolean | Promise>() - .mockReturnValueOnce(true) - .mockImplementationOnce(() => finalAuthorization.promise); - - const submitting = manager.submitInput( - session.id, - "must not submit after shutdown", - true, - canWrite, - false, - { canWriteNow: () => true }, - ); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - expect(spawns[0]?.pty.write).toHaveBeenCalledWith( - "must not submit after shutdown", - ); - await vi.advanceTimersByTimeAsync(300); - manager.beginShutdown(); - finalAuthorization.resolve(true); - - await expect(submitting).rejects.toMatchObject({ staged: true }); - expect(spawns[0]?.pty.write).toHaveBeenLastCalledWith("\x15"); - expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); + it("setReady is idempotent and a silent no-op for an unknown session id", () => { + const { manager } = makeManager(); + expect(() => manager.setReady("unknown-id")).not.toThrow(); }); - }); - - describe("exit output capture (exitTail)", () => { - it("preserves the tail of output when a session exits abnormally (non-zero code)", async () => { - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData( - "\x1b[31merror\x1b[0m: unknown option '--plugin-dir'\n", - ); - spawns[0]?.emitExit(1); - await manager.flush(); + describe("harnesses with detectBlockingPrompt (Codex's lazy-rollout-file bridge)", () => { + it("is not ready before the settle window elapses, even with a clean scrollback", async () => { + const detectBlockingPrompt = vi.fn(() => false); + const { manager, spawns } = makeManager({ + adapter: createFakeAdapter({ + detectBlockingPrompt, + readyFallback: "immediate", + }), + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + spawns[0]?.emitData("› Ask Codex to do anything\r\n"); - const exited = manager.get(session.id); - expect(exited?.status).toBe("exited"); - expect(exited?.exitCode).toBe(1); - // The agent's own error line survives — with ANSI stripped — which is the - // whole point: a startup crash is no longer an opaque exit code. - expect(exited?.exitTail).toContain( - "error: unknown option '--plugin-dir'", - ); - expect(exited?.exitTail).not.toContain("\x1b"); - }); + const submitPromise = manager.submitInput(session.id, "hello", true); + expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); - it("captures nothing for a clean exit (code 0)", async () => { - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", + // Generous, not tightly matched to READY_SETTLE_MS + SUBMIT_DELAY_MS: + // the settle window is checked once per READY_POLL_MS poll tick, not + // the instant it elapses, so the actual crossing (and the fresh + // SUBMIT_DELAY_MS sleep that only starts once it does) can land + // meaningfully later than the nominal 700ms. + await vi.advanceTimersByTimeAsync(1_500); + expect(await submitPromise).toBe(true); }); - spawns[0]?.emitData("all good, bye\n"); - spawns[0]?.emitExit(0); - await manager.flush(); + it("becomes ready enough after the settle window when the scrollback shows no blocking prompt (the common already-trusted case)", async () => { + const detectBlockingPrompt = vi.fn(() => false); + const { manager, spawns } = makeManager({ + adapter: createFakeAdapter({ + detectBlockingPrompt, + readyFallback: "immediate", + }), + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + spawns[0]?.emitData("› Ask Codex to do anything\r\n"); - expect(manager.get(session.id)?.exitTail ?? null).toBeNull(); - }); + await vi.advanceTimersByTimeAsync(700); + const submitPromise = manager.submitInput(session.id, "hello", true); + await vi.advanceTimersByTimeAsync(1_000); + const ok = await submitPromise; - it("captures nothing when an abnormal exit produced no readable output", async () => { - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", + expect(ok).toBe(true); + expect(spawns[0]?.pty.write).toHaveBeenCalledWith("hello"); + // Only the tail of retained scrollback is scanned, not the full history. + expect(detectBlockingPrompt).toHaveBeenCalledWith(expect.any(String)); }); - // Only cursor/clear control noise — nothing a human could read. - spawns[0]?.emitData("\x1b[2J\x1b[H"); - spawns[0]?.emitExit(1); - await manager.flush(); + it("stays not-ready while the scrollback shows a blocking prompt, then proceeds once it clears", async () => { + let showingPrompt = true; + const detectBlockingPrompt = vi.fn(() => showingPrompt); + const { manager, spawns } = makeManager({ + adapter: createFakeAdapter({ + detectBlockingPrompt, + readyFallback: "immediate", + }), + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + spawns[0]?.emitData("Do you trust the contents of this directory?\r\n"); - expect(manager.get(session.id)?.exitTail ?? null).toBeNull(); - }); + const submitPromise = manager.submitInput(session.id, "hello", true); + await vi.advanceTimersByTimeAsync(700); + expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); - it("captures nothing for a user-initiated kill, even when the pty reports a non-zero signal code", async () => { - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", + // Simulated: a human answers the prompt directly in the terminal. + showingPrompt = false; + spawns[0]?.emitData("› Ask Codex to do anything\r\n"); + await vi.advanceTimersByTimeAsync(1_200); + expect(await submitPromise).toBe(true); }); - spawns[0]?.emitData( - "some normal session output the user was looking at\n", - ); - // kill() marks the handle; node-pty then reports 143 (128 + SIGTERM) on - // some platforms — a non-zero code, but NOT a crash to diagnose. - const killed = manager.kill(session.id); - spawns[0]?.emitExit(143); - await killed; - await manager.flush(); - - const exited = manager.get(session.id); - expect(exited?.exitCode).toBe(143); - expect(exited?.exitTail ?? null).toBeNull(); - }); - }); - - describe("sanitizeExitTail", () => { - it("strips ANSI and trailing control noise, keeping readable text", () => { - expect(sanitizeExitTail("\x1b[31mboom\x1b[0m\r\n")).toBe("boom"); - }); + it("throws SessionNotReadyError if the blocking prompt never clears within the grace period", async () => { + const detectBlockingPrompt = vi.fn(() => true); + const { manager, spawns } = makeManager({ + adapter: createFakeAdapter({ + detectBlockingPrompt, + readyFallback: "immediate", + }), + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + spawns[0]?.emitData("Do you trust the contents of this directory?\r\n"); - it("returns null when nothing readable remains", () => { - expect(sanitizeExitTail("\x1b[2J\x1b[H")).toBeNull(); - expect(sanitizeExitTail("")).toBeNull(); - }); + const submitPromise = manager.submitInput(session.id, "hello", true); + const assertion = + expect(submitPromise).rejects.toThrow(/not ready yet/i); + await vi.advanceTimersByTimeAsync(8_000); + await assertion; - it("keeps only the final window of a large buffer", () => { - const out = sanitizeExitTail("x".repeat(10_000) + "TAIL_MARKER"); - expect(out?.endsWith("TAIL_MARKER")).toBe(true); - expect((out ?? "").length).toBeLessThanOrEqual(4_096); + expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); + }); }); }); - describe("readiness gating (SessionNotReadyError / setReady / detectBlockingPrompt)", () => { + describe("immediate ready fallback (Codex's first-prompt bridge)", () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -1693,832 +1511,568 @@ describe("SessionManager", () => { vi.useRealTimers(); }); - it('a fresh session starts not-ready even though its pty is already "running"', async () => { - const { manager } = makeManager(); + const immediateAdapter = ( + detect: (scrollback: string) => boolean = () => false, + detectReady: (scrollback: string) => boolean = (scrollback) => + scrollback.includes("Ask Codex to do anything"), + ) => + createFakeAdapter({ + readyFallback: "immediate", + detectBlockingPrompt: vi.fn(detect), + detectReadyPrompt: vi.fn(detectReady), + }); + + it("publishes ready after output settles without waiting for submitInput()", async () => { + const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); + const readyStatuses: boolean[] = []; + manager.onStatusChange((session) => readyStatuses.push(session.ready)); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - expect(session.status).toBe("running"); - expect(session.ready).toBe(false); + spawns[0]?.emitData("\x1b[1;1H› Ask Codex to do anything\r\n"); + + await vi.advanceTimersByTimeAsync(749); + expect(manager.get(session.id)?.ready).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(manager.get(session.id)?.ready).toBe(true); + expect(readyStatuses.filter(Boolean)).toHaveLength(1); + expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); }); - it("write() (raw keystrokes) is never gated on readiness — a human must be able to answer a blocking prompt themselves", async () => { - const onTerminalInput = vi.fn(); - const { manager, spawns } = makeManager({ onTerminalInput }); + it("requires pty output before publishing ready", async () => { + const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - expect(session.ready).toBe(false); - expect(manager.write(session.id, "1\r")).toBe(true); - expect(spawns[0]?.pty.write).toHaveBeenCalledWith("1\r"); - expect(onTerminalInput).toHaveBeenCalledOnce(); - expect(onTerminalInput).toHaveBeenCalledWith(session.id, { - blockingPrompt: false, - runtimeEpoch: manager.getRuntimeEpoch(session.id), + await vi.advanceTimersByTimeAsync(1_000); + expect(manager.get(session.id)?.ready).toBe(false); + + spawns[0]?.emitData("› Ask Codex to do anything\r\n"); + await vi.advanceTimersByTimeAsync(799); + expect(manager.get(session.id)?.ready).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(manager.get(session.id)?.ready).toBe(true); + }); + + it("ignores Codex's animation-only synchronized repaints when settling", async () => { + const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", }); + spawns[0]?.emitData("› Ask Codex to do anything\r\n"); + + // Real codex-cli 0.147.0 emits a control-only frame like this about + // every 80ms while idle. It must not postpone readiness forever. + const animationFrame = + "\x1b[?2026h\x1b[1;55H\x1b[0m\x1b[49m\x1b[K\x1b[?25l\x1b[?2026l"; + for (let elapsed = 100; elapsed <= 700; elapsed += 100) { + await vi.advanceTimersByTimeAsync(100); + spawns[0]?.emitData(animationFrame); + } + expect(manager.get(session.id)?.ready).toBe(false); + + await vi.advanceTimersByTimeAsync(50); + expect(manager.get(session.id)?.ready).toBe(true); }); - it("classifies raw input on a recognized trust screen without blocking the user's bytes", async () => { - const onTerminalInput = vi.fn(); + it("keeps a blocking screen visible to readiness checks through ANSI-only redraw churn", async () => { + const detectBlockingPrompt = vi.fn((scrollback: string) => + scrollback.includes("Sign in with ChatGPT"), + ); const { manager, spawns } = makeManager({ - onTerminalInput, - adapter: createFakeAdapter({ - detectBlockingPrompt: (output) => output.includes("Do you trust"), - }), + adapter: immediateAdapter(detectBlockingPrompt), }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - spawns[0]?.emitData("Do you trust the files in this folder?\r\n"); - expect(manager.write(session.id, "y\r")).toBe(true); - expect(onTerminalInput).toHaveBeenCalledWith(session.id, { - blockingPrompt: true, - runtimeEpoch: manager.getRuntimeEpoch(session.id), - }); - expect(spawns[0]?.pty.write).toHaveBeenCalledWith("y\r"); + // A real Ratatui repaint — including its boundary markers — can span + // several node-pty chunks. + spawns[0]?.emitData("\x1b[?20"); + spawns[0]?.emitData("26h\x1b[2J"); + spawns[0]?.emitData( + "\x1b[1;1HSign in with ChatGPT\r\nProvide your own API key", + ); + spawns[0]?.emitData("\x1b[?20"); + spawns[0]?.emitData("26l"); + // More than the 4KB detector window of raw ANSI noise must not evict the + // sign-in copy that remains visibly painted on the terminal. + const animationFrame = + "\x1b[?2026h" + + "\x1b[1;55H\x1b[0m\x1b[49m\x1b[K".repeat(200) + + "\x1b[?2026l"; + spawns[0]?.emitData(animationFrame); + await vi.advanceTimersByTimeAsync(2_000); + expect(manager.get(session.id)?.ready).toBe(false); + expect(detectBlockingPrompt).toHaveBeenCalledWith( + expect.stringContaining("Sign in with ChatGPT"), + ); + + // A positive composer frame clears the old-screen latch. + spawns[0]?.emitData( + "\x1b[?2026h\x1b[1;1H› Ask Codex to do anything\r\n\x1b[?2026l", + ); + await vi.advanceTimersByTimeAsync(700); + expect(manager.get(session.id)?.ready).toBe(true); }); - it( - "THE RACE REPRO: submitInput() against a not-yet-ready session queues and succeeds once " + - "setReady() fires before the grace period elapses (macro fired a beat before onboarding finished)", - async () => { - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); + it("clears a trust-screen latch on the Codex 0.143 empty composer", async () => { + const { manager, spawns } = makeManager({ + adapter: new CodexAdapter({ binary: "fake-codex" }), + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); - const submitPromise = manager.submitInput(session.id, "hello", true); - // Not ready yet — must NOT have written anything, this is exactly the - // bug: input landing on a TUI that isn't listening yet. - expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); + spawns[0]?.emitData( + "\x1b[?2026h\x1b[1;1HDo you trust the contents of this directory?\r\n" + + "\x1b[6;1H1. Yes, continue\r\n\x1b[7;1H2. No, quit\x1b[?2026l", + ); + await vi.advanceTimersByTimeAsync(6_000); + expect(manager.get(session.id)?.ready).toBe(false); - // The real SessionStart hook lands a moment later. - await vi.advanceTimersByTimeAsync(500); - manager.setReady(session.id); + // A diff-rendered modal can repaint only its moved selection rows while + // retaining the underlying footer. Even without the trust heading in + // this frame, a single known modal fragment must keep the latch closed. + spawns[0]?.emitData( + "\x1b[?2026h\x1b[6;1H 1. Yes, continue\r\n" + + "\x1b[7;1H› 2. No, quit\r\n" + + "\x1b[14;1Hgpt-5.5 default · /tmp/proj\x1b[?2026l", + ); + await vi.advanceTimersByTimeAsync(1_000); + expect(manager.get(session.id)?.ready).toBe(false); - // Generous, not tightly matched to SUBMIT_DELAY_MS: the readiness - // poll loop's own in-flight tick can eat into part of a - // precisely-sized advance before SUBMIT_DELAY_MS's sleep even - // starts, since setReady() above only flips a flag — the loop still - // has to wake up and notice it on its own schedule. - await vi.advanceTimersByTimeAsync(1_000); - const ok = await submitPromise; + // Real empty-composer copy captured from Codex 0.143.0 after the user + // accepted the trust screen. v0.3.3 only recognized the newer "Ask + // Codex to do anything" copy, so the safety latch never reopened. + spawns[0]?.emitData( + "\x1b[?2026h\x1b[10;1H⚠ MCP startup incomplete (failed: notion, render)\r\n" + + "\x1b[12;1H› Use /skills to list available skills\r\n" + + "\x1b[14;1Hgpt-5.5 default · /tmp/proj\x1b[?2026l", + ); + await vi.advanceTimersByTimeAsync(600); + expect(manager.get(session.id)?.ready).toBe(false); - expect(ok).toBe(true); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "hello"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\r"); - }, - ); + // Give the poll loop a generous crossing beyond READY_SETTLE_MS rather + // than pinning this regression to the exact constant by one millisecond. + await vi.advanceTimersByTimeAsync(600); + expect(manager.get(session.id)?.ready).toBe(true); + expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); + }); - it("rechecks a scoped guard after readiness and writes zero bytes after a queued planner is rebound", async () => { - const { manager, spawns } = makeManager(); + it("retains a blocking screen across partial synchronized diff repaints until the composer is proven", async () => { + const detectBlockingPrompt = vi.fn( + (output: string) => + output.includes("Sign in with ChatGPT to use Codex") && + output.includes("connect an API key for usage-based billing"), + ); + const { manager, spawns } = makeManager({ + adapter: immediateAdapter(detectBlockingPrompt), + }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - let authorized = true; - const submitPromise = manager.submitInput( - session.id, - "planner message", - true, - () => authorized, + + spawns[0]?.emitData( + "\x1b[?2026h\x1b[1;1HSign in with ChatGPT to use Codex\r\n" + + "or connect an API key for usage-based billing\x1b[?2026l", + ); + // Real Ratatui arrow-key navigation repaints only the changed choice + // row. Losing the earlier full frame here was the review regression. + spawns[0]?.emitData( + "\x1b[?2026h\x1b[8;1H 1. Sign in with ChatGPT\r\n> 2. Sign in with Device Code\x1b[?2026l", ); - expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); - authorized = false; - manager.setReady(session.id); - const assertion = expect(submitPromise).rejects.toBeInstanceOf( - SessionInputGuardRejectedError, + // Even the liveness ceiling must never override a recognized blocker. + await vi.advanceTimersByTimeAsync(5_500); + expect(manager.get(session.id)?.ready).toBe(false); + expect(detectBlockingPrompt).toHaveBeenCalledWith( + expect.stringContaining("connect an API key for usage-based billing"), ); - await vi.advanceTimersByTimeAsync(500); - await assertion; - expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); + spawns[0]?.emitData( + "\x1b[?2026h\x1b[1;1H› Ask Codex to do anything\r\n\x1b[?2026l", + ); + await vi.advanceTimersByTimeAsync(850); + expect(manager.get(session.id)?.ready).toBe(true); }); - it("throws SessionNotReadyError (never silently proceeds) when a session never becomes ready within the grace period", async () => { - const { manager, spawns } = makeManager(); + it("uses a hard ceiling when visible startup animation never leaves a 700ms quiet window", async () => { + const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - const submitPromise = manager.submitInput(session.id, "hello", true); - const assertion = expect(submitPromise).rejects.toThrow(/not ready yet/i); - await vi.advanceTimersByTimeAsync(8_000); - await assertion; + for (let frame = 1; frame <= 12; frame += 1) { + spawns[0]?.emitData( + `\x1b[?2026h\x1b[1;1HStarting Codex ${frame}\x1b[?2026l`, + ); + await vi.advanceTimersByTimeAsync(400); + } + expect(manager.get(session.id)?.ready).toBe(false); - // The whole point: nothing was ever written into the not-listening TUI. - expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); + // Only 300ms since the latest visible frame, but more than five seconds + // since the first: the liveness ceiling now wins. + await vi.advanceTimersByTimeAsync(300); + expect(manager.get(session.id)?.ready).toBe(true); }); - it("resuming resets ready back to false, even for a session that was ready before its pty exited", async () => { - const { manager, spawns } = makeManager(); + it("uses the hard ceiling when a clean synchronized repaint never emits its end marker", async () => { + const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - await manager.setAgentSessionId(session.id, "agent-1"); - manager.setReady(session.id); - expect(manager.get(session.id)?.ready).toBe(true); - - spawns[0]?.emitExit(0); - expect(manager.get(session.id)?.status).toBe("exited"); + spawns[0]?.emitData("\x1b[?2026h\x1b[1;1H› Ask Codex to do anything\r\n"); - await manager.resume(session.id); - expect(manager.get(session.id)?.status).toBe("running"); - // Trust dialogs can reappear on resume (e.g. different sandbox flags) - // — a fresh pty hasn't proven itself interactive yet either way. + await vi.advanceTimersByTimeAsync(4_999); expect(manager.get(session.id)?.ready).toBe(false); + + await vi.advanceTimersByTimeAsync(151); + expect(manager.get(session.id)?.ready).toBe(true); }); - it("setReady is idempotent and a silent no-op for an unknown session id", () => { - const { manager } = makeManager(); - expect(() => manager.setReady("unknown-id")).not.toThrow(); + it("does not let the hard ceiling override a blocking repaint missing its end marker", async () => { + const detectBlockingPrompt = (output: string) => + output.includes("Finish signing in via your browser") && + output.includes("open the following link to authenticate"); + const { manager, spawns } = makeManager({ + adapter: immediateAdapter(detectBlockingPrompt), + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + spawns[0]?.emitData( + "\x1b[?2026h\x1b[1;1HFinish signing in via your browser\r\n" + + "open the following link to authenticate\r\n", + ); + + await vi.advanceTimersByTimeAsync(8_000); + expect(manager.get(session.id)?.ready).toBe(false); }); - describe("harnesses with detectBlockingPrompt (Codex's lazy-rollout-file bridge)", () => { - it("is not ready before the settle window elapses, even with a clean scrollback", async () => { - const detectBlockingPrompt = vi.fn(() => false); - const { manager, spawns } = makeManager({ - adapter: createFakeAdapter({ - detectBlockingPrompt, - readyFallback: "immediate", - }), - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData("› Ask Codex to do anything\r\n"); - - const submitPromise = manager.submitInput(session.id, "hello", true); - expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); - - // Generous, not tightly matched to READY_SETTLE_MS + SUBMIT_DELAY_MS: - // the settle window is checked once per READY_POLL_MS poll tick, not - // the instant it elapses, so the actual crossing (and the fresh - // SUBMIT_DELAY_MS sleep that only starts once it does) can land - // meaningfully later than the nominal 700ms. - await vi.advanceTimersByTimeAsync(1_500); - expect(await submitPromise).toBe(true); - }); - - it("becomes ready enough after the settle window when the scrollback shows no blocking prompt (the common already-trusted case)", async () => { - const detectBlockingPrompt = vi.fn(() => false); - const { manager, spawns } = makeManager({ - adapter: createFakeAdapter({ - detectBlockingPrompt, - readyFallback: "immediate", - }), - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData("› Ask Codex to do anything\r\n"); - - await vi.advanceTimersByTimeAsync(700); - const submitPromise = manager.submitInput(session.id, "hello", true); - await vi.advanceTimersByTimeAsync(1_000); - const ok = await submitPromise; - - expect(ok).toBe(true); - expect(spawns[0]?.pty.write).toHaveBeenCalledWith("hello"); - // Only the tail of retained scrollback is scanned, not the full history. - expect(detectBlockingPrompt).toHaveBeenCalledWith(expect.any(String)); - }); - - it("stays not-ready while the scrollback shows a blocking prompt, then proceeds once it clears", async () => { - let showingPrompt = true; - const detectBlockingPrompt = vi.fn(() => showingPrompt); - const { manager, spawns } = makeManager({ - adapter: createFakeAdapter({ - detectBlockingPrompt, - readyFallback: "immediate", - }), - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData("Do you trust the contents of this directory?\r\n"); - - const submitPromise = manager.submitInput(session.id, "hello", true); - await vi.advanceTimersByTimeAsync(700); - expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); - - // Simulated: a human answers the prompt directly in the terminal. - showingPrompt = false; - spawns[0]?.emitData("› Ask Codex to do anything\r\n"); - await vi.advanceTimersByTimeAsync(1_200); - expect(await submitPromise).toBe(true); - }); - - it("throws SessionNotReadyError if the blocking prompt never clears within the grace period", async () => { - const detectBlockingPrompt = vi.fn(() => true); - const { manager, spawns } = makeManager({ - adapter: createFakeAdapter({ - detectBlockingPrompt, - readyFallback: "immediate", - }), - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData("Do you trust the contents of this directory?\r\n"); - - const submitPromise = manager.submitInput(session.id, "hello", true); - const assertion = - expect(submitPromise).rejects.toThrow(/not ready yet/i); - await vi.advanceTimersByTimeAsync(8_000); - await assertion; - - expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); - }); - }); - }); - - describe("immediate ready fallback (Codex's first-prompt bridge)", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - const immediateAdapter = ( - detect: (scrollback: string) => boolean = () => false, - detectReady: (scrollback: string) => boolean = (scrollback) => - scrollback.includes("Ask Codex to do anything"), - ) => - createFakeAdapter({ - readyFallback: "immediate", - detectBlockingPrompt: vi.fn(detect), - detectReadyPrompt: vi.fn(detectReady), - }); - - it("publishes ready after output settles without waiting for submitInput()", async () => { + it("restarts the settle window when later startup output arrives", async () => { const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); - const readyStatuses: boolean[] = []; - manager.onStatusChange((session) => readyStatuses.push(session.ready)); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - spawns[0]?.emitData("\x1b[1;1H› Ask Codex to do anything\r\n"); + spawns[0]?.emitData("early Codex banner\r\n"); + + await vi.advanceTimersByTimeAsync(600); + spawns[0]?.emitData("later startup frame\r\n"); + // Spawn age has passed the old 700ms threshold, but the latest frame + // has not been quiet for 700ms yet. await vi.advanceTimersByTimeAsync(749); expect(manager.get(session.id)?.ready).toBe(false); await vi.advanceTimersByTimeAsync(1); expect(manager.get(session.id)?.ready).toBe(true); - expect(readyStatuses.filter(Boolean)).toHaveLength(1); - expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); }); - it("requires pty output before publishing ready", async () => { - const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); + it("waits through a blocking prompt and publishes ready after the user clears it", async () => { + let showingPrompt = true; + const detectBlockingPrompt = vi.fn(() => showingPrompt); + const { manager, spawns } = makeManager({ + adapter: immediateAdapter(detectBlockingPrompt), + }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); + spawns[0]?.emitData("Do you trust the contents of this directory?\r\n"); - await vi.advanceTimersByTimeAsync(1_000); + // Leave the prompt open beyond the hard ceiling. Clearing it must start + // a fresh candidate window rather than inheriting an already-expired one. + await vi.advanceTimersByTimeAsync(6_000); expect(manager.get(session.id)?.ready).toBe(false); - spawns[0]?.emitData("› Ask Codex to do anything\r\n"); - await vi.advanceTimersByTimeAsync(799); + // A human answers through raw terminal input; Codex redraws its composer. + showingPrompt = false; + spawns[0]?.emitData("\x1b[1;1H› Ask Codex to do anything\r\n"); + await vi.advanceTimersByTimeAsync(749); expect(manager.get(session.id)?.ready).toBe(false); await vi.advanceTimersByTimeAsync(1); expect(manager.get(session.id)?.ready).toBe(true); + expect(detectBlockingPrompt).toHaveBeenCalled(); }); - it("ignores Codex's animation-only synchronized repaints when settling", async () => { - const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); + it("rechecks for a late blocking screen before writing after readiness has latched", async () => { + let showingPrompt = false; + const { manager, spawns } = makeManager({ + adapter: immediateAdapter(() => showingPrompt), + }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); spawns[0]?.emitData("› Ask Codex to do anything\r\n"); + await vi.advanceTimersByTimeAsync(750); + expect(manager.get(session.id)?.ready).toBe(true); - // Real codex-cli 0.147.0 emits a control-only frame like this about - // every 80ms while idle. It must not postpone readiness forever. - const animationFrame = - "\x1b[?2026h\x1b[1;55H\x1b[0m\x1b[49m\x1b[K\x1b[?25l\x1b[?2026l"; - for (let elapsed = 100; elapsed <= 700; elapsed += 100) { - await vi.advanceTimersByTimeAsync(100); - spawns[0]?.emitData(animationFrame); - } - expect(manager.get(session.id)?.ready).toBe(false); + showingPrompt = true; + spawns[0]?.emitData("Sign in with ChatGPT\r\n"); + const submitPromise = manager.submitInput( + session.id, + "held prompt", + true, + ); + expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(50); + // `ready` remains the single latched status transition, but injection + // waits for the adapter's current frame to become safe again. expect(manager.get(session.id)?.ready).toBe(true); + showingPrompt = false; + spawns[0]?.emitData("› Ask Codex to do anything\r\n"); + await vi.advanceTimersByTimeAsync(1_200); + + expect(await submitPromise).toBe(true); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "held prompt"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\r"); }); - it("keeps a blocking screen visible to readiness checks through ANSI-only redraw churn", async () => { - const detectBlockingPrompt = vi.fn((scrollback: string) => - scrollback.includes("Sign in with ChatGPT"), - ); + it("stops after a real readiness signal and does not broadcast ready twice", async () => { + const detectBlockingPrompt = vi.fn(() => false); const { manager, spawns } = makeManager({ adapter: immediateAdapter(detectBlockingPrompt), }); + const readyStatuses: boolean[] = []; + manager.onStatusChange((session) => readyStatuses.push(session.ready)); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); + spawns[0]?.emitData("› Ask Codex to do anything\r\n"); - // A real Ratatui repaint — including its boundary markers — can span - // several node-pty chunks. - spawns[0]?.emitData("\x1b[?20"); - spawns[0]?.emitData("26h\x1b[2J"); - spawns[0]?.emitData( - "\x1b[1;1HSign in with ChatGPT\r\nProvide your own API key", + await vi.advanceTimersByTimeAsync(300); + const detectorCallsBeforeSignal = detectBlockingPrompt.mock.calls.length; + manager.setReady(session.id); + await vi.advanceTimersByTimeAsync(1_000); + + expect(manager.get(session.id)?.ready).toBe(true); + expect(readyStatuses.filter(Boolean)).toHaveLength(1); + expect(detectBlockingPrompt).toHaveBeenCalledTimes( + detectorCallsBeforeSignal, ); - spawns[0]?.emitData("\x1b[?20"); - spawns[0]?.emitData("26l"); - // More than the 4KB detector window of raw ANSI noise must not evict the - // sign-in copy that remains visibly painted on the terminal. - const animationFrame = - "\x1b[?2026h" + - "\x1b[1;55H\x1b[0m\x1b[49m\x1b[K".repeat(200) + - "\x1b[?2026l"; - spawns[0]?.emitData(animationFrame); - await vi.advanceTimersByTimeAsync(2_000); + }); + + it("stops the old monitor when its pty exits and is replaced on resume", async () => { + const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + await manager.setAgentSessionId(session.id, "agent-1"); + spawns[0]?.emitData("› old Codex composer\r\n"); + + await vi.advanceTimersByTimeAsync(300); + spawns[0]?.emitExit(0); + await manager.resume(session.id); + expect(spawns).toHaveLength(2); + + // The old clean frame must not promote the fresh, still-empty pty. + await vi.advanceTimersByTimeAsync(1_000); expect(manager.get(session.id)?.ready).toBe(false); - expect(detectBlockingPrompt).toHaveBeenCalledWith( - expect.stringContaining("Sign in with ChatGPT"), - ); - // A positive composer frame clears the old-screen latch. - spawns[0]?.emitData( - "\x1b[?2026h\x1b[1;1H› Ask Codex to do anything\r\n\x1b[?2026l", - ); - await vi.advanceTimersByTimeAsync(700); + spawns[1]?.emitData("› resumed Codex composer\r\n"); + await vi.advanceTimersByTimeAsync(800); expect(manager.get(session.id)?.ready).toBe(true); }); - it("clears a trust-screen latch on the Codex 0.143 empty composer", async () => { + it("does not proactively promote a legacy detect-only adapter", async () => { + const detectBlockingPrompt = vi.fn(() => false); const { manager, spawns } = makeManager({ - adapter: new CodexAdapter({ binary: "fake-codex" }), + adapter: createFakeAdapter({ detectBlockingPrompt }), }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); + spawns[0]?.emitData("legacy composer\r\n"); - spawns[0]?.emitData( - "\x1b[?2026h\x1b[1;1HDo you trust the contents of this directory?\r\n" + - "\x1b[6;1H1. Yes, continue\r\n\x1b[7;1H2. No, quit\x1b[?2026l", - ); - await vi.advanceTimersByTimeAsync(6_000); - expect(manager.get(session.id)?.ready).toBe(false); - - // A diff-rendered modal can repaint only its moved selection rows while - // retaining the underlying footer. Even without the trust heading in - // this frame, a single known modal fragment must keep the latch closed. - spawns[0]?.emitData( - "\x1b[?2026h\x1b[6;1H 1. Yes, continue\r\n" + - "\x1b[7;1H› 2. No, quit\r\n" + - "\x1b[14;1Hgpt-5.5 default · /tmp/proj\x1b[?2026l", - ); await vi.advanceTimersByTimeAsync(1_000); expect(manager.get(session.id)?.ready).toBe(false); - // Real empty-composer copy captured from Codex 0.143.0 after the user - // accepted the trust screen. v0.3.3 only recognized the newer "Ask - // Codex to do anything" copy, so the safety latch never reopened. - spawns[0]?.emitData( - "\x1b[?2026h\x1b[10;1H⚠ MCP startup incomplete (failed: notion, render)\r\n" + - "\x1b[12;1H› Use /skills to list available skills\r\n" + - "\x1b[14;1Hgpt-5.5 default · /tmp/proj\x1b[?2026l", - ); - await vi.advanceTimersByTimeAsync(600); + // Its pre-existing request-time compatibility path remains intact. + const submitPromise = manager.submitInput(session.id, "hello", true); + await vi.advanceTimersByTimeAsync(500); + expect(await submitPromise).toBe(true); expect(manager.get(session.id)?.ready).toBe(false); + }); + }); - // Give the poll loop a generous crossing beyond READY_SETTLE_MS rather - // than pinning this regression to the exact constant by one millisecond. - await vi.advanceTimersByTimeAsync(600); - expect(manager.get(session.id)?.ready).toBe(true); - expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); + describe("hook-timeout ready fallback (Claude Code's broken-hook rescue)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(console, "warn").mockImplementation(() => {}); }); - it("retains a blocking screen across partial synchronized diff repaints until the composer is proven", async () => { - const detectBlockingPrompt = vi.fn( - (output: string) => - output.includes("Sign in with ChatGPT to use Codex") && - output.includes("connect an API key for usage-based billing"), - ); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const hookTimeoutAdapter = (detect: (scrollback: string) => boolean = () => false) => + createFakeAdapter({ + readyFallback: "hook-timeout", + detectBlockingPrompt: vi.fn(detect), + }); + + it("flips ready ~20s after spawn when the hook never lands and the screen shows no blocking prompt", async () => { + // The Windows failure this rescues: the SessionStart hook runs `node` + // through the agent's hook shell; where that resolution breaks, ready + // never flips and the SPA's held first prompt is dropped after 10min. const { manager, spawns } = makeManager({ - adapter: immediateAdapter(detectBlockingPrompt), + adapter: hookTimeoutAdapter(), }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); + spawns[0]?.emitData("welcome to claude\r\n> "); - spawns[0]?.emitData( - "\x1b[?2026h\x1b[1;1HSign in with ChatGPT to use Codex\r\n" + - "or connect an API key for usage-based billing\x1b[?2026l", - ); - // Real Ratatui arrow-key navigation repaints only the changed choice - // row. Losing the earlier full frame here was the review regression. - spawns[0]?.emitData( - "\x1b[?2026h\x1b[8;1H 1. Sign in with ChatGPT\r\n> 2. Sign in with Device Code\x1b[?2026l", - ); - - // Even the liveness ceiling must never override a recognized blocker. - await vi.advanceTimersByTimeAsync(5_500); + await vi.advanceTimersByTimeAsync(19_000); expect(manager.get(session.id)?.ready).toBe(false); - expect(detectBlockingPrompt).toHaveBeenCalledWith( - expect.stringContaining("connect an API key for usage-based billing"), - ); - spawns[0]?.emitData( - "\x1b[?2026h\x1b[1;1H› Ask Codex to do anything\r\n\x1b[?2026l", - ); - await vi.advanceTimersByTimeAsync(850); + await vi.advanceTimersByTimeAsync(2_500); expect(manager.get(session.id)?.ready).toBe(true); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("marking ready by fallback"), + ); }); - it("uses a hard ceiling when visible startup animation never leaves a 700ms quiet window", async () => { - const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); + it("never fires when the real hook already landed — a healthy machine sees no behavior change", async () => { + const { manager, spawns } = makeManager({ + adapter: hookTimeoutAdapter(), + }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); + spawns[0]?.emitData("welcome\r\n"); + manager.setReady(session.id); - for (let frame = 1; frame <= 12; frame += 1) { - spawns[0]?.emitData( - `\x1b[?2026h\x1b[1;1HStarting Codex ${frame}\x1b[?2026l`, - ); - await vi.advanceTimersByTimeAsync(400); - } - expect(manager.get(session.id)?.ready).toBe(false); - - // Only 300ms since the latest visible frame, but more than five seconds - // since the first: the liveness ceiling now wins. - await vi.advanceTimersByTimeAsync(300); - expect(manager.get(session.id)?.ready).toBe(true); + await vi.advanceTimersByTimeAsync(25_000); + expect(console.warn).not.toHaveBeenCalled(); }); - it("uses the hard ceiling when a clean synchronized repaint never emits its end marker", async () => { - const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); + it("keeps waiting while a blocking prompt is on screen — the submit \\r must never answer a trust dialog", async () => { + let showingPrompt = true; + const { manager, spawns } = makeManager({ + adapter: hookTimeoutAdapter(() => showingPrompt), + }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - spawns[0]?.emitData("\x1b[?2026h\x1b[1;1H› Ask Codex to do anything\r\n"); + spawns[0]?.emitData("Do you trust the files in this folder?\r\n"); - await vi.advanceTimersByTimeAsync(4_999); + await vi.advanceTimersByTimeAsync(30_000); expect(manager.get(session.id)?.ready).toBe(false); - await vi.advanceTimersByTimeAsync(151); + // The user answers the dialog in the terminal; the next poll may flip. + showingPrompt = false; + await vi.advanceTimersByTimeAsync(1_500); expect(manager.get(session.id)?.ready).toBe(true); }); - it("does not let the hard ceiling override a blocking repaint missing its end marker", async () => { - const detectBlockingPrompt = (output: string) => - output.includes("Finish signing in via your browser") && - output.includes("open the following link to authenticate"); - const { manager, spawns } = makeManager({ - adapter: immediateAdapter(detectBlockingPrompt), - }); + it("requires SOME output first — a pty that never drew anything is still starting, not hook-broken", async () => { + const { manager } = makeManager({ adapter: hookTimeoutAdapter() }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - spawns[0]?.emitData( - "\x1b[?2026h\x1b[1;1HFinish signing in via your browser\r\n" + - "open the following link to authenticate\r\n", - ); - await vi.advanceTimersByTimeAsync(8_000); + await vi.advanceTimersByTimeAsync(30_000); expect(manager.get(session.id)?.ready).toBe(false); }); - it("restarts the settle window when later startup output arrives", async () => { - const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); + it("stops polling once the pty exits", async () => { + const { manager, spawns } = makeManager({ + adapter: hookTimeoutAdapter(), + }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - spawns[0]?.emitData("early Codex banner\r\n"); - - await vi.advanceTimersByTimeAsync(600); - spawns[0]?.emitData("later startup frame\r\n"); + spawns[0]?.emitData("output\r\n"); + spawns[0]?.emitExit(1); - // Spawn age has passed the old 700ms threshold, but the latest frame - // has not been quiet for 700ms yet. - await vi.advanceTimersByTimeAsync(749); + await vi.advanceTimersByTimeAsync(30_000); expect(manager.get(session.id)?.ready).toBe(false); - - await vi.advanceTimersByTimeAsync(1); - expect(manager.get(session.id)?.ready).toBe(true); + expect(console.warn).not.toHaveBeenCalledWith( + expect.stringContaining("marking ready by fallback"), + ); }); - it("waits through a blocking prompt and publishes ready after the user clears it", async () => { - let showingPrompt = true; - const detectBlockingPrompt = vi.fn(() => showingPrompt); - const { manager, spawns } = makeManager({ - adapter: immediateAdapter(detectBlockingPrompt), - }); + it("is not armed for adapters without a declared fallback", async () => { + const { manager, spawns } = makeManager({ adapter: createFakeAdapter() }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - spawns[0]?.emitData("Do you trust the contents of this directory?\r\n"); - - // Leave the prompt open beyond the hard ceiling. Clearing it must start - // a fresh candidate window rather than inheriting an already-expired one. - await vi.advanceTimersByTimeAsync(6_000); - expect(manager.get(session.id)?.ready).toBe(false); + spawns[0]?.emitData("output\r\n"); - // A human answers through raw terminal input; Codex redraws its composer. - showingPrompt = false; - spawns[0]?.emitData("\x1b[1;1H› Ask Codex to do anything\r\n"); - await vi.advanceTimersByTimeAsync(749); + await vi.advanceTimersByTimeAsync(30_000); expect(manager.get(session.id)?.ready).toBe(false); + }); + }); - await vi.advanceTimersByTimeAsync(1); - expect(manager.get(session.id)?.ready).toBe(true); - expect(detectBlockingPrompt).toHaveBeenCalled(); + it("replays the scrollback buffer to new attach()ers and streams live data", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", }); - it("rechecks for a late blocking screen before writing after readiness has latched", async () => { - let showingPrompt = false; - const { manager, spawns } = makeManager({ - adapter: immediateAdapter(() => showingPrompt), - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData("› Ask Codex to do anything\r\n"); - await vi.advanceTimersByTimeAsync(750); - expect(manager.get(session.id)?.ready).toBe(true); + spawns[0]?.emitData("hello "); + spawns[0]?.emitData("world"); - showingPrompt = true; - spawns[0]?.emitData("Sign in with ChatGPT\r\n"); - const submitPromise = manager.submitInput( - session.id, - "held prompt", - true, - ); - expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); + const received: string[] = []; + const detach = manager.attach(session.id, (chunk) => received.push(chunk)); + expect(received).toEqual(["hello world"]); - // `ready` remains the single latched status transition, but injection - // waits for the adapter's current frame to become safe again. - expect(manager.get(session.id)?.ready).toBe(true); - showingPrompt = false; - spawns[0]?.emitData("› Ask Codex to do anything\r\n"); - await vi.advanceTimersByTimeAsync(1_200); + spawns[0]?.emitData("!"); + expect(received).toEqual(["hello world", "!"]); - expect(await submitPromise).toBe(true); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "held prompt"); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\r"); - }); + detach?.(); + spawns[0]?.emitData("ignored"); + expect(received).toEqual(["hello world", "!"]); + }); - it("stops after a real readiness signal and does not broadcast ready twice", async () => { - const detectBlockingPrompt = vi.fn(() => false); - const { manager, spawns } = makeManager({ - adapter: immediateAdapter(detectBlockingPrompt), - }); - const readyStatuses: boolean[] = []; - manager.onStatusChange((session) => readyStatuses.push(session.ready)); + describe("onActivity", () => { + it("broadcasts once immediately, then throttles further data within the window", async () => { + vi.useFakeTimers(); + const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - spawns[0]?.emitData("› Ask Codex to do anything\r\n"); - await vi.advanceTimersByTimeAsync(300); - const detectorCallsBeforeSignal = detectBlockingPrompt.mock.calls.length; - manager.setReady(session.id); + const activity: string[] = []; + manager.onActivity((id) => activity.push(id)); + + spawns[0]?.emitData("hello"); + expect(activity).toEqual([session.id]); + + // Still within the 2s throttle window — no second broadcast yet. + spawns[0]?.emitData("more"); await vi.advanceTimersByTimeAsync(1_000); - - expect(manager.get(session.id)?.ready).toBe(true); - expect(readyStatuses.filter(Boolean)).toHaveLength(1); - expect(detectBlockingPrompt).toHaveBeenCalledTimes( - detectorCallsBeforeSignal, - ); - }); - - it("stops the old monitor when its pty exits and is replaced on resume", async () => { - const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - await manager.setAgentSessionId(session.id, "agent-1"); - spawns[0]?.emitData("› old Codex composer\r\n"); - - await vi.advanceTimersByTimeAsync(300); - spawns[0]?.emitExit(0); - await manager.resume(session.id); - expect(spawns).toHaveLength(2); - - // The old clean frame must not promote the fresh, still-empty pty. - await vi.advanceTimersByTimeAsync(1_000); - expect(manager.get(session.id)?.ready).toBe(false); - - spawns[1]?.emitData("› resumed Codex composer\r\n"); - await vi.advanceTimersByTimeAsync(800); - expect(manager.get(session.id)?.ready).toBe(true); - }); - - it("does not proactively promote a legacy detect-only adapter", async () => { - const detectBlockingPrompt = vi.fn(() => false); - const { manager, spawns } = makeManager({ - adapter: createFakeAdapter({ detectBlockingPrompt }), - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData("legacy composer\r\n"); - - await vi.advanceTimersByTimeAsync(1_000); - expect(manager.get(session.id)?.ready).toBe(false); - - // Its pre-existing request-time compatibility path remains intact. - const submitPromise = manager.submitInput(session.id, "hello", true); - await vi.advanceTimersByTimeAsync(500); - expect(await submitPromise).toBe(true); - expect(manager.get(session.id)?.ready).toBe(false); - }); - }); - - describe("hook-timeout ready fallback (Claude Code's broken-hook rescue)", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - const hookTimeoutAdapter = ( - detect: (scrollback: string) => boolean = () => false, - ) => - createFakeAdapter({ - readyFallback: "hook-timeout", - detectBlockingPrompt: vi.fn(detect), - }); - - it("flips ready ~20s after spawn when the hook never lands and the screen shows no blocking prompt", async () => { - // The Windows failure this rescues: the SessionStart hook runs `node` - // through the agent's hook shell; where that resolution breaks, ready - // never flips and the SPA's held first prompt is dropped after 10min. - const { manager, spawns } = makeManager({ - adapter: hookTimeoutAdapter(), - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData("welcome to claude\r\n> "); - - await vi.advanceTimersByTimeAsync(19_000); - expect(manager.get(session.id)?.ready).toBe(false); - - await vi.advanceTimersByTimeAsync(2_500); - expect(manager.get(session.id)?.ready).toBe(true); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining("marking ready by fallback"), - ); - }); - - it("never fires when the real hook already landed — a healthy machine sees no behavior change", async () => { - const { manager, spawns } = makeManager({ - adapter: hookTimeoutAdapter(), - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData("welcome\r\n"); - manager.setReady(session.id); - - await vi.advanceTimersByTimeAsync(25_000); - expect(console.warn).not.toHaveBeenCalled(); - }); - - it("keeps waiting while a blocking prompt is on screen — the submit \\r must never answer a trust dialog", async () => { - let showingPrompt = true; - const { manager, spawns } = makeManager({ - adapter: hookTimeoutAdapter(() => showingPrompt), - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData("Do you trust the files in this folder?\r\n"); - - await vi.advanceTimersByTimeAsync(30_000); - expect(manager.get(session.id)?.ready).toBe(false); - - // The user answers the dialog in the terminal; the next poll may flip. - showingPrompt = false; - await vi.advanceTimersByTimeAsync(1_500); - expect(manager.get(session.id)?.ready).toBe(true); - }); - - it("requires SOME output first — a pty that never drew anything is still starting, not hook-broken", async () => { - const { manager } = makeManager({ adapter: hookTimeoutAdapter() }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - - await vi.advanceTimersByTimeAsync(30_000); - expect(manager.get(session.id)?.ready).toBe(false); - }); - - it("stops polling once the pty exits", async () => { - const { manager, spawns } = makeManager({ - adapter: hookTimeoutAdapter(), - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData("output\r\n"); - spawns[0]?.emitExit(1); - - await vi.advanceTimersByTimeAsync(30_000); - expect(manager.get(session.id)?.ready).toBe(false); - expect(console.warn).not.toHaveBeenCalledWith( - expect.stringContaining("marking ready by fallback"), - ); - }); - - it("is not armed for adapters without a declared fallback", async () => { - const { manager, spawns } = makeManager({ adapter: createFakeAdapter() }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitData("output\r\n"); - - await vi.advanceTimersByTimeAsync(30_000); - expect(manager.get(session.id)?.ready).toBe(false); - }); - }); - - it("replays the scrollback buffer to new attach()ers and streams live data", async () => { - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - - spawns[0]?.emitData("hello "); - spawns[0]?.emitData("world"); - - const received: string[] = []; - const detach = manager.attach(session.id, (chunk) => received.push(chunk)); - expect(received).toEqual(["hello world"]); - - spawns[0]?.emitData("!"); - expect(received).toEqual(["hello world", "!"]); - - detach?.(); - spawns[0]?.emitData("ignored"); - expect(received).toEqual(["hello world", "!"]); - }); - - describe("onActivity", () => { - it("broadcasts once immediately, then throttles further data within the window", async () => { - vi.useFakeTimers(); - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - - const activity: string[] = []; - manager.onActivity((id) => activity.push(id)); - - spawns[0]?.emitData("hello"); - expect(activity).toEqual([session.id]); - - // Still within the 2s throttle window — no second broadcast yet. - spawns[0]?.emitData("more"); - await vi.advanceTimersByTimeAsync(1_000); - spawns[0]?.emitData("even more"); - expect(activity).toEqual([session.id]); + spawns[0]?.emitData("even more"); + expect(activity).toEqual([session.id]); // Past the window — the next chunk broadcasts again. await vi.advanceTimersByTimeAsync(1_100); @@ -3305,6 +2859,28 @@ describe("SessionManager", () => { } }); + it("prepares files before handing the first request to the CLI and never replays it on resume", async () => { + const prepared = deferred(); + const buildLaunchOpts = vi.fn(async (_id: string, _req: CreateSessionRequest) => { await prepared.promise; return {}; }); + const { manager, adapter, spawns } = makeManager({ buildLaunchOpts }); + const initialPrompt = "Build my ticket triage agent."; + const creating = manager.create({ cwd: "/tmp/proj", harness: "claude-code", initialPrompt }); + await vi.waitFor(() => expect(buildLaunchOpts).toHaveBeenCalledOnce()); + expect(adapter.launch).not.toHaveBeenCalled(); + prepared.resolve(); + const session = await creating; + expect(adapter.launch).toHaveBeenCalledOnce(); + expect(adapter.launch).toHaveBeenCalledWith(expect.objectContaining({ initialPrompt })); + expect(spawns[0]!.pty.write).not.toHaveBeenCalled(); + expect(await readFile(sessionsPath, "utf8")).not.toContain(initialPrompt); + await manager.setAgentSessionId(session.id, "native-first-task"); + spawns[0]!.emitExit(0); + await manager.flush(); + await manager.resume(session.id); + expect(adapter.resume).toHaveBeenCalledWith("native-first-task", expect.not.objectContaining({ initialPrompt })); + expect(buildLaunchOpts.mock.calls[1]?.[1]).not.toHaveProperty("initialPrompt"); + }); + it("awaits an async buildLaunchOpts and merges its result into launch opts", async () => { const buildLaunchOpts = vi.fn(async (harnessSessionId: string) => { await new Promise((resolve) => setTimeout(resolve, 5)); @@ -3375,1789 +2951,2328 @@ describe("SessionManager", () => { ); }); - it("claims the starting lifecycle before asynchronous resume config is regenerated", async () => { - const resumeConfig = deferred(); - let buildCount = 0; - const buildLaunchOpts = vi.fn(async () => { - buildCount += 1; - if (buildCount === 2) await resumeConfig.promise; - return {}; - }); - const { manager, adapter, spawns } = makeManager({ buildLaunchOpts }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", + it("revalidates project scope after launch preparation and before spawning a new pty", async () => { + const resolveAgentMapIdentity = vi + .fn() + .mockImplementationOnce(async (sessionId: string) => ({ + projectId: "project-1", + userId: "user-1", + sessionId, + })) + .mockResolvedValueOnce(undefined); + const onAgentMapSessionExit = vi.fn(); + const { manager, adapter, spawns } = makeManager({ + resolveAgentMapIdentity, + onAgentMapSessionExit, }); - await manager.setAgentSessionId(session.id, "agent-uuid-1"); - spawns[0]?.emitExit(0); - await manager.flush(); - const statuses: HarnessSession["status"][] = []; - const unsubscribe = manager.onStatusChange((updated) => { - if (updated.id === session.id) statuses.push(updated.status); - }); - const resumed = manager.resume(session.id); - await vi.waitFor(() => expect(buildCount).toBe(2)); - expect(manager.get(session.id)?.status).toBe("starting"); + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).rejects.toBeInstanceOf(ProjectSessionScopeUnavailableError); - // Bootstrap exit bookkeeping can finish after kill() resolves. Its metadata - // update must observe the claimed resume lifecycle, so server cleanup does - // not remove the config currently being regenerated. - await manager.setProjectBootstrapMetadata(session.id, { - projectId: "project-1", - userId: "user-1", - targetSessionId: session.id, - bootstrap: { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [], + expect(adapter.launch).toHaveBeenCalledOnce(); + expect(spawns).toHaveLength(0); + expect(manager.list()).toHaveLength(1); + expect(manager.list()[0]).toMatchObject({ + status: "exited", + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + }, }); - expect(statuses.at(-1)).toBe("starting"); - expect(adapter.resume).not.toHaveBeenCalled(); + expect(onAgentMapSessionExit).toHaveBeenCalledWith(manager.list()[0]!.id); + }); - resumeConfig.resolve(); - await resumed; - unsubscribe(); - expect(manager.get(session.id)?.status).toBe("running"); + it("registerHistorical() creates an exited placeholder session resumable later", async () => { + const { manager } = makeManager(); + const session = await manager.registerHistorical({ + agentSessionId: "agent-uuid-9", + harness: "claude-code", + cwd: "/tmp/proj", + title: "past session", + lastActiveAt: "2026-01-01T00:00:00.000Z", + }); + + expect(session.status).toBe("exited"); + expect(session.agentSessionId).toBe("agent-uuid-9"); + + const resumed = await manager.resume(session.id); + expect(resumed.status).toBe("running"); }); - it.each([ - "prepareProjectSession", - "promptAppendix", - "buildLaunchOpts", - "adapter.launch", - ] as const)( - "releases project launch authority when create setup fails in $stage", - async (stage) => { - const setupError = new Error(`setup failed in ${stage}`); - const cleanupError = new Error("cleanup also failed"); - let claimedSessionId = ""; - const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => { - claimedSessionId = sessionId; - return { - projectId: "project-1", - userId: "user-1", - sessionId, - }; - }); - const prepareProjectSession = vi.fn(async () => { - if (stage === "prepareProjectSession") throw setupError; - return { - projectBootstrap: { - projectId: "project-1", - userId: "user-1", - targetSessionId: claimedSessionId, - bootstrap: { status: "pending" as const }, - queuedInputIds: [], - }, - }; - }); - const buildLaunchOpts = vi.fn(async () => { - if (stage === "buildLaunchOpts") throw setupError; - return {}; + describe("resume() resumability pre-flight", () => { + /** A session record that looks perfectly resumable — status exited, an + * agentSessionId captured from the SessionStart hook — which is exactly + * the phantom shape: 16 of 49 real registry entries on the dev machine + * measured this way (SAP-2057 first reported 15 of 46), and + * every one of them was a Resume button guaranteed to fail. */ + async function registerPhantom(manager: SessionManager) { + return await manager.registerHistorical({ + agentSessionId: "agent-uuid-phantom", + harness: "claude-code", + cwd: "/tmp/proj", + title: "never prompted", + lastActiveAt: "2026-01-01T00:00:00.000Z", }); + } + + it("throws SessionNotResumeableError instead of spawning when the agent no longer holds the conversation", async () => { const adapter = createFakeAdapter({ - launch: vi.fn((opts) => { - if (stage === "adapter.launch") throw setupError; - return { - command: "fake-claude", - args: ["--launch"], - env: {}, - cwd: opts.cwd, - }; - }), + canResume: vi.fn(async () => false), }); - const onAgentMapSessionExit = vi.fn(async () => { - throw cleanupError; + const { manager, spawns } = makeManager({ adapter }); + const session = await registerPhantom(manager); + + await expect(manager.resume(session.id)).rejects.toThrow( + SessionNotResumeableError, + ); + // The point of the pre-flight: no doomed pty. Previously this spawned + // `claude --resume `, which exited 1 with "No conversation found". + expect(spawns).toHaveLength(0); + expect(adapter.resume).not.toHaveBeenCalled(); + }); + + it("names the agent and the reason so the 409 tells the user WHY", async () => { + const adapter = createFakeAdapter({ + canResume: vi.fn(async () => false), }); - const { manager, spawns } = makeManager({ - adapter, - resolveAgentMapIdentity, - prepareProjectSession, - buildLaunchOpts, - onAgentMapSessionExit, + const { manager } = makeManager({ adapter }); + const session = await registerPhantom(manager); + + await expect(manager.resume(session.id)).rejects.toMatchObject({ + code: "SESSION_NOT_RESUMEABLE", + message: expect.stringContaining("Claude Code"), }); + await expect(manager.resume(session.id)).rejects.toThrow( + /before their first prompt are never written to the coding agent's history/, + ); + }); - await expect( - manager.create( - { cwd: "/tmp/proj", harness: "claude-code" }, - { - promptAppendix: () => { - if (stage === "promptAppendix") throw setupError; - return "bounded project context"; - }, - }, - ), - ).rejects.toBe(setupError); + it("probes the agent's store with the session's own agentSessionId and cwd", async () => { + const canResume = vi.fn(async () => true); + const { manager } = makeManager({ + adapter: createFakeAdapter({ canResume }), + }); + const session = await registerPhantom(manager); - expect(onAgentMapSessionExit).toHaveBeenCalledOnce(); - expect(onAgentMapSessionExit).toHaveBeenCalledWith(claimedSessionId); - expect(manager.list()).toEqual([]); - expect(spawns).toEqual([]); - }, - ); + await manager.resume(session.id); - it("rejects a focused overlay when no project-agent identity resolves", async () => { - const adapter = createFakeAdapter(); - const { manager, spawns } = makeManager({ adapter }); + expect(canResume).toHaveBeenCalledWith("agent-uuid-phantom", "/tmp/proj"); + }); - await expect(manager.create( - { cwd: "/tmp/proj", harness: "claude-code" }, - { focusedContext: () => "bounded focused data" as FocusedSessionContextProjection }, - )).rejects.toThrow("Focused project context requires a project-agent identity"); - expect(adapter.launch).not.toHaveBeenCalled(); - expect(spawns).toEqual([]); - expect(manager.list()).toEqual([]); - }); + it("leaves a rejected session's record untouched — still exited, same lastActiveAt", async () => { + const { manager } = makeManager({ + adapter: createFakeAdapter({ canResume: vi.fn(async () => false) }), + }); + const session = await registerPhantom(manager); - it("rejects a focused overlay on resume when no project-agent identity resolves", async () => { - const adapter = createFakeAdapter(); - const { manager, spawns } = makeManager({ adapter }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); - await manager.setAgentSessionId(session.id, "provider-session"); - spawns[0]!.emitExit(0); - await manager.flush(); + await expect(manager.resume(session.id)).rejects.toThrow( + SessionNotResumeableError, + ); - await expect(manager.resume(session.id, { - focusedContext: "bounded focused data" as FocusedSessionContextProjection, - })).rejects.toThrow("Focused project context requires a project-agent identity"); - expect(adapter.resume).not.toHaveBeenCalled(); - expect(manager.get(session.id)?.status).toBe("exited"); - }); + const after = manager.get(session.id); + expect(after?.status).toBe("exited"); + // The duration bug: stamping lastActiveAt here made a session idle since + // last night report "Ran for 6h 25m" purely because someone clicked Resume. + expect(after?.lastActiveAt).toBe("2026-01-01T00:00:00.000Z"); + }); - it.each(["buildLaunchOpts", "adapter.resume"] as const)( - "releases project launch authority when resume setup fails in $stage", - async (stage) => { - const setupError = new Error(`resume setup failed in ${stage}`); - const adapter = createFakeAdapter({ - resume: vi.fn((agentSessionId, opts) => { - if (stage === "adapter.resume") throw setupError; - return { - command: "fake-claude", - args: ["--resume", agentSessionId], - env: {}, - cwd: opts.cwd, - }; - }), + it("rolls lastActiveAt back when the resume passes pre-flight but dies before a live pty", async () => { + const { manager } = makeManager({ + adapter: createFakeAdapter({ canResume: vi.fn(async () => true) }), + spawnPty: () => { + throw new Error("node-pty exploded"); + }, }); - const buildLaunchOpts = vi.fn(async () => { - if ( - stage === "buildLaunchOpts" && - buildLaunchOpts.mock.calls.length > 1 - ) { - throw setupError; - } - return {}; + const session = await registerPhantom(manager); + + await expect(manager.resume(session.id)).rejects.toThrow( + "node-pty exploded", + ); + + const after = manager.get(session.id); + expect(after?.status).toBe("exited"); + // No pty ever ran, so the session's last real activity is unchanged — + // "we noticed it's dead" is not activity. + expect(after?.lastActiveAt).toBe("2026-01-01T00:00:00.000Z"); + }); + + it("still short-circuits on a missing agentSessionId without probing the adapter", async () => { + const canResume = vi.fn(async () => true); + const { manager } = makeManager({ + adapter: createFakeAdapter({ canResume }), }); - const onAgentMapSessionExit = vi.fn(async () => { - throw new Error("cleanup also failed"); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", }); - const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => ({ - projectId: "project-1", - userId: "user-1", - sessionId, - })); - const { manager, spawns } = makeManager({ - adapter, - buildLaunchOpts, - onAgentMapSessionExit, - resolveAgentMapIdentity, - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - await manager.setAgentSessionId(session.id, "provider-session"); - spawns[0]!.emitExit(0); - await vi.waitFor(() => { - expect(onAgentMapSessionExit).toHaveBeenCalledWith(session.id); - }); - onAgentMapSessionExit.mockClear(); - const beforeResume = structuredClone(manager.get(session.id)); - - await expect(manager.resume(session.id)).rejects.toBe(setupError); - - expect(onAgentMapSessionExit).toHaveBeenCalledOnce(); - expect(onAgentMapSessionExit).toHaveBeenCalledWith(session.id); - expect(manager.get(session.id)).toEqual(beforeResume); - expect(spawns).toHaveLength(1); - }, - ); + await manager.kill(session.id); - it("derives one neutral project identity for create/resume and revokes it on exit", async () => { - const buildLaunchOpts = vi.fn(async () => ({})); - const onAgentMapSessionExit = vi.fn(); - const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => ({ - projectId: "project-1", - userId: "user-1", - sessionId, - })); - const { manager, spawns } = makeManager({ - buildLaunchOpts, - resolveAgentMapIdentity, - onAgentMapSessionExit, + await expect(manager.resume(session.id)).rejects.toThrow( + SessionNotResumeableError, + ); + expect(canResume).not.toHaveBeenCalled(); }); + }); + + it("new sessions start with boundWorkflowPath: null", async () => { + const { manager } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - expect(session.agentMapIdentity).toEqual({ - projectId: "project-1", - userId: "user-1", - sessionId: session.id, - }); - expect(buildLaunchOpts).toHaveBeenLastCalledWith( - session.id, - expect.anything(), - expect.objectContaining({ agentMapIdentity: session.agentMapIdentity }), - ); - await manager.setAgentSessionId(session.id, "agent-uuid-map"); - spawns[0]?.emitExit(0); - await manager.flush(); - expect(onAgentMapSessionExit).toHaveBeenCalledWith(session.id); - await manager.resume(session.id); - expect(buildLaunchOpts).toHaveBeenLastCalledWith( - session.id, - expect.anything(), - expect.objectContaining({ - resume: true, - agentMapIdentity: session.agentMapIdentity, - }), - ); + expect(session.boundWorkflowPath).toBeNull(); + expect(manager.get(session.id)?.boundWorkflowPath).toBeNull(); }); - it("publishes a claimed first project session before a concurrent ordinary session", async () => { - const firstLaunch = deferred(); - let buildCount = 0; - const prepareProjectSession = vi.fn(async (identity) => - prepareProjectSession.mock.calls.length === 1 - ? { - initialTitle: "Plan Agents", - projectBootstrap: { - projectId: identity.projectId, - userId: identity.userId, - targetSessionId: identity.sessionId, - bootstrap: { status: "pending" as const }, - queuedInputIds: [], - }, - } - : {}, - ); - const { manager, adapter } = makeManager({ - resolveAgentMapIdentity: async (sessionId) => ({ - projectId: "project-1", - userId: "user-1", - sessionId, - }), - prepareProjectSession, - buildLaunchOpts: async () => { - buildCount += 1; - if (buildCount === 1) await firstLaunch.promise; - return {}; - }, - }); + describe("harness-context.json wiring", () => { + it("create() writes the initial workspace context for every session, regardless of caller", async () => { + const writeWorkspaceContext = vi.fn(async () => {}); + const { manager } = makeManager({ writeWorkspaceContext }); - const automatic = manager.create( - { cwd: "/tmp/proj", harness: "claude-code" }, - { requireProjectBootstrapClaim: true }, - ); - await vi.waitFor(() => expect(buildCount).toBe(1)); - const explicit = manager.create({ - cwd: "/tmp/proj/packages/worker", - harness: "claude-code", - initialUserInputPending: true, + // No REST layer involved at all here — this is exactly the + // autoCreateSession call shape (server/index.ts calling + // sessionManager.create() directly), the entry point that used to skip + // the write entirely because it lived in the REST handler instead. + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + + expect(writeWorkspaceContext).toHaveBeenCalledTimes(1); + expect(writeWorkspaceContext).toHaveBeenCalledWith(session); }); - await Promise.resolve(); - expect(prepareProjectSession).toHaveBeenCalledOnce(); - expect(adapter.launch).not.toHaveBeenCalled(); - firstLaunch.resolve(); - const [first, second] = await Promise.all([automatic, explicit]); + it("create() writes the workspace context before the pty is actually spawned", async () => { + const order: string[] = []; + const writeWorkspaceContext = vi.fn(async () => { + order.push("write"); + }); + const spawnPty: PtySpawnFn = (file, args) => { + order.push("spawn"); + void file; + void args; + return createFakePty().pty as unknown as ReturnType; + }; + const { manager } = makeManager({ writeWorkspaceContext, spawnPty }); - expect(first.title).toBe("Plan Agents"); - expect(first.projectBootstrap?.targetSessionId).toBe(first.id); - expect(second.title).toBe("worker"); - expect(second.projectBootstrap).toBeUndefined(); - expect(manager.list().map((session) => session.id)).toEqual([ - first.id, - second.id, - ]); - expect(adapter.launch).toHaveBeenCalledTimes(2); - }); + await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); - it("cancels only an automatic create that loses its bootstrap claim", async () => { - const firstLaunch = deferred(); - let buildCount = 0; - const prepareProjectSession = vi.fn(async (identity) => - prepareProjectSession.mock.calls.length === 1 - ? { - initialTitle: "Plan Agents", - projectBootstrap: { - projectId: identity.projectId, - userId: identity.userId, - targetSessionId: identity.sessionId, - bootstrap: { - status: "skipped" as const, - reason: "user-proceeded" as const, - }, - queuedInputIds: [], - }, - } - : {}, - ); - const onAgentMapSessionExit = vi.fn(); - const { manager, adapter, spawns } = makeManager({ - resolveAgentMapIdentity: async (sessionId) => ({ - projectId: "project-1", - userId: "user-1", - sessionId, - }), - prepareProjectSession, - onAgentMapSessionExit, - buildLaunchOpts: async () => { - buildCount += 1; - if (buildCount === 1) await firstLaunch.promise; - return {}; - }, + // The agent's very first read of HARNESS_CONTEXT_FILE must never race + // session creation with an ENOENT — that only holds if the write is + // fully awaited before the real process (the pty) ever starts. + expect(order).toEqual(["write", "spawn"]); }); - const explicit = manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - initialUserInputPending: true, - }); - await vi.waitFor(() => expect(buildCount).toBe(1)); - const automatic = manager.create( - { cwd: "/tmp/proj", harness: "claude-code" }, - { requireProjectBootstrapClaim: true }, - ); - firstLaunch.resolve(); + it("create() surfaces a writeWorkspaceContext rejection and reconciles the record to exited", async () => { + const writeWorkspaceContext = vi.fn(async () => { + throw new Error("disk full"); + }); + const { manager } = makeManager({ writeWorkspaceContext }); - const first = await explicit; - await expect(automatic).rejects.toBeInstanceOf( - ProjectBootstrapClaimUnavailableError, - ); - expect(first).toMatchObject({ - title: "Plan Agents", - projectBootstrap: { - targetSessionId: first.id, - bootstrap: { status: "skipped", reason: "user-proceeded" }, - }, + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).rejects.toThrow("disk full"); + // The record was persisted as "starting" before the failing write — it + // must not stay that way (a non-exited record with no pty behind it + // renders as a ghost tab forever). + expect(manager.list()).toHaveLength(1); + expect(manager.list()[0]?.status).toBe("exited"); }); - expect(manager.list()).toEqual([first]); - expect(adapter.launch).toHaveBeenCalledOnce(); - expect(spawns).toHaveLength(1); - expect(onAgentMapSessionExit).toHaveBeenCalledOnce(); - }); - it("drains project create queues without preparing a waiter after shutdown", async () => { - const firstLaunch = deferred(); - let buildCount = 0; - const prepareProjectSession = vi.fn(async () => ({})); - const { manager, adapter } = makeManager({ - resolveAgentMapIdentity: async (sessionId) => ({ - projectId: "project-1", - userId: "user-1", - sessionId, - }), - prepareProjectSession, - buildLaunchOpts: async () => { - buildCount += 1; - if (buildCount === 1) await firstLaunch.promise; + it("resume() always awaits schema-aware context preparation before spawning", async () => { + const order: string[] = []; + const buildLaunchOpts = vi.fn(async () => { + order.push("prompt"); return {}; - }, - }); - - const first = manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - await vi.waitFor(() => expect(buildCount).toBe(1)); - const queued = manager.create({ - cwd: "/tmp/proj/packages/worker", - harness: "claude-code", - }); - manager.beginShutdown(); - const flushed = manager.flush(); - let flushSettled = false; - void flushed.then(() => { - flushSettled = true; + }); + const prepareWorkspaceContext = vi.fn(async () => { + order.push("prepare"); + }); + const spawnPty: PtySpawnFn = (file, args) => { + order.push("spawn"); + void file; + void args; + return createFakePty().pty as unknown as ReturnType; + }; + const { manager } = makeManager({ + buildLaunchOpts, + prepareWorkspaceContext, + spawnPty, + }); + + const session = await manager.registerHistorical({ + agentSessionId: "agent-uuid-9", + harness: "claude-code", + cwd: "/tmp/proj", + title: "past session", + lastActiveAt: "2026-01-01T00:00:00.000Z", + }); + await manager.resume(session.id); + + expect(prepareWorkspaceContext).toHaveBeenCalledTimes(1); + expect(prepareWorkspaceContext).toHaveBeenCalledWith( + manager.get(session.id), + ); + expect(order).toEqual(["prompt", "prepare", "spawn"]); }); - await Promise.resolve(); - expect(flushSettled).toBe(false); - firstLaunch.resolve(); - await expect(first).rejects.toBeInstanceOf(SessionManagerClosingError); - await expect(queued).rejects.toBeInstanceOf(SessionManagerClosingError); - await flushed; + it("resume() refuses to spawn when context preparation cannot make the prompt schema safe", async () => { + const prepareWorkspaceContext = vi.fn(async () => { + throw new Error("context path unreadable"); + }); + const { manager, spawns } = makeManager({ prepareWorkspaceContext }); - expect(prepareProjectSession).toHaveBeenCalledOnce(); - expect(buildCount).toBe(1); - expect(adapter.launch).toHaveBeenCalledOnce(); - expect(manager.list()).toEqual([ - expect.objectContaining({ status: "exited" }), - ]); - }); + const session = await manager.registerHistorical({ + agentSessionId: "agent-uuid-9", + harness: "claude-code", + cwd: "/tmp/proj", + title: "past session", + lastActiveAt: "2026-01-01T00:00:00.000Z", + }); - it("revalidates project scope after launch preparation and before spawning a new pty", async () => { - const resolveAgentMapIdentity = vi - .fn() - .mockImplementationOnce(async (sessionId: string) => ({ - projectId: "project-1", - userId: "user-1", - sessionId, - })) - .mockResolvedValueOnce(undefined); - const onAgentMapSessionExit = vi.fn(); - const { manager, adapter, spawns } = makeManager({ - resolveAgentMapIdentity, - onAgentMapSessionExit, + await expect(manager.resume(session.id)).rejects.toThrow( + "context path unreadable", + ); + + expect(spawns).toHaveLength(0); + expect(manager.get(session.id)).toMatchObject({ + status: "exited", + lastActiveAt: "2026-01-01T00:00:00.000Z", + }); }); - await expect( - manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), - ).rejects.toBeInstanceOf(ProjectSessionScopeUnavailableError); + it("defaults to a no-op for both hooks so tests with fake cwds never touch the real filesystem", async () => { + // makeManager() with no overrides exercises the SessionManagerOptions + // defaults directly against a fake cwd ("/tmp/proj") that this test + // never creates on disk. If the defaults silently did real fs I/O + // instead of no-op'ing, this would either throw (ENOENT under a path + // that doesn't exist) or leave a real .sapiom dir behind on the test + // runner's machine — neither happens, proving both defaults are inert. + const { manager } = makeManager(); + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).resolves.toBeDefined(); - expect(adapter.launch).toHaveBeenCalledOnce(); - expect(spawns).toHaveLength(0); - expect(manager.list()).toHaveLength(1); - expect(manager.list()[0]).toMatchObject({ - status: "exited", - agentMapIdentity: { - projectId: "project-1", - userId: "user-1", - }, + const historical = await manager.registerHistorical({ + agentSessionId: "agent-uuid-9", + harness: "claude-code", + cwd: "/tmp/proj", + title: "past session", + lastActiveAt: "2026-01-01T00:00:00.000Z", + }); + await expect(manager.resume(historical.id)).resolves.toBeDefined(); }); - expect(onAgentMapSessionExit).toHaveBeenCalledWith(manager.list()[0]!.id); }); - it("revalidates resumed project scope after launch preparation and before spawning another pty", async () => { - const identity = (sessionId: string) => ({ - projectId: "project-1", - userId: "user-1", - sessionId, - }); - const resolveAgentMapIdentity = vi - .fn() - .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) - .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) - .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) - .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) - .mockResolvedValueOnce(undefined); - const { manager, adapter, spawns } = makeManager({ - resolveAgentMapIdentity, - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - await manager.setAgentSessionId(session.id, "provider-project-session"); - spawns[0]?.emitExit(0); - await manager.flush(); + describe("canvas template wiring", () => { + it("create() drops the canvas template for every session, regardless of caller", async () => { + const ensureCanvasTemplate = vi.fn(async () => {}); + const { manager } = makeManager({ ensureCanvasTemplate }); - await expect(manager.resume(session.id)).rejects.toBeInstanceOf( - ProjectSessionScopeUnavailableError, - ); + await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); - expect(adapter.resume).toHaveBeenCalledOnce(); - expect(spawns).toHaveLength(1); - expect(manager.get(session.id)).toMatchObject({ - id: session.id, - agentSessionId: "provider-project-session", - status: "exited", - agentMapIdentity: identity(session.id), + expect(ensureCanvasTemplate).toHaveBeenCalledTimes(1); + expect(ensureCanvasTemplate).toHaveBeenCalledWith("/tmp/proj"); }); - }); - it("revalidates create scope after the lazy PTY loader settles and before admission", async () => { - const loader = deferred(); - const loadSpawnPty = vi.fn(() => loader.promise); - const spawnPty = vi.fn( - () => createFakePty().pty as unknown as ReturnType, - ); - let inScope = true; - const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => - inScope - ? { projectId: "project-1", userId: "user-1", sessionId } - : undefined, - ); - const onAgentMapSessionExit = vi.fn(); - const { manager, adapter } = makeManager({ - loadSpawnPty, - resolveAgentMapIdentity, - onAgentMapSessionExit, - }); + it("create() ensures the canvas template before the pty is actually spawned", async () => { + const order: string[] = []; + const ensureCanvasTemplate = vi.fn(async () => { + order.push("canvas"); + }); + const spawnPty: PtySpawnFn = (file, args) => { + order.push("spawn"); + void file; + void args; + return createFakePty().pty as unknown as ReturnType; + }; + const { manager } = makeManager({ ensureCanvasTemplate, spawnPty }); - const creating = manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", + await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + + // Same reasoning as writeWorkspaceContext: the canvas pane can open the + // moment the session reports "running", so the template must already + // be on disk before the real process (the pty) ever starts. + expect(order).toEqual(["canvas", "spawn"]); }); - await vi.waitFor(() => expect(loadSpawnPty).toHaveBeenCalledOnce()); - expect(resolveAgentMapIdentity).toHaveBeenCalledOnce(); - inScope = false; - loader.resolve(spawnPty); - await expect(creating).rejects.toBeInstanceOf( - ProjectSessionScopeUnavailableError, - ); - expect(resolveAgentMapIdentity).toHaveBeenCalledTimes(2); - expect(adapter.launch).toHaveBeenCalledOnce(); - expect(spawnPty).not.toHaveBeenCalled(); - expect(manager.list()).toEqual([ - expect.objectContaining({ status: "exited" }), - ]); - expect(onAgentMapSessionExit).toHaveBeenCalledWith(manager.list()[0]!.id); - }); + it("resume() also ensures the canvas template — the function itself is the backfill check", async () => { + const ensureCanvasTemplate = vi.fn(async () => {}); + const { manager } = makeManager({ ensureCanvasTemplate }); - it("revalidates resume scope after the lazy PTY loader settles and before admission", async () => { - const resumeLoader = deferred(); - const fakePty = createFakePty(); - const spawnPty = vi.fn( - () => fakePty.pty as unknown as ReturnType, - ); - let loadCount = 0; - const loadSpawnPty = vi.fn(async () => { - loadCount += 1; - return loadCount === 1 ? spawnPty : resumeLoader.promise; + const session = await manager.registerHistorical({ + agentSessionId: "agent-uuid-9", + harness: "claude-code", + cwd: "/tmp/proj", + title: "past session", + lastActiveAt: "2026-01-01T00:00:00.000Z", + }); + ensureCanvasTemplate.mockClear(); // registerHistorical() doesn't call it; isolate resume()'s call + + await manager.resume(session.id); + + expect(ensureCanvasTemplate).toHaveBeenCalledWith("/tmp/proj"); }); - let inScope = true; - const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => - inScope - ? { projectId: "project-1", userId: "user-1", sessionId } - : undefined, - ); - const onAgentMapSessionExit = vi.fn(); - const { manager, adapter } = makeManager({ - loadSpawnPty, - resolveAgentMapIdentity, - onAgentMapSessionExit, + + it("defaults to a no-op so tests with fake cwds never touch the real filesystem", async () => { + const { manager } = makeManager(); + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).resolves.toBeDefined(); }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - await manager.setAgentSessionId(session.id, "provider-project-session"); - fakePty.emitExit(0); - await manager.flush(); - - const resuming = manager.resume(session.id); - await vi.waitFor(() => expect(loadSpawnPty).toHaveBeenCalledTimes(2)); - expect(resolveAgentMapIdentity).toHaveBeenCalledTimes(4); - inScope = false; - resumeLoader.resolve(spawnPty); - - await expect(resuming).rejects.toBeInstanceOf( - ProjectSessionScopeUnavailableError, - ); - expect(resolveAgentMapIdentity).toHaveBeenCalledTimes(5); - expect(adapter.resume).toHaveBeenCalledOnce(); - expect(spawnPty).toHaveBeenCalledOnce(); - expect(manager.get(session.id)).toMatchObject({ - id: session.id, - status: "exited", - agentMapIdentity: { - projectId: "project-1", - userId: "user-1", - sessionId: session.id, - }, - }); - expect(onAgentMapSessionExit).toHaveBeenCalledWith(session.id); }); - it("fails resume closed when neutral project scope cannot be revalidated and preserves the session identity", async () => { - const identity = (sessionId: string) => ({ - projectId: "project-1", - userId: "user-1", - sessionId, - }); - const resolveAgentMapIdentity = vi - .fn() - .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) - .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) - .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) - .mockResolvedValueOnce(undefined); - const { manager, adapter, spawns } = makeManager({ - resolveAgentMapIdentity, - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - await manager.setAgentSessionId(session.id, "provider-project-session"); - spawns[0]?.emitExit(0); - await manager.flush(); - const beforeResume = structuredClone(manager.get(session.id)); - - await expect(manager.resume(session.id)).rejects.toBeInstanceOf( - ProjectSessionScopeUnavailableError, - ); - - expect(resolveAgentMapIdentity).toHaveBeenLastCalledWith( - session.id, - session.cwd, - session.agentMapIdentity, - ); - expect(manager.get(session.id)).toEqual(beforeResume); - expect(manager.get(session.id)?.agentMapIdentity).toEqual({ - projectId: "project-1", - userId: "user-1", - sessionId: session.id, - }); - expect(adapter.resume).not.toHaveBeenCalled(); - expect(spawns).toHaveLength(1); - }); + describe("ghost-session reconciliation (non-exited records with no live pty)", () => { + it("create() preserves its original persist error when exited reconciliation also fails", async () => { + const original = new Error("initial create persist failed"); + const cleanup = new Error("create reconciliation persist failed"); + let writes = 0; + const writeSessionRegistry = vi.fn(async () => { + writes += 1; + throw writes === 1 ? original : cleanup; + }); + const { manager } = makeManager({ writeSessionRegistry }); - it("registerHistorical() creates an exited placeholder session resumable later", async () => { - const { manager } = makeManager(); - const session = await manager.registerHistorical({ - agentSessionId: "agent-uuid-9", - harness: "claude-code", - cwd: "/tmp/proj", - title: "past session", - lastActiveAt: "2026-01-01T00:00:00.000Z", + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).rejects.toBe(original); + expect(writeSessionRegistry).toHaveBeenCalledTimes(2); + expect(manager.list()[0]?.status).toBe("exited"); }); - expect(session.status).toBe("exited"); - expect(session.agentSessionId).toBe("agent-uuid-9"); - - const resumed = await manager.resume(session.id); - expect(resumed.status).toBe("running"); - }); - - describe("resume() resumability pre-flight", () => { - /** A session record that looks perfectly resumable — status exited, an - * agentSessionId captured from the SessionStart hook — which is exactly - * the phantom shape: 16 of 49 real registry entries on the dev machine - * measured this way (SAP-2057 first reported 15 of 46), and - * every one of them was a Resume button guaranteed to fail. */ - async function registerPhantom(manager: SessionManager) { - return await manager.registerHistorical({ - agentSessionId: "agent-uuid-phantom", + it("resume() preserves its original persist error when exited reconciliation also fails", async () => { + const original = new Error("initial resume persist failed"); + const cleanup = new Error("resume reconciliation persist failed"); + let failWrites = false; + let failedWriteCount = 0; + const writeSessionRegistry = vi.fn(async () => { + if (!failWrites) return; + failedWriteCount += 1; + throw failedWriteCount === 1 ? original : cleanup; + }); + const { manager } = makeManager({ writeSessionRegistry }); + const session = await manager.registerHistorical({ + agentSessionId: "agent-uuid-persist-failure", harness: "claude-code", cwd: "/tmp/proj", - title: "never prompted", + title: "past session", lastActiveAt: "2026-01-01T00:00:00.000Z", }); - } + failWrites = true; - it("throws SessionNotResumeableError instead of spawning when the agent no longer holds the conversation", async () => { - const adapter = createFakeAdapter({ - canResume: vi.fn(async () => false), + await expect(manager.resume(session.id)).rejects.toBe(original); + expect(failedWriteCount).toBe(2); + expect(manager.get(session.id)).toMatchObject({ + status: "exited", + lastActiveAt: "2026-01-01T00:00:00.000Z", }); - const { manager, spawns } = makeManager({ adapter }); - const session = await registerPhantom(manager); - - await expect(manager.resume(session.id)).rejects.toThrow( - SessionNotResumeableError, - ); - // The point of the pre-flight: no doomed pty. Previously this spawned - // `claude --resume `, which exited 1 with "No conversation found". - expect(spawns).toHaveLength(0); - expect(adapter.resume).not.toHaveBeenCalled(); }); - it("names the agent and the reason so the 409 tells the user WHY", async () => { - const adapter = createFakeAdapter({ - canResume: vi.fn(async () => false), + it("create() reconciles the record to exited when ensureCanvasTemplate rejects", async () => { + const ensureCanvasTemplate = vi.fn(async () => { + throw new Error("read-only fs"); }); - const { manager } = makeManager({ adapter }); - const session = await registerPhantom(manager); + const { manager } = makeManager({ ensureCanvasTemplate }); - await expect(manager.resume(session.id)).rejects.toMatchObject({ - code: "SESSION_NOT_RESUMEABLE", - message: expect.stringContaining("Claude Code"), - }); - await expect(manager.resume(session.id)).rejects.toThrow( - /before their first prompt are never written to the coding agent's history/, - ); + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).rejects.toThrow("read-only fs"); + expect(manager.list()[0]?.status).toBe("exited"); }); - it("probes the agent's store with the session's own agentSessionId and cwd", async () => { - const canResume = vi.fn(async () => true); - const { manager } = makeManager({ - adapter: createFakeAdapter({ canResume }), - }); - const session = await registerPhantom(manager); + it("create() reconciles the record to exited when the pty spawn itself throws", async () => { + const spawnPty: PtySpawnFn = () => { + throw new Error("posix_spawnp failed"); + }; + const { manager } = makeManager({ spawnPty }); + const statuses: string[] = []; + manager.onStatusChange((s) => statuses.push(s.status)); - await manager.resume(session.id); + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).rejects.toThrow("posix_spawnp failed"); + expect(manager.list()[0]?.status).toBe("exited"); + expect(statuses).toContain("exited"); - expect(canResume).toHaveBeenCalledWith("agent-uuid-phantom", "/tmp/proj"); + // The reconciliation must be durable, not just in-memory — a persisted + // "starting" record would still ghost after the SPA refetches state. + await manager.flush(); + const raw = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as HarnessSession[]; + expect(raw[0]?.status).toBe("exited"); }); - it("leaves a rejected session's record untouched — still exited, same lastActiveAt", async () => { - const { manager } = makeManager({ - adapter: createFakeAdapter({ canResume: vi.fn(async () => false) }), + it("resume() reconciles the record back to exited when a pre-spawn step rejects", async () => { + const ensureCanvasTemplate = vi.fn(async () => { + throw new Error("read-only fs"); }); - const session = await registerPhantom(manager); - - await expect(manager.resume(session.id)).rejects.toThrow( - SessionNotResumeableError, - ); - - const after = manager.get(session.id); - expect(after?.status).toBe("exited"); - // The duration bug: stamping lastActiveAt here made a session idle since - // last night report "Ran for 6h 25m" purely because someone clicked Resume. - expect(after?.lastActiveAt).toBe("2026-01-01T00:00:00.000Z"); - }); - - it("rolls lastActiveAt back when the resume passes pre-flight but dies before a live pty", async () => { - const { manager } = makeManager({ - adapter: createFakeAdapter({ canResume: vi.fn(async () => true) }), - spawnPty: () => { - throw new Error("node-pty exploded"); - }, + const { manager } = makeManager({ ensureCanvasTemplate }); + const session = await manager.registerHistorical({ + agentSessionId: "agent-uuid-9", + harness: "claude-code", + cwd: "/tmp/proj", + title: "past session", + lastActiveAt: "2026-01-01T00:00:00.000Z", }); - const session = await registerPhantom(manager); - - await expect(manager.resume(session.id)).rejects.toThrow( - "node-pty exploded", - ); - const after = manager.get(session.id); - expect(after?.status).toBe("exited"); - // No pty ever ran, so the session's last real activity is unchanged — - // "we noticed it's dead" is not activity. - expect(after?.lastActiveAt).toBe("2026-01-01T00:00:00.000Z"); + await expect(manager.resume(session.id)).rejects.toThrow("read-only fs"); + // resume() flipped it to "starting" and persisted before failing — it + // must land back on "exited", not stay stranded mid-transition. + expect(manager.get(session.id)?.status).toBe("exited"); }); - it("still short-circuits on a missing agentSessionId without probing the adapter", async () => { - const canResume = vi.fn(async () => true); - const { manager } = makeManager({ - adapter: createFakeAdapter({ canResume }), - }); + it("kill() transitions a stale non-exited record with no pty to exited instead of failing", async () => { + const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - await manager.kill(session.id); + spawns[0]?.emitExit(0); + // Simulate the ghost state directly (the transitions that used to + // produce it are all reconciled now): a record stuck non-exited whose + // pty handle is long gone. + const record = manager.get(session.id)!; + record.status = "running"; - await expect(manager.resume(session.id)).rejects.toThrow( - SessionNotResumeableError, - ); - expect(canResume).not.toHaveBeenCalled(); - }); - }); + const statuses: string[] = []; + manager.onStatusChange((s) => statuses.push(s.status)); + // kill() now returns Promise; the ghost path resolves immediately. + expect(await manager.kill(session.id)).toBe(true); + expect(manager.get(session.id)?.status).toBe("exited"); + expect(statuses).toEqual(["exited"]); - it("new sessions start with boundWorkflowPath: null", async () => { - const { manager } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", + // A genuinely exited record is still a no-op false, as before. + expect(await manager.kill(session.id)).toBe(false); + expect(await manager.kill("unknown-id")).toBe(false); }); - expect(session.boundWorkflowPath).toBeNull(); - expect(manager.get(session.id)?.boundWorkflowPath).toBeNull(); - }); - describe("harness-context.json wiring", () => { - it("create() writes the initial workspace context for every session, regardless of caller", async () => { - const writeWorkspaceContext = vi.fn(async () => {}); - const { manager } = makeManager({ writeWorkspaceContext }); + describe("sweepDeadSessions", () => { + it("synthesizes an exit for a running session whose process died without onExit ever firing", async () => { + // The node-pty missed-exit bug (see kill()'s fallback), but for a + // process that died on its own — no kill() call means no fallback + // was ever armed, which is exactly what the sweep exists to catch. + const { manager } = makeManager({ + fakePid: 4242, + isPidAlive: () => false, + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + expect(session.status).toBe("running"); - // No REST layer involved at all here — this is exactly the - // autoCreateSession call shape (server/index.ts calling - // sessionManager.create() directly), the entry point that used to skip - // the write entirely because it lived in the REST handler instead. - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); + const statuses: string[] = []; + manager.onStatusChange((s) => statuses.push(s.status)); + manager.sweepDeadSessions(); - expect(writeWorkspaceContext).toHaveBeenCalledTimes(1); - expect(writeWorkspaceContext).toHaveBeenCalledWith(session); - }); + expect(manager.get(session.id)?.status).toBe("exited"); + expect(manager.get(session.id)?.exitCode).toBeNull(); + expect(statuses).toEqual(["exited"]); + // The dead handle is fully released, same as a real onExit. + expect(manager.attach(session.id, () => {})).toBeUndefined(); - it("create() writes the workspace context before the pty is actually spawned", async () => { - const order: string[] = []; - const writeWorkspaceContext = vi.fn(async () => { - order.push("write"); + await manager.flush(); + const raw = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as HarnessSession[]; + expect(raw[0]?.status).toBe("exited"); }); - const spawnPty: PtySpawnFn = (file, args) => { - order.push("spawn"); - void file; - void args; - return createFakePty().pty as unknown as ReturnType; - }; - const { manager } = makeManager({ writeWorkspaceContext, spawnPty }); - await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + it("leaves sessions whose process is alive untouched", async () => { + const { manager } = makeManager({ + fakePid: 4242, + isPidAlive: () => true, + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); - // The agent's very first read of HARNESS_CONTEXT_FILE must never race - // session creation with an ENOENT — that only holds if the write is - // fully awaited before the real process (the pty) ever starts. - expect(order).toEqual(["write", "spawn"]); - }); + manager.sweepDeadSessions(); - it("create() surfaces a writeWorkspaceContext rejection and reconciles the record to exited", async () => { - const writeWorkspaceContext = vi.fn(async () => { - throw new Error("disk full"); + expect(manager.get(session.id)?.status).toBe("running"); }); - const { manager } = makeManager({ writeWorkspaceContext }); - await expect( - manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), - ).rejects.toThrow("disk full"); - // The record was persisted as "starting" before the failing write — it - // must not stay that way (a non-exited record with no pty behind it - // renders as a ghost tab forever). - expect(manager.list()).toHaveLength(1); - expect(manager.list()[0]?.status).toBe("exited"); - }); + it("never probes a pty without a numeric pid, and never declares it dead", async () => { + const isPidAlive = vi.fn(() => false); + const { manager } = makeManager({ isPidAlive }); // fake pty with pid: undefined + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); - it("resume() always awaits schema-aware context preparation before spawning", async () => { - const order: string[] = []; - const buildLaunchOpts = vi.fn(async () => { - order.push("prompt"); - return {}; - }); - const prepareWorkspaceContext = vi.fn(async () => { - order.push("prepare"); + manager.sweepDeadSessions(); + + expect(isPidAlive).not.toHaveBeenCalled(); + expect(manager.get(session.id)?.status).toBe("running"); }); - const spawnPty: PtySpawnFn = (file, args) => { - order.push("spawn"); - void file; - void args; - return createFakePty().pty as unknown as ReturnType; - }; - const { manager } = makeManager({ - buildLaunchOpts, - prepareWorkspaceContext, - spawnPty, + + it("reconciles a non-exited record with no pty only after it outlives the grace window", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + spawns[0]?.emitExit(0); + const record = manager.get(session.id)!; + record.status = "starting"; // simulate the stale mid-transition ghost + + // Fresh record (lastActiveAt just now): could be a create()/resume() + // still inside its legitimate pre-spawn window — hands off. + record.lastActiveAt = new Date().toISOString(); + manager.sweepDeadSessions(); + expect(manager.get(session.id)?.status).toBe("starting"); + + // Same record well past any plausible spawn window: dead, reconcile. + record.lastActiveAt = new Date(Date.now() - 60_000).toISOString(); + manager.sweepDeadSessions(); + expect(manager.get(session.id)?.status).toBe("exited"); }); + }); + }); - const session = await manager.registerHistorical({ - agentSessionId: "agent-uuid-9", - harness: "claude-code", + describe("setBoundWorkflowPath", () => { + it("updates the in-memory session, persists it, and notifies status listeners", async () => { + const { manager } = makeManager(); + const session = await manager.create({ cwd: "/tmp/proj", - title: "past session", - lastActiveAt: "2026-01-01T00:00:00.000Z", + harness: "claude-code", }); - await manager.resume(session.id); - expect(prepareWorkspaceContext).toHaveBeenCalledTimes(1); - expect(prepareWorkspaceContext).toHaveBeenCalledWith( - manager.get(session.id), + const statuses: (string | null)[] = []; + manager.onStatusChange((s) => { + if (s.id === session.id) statuses.push(s.boundWorkflowPath); + }); + + manager.setBoundWorkflowPath(session.id, "/tmp/leasing"); + await manager.flush(); + + expect(manager.get(session.id)?.boundWorkflowPath).toBe("/tmp/leasing"); + expect(statuses).toEqual(["/tmp/leasing"]); + + const raw = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as HarnessSession[]; + expect(raw.find((s) => s.id === session.id)?.boundWorkflowPath).toBe( + "/tmp/leasing", ); - expect(order).toEqual(["prompt", "prepare", "spawn"]); }); - it("resume() refuses to spawn when context preparation cannot make the prompt schema safe", async () => { - const prepareWorkspaceContext = vi.fn(async () => { - throw new Error("context path unreadable"); + it("unbinds with null, persisting and notifying again", async () => { + const { manager } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", }); - const { manager, spawns } = makeManager({ prepareWorkspaceContext }); + manager.setBoundWorkflowPath(session.id, "/tmp/leasing"); - const session = await manager.registerHistorical({ - agentSessionId: "agent-uuid-9", - harness: "claude-code", - cwd: "/tmp/proj", - title: "past session", - lastActiveAt: "2026-01-01T00:00:00.000Z", + const statuses: (string | null)[] = []; + manager.onStatusChange((s) => { + if (s.id === session.id) statuses.push(s.boundWorkflowPath); }); - await expect(manager.resume(session.id)).rejects.toThrow( - "context path unreadable", - ); + manager.setBoundWorkflowPath(session.id, null); + await manager.flush(); - expect(spawns).toHaveLength(0); - expect(manager.get(session.id)).toMatchObject({ - status: "exited", - lastActiveAt: "2026-01-01T00:00:00.000Z", - }); + expect(manager.get(session.id)?.boundWorkflowPath).toBeNull(); + expect(statuses).toEqual([null]); }); - it("defaults to a no-op for both hooks so tests with fake cwds never touch the real filesystem", async () => { - // makeManager() with no overrides exercises the SessionManagerOptions - // defaults directly against a fake cwd ("/tmp/proj") that this test - // never creates on disk. If the defaults silently did real fs I/O - // instead of no-op'ing, this would either throw (ENOENT under a path - // that doesn't exist) or leave a real .sapiom dir behind on the test - // runner's machine — neither happens, proving both defaults are inert. + it("is a no-op (doesn't throw, doesn't notify) for an unknown session id", async () => { const { manager } = makeManager(); - await expect( - manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), - ).resolves.toBeDefined(); + const statuses: string[] = []; + manager.onStatusChange(() => statuses.push("fired")); - const historical = await manager.registerHistorical({ - agentSessionId: "agent-uuid-9", - harness: "claude-code", - cwd: "/tmp/proj", - title: "past session", - lastActiveAt: "2026-01-01T00:00:00.000Z", - }); - await expect(manager.resume(historical.id)).resolves.toBeDefined(); + expect(() => + manager.setBoundWorkflowPath("does-not-exist", "/tmp/leasing"), + ).not.toThrow(); + await manager.flush(); + expect(statuses).toEqual([]); }); - }); - describe("canvas template wiring", () => { - it("create() drops the canvas template for every session, regardless of caller", async () => { - const ensureCanvasTemplate = vi.fn(async () => {}); - const { manager } = makeManager({ ensureCanvasTemplate }); + it("is a no-op when rebinding to the already-current value (no redundant persist/notify)", async () => { + const { manager } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setBoundWorkflowPath(session.id, "/tmp/leasing"); + await manager.flush(); - await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const statuses: (string | null)[] = []; + manager.onStatusChange((s) => { + if (s.id === session.id) statuses.push(s.boundWorkflowPath); + }); - expect(ensureCanvasTemplate).toHaveBeenCalledTimes(1); - expect(ensureCanvasTemplate).toHaveBeenCalledWith("/tmp/proj"); + manager.setBoundWorkflowPath(session.id, "/tmp/leasing"); + await manager.flush(); + expect(statuses).toEqual([]); }); + }); - it("create() ensures the canvas template before the pty is actually spawned", async () => { - const order: string[] = []; - const ensureCanvasTemplate = vi.fn(async () => { - order.push("canvas"); - }); - const spawnPty: PtySpawnFn = (file, args) => { - order.push("spawn"); - void file; - void args; - return createFakePty().pty as unknown as ReturnType; - }; - const { manager } = makeManager({ ensureCanvasTemplate, spawnPty }); + it("injects the contract's ENV.* variables into the spawned process env", async () => { + const capturedEnvs: Record[] = []; + const spawnPty: PtySpawnFn = (_file, _args, options) => { + capturedEnvs.push(options.env ?? {}); + const fake = createFakePty(); + return fake.pty as unknown as ReturnType; + }; + const { manager } = makeManager({ spawnPty }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); - await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const env = capturedEnvs[0]; + expect(env?.["SAPIOM_HARNESS_INGEST_URL"]).toBe( + "http://127.0.0.1:4100/ingest", + ); + expect(env?.["SAPIOM_HARNESS_INGEST_TOKEN"]).toBe("boot-token"); + expect(env?.["SAPIOM_HARNESS_SESSION_ID"]).toBe(session.id); + }); - // Same reasoning as writeWorkspaceContext: the canvas pane can open the - // moment the session reports "running", so the template must already - // be on disk before the real process (the pty) ever starts. - expect(order).toEqual(["canvas", "spawn"]); + it("issues and revokes a capability for the exact spawned session", async () => { + const issue = vi.fn((id: string) => ({ + token: `token-for:${id}`, + runtimeEpoch: `epoch-for:${id}`, + })); + const revoke = vi.fn(); + const { manager, spawns } = makeManager({ + ingestCredentials: { issue, revoke, authenticate: () => null }, + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", }); - it("resume() also ensures the canvas template — the function itself is the backfill check", async () => { - const ensureCanvasTemplate = vi.fn(async () => {}); - const { manager } = makeManager({ ensureCanvasTemplate }); + expect(issue).toHaveBeenCalledWith(session.id); + spawns[0]!.emitExit(0); + await manager.flush(); + expect(revoke).toHaveBeenCalledWith(session.id); + }); - const session = await manager.registerHistorical({ - agentSessionId: "agent-uuid-9", - harness: "claude-code", - cwd: "/tmp/proj", - title: "past session", - lastActiveAt: "2026-01-01T00:00:00.000Z", - }); - ensureCanvasTemplate.mockClear(); // registerHistorical() doesn't call it; isolate resume()'s call + it("owns a colour-capable PTY environment instead of inheriting launcher suppression", async () => { + const previous = { + NO_COLOR: process.env.NO_COLOR, + FORCE_COLOR: process.env.FORCE_COLOR, + TERM: process.env.TERM, + COLORTERM: process.env.COLORTERM, + }; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + process.env.TERM = "dumb"; + process.env.COLORTERM = ""; - await manager.resume(session.id); + try { + const capturedEnvs: Record[] = []; + const spawnPty: PtySpawnFn = (_file, _args, options) => { + capturedEnvs.push(options.env ?? {}); + return createFakePty().pty as unknown as ReturnType; + }; + const { manager } = makeManager({ spawnPty }); + await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); - expect(ensureCanvasTemplate).toHaveBeenCalledWith("/tmp/proj"); - }); + expect(capturedEnvs[0]?.NO_COLOR).toBeUndefined(); + expect(capturedEnvs[0]?.FORCE_COLOR).toBeUndefined(); + expect(capturedEnvs[0]?.TERM).toBe("xterm-256color"); + expect(capturedEnvs[0]?.COLORTERM).toBe("truecolor"); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); - it("defaults to a no-op so tests with fake cwds never touch the real filesystem", async () => { - const { manager } = makeManager(); - await expect( - manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), - ).resolves.toBeDefined(); + it("unsets env vars the adapter's SpawnSpec maps to null", async () => { + process.env["HARNESS_TEST_UNSET_ME"] = "should-be-removed"; + const capturedEnvs: Record[] = []; + const adapter = createFakeAdapter({ + launch: vi.fn( + (opts): SpawnSpec => ({ + command: "fake-claude", + args: [], + env: { HARNESS_TEST_UNSET_ME: null }, + cwd: opts.cwd, + }), + ), }); + const spawnPty: PtySpawnFn = (_file, _args, options) => { + capturedEnvs.push(options.env ?? {}); + const fake = createFakePty(); + return fake.pty as unknown as ReturnType; + }; + const { manager } = makeManager({ adapter, spawnPty }); + await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + + expect(capturedEnvs[0]?.["HARNESS_TEST_UNSET_ME"]).toBeUndefined(); + delete process.env["HARNESS_TEST_UNSET_ME"]; }); - describe("ghost-session reconciliation (non-exited records with no live pty)", () => { - it("create() preserves its original persist error when exited reconciliation also fails", async () => { - const original = new Error("initial create persist failed"); - const cleanup = new Error("create reconciliation persist failed"); - let writes = 0; - const writeSessionRegistry = vi.fn(async () => { - writes += 1; - throw writes === 1 ? original : cleanup; - }); - const { manager } = makeManager({ writeSessionRegistry }); + /** + * The desktop host pins `ESBUILD_BINARY_PATH` at an esbuild binary outside + * app.asar, because it cannot exec one from inside the archive. That pin must + * not reach the agent: this loop copies the WHOLE parent environment into the + * pty, so the agent — and everything the agent spawns in the user's own repo — + * inherited a pin to OUR esbuild build. Any project on a different esbuild + * version (vite, vitest, tsup, tsx, astro…) then dies with + * `Cannot start service: Host version "0.25.12" does not match binary version + * "0.28.1"` on a project that builds fine outside the app. + */ + it("never leaks the host's ESBUILD_BINARY_PATH pin into the agent's environment", async () => { + process.env["ESBUILD_BINARY_PATH"] = "/app/resources/app.asar.unpacked/node_modules/@esbuild/linux-x64/bin/esbuild"; + const capturedEnvs: Record[] = []; + const spawnPty: PtySpawnFn = (_file, _args, options) => { + capturedEnvs.push(options.env ?? {}); + const fake = createFakePty(); + return fake.pty as unknown as ReturnType; + }; + const { manager } = makeManager({ spawnPty }); + await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); - await expect( - manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), - ).rejects.toBe(original); - expect(writeSessionRegistry).toHaveBeenCalledTimes(2); - expect(manager.list()[0]?.status).toBe("exited"); + expect(capturedEnvs[0]?.["ESBUILD_BINARY_PATH"]).toBeUndefined(); + // Everything else still comes through — this is a targeted strip, not a + // switch to a clean environment (the agent needs PATH, HOME, the lot). + expect(capturedEnvs[0]?.["PATH"]).toBe(process.env["PATH"]); + delete process.env["ESBUILD_BINARY_PATH"]; + }); + + describe("awaitable kill — liveness-fallback resolution", () => { + beforeEach(() => { + vi.useFakeTimers(); }); - it("resume() preserves its original persist error when exited reconciliation also fails", async () => { - const original = new Error("initial resume persist failed"); - const cleanup = new Error("resume reconciliation persist failed"); - let failWrites = false; - let failedWriteCount = 0; - const writeSessionRegistry = vi.fn(async () => { - if (!failWrites) return; - failedWriteCount += 1; - throw failedWriteCount === 1 ? original : cleanup; + afterEach(() => { + vi.useRealTimers(); + }); + + it("REGRESSION: kill() resolves via the synthesis/liveness path when node-pty's onExit never fires (missed-exit bug)", async () => { + // Simulate the node-pty missed-exit bug: the pty's kill() is called + // but its onExit listeners are never invoked — the OS process is gone + // (isPidAlive returns false) but the event never arrives. kill() must + // still resolve within the escalation window via the synthesized exit. + let pidAlive = true; + const { manager, spawns } = makeManager({ + fakePid: 9999, + isPidAlive: () => pidAlive, }); - const { manager } = makeManager({ writeSessionRegistry }); - const session = await manager.registerHistorical({ - agentSessionId: "agent-uuid-persist-failure", - harness: "claude-code", + const session = await manager.create({ cwd: "/tmp/proj", - title: "past session", - lastActiveAt: "2026-01-01T00:00:00.000Z", + harness: "claude-code", }); - failWrites = true; + expect(session.status).toBe("running"); - await expect(manager.resume(session.id)).rejects.toBe(original); - expect(failedWriteCount).toBe(2); - expect(manager.get(session.id)).toMatchObject({ - status: "exited", - lastActiveAt: "2026-01-01T00:00:00.000Z", - }); - }); + // Confirm the pty exists and won't emit onExit on its own — the exit + // listeners on the fake pty exist, but we never call emitExit(). + expect(spawns[0]?.pty.kill).not.toHaveBeenCalled(); - it("create() reconciles the record to exited when ensureCanvasTemplate rejects", async () => { - const ensureCanvasTemplate = vi.fn(async () => { - throw new Error("read-only fs"); - }); - const { manager } = makeManager({ ensureCanvasTemplate }); + const killPromise = manager.kill(session.id); + // kill() sent SIGTERM (the initial pty.kill() with no signal arg). + expect(spawns[0]?.pty.kill).toHaveBeenCalledTimes(1); - await expect( - manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), - ).rejects.toThrow("read-only fs"); - expect(manager.list()[0]?.status).toBe("exited"); - }); + // The process is now "dead" at the OS level but node-pty hasn't fired. + pidAlive = false; - it("create() reconciles the record to exited when the pty spawn itself throws", async () => { - const spawnPty: PtySpawnFn = () => { - throw new Error("posix_spawnp failed"); - }; - const { manager } = makeManager({ spawnPty }); - const statuses: string[] = []; - manager.onStatusChange((s) => statuses.push(s.status)); + // Advance past KILL_ESCALATION_MS (2000ms): the escalation fires and + // checks isPidAlive. Since the process is already dead, it skips SIGKILL + // and schedules the KILL_ESCALATION_CONFIRM_MS (500ms) confirm window. + await vi.advanceTimersByTimeAsync(2_000); - await expect( - manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), - ).rejects.toThrow("posix_spawnp failed"); - expect(manager.list()[0]?.status).toBe("exited"); - expect(statuses).toContain("exited"); + // Advance past KILL_ESCALATION_CONFIRM_MS: the confirm fires, sees + // isPidAlive=false, and calls markExited() → resolves handle.exited. + await vi.advanceTimersByTimeAsync(500); - // The reconciliation must be durable, not just in-memory — a persisted - // "starting" record would still ghost after the SPA refetches state. - await manager.flush(); - const raw = JSON.parse( - await readFile(sessionsPath, "utf8"), - ) as HarnessSession[]; - expect(raw[0]?.status).toBe("exited"); + // The promise must now be resolved — await it to confirm. + expect(await killPromise).toBe(true); + expect(manager.get(session.id)?.status).toBe("exited"); }); - it("resume() reconciles the record back to exited when a pre-spawn step rejects", async () => { - const ensureCanvasTemplate = vi.fn(async () => { - throw new Error("read-only fs"); + it("kill() resolves immediately via real onExit when node-pty fires before the escalation window", async () => { + const { manager, spawns } = makeManager({ + fakePid: 8888, + isPidAlive: () => false, }); - const { manager } = makeManager({ ensureCanvasTemplate }); - const session = await manager.registerHistorical({ - agentSessionId: "agent-uuid-9", - harness: "claude-code", + const session = await manager.create({ cwd: "/tmp/proj", - title: "past session", - lastActiveAt: "2026-01-01T00:00:00.000Z", + harness: "claude-code", }); - await expect(manager.resume(session.id)).rejects.toThrow("read-only fs"); - // resume() flipped it to "starting" and persisted before failing — it - // must land back on "exited", not stay stranded mid-transition. + const killPromise = manager.kill(session.id); + // Drive the real onExit — this fires before the escalation timer. + spawns[0]?.emitExit(0); + + // Promise should resolve immediately (the real path, not the synthesis path). + expect(await killPromise).toBe(true); expect(manager.get(session.id)?.status).toBe("exited"); + expect(manager.get(session.id)?.exitCode).toBe(0); }); - it("kill() transitions a stale non-exited record with no pty to exited instead of failing", async () => { - const { manager, spawns } = makeManager(); + it("kill() escalates to SIGKILL when the process survives SIGTERM, then resolves once it dies", async () => { + // Process ignores SIGTERM (stubborn process), but dies after SIGKILL. + let pidAlive = true; + const { manager, spawns } = makeManager({ + fakePid: 7777, + isPidAlive: () => pidAlive, + }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - spawns[0]?.emitExit(0); - // Simulate the ghost state directly (the transitions that used to - // produce it are all reconciled now): a record stuck non-exited whose - // pty handle is long gone. - const record = manager.get(session.id)!; - record.status = "running"; - - const statuses: string[] = []; - manager.onStatusChange((s) => statuses.push(s.status)); - // kill() now returns Promise; the ghost path resolves immediately. - expect(await manager.kill(session.id)).toBe(true); - expect(manager.get(session.id)?.status).toBe("exited"); - expect(statuses).toEqual(["exited"]); - - // A genuinely exited record is still a no-op false, as before. - expect(await manager.kill(session.id)).toBe(false); - expect(await manager.kill("unknown-id")).toBe(false); - }); - - describe("sweepDeadSessions", () => { - it("synthesizes an exit for a running session whose process died without onExit ever firing", async () => { - // The node-pty missed-exit bug (see kill()'s fallback), but for a - // process that died on its own — no kill() call means no fallback - // was ever armed, which is exactly what the sweep exists to catch. - const { manager } = makeManager({ - fakePid: 4242, - isPidAlive: () => false, - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - expect(session.status).toBe("running"); - - const statuses: string[] = []; - manager.onStatusChange((s) => statuses.push(s.status)); - manager.sweepDeadSessions(); - expect(manager.get(session.id)?.status).toBe("exited"); - expect(manager.get(session.id)?.exitCode).toBeNull(); - expect(statuses).toEqual(["exited"]); - // The dead handle is fully released, same as a real onExit. - expect(manager.attach(session.id, () => {})).toBeUndefined(); + const killPromise = manager.kill(session.id); + expect(spawns[0]?.pty.kill).toHaveBeenCalledTimes(1); // initial SIGTERM (no arg) - await manager.flush(); - const raw = JSON.parse( - await readFile(sessionsPath, "utf8"), - ) as HarnessSession[]; - expect(raw[0]?.status).toBe("exited"); - }); + // Advance to KILL_ESCALATION_MS: process is still alive → SIGKILL sent. + await vi.advanceTimersByTimeAsync(2_000); + expect(spawns[0]?.pty.kill).toHaveBeenCalledTimes(2); // SIGKILL + expect(spawns[0]?.pty.kill).toHaveBeenLastCalledWith("SIGKILL"); - it("leaves sessions whose process is alive untouched", async () => { - const { manager } = makeManager({ - fakePid: 4242, - isPidAlive: () => true, - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); + // Now the process dies (SIGKILL lands) — simulate via emitExit. + pidAlive = false; + spawns[0]?.emitExit(137); // SIGKILL exit code - manager.sweepDeadSessions(); + expect(await killPromise).toBe(true); + expect(manager.get(session.id)?.exitCode).toBe(137); + }); - expect(manager.get(session.id)?.status).toBe("running"); + it("killAll() resolves once all sessions are confirmed dead, even when exits come at different times", async () => { + const { manager, spawns } = makeManager({ + fakePid: 6666, + isPidAlive: () => false, }); + const a = await manager.create({ cwd: "/tmp/a", harness: "claude-code" }); + const b = await manager.create({ cwd: "/tmp/b", harness: "claude-code" }); - it("never probes a pty without a numeric pid, and never declares it dead", async () => { - const isPidAlive = vi.fn(() => false); - const { manager } = makeManager({ isPidAlive }); // fake pty with pid: undefined - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - - manager.sweepDeadSessions(); - - expect(isPidAlive).not.toHaveBeenCalled(); - expect(manager.get(session.id)?.status).toBe("running"); + const killAllPromise = manager.killAll(); + let resolved = false; + void killAllPromise.then(() => { + resolved = true; }); - it("reconciles a non-exited record with no pty only after it outlives the grace window", async () => { - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - spawns[0]?.emitExit(0); - const record = manager.get(session.id)!; - record.status = "starting"; // simulate the stale mid-transition ghost + // Neither has exited yet — killAll() should not be resolved. + await vi.advanceTimersByTimeAsync(0); + expect(resolved).toBe(false); - // Fresh record (lastActiveAt just now): could be a create()/resume() - // still inside its legitimate pre-spawn window — hands off. - record.lastActiveAt = new Date().toISOString(); - manager.sweepDeadSessions(); - expect(manager.get(session.id)?.status).toBe("starting"); + // First session exits. + spawns[0]?.emitExit(0); + await vi.advanceTimersByTimeAsync(0); + // Second session still alive — not resolved yet. + expect(resolved).toBe(false); - // Same record well past any plausible spawn window: dead, reconcile. - record.lastActiveAt = new Date(Date.now() - 60_000).toISOString(); - manager.sweepDeadSessions(); - expect(manager.get(session.id)?.status).toBe("exited"); - }); + // Second session exits. + spawns[1]?.emitExit(0); + await killAllPromise; + expect(resolved).toBe(true); + expect(manager.get(a.id)?.status).toBe("exited"); + expect(manager.get(b.id)?.status).toBe("exited"); }); - }); - describe("setBoundWorkflowPath", () => { - it("updates the in-memory session, persists it, and notifies status listeners", async () => { - const { manager } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", + it("killAll() resolves via liveness synthesis when node-pty misses exits for all sessions", async () => { + // Both ptys swallow their onExit events — killAll() must still resolve + // via the missed-exit synthesis path within the escalation window. + let pidAlive = true; + const { manager } = makeManager({ + fakePid: 5555, + isPidAlive: () => pidAlive, }); + await manager.create({ cwd: "/tmp/a", harness: "claude-code" }); + await manager.create({ cwd: "/tmp/b", harness: "claude-code" }); - const statuses: (string | null)[] = []; - manager.onStatusChange((s) => { - if (s.id === session.id) statuses.push(s.boundWorkflowPath); + const killAllPromise = manager.killAll(); + let resolved = false; + void killAllPromise.then(() => { + resolved = true; }); - manager.setBoundWorkflowPath(session.id, "/tmp/leasing"); - await manager.flush(); + // Mark the OS processes as gone — liveness check will confirm this. + pidAlive = false; - expect(manager.get(session.id)?.boundWorkflowPath).toBe("/tmp/leasing"); - expect(statuses).toEqual(["/tmp/leasing"]); + // Advance past the full escalation window. + await vi.advanceTimersByTimeAsync(2_000 + 500); - const raw = JSON.parse( - await readFile(sessionsPath, "utf8"), - ) as HarnessSession[]; - expect(raw.find((s) => s.id === session.id)?.boundWorkflowPath).toBe( - "/tmp/leasing", - ); + await killAllPromise; + expect(resolved).toBe(true); }); - it("unbinds with null, persisting and notifying again", async () => { - const { manager } = makeManager(); + it("REGRESSION: kill() resolves unconditionally in the confirm window even when isPidAlive stays true (EPERM zombie after SIGKILL)", async () => { + // An EPERM zombie: process.kill(pid, 0) still returns true (EPERM means + // "exists but can't be signalled") even after SIGKILL. The old confirm- + // timer guarded on `!isPidAlive(pid)` — that would leave handle.exited + // pending forever. The fixed confirm callback synthesizes markExited() + // unconditionally (SIGKILL was already sent; the session is over). + const { manager, spawns } = makeManager({ + fakePid: 4444, + // Always "alive" — simulates an EPERM zombie that survives all probes. + isPidAlive: () => true, + }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - manager.setBoundWorkflowPath(session.id, "/tmp/leasing"); - - const statuses: (string | null)[] = []; - manager.onStatusChange((s) => { - if (s.id === session.id) statuses.push(s.boundWorkflowPath); - }); - manager.setBoundWorkflowPath(session.id, null); - await manager.flush(); + const killPromise = manager.kill(session.id); + // Initial signal sent. + expect(spawns[0]?.pty.kill).toHaveBeenCalledTimes(1); - expect(manager.get(session.id)?.boundWorkflowPath).toBeNull(); - expect(statuses).toEqual([null]); - }); + // Advance past KILL_ESCALATION_MS: isPidAlive returns true → SIGKILL sent. + await vi.advanceTimersByTimeAsync(2_000); + expect(spawns[0]?.pty.kill).toHaveBeenCalledTimes(2); + expect(spawns[0]?.pty.kill).toHaveBeenLastCalledWith("SIGKILL"); - it("is a no-op (doesn't throw, doesn't notify) for an unknown session id", async () => { - const { manager } = makeManager(); - const statuses: string[] = []; - manager.onStatusChange(() => statuses.push("fired")); + // Advance past KILL_ESCALATION_CONFIRM_MS: the confirm callback fires. + // isPidAlive is still true (zombie) but the fix synthesizes unconditionally. + await vi.advanceTimersByTimeAsync(500); - expect(() => - manager.setBoundWorkflowPath("does-not-exist", "/tmp/leasing"), - ).not.toThrow(); - await manager.flush(); - expect(statuses).toEqual([]); + expect(await killPromise).toBe(true); + expect(manager.get(session.id)?.status).toBe("exited"); }); + }); - it("is a no-op when rebinding to the already-current value (no redundant persist/notify)", async () => { - const { manager } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - manager.setBoundWorkflowPath(session.id, "/tmp/leasing"); - await manager.flush(); + describe("ExternalHarnessError — real-path (no mocks)", () => { + /** + * A sessions.json entry with harness="conductor" can appear via an earlier + * build, a hand-edited file, or a future import. These tests verify the + * real production code path: when getAdapter() / submitInput() encounter + * an external-mode harness id they surface HARNESS_EXTERNAL (409) rather + * than AdapterNotFoundError or a silent false. + * + * We cast "conductor" as HarnessKind in registerHistorical() to simulate + * a persisted record that predates the typed enum. + */ - const statuses: (string | null)[] = []; - manager.onStatusChange((s) => { - if (s.id === session.id) statuses.push(s.boundWorkflowPath); + it("resume() throws ExternalHarnessError for a session persisted with harness='conductor'", async () => { + const { manager } = makeManager(); + + // Simulate a session record that arrived from disk or an earlier build. + const session = await manager.registerHistorical({ + agentSessionId: "agent-session-abc", + harness: "conductor" as HarnessKind, + cwd: "/tmp/conductor-proj", + title: "conductor-proj", + lastActiveAt: new Date().toISOString(), }); - manager.setBoundWorkflowPath(session.id, "/tmp/leasing"); - await manager.flush(); - expect(statuses).toEqual([]); + await expect(manager.resume(session.id)).rejects.toThrow( + ExternalHarnessError, + ); }); - }); - it("injects the contract's ENV.* variables into the spawned process env", async () => { - const capturedEnvs: Record[] = []; - const spawnPty: PtySpawnFn = (_file, _args, options) => { - capturedEnvs.push(options.env ?? {}); - const fake = createFakePty(); - return fake.pty as unknown as ReturnType; - }; - const { manager } = makeManager({ spawnPty }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); + it("resume() ExternalHarnessError has code HARNESS_EXTERNAL and names the harness label", async () => { + const { manager } = makeManager(); - const env = capturedEnvs[0]; - expect(env?.["SAPIOM_HARNESS_INGEST_URL"]).toBe( - "http://127.0.0.1:4100/ingest", - ); - expect(env?.["SAPIOM_HARNESS_INGEST_TOKEN"]).toBe("boot-token"); - expect(env?.["SAPIOM_HARNESS_SESSION_ID"]).toBe(session.id); - }); + const session = await manager.registerHistorical({ + agentSessionId: "agent-session-def", + harness: "conductor" as HarnessKind, + cwd: "/tmp/conductor-proj", + title: "conductor-proj", + lastActiveAt: new Date().toISOString(), + }); - it("issues and revokes a capability for the exact spawned session", async () => { - const issue = vi.fn((id: string) => ({ - token: `token-for:${id}`, - runtimeEpoch: `epoch-for:${id}`, - })); - const revoke = vi.fn(); - const { manager, spawns } = makeManager({ - ingestCredentials: { issue, revoke, authenticate: () => null }, + let caught: unknown; + try { + await manager.resume(session.id); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(ExternalHarnessError); + const err = caught as ExternalHarnessError; + expect(err.code).toBe("HARNESS_EXTERNAL"); + expect(err.message).toMatch(/Conductor/); + expect(err.harness).toBe("conductor"); }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", + + it("submitInput() throws ExternalHarnessError for a session with harness='conductor' and no live pty", async () => { + const { manager } = makeManager(); + + const session = await manager.registerHistorical({ + agentSessionId: "agent-session-ghi", + harness: "conductor" as HarnessKind, + cwd: "/tmp/conductor-proj", + title: "conductor-proj", + lastActiveAt: new Date().toISOString(), + }); + + await expect(manager.submitInput(session.id, "hello")).rejects.toThrow( + ExternalHarnessError, + ); }); - expect(issue).toHaveBeenCalledWith(session.id); - spawns[0]!.emitExit(0); - await manager.flush(); - expect(revoke).toHaveBeenCalledWith(session.id); - }); + it("submitInput() ExternalHarnessError has code HARNESS_EXTERNAL", async () => { + const { manager } = makeManager(); - it("rotates the authoritative runtime epoch and rejects stale lifecycle signals after resume", async () => { - const tokens = ["token-a", "token-b"]; - const epochs = ["epoch-a", "epoch-b"]; - const { manager, spawns } = makeManager({ - ingestCredentials: new IngestCredentialRegistry( - () => tokens.shift()!, - () => epochs.shift()!, - ), + const session = await manager.registerHistorical({ + agentSessionId: "agent-session-jkl", + harness: "conductor" as HarnessKind, + cwd: "/tmp/conductor-proj", + title: "conductor-proj", + lastActiveAt: new Date().toISOString(), + }); + + let caught: unknown; + try { + await manager.submitInput(session.id, "test input"); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(ExternalHarnessError); + expect((caught as ExternalHarnessError).code).toBe("HARNESS_EXTERNAL"); }); + }); + + + it("reports exact input write phases and kills only an exact runtime", async () => { + const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - const firstEpoch = manager.getRuntimeEpoch(session.id); - expect(firstEpoch).toBe("epoch-a"); - expect( - await manager.setAgentSessionId( - session.id, - "provider-shared", - "startup", - firstEpoch!, - ), - ).toBe(true); + const runtime = manager.getRuntimeEpoch(session.id)!; + manager.setReady(session.id, runtime); - spawns[0]!.emitExit(0); - await manager.flush(); - expect(manager.isCurrentRuntimeEpoch(session.id, firstEpoch!)).toBe(false); - expect(manager.acceptsIngestRuntimeEpoch(session.id, firstEpoch!)).toBe( - true, + const submitted = await manager.submitInputTracked( + session.id, + "Implement the scoped task", ); - manager.setReady(session.id, firstEpoch!); - expect(manager.get(session.id)?.ready).toBe(false); - - await manager.resume(session.id); - const secondEpoch = manager.getRuntimeEpoch(session.id); - expect(secondEpoch).toBe("epoch-b"); - expect(secondEpoch).not.toBe(firstEpoch); - expect(manager.isCurrentRuntimeEpoch(session.id, firstEpoch!)).toBe(false); - expect(manager.acceptsIngestRuntimeEpoch(session.id, firstEpoch!)).toBe( + expect(submitted).toEqual({ accepted: true, phase: "enter-written" }); + expect(await manager.killIfRuntime(session.id, "foreign-runtime")).toBe( false, ); - expect(manager.isCurrentRuntimeEpoch(session.id, secondEpoch!)).toBe(true); - expect(manager.acceptsIngestRuntimeEpoch(session.id, secondEpoch!)).toBe( - true, - ); - - manager.setReady(session.id, firstEpoch!); - expect(manager.get(session.id)?.ready).toBe(false); - expect( - await manager.setAgentSessionId( - session.id, - "provider-shared", - "resume", - firstEpoch!, - ), - ).toBe(false); - - manager.setReady(session.id, secondEpoch!); - expect(manager.get(session.id)?.ready).toBe(true); - expect( - await manager.setAgentSessionId( - session.id, - "provider-shared", - "resume", - secondEpoch!, - ), - ).toBe(true); - }); + expect(spawns[0]!.pty.kill).not.toHaveBeenCalled(); - it("fences transcript identity state to the exact live runtime epoch", async () => { - const { manager } = makeManager({ - adapter: createFakeAdapter({ eventSource: "transcript-tail" }), - ingestCredentials: new IngestCredentialRegistry( - () => "token-a", - () => "epoch-a", - ), + spawns[0]!.pty.write.mockImplementationOnce(() => { + throw new Error("ambiguous write"); }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", + const ambiguous = await manager.submitInputTracked( + session.id, + "Retry-sensitive task", + ); + expect(ambiguous).toMatchObject({ + accepted: false, + phase: "text-staged", + error: expect.any(Error), }); - - expect(manager.getAdapterIdentityState(session.id, "epoch-a")).toBe("pending"); - expect(manager.setAdapterIdentityState(session.id, "stale-epoch", "ready")).toBe(false); - expect(manager.setAdapterIdentityState(session.id, "epoch-a", "ready")).toBe(true); - expect(manager.getAdapterIdentityState(session.id, "epoch-a")).toBe("ready"); }); - it("does not publish a PTY when the runtime epoch transition fails", async () => { + + it("closes PTY admission before shutdown and rejects creates and resumes", async () => { + let releaseLaunchOptions!: () => void; + const launchOptionsReady = new Promise((resolve) => { + releaseLaunchOptions = resolve; + }); const spawnPty = vi.fn(() => { return createFakePty().pty as unknown as ReturnType; }); - const transition = vi.fn( - async (_session: HarnessSession, _runtimeEpoch: string | null) => { - throw new Error("durable epoch retirement failed"); - }, - ); const { manager } = makeManager({ spawnPty, - onRuntimeEpochTransition: transition, + buildLaunchOpts: async () => { + await launchOptionsReady; + return { prompt: "not-used-by-interactive-launch" }; + }, }); - await expect( - manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), - ).rejects.toThrow("durable epoch retirement failed"); + const creating = manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.beginShutdown(); + releaseLaunchOptions(); - expect(transition).toHaveBeenCalledOnce(); - expect(transition.mock.calls[0]?.[1]).toEqual(expect.any(String)); + await expect(creating).rejects.toBeInstanceOf(SessionManagerClosingError); expect(spawnPty).not.toHaveBeenCalled(); expect(manager.list()).toEqual([ - expect.objectContaining({ status: "exited", ready: false }), + expect.objectContaining({ status: "exited" }), ]); - expect(manager.isLive(manager.list()[0]!.id)).toBe(false); + await expect( + manager.create({ cwd: "/tmp/second", harness: "claude-code" }), + ).rejects.toBeInstanceOf(SessionManagerClosingError); + await expect(manager.resume(manager.list()[0]!.id)).rejects.toBeInstanceOf( + SessionManagerClosingError, + ); }); - it("owns a colour-capable PTY environment instead of inheriting launcher suppression", async () => { - const previous = { - NO_COLOR: process.env.NO_COLOR, - FORCE_COLOR: process.env.FORCE_COLOR, - TERM: process.env.TERM, - COLORTERM: process.env.COLORTERM, - }; - process.env.NO_COLOR = "1"; - process.env.FORCE_COLOR = "0"; - process.env.TERM = "dumb"; - process.env.COLORTERM = ""; - try { - const capturedEnvs: Record[] = []; - const spawnPty: PtySpawnFn = (_file, _args, options) => { - capturedEnvs.push(options.env ?? {}); - return createFakePty().pty as unknown as ReturnType; - }; - const { manager } = makeManager({ spawnPty }); - await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + it("classifies raw input on a recognized trust screen without blocking the user's bytes", async () => { + const onTerminalInput = vi.fn(); + const { manager, spawns } = makeManager({ + onTerminalInput, + adapter: createFakeAdapter({ + detectBlockingPrompt: (output) => output.includes("Do you trust"), + }), + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + spawns[0]?.emitData("Do you trust the files in this folder?\r\n"); - expect(capturedEnvs[0]?.NO_COLOR).toBeUndefined(); - expect(capturedEnvs[0]?.FORCE_COLOR).toBeUndefined(); - expect(capturedEnvs[0]?.TERM).toBe("xterm-256color"); - expect(capturedEnvs[0]?.COLORTERM).toBe("truecolor"); - } finally { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - } + expect(manager.write(session.id, "y\r")).toBe(true); + expect(onTerminalInput).toHaveBeenCalledWith(session.id, { + blockingPrompt: true, + runtimeEpoch: manager.getRuntimeEpoch(session.id), + }); + expect(spawns[0]?.pty.write).toHaveBeenCalledWith("y\r"); + }); + + + it("claims the starting lifecycle before asynchronous resume config is regenerated", async () => { + const resumeConfig = deferred(); + let buildCount = 0; + const buildLaunchOpts = vi.fn(async () => { + buildCount += 1; + if (buildCount === 2) await resumeConfig.promise; + return {}; + }); + const { manager, adapter, spawns } = makeManager({ buildLaunchOpts }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + await manager.setAgentSessionId(session.id, "agent-uuid-1"); + spawns[0]?.emitExit(0); + await manager.flush(); + + const statuses: HarnessSession["status"][] = []; + const unsubscribe = manager.onStatusChange((updated) => { + if (updated.id === session.id) statuses.push(updated.status); + }); + const resumed = manager.resume(session.id); + await vi.waitFor(() => expect(buildCount).toBe(2)); + expect(manager.get(session.id)?.status).toBe("starting"); + + // Bootstrap exit bookkeeping can finish after kill() resolves. Its metadata + // update must observe the claimed resume lifecycle, so server cleanup does + // not remove the config currently being regenerated. + await manager.setProjectBootstrapMetadata(session.id, { + projectId: "project-1", + userId: "user-1", + targetSessionId: session.id, + bootstrap: { status: "skipped", reason: "user-proceeded" }, + queuedInputIds: [], + }); + expect(statuses.at(-1)).toBe("starting"); + expect(adapter.resume).not.toHaveBeenCalled(); + + resumeConfig.resolve(); + await resumed; + unsubscribe(); + expect(manager.get(session.id)?.status).toBe("running"); }); - it("unsets env vars the adapter's SpawnSpec maps to null", async () => { - process.env["HARNESS_TEST_UNSET_ME"] = "should-be-removed"; - const capturedEnvs: Record[] = []; - const adapter = createFakeAdapter({ - launch: vi.fn( - (opts): SpawnSpec => ({ - command: "fake-claude", - args: [], - env: { HARNESS_TEST_UNSET_ME: null }, - cwd: opts.cwd, - }), - ), + + it("revalidates create scope after the lazy PTY loader settles and before admission", async () => { + const loader = deferred(); + const loadSpawnPty = vi.fn(() => loader.promise); + const spawnPty = vi.fn( + () => createFakePty().pty as unknown as ReturnType, + ); + let inScope = true; + const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => + inScope + ? { projectId: "project-1", userId: "user-1", sessionId } + : undefined, + ); + const onAgentMapSessionExit = vi.fn(); + const { manager, adapter } = makeManager({ + loadSpawnPty, + resolveAgentMapIdentity, + onAgentMapSessionExit, }); - const spawnPty: PtySpawnFn = (_file, _args, options) => { - capturedEnvs.push(options.env ?? {}); - const fake = createFakePty(); - return fake.pty as unknown as ReturnType; - }; - const { manager } = makeManager({ adapter, spawnPty }); - await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); - expect(capturedEnvs[0]?.["HARNESS_TEST_UNSET_ME"]).toBeUndefined(); - delete process.env["HARNESS_TEST_UNSET_ME"]; + const creating = manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + await vi.waitFor(() => expect(loadSpawnPty).toHaveBeenCalledOnce()); + expect(resolveAgentMapIdentity).toHaveBeenCalledOnce(); + inScope = false; + loader.resolve(spawnPty); + + await expect(creating).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); + expect(resolveAgentMapIdentity).toHaveBeenCalledTimes(2); + expect(adapter.launch).toHaveBeenCalledOnce(); + expect(spawnPty).not.toHaveBeenCalled(); + expect(manager.list()).toEqual([ + expect.objectContaining({ status: "exited" }), + ]); + expect(onAgentMapSessionExit).toHaveBeenCalledWith(manager.list()[0]!.id); }); - /** - * The desktop host pins `ESBUILD_BINARY_PATH` at an esbuild binary outside - * app.asar, because it cannot exec one from inside the archive. That pin must - * not reach the agent: this loop copies the WHOLE parent environment into the - * pty, so the agent — and everything the agent spawns in the user's own repo — - * inherited a pin to OUR esbuild build. Any project on a different esbuild - * version (vite, vitest, tsup, tsx, astro…) then dies with - * `Cannot start service: Host version "0.25.12" does not match binary version - * "0.28.1"` on a project that builds fine outside the app. - */ - it("never leaks the host's ESBUILD_BINARY_PATH pin into the agent's environment", async () => { - process.env["ESBUILD_BINARY_PATH"] = - "/app/resources/app.asar.unpacked/node_modules/@esbuild/linux-x64/bin/esbuild"; - const capturedEnvs: Record[] = []; - const spawnPty: PtySpawnFn = (_file, _args, options) => { - capturedEnvs.push(options.env ?? {}); - const fake = createFakePty(); - return fake.pty as unknown as ReturnType; - }; - const { manager } = makeManager({ spawnPty }); - await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); - expect(capturedEnvs[0]?.["ESBUILD_BINARY_PATH"]).toBeUndefined(); - // Everything else still comes through — this is a targeted strip, not a - // switch to a clean environment (the agent needs PATH, HOME, the lot). - expect(capturedEnvs[0]?.["PATH"]).toBe(process.env["PATH"]); - delete process.env["ESBUILD_BINARY_PATH"]; - }); + it("revalidates resume scope after the lazy PTY loader settles and before admission", async () => { + const resumeLoader = deferred(); + const fakePty = createFakePty(); + const spawnPty = vi.fn( + () => fakePty.pty as unknown as ReturnType, + ); + let loadCount = 0; + const loadSpawnPty = vi.fn(async () => { + loadCount += 1; + return loadCount === 1 ? spawnPty : resumeLoader.promise; + }); + let inScope = true; + const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => + inScope + ? { projectId: "project-1", userId: "user-1", sessionId } + : undefined, + ); + const onAgentMapSessionExit = vi.fn(); + const { manager, adapter } = makeManager({ + loadSpawnPty, + resolveAgentMapIdentity, + onAgentMapSessionExit, + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + await manager.setAgentSessionId(session.id, "provider-project-session"); + fakePty.emitExit(0); + await manager.flush(); + + const resuming = manager.resume(session.id); + await vi.waitFor(() => expect(loadSpawnPty).toHaveBeenCalledTimes(2)); + expect(resolveAgentMapIdentity).toHaveBeenCalledTimes(4); + inScope = false; + resumeLoader.resolve(spawnPty); + + await expect(resuming).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); + expect(resolveAgentMapIdentity).toHaveBeenCalledTimes(5); + expect(adapter.resume).toHaveBeenCalledOnce(); + expect(spawnPty).toHaveBeenCalledOnce(); + expect(manager.get(session.id)).toMatchObject({ + id: session.id, + status: "exited", + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: session.id, + }, + }); + expect(onAgentMapSessionExit).toHaveBeenCalledWith(session.id); + }); + + + it("rotates the authoritative runtime epoch and rejects stale lifecycle signals after resume", async () => { + const tokens = ["token-a", "token-b"]; + const epochs = ["epoch-a", "epoch-b"]; + const { manager, spawns } = makeManager({ + ingestCredentials: new IngestCredentialRegistry( + () => tokens.shift()!, + () => epochs.shift()!, + ), + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + const firstEpoch = manager.getRuntimeEpoch(session.id); + expect(firstEpoch).toBe("epoch-a"); + expect( + await manager.setAgentSessionId( + session.id, + "provider-shared", + "startup", + firstEpoch!, + ), + ).toBe(true); + + spawns[0]!.emitExit(0); + await manager.flush(); + expect(manager.isCurrentRuntimeEpoch(session.id, firstEpoch!)).toBe(false); + expect(manager.acceptsIngestRuntimeEpoch(session.id, firstEpoch!)).toBe( + true, + ); + manager.setReady(session.id, firstEpoch!); + expect(manager.get(session.id)?.ready).toBe(false); + + await manager.resume(session.id); + const secondEpoch = manager.getRuntimeEpoch(session.id); + expect(secondEpoch).toBe("epoch-b"); + expect(secondEpoch).not.toBe(firstEpoch); + expect(manager.isCurrentRuntimeEpoch(session.id, firstEpoch!)).toBe(false); + expect(manager.acceptsIngestRuntimeEpoch(session.id, firstEpoch!)).toBe( + false, + ); + expect(manager.isCurrentRuntimeEpoch(session.id, secondEpoch!)).toBe(true); + expect(manager.acceptsIngestRuntimeEpoch(session.id, secondEpoch!)).toBe( + true, + ); + + manager.setReady(session.id, firstEpoch!); + expect(manager.get(session.id)?.ready).toBe(false); + expect( + await manager.setAgentSessionId( + session.id, + "provider-shared", + "resume", + firstEpoch!, + ), + ).toBe(false); + + manager.setReady(session.id, secondEpoch!); + expect(manager.get(session.id)?.ready).toBe(true); + expect( + await manager.setAgentSessionId( + session.id, + "provider-shared", + "resume", + secondEpoch!, + ), + ).toBe(true); + }); + + + it("fences transcript identity state to the exact live runtime epoch", async () => { + const { manager } = makeManager({ + adapter: createFakeAdapter({ eventSource: "transcript-tail" }), + ingestCredentials: new IngestCredentialRegistry( + () => "token-a", + () => "epoch-a", + ), + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + + expect(manager.getAdapterIdentityState(session.id, "epoch-a")).toBe("pending"); + expect(manager.setAdapterIdentityState(session.id, "stale-epoch", "ready")).toBe(false); + expect(manager.setAdapterIdentityState(session.id, "epoch-a", "ready")).toBe(true); + expect(manager.getAdapterIdentityState(session.id, "epoch-a")).toBe("ready"); + }); + + + it("does not publish a PTY when the runtime epoch transition fails", async () => { + const spawnPty = vi.fn(() => { + return createFakePty().pty as unknown as ReturnType; + }); + const transition = vi.fn( + async (_session: HarnessSession, _runtimeEpoch: string | null) => { + throw new Error("durable epoch retirement failed"); + }, + ); + const { manager } = makeManager({ + spawnPty, + onRuntimeEpochTransition: transition, + }); + + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).rejects.toThrow("durable epoch retirement failed"); + + expect(transition).toHaveBeenCalledOnce(); + expect(transition.mock.calls[0]?.[1]).toEqual(expect.any(String)); + expect(spawnPty).not.toHaveBeenCalled(); + expect(manager.list()).toEqual([ + expect.objectContaining({ status: "exited", ready: false }), + ]); + expect(manager.isLive(manager.list()[0]!.id)).toBe(false); + }); + + describe("tracked runtime input", () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + + + it("lets durable API input cancel only a staged background turn", async () => { + const onTerminalInput = vi.fn(); + const { manager, spawns } = makeManager({ onTerminalInput }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setReady(session.id); + + const background = manager.submitInput( + session.id, + "automatic map bootstrap", + true, + undefined, + true, + ); + expect(spawns[0]?.pty.write).toHaveBeenCalledWith( + "automatic map bootstrap", + ); + + expect(manager.preemptBackgroundInput(session.id)).toBe(true); + expect(manager.preemptBackgroundInput(session.id)).toBe(false); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); + expect(onTerminalInput).not.toHaveBeenCalled(); + + const assertion = expect(background).rejects.toMatchObject({ + code: "SESSION_BACKGROUND_INPUT_PREEMPTED", + staged: true, + }); + await vi.advanceTimersByTimeAsync(300); + await assertion; + expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); + }); + + + + it("waits for the durable pre-write hook before crossing the PTY boundary", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setReady(session.id); + const phase = deferred(); + const beforeFirstWrite = vi.fn(() => phase.promise); + + const submitting = manager.submitInput( + session.id, + "durable turn", + true, + undefined, + false, + { beforeFirstWrite, canWriteNow: () => true }, + ); + await Promise.resolve(); + expect(beforeFirstWrite).toHaveBeenCalledOnce(); + expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); + + phase.resolve(); + await vi.advanceTimersByTimeAsync(300); + await expect(submitting).resolves.toBe(true); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "durable turn"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\r"); + }); + + + + it("records positive not-submitted evidence when the PTY rejects text before Enter", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setReady(session.id); + const beforeFirstWrite = vi.fn(async () => {}); + const onNotSubmitted = vi.fn(async () => {}); + spawns[0]?.pty.write.mockImplementationOnce(() => { + throw new Error("PTY rejected text"); + }); + + await expect( + manager.submitInput(session.id, "durable turn", true, undefined, true, { + beforeFirstWrite, + onNotSubmitted, + }), + ).rejects.toThrow("PTY rejected text"); + + expect(beforeFirstWrite).toHaveBeenCalledOnce(); + expect(onNotSubmitted).toHaveBeenCalledOnce(); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "durable turn"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); + expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); + }); + + + + it("withholds not-submitted evidence when a partial text line cannot be cleared", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setReady(session.id); + const onNotSubmitted = vi.fn(async () => {}); + spawns[0]?.pty.write + .mockImplementationOnce(() => { + throw new Error("PTY rejected text after a possible prefix"); + }) + .mockImplementationOnce(() => { + throw new Error("PTY also rejected line cleanup"); + }); + + await expect( + manager.submitInput(session.id, "durable turn", true, undefined, true, { + onNotSubmitted, + }), + ).rejects.toThrow("PTY rejected text after a possible prefix"); + + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "durable turn"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); + expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); + expect(onNotSubmitted).not.toHaveBeenCalled(); + }); + + + + it("closes a partial bracketed paste before proving the composer line was cleared", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setReady(session.id); + spawns[0]?.emitData("\x1b[?2004h"); + const onNotSubmitted = vi.fn(async () => {}); + spawns[0]?.pty.write.mockImplementationOnce(() => { + throw new Error("partial bracketed paste"); + }); + + await expect( + manager.submitInput(session.id, "line one\nline two", true, undefined, true, { + onNotSubmitted, + }), + ).rejects.toThrow("partial bracketed paste"); + + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith( + 1, + "\x1b[200~line one\nline two\x1b[201~", + ); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x1b[201~"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(3, "\x15"); + expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); + expect(onNotSubmitted).toHaveBeenCalledOnce(); + }); + + + + it("withholds retry proof when a partial bracketed paste cannot be closed", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setReady(session.id); + spawns[0]?.emitData("\x1b[?2004h"); + const onNotSubmitted = vi.fn(async () => {}); + spawns[0]?.pty.write + .mockImplementationOnce(() => { + throw new Error("partial bracketed paste"); + }) + .mockImplementationOnce(() => { + throw new Error("paste closer rejected"); + }); + + await expect( + manager.submitInput(session.id, "unsafe paste", true, undefined, true, { + onNotSubmitted, + }), + ).rejects.toThrow("partial bracketed paste"); + + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x1b[201~"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(3, "\x15"); + expect(onNotSubmitted).not.toHaveBeenCalled(); + }); + + + + it("withholds retry proof when line cleanup fails after closing a partial paste", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setReady(session.id); + spawns[0]?.emitData("\x1b[?2004h"); + const onNotSubmitted = vi.fn(async () => {}); + spawns[0]?.pty.write + .mockImplementationOnce(() => { + throw new Error("partial bracketed paste"); + }) + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => { + throw new Error("line cleanup rejected"); + }); + + await expect( + manager.submitInput(session.id, "unsafe paste", true, undefined, true, { + onNotSubmitted, + }), + ).rejects.toThrow("partial bracketed paste"); + + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x1b[201~"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(3, "\x15"); + expect(onNotSubmitted).not.toHaveBeenCalled(); + }); + + + + it("blocks later raw input on a poisoned composer until a reset succeeds", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setReady(session.id); + let call = 0; + spawns[0]?.pty.write.mockImplementation(() => { + call += 1; + if (call <= 3) throw new Error(`injected write failure ${call}`); + }); + + await expect( + manager.submitInput(session.id, "partial A", true, undefined, true), + ).rejects.toThrow("injected write failure 1"); + expect(() => manager.write(session.id, "must not append to A")).toThrow( + SessionInputIsolationError, + ); + expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith( + "must not append to A", + ); + + expect(manager.write(session.id, "safe after reset")).toBe(true); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(4, "\x15"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith( + 5, + "safe after reset", + ); + }); + + + + it("uses Ctrl-C only as a fallback after a complete staged paste", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setReady(session.id); + spawns[0]?.emitData("\x1b[?2004h"); + const onNotSubmitted = vi.fn(async () => {}); + const background = manager.submitInput( + session.id, + "complete staged paste", + true, + undefined, + true, + { onNotSubmitted }, + ); + const rejected = expect(background).rejects.toBeInstanceOf( + SessionBackgroundInputPreemptedError, + ); + spawns[0]?.pty.write.mockImplementationOnce(() => { + throw new Error("Ctrl-U rejected"); + }); + + expect(manager.preemptBackgroundInput(session.id)).toBe(true); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(3, "\x03"); + await vi.advanceTimersByTimeAsync(300); + await rejected; + expect(onNotSubmitted).toHaveBeenCalledOnce(); + }); + + - describe("awaitable kill — liveness-fallback resolution", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); + it("poisons arbitrary submit:false text after a partial write", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setReady(session.id); + spawns[0]?.pty.write.mockImplementationOnce(() => { + throw new Error("partial draft"); + }); - afterEach(() => { - vi.useRealTimers(); + await expect( + manager.submitInput(session.id, "multi-byte draft", false), + ).rejects.toThrow("partial draft"); + expect(manager.write(session.id, "new raw input")).toBe(true); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(3, "new raw input"); }); - it("REGRESSION: kill() resolves via the synthesis/liveness path when node-pty's onExit never fires (missed-exit bug)", async () => { - // Simulate the node-pty missed-exit bug: the pty's kill() is called - // but its onExit listeners are never invoked — the OS process is gone - // (isPidAlive returns false) but the event never arrives. kill() must - // still resolve within the escalation window via the synthesized exit. - let pidAlive = true; - const { manager, spawns } = makeManager({ - fakePid: 9999, - isPidAlive: () => pidAlive, - }); + + + it("does not claim not-submitted proof when the Enter write is ambiguous", async () => { + const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); - expect(session.status).toBe("running"); + manager.setReady(session.id); + const onNotSubmitted = vi.fn(async () => {}); + spawns[0]?.pty.write + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => { + throw new Error("PTY Enter outcome unknown"); + }); - // Confirm the pty exists and won't emit onExit on its own — the exit - // listeners on the fake pty exist, but we never call emitExit(). - expect(spawns[0]?.pty.kill).not.toHaveBeenCalled(); + const submission = manager.submitInput( + session.id, + "durable turn", + true, + undefined, + true, + { onNotSubmitted }, + ); + const rejected = expect(submission).rejects.toThrow( + "PTY Enter outcome unknown", + ); + await vi.advanceTimersByTimeAsync(300); + await rejected; - const killPromise = manager.kill(session.id); - // kill() sent SIGTERM (the initial pty.kill() with no signal arg). - expect(spawns[0]?.pty.kill).toHaveBeenCalledTimes(1); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "durable turn"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\r"); + expect(onNotSubmitted).not.toHaveBeenCalled(); + }); - // The process is now "dead" at the OS level but node-pty hasn't fired. - pidAlive = false; - // Advance past KILL_ESCALATION_MS (2000ms): the escalation fires and - // checks isPidAlive. Since the process is already dead, it skips SIGKILL - // and schedules the KILL_ESCALATION_CONFIRM_MS (500ms) confirm window. - await vi.advanceTimersByTimeAsync(2_000); - // Advance past KILL_ESCALATION_CONFIRM_MS: the confirm fires, sees - // isPidAlive=false, and calls markExited() → resolves handle.exited. - await vi.advanceTimersByTimeAsync(500); + it("clears staged text and never writes Enter when shutdown wins the final admission boundary", async () => { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + manager.setReady(session.id); + const finalAuthorization = deferred(); + const canWrite = vi + .fn<() => boolean | Promise>() + .mockReturnValueOnce(true) + .mockImplementationOnce(() => finalAuthorization.promise); - // The promise must now be resolved — await it to confirm. - expect(await killPromise).toBe(true); - expect(manager.get(session.id)?.status).toBe("exited"); + const submitting = manager.submitInput( + session.id, + "must not submit after shutdown", + true, + canWrite, + false, + { canWriteNow: () => true }, + ); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(spawns[0]?.pty.write).toHaveBeenCalledWith( + "must not submit after shutdown", + ); + await vi.advanceTimersByTimeAsync(300); + manager.beginShutdown(); + finalAuthorization.resolve(true); + + await expect(submitting).rejects.toMatchObject({ staged: true }); + expect(spawns[0]?.pty.write).toHaveBeenLastCalledWith("\x15"); + expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); }); - it("kill() resolves immediately via real onExit when node-pty fires before the escalation window", async () => { - const { manager, spawns } = makeManager({ - fakePid: 8888, - isPidAlive: () => false, - }); + + + it("lets raw user input preempt a staged background turn without combining either prompt", async () => { + const onTerminalInput = vi.fn(); + const { manager, spawns } = makeManager({ onTerminalInput }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); + manager.setReady(session.id); + + const background = manager.submitInput( + session.id, + "automatic map bootstrap", + true, + undefined, + true, + ); + expect(spawns[0]?.pty.write).toHaveBeenCalledTimes(1); + expect(spawns[0]?.pty.write).toHaveBeenCalledWith( + "automatic map bootstrap", + ); + + expect(manager.write(session.id, "implement the API now\r")).toBe(true); + expect(onTerminalInput).toHaveBeenCalledWith(session.id, { + blockingPrompt: false, + runtimeEpoch: manager.getRuntimeEpoch(session.id), + }); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\x15"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith( + 3, + "implement the API now\r", + ); + + const assertion = expect(background).rejects.toMatchObject({ + code: "SESSION_BACKGROUND_INPUT_PREEMPTED", + staged: true, + }); + await vi.advanceTimersByTimeAsync(300); + await assertion; + + expect(spawns[0]?.pty.write).toHaveBeenCalledTimes(3); + expect(spawns[0]?.pty.write).not.toHaveBeenCalledWith("\r"); + await expect(background).rejects.toBeInstanceOf( + SessionBackgroundInputPreemptedError, + ); + }); +}); + + + it.each([ + "prepareProjectSession", + "promptAppendix", + "buildLaunchOpts", + "adapter.launch", + ] as const)( + "releases project launch authority when create setup fails in $stage", + async (stage) => { + const setupError = new Error(`setup failed in ${stage}`); + const cleanupError = new Error("cleanup also failed"); + let claimedSessionId = ""; + const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => { + claimedSessionId = sessionId; + return { + projectId: "project-1", + userId: "user-1", + sessionId, + }; + }); + const prepareProjectSession = vi.fn(async () => { + if (stage === "prepareProjectSession") throw setupError; + return { + projectBootstrap: { + projectId: "project-1", + userId: "user-1", + targetSessionId: claimedSessionId, + bootstrap: { status: "pending" as const }, + queuedInputIds: [], + }, + }; + }); + const buildLaunchOpts = vi.fn(async () => { + if (stage === "buildLaunchOpts") throw setupError; + return {}; + }); + const adapter = createFakeAdapter({ + launch: vi.fn((opts) => { + if (stage === "adapter.launch") throw setupError; + return { + command: "fake-claude", + args: ["--launch"], + env: {}, + cwd: opts.cwd, + }; + }), + }); + const onAgentMapSessionExit = vi.fn(async () => { + throw cleanupError; + }); + const { manager, spawns } = makeManager({ + adapter, + resolveAgentMapIdentity, + prepareProjectSession, + buildLaunchOpts, + onAgentMapSessionExit, + }); + + await expect( + manager.create( + { cwd: "/tmp/proj", harness: "claude-code" }, + { + promptAppendix: () => { + if (stage === "promptAppendix") throw setupError; + return "bounded project context"; + }, + }, + ), + ).rejects.toBe(setupError); + + expect(onAgentMapSessionExit).toHaveBeenCalledOnce(); + expect(onAgentMapSessionExit).toHaveBeenCalledWith(claimedSessionId); + expect(manager.list()).toEqual([]); + expect(spawns).toEqual([]); + }, + ); + + + it("rejects a focused overlay when no project-agent identity resolves", async () => { + const adapter = createFakeAdapter(); + const { manager, spawns } = makeManager({ adapter }); + + await expect(manager.create( + { cwd: "/tmp/proj", harness: "claude-code" }, + { focusedContext: () => "bounded focused data" as FocusedSessionContextProjection }, + )).rejects.toThrow("Focused project context requires a project-agent identity"); + expect(adapter.launch).not.toHaveBeenCalled(); + expect(spawns).toEqual([]); + expect(manager.list()).toEqual([]); + }); - const killPromise = manager.kill(session.id); - // Drive the real onExit — this fires before the escalation timer. - spawns[0]?.emitExit(0); + it("rejects a focused overlay on resume when no project-agent identity resolves", async () => { + const adapter = createFakeAdapter(); + const { manager, spawns } = makeManager({ adapter }); + const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + await manager.setAgentSessionId(session.id, "provider-session"); + spawns[0]!.emitExit(0); + await manager.flush(); - // Promise should resolve immediately (the real path, not the synthesis path). - expect(await killPromise).toBe(true); - expect(manager.get(session.id)?.status).toBe("exited"); - expect(manager.get(session.id)?.exitCode).toBe(0); - }); + await expect(manager.resume(session.id, { + focusedContext: "bounded focused data" as FocusedSessionContextProjection, + })).rejects.toThrow("Focused project context requires a project-agent identity"); + expect(adapter.resume).not.toHaveBeenCalled(); + expect(manager.get(session.id)?.status).toBe("exited"); + }); - it("kill() escalates to SIGKILL when the process survives SIGTERM, then resolves once it dies", async () => { - // Process ignores SIGTERM (stubborn process), but dies after SIGKILL. - let pidAlive = true; + it.each(["buildLaunchOpts", "adapter.resume"] as const)( + "releases project launch authority when resume setup fails in $stage", + async (stage) => { + const setupError = new Error(`resume setup failed in ${stage}`); + const adapter = createFakeAdapter({ + resume: vi.fn((agentSessionId, opts) => { + if (stage === "adapter.resume") throw setupError; + return { + command: "fake-claude", + args: ["--resume", agentSessionId], + env: {}, + cwd: opts.cwd, + }; + }), + }); + const buildLaunchOpts = vi.fn(async () => { + if ( + stage === "buildLaunchOpts" && + buildLaunchOpts.mock.calls.length > 1 + ) { + throw setupError; + } + return {}; + }); + const onAgentMapSessionExit = vi.fn(async () => { + throw new Error("cleanup also failed"); + }); + const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => ({ + projectId: "project-1", + userId: "user-1", + sessionId, + })); const { manager, spawns } = makeManager({ - fakePid: 7777, - isPidAlive: () => pidAlive, + adapter, + buildLaunchOpts, + onAgentMapSessionExit, + resolveAgentMapIdentity, }); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code", }); + await manager.setAgentSessionId(session.id, "provider-session"); + spawns[0]!.emitExit(0); + await vi.waitFor(() => { + expect(onAgentMapSessionExit).toHaveBeenCalledWith(session.id); + }); + onAgentMapSessionExit.mockClear(); + const beforeResume = structuredClone(manager.get(session.id)); - const killPromise = manager.kill(session.id); - expect(spawns[0]?.pty.kill).toHaveBeenCalledTimes(1); // initial SIGTERM (no arg) + await expect(manager.resume(session.id)).rejects.toBe(setupError); - // Advance to KILL_ESCALATION_MS: process is still alive → SIGKILL sent. - await vi.advanceTimersByTimeAsync(2_000); - expect(spawns[0]?.pty.kill).toHaveBeenCalledTimes(2); // SIGKILL - expect(spawns[0]?.pty.kill).toHaveBeenLastCalledWith("SIGKILL"); + expect(onAgentMapSessionExit).toHaveBeenCalledOnce(); + expect(onAgentMapSessionExit).toHaveBeenCalledWith(session.id); + expect(manager.get(session.id)).toEqual(beforeResume); + expect(spawns).toHaveLength(1); + }, + ); - // Now the process dies (SIGKILL lands) — simulate via emitExit. - pidAlive = false; - spawns[0]?.emitExit(137); // SIGKILL exit code - expect(await killPromise).toBe(true); - expect(manager.get(session.id)?.exitCode).toBe(137); + it("derives one neutral project identity for create/resume and revokes it on exit", async () => { + const buildLaunchOpts = vi.fn(async () => ({})); + const onAgentMapSessionExit = vi.fn(); + const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => ({ + projectId: "project-1", + userId: "user-1", + sessionId, + })); + const { manager, spawns } = makeManager({ + buildLaunchOpts, + resolveAgentMapIdentity, + onAgentMapSessionExit, }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + expect(session.agentMapIdentity).toEqual({ + projectId: "project-1", + userId: "user-1", + sessionId: session.id, + }); + expect(buildLaunchOpts).toHaveBeenLastCalledWith( + session.id, + expect.anything(), + expect.objectContaining({ agentMapIdentity: session.agentMapIdentity }), + ); + await manager.setAgentSessionId(session.id, "agent-uuid-map"); + spawns[0]?.emitExit(0); + await manager.flush(); + expect(onAgentMapSessionExit).toHaveBeenCalledWith(session.id); + await manager.resume(session.id); + expect(buildLaunchOpts).toHaveBeenLastCalledWith( + session.id, + expect.anything(), + expect.objectContaining({ + resume: true, + agentMapIdentity: session.agentMapIdentity, + }), + ); + }); - it("killAll() resolves once all sessions are confirmed dead, even when exits come at different times", async () => { - const { manager, spawns } = makeManager({ - fakePid: 6666, - isPidAlive: () => false, - }); - const a = await manager.create({ cwd: "/tmp/a", harness: "claude-code" }); - const b = await manager.create({ cwd: "/tmp/b", harness: "claude-code" }); - - const killAllPromise = manager.killAll(); - let resolved = false; - void killAllPromise.then(() => { - resolved = true; - }); - - // Neither has exited yet — killAll() should not be resolved. - await vi.advanceTimersByTimeAsync(0); - expect(resolved).toBe(false); - - // First session exits. - spawns[0]?.emitExit(0); - await vi.advanceTimersByTimeAsync(0); - // Second session still alive — not resolved yet. - expect(resolved).toBe(false); - // Second session exits. - spawns[1]?.emitExit(0); - await killAllPromise; - expect(resolved).toBe(true); - expect(manager.get(a.id)?.status).toBe("exited"); - expect(manager.get(b.id)?.status).toBe("exited"); + it("publishes a claimed first project session before a concurrent ordinary session", async () => { + const firstLaunch = deferred(); + let buildCount = 0; + const prepareProjectSession = vi.fn(async (identity) => + prepareProjectSession.mock.calls.length === 1 + ? { + initialTitle: "Plan Agents", + projectBootstrap: { + projectId: identity.projectId, + userId: identity.userId, + targetSessionId: identity.sessionId, + bootstrap: { status: "pending" as const }, + queuedInputIds: [], + }, + } + : {}, + ); + const { manager, adapter } = makeManager({ + resolveAgentMapIdentity: async (sessionId) => ({ + projectId: "project-1", + userId: "user-1", + sessionId, + }), + prepareProjectSession, + buildLaunchOpts: async () => { + buildCount += 1; + if (buildCount === 1) await firstLaunch.promise; + return {}; + }, }); - it("killAll() resolves via liveness synthesis when node-pty misses exits for all sessions", async () => { - // Both ptys swallow their onExit events — killAll() must still resolve - // via the missed-exit synthesis path within the escalation window. - let pidAlive = true; - const { manager } = makeManager({ - fakePid: 5555, - isPidAlive: () => pidAlive, - }); - await manager.create({ cwd: "/tmp/a", harness: "claude-code" }); - await manager.create({ cwd: "/tmp/b", harness: "claude-code" }); + const automatic = manager.create( + { cwd: "/tmp/proj", harness: "claude-code" }, + { requireProjectBootstrapClaim: true }, + ); + await vi.waitFor(() => expect(buildCount).toBe(1)); + const explicit = manager.create({ + cwd: "/tmp/proj/packages/worker", + harness: "claude-code", + initialUserInputPending: true, + }); + await Promise.resolve(); + expect(prepareProjectSession).toHaveBeenCalledOnce(); + expect(adapter.launch).not.toHaveBeenCalled(); - const killAllPromise = manager.killAll(); - let resolved = false; - void killAllPromise.then(() => { - resolved = true; - }); + firstLaunch.resolve(); + const [first, second] = await Promise.all([automatic, explicit]); - // Mark the OS processes as gone — liveness check will confirm this. - pidAlive = false; + expect(first.title).toBe("Plan Agents"); + expect(first.projectBootstrap?.targetSessionId).toBe(first.id); + expect(second.title).toBe("worker"); + expect(second.projectBootstrap).toBeUndefined(); + expect(manager.list().map((session) => session.id)).toEqual([ + first.id, + second.id, + ]); + expect(adapter.launch).toHaveBeenCalledTimes(2); + }); - // Advance past the full escalation window. - await vi.advanceTimersByTimeAsync(2_000 + 500); - await killAllPromise; - expect(resolved).toBe(true); + it("cancels only an automatic create that loses its bootstrap claim", async () => { + const firstLaunch = deferred(); + let buildCount = 0; + const prepareProjectSession = vi.fn(async (identity) => + prepareProjectSession.mock.calls.length === 1 + ? { + initialTitle: "Plan Agents", + projectBootstrap: { + projectId: identity.projectId, + userId: identity.userId, + targetSessionId: identity.sessionId, + bootstrap: { + status: "skipped" as const, + reason: "user-proceeded" as const, + }, + queuedInputIds: [], + }, + } + : {}, + ); + const onAgentMapSessionExit = vi.fn(); + const { manager, adapter, spawns } = makeManager({ + resolveAgentMapIdentity: async (sessionId) => ({ + projectId: "project-1", + userId: "user-1", + sessionId, + }), + prepareProjectSession, + onAgentMapSessionExit, + buildLaunchOpts: async () => { + buildCount += 1; + if (buildCount === 1) await firstLaunch.promise; + return {}; + }, }); - it("REGRESSION: kill() resolves unconditionally in the confirm window even when isPidAlive stays true (EPERM zombie after SIGKILL)", async () => { - // An EPERM zombie: process.kill(pid, 0) still returns true (EPERM means - // "exists but can't be signalled") even after SIGKILL. The old confirm- - // timer guarded on `!isPidAlive(pid)` — that would leave handle.exited - // pending forever. The fixed confirm callback synthesizes markExited() - // unconditionally (SIGKILL was already sent; the session is over). - const { manager, spawns } = makeManager({ - fakePid: 4444, - // Always "alive" — simulates an EPERM zombie that survives all probes. - isPidAlive: () => true, - }); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - - const killPromise = manager.kill(session.id); - // Initial signal sent. - expect(spawns[0]?.pty.kill).toHaveBeenCalledTimes(1); - - // Advance past KILL_ESCALATION_MS: isPidAlive returns true → SIGKILL sent. - await vi.advanceTimersByTimeAsync(2_000); - expect(spawns[0]?.pty.kill).toHaveBeenCalledTimes(2); - expect(spawns[0]?.pty.kill).toHaveBeenLastCalledWith("SIGKILL"); - - // Advance past KILL_ESCALATION_CONFIRM_MS: the confirm callback fires. - // isPidAlive is still true (zombie) but the fix synthesizes unconditionally. - await vi.advanceTimersByTimeAsync(500); + const explicit = manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + initialUserInputPending: true, + }); + await vi.waitFor(() => expect(buildCount).toBe(1)); + const automatic = manager.create( + { cwd: "/tmp/proj", harness: "claude-code" }, + { requireProjectBootstrapClaim: true }, + ); + firstLaunch.resolve(); - expect(await killPromise).toBe(true); - expect(manager.get(session.id)?.status).toBe("exited"); + const first = await explicit; + await expect(automatic).rejects.toBeInstanceOf( + ProjectBootstrapClaimUnavailableError, + ); + expect(first).toMatchObject({ + title: "Plan Agents", + projectBootstrap: { + targetSessionId: first.id, + bootstrap: { status: "skipped", reason: "user-proceeded" }, + }, }); + expect(manager.list()).toEqual([first]); + expect(adapter.launch).toHaveBeenCalledOnce(); + expect(spawns).toHaveLength(1); + expect(onAgentMapSessionExit).toHaveBeenCalledOnce(); }); - describe("ExternalHarnessError — real-path (no mocks)", () => { - /** - * A sessions.json entry with harness="conductor" can appear via an earlier - * build, a hand-edited file, or a future import. These tests verify the - * real production code path: when getAdapter() / submitInput() encounter - * an external-mode harness id they surface HARNESS_EXTERNAL (409) rather - * than AdapterNotFoundError or a silent false. - * - * We cast "conductor" as HarnessKind in registerHistorical() to simulate - * a persisted record that predates the typed enum. - */ - - it("resume() throws ExternalHarnessError for a session persisted with harness='conductor'", async () => { - const { manager } = makeManager(); - // Simulate a session record that arrived from disk or an earlier build. - const session = await manager.registerHistorical({ - agentSessionId: "agent-session-abc", - harness: "conductor" as HarnessKind, - cwd: "/tmp/conductor-proj", - title: "conductor-proj", - lastActiveAt: new Date().toISOString(), - }); + it("drains project create queues without preparing a waiter after shutdown", async () => { + const firstLaunch = deferred(); + let buildCount = 0; + const prepareProjectSession = vi.fn(async () => ({})); + const { manager, adapter } = makeManager({ + resolveAgentMapIdentity: async (sessionId) => ({ + projectId: "project-1", + userId: "user-1", + sessionId, + }), + prepareProjectSession, + buildLaunchOpts: async () => { + buildCount += 1; + if (buildCount === 1) await firstLaunch.promise; + return {}; + }, + }); - await expect(manager.resume(session.id)).rejects.toThrow( - ExternalHarnessError, - ); + const first = manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + await vi.waitFor(() => expect(buildCount).toBe(1)); + const queued = manager.create({ + cwd: "/tmp/proj/packages/worker", + harness: "claude-code", + }); + manager.beginShutdown(); + const flushed = manager.flush(); + let flushSettled = false; + void flushed.then(() => { + flushSettled = true; }); + await Promise.resolve(); + expect(flushSettled).toBe(false); - it("resume() ExternalHarnessError has code HARNESS_EXTERNAL and names the harness label", async () => { - const { manager } = makeManager(); + firstLaunch.resolve(); + await expect(first).rejects.toBeInstanceOf(SessionManagerClosingError); + await expect(queued).rejects.toBeInstanceOf(SessionManagerClosingError); + await flushed; - const session = await manager.registerHistorical({ - agentSessionId: "agent-session-def", - harness: "conductor" as HarnessKind, - cwd: "/tmp/conductor-proj", - title: "conductor-proj", - lastActiveAt: new Date().toISOString(), - }); + expect(prepareProjectSession).toHaveBeenCalledOnce(); + expect(buildCount).toBe(1); + expect(adapter.launch).toHaveBeenCalledOnce(); + expect(manager.list()).toEqual([ + expect.objectContaining({ status: "exited" }), + ]); + }); - let caught: unknown; - try { - await manager.resume(session.id); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(ExternalHarnessError); - const err = caught as ExternalHarnessError; - expect(err.code).toBe("HARNESS_EXTERNAL"); - expect(err.message).toMatch(/Conductor/); - expect(err.harness).toBe("conductor"); + it("revalidates resumed project scope after launch preparation and before spawning another pty", async () => { + const identity = (sessionId: string) => ({ + projectId: "project-1", + userId: "user-1", + sessionId, }); + const resolveAgentMapIdentity = vi + .fn() + .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) + .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) + .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) + .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) + .mockResolvedValueOnce(undefined); + const { manager, adapter, spawns } = makeManager({ + resolveAgentMapIdentity, + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + await manager.setAgentSessionId(session.id, "provider-project-session"); + spawns[0]?.emitExit(0); + await manager.flush(); - it("submitInput() throws ExternalHarnessError for a session with harness='conductor' and no live pty", async () => { - const { manager } = makeManager(); - - const session = await manager.registerHistorical({ - agentSessionId: "agent-session-ghi", - harness: "conductor" as HarnessKind, - cwd: "/tmp/conductor-proj", - title: "conductor-proj", - lastActiveAt: new Date().toISOString(), - }); + await expect(manager.resume(session.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); - await expect(manager.submitInput(session.id, "hello")).rejects.toThrow( - ExternalHarnessError, - ); + expect(adapter.resume).toHaveBeenCalledOnce(); + expect(spawns).toHaveLength(1); + expect(manager.get(session.id)).toMatchObject({ + id: session.id, + agentSessionId: "provider-project-session", + status: "exited", + agentMapIdentity: identity(session.id), }); + }); - it("submitInput() ExternalHarnessError has code HARNESS_EXTERNAL", async () => { - const { manager } = makeManager(); - const session = await manager.registerHistorical({ - agentSessionId: "agent-session-jkl", - harness: "conductor" as HarnessKind, - cwd: "/tmp/conductor-proj", - title: "conductor-proj", - lastActiveAt: new Date().toISOString(), - }); + it("fails resume closed when neutral project scope cannot be revalidated and preserves the session identity", async () => { + const identity = (sessionId: string) => ({ + projectId: "project-1", + userId: "user-1", + sessionId, + }); + const resolveAgentMapIdentity = vi + .fn() + .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) + .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) + .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) + .mockResolvedValueOnce(undefined); + const { manager, adapter, spawns } = makeManager({ + resolveAgentMapIdentity, + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + await manager.setAgentSessionId(session.id, "provider-project-session"); + spawns[0]?.emitExit(0); + await manager.flush(); + const beforeResume = structuredClone(manager.get(session.id)); - let caught: unknown; - try { - await manager.submitInput(session.id, "test input"); - } catch (err) { - caught = err; - } + await expect(manager.resume(session.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); - expect(caught).toBeInstanceOf(ExternalHarnessError); - expect((caught as ExternalHarnessError).code).toBe("HARNESS_EXTERNAL"); + expect(resolveAgentMapIdentity).toHaveBeenLastCalledWith( + session.id, + session.cwd, + session.agentMapIdentity, + ); + expect(manager.get(session.id)).toEqual(beforeResume); + expect(manager.get(session.id)?.agentMapIdentity).toEqual({ + projectId: "project-1", + userId: "user-1", + sessionId: session.id, }); + expect(adapter.resume).not.toHaveBeenCalled(); + expect(spawns).toHaveLength(1); }); }); diff --git a/packages/harness/src/core/session-manager.ts b/packages/harness/src/core/session-manager.ts index be56db93b..2e5ab1965 100644 --- a/packages/harness/src/core/session-manager.ts +++ b/packages/harness/src/core/session-manager.ts @@ -5,6 +5,7 @@ * server restarts, even though the ptys themselves do not. */ +import type { FocusedSessionContextProjection } from "./focused-session-context.js"; import { createHash, randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { @@ -33,7 +34,6 @@ import type { ProjectAgentSession, ProjectBootstrapMetadata, } from "../shared/agent-map.js"; -import type { FocusedSessionContextProjection } from "./focused-session-context.js"; import { migratePersistedProjectIdentity, removeLegacyProjectSessionMetadata, @@ -86,75 +86,6 @@ export class SessionInputGuardRejectedError extends Error { } } -/** A real terminal write preempted a lower-priority background injection. */ -export class SessionBackgroundInputPreemptedError extends Error { - readonly code = "SESSION_BACKGROUND_INPUT_PREEMPTED"; - - constructor(readonly staged: boolean) { - super("background session input was preempted by user input"); - this.name = "SessionBackgroundInputPreemptedError"; - } -} - -export class SessionManagerClosingError extends Error { - readonly code = "SESSION_MANAGER_CLOSING"; - - constructor() { - super("session manager is shutting down"); - this.name = "SessionManagerClosingError"; - } -} - -/** A prior partial PTY write could not be safely removed from the composer. */ -export class SessionInputIsolationError extends Error { - readonly code = "SESSION_INPUT_ISOLATION_REQUIRED"; - - constructor() { - super("session input is blocked until the terminal composer is reset"); - this.name = "SessionInputIsolationError"; - } -} - -export interface SessionInputWriteLifecycle { - /** Durable transition that must commit before the first PTY byte. */ - beforeFirstWrite?: () => Promise; - /** Synchronous final admission fence checked immediately before each write. */ - canWriteNow?: () => boolean; - /** Durable positive evidence that the writer returned before attempting - * Enter. Errors at the Enter write are intentionally excluded. */ - onNotSubmitted?: () => Promise; - /** Synchronous byte-boundary observation for durable delivery recovery. */ - onWritePhase?: (phase: SessionInputWritePhase) => void; -} - -export type SessionInputWritePhase = - | "not-written" - | "text-staged" - | "enter-written"; - -export type TrackedSessionInputResult = Readonly<{ - accepted: boolean; - phase: SessionInputWritePhase; - error?: unknown; -}>; - -/** Server-private half of a coordinator/session ownership proof. */ -export type TrustedSubsessionBindingMarker = Readonly<{ - projectId: string; - parentSessionId: string; - bindingId: string; - sessionId: string; - incarnation: number; - spawnEpoch: number; -}>; - -export interface TerminalInputContext { - /** Server-owned identity of the exact PTY receiving these bytes. */ - runtimeEpoch: string; - /** The current adapter screen is a recognized trust/login/setup blocker. */ - blockingPrompt: boolean; -} - export class ProjectSessionScopeUnavailableError extends Error { readonly code = "PROJECT_SESSION_SCOPE_UNAVAILABLE"; @@ -164,20 +95,6 @@ export class ProjectSessionScopeUnavailableError extends Error { } } -/** An automatic first-session create lost the project bootstrap claim. */ -export class ProjectBootstrapClaimUnavailableError extends Error { - readonly code = "PROJECT_BOOTSTRAP_CLAIM_UNAVAILABLE"; - - constructor() { - super("the project bootstrap claim is already owned by another session"); - this.name = "ProjectBootstrapClaimUnavailableError"; - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function sameProjectAgent( left: ProjectAgentSession, right: ProjectAgentSession, @@ -462,18 +379,6 @@ export function sanitizeExitTail(raw: string): string | null { return trimmed.slice(-EXIT_TAIL_BYTES); } -export interface SessionStatusContext { - /** Exact live/retiring PTY generation, or null before a PTY exists. */ - runtimeEpoch: string | null; -} - -export type AdapterIdentityState = - | "not-required" - | "pending" - | "ready" - | "ambiguous" - | "unavailable"; - export type SessionStatusListener = ( session: HarnessSession, context: SessionStatusContext, @@ -742,722 +647,565 @@ interface PtyHandle { killed: boolean; } -export class SessionManager { - private readonly adapters: Partial>; - private readonly ingestUrl: string; - private readonly issueIngestCredential: ( - sessionId: string, - ) => IssuedIngestCredential; - private readonly revokeIngestToken: (sessionId: string) => void; - private readonly collectorUrl: string | undefined; - private readonly sessionsPath: string; - /** Server-private, digest-only tombstones for every vendor identity ever - * accepted by a HarnessSession. Keeping this outside sessions.json avoids - * leaking historical aliases through the browser DTO. */ - private readonly agentSessionOwnersPath: string; - /** Never projected through REST; public session fields are not ownership. */ - private readonly subsessionBindingsPath: string; - private readonly spawnPty: PtySpawnFn | undefined; - private readonly loadSpawnPty: () => Promise; - private readonly buildLaunchOpts: LaunchOptsBuilder; - private readonly resolveAgentMapIdentity: SessionManagerOptions["resolveAgentMapIdentity"]; - private readonly prepareProjectSession: SessionManagerOptions["prepareProjectSession"]; - private readonly onAgentMapSessionExit: SessionManagerOptions["onAgentMapSessionExit"]; - private readonly onTerminalInput: ( - sessionId: string, - context: TerminalInputContext, - ) => void; - private readonly onProjectAgentIdentityMigration: SessionManagerOptions["onProjectAgentIdentityMigration"]; - private readonly onProjectBootstrapSession: SessionManagerOptions["onProjectBootstrapSession"]; - private readonly onRuntimeEpochTransition: SessionManagerOptions["onRuntimeEpochTransition"]; - private readonly onSubsessionUserClosed: SessionManagerOptions["onSubsessionUserClosed"]; - private readonly now: () => string; - private readonly generateId: () => string; - private readonly writeSessionRegistry: - | ((file: string, serialized: string) => Promise) - | undefined; - private readonly writeAgentSessionOwnerRegistry: - | ((file: string, serialized: string) => Promise) - | undefined; - private readonly writeSubsessionBindingRegistry: - | ((file: string, serialized: string) => Promise) - | undefined; - private readonly writeWorkspaceContext: ( - session: HarnessSession, - ) => Promise; - private readonly prepareWorkspaceContext: ( - session: HarnessSession, - ) => Promise; - private readonly ensureCanvasTemplate: (cwd: string) => Promise; - private readonly isPidAlive: (pid: number) => boolean; - private readonly platform: NodeJS.Platform; +export type AdapterIdentityState = + | "not-required" + | "pending" + | "ready" + | "ambiguous" + | "unavailable"; - private readonly sessions = new Map(); - /** Persisted project metadata that could not be normalized without choosing - * between conflicting authorities. Keep the record visible and untouched, - * but never let it acquire a fresh project capability on resume. */ - private readonly rejectedProjectSessionMetadata = new Set(); - private readonly ptys = new Map(); - /** Last cleanly retired PTY generation. It may finish already-admitted ingest - * work while the session is exited, but loses immediately to a replacement. */ - private readonly retiredRuntimeEpochs = new Map(); - /** Adapter-owned correlation for transcript-backed runtimes. This is kept - * separate from terminal readiness: a TUI can be interactive before its - * exact vendor transcript has been identified. */ - private readonly adapterIdentityStates = new Map< - string, - { runtimeEpoch: string; state: AdapterIdentityState } - >(); - /** Monotonic raw-input observations used to preempt background injection. */ - private readonly terminalInputEpochs = new Map(); - /** One text→Enter transaction may be staged per session. */ - private readonly stagedInputs = new Map< - string, - { - handle: PtyHandle; - background: boolean; - preempted: boolean; - textWritten: boolean; - lineCleared: boolean; - } - >(); - /** Handle-local poison from a partial write whose composer cleanup could not - * be proven. A replacement PTY is clean by construction; the same handle - * must complete this reset before any later text or Enter is allowed. */ - private readonly unsafeComposers = new WeakMap< - PtyHandle, - { pasteMayBeOpen: boolean } - >(); - private readonly statusEmitter = new EventEmitter(); - private readonly activityEmitter = new EventEmitter(); - /** Epoch ms of the last `onActivity` broadcast per session — see `recordActivity()`. */ - private readonly lastActivityBroadcast = new Map(); - private writeQueue: Promise = Promise.resolve(); - private writeSeq = 0; - /** While a vendor-pointer/add-row transaction is committing its private - * candidate snapshot, every ordinary registry write waits here and captures - * state only after the candidate was published or rejected. */ - private sessionRegistryIdentityFence: Promise | null = null; - private readonly agentSessionOwners = new Map(); - private readonly subsessionBindings = new Map< - string, - TrustedSubsessionBindingMarker - >(); - private readonly userClosedSubsessions = new Set(); - /** Serializes the full authorize -> reserve -> pointer commit transition. - * A file-level atomic rename alone is insufficient when two starts race the - * in-memory ownership check before either write begins. */ - private agentSessionIdentityQueue: Promise = Promise.resolve(); - /** Publish project sessions in claim order so the first durable/visible row - * is also the one that owns the first-session lifecycle. */ - private readonly projectCreateQueues = new Map>(); - private agentSessionOwnerWriteSeq = 0; - private subsessionBindingWriteSeq = 0; - private subsessionBindingQueue: Promise = Promise.resolve(); - private initialized = false; - private closing = false; - constructor(options: SessionManagerOptions) { - this.adapters = options.adapters; - this.ingestUrl = options.ingestUrl; - this.issueIngestCredential = (sessionId) => - options.ingestCredentials.issue(sessionId); - this.revokeIngestToken = (sessionId) => - options.ingestCredentials.revoke(sessionId); - this.collectorUrl = options.collectorUrl; - this.sessionsPath = expandHome( - options.sessionsPath ?? HARNESS_PATHS.sessions, - ); - this.agentSessionOwnersPath = `${this.sessionsPath}.agent-session-owners.json`; - this.subsessionBindingsPath = `${this.sessionsPath}.subsession-bindings.json`; - this.spawnPty = options.spawnPty; - this.loadSpawnPty = options.loadSpawnPty ?? loadDefaultSpawn; - this.buildLaunchOpts = options.buildLaunchOpts ?? defaultBuildLaunchOpts; - this.resolveAgentMapIdentity = options.resolveAgentMapIdentity; - this.prepareProjectSession = options.prepareProjectSession; - this.onAgentMapSessionExit = options.onAgentMapSessionExit; - this.onTerminalInput = options.onTerminalInput ?? (() => {}); - this.onProjectAgentIdentityMigration = - options.onProjectAgentIdentityMigration; - this.onProjectBootstrapSession = options.onProjectBootstrapSession; - this.onRuntimeEpochTransition = options.onRuntimeEpochTransition; - this.onSubsessionUserClosed = options.onSubsessionUserClosed; - this.now = options.now ?? (() => new Date().toISOString()); - this.generateId = options.generateId ?? randomUUID; - this.writeSessionRegistry = options.writeSessionRegistry; - this.writeAgentSessionOwnerRegistry = - options.writeAgentSessionOwnerRegistry; - this.writeSubsessionBindingRegistry = - options.writeSubsessionBindingRegistry; - this.writeWorkspaceContext = - options.writeWorkspaceContext ?? (async () => {}); - this.prepareWorkspaceContext = - options.prepareWorkspaceContext ?? (async () => {}); - this.ensureCanvasTemplate = - options.ensureCanvasTemplate ?? (async () => {}); - this.isPidAlive = options.isPidAlive ?? defaultIsPidAlive; - this.platform = options.platform ?? process.platform; - // Many WS clients (terminal + events) can subscribe over a long-running process. - this.statusEmitter.setMaxListeners(0); - this.activityEmitter.setMaxListeners(0); - } +export interface SessionStatusContext { + /** Exact live/retiring PTY generation, or null before a PTY exists. */ + runtimeEpoch: string | null; +} - /** - * Loads the persisted registry. Any session left "starting"/"running" from - * a previous process is marked "exited" — ptys don't survive a restart. - */ - async init(): Promise { - if (this.initialized) return; - this.initialized = true; - let persisted: HarnessSession[] = []; - try { - const raw = await readFile(this.sessionsPath, "utf8"); - persisted = JSON.parse(raw) as HarnessSession[]; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; - } - let dirty = false; - for (const session of persisted) { - const migration = migratePersistedProjectIdentity(session); - if (migration.outcome === "rejected") { - this.rejectedProjectSessionMetadata.add(session.id); - } - if (migration.outcome === "migrated") { - if (migration.identity) { - session.agentMapIdentity = structuredClone(migration.identity); - } else { - delete session.agentMapIdentity; - } - if (migration.bootstrap) { - session.projectBootstrap = structuredClone(migration.bootstrap); - } else { - delete session.projectBootstrap; - } - // Planner-era metadata is never live authority after normalization. - // Its on-disk input queue is migrated by ProjectBootstrapCoordinator. - removeLegacyProjectSessionMetadata(session); - dirty = true; - } - if (migration.outcome !== "unchanged") { - try { - this.onProjectAgentIdentityMigration?.({ - sessionId: session.id, - outcome: migration.outcome, - }); - } catch { - // Observability is best effort and cannot affect session recovery. - } - } - if (session.status !== "exited") { - session.status = "exited"; - session.exitCode = session.exitCode ?? null; - dirty = true; - } - // Drop a persisted binding that points outside this session's own - // workspace. A stale carryover from an earlier session in a different - // directory would otherwise render a FOREIGN workflow onto the canvas - // (observed: a session in one workspace showing a workflow from - // ~/harness-playground). Cleared here, the session simply starts unbound - // and its own workspace scan re-binds a local workflow. - if (session.boundWorkflowPath) { - const root = resolve(session.cwd); - const target = resolve(session.boundWorkflowPath); - if (target !== root && !target.startsWith(root + sep)) { - session.boundWorkflowPath = null; - dirty = true; - } - } - this.sessions.set(session.id, session); - } - dirty = (await this.loadAgentSessionOwners(persisted)) || dirty; - await this.loadSubsessionBindings(); - if (dirty) await this.persist(); - } - list(): HarnessSession[] { - return Array.from(this.sessions.values()); - } +export interface TerminalInputContext { + /** Server-owned identity of the exact PTY receiving these bytes. */ + runtimeEpoch: string; + /** The current adapter screen is a recognized trust/login/setup blocker. */ + blockingPrompt: boolean; +} - get(id: string): HarnessSession | undefined { - return this.sessions.get(id); - } - /** Read-only vendor-history probe used before a coordinator claims recovery. */ - async canResumeSession(id: string): Promise { - const session = this.sessions.get(id); - if (!session?.agentSessionId) return false; - return this.getAdapter(session.harness).canResume( - session.agentSessionId, - session.cwd, - ); - } +export type TrackedSessionInputResult = Readonly<{ + accepted: boolean; + phase: SessionInputWritePhase; + error?: unknown; +}>; - /** True only when this process owns the live PTY behind the record. */ - isLive(id: string): boolean { - return this.ptys.has(id); - } +/** Server-private half of a coordinator/session ownership proof. */ +export type TrustedSubsessionBindingMarker = Readonly<{ + projectId: string; + parentSessionId: string; + bindingId: string; + sessionId: string; + incarnation: number; + spawnEpoch: number; +}>; - /** Opaque identity for the exact live PTY generation behind `id`. */ - getRuntimeEpoch(id: string): string | null { - return this.ptys.get(id)?.runtimeEpoch ?? null; - } - /** Exact-runtime adapter identity used by trusted background delivery. */ - getAdapterIdentityState(id: string, runtimeEpoch: string): AdapterIdentityState { - const state = this.adapterIdentityStates.get(id); - if (!state || state.runtimeEpoch !== runtimeEpoch) return "pending"; - return state.state; - } +export type SessionInputWritePhase = + | "not-written" + | "text-staged" + | "enter-written"; - /** Server-only acknowledgement from an adapter-owned identity broker. */ - setAdapterIdentityState( - id: string, - runtimeEpoch: string, - state: Exclude, - ): boolean { - const current = this.adapterIdentityStates.get(id); - if (!current || current.runtimeEpoch !== runtimeEpoch || - !this.isCurrentRuntimeEpoch(id, runtimeEpoch)) return false; - current.state = state; - return true; - } - /** True only for the exact PTY generation that is live right now. */ - isCurrentRuntimeEpoch(id: string, runtimeEpoch: string): boolean { - const live = this.ptys.get(id); - return live?.runtimeEpoch === runtimeEpoch; - } +export interface SessionInputWriteLifecycle { + /** Durable transition that must commit before the first PTY byte. */ + beforeFirstWrite?: () => Promise; + /** Synchronous final admission fence checked immediately before each write. */ + canWriteNow?: () => boolean; + /** Durable positive evidence that the writer returned before attempting + * Enter. Errors at the Enter write are intentionally excluded. */ + onNotSubmitted?: () => Promise; + /** Synchronous byte-boundary observation for durable delivery recovery. */ + onWritePhase?: (phase: SessionInputWritePhase) => void; +} - /** - * Fail-closed admission for already-authenticated ingest work. A terminal - * event may finish after its PTY retires, but a replacement handle always - * takes ownership immediately and rejects every earlier epoch. - */ - acceptsIngestRuntimeEpoch(id: string, runtimeEpoch: string): boolean { - const live = this.ptys.get(id); - if (live) return live.runtimeEpoch === runtimeEpoch; - return ( - this.sessions.get(id)?.status === "exited" && - this.retiredRuntimeEpochs.get(id) === runtimeEpoch - ); - } - private getAdapter(harness: HarnessKind): HarnessAdapter { - const adapter = this.adapters[harness]; - if (!adapter) { - // Before surfacing a generic "adapter not found", check whether this id - // belongs to a known external-mode adapter — if so, the 409 with a human - // message ("X sessions are managed by the X app") is far more actionable - // than "no adapter registered for harness X". A sessions.json entry - // with harness="conductor" (written by an earlier build, hand-edited, or - // a future registration) hits this path on resume/submitInput. - const info = listHarnessAdapters().find((a) => a.id === harness); - if (info?.mode === "external") - throw new ExternalHarnessError(harness, info.label); - throw new AdapterNotFoundError(harness); - } - return adapter; - } +/** A prior partial PTY write could not be safely removed from the composer. */ +export class SessionInputIsolationError extends Error { + readonly code = "SESSION_INPUT_ISOLATION_REQUIRED"; - /** Recheck the immutable project principal immediately before spawning. */ - private async revalidateAgentMapIdentity( - sessionId: string, - cwd: string, - expected: ProjectAgentSession | undefined, - ): Promise { - if (!expected || !this.resolveAgentMapIdentity) return; - const current = await this.resolveAgentMapIdentity( - sessionId, - cwd, - expected, - ); - if (!current || !sameProjectAgent(current, expected)) { - throw new ProjectSessionScopeUnavailableError(sessionId); - } + constructor() { + super("session input is blocked until the terminal composer is reset"); + this.name = "SessionInputIsolationError"; } +} - private serializeProjectCreate( - projectId: string, - operation: () => Promise, - ): Promise { - const prior = this.projectCreateQueues.get(projectId) ?? Promise.resolve(); - const next = prior.catch(() => {}).then(operation); - const settled = next.then( - () => {}, - () => {}, - ); - this.projectCreateQueues.set(projectId, settled); - void settled.then(() => { - if (this.projectCreateQueues.get(projectId) === settled) { - this.projectCreateQueues.delete(projectId); - } - }); - return next; + +export class SessionManagerClosingError extends Error { + readonly code = "SESSION_MANAGER_CLOSING"; + + constructor() { + super("session manager is shutting down"); + this.name = "SessionManagerClosingError"; } +} - async create( - req: CreateSessionRequest, - trusted: TrustedSessionCreateOptions = {}, - ): Promise { - return this.createWithId(this.generateId(), req, trusted); + +/** A real terminal write preempted a lower-priority background injection. */ +export class SessionBackgroundInputPreemptedError extends Error { + readonly code = "SESSION_BACKGROUND_INPUT_PREEMPTED"; + + constructor(readonly staged: boolean) { + super("background session input was preempted by user input"); + this.name = "SessionBackgroundInputPreemptedError"; } +} + + +export class SessionManager { /** - * Server-only reserved-ID create. The private marker is committed before a - * session row or process can exist, closing the row-before-binding crash - * window while preserving the ordinary writable create path. + * Internal tracked variant for retry-safe coordinator delivery. It never + * turns an ambiguous write exception into zero-byte proof: callers receive + * the furthest phase observed at the exact PTY boundary. */ - async createReserved( - reservedSessionId: string, - req: CreateSessionRequest, - markerInput: TrustedSubsessionBindingMarker, - trusted: TrustedSessionCreateOptions, - ): Promise { - const marker = parseTrustedSubsessionBindingMarker( - markerInput, - reservedSessionId, - ); - if (!marker) throw new SubsessionBindingMismatchError(); - const operation = async (): Promise => { - const existingMarker = this.subsessionBindings.get(reservedSessionId); - const existingSession = this.sessions.get(reservedSessionId); - if (existingMarker) { - if (!sameSubsessionBinding(existingMarker, marker)) - throw new SubsessionBindingMismatchError(); - if (this.userClosedSubsessions.has(reservedSessionId)) - throw new SubsessionFreshRestartForbiddenError(); - if (existingSession) return existingSession; - } else { - if (existingSession) throw new SubsessionBindingMismatchError(); - this.subsessionBindings.set(reservedSessionId, marker); - try { - await this.persistSubsessionBindings(); - } catch (error) { - if (this.subsessionBindings.get(reservedSessionId) === marker) - this.subsessionBindings.delete(reservedSessionId); - throw error; - } + async submitInputTracked( + id: string, + text: string, + options: Readonly<{ + canWrite?: () => boolean | Promise; + lifecycle?: Omit; + background?: boolean; + }> = {}, + ): Promise { + let phase: SessionInputWritePhase = "not-written"; + try { + const accepted = await this.submitInput( + id, + text, + true, + options.canWrite, + options.background ?? true, + { + ...options.lifecycle, + onWritePhase: (next) => { + phase = next; + }, + }, + ); + return { accepted, phase }; + } catch (error) { + return { accepted: false, phase, error }; + } + } + + + /** + * Cancel only a lower-priority server-owned background submission. Unlike + * write(), this does not forward bytes or preempt an ordinary user/API + * submission. It is safe to call before staging begins; the coordinator's + * submit guard covers that side of the race. + */ + preemptBackgroundInput(id: string): boolean { + const staged = this.stagedInputs.get(id); + if (!staged?.background || staged.preempted) return false; + staged.preempted = true; + if (staged.textWritten) { + if (this.abandonStagedLine(staged.handle)) { + staged.lineCleared = true; } - return this.createWithId(reservedSessionId, req, trusted, marker); - }; - const next = this.subsessionBindingQueue.catch(() => {}).then(operation); - this.subsessionBindingQueue = next.then( - () => undefined, - () => undefined, - ); - return next; + } + return true; } - getSubsessionBinding( - sessionId: string, - ): TrustedSubsessionBindingMarker | null { - const marker = this.subsessionBindings.get(sessionId); - return marker ? structuredClone(marker) : null; + + /** Abandon a fully staged line. Ctrl-C is a safe fallback here because a + * successful full bracketed-paste write already carried its closing marker. */ + private abandonStagedLine(handle: PtyHandle): boolean { + try { + handle.pty.write("\x15"); + this.observeTrustedTerminalInput(handle, "\x15"); + return true; + } catch { + try { + handle.pty.write("\x03"); + this.observeTrustedTerminalInput(handle, "\x03"); + return true; + } catch { + this.markComposerUnsafe(handle, false); + return false; + } + } } - matchesSubsessionBinding( - expected: TrustedSubsessionBindingMarker, - ): boolean { - const parsed = parseTrustedSubsessionBindingMarker( - expected, - expected.sessionId, - ); - const current = parsed - ? this.subsessionBindings.get(parsed.sessionId) - : undefined; - return Boolean(parsed && current && sameSubsessionBinding(current, parsed)); + + /** + * Recover only a previously poisoned composer, before any new user or + * server-owned text is written. Closing bracketed paste (when required) and + * abandoning the line are both non-submitting operations. Failure leaves + * the handle poisoned and no caller payload is forwarded. + */ + private resetUnsafeComposer(handle: PtyHandle): boolean { + const unsafe = this.unsafeComposers.get(handle); + if (!unsafe) return true; + if (this.closing) return false; + let pasteMayBeOpen = unsafe.pasteMayBeOpen; + if (pasteMayBeOpen) { + try { + handle.pty.write(BRACKETED_PASTE_END); + this.observeTrustedTerminalInput(handle, BRACKETED_PASTE_END); + pasteMayBeOpen = false; + } catch { + return false; + } + } + try { + handle.pty.write("\x15"); + this.observeTrustedTerminalInput(handle, "\x15"); + } catch { + this.unsafeComposers.set(handle, { pasteMayBeOpen }); + return false; + } + this.unsafeComposers.delete(handle); + return true; } - wasSubsessionClosedByUser( - expected: TrustedSubsessionBindingMarker, - ): boolean { + + private markComposerUnsafe( + handle: PtyHandle, + pasteMayBeOpen: boolean, + ): void { + const current = this.unsafeComposers.get(handle); + this.unsafeComposers.set(handle, { + pasteMayBeOpen: current?.pasteMayBeOpen === true || pasteMayBeOpen, + }); + } + + + /** Close admission before a server shutdown snapshots live PTYs. */ + beginShutdown(): void { + this.closing = true; + } + + + /** Kill only the exact PTY generation a losing coordinator created. */ + killIfRuntime(id: string, runtimeEpoch: string): Promise { + if (this.ptys.get(id)?.runtimeEpoch !== runtimeEpoch) + return Promise.resolve(false); + return this.kill(id); + } + + + /** + * Fail-closed admission for already-authenticated ingest work. A terminal + * event may finish after its PTY retires, but a replacement handle always + * takes ownership immediately and rejects every earlier epoch. + */ + acceptsIngestRuntimeEpoch(id: string, runtimeEpoch: string): boolean { + const live = this.ptys.get(id); + if (live) return live.runtimeEpoch === runtimeEpoch; return ( - this.matchesSubsessionBinding(expected) && - this.userClosedSubsessions.has(expected.sessionId) + this.sessions.get(id)?.status === "exited" && + this.retiredRuntimeEpochs.get(id) === runtimeEpoch ); } - private async createWithId( + + /** True only for the exact PTY generation that is live right now. */ + isCurrentRuntimeEpoch(id: string, runtimeEpoch: string): boolean { + const live = this.ptys.get(id); + return live?.runtimeEpoch === runtimeEpoch; + } + + + /** Server-only acknowledgement from an adapter-owned identity broker. */ + setAdapterIdentityState( id: string, - req: CreateSessionRequest, - trusted: TrustedSessionCreateOptions, - expectedSubsessionBinding?: TrustedSubsessionBindingMarker, - ): Promise { - if (this.closing) throw new SessionManagerClosingError(); - const marker = this.subsessionBindings.get(id); - if ( - (marker !== undefined || expectedSubsessionBinding !== undefined) && - (!marker || - !expectedSubsessionBinding || - !sameSubsessionBinding(marker, expectedSubsessionBinding)) - ) { - throw new SubsessionBindingMismatchError(); + runtimeEpoch: string, + state: Exclude, + ): boolean { + const current = this.adapterIdentityStates.get(id); + if (!current || current.runtimeEpoch !== runtimeEpoch || + !this.isCurrentRuntimeEpoch(id, runtimeEpoch)) return false; + current.state = state; + return true; + } + + + /** Exact-runtime adapter identity used by trusted background delivery. */ + getAdapterIdentityState(id: string, runtimeEpoch: string): AdapterIdentityState { + const state = this.adapterIdentityStates.get(id); + if (!state || state.runtimeEpoch !== runtimeEpoch) return "pending"; + return state.state; + } + + + /** Opaque identity for the exact live PTY generation behind `id`. */ + getRuntimeEpoch(id: string): string | null { + return this.ptys.get(id)?.runtimeEpoch ?? null; + } + + private closing = false; + + /** Handle-local poison from a partial write whose composer cleanup could not + * be proven. A replacement PTY is clean by construction; the same handle + * must complete this reset before any later text or Enter is allowed. */ + private readonly unsafeComposers = new WeakMap< + PtyHandle, + { pasteMayBeOpen: boolean } + >(); + + /** One text→Enter transaction may be staged per session. */ + private readonly stagedInputs = new Map< + string, + { + handle: PtyHandle; + background: boolean; + preempted: boolean; + textWritten: boolean; + lineCleared: boolean; } - if (this.sessions.has(id)) throw new SubsessionBindingMismatchError(); - const adapter = this.getAdapter(req.harness); - const trustedIdentity = trusted.agentMapIdentity?.(id); - const agentMapIdentity = this.resolveAgentMapIdentity - ? await this.resolveAgentMapIdentity(id, req.cwd, trustedIdentity) - : trustedIdentity; - const createResolved = async (): Promise => { - // A project create may have been waiting behind another publication - // when shutdown closed admission. Refuse it before claiming bootstrap, - // issuing capabilities, or writing generated session state. - if (this.closing) throw new SessionManagerClosingError(); - let preparedProjectSession: - | Awaited< - ReturnType< - NonNullable - > - > - | undefined; - let opts: LaunchOpts; - let spec: SpawnSpec; - try { - preparedProjectSession = - agentMapIdentity && this.prepareProjectSession - ? await this.prepareProjectSession(agentMapIdentity, req) - : undefined; - if ( - trusted.requireProjectBootstrapClaim && - !preparedProjectSession?.projectBootstrap - ) { - throw new ProjectBootstrapClaimUnavailableError(); - } - const promptAppendix = trusted.promptAppendix?.(id); - const focusedContext = trusted.focusedContext?.(id); - if (focusedContext && !agentMapIdentity) - throw new TypeError("Focused project context requires a project-agent identity"); - const sessionStartSystemMessage = - trusted.sessionStartSystemMessage?.(id); - const launchContext = - promptAppendix || focusedContext || sessionStartSystemMessage || agentMapIdentity - ? { - ...(promptAppendix ? { promptAppendix } : {}), - ...(focusedContext ? { focusedContext } : {}), - ...(sessionStartSystemMessage - ? { sessionStartSystemMessage } - : {}), - ...(agentMapIdentity ? { agentMapIdentity } : {}), - } - : undefined; - opts = { - harnessSessionId: id, - cwd: req.cwd, - ...(await (launchContext - ? this.buildLaunchOpts(id, req, launchContext) - : this.buildLaunchOpts(id, req))), - }; - spec = adapter.launch(opts); - } catch (error) { - // Scope resolution may already have claimed bootstrap ownership, and - // launch preparation may already have issued a capability. Revoke both - // for every setup failure, including prompt composition/config writes, - // while preserving the original actionable error. - await Promise.resolve(this.onAgentMapSessionExit?.(id)).catch(() => {}); - throw error; - } - const projectBootstrap = preparedProjectSession?.projectBootstrap; - const session: HarnessSession = { - id, - agentSessionId: null, - harness: req.harness, - cwd: req.cwd, - title: - trusted.initialTitle ?? - preparedProjectSession?.initialTitle ?? - (basename(req.cwd) || req.cwd), - status: "starting", - createdAt: this.now(), - lastActiveAt: this.now(), - exitCode: null, - boundWorkflowPath: null, - // Ordinary callers record only what the builder actually rehydrated. - // A trusted planner replacement records its exact FIFO predecessor even - // when the brief came from an older recorded ancestor in that chain. - rehydratedFrom: - trusted.handoffFromSessionId ?? opts.rehydratedFrom ?? null, - // Persisted so resume() regenerates the same ANSI base — otherwise a - // resumed session would fall back to the server default and its dim text - // could lose contrast against a differently-themed terminal. - ...(req.theme ? { theme: req.theme } : {}), - ready: false, - ...(projectBootstrap - ? { projectBootstrap: structuredClone(projectBootstrap) } - : {}), - ...(agentMapIdentity - ? { agentMapIdentity: structuredClone(agentMapIdentity) } - : {}), - }; - this.sessions.set(id, session); - try { - await this.persist(); - // Before spawning, not fire-and-forget: the agent's very first read of - // HARNESS_CONTEXT_FILE must never race session creation with an ENOENT, - // regardless of which entry point called create() (REST, autoCreateSession). - await this.writeWorkspaceContext(session); - // Same reasoning: the canvas pane opens immediately once the session is - // "running" — it must never show a bare empty iframe because nothing's - // been written to .sapiom/canvas/index.html yet. - await this.ensureCanvasTemplate(session.cwd); - await this.spawn(session, spec, () => - this.revalidateAgentMapIdentity( - session.id, - session.cwd, - agentMapIdentity, - ), - ); - if (session.projectBootstrap) { - const runtimeEpoch = this.getRuntimeEpoch(session.id); - if (runtimeEpoch === null) throw new Error("session runtime unavailable"); - await Promise.resolve( - this.onProjectBootstrapSession?.(session, "created", runtimeEpoch), - ).catch(() => {}); - } - } catch (err) { - // The first persist may itself be the failure, so reconciliation is - // best-effort: always repair the in-memory record to "exited", attempt - // the durable repair, and preserve the original actionable failure if - // that second write also fails. - await this.transitionExited(session, null).catch(() => {}); - throw err; - } - return session; - }; - return agentMapIdentity - ? this.serializeProjectCreate(agentMapIdentity.projectId, createResolved) - : createResolved(); + >(); + + /** Monotonic raw-input observations used to preempt background injection. */ + private readonly terminalInputEpochs = new Map(); + + /** Adapter-owned correlation for transcript-backed runtimes. This is kept + * separate from terminal readiness: a TUI can be interactive before its + * exact vendor transcript has been identified. */ + private readonly adapterIdentityStates = new Map< + string, + { runtimeEpoch: string; state: AdapterIdentityState } + >(); + + /** Last cleanly retired PTY generation. It may finish already-admitted ingest + * work while the session is exited, but loses immediately to a replacement. */ + private readonly retiredRuntimeEpochs = new Map(); + + private readonly onRuntimeEpochTransition: SessionManagerOptions["onRuntimeEpochTransition"]; + private readonly onSubsessionUserClosed: SessionManagerOptions["onSubsessionUserClosed"]; + + private readonly onTerminalInput: ( + sessionId: string, + context: TerminalInputContext, + ) => void; + + private readonly loadSpawnPty: () => Promise; + + private readonly issueIngestCredential: ( + sessionId: string, + ) => IssuedIngestCredential; + + private readonly adapters: Partial>; + private readonly ingestUrl: string; + private readonly revokeIngestToken: (sessionId: string) => void; + private readonly collectorUrl: string | undefined; + private readonly sessionsPath: string; + /** Server-private, digest-only tombstones for every vendor identity ever + * accepted by a HarnessSession. Keeping this outside sessions.json avoids + * leaking historical aliases through the browser DTO. */ + private readonly agentSessionOwnersPath: string; + /** Never projected through REST; public session fields are not ownership. */ + private readonly subsessionBindingsPath: string; + private readonly spawnPty: PtySpawnFn | undefined; + private readonly buildLaunchOpts: LaunchOptsBuilder; + private readonly resolveAgentMapIdentity: SessionManagerOptions["resolveAgentMapIdentity"]; + private readonly onProjectAgentIdentityMigration: SessionManagerOptions["onProjectAgentIdentityMigration"]; + private readonly rejectedProjectSessionMetadata = new Set(); + private readonly onAgentMapSessionExit: SessionManagerOptions["onAgentMapSessionExit"]; + private readonly now: () => string; + private readonly generateId: () => string; + private readonly writeSessionRegistry: + | ((file: string, serialized: string) => Promise) + | undefined; + private readonly writeAgentSessionOwnerRegistry: + | ((file: string, serialized: string) => Promise) + | undefined; + private readonly writeSubsessionBindingRegistry: + | ((file: string, serialized: string) => Promise) + | undefined; + private readonly writeWorkspaceContext: ( + session: HarnessSession, + ) => Promise; + private readonly prepareWorkspaceContext: ( + session: HarnessSession, + ) => Promise; + private readonly ensureCanvasTemplate: (cwd: string) => Promise; + private readonly isPidAlive: (pid: number) => boolean; + private readonly platform: NodeJS.Platform; + + private readonly sessions = new Map(); + private readonly ptys = new Map(); + private readonly statusEmitter = new EventEmitter(); + private readonly activityEmitter = new EventEmitter(); + /** Epoch ms of the last `onActivity` broadcast per session — see `recordActivity()`. */ + private readonly lastActivityBroadcast = new Map(); + private writeQueue: Promise = Promise.resolve(); + private writeSeq = 0; + /** While a vendor-pointer/add-row transaction is committing its private + * candidate snapshot, every ordinary registry write waits here and captures + * state only after the candidate was published or rejected. */ + private sessionRegistryIdentityFence: Promise | null = null; + private readonly agentSessionOwners = new Map(); + private readonly subsessionBindings = new Map< + string, + TrustedSubsessionBindingMarker + >(); + private readonly userClosedSubsessions = new Set(); + /** Serializes the full authorize -> reserve -> pointer commit transition. + * A file-level atomic rename alone is insufficient when two starts race the + * in-memory ownership check before either write begins. */ + private agentSessionIdentityQueue: Promise = Promise.resolve(); + private agentSessionOwnerWriteSeq = 0; + private subsessionBindingWriteSeq = 0; + private subsessionBindingQueue: Promise = Promise.resolve(); + private initialized = false; + + constructor(options: SessionManagerOptions) { + this.adapters = options.adapters; + this.ingestUrl = options.ingestUrl; + this.issueIngestCredential = (sessionId) => + options.ingestCredentials.issue(sessionId); + this.revokeIngestToken = (sessionId) => + options.ingestCredentials.revoke(sessionId); + this.collectorUrl = options.collectorUrl; + this.sessionsPath = expandHome( + options.sessionsPath ?? HARNESS_PATHS.sessions, + ); + this.agentSessionOwnersPath = `${this.sessionsPath}.agent-session-owners.json`; + this.subsessionBindingsPath = `${this.sessionsPath}.subsession-bindings.json`; + this.spawnPty = options.spawnPty; + this.loadSpawnPty = options.loadSpawnPty ?? loadDefaultSpawn; + this.buildLaunchOpts = options.buildLaunchOpts ?? defaultBuildLaunchOpts; + this.resolveAgentMapIdentity = options.resolveAgentMapIdentity; + this.prepareProjectSession = options.prepareProjectSession; + this.onAgentMapSessionExit = options.onAgentMapSessionExit; + this.onTerminalInput = options.onTerminalInput ?? (() => {}); + this.onProjectAgentIdentityMigration = + options.onProjectAgentIdentityMigration; + this.onProjectBootstrapSession = options.onProjectBootstrapSession; + this.onRuntimeEpochTransition = options.onRuntimeEpochTransition; + this.onSubsessionUserClosed = options.onSubsessionUserClosed; + this.now = options.now ?? (() => new Date().toISOString()); + this.generateId = options.generateId ?? randomUUID; + this.writeSessionRegistry = options.writeSessionRegistry; + this.writeAgentSessionOwnerRegistry = + options.writeAgentSessionOwnerRegistry; + this.writeSubsessionBindingRegistry = + options.writeSubsessionBindingRegistry; + this.writeWorkspaceContext = + options.writeWorkspaceContext ?? (async () => {}); + this.prepareWorkspaceContext = + options.prepareWorkspaceContext ?? (async () => {}); + this.ensureCanvasTemplate = + options.ensureCanvasTemplate ?? (async () => {}); + this.isPidAlive = options.isPidAlive ?? defaultIsPidAlive; + this.platform = options.platform ?? process.platform; + // Many WS clients (terminal + events) can subscribe over a long-running process. + this.statusEmitter.setMaxListeners(0); + this.activityEmitter.setMaxListeners(0); } /** - * Narrow recovery for an exact coordinator-owned row that exited before its - * first turn and has no resumable vendor conversation. The Harness ID stays - * fixed; the private marker advances before a fresh PTY can be admitted. + * Loads the persisted registry. Any session left "starting"/"running" from + * a previous process is marked "exited" — ptys don't survive a restart. */ - async restartFreshBound( - id: string, - expected: TrustedSubsessionBindingMarker, - nextInput: TrustedSubsessionBindingMarker, - trusted: TrustedSessionCreateOptions, - hasRecordedTurns: (sessionId: string) => Promise, - ): Promise { - if (this.closing) throw new SessionManagerClosingError(); - const currentExpected = parseTrustedSubsessionBindingMarker(expected, id); - const next = parseTrustedSubsessionBindingMarker(nextInput, id); - const current = this.subsessionBindings.get(id); - const session = this.sessions.get(id); - if ( - !currentExpected || - !next || - !current || - !session || - (current.projectId !== currentExpected.projectId || - current.parentSessionId !== currentExpected.parentSessionId || - current.bindingId !== currentExpected.bindingId || - current.sessionId !== currentExpected.sessionId) || - next.projectId !== currentExpected.projectId || - next.parentSessionId !== currentExpected.parentSessionId || - next.bindingId !== currentExpected.bindingId || - next.sessionId !== currentExpected.sessionId || - next.incarnation !== currentExpected.incarnation + 1 || - next.spawnEpoch <= currentExpected.spawnEpoch || - this.ptys.has(id) || - session.status !== "exited" - ) { - throw new SubsessionBindingMismatchError(); - } - if (this.userClosedSubsessions.has(id)) - throw new SubsessionFreshRestartForbiddenError(); - // A retry may observe the already-advanced marker after the sidecar write - // committed but before the fresh process existed. - if ( - !sameSubsessionBinding(current, currentExpected) && - !sameSubsessionBinding(current, next) - ) { - throw new SubsessionBindingMismatchError(); - } - const adapter = this.getAdapter(session.harness); - if ( - (session.agentSessionId !== null && - (await adapter.canResume(session.agentSessionId, session.cwd))) || - (await hasRecordedTurns(id)) - ) { - throw new SubsessionFreshRestartForbiddenError(); + async init(): Promise { + if (this.initialized) return; + this.initialized = true; + let persisted: HarnessSession[] = []; + try { + const raw = await readFile(this.sessionsPath, "utf8"); + persisted = JSON.parse(raw) as HarnessSession[]; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; } - - if (!sameSubsessionBinding(current, next)) { - this.subsessionBindings.set(id, next); - try { - await this.persistSubsessionBindings(); - } catch (error) { - this.subsessionBindings.set(id, current); - throw error; + let dirty = false; + for (const session of persisted) { + const migration = migratePersistedProjectIdentity(session); + if (migration.outcome === "rejected") { + this.rejectedProjectSessionMetadata.add(session.id); + } + if (migration.outcome === "migrated") { + if (migration.identity) { + session.agentMapIdentity = structuredClone(migration.identity); + } else { + delete session.agentMapIdentity; + } + if (migration.bootstrap) { + session.projectBootstrap = structuredClone(migration.bootstrap); + } else { + delete session.projectBootstrap; + } + // Planner-era metadata is never live authority after normalization. + // Its on-disk input queue is migrated by ProjectBootstrapCoordinator. + removeLegacyProjectSessionMetadata(session); + dirty = true; + } + if (migration.outcome !== "unchanged") { + try { + this.onProjectAgentIdentityMigration?.({ + sessionId: session.id, + outcome: migration.outcome, + }); + } catch { + // Observability is best effort and cannot affect session recovery. + } } + if (session.status !== "exited") { + session.status = "exited"; + session.exitCode = session.exitCode ?? null; + dirty = true; + } + // Drop a persisted binding that points outside this session's own + // workspace. A stale carryover from an earlier session in a different + // directory would otherwise render a FOREIGN workflow onto the canvas + // (observed: a session in one workspace showing a workflow from + // ~/harness-playground). Cleared here, the session simply starts unbound + // and its own workspace scan re-binds a local workflow. + if (session.boundWorkflowPath) { + const root = resolve(session.cwd); + const target = resolve(session.boundWorkflowPath); + if (target !== root && !target.startsWith(root + sep)) { + session.boundWorkflowPath = null; + dirty = true; + } + } + this.sessions.set(session.id, session); } + dirty = (await this.loadAgentSessionOwners(persisted)) || dirty; + await this.loadSubsessionBindings(); + if (dirty) await this.persist(); + } - const trustedIdentity = trusted.agentMapIdentity?.(id); - const agentMapIdentity = this.resolveAgentMapIdentity - ? await this.resolveAgentMapIdentity(id, session.cwd, trustedIdentity) - : trustedIdentity; - if ( - !agentMapIdentity || - agentMapIdentity.projectId !== next.projectId || - agentMapIdentity.sessionId !== id - ) { - throw new ProjectSessionScopeUnavailableError(id); - } + list(): HarnessSession[] { + return Array.from(this.sessions.values()); + } - const lastActiveBeforeRestart = session.lastActiveAt; - session.status = "starting"; - session.exitCode = null; - session.exitTail = null; - session.agentSessionId = null; - session.agentMapIdentity = structuredClone(agentMapIdentity); - session.lastActiveAt = this.now(); - let spec: SpawnSpec; - try { - const promptAppendix = trusted.promptAppendix?.(id); - const focusedContext = trusted.focusedContext?.(id); - const sessionStartSystemMessage = - trusted.sessionStartSystemMessage?.(id); - const context = { - ...(promptAppendix ? { promptAppendix } : {}), - ...(focusedContext ? { focusedContext } : {}), - ...(sessionStartSystemMessage - ? { sessionStartSystemMessage } - : {}), - agentMapIdentity, - }; - const opts: LaunchOpts = { - harnessSessionId: id, - cwd: session.cwd, - ...(await this.buildLaunchOpts(id, session, context)), - }; - spec = adapter.launch(opts); - } catch (error) { - session.status = "exited"; - session.lastActiveAt = lastActiveBeforeRestart; - await Promise.resolve(this.onAgentMapSessionExit?.(id)).catch(() => {}); - throw error; + get(id: string): HarnessSession | undefined { + return this.sessions.get(id); + } + + /** True only when this process owns the live PTY behind the record. */ + isLive(id: string): boolean { + return this.ptys.has(id); + } + + private getAdapter(harness: HarnessKind): HarnessAdapter { + const adapter = this.adapters[harness]; + if (!adapter) { + // Before surfacing a generic "adapter not found", check whether this id + // belongs to a known external-mode adapter — if so, the 409 with a human + // message ("X sessions are managed by the X app") is far more actionable + // than "no adapter registered for harness X". A sessions.json entry + // with harness="conductor" (written by an earlier build, hand-edited, or + // a future registration) hits this path on resume/submitInput. + const info = listHarnessAdapters().find((a) => a.id === harness); + if (info?.mode === "external") throw new ExternalHarnessError(harness, info.label); + throw new AdapterNotFoundError(harness); } - try { - await this.persist(); - this.emitStatus(session); - await this.writeWorkspaceContext(session); - await this.ensureCanvasTemplate(session.cwd); - await this.spawn(session, spec, () => - this.revalidateAgentMapIdentity(id, session.cwd, agentMapIdentity), - ); - return session; - } catch (error) { - session.lastActiveAt = lastActiveBeforeRestart; - await this.transitionExited(session, null, { - stampLastActive: false, - }).catch(() => {}); - throw error; + return adapter; + } + + /** Recheck the immutable project principal immediately before spawning. */ + private async revalidateAgentMapIdentity( + sessionId: string, + cwd: string, + expected: ProjectAgentSession | undefined, + ): Promise { + if (!expected || !this.resolveAgentMapIdentity) return; + const current = await this.resolveAgentMapIdentity( + sessionId, + cwd, + expected, + ); + if (!current || !sameProjectAgent(current, expected)) { + throw new ProjectSessionScopeUnavailableError(sessionId); } } + async create( + req: CreateSessionRequest, + trusted: TrustedSessionCreateOptions = {}, + ): Promise { + return this.createWithId(this.generateId(), req, trusted); + } + /** * Registers a purely historical (never-launched-by-this-harness) session so * it can subsequently be resumed via `resume()`. Called by @@ -1795,6 +1543,30 @@ export class SessionManager { return next; } + /** + * Signals the session's pty to exit and returns a Promise that resolves + * once the process is **actually gone** — not fire-and-forget. + * + * Resolution source (either one unblocks the promise): + * 1. node-pty's own `onExit` event → markExited() → `handle.exited` resolves. + * 2. Synthesized exit: kill()'s escalation fallback (SIGTERM → SIGKILL → + * pid liveness check) → markExited() → `handle.exited` resolves. + * 3. Synthesized exit from an external `sweepDeadSessions()` call that + * happens to run during the escalation window → same path. + * + * The promise is bounded: after `KILL_ESCALATION_MS` the escalation sends + * SIGKILL; after a further `KILL_ESCALATION_CONFIRM_MS` it synthesizes the + * exit from an OS-level pid check regardless of node-pty's event. So the + * worst-case resolution time is `KILL_ESCALATION_MS + KILL_ESCALATION_CONFIRM_MS` + * (2500 ms at current constants), never infinite. + * + * Existing fire-and-forget callers keep working: an unawaited Promise is + * fine and produces no floating-promise lint warnings when suppressed with + * `void`. + * + * Returns false (resolved immediately) when the session has no live pty. + * Returns true (resolved on actual death) when a pty was signalled. + */ kill(id: string): Promise { const handle = this.ptys.get(id); if (!handle) { @@ -1842,149 +1614,55 @@ export class SessionManager { // event or a synthesized exit from the escalation above or sweepDeadSessions. // `handle.exited` is resolved by markExited(), which is the single // convergence point for all three paths — it never hangs. - return handle.exited.then(() => true); - } - - /** Kill only the exact PTY generation a losing coordinator created. */ - killIfRuntime(id: string, runtimeEpoch: string): Promise { - if (this.ptys.get(id)?.runtimeEpoch !== runtimeEpoch) - return Promise.resolve(false); - return this.kill(id); - } - - /** - * Kills every currently-live pty and returns a Promise that resolves when - * all of them have actually exited (real or synthesized). Bounded by the - * same escalation window as `kill()` — never hangs. - * - * Call this on server shutdown so the process actually exits instead of - * waiting on orphaned claude/codex children. A bounded timeout can be layered - * on top via `Promise.race` when callers need a hard deadline: - * `await Promise.race([sessionManager.killAll(), sleep(5_000)])` - */ - async killAll(): Promise { - const kills = [...this.ptys.keys()].map((id) => this.kill(id)); - await Promise.all(kills); - } - - /** Close admission before a server shutdown snapshots live PTYs. */ - beginShutdown(): void { - this.closing = true; - } - - /** - * Defensive liveness backstop, run periodically by the server: any - * non-exited session whose pty process is provably gone gets its exit - * synthesized. The specific transitions are already reconciled at their - * source (create/resume pre-pty failures, kill()'s missed-exit fallback), - * but node-pty has been observed to simply never fire `onExit` for a - * process that died moments after spawning (see `kill()`) — and a process - * that dies *on its own* that way has no kill()-style fallback watching - * it. This sweep is the catch-all for that and any transition not yet - * root-caused: a stale record shows as a ghost tab (non-exited status, no - * live pty) until something reconciles it. - */ - sweepDeadSessions(): void { - for (const session of [...this.sessions.values()]) { - if (session.status === "exited") continue; - const handle = this.ptys.get(session.id); - if (handle) { - // Guard against non-numeric pids (test fakes) — never probe the OS - // with a garbage value, and never declare a session dead on one. - if ( - typeof handle.pty.pid === "number" && - !this.isPidAlive(handle.pty.pid) - ) { - this.markExited(session.id, handle, null); - } - continue; - } - // No pty handle at all. Within create()/resume() there's a legitimate - // pre-spawn window where the persisted record briefly looks like this, - // so only sweep records older than the grace period (an unparseable - // lastActiveAt is garbage and sweeps immediately). - const ageMs = Date.now() - Date.parse(session.lastActiveAt); - if (!(ageMs < NO_PTY_SWEEP_GRACE_MS)) - void this.transitionExited(session, null); - } - } - - private markComposerUnsafe( - handle: PtyHandle, - pasteMayBeOpen: boolean, - ): void { - const current = this.unsafeComposers.get(handle); - this.unsafeComposers.set(handle, { - pasteMayBeOpen: current?.pasteMayBeOpen === true || pasteMayBeOpen, - }); - } - - /** - * Recover only a previously poisoned composer, before any new user or - * server-owned text is written. Closing bracketed paste (when required) and - * abandoning the line are both non-submitting operations. Failure leaves - * the handle poisoned and no caller payload is forwarded. - */ - private resetUnsafeComposer(handle: PtyHandle): boolean { - const unsafe = this.unsafeComposers.get(handle); - if (!unsafe) return true; - if (this.closing) return false; - let pasteMayBeOpen = unsafe.pasteMayBeOpen; - if (pasteMayBeOpen) { - try { - handle.pty.write(BRACKETED_PASTE_END); - this.observeTrustedTerminalInput(handle, BRACKETED_PASTE_END); - pasteMayBeOpen = false; - } catch { - return false; - } - } - try { - handle.pty.write("\x15"); - this.observeTrustedTerminalInput(handle, "\x15"); - } catch { - this.unsafeComposers.set(handle, { pasteMayBeOpen }); - return false; - } - this.unsafeComposers.delete(handle); - return true; + return handle.exited.then(() => true); } - /** Abandon a fully staged line. Ctrl-C is a safe fallback here because a - * successful full bracketed-paste write already carried its closing marker. */ - private abandonStagedLine(handle: PtyHandle): boolean { - try { - handle.pty.write("\x15"); - this.observeTrustedTerminalInput(handle, "\x15"); - return true; - } catch { - try { - handle.pty.write("\x03"); - this.observeTrustedTerminalInput(handle, "\x03"); - return true; - } catch { - this.markComposerUnsafe(handle, false); - return false; - } - } + /** + * Kills every currently-live pty and returns a Promise that resolves when + * all of them have actually exited (real or synthesized). Bounded by the + * same escalation window as `kill()` — never hangs. + * + * Call this on server shutdown so the process actually exits instead of + * waiting on orphaned claude/codex children. A bounded timeout can be layered + * on top via `Promise.race` when callers need a hard deadline: + * `await Promise.race([sessionManager.killAll(), sleep(5_000)])` + */ + async killAll(): Promise { + const kills = [...this.ptys.keys()].map((id) => this.kill(id)); + await Promise.all(kills); } /** - * Cancel only a lower-priority server-owned background submission. Unlike - * write(), this does not forward bytes or preempt an ordinary user/API - * submission. It is safe to call before staging begins; the coordinator's - * submit guard covers that side of the race. + * Defensive liveness backstop, run periodically by the server: any + * non-exited session whose pty process is provably gone gets its exit + * synthesized. The specific transitions are already reconciled at their + * source (create/resume pre-pty failures, kill()'s missed-exit fallback), + * but node-pty has been observed to simply never fire `onExit` for a + * process that died moments after spawning (see `kill()`) — and a process + * that dies *on its own* that way has no kill()-style fallback watching + * it. This sweep is the catch-all for that and any transition not yet + * root-caused: a stale record shows as a ghost tab (non-exited status, no + * live pty) until something reconciles it. */ - preemptBackgroundInput(id: string): boolean { - const staged = this.stagedInputs.get(id); - if (!staged?.background || staged.preempted) return false; - staged.preempted = true; - if (staged.textWritten) { - if (this.abandonStagedLine(staged.handle)) { - staged.lineCleared = true; + sweepDeadSessions(): void { + for (const session of [...this.sessions.values()]) { + if (session.status === "exited") continue; + const handle = this.ptys.get(session.id); + if (handle) { + // Guard against non-numeric pids (test fakes) — never probe the OS + // with a garbage value, and never declare a session dead on one. + if (typeof handle.pty.pid === "number" && !this.isPidAlive(handle.pty.pid)) { + this.markExited(session.id, handle, null); + } + continue; } + // No pty handle at all. Within create()/resume() there's a legitimate + // pre-spawn window where the persisted record briefly looks like this, + // so only sweep records older than the grace period (an unparseable + // lastActiveAt is garbage and sweeps immediately). + const ageMs = Date.now() - Date.parse(session.lastActiveAt); + if (!(ageMs < NO_PTY_SWEEP_GRACE_MS)) void this.transitionExited(session, null); } - return true; } write(id: string, data: string): boolean { @@ -2277,41 +1955,6 @@ export class SessionManager { return true; } - /** - * Internal tracked variant for retry-safe coordinator delivery. It never - * turns an ambiguous write exception into zero-byte proof: callers receive - * the furthest phase observed at the exact PTY boundary. - */ - async submitInputTracked( - id: string, - text: string, - options: Readonly<{ - canWrite?: () => boolean | Promise; - lifecycle?: Omit; - background?: boolean; - }> = {}, - ): Promise { - let phase: SessionInputWritePhase = "not-written"; - try { - const accepted = await this.submitInput( - id, - text, - true, - options.canWrite, - options.background ?? true, - { - ...options.lifecycle, - onWritePhase: (next) => { - phase = next; - }, - }, - ); - return { accepted, phase }; - } catch (error) { - return { accepted: false, phase, error }; - } - } - resize(id: string, cols: number, rows: number): boolean { const handle = this.ptys.get(id); if (!handle) return false; @@ -2417,10 +2060,7 @@ export class SessionManager { // chunk arrives. Everything before it is ordinary unsynchronized // output and can be committed now. let prefixLength = Math.min(SYNC_OUTPUT_START.length - 1, rest.length); - while ( - prefixLength > 0 && - !SYNC_OUTPUT_START.startsWith(rest.slice(-prefixLength)) - ) { + while (prefixLength > 0 && !SYNC_OUTPUT_START.startsWith(rest.slice(-prefixLength))) { prefixLength -= 1; } const outputEnd = rest.length - prefixLength; @@ -2760,6 +2400,9 @@ export class SessionManager { this.emitStatus(session); } + /** Persist a coordinator-owned metadata projection before exposing it. */ + + /** Persist the neutral project-bootstrap projection before exposing it. */ async setProjectBootstrapMetadata( id: string, @@ -3176,438 +2819,872 @@ export class SessionManager { // existing optional-detector behaviour for third-party adapters. if (mode === "immediate" && !adapter.detectBlockingPrompt) return; - const pollMs = mode === "immediate" ? READY_POLL_MS : HOOK_READY_POLL_MS; + const pollMs = mode === "immediate" ? READY_POLL_MS : HOOK_READY_POLL_MS; + + const poll = setInterval(() => { + const current = this.sessions.get(id); + if ( + !current || + this.ptys.get(id) !== handle || + current.status !== "running" + ) { + clearInterval(poll); + return; + } + if (current.ready) { + clearInterval(poll); + return; + } + if (mode === "immediate") { + if (!this.isImmediateFallbackSettled(handle)) return; + } else { + // Preserve Claude's original fallback contract exactly: 20 seconds + // from spawn and at least one output byte, without a quiet-window + // requirement layered on top. + if (Date.now() - handle.spawnedAt < HOOK_READY_FALLBACK_MS) return; + if (!handle.buffer) return; + } + const hasBlockingPrompt = + mode === "immediate" + ? this.hasImmediateBlockingPrompt(adapter, handle) + : this.hasRetainedBlockingPrompt(adapter, handle); + if (hasBlockingPrompt) return; + clearInterval(poll); + if (mode === "hook-timeout") { + console.warn( + `[harness] session ${id}: SessionStart hook never reached /ingest after ${ + HOOK_READY_FALLBACK_MS / 1000 + }s — marking ready by fallback. The hook command may be failing on this machine ` + + `(is \`node\` resolvable from the agent's hook shell?).`, + ); + } + this.setReady(id, handle.runtimeEpoch); + }, pollMs); + // Never the reason the process stays alive; tests with fake timers and + // the real server both tear down via the exited hook below anyway. + poll.unref?.(); + void handle.exited.then(() => clearInterval(poll)); + } + + /** + * Transitions a session to "exited". Shared by node-pty's own `onExit` + * callback and `kill()`'s missed-event fallback (see `kill()`) — both are + * racing to be the one that reports a given pty's death, so this is + * idempotent: a stale/duplicate call (`this.ptys.get(id) !== handle`, + * i.e. this handle was already replaced or already reported exited) is a + * silent no-op rather than double-transitioning or clobbering a newer + * session/handle that's since taken its place (e.g. a resume). + */ + private markExited( + id: string, + handle: PtyHandle, + exitCode: number | null, + ): void { + if (this.ptys.get(id) !== handle) return; + // Preserve the tail of output BEFORE the handle (and its buffer) is dropped + // — this is the only chance to keep the agent's own error line. Worth it + // only for a genuine, unprompted non-zero exit: a clean exit (0) has + // nothing to diagnose, and a death WE caused (`handle.killed`, or a + // synthesized null exit from kill()/sweep) is not a crash whose reason the + // output would explain — even if node-pty reports a non-zero signal code + // for it. Best-effort: it captures whatever onData has delivered into + // `handle.buffer` by now, which for a fast startup crash is normally the + // error banner, but a build that exits before its final chunk drains can + // leave it short (hence `sanitizeExitTail` returning null over an empty box). + const exitTail = + !handle.killed && exitCode != null && exitCode !== 0 + ? sanitizeExitTail(handle.buffer) + : null; + this.ptys.delete(id); + const identityState = this.adapterIdentityStates.get(id); + if (identityState?.runtimeEpoch === handle.runtimeEpoch) + this.adapterIdentityStates.delete(id); + this.retiredRuntimeEpochs.set(id, handle.runtimeEpoch); + this.lastActivityBroadcast.delete(id); + // Resolve after the pty map is cleaned up. transitionExited runs + // synchronously to set status before any awaiting continuation resumes. + handle.resolveExited(); + const session = this.sessions.get(id); + if (!session) return; + void this.transitionExited(session, exitCode, { + exitTail, + runtimeEpoch: handle.runtimeEpoch, + }); + } + + /** + * The single place a session record flips to "exited" — shared by + * `markExited()` (live-pty deaths), `create()`/`resume()`'s pre-pty + * failure reconciliation, `kill()`'s stale-record path, and + * `sweepDeadSessions()`. Returns the persist promise so callers that need + * the registry durably updated before rethrowing (create/resume) can + * await it; event-driven callers fire-and-forget it like any other write. + */ + private transitionExited( + session: HarnessSession, + exitCode: number | null, + { + stampLastActive = true, + exitTail = null, + runtimeEpoch = null, + }: { + stampLastActive?: boolean; + exitTail?: string | null; + runtimeEpoch?: string | null; + } = {}, + ): Promise { + this.revokeIngestToken(session.id); + try { + void Promise.resolve(this.onAgentMapSessionExit?.(session.id)).catch( + () => {}, + ); + } catch { + // Capability cleanup never delays durable session reconciliation. + } + session.status = "exited"; + session.exitCode = exitCode; + // Only markExited (a live-pty death) has output to preserve; every other + // caller (pre-pty create/resume failure, kill()'s ghost path, the sweep) + // passes nothing, which clears any stale tail from a previous life — a + // resume that fails before spawning must not still show the last crash's. + session.exitTail = exitTail; + // `stampLastActive: false` is for reconciling a resume that never got a + // pty: "we noticed it's dead" is not activity, and stamping it there is + // what made an untouched session's duration grow on every failed Resume. + if (stampLastActive) session.lastActiveAt = this.now(); + const persisted = this.persist(); + this.emitStatus(session, runtimeEpoch); + return persisted; + } + + private emitStatus( + session: HarnessSession, + runtimeEpoch = this.getRuntimeEpoch(session.id), + ): void { + this.statusEmitter.emit( + "status", + { ...session }, + { runtimeEpoch } satisfies SessionStatusContext, + ); + } + + private agentSessionIdentityDigest(agentSessionId: string): string { + return createHash("sha256").update(agentSessionId, "utf8").digest("hex"); + } + + private serializeAgentSessionIdentity( + operation: () => Promise, + ): Promise { + const next = this.agentSessionIdentityQueue + .catch(() => {}) + .then(operation); + this.agentSessionIdentityQueue = next.then( + () => {}, + () => {}, + ); + return next; + } - const poll = setInterval(() => { - const current = this.sessions.get(id); + /** Load the private alias ledger before accepting ingest. Missing is the + * one recoverable case (upgrade from an older build): current pointers seed + * it exactly once, with sessions.json order deterministically choosing the + * first owner of a legacy duplicate. Malformed/unreadable ledger state still + * fails boot rather than silently forgetting rotation tombstones. */ + private async loadAgentSessionOwners( + persisted: HarnessSession[], + ): Promise { + let needsWrite = false; + let pointersDirty = false; + try { + const raw = await readFile(this.agentSessionOwnersPath, "utf8"); + if (Buffer.byteLength(raw, "utf8") > AGENT_SESSION_OWNER_MAX_BYTES) { + throw new Error("agent-session owner ledger exceeds its size limit"); + } + const parsed = JSON.parse(raw) as unknown; if ( - !current || - this.ptys.get(id) !== handle || - current.status !== "running" + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) || + Object.keys(parsed).sort().join(",") !== "owners,version" ) { - clearInterval(poll); - return; + throw new Error("agent-session owner ledger has an invalid shape"); } - if (current.ready) { - clearInterval(poll); - return; + const record = parsed as { version?: unknown; owners?: unknown }; + if ( + record.version !== AGENT_SESSION_OWNER_FILE_VERSION || + typeof record.owners !== "object" || + record.owners === null || + Array.isArray(record.owners) + ) { + throw new Error( + "agent-session owner ledger has an unsupported version", + ); } - if (mode === "immediate") { - if (!this.isImmediateFallbackSettled(handle)) return; - } else { - // Preserve Claude's original fallback contract exactly: 20 seconds - // from spawn and at least one output byte, without a quiet-window - // requirement layered on top. - if (Date.now() - handle.spawnedAt < HOOK_READY_FALLBACK_MS) return; - if (!handle.buffer) return; + const entries = Object.entries(record.owners as Record); + if (entries.length > AGENT_SESSION_OWNER_MAX_ENTRIES) { + throw new Error("agent-session owner ledger exceeds its entry limit"); } - const hasBlockingPrompt = - mode === "immediate" - ? this.hasImmediateBlockingPrompt(adapter, handle) - : this.hasRetainedBlockingPrompt(adapter, handle); - if (hasBlockingPrompt) return; - clearInterval(poll); - if (mode === "hook-timeout") { - console.warn( - `[harness] session ${id}: SessionStart hook never reached /ingest after ${ - HOOK_READY_FALLBACK_MS / 1000 - }s — marking ready by fallback. The hook command may be failing on this machine ` + - `(is \`node\` resolvable from the agent's hook shell?).`, - ); + for (const [digest, ownerId] of entries) { + if ( + !/^[a-f0-9]{64}$/u.test(digest) || + typeof ownerId !== "string" || + ownerId.length === 0 || + ownerId.length > 256 + ) { + throw new Error( + "agent-session owner ledger contains an invalid entry", + ); + } + this.agentSessionOwners.set(digest, ownerId); } - this.setReady(id, handle.runtimeEpoch); - }, pollMs); - // Never the reason the process stays alive; tests with fake timers and - // the real server both tear down via the exited hook below anyway. - poll.unref?.(); - void handle.exited.then(() => clearInterval(poll)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + needsWrite = true; + } + + // Preserve persisted array order: released versions could create two rows + // with the same vendor id, so the first recorded owner wins the one-time + // upgrade. When a ledger already exists it is authoritative instead. + for (const session of persisted) { + if (!session.agentSessionId) continue; + const digest = this.agentSessionIdentityDigest(session.agentSessionId); + const ownerId = this.agentSessionOwners.get(digest); + if (ownerId !== undefined && ownerId !== session.id) { + session.agentSessionId = null; + pointersDirty = true; + continue; + } + if (ownerId === undefined) { + this.agentSessionOwners.set(digest, session.id); + needsWrite = true; + } + } + if (needsWrite) await this.persistAgentSessionOwners(); + return pointersDirty; + } + + private async reserveAgentSessionIdentity( + digest: string, + sessionId: string, + ): Promise { + const ownerId = this.agentSessionOwners.get(digest); + if (ownerId === sessionId) return; + if (ownerId !== undefined) { + throw new AgentSessionIdentityReservedError(); + } + this.agentSessionOwners.set(digest, sessionId); + // Atomic rename can commit and still surface an ambiguous later I/O + // failure. Retain the in-memory claim so this process never lets another + // session overwrite a possibly durable owner. A clean pre-commit failure + // therefore also fails closed until restart, which is the safe tradeoff. + await this.persistAgentSessionOwners(); + } + + private async persistAgentSessionOwners(): Promise { + const owners = Object.fromEntries( + [...this.agentSessionOwners.entries()].sort(([a], [b]) => + a.localeCompare(b), + ), + ); + const serialized = `${JSON.stringify( + { version: AGENT_SESSION_OWNER_FILE_VERSION, owners }, + null, + 2, + )}\n`; + if (Buffer.byteLength(serialized, "utf8") > AGENT_SESSION_OWNER_MAX_BYTES) { + throw new Error("agent-session owner ledger exceeds its size limit"); + } + if (this.writeAgentSessionOwnerRegistry) { + await this.writeAgentSessionOwnerRegistry( + this.agentSessionOwnersPath, + serialized, + ); + return; + } + await mkdir(dirname(this.agentSessionOwnersPath), { recursive: true }); + const tmpPath = `${this.agentSessionOwnersPath}.tmp-${process.pid}-${ + this.agentSessionOwnerWriteSeq++ + }`; + await writeFile(tmpPath, serialized, { encoding: "utf8", mode: 0o600 }); + await rename(tmpPath, this.agentSessionOwnersPath); + } + + private async loadSubsessionBindings(): Promise { + let decoded: unknown; + try { + const raw = await readFile(this.subsessionBindingsPath, "utf8"); + if (Buffer.byteLength(raw, "utf8") > SUBSESSION_BINDING_MAX_BYTES) + throw new Error("subsession binding registry exceeds its size limit"); + decoded = JSON.parse(raw) as unknown; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if ( + !isRecord(decoded) || + Object.keys(decoded).sort().join(",") !== + "closedSessionIds,markers,version" || + decoded.version !== SUBSESSION_BINDING_FILE_VERSION || + !isRecord(decoded.markers) || + !Array.isArray(decoded.closedSessionIds) || + decoded.closedSessionIds.length > SUBSESSION_BINDING_MAX_ENTRIES || + !decoded.closedSessionIds.every( + (sessionId) => typeof sessionId === "string", + ) + ) { + throw new Error("subsession binding registry is malformed"); + } + const entries = Object.entries(decoded.markers); + if (entries.length > SUBSESSION_BINDING_MAX_ENTRIES) + throw new Error("subsession binding registry exceeds its entry limit"); + const bindingIds = new Set(); + for (const [sessionId, value] of entries) { + const marker = parseTrustedSubsessionBindingMarker(value, sessionId); + if (!marker || bindingIds.has(marker.bindingId)) + throw new Error("subsession binding registry is malformed"); + bindingIds.add(marker.bindingId); + this.subsessionBindings.set(sessionId, marker); + } + for (const sessionId of decoded.closedSessionIds) { + if (!this.subsessionBindings.has(sessionId)) + throw new Error("subsession binding registry is malformed"); + this.userClosedSubsessions.add(sessionId); + } + } + + private async persistSubsessionBindings(): Promise { + const markers = Object.fromEntries( + [...this.subsessionBindings.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([sessionId, marker]) => [sessionId, marker]), + ); + const serialized = `${JSON.stringify( + { + version: SUBSESSION_BINDING_FILE_VERSION, + markers, + closedSessionIds: [...this.userClosedSubsessions].sort(), + }, + null, + 2, + )}\n`; + if (Buffer.byteLength(serialized, "utf8") > SUBSESSION_BINDING_MAX_BYTES) + throw new Error("subsession binding registry exceeds its size limit"); + if (this.writeSubsessionBindingRegistry) { + await this.writeSubsessionBindingRegistry( + this.subsessionBindingsPath, + serialized, + ); + return; + } + const directory = dirname(this.subsessionBindingsPath); + await mkdir(directory, { recursive: true }); + const temporary = `${this.subsessionBindingsPath}.tmp-${process.pid}-${ + this.subsessionBindingWriteSeq++ + }`; + let handle: Awaited> | undefined; + try { + handle = await open(temporary, "wx", 0o600); + await handle.writeFile(serialized, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, this.subsessionBindingsPath); + await chmod(this.subsessionBindingsPath, 0o600); + const directoryHandle = await open(directory, "r"); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + } finally { + await handle?.close().catch(() => {}); + await rm(temporary, { force: true }).catch(() => {}); + } } - /** - * Transitions a session to "exited". Shared by node-pty's own `onExit` - * callback and `kill()`'s missed-event fallback (see `kill()`) — both are - * racing to be the one that reports a given pty's death, so this is - * idempotent: a stale/duplicate call (`this.ptys.get(id) !== handle`, - * i.e. this handle was already replaced or already reported exited) is a - * silent no-op rather than double-transitioning or clobbering a newer - * session/handle that's since taken its place (e.g. a resume). - */ - private markExited( - id: string, - handle: PtyHandle, - exitCode: number | null, - ): void { - if (this.ptys.get(id) !== handle) return; - // Preserve the tail of output BEFORE the handle (and its buffer) is dropped - // — this is the only chance to keep the agent's own error line. Worth it - // only for a genuine, unprompted non-zero exit: a clean exit (0) has - // nothing to diagnose, and a death WE caused (`handle.killed`, or a - // synthesized null exit from kill()/sweep) is not a crash whose reason the - // output would explain — even if node-pty reports a non-zero signal code - // for it. Best-effort: it captures whatever onData has delivered into - // `handle.buffer` by now, which for a fast startup crash is normally the - // error banner, but a build that exits before its final chunk drains can - // leave it short (hence `sanitizeExitTail` returning null over an empty box). - const exitTail = - !handle.killed && exitCode != null && exitCode !== 0 - ? sanitizeExitTail(handle.buffer) - : null; - this.ptys.delete(id); - const identityState = this.adapterIdentityStates.get(id); - if (identityState?.runtimeEpoch === handle.runtimeEpoch) - this.adapterIdentityStates.delete(id); - this.retiredRuntimeEpochs.set(id, handle.runtimeEpoch); - this.lastActivityBroadcast.delete(id); - // Resolve after the pty map is cleaned up. transitionExited runs - // synchronously to set status before any awaiting continuation resumes. - handle.resolveExited(); - const session = this.sessions.get(id); - if (!session) return; - void this.transitionExited(session, exitCode, { - exitTail, - runtimeEpoch: handle.runtimeEpoch, - }); + private persistIdentityCandidate(candidate: HarnessSession): Promise { + const current = this.list(); + const index = current.findIndex((session) => session.id === candidate.id); + const proposed = + index === -1 + ? [...current, candidate] + : current.map((session) => + session.id === candidate.id ? candidate : session, + ); + return this.enqueueRegistryWrite(JSON.stringify(proposed, null, 2) + "\n"); } - /** - * The single place a session record flips to "exited" — shared by - * `markExited()` (live-pty deaths), `create()`/`resume()`'s pre-pty - * failure reconciliation, `kill()`'s stale-record path, and - * `sweepDeadSessions()`. Returns the persist promise so callers that need - * the registry durably updated before rethrowing (create/resume) can - * await it; event-driven callers fire-and-forget it like any other write. - */ - private transitionExited( - session: HarnessSession, - exitCode: number | null, - { - stampLastActive = true, - exitTail = null, - runtimeEpoch = null, - }: { - stampLastActive?: boolean; - exitTail?: string | null; - runtimeEpoch?: string | null; - } = {}, + private enqueueRegistryWrite( + serialized: string | (() => string), + fence?: Promise, ): Promise { - this.revokeIngestToken(session.id); - try { - void Promise.resolve(this.onAgentMapSessionExit?.(session.id)).catch( - () => {}, + const run = async (): Promise => { + if (fence) await fence; + const content = + typeof serialized === "string" ? serialized : serialized(); + if (this.writeSessionRegistry) { + await this.writeSessionRegistry(this.sessionsPath, content); + return; + } + await mkdir(dirname(this.sessionsPath), { recursive: true }); + const tmpPath = `${this.sessionsPath}.tmp-${process.pid}-${this.writeSeq++}`; + await writeFile(tmpPath, content, "utf8"); + await rename(tmpPath, this.sessionsPath); + }; + const next = this.writeQueue.catch(() => {}).then(run); + this.writeQueue = next.catch(() => {}); + return next; + } + + /** Serializes writes so overlapping persist() calls can't interleave and + * corrupt the registry file; a failed write doesn't poison later ones. */ + private persist(): Promise { + const fence = this.sessionRegistryIdentityFence; + if (fence) { + // Capturing an ordinary mutation while a private identity candidate is + // unresolved could persist the uncommitted pointer (if memory were + // mutated) or overwrite a successful commit with its old value. Wait, + // then snapshot the authoritative published map at execution time. + return this.enqueueRegistryWrite( + () => JSON.stringify(this.list(), null, 2) + "\n", + fence, ); - } catch { - // Capability cleanup never delays durable session reconciliation. } - session.status = "exited"; - session.exitCode = exitCode; - // Only markExited (a live-pty death) has output to preserve; every other - // caller (pre-pty create/resume failure, kill()'s ghost path, the sweep) - // passes nothing, which clears any stale tail from a previous life — a - // resume that fails before spawning must not still show the last crash's. - session.exitTail = exitTail; - // `stampLastActive: false` is for reconciling a resume that never got a - // pty: "we noticed it's dead" is not activity, and stamping it there is - // what made an untouched session's duration grow on every failed Resume. - if (stampLastActive) session.lastActiveAt = this.now(); - const persisted = this.persist(); - this.emitStatus(session, runtimeEpoch); - return persisted; + // Outside an identity transaction, capture at call time: queued writes + // represent the mutation that requested them, not an unrelated later one. + return this.enqueueRegistryWrite( + JSON.stringify(this.list(), null, 2) + "\n", + ); } - private emitStatus( - session: HarnessSession, - runtimeEpoch = this.getRuntimeEpoch(session.id), - ): void { - this.statusEmitter.emit( - "status", - { ...session }, - { runtimeEpoch } satisfies SessionStatusContext, + private readonly prepareProjectSession: SessionManagerOptions["prepareProjectSession"]; + + private readonly onProjectBootstrapSession: SessionManagerOptions["onProjectBootstrapSession"]; + + /** Publish project sessions in claim order so the first durable/visible row + * is also the one that owns the first-session lifecycle. */ + private readonly projectCreateQueues = new Map>(); + + private readonly pendingCreates = new Map< + string, + Pick + >(); + + + /** Read-only vendor-history probe used before a coordinator claims recovery. */ + async canResumeSession(id: string): Promise { + const session = this.sessions.get(id); + if (!session?.agentSessionId) return false; + return this.getAdapter(session.harness).canResume( + session.agentSessionId, + session.cwd, ); } - private agentSessionIdentityDigest(agentSessionId: string): string { - return createHash("sha256").update(agentSessionId, "utf8").digest("hex"); - } - private serializeAgentSessionIdentity( + private serializeProjectCreate( + projectId: string, operation: () => Promise, ): Promise { - const next = this.agentSessionIdentityQueue.catch(() => {}).then(operation); - this.agentSessionIdentityQueue = next.then( + const prior = this.projectCreateQueues.get(projectId) ?? Promise.resolve(); + const next = prior.catch(() => {}).then(operation); + const settled = next.then( () => {}, () => {}, ); + this.projectCreateQueues.set(projectId, settled); + void settled.then(() => { + if (this.projectCreateQueues.get(projectId) === settled) { + this.projectCreateQueues.delete(projectId); + } + }); return next; } - /** Load the private alias ledger before accepting ingest. Missing is the - * one recoverable case (upgrade from an older build): current pointers seed - * it exactly once, with sessions.json order deterministically choosing the - * first owner of a legacy duplicate. Malformed/unreadable ledger state still - * fails boot rather than silently forgetting rotation tombstones. */ - private async loadAgentSessionOwners( - persisted: HarnessSession[], - ): Promise { - let needsWrite = false; - let pointersDirty = false; - try { - const raw = await readFile(this.agentSessionOwnersPath, "utf8"); - if (Buffer.byteLength(raw, "utf8") > AGENT_SESSION_OWNER_MAX_BYTES) { - throw new Error("agent-session owner ledger exceeds its size limit"); - } - const parsed = JSON.parse(raw) as unknown; - if ( - typeof parsed !== "object" || - parsed === null || - Array.isArray(parsed) || - Object.keys(parsed).sort().join(",") !== "owners,version" - ) { - throw new Error("agent-session owner ledger has an invalid shape"); - } - const record = parsed as { version?: unknown; owners?: unknown }; - if ( - record.version !== AGENT_SESSION_OWNER_FILE_VERSION || - typeof record.owners !== "object" || - record.owners === null || - Array.isArray(record.owners) - ) { - throw new Error( - "agent-session owner ledger has an unsupported version", - ); - } - const entries = Object.entries(record.owners as Record); - if (entries.length > AGENT_SESSION_OWNER_MAX_ENTRIES) { - throw new Error("agent-session owner ledger exceeds its entry limit"); - } - for (const [digest, ownerId] of entries) { - if ( - !/^[a-f0-9]{64}$/u.test(digest) || - typeof ownerId !== "string" || - ownerId.length === 0 || - ownerId.length > 256 - ) { - throw new Error( - "agent-session owner ledger contains an invalid entry", - ); + + /** Keep ordinary project roots discoverable during asynchronous launch + * preparation, before their session rows can be published. Server-only. */ + listPendingCreates(): Pick[] { + return [...this.pendingCreates.values()]; + } + + /** + * Server-only reserved-ID create. The private marker is committed before a + * session row or process can exist, closing the row-before-binding crash + * window while preserving the ordinary writable create path. + */ + async createReserved( + reservedSessionId: string, + req: CreateSessionRequest, + markerInput: TrustedSubsessionBindingMarker, + trusted: TrustedSessionCreateOptions, + ): Promise { + const marker = parseTrustedSubsessionBindingMarker( + markerInput, + reservedSessionId, + ); + if (!marker) throw new SubsessionBindingMismatchError(); + const operation = async (): Promise => { + const existingMarker = this.subsessionBindings.get(reservedSessionId); + const existingSession = this.sessions.get(reservedSessionId); + if (existingMarker) { + if (!sameSubsessionBinding(existingMarker, marker)) + throw new SubsessionBindingMismatchError(); + if (this.userClosedSubsessions.has(reservedSessionId)) + throw new SubsessionFreshRestartForbiddenError(); + if (existingSession) return existingSession; + } else { + if (existingSession) throw new SubsessionBindingMismatchError(); + this.subsessionBindings.set(reservedSessionId, marker); + try { + await this.persistSubsessionBindings(); + } catch (error) { + if (this.subsessionBindings.get(reservedSessionId) === marker) + this.subsessionBindings.delete(reservedSessionId); + throw error; } - this.agentSessionOwners.set(digest, ownerId); } - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; - needsWrite = true; - } + return this.createWithId(reservedSessionId, req, trusted, marker); + }; + const next = this.subsessionBindingQueue.catch(() => {}).then(operation); + this.subsessionBindingQueue = next.then( + () => undefined, + () => undefined, + ); + return next; + } - // Preserve persisted array order: released versions could create two rows - // with the same vendor id, so the first recorded owner wins the one-time - // upgrade. When a ledger already exists it is authoritative instead. - for (const session of persisted) { - if (!session.agentSessionId) continue; - const digest = this.agentSessionIdentityDigest(session.agentSessionId); - const ownerId = this.agentSessionOwners.get(digest); - if (ownerId !== undefined && ownerId !== session.id) { - session.agentSessionId = null; - pointersDirty = true; - continue; + getSubsessionBinding( + sessionId: string, + ): TrustedSubsessionBindingMarker | null { + const marker = this.subsessionBindings.get(sessionId); + return marker ? structuredClone(marker) : null; + } + + matchesSubsessionBinding( + expected: TrustedSubsessionBindingMarker, + ): boolean { + const parsed = parseTrustedSubsessionBindingMarker( + expected, + expected.sessionId, + ); + const current = parsed + ? this.subsessionBindings.get(parsed.sessionId) + : undefined; + return Boolean(parsed && current && sameSubsessionBinding(current, parsed)); + } + + wasSubsessionClosedByUser( + expected: TrustedSubsessionBindingMarker, + ): boolean { + return ( + this.matchesSubsessionBinding(expected) && + this.userClosedSubsessions.has(expected.sessionId) + ); + } + + private async createWithId( + id: string, + req: CreateSessionRequest, + trusted: TrustedSessionCreateOptions, + expectedSubsessionBinding?: TrustedSubsessionBindingMarker, + ): Promise { + if (this.closing) throw new SessionManagerClosingError(); + const marker = this.subsessionBindings.get(id); + if ( + (marker !== undefined || expectedSubsessionBinding !== undefined) && + (!marker || + !expectedSubsessionBinding || + !sameSubsessionBinding(marker, expectedSubsessionBinding)) + ) { + throw new SubsessionBindingMismatchError(); + } + if (this.sessions.has(id)) throw new SubsessionBindingMismatchError(); + const adapter = this.getAdapter(req.harness); + const trustedIdentity = trusted.agentMapIdentity?.(id); + const agentMapIdentity = this.resolveAgentMapIdentity + ? await this.resolveAgentMapIdentity(id, req.cwd, trustedIdentity) + : trustedIdentity; + const createResolved = async (): Promise => { + // A project create may have been waiting behind another publication + // when shutdown closed admission. Refuse it before claiming bootstrap, + // issuing capabilities, or writing generated session state. + if (this.closing) throw new SessionManagerClosingError(); + let preparedProjectSession: + | Awaited< + ReturnType< + NonNullable + > + > + | undefined; + let opts: LaunchOpts; + let spec: SpawnSpec; + try { + preparedProjectSession = + agentMapIdentity && this.prepareProjectSession + ? await this.prepareProjectSession(agentMapIdentity, req) + : undefined; + if ( + trusted.requireProjectBootstrapClaim && + !preparedProjectSession?.projectBootstrap + ) { + throw new ProjectBootstrapClaimUnavailableError(); + } + // Retain only AFTER claiming first-session ownership. Advertising the + // root earlier could start an automatic session ahead of this request. + this.pendingCreates.set(id, { cwd: req.cwd, agentMapIdentity }); + const promptAppendix = trusted.promptAppendix?.(id); + const focusedContext = trusted.focusedContext?.(id); + if (focusedContext && !agentMapIdentity) + throw new TypeError("Focused project context requires a project-agent identity"); + const sessionStartSystemMessage = + trusted.sessionStartSystemMessage?.(id); + const launchContext = + promptAppendix || focusedContext || sessionStartSystemMessage || agentMapIdentity + ? { + ...(promptAppendix ? { promptAppendix } : {}), + ...(focusedContext ? { focusedContext } : {}), + ...(sessionStartSystemMessage + ? { sessionStartSystemMessage } + : {}), + ...(agentMapIdentity ? { agentMapIdentity } : {}), + } + : undefined; + opts = { + harnessSessionId: id, + cwd: req.cwd, + ...(req.initialPrompt ? { initialPrompt: req.initialPrompt } : {}), + ...(await (launchContext + ? this.buildLaunchOpts(id, req, launchContext) + : this.buildLaunchOpts(id, req))), + }; + spec = adapter.launch(opts); + } catch (error) { + // Scope resolution may already have claimed bootstrap ownership, and + // launch preparation may already have issued a capability. Revoke both + // for every setup failure, including prompt composition/config writes, + // while preserving the original actionable error. + await Promise.resolve(this.onAgentMapSessionExit?.(id)).catch(() => {}); + throw error; } - if (ownerId === undefined) { - this.agentSessionOwners.set(digest, session.id); - needsWrite = true; + const projectBootstrap = preparedProjectSession?.projectBootstrap; + const session: HarnessSession = { + id, + agentSessionId: null, + harness: req.harness, + cwd: req.cwd, + title: + trusted.initialTitle ?? + preparedProjectSession?.initialTitle ?? + (basename(req.cwd) || req.cwd), + status: "starting", + createdAt: this.now(), + lastActiveAt: this.now(), + exitCode: null, + boundWorkflowPath: null, + // Ordinary callers record only what the builder actually rehydrated. + // A trusted planner replacement records its exact FIFO predecessor even + // when the brief came from an older recorded ancestor in that chain. + rehydratedFrom: + trusted.handoffFromSessionId ?? opts.rehydratedFrom ?? null, + // Persisted so resume() regenerates the same ANSI base — otherwise a + // resumed session would fall back to the server default and its dim text + // could lose contrast against a differently-themed terminal. + ...(req.theme ? { theme: req.theme } : {}), + ready: false, + ...(projectBootstrap + ? { projectBootstrap: structuredClone(projectBootstrap) } + : {}), + ...(agentMapIdentity + ? { agentMapIdentity: structuredClone(agentMapIdentity) } + : {}), + }; + this.sessions.set(id, session); + try { + await this.persist(); + // Before spawning, not fire-and-forget: the agent's very first read of + // HARNESS_CONTEXT_FILE must never race session creation with an ENOENT, + // regardless of which entry point called create() (REST, autoCreateSession). + await this.writeWorkspaceContext(session); + // Same reasoning: the canvas pane opens immediately once the session is + // "running" — it must never show a bare empty iframe because nothing's + // been written to .sapiom/canvas/index.html yet. + await this.ensureCanvasTemplate(session.cwd); + await this.spawn(session, spec, () => + this.revalidateAgentMapIdentity( + session.id, + session.cwd, + agentMapIdentity, + ), + ); + if (session.projectBootstrap) { + const runtimeEpoch = this.getRuntimeEpoch(session.id); + if (runtimeEpoch === null) throw new Error("session runtime unavailable"); + await Promise.resolve( + this.onProjectBootstrapSession?.(session, "created", runtimeEpoch), + ).catch(() => {}); + } + } catch (err) { + // The first persist may itself be the failure, so reconciliation is + // best-effort: always repair the in-memory record to "exited", attempt + // the durable repair, and preserve the original actionable failure if + // that second write also fails. + await this.transitionExited(session, null).catch(() => {}); + throw err; } + return session; + }; + try { + return await (agentMapIdentity + ? this.serializeProjectCreate(agentMapIdentity.projectId, createResolved) + : createResolved()); + } finally { + this.pendingCreates.delete(id); } - if (needsWrite) await this.persistAgentSessionOwners(); - return pointersDirty; } - private async reserveAgentSessionIdentity( - digest: string, - sessionId: string, - ): Promise { - const ownerId = this.agentSessionOwners.get(digest); - if (ownerId === sessionId) return; - if (ownerId !== undefined) { - throw new AgentSessionIdentityReservedError(); + /** + * Narrow recovery for an exact coordinator-owned row that exited before its + * first turn and has no resumable vendor conversation. The Harness ID stays + * fixed; the private marker advances before a fresh PTY can be admitted. + */ + async restartFreshBound( + id: string, + expected: TrustedSubsessionBindingMarker, + nextInput: TrustedSubsessionBindingMarker, + trusted: TrustedSessionCreateOptions, + hasRecordedTurns: (sessionId: string) => Promise, + ): Promise { + if (this.closing) throw new SessionManagerClosingError(); + const currentExpected = parseTrustedSubsessionBindingMarker(expected, id); + const next = parseTrustedSubsessionBindingMarker(nextInput, id); + const current = this.subsessionBindings.get(id); + const session = this.sessions.get(id); + if ( + !currentExpected || + !next || + !current || + !session || + (current.projectId !== currentExpected.projectId || + current.parentSessionId !== currentExpected.parentSessionId || + current.bindingId !== currentExpected.bindingId || + current.sessionId !== currentExpected.sessionId) || + next.projectId !== currentExpected.projectId || + next.parentSessionId !== currentExpected.parentSessionId || + next.bindingId !== currentExpected.bindingId || + next.sessionId !== currentExpected.sessionId || + next.incarnation !== currentExpected.incarnation + 1 || + next.spawnEpoch <= currentExpected.spawnEpoch || + this.ptys.has(id) || + session.status !== "exited" + ) { + throw new SubsessionBindingMismatchError(); } - this.agentSessionOwners.set(digest, sessionId); - // Atomic rename can commit and still surface an ambiguous later I/O - // failure. Retain the in-memory claim so this process never lets another - // session overwrite a possibly durable owner. A clean pre-commit failure - // therefore also fails closed until restart, which is the safe tradeoff. - await this.persistAgentSessionOwners(); - } - - private async persistAgentSessionOwners(): Promise { - const owners = Object.fromEntries( - [...this.agentSessionOwners.entries()].sort(([a], [b]) => - a.localeCompare(b), - ), - ); - const serialized = `${JSON.stringify( - { version: AGENT_SESSION_OWNER_FILE_VERSION, owners }, - null, - 2, - )}\n`; - if (Buffer.byteLength(serialized, "utf8") > AGENT_SESSION_OWNER_MAX_BYTES) { - throw new Error("agent-session owner ledger exceeds its size limit"); + if (this.userClosedSubsessions.has(id)) + throw new SubsessionFreshRestartForbiddenError(); + // A retry may observe the already-advanced marker after the sidecar write + // committed but before the fresh process existed. + if ( + !sameSubsessionBinding(current, currentExpected) && + !sameSubsessionBinding(current, next) + ) { + throw new SubsessionBindingMismatchError(); } - if (this.writeAgentSessionOwnerRegistry) { - await this.writeAgentSessionOwnerRegistry( - this.agentSessionOwnersPath, - serialized, - ); - return; + const adapter = this.getAdapter(session.harness); + if ( + (session.agentSessionId !== null && + (await adapter.canResume(session.agentSessionId, session.cwd))) || + (await hasRecordedTurns(id)) + ) { + throw new SubsessionFreshRestartForbiddenError(); } - await mkdir(dirname(this.agentSessionOwnersPath), { recursive: true }); - const tmpPath = `${this.agentSessionOwnersPath}.tmp-${process.pid}-${this - .agentSessionOwnerWriteSeq++}`; - await writeFile(tmpPath, serialized, { encoding: "utf8", mode: 0o600 }); - await rename(tmpPath, this.agentSessionOwnersPath); - } - private async loadSubsessionBindings(): Promise { - let decoded: unknown; - try { - const raw = await readFile(this.subsessionBindingsPath, "utf8"); - if (Buffer.byteLength(raw, "utf8") > SUBSESSION_BINDING_MAX_BYTES) - throw new Error("subsession binding registry exceeds its size limit"); - decoded = JSON.parse(raw) as unknown; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; + if (!sameSubsessionBinding(current, next)) { + this.subsessionBindings.set(id, next); + try { + await this.persistSubsessionBindings(); + } catch (error) { + this.subsessionBindings.set(id, current); + throw error; + } } + + const trustedIdentity = trusted.agentMapIdentity?.(id); + const agentMapIdentity = this.resolveAgentMapIdentity + ? await this.resolveAgentMapIdentity(id, session.cwd, trustedIdentity) + : trustedIdentity; if ( - !isRecord(decoded) || - Object.keys(decoded).sort().join(",") !== - "closedSessionIds,markers,version" || - decoded.version !== SUBSESSION_BINDING_FILE_VERSION || - !isRecord(decoded.markers) || - !Array.isArray(decoded.closedSessionIds) || - decoded.closedSessionIds.length > SUBSESSION_BINDING_MAX_ENTRIES || - !decoded.closedSessionIds.every( - (sessionId) => typeof sessionId === "string", - ) + !agentMapIdentity || + agentMapIdentity.projectId !== next.projectId || + agentMapIdentity.sessionId !== id ) { - throw new Error("subsession binding registry is malformed"); - } - const entries = Object.entries(decoded.markers); - if (entries.length > SUBSESSION_BINDING_MAX_ENTRIES) - throw new Error("subsession binding registry exceeds its entry limit"); - const bindingIds = new Set(); - for (const [sessionId, value] of entries) { - const marker = parseTrustedSubsessionBindingMarker(value, sessionId); - if (!marker || bindingIds.has(marker.bindingId)) - throw new Error("subsession binding registry is malformed"); - bindingIds.add(marker.bindingId); - this.subsessionBindings.set(sessionId, marker); - } - for (const sessionId of decoded.closedSessionIds) { - if (!this.subsessionBindings.has(sessionId)) - throw new Error("subsession binding registry is malformed"); - this.userClosedSubsessions.add(sessionId); + throw new ProjectSessionScopeUnavailableError(id); } - } - private async persistSubsessionBindings(): Promise { - const markers = Object.fromEntries( - [...this.subsessionBindings.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([sessionId, marker]) => [sessionId, marker]), - ); - const serialized = `${JSON.stringify( - { - version: SUBSESSION_BINDING_FILE_VERSION, - markers, - closedSessionIds: [...this.userClosedSubsessions].sort(), - }, - null, - 2, - )}\n`; - if (Buffer.byteLength(serialized, "utf8") > SUBSESSION_BINDING_MAX_BYTES) - throw new Error("subsession binding registry exceeds its size limit"); - if (this.writeSubsessionBindingRegistry) { - await this.writeSubsessionBindingRegistry( - this.subsessionBindingsPath, - serialized, - ); - return; + const lastActiveBeforeRestart = session.lastActiveAt; + session.status = "starting"; + session.exitCode = null; + session.exitTail = null; + session.agentSessionId = null; + session.agentMapIdentity = structuredClone(agentMapIdentity); + session.lastActiveAt = this.now(); + let spec: SpawnSpec; + try { + const promptAppendix = trusted.promptAppendix?.(id); + const focusedContext = trusted.focusedContext?.(id); + const sessionStartSystemMessage = + trusted.sessionStartSystemMessage?.(id); + const context = { + ...(promptAppendix ? { promptAppendix } : {}), + ...(focusedContext ? { focusedContext } : {}), + ...(sessionStartSystemMessage + ? { sessionStartSystemMessage } + : {}), + agentMapIdentity, + }; + const opts: LaunchOpts = { + harnessSessionId: id, + cwd: session.cwd, + ...(await this.buildLaunchOpts(id, session, context)), + }; + spec = adapter.launch(opts); + } catch (error) { + session.status = "exited"; + session.lastActiveAt = lastActiveBeforeRestart; + await Promise.resolve(this.onAgentMapSessionExit?.(id)).catch(() => {}); + throw error; } - const directory = dirname(this.subsessionBindingsPath); - await mkdir(directory, { recursive: true }); - const temporary = `${this.subsessionBindingsPath}.tmp-${process.pid}-${ - this.subsessionBindingWriteSeq++ - }`; - let handle: Awaited> | undefined; try { - handle = await open(temporary, "wx", 0o600); - await handle.writeFile(serialized, "utf8"); - await handle.sync(); - await handle.close(); - handle = undefined; - await rename(temporary, this.subsessionBindingsPath); - await chmod(this.subsessionBindingsPath, 0o600); - const directoryHandle = await open(directory, "r"); - try { - await directoryHandle.sync(); - } finally { - await directoryHandle.close(); - } - } finally { - await handle?.close().catch(() => {}); - await rm(temporary, { force: true }).catch(() => {}); + await this.persist(); + this.emitStatus(session); + await this.writeWorkspaceContext(session); + await this.ensureCanvasTemplate(session.cwd); + await this.spawn(session, spec, () => + this.revalidateAgentMapIdentity(id, session.cwd, agentMapIdentity), + ); + return session; + } catch (error) { + session.lastActiveAt = lastActiveBeforeRestart; + await this.transitionExited(session, null, { + stampLastActive: false, + }).catch(() => {}); + throw error; } } +} - private persistIdentityCandidate(candidate: HarnessSession): Promise { - const current = this.list(); - const index = current.findIndex((session) => session.id === candidate.id); - const proposed = - index === -1 - ? [...current, candidate] - : current.map((session) => - session.id === candidate.id ? candidate : session, - ); - return this.enqueueRegistryWrite(JSON.stringify(proposed, null, 2) + "\n"); - } - private enqueueRegistryWrite( - serialized: string | (() => string), - fence?: Promise, - ): Promise { - const run = async (): Promise => { - if (fence) await fence; - const content = - typeof serialized === "string" ? serialized : serialized(); - if (this.writeSessionRegistry) { - await this.writeSessionRegistry(this.sessionsPath, content); - return; - } - await mkdir(dirname(this.sessionsPath), { recursive: true }); - const tmpPath = `${this.sessionsPath}.tmp-${process.pid}-${this.writeSeq++}`; - await writeFile(tmpPath, content, "utf8"); - await rename(tmpPath, this.sessionsPath); - }; - const next = this.writeQueue.catch(() => {}).then(run); - this.writeQueue = next.catch(() => {}); - return next; - } - /** Serializes writes so overlapping persist() calls can't interleave and - * corrupt the registry file; a failed write doesn't poison later ones. */ - private persist(): Promise { - const fence = this.sessionRegistryIdentityFence; - if (fence) { - // Capturing an ordinary mutation while a private identity candidate is - // unresolved could persist the uncommitted pointer (if memory were - // mutated) or overwrite a successful commit with its old value. Wait, - // then snapshot the authoritative published map at execution time. - return this.enqueueRegistryWrite( - () => JSON.stringify(this.list(), null, 2) + "\n", - fence, - ); - } - // Outside an identity transaction, capture at call time: queued writes - // represent the mutation that requested them, not an unrelated later one. - return this.enqueueRegistryWrite( - JSON.stringify(this.list(), null, 2) + "\n", - ); +/** An automatic first-session create lost the project bootstrap claim. */ +export class ProjectBootstrapClaimUnavailableError extends Error { + readonly code = "PROJECT_BOOTSTRAP_CLAIM_UNAVAILABLE"; + + constructor() { + super("the project bootstrap claim is already owned by another session"); + this.name = "ProjectBootstrapClaimUnavailableError"; } } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/harness/src/core/studio-project-catalog.test.ts b/packages/harness/src/core/studio-project-catalog.test.ts index c50906c12..521d03e53 100644 --- a/packages/harness/src/core/studio-project-catalog.test.ts +++ b/packages/harness/src/core/studio-project-catalog.test.ts @@ -160,6 +160,64 @@ describe("StudioProjectCatalog", () => { ).toEqual(new Set([project.projectId])); }); + it("loads and persists legacy Windows case aliases without replacing project identity", async () => { + const { catalogPath } = await fixture(); + const catalog = new StudioProjectCatalog(catalogPath); + const project = await catalog.create("Legacy Windows project"); + await catalog.addRootBinding(project.projectId, "C:\\Work\\Project"); + const raw = JSON.parse(await fs.readFile(catalogPath, "utf8")); + const originalBinding = raw.projects[0].rootBindings[0]; + raw.projects[0].rootBindings.push({ + ...originalBinding, + id: "root_00000000-0000-4000-8000-000000000099", + localRootRef: "c:\\work\\project", + }); + await fs.writeFile(catalogPath, JSON.stringify(raw)); + + const restarted = new StudioProjectCatalog(catalogPath); + expect(await restarted.list()).toEqual([ + expect.objectContaining({ + projectId: project.projectId, + bindings: [{ id: originalBinding.id, status: "active" }], + }), + ]); + expect(await restarted.resolveIdentityForPath("c:\\WORK\\PROJECT\\src")) + .toMatchObject({ projectId: project.projectId }); + await restarted.reconcile([{ workspaceKey: "legacy", cwd: "c:\\work\\project" }]); + const persisted = JSON.parse(await fs.readFile(catalogPath, "utf8")); + expect(persisted.projects[0].rootBindings).toEqual([originalBinding]); + expect((await new StudioProjectCatalog(catalogPath).list())[0]?.projectId) + .toBe(project.projectId); + }); + + it("preserves separate legacy project identities when Windows roots become ambiguous", async () => { + const { catalogPath } = await fixture(); + const catalog = new StudioProjectCatalog(catalogPath); + const first = await catalog.create("First legacy project"); + const second = await catalog.create("Second legacy project"); + await catalog.addRootBinding(first.projectId, "C:\\Work\\Project"); + await catalog.addRootBinding(second.projectId, "D:\\Work\\Project"); + const raw = JSON.parse(await fs.readFile(catalogPath, "utf8")); + raw.projects.find((entry: { projectId: string }) => entry.projectId === second.projectId) + .rootBindings[0].localRootRef = "c:\\work\\project"; + await fs.writeFile(catalogPath, JSON.stringify(raw)); + + const restarted = new StudioProjectCatalog(catalogPath); + expect((await restarted.list()).map(({ projectId }) => projectId).sort()) + .toEqual([first.projectId, second.projectId].sort()); + expect(await restarted.resolveIdentityForPath("C:\\Work\\Project\\src")).toBeNull(); + const result = await restarted.reconcile([ + { workspaceKey: "ambiguous", cwd: "C:\\Work\\Project" }, + { workspaceKey: "unrelated", cwd: "/unrelated-project" }, + ]); + expect(result.workspaceScopes.find(({ workspaceKey }) => workspaceKey === "ambiguous")) + .toEqual({ workspaceKey: "ambiguous", cwd: "C:\\Work\\Project" }); + expect(result.workspaceScopes.find(({ workspaceKey }) => workspaceKey === "unrelated")?.projectId) + .toMatch(/^project_/); + expect(await restarted.resolve(first.projectId)).not.toBeNull(); + expect(await restarted.resolve(second.projectId)).not.toBeNull(); + }); + it("keeps project identity across a root move and an additional repository binding", async () => { const { root, catalogPath } = await fixture(); const originalRoot = path.join(root, "old-name"); diff --git a/packages/harness/src/core/studio-project-catalog.ts b/packages/harness/src/core/studio-project-catalog.ts index fcf32a0a2..b7816ba5f 100644 --- a/packages/harness/src/core/studio-project-catalog.ts +++ b/packages/harness/src/core/studio-project-catalog.ts @@ -201,8 +201,7 @@ function parseProject(value: unknown): StudioProjectIdentity | null { const keys = value.legacyWorkspaceKeys as string[]; if ( new Set(bindings.map((binding) => binding.id)).size !== bindings.length || - new Set(bindings.map((binding) => pathComparisonKey(binding.localRootRef))) - .size !== bindings.length || + new Set(bindings.map((binding) => binding.localRootRef)).size !== bindings.length || new Set(keys).size !== keys.length ) { return null; @@ -218,7 +217,7 @@ function parseProject(value: unknown): StudioProjectIdentity | null { }; } -function parseCatalog(value: unknown): PersistedStudioProjectCatalog { +function parseCatalog(value: unknown): PersistedStudioProjectCatalog & { migrated: boolean } { if ( isRecord(value) && Number.isSafeInteger(value.schemaVersion) && @@ -250,9 +249,7 @@ function parseCatalog(value: unknown): PersistedStudioProjectCatalog { project.rootBindings.map((binding) => binding.id), ); const roots = parsed.flatMap((project) => - project.rootBindings.map((binding) => - pathComparisonKey(binding.localRootRef), - ), + project.rootBindings.map((binding) => binding.localRootRef), ); const legacyKeys = parsed.flatMap((project) => project.legacyWorkspaceKeys); if ( @@ -264,9 +261,29 @@ function parseCatalog(value: unknown): PersistedStudioProjectCatalog { ) { throw new StudioProjectCatalogError("malformed_state"); } + // Older catalogs allowed differently cased Windows spellings of one root. + // Validate that persisted format first, then collapse aliases within their + // existing project. Keep separate project IDs: their map state cannot be + // merged implicitly, and ambiguous roots remain unassigned by reconcile. + let migrated = false; + for (const project of parsed) { + const bindings = new Map(); + for (const binding of project.rootBindings) { + const key = pathComparisonKey(binding.localRootRef); + const previous = bindings.get(key); + if (previous) { + if (binding.status === "active") previous.status = "active"; + migrated = true; + } else { + bindings.set(key, binding); + } + } + project.rootBindings = [...bindings.values()]; + } return { schemaVersion: STUDIO_PROJECT_CATALOG_SCHEMA_VERSION, projects: parsed, + migrated, }; } @@ -338,6 +355,7 @@ export class StudioProjectCatalog { private projects: StudioProjectIdentity[] | null = null; private loadPromise: Promise | null = null; private mutationQueue: Promise = Promise.resolve(); + private migrationPending = false; constructor( private readonly catalogPath: string, @@ -354,6 +372,12 @@ export class StudioProjectCatalog { // the cross-instance lock so a whole-catalog atomic rewrite includes // identities committed by another live host. await this.load(true); + if (this.migrationPending) { + // Read-only callers can use repaired identities immediately. Commit + // the repair only under the same cross-host lock as other writes. + await this.persist(this.projects!); + this.migrationPending = false; + } return await operation(); } finally { await release(); @@ -390,6 +414,7 @@ export class StudioProjectCatalog { } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { this.projects = []; + this.migrationPending = false; return; } throw storageError(); @@ -400,7 +425,9 @@ export class StudioProjectCatalog { } catch { throw new StudioProjectCatalogError("malformed_state"); } - this.projects = parseCatalog(decoded).projects; + const parsed = parseCatalog(decoded); + this.projects = parsed.projects; + this.migrationPending = parsed.migrated; })().finally(() => { this.loadPromise = null; }); @@ -620,7 +647,8 @@ export class StudioProjectCatalog { ), ); if (matchingProjects.length > 1) { - throw new StudioProjectCatalogError("malformed_state"); + unassignedScopes.push({ workspaceKey: scope.workspaceKey, cwd: scope.cwd }); + continue; } let project = matchingProjects[0]; if (!project) { diff --git a/packages/harness/src/core/studio-workspace-preferences.test.ts b/packages/harness/src/core/studio-workspace-preferences.test.ts index 84b9fa8be..216c88381 100644 --- a/packages/harness/src/core/studio-workspace-preferences.test.ts +++ b/packages/harness/src/core/studio-workspace-preferences.test.ts @@ -38,6 +38,131 @@ describe("StudioWorkspacePreferenceStore", () => { }; } + it("keeps a scaffolded sibling in its creating project across scans and restart", async () => { + const value = await fixture(); + const projectRoot = path.join(value.root, "project"); + const sibling = { + name: "Reviewer", + path: path.join(value.root, "reviewer"), + definitionId: null, + }; + const store = new StudioWorkspacePreferenceStore(value.file); + const created = await store.registerCreatedAgent( + value.projectId, + "session-a", + sibling, + ); + expect(created).toBe(true); + const first = await store.current( + "user", + value.projectId, + [projectRoot], + [sibling], + true, + ); + expect(first.agents).toHaveLength(1); + await store.put( + "user", + value.projectId, + { + kind: "agent", + projectId: value.projectId, + agentId: first.agents[0]!.agentId, + }, + [projectRoot], + [sibling], + true, + ); + // A complete scan of the project directory says nothing about its sibling. + expect( + await store.current("user", value.projectId, [projectRoot], [], true), + ).toMatchObject({ repaired: false }); + expect( + await store.put( + "user", + value.projectId, + { + kind: "agent", + projectId: value.projectId, + agentId: first.agents[0]!.agentId, + }, + [projectRoot], + [], + true, + ), + ).toMatchObject({ repaired: false }); + const restarted = new StudioWorkspacePreferenceStore(value.file); + await restarted.registerCreatedAgent(value.projectId, "session-a", sibling); + const restored = await restarted.current( + "user", + value.projectId, + [projectRoot], + [sibling], + true, + ); + expect(restored.agents).toEqual(first.agents); + expect(restored.selection).toMatchObject({ + kind: "agent", + agentId: first.agents[0]!.agentId, + }); + expect(JSON.stringify(restored)).not.toContain(sibling.path); + expect(await restarted.createdAgents()).toMatchObject([ + { + projectId: value.projectId, + path: sibling.path, + createdBySessionId: "session-a", + }, + ]); + }); + + it("does not let another project claim a created agent, even through a containing root", async () => { + const value = await fixture(); + const foreign = "project_00000000-0000-4000-8000-000000000002"; + const store = new StudioWorkspacePreferenceStore(value.file); + await store.registerCreatedAgent( + value.projectId, + "session-a", + value.workflows[0]!, + ); + expect( + await store.registerCreatedAgent( + foreign, + "session-b", + value.workflows[0]!, + ), + ).toBe(false); + expect( + ( + await store.current( + "user", + foreign, + [value.root], + value.workflows, + true, + ) + ).agents, + ).toEqual([]); + }); + + it("does not take over an agent already assigned to another project", async () => { + const value = await fixture(); + const store = new StudioWorkspacePreferenceStore(value.file); + await store.current( + "user", + value.projectId, + [value.root], + value.workflows, + true, + ); + expect( + await store.registerCreatedAgent( + "project_00000000-0000-4000-8000-000000000002", + "session-b", + value.workflows[0]!, + ), + ).toBe(false); + }); + it("defaults to map and restores an opaque agent selection after restart", async () => { const value = await fixture(); const projectRoot = path.join(value.root, "project"); diff --git a/packages/harness/src/core/studio-workspace-preferences.ts b/packages/harness/src/core/studio-workspace-preferences.ts index 7b063ae9a..7c6116741 100644 --- a/packages/harness/src/core/studio-workspace-preferences.ts +++ b/packages/harness/src/core/studio-workspace-preferences.ts @@ -17,6 +17,8 @@ interface PrivateAgentBinding extends StudioWorkspaceAgentSummary { projectId: StudioProjectId; /** Private reconciliation evidence. Never returned by this store. */ path: string; + /** Successful scaffold ownership, independent of physical root containment. */ + createdBySessionId?: string; updatedAt: string; } @@ -124,6 +126,9 @@ function parseState(value: unknown): PersistedPreferences { !candidate.name || typeof candidate.path !== "string" || !candidate.path || + (candidate.createdBySessionId !== undefined && + (typeof candidate.createdBySessionId !== "string" || + !candidate.createdBySessionId)) || (candidate.definitionId !== null && !Number.isSafeInteger(candidate.definitionId)) || !validTimestamp(candidate.updatedAt) @@ -207,11 +212,18 @@ export class StudioWorkspacePreferenceStore { agentBindings: state.agentBindings.map((binding) => ({ ...binding })), }; let changed = false; - const eligible = workflows.filter((workflow) => - roots.some( - (root) => workspaceRelativeLocalKey(root, workflow.path) !== null, - ), - ); + const eligible = workflows.filter((workflow) => { + const created = state.agentBindings.find( + (binding) => + binding.path === workflow.path && binding.createdBySessionId, + ); + // Explicit creation ownership wins over a broader physical parent root. + return created + ? created.projectId === projectId + : roots.some( + (root) => workspaceRelativeLocalKey(root, workflow.path) !== null, + ); + }); const agents = eligible.map((workflow) => { let binding = next.agentBindings.find( (candidate) => @@ -257,6 +269,8 @@ export class StudioWorkspacePreferenceStore { const retained = next.agentBindings.filter( (binding) => binding.projectId !== projectId || + // A scan of the project root cannot disprove a sibling's existence. + Boolean(binding.createdBySessionId) || activeAgentIds.has(binding.agentId), ); if (retained.length !== next.agentBindings.length) { @@ -291,7 +305,17 @@ export class StudioWorkspacePreferenceStore { !requested || requested.kind !== "agent" || reconciled.agents.some((agent) => agent.agentId === requested.agentId); - const repaired = Boolean(preference && !valid && scanComplete); + const createdSelection = + requested?.kind === "agent" && + reconciled.state.agentBindings.some( + (binding) => + binding.projectId === projectId && + binding.agentId === requested.agentId && + binding.createdBySessionId, + ); + const repaired = Boolean( + preference && !valid && scanComplete && !createdSelection, + ); const selection: StudioWorkspaceSelection = preference && valid ? preference.selection @@ -312,6 +336,49 @@ export class StudioWorkspacePreferenceStore { }); } + /** Server-only: called after authenticated, successful scaffold evidence. */ + async registerCreatedAgent( + projectId: StudioProjectId, + sessionId: string, + workflow: SelectableWorkflow, + ): Promise { + return this.enqueue(async () => { + const state = await this.load(); + const existing = state.agentBindings.filter( + (binding) => binding.path === workflow.path, + ); + if (existing.some((binding) => binding.projectId !== projectId)) + return false; + if (existing.some((binding) => binding.createdBySessionId)) return true; + const binding: PrivateAgentBinding = { + ...workflow, + projectId, + agentId: existing[0]?.agentId ?? `agent_${randomUUID()}`, + createdBySessionId: sessionId, + updatedAt: this.now().toISOString(), + }; + await this.persist({ + ...state, + agentBindings: [ + ...state.agentBindings.filter( + (candidate) => candidate.path !== workflow.path, + ), + binding, + ], + }); + return true; + }); + } + + /** Private exact paths to observe on restart; never added as project roots. */ + async createdAgents(): Promise> { + return this.enqueue(async () => + (await this.load()).agentBindings + .filter((binding) => binding.createdBySessionId) + .map((binding) => ({ ...binding })), + ); + } + async put( userId: string, projectId: StudioProjectId, @@ -351,6 +418,14 @@ export class StudioWorkspacePreferenceStore { binding.projectId === projectId && binding.agentId === normalized.agentId, ); + const createdAgent = + normalized.kind === "agent" && + reconciled.state.agentBindings.some( + (binding) => + binding.projectId === projectId && + binding.agentId === normalized.agentId && + binding.createdBySessionId, + ); // A degraded scan cannot disprove a server-issued opaque id. Accept a // binding the private store still knows, and leave an unknown request's // previous durable preference untouched until a complete scan can judge @@ -359,9 +434,11 @@ export class StudioWorkspacePreferenceStore { sameProject && (normalized.kind === "agent-map" || visibleAgent || - (!scanComplete && privatelyKnownAgent)); + ((!scanComplete || createdAgent) && privatelyKnownAgent)); const absenceUnproven = - sameProject && normalized.kind === "agent" && !scanComplete; + sameProject && + normalized.kind === "agent" && + (!scanComplete || createdAgent); const selection: StudioWorkspaceSelection = accepted ? normalized : absenceUnproven @@ -445,6 +522,9 @@ export class StudioWorkspacePreferenceStore { changed = true; return { ...binding, + // An explicit disk move is a new ownership decision. Reconcile at + // the destination; do not replay the old creation path into it. + createdBySessionId: undefined, path: relative === "" ? target : path.join(target, relative), updatedAt: this.now().toISOString(), }; diff --git a/packages/harness/src/core/subsession-coordinator-store.test.ts b/packages/harness/src/core/subsession-coordinator-store.test.ts index e4a6ccb38..fc647be56 100644 --- a/packages/harness/src/core/subsession-coordinator-store.test.ts +++ b/packages/harness/src/core/subsession-coordinator-store.test.ts @@ -1116,6 +1116,42 @@ describe("SubsessionCoordinatorStore", () => { ); }); + it("retains unfinished dormant cleanup beyond receipt and tombstone retention", async () => { + const root = await fixture(); + const options = { receiptRetentionLimit: 1, historyTombstoneLimit: 1 }; + const store = new SubsessionCoordinatorStore(root, options); + const reserved = await store.reserveDelegations(identity, delegate("cleanup-history", [ + { delegationKey: "research", outcome: "Collect evidence" }, + { delegationKey: "publisher", outcome: "Publish evidence" }, + ]), target); + for (const binding of reserved.bindings) { + await store.transitionSession(identity, binding.bindingId, { + expectedLifecycleEpoch: binding.lifecycleEpoch, + expectedSpawnEpoch: binding.spawnEpoch, + expectedRuntimeToken: null, + state: "failed", + }); + } + const [first, second] = reserved.bindings; + await store.reserveDormantReleases(identity, releaseDormant("cleanup-first", 1), [first!.bindingId]); + await store.reserveDormantReleases(identity, releaseDormant("cleanup-second", 1), [second!.bindingId]); + + const restarted = new SubsessionCoordinatorStore(root, options); + expect((await restarted.read(projectId)).bindingTombstones).toHaveLength(2); + await expect(restarted.reserveDormantReleases(identity, releaseDormant("cleanup-first", 1), [])) + .rejects.toMatchObject({ code: "request_key_expired" }); + const retry = await restarted.reserveDormantReleases(identity, + releaseDormant("cleanup-retry", 1), [first!.bindingId]); + expect(retry.bindings).toMatchObject([{ + state: "released", binding: { bindingId: first!.bindingId }, + }]); + await restarted.reserveDormantReleases(identity, releaseDormant("advance-cleanup", 1), []); + await restarted.completeDormantReleaseCleanup(identity, first!.bindingId, first!.sessionId); + const remaining = (await restarted.read(projectId)).bindingTombstones; + expect(remaining).toHaveLength(1); + expect(remaining[0]?.bindingId).toBe(second!.bindingId); + }); + it("reclaims released capacity so a sixty-fifth delegation can be reserved", async () => { const root = await fixture(); const store = new SubsessionCoordinatorStore(root, { diff --git a/packages/harness/src/core/subsession-coordinator-store.ts b/packages/harness/src/core/subsession-coordinator-store.ts index d739264e4..98e8b32c7 100644 --- a/packages/harness/src/core/subsession-coordinator-store.ts +++ b/packages/harness/src/core/subsession-coordinator-store.ts @@ -93,6 +93,8 @@ export type SubsessionCoordinatorBindingTombstone = Readonly<{ bindingDigest: string; sessionId: string; disposition: "terminal" | "dormant-evicted"; + /** Absent on older records: retain dormant eviction proof until verified. */ + cleanupComplete?: true; closedAt: string; }>; @@ -560,6 +562,7 @@ function parseBindingTombstone( "sessionId", "disposition", "closedAt", + ...("cleanupComplete" in value ? ["cleanupComplete"] : []), ]) || !identifier(value.bindingId, "binding") || !identifier(value.parentSessionId) || @@ -572,6 +575,8 @@ function parseBindingTombstone( !digest(value.bindingDigest) || !identifier(value.sessionId) || !["terminal", "dormant-evicted"].includes(String(value.disposition)) || + ("cleanupComplete" in value && + (value.cleanupComplete !== true || value.disposition !== "dormant-evicted")) || !timestamp(value.closedAt) ) { throw new SubsessionCoordinatorStoreError("malformed_state"); @@ -814,13 +819,16 @@ export class SubsessionCoordinatorStore { if (aggregate.bindingTombstones.length > historyLimit) { let remaining = aggregate.bindingTombstones.length - historyLimit; aggregate.bindingTombstones = aggregate.bindingTombstones.filter( - ({ bindingId }) => { - if (remaining === 0 || referenced.has(bindingId)) return true; + ({ bindingId, disposition, cleanupComplete }) => { + if (remaining === 0 || referenced.has(bindingId) || + (disposition === "dormant-evicted" && !cleanupComplete)) return true; remaining -= 1; return false; }, ); } + if (aggregate.bindingTombstones.length > SUBSESSION_COORDINATOR_BINDING_LIMIT) + throw new SubsessionCoordinatorStoreError("history_quota_exceeded"); if (reclaimable.length > 0) { const reclaimed = new Set(reclaimable.map(({ bindingId }) => bindingId)); aggregate.bindings = aggregate.bindings.filter( @@ -1341,6 +1349,29 @@ export class SubsessionCoordinatorStore { }); } + /** Release eviction proof only after the exact private session close succeeds. */ + completeDormantReleaseCleanup( + identity: ProjectAgentSession, + bindingId: SubsessionBindingId, + expectedSessionId: string, + ): Promise { + return this.transact(identity.projectId, async (aggregate) => { + const index = aggregate.bindingTombstones.findIndex( + (entry) => entry.bindingId === bindingId, + ); + const binding = aggregate.bindingTombstones[index]; + if (!binding) return { value: undefined }; + if (binding.sessionId !== expectedSessionId || binding.disposition !== "dormant-evicted") + throw new SubsessionCoordinatorStoreError("binding_scope_mismatch"); + if (binding.cleanupComplete) return { value: undefined }; + aggregate.bindingTombstones[index] = { ...binding, cleanupComplete: true }; + this.compactTerminalHistory(aggregate); + aggregate.recordVersion += 1; + aggregate.updatedAt = this.now(); + return { value: undefined, next: aggregate }; + }); + } + /** Server-only bridge from SessionManager's private two-sided marker. */ closeOwnedBinding(marker: Readonly<{ projectId: StudioProjectId; diff --git a/packages/harness/src/core/subsession-coordinator.test.ts b/packages/harness/src/core/subsession-coordinator.test.ts index 75c6560f9..e67977cfa 100644 --- a/packages/harness/src/core/subsession-coordinator.test.ts +++ b/packages/harness/src/core/subsession-coordinator.test.ts @@ -698,6 +698,61 @@ describe("SubsessionCoordinator", () => { ).toHaveLength(1); }); + it("recovers unfinished dormant cleanup under a new key after its receipt expires", async () => { + const { coordinator, newCoordinator, caller, manager, store, spawned, telemetry, unsubscribe } = + await fixture(false, undefined, {}, { + receiptRetentionLimit: 1, + historyTombstoneLimit: 1, + }); + const created = await coordinator.execute(caller, request); + const childId = created.results[0]!.sessionId!; + const binding = (await store.read(projectId)).bindings[0]!; + // A failed coordinator binding can still have a live process whose exact + // private close must finish after the durable eviction has committed. + await store.transitionSession(caller, binding.bindingId, { + expectedLifecycleEpoch: binding.lifecycleEpoch, + expectedSpawnEpoch: binding.spawnEpoch, + expectedRuntimeToken: binding.runtime?.runtimeToken ?? null, + state: "failed", + }); + vi.spyOn(manager, "closeBound") + .mockRejectedValueOnce(new SubsessionBindingMismatchError()); + const partial = await coordinator.execute(caller, dormantReleaseRequest); + expect(partial.results[0]).toMatchObject({ + sessionId: childId, + outcome: "released", + error: { code: "binding_session_mismatch" }, + }); + + await store.reserveDormantReleases(caller, { + ...dormantReleaseRequest, + requestKey: "advance-cleanup-receipts", + }, []); + await expect(coordinator.execute(caller, dormantReleaseRequest)) + .rejects.toMatchObject({ detail: { code: "request_key_expired" } }); + expect(manager.get(childId)?.status).toBe("running"); + expect(manager.getSubsessionBinding(childId)).not.toBeNull(); + + const recovering = newCoordinator("restarted-cleanup-owner").execute(caller, { + ...dormantReleaseRequest, + requestKey: "retry-unfinished-cleanup", + }); + await vi.waitFor(() => expect(spawned[1]!.pty.kill).toHaveBeenCalledTimes(1)); + spawned[1]!.emitExit(0); + const recovered = await recovering; + unsubscribe(); + + expect(recovered).toMatchObject({ + replayed: false, + results: [{ sessionId: childId, outcome: "released" }], + }); + expect(recovered.results[0]?.error).toBeUndefined(); + expect(manager.getSubsessionBinding(childId)).toBeNull(); + expect(telemetry.filter((event) => + event.name === "subsession.released" && event.sessionId === childId, + )).toHaveLength(1); + }); + it.each(["exited", "failed"] as const)( "releases an already-%s child without spawning or resuming it", async (terminalState) => { diff --git a/packages/harness/src/core/subsession-coordinator.ts b/packages/harness/src/core/subsession-coordinator.ts index 255a906b2..ca5d0394a 100644 --- a/packages/harness/src/core/subsession-coordinator.ts +++ b/packages/harness/src/core/subsession-coordinator.ts @@ -307,10 +307,17 @@ export class SubsessionCoordinator { let reserved; try { const aggregate = await this.options.store.read(identity.projectId); - const candidateBindingIds = aggregate.bindings - .filter(({ sessionState }) => + const pendingCleanup = aggregate.bindingTombstones + .filter(({ disposition, cleanupComplete }) => + disposition === "dormant-evicted" && !cleanupComplete, + ); + const candidates = [ + ...pendingCleanup.map((binding) => ({ ...binding, updatedAt: binding.closedAt })), + ...aggregate.bindings.filter(({ sessionState }) => ["exited", "failed"].includes(sessionState), - ) + ), + ]; + const candidateBindingIds = candidates .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt) || left.bindingId.localeCompare(right.bindingId), @@ -375,6 +382,12 @@ export class SubsessionCoordinator { } await this.options.sessionManager.closeBound(privateMarker); } + if (binding.disposition === "dormant-evicted") + await this.options.store.completeDormantReleaseCleanup( + identity, + binding.bindingId, + binding.sessionId, + ); results.push(this.releasedResult(binding)); } catch (cause) { const detail = this.itemError(cause); diff --git a/packages/harness/src/profiles/default.test.ts b/packages/harness/src/profiles/default.test.ts index 3b0205654..b3ac9d378 100644 --- a/packages/harness/src/profiles/default.test.ts +++ b/packages/harness/src/profiles/default.test.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; import { describe, it, expect } from "vitest"; -import { DEFAULT_SYSTEM_PROMPT } from "./default.js"; +import { DEFAULT_SYSTEM_PROMPT, resolveKnownSystemPrompt } from "./default.js"; const sha256 = (content: string) => createHash("sha256").update(content, "utf8").digest("hex"); @@ -11,7 +12,9 @@ const sha256 = (content: string) => * the copy it serves, so the two move together — see the drift-guard test below. */ const PINNED_PROMPT_DIGEST = - "f9128ff6afed47242b7bc7946b2e1dab20627171371191cdd2c45537198ce8ed"; + "e9453ef4262205c54a6d1d4833cdaa963259ef7bd9e035167ca0cf93a1b502b6"; + +const legacy = readFileSync(new URL("./fixtures/legacy-system-prompt.md", import.meta.url), "utf8").trim(); describe("DEFAULT_SYSTEM_PROMPT", () => { it("stays byte-identical to the copy the backend serves (cross-repo pin, SAP-2810)", () => { @@ -37,4 +40,38 @@ describe("DEFAULT_SYSTEM_PROMPT", () => { expect(DEFAULT_SYSTEM_PROMPT).toContain("Local Run, Prod Run, and Deploy"); expect(DEFAULT_SYSTEM_PROMPT).toContain("sapiom_send_feedback"); }); + + it("orients to the project map without delaying a clear first request", () => { + expect(DEFAULT_SYSTEM_PROMPT).toContain("agent-map"); + expect(DEFAULT_SYSTEM_PROMPT).toContain("shared project Agent Map"); + expect(DEFAULT_SYSTEM_PROMPT).toContain("clear task"); + expect(DEFAULT_SYSTEM_PROMPT).not.toContain("two Sapiom MCP servers"); + expect(DEFAULT_SYSTEM_PROMPT).not.toContain("The two MCPs"); + expect(DEFAULT_SYSTEM_PROMPT).not.toContain("then stop"); + }); +}); + +describe("known served-prompt compatibility", () => { + it("pins the one historical revision that is safe to upgrade", () => { + expect(sha256(legacy)).toBe("f9128ff6afed47242b7bc7946b2e1dab20627171371191cdd2c45537198ce8ed"); + expect(resolveKnownSystemPrompt(`\n${legacy}\n`)).toBe(DEFAULT_SYSTEM_PROMPT); + }); + + it("never replaces unknown, custom, or newer remote guidance", () => { + for (const prompt of ["custom profile", DEFAULT_SYSTEM_PROMPT, + `${legacy}\nNew runtime instructions from the backend.`]) { + expect(resolveKnownSystemPrompt(prompt)).toBe(prompt); + } + }); + + it("preserves the legacy runtime, feedback, and workspace guidance verbatim", () => { + for (const [start, end] of [ + ["**Calling LLMs", "**The authoring loop"], + ["**Your current workspace state", "**In your very first reply"], + ]) { + const block = legacy.slice(legacy.indexOf(start), legacy.indexOf(end)).trim(); + expect(block.length).toBeGreaterThan(100); + expect(DEFAULT_SYSTEM_PROMPT).toContain(block); + } + }); }); diff --git a/packages/harness/src/profiles/default.ts b/packages/harness/src/profiles/default.ts index 72f5d0833..1dae853d7 100644 --- a/packages/harness/src/profiles/default.ts +++ b/packages/harness/src/profiles/default.ts @@ -1,17 +1,18 @@ +import { createHash } from "node:crypto"; + /** * Default system prompt, appended to the coding agent's own instructions via * `--append-system-prompt`. Orients a fresh session to the Sapiom-specific * conventions the harness adds on top of a stock coding agent. Written to be - * assertive, not just informative — first-user feedback showed the prompt - * was being injected (confirmed via ps) but behaviorally invisible, so the - * closing line asks for one visible signal that it actually loaded. + * shared by the CLI and desktop hosts. Authoring and runtime guidance is primary; + * orientation should help the user start, never delay a clear first request. */ export const DEFAULT_SYSTEM_PROMPT = ` You are the coding agent running in Agent Studio. This is not a stock coding session — -you have two Sapiom MCP servers pre-wired, and the conventions below are +you have Sapiom MCP servers pre-wired, and the conventions below are active for the whole session. Follow them. -**The two MCPs, and when to use each:** +**The MCPs, and when to use each:** - **sapiom** (remote, HTTP) — the paid capability surface an agent calls at *runtime* from inside a deployed agent's step code (ctx.sapiom.*): repositories, sandboxes, LLM calls (see below), and so on. You don't call @@ -21,6 +22,9 @@ active for the whole session. Follow them. and Prod Run are authenticated cloud operations. Use its sapiom_dev_agents_* tools to author and ship agents, and sapiom_authenticate / sapiom_status if you need to sign in. +- **agent-map** (local, HTTP, in a Studio project) — shared project Agent Map, + build-plan, and writable subsession tools. These support agent delivery; + they do not replace the authoring tools or execute deployed agents. **Calling LLMs from agent code:** one-shot call → \`ctx.sapiom.llm.run\`; a platform-driven multi-turn loop → \`ctx.sapiom.models.run\` (never for a @@ -47,17 +51,20 @@ contents, logs, or secrets. capability spend, while the code's own side effects remain real) → link (associate the project with a hosted agent) → deploy (push, build, go live). Read a project's AGENTS.md before touching its steps — it documents that project's specifics. +Stop at the stage the user requested; a local-only task does not authorize deployment. **Canvas convention:** the canvas pane renders the selected agent's step graph automatically and deterministically — the harness extracts it from the agent's manifest and draws the diagram (nodes, edges, a summary and annotations) server-side: no LLM, no tokens, identical every time. You do NOT author or edit any canvas HTML, and there is nothing to write under -\`.sapiom/canvas/\`. When someone asks to "visualize this agent" or "how -does everything connect", make sure the agent is selected in the workspace +\`.sapiom/canvas/\`. When someone asks to "visualize this agent", make sure +the agent is selected in the workspace rail. The Canvas follows that selection and refreshes automatically when the source changes. Local Run, Prod Run, and Deploy are available in the selected -agent's action bar. +agent's action bar. For how multiple agents, resources, and artifacts connect, +use the shared project Agent Map instead: it is maintained through project +tools, not automatically inferred from source edits. **Your current workspace state:** Agent Studio mirrors what it knows about this workspace at \`.sapiom/harness-context.json\`, relative to your working @@ -71,18 +78,24 @@ ask what agents exist — both fields can change mid-session (a new selection, a newly scanned/connected project), so re-read the file rather than assuming it's still what it was earlier in the conversation. -**In your very first reply this session**, orient the person before you get -to their actual request — briefly, 2-4 sentences total, not a lecture: -1. Acknowledge that you're the coding agent in Agent Studio with these MCPs - available (one line), so they can see this loaded. -2. Say what you can do for them here: inspect the selected agent on its - automatically generated Canvas, start a local agent run against stub - capabilities at no cost, start a production agent run after deployment, - and deploy it live. The exact action-bar controls are Local Run, Prod Run, - and Deploy. -3. Suggest ONE concrete first step, picked from the workspace state file - above: if an agent is bound or listed (e.g. the bundled order-triage - sample project), offer by name to inspect its Canvas or start a Local Run; if none - exists yet, offer to scaffold a new agent project. Phrase it as an - invitation ("want me to…?"), then stop — don't act on it unprompted. +**Your first reply:** if the user supplied a clear task, briefly acknowledge +it and proceed within its scope; do not ask them to repeat or reconfirm it. +If they have not supplied a task, use the workspace state to offer one concrete +next step: scaffold their first agent, or inspect/test an existing agent by name. +Keep orientation to 1-2 relevant sentences: author and test agents here, inspect +the per-agent Canvas or shared project Agent Map, and deploy when requested. +Do not assume a sample project exists or recite every tool. `.trim(); + +/** + * A published backend may still serve this exact older bundled profile. Upgrade + * only that known revision at materialization time: broad text replacement could + * erase newer remote authoring/runtime instructions or a host's custom profile. + * Keep the legacy fixture/digest fixed when the current prompt pin moves. + */ +export function resolveKnownSystemPrompt(prompt: string): string { + const digest = createHash("sha256").update(prompt.trim(), "utf8").digest("hex"); + return digest === "f9128ff6afed47242b7bc7946b2e1dab20627171371191cdd2c45537198ce8ed" + ? DEFAULT_SYSTEM_PROMPT + : prompt; +} diff --git a/packages/harness/src/profiles/fixtures/legacy-system-prompt.md b/packages/harness/src/profiles/fixtures/legacy-system-prompt.md new file mode 100644 index 000000000..682d64176 --- /dev/null +++ b/packages/harness/src/profiles/fixtures/legacy-system-prompt.md @@ -0,0 +1,78 @@ +You are the coding agent running in Agent Studio. This is not a stock coding session — +you have two Sapiom MCP servers pre-wired, and the conventions below are +active for the whole session. Follow them. + +**The two MCPs, and when to use each:** +- **sapiom** (remote, HTTP) — the paid capability surface an agent calls at + *runtime* from inside a deployed agent's step code (ctx.sapiom.*): + repositories, sandboxes, LLM calls (see below), and so on. You don't call + this directly while authoring. +- **sapiom-dev** (local, stdio) — the developer surface for this session. Its + scaffold, check, and Local Run path uses no Sapiom capability spend; Deploy + and Prod Run are authenticated cloud operations. Use its sapiom_dev_agents_* + tools to author and ship agents, and sapiom_authenticate / sapiom_status if + you need to sign in. + +**Calling LLMs from agent code:** one-shot call → `ctx.sapiom.llm.run`; a +platform-driven multi-turn loop → `ctx.sapiom.models.run` (never for a +one-shot — it overthinks); dispatching a deployed agent by slug → +`ctx.sapiom.agents.run`. Structured output = tool-use/schema output — read +the `tool_use` block's input, never string-parse; a plain-text reply reads +only `type === 'text'` blocks. **Omit `model` entirely** — the platform +routes it, and `smart` is already the default, so naming it changes +nothing. Reach for `small`/`medium`/`large` only to choose a class +deliberately. Raw provider ids are never +honored. Results disclose the served class + lane. Debugging a run: the +Run Inspector, or the per-step I/O endpoint documented in the guide. +Guide: https://docs.sapiom.ai/guides/choose-a-call-surface. + +**When something about Sapiom is wrong, send it upstream.** If the user hits a +bug, calls something confusing or broken, or wishes it worked differently, +offer to pass it on — sapiom_send_feedback puts their words in front of the +team. Confirm the wording, send what they actually said, and never include file +contents, logs, or secrets. + +**The authoring loop, in order:** scaffold a new agent project → check +(typecheck + bundle/import + manifest + step-graph validation; no Sapiom account) +→ run_local (your real step code with ctx.sapiom.* calls stubbed; no Sapiom +capability spend, while the code's own side effects remain real) → link (associate the project +with a hosted agent) → deploy (push, build, go live). Read a project's +AGENTS.md before touching its steps — it documents that project's specifics. + +**Canvas convention:** the canvas pane renders the selected agent's step +graph automatically and deterministically — the harness extracts it from the +agent's manifest and draws the diagram (nodes, edges, a summary and +annotations) server-side: no LLM, no tokens, identical every time. You do NOT +author or edit any canvas HTML, and there is nothing to write under +`.sapiom/canvas/`. When someone asks to "visualize this agent" or "how +does everything connect", make sure the agent is selected in the workspace +rail. The Canvas follows that selection and refreshes automatically when the +source changes. Local Run, Prod Run, and Deploy are available in the selected +agent's action bar. + +**Your current workspace state:** Agent Studio mirrors what it knows about +this workspace at `.sapiom/harness-context.json`, relative to your working +directory (`{"boundAgent": {name, path, definitionId} | null, +"agents": [{name, path, definitionId}, ...], "session": {id, cwd, +harness}, "updatedAt": ...}`). `boundAgent` is whichever deployable agent the +person currently has selected in the app, or `null` if none; +`agents` is every agent currently known to this Agent Studio installation, +selected or not. Read it when they say "this agent," ask what they're working on, or +ask what agents exist — both fields can change mid-session (a new +selection, a newly scanned/connected project), so re-read the file rather +than assuming it's still what it was earlier in the conversation. + +**In your very first reply this session**, orient the person before you get +to their actual request — briefly, 2-4 sentences total, not a lecture: +1. Acknowledge that you're the coding agent in Agent Studio with these MCPs + available (one line), so they can see this loaded. +2. Say what you can do for them here: inspect the selected agent on its + automatically generated Canvas, start a local agent run against stub + capabilities at no cost, start a production agent run after deployment, + and deploy it live. The exact action-bar controls are Local Run, Prod Run, + and Deploy. +3. Suggest ONE concrete first step, picked from the workspace state file + above: if an agent is bound or listed (e.g. the bundled order-triage + sample project), offer by name to inspect its Canvas or start a Local Run; if none + exists yet, offer to scaffold a new agent project. Phrase it as an + invitation ("want me to…?"), then stop — don't act on it unprompted. diff --git a/packages/harness/src/profiles/project-agent.test.ts b/packages/harness/src/profiles/project-agent.test.ts new file mode 100644 index 000000000..ab8f95cc8 --- /dev/null +++ b/packages/harness/src/profiles/project-agent.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { PROJECT_AGENT_PROMPT_APPENDIX, projectAgentPromptAppendix } from "./project-agent.js"; + +describe("common writable project prompt", () => { + it("preserves one prompt and the current map tool guidance", () => { + expect(projectAgentPromptAppendix()).toBe(PROJECT_AGENT_PROMPT_APPENDIX); + expect(PROJECT_AGENT_PROMPT_APPENDIX).toContain("ordinary writable coding agent"); + for (const tool of ["agent_map_read", "agent_map_validate", "agent_map_propose"]) { + expect(PROJECT_AGENT_PROMPT_APPENDIX).toContain(tool); + } + expect(PROJECT_AGENT_PROMPT_APPENDIX).toContain("no role, approval, confirmation, or mode transition"); + }); +}); + +describe("project-agent operating guidance", () => { + const prompt = PROJECT_AGENT_PROMPT_APPENDIX; + + it("keeps delivery primary and project coordination out of the approval path", () => { + expect(prompt).toContain("Building, testing, and delivering the requested agent is the primary task"); + expect(prompt).toContain("no role, approval, confirmation, or mode transition"); + expect(prompt).toContain("clear initial request"); + }); + + it("distinguishes the shared map from automatic per-agent rendering", () => { + expect(prompt).toContain("agent-map"); + expect(prompt).toContain("not the automatically rendered per-agent Canvas"); + expect(prompt).toContain("does not update itself from code edits"); + expect(prompt).toContain("summary.md"); + expect(prompt).toContain("Before reporting completion"); + }); + + it("teaches discovery, empty-map creation, and conflict-safe persistence", () => { + expect(prompt).toContain("discover their schemas"); + expect(prompt).toContain("proposalId: null"); + expect(prompt).toContain("expectedVersion: 0"); + expect(prompt).toContain("draftRef"); + expect(prompt).toContain("agent_map_validate"); + expect(prompt).toContain("agent_map_propose"); + expect(prompt).toContain("Re-read and reconcile"); + }); + + it("covers the plan lifecycle without requiring a plan for small edits", () => { + for (const name of ["build_plan_read", "build_plan_validate", "build_plan_apply", + "build_plan_rebase", "build_plan_brief_refresh"]) expect(prompt).toContain(name); + expect(prompt).toContain("Small edits do not need a new plan"); + expect(prompt).toContain('kind: "current"'); + }); + + it("describes writable delegation and its actual completion boundary", () => { + expect(prompt).toContain("project_subsession_delegate"); + expect(prompt).toContain("share the working directory"); + expect(prompt).toContain("not completed work"); + expect(prompt).toContain("Never relabel, close"); + expect(prompt).toContain("release-dormant"); + }); +}); diff --git a/packages/harness/src/profiles/project-agent.ts b/packages/harness/src/profiles/project-agent.ts index a03bbe0e4..8d58764b9 100644 --- a/packages/harness/src/profiles/project-agent.ts +++ b/packages/harness/src/profiles/project-agent.ts @@ -6,13 +6,19 @@ import type { FocusedSessionContextProjection } from "../core/focused-session-co * agent; it never changes the session's tools or implementation authority. */ export const PROJECT_AGENT_PROMPT_APPENDIX = ` -You are an ordinary writable coding agent working in a shared Studio project. You can plan and implement in the same session; no role, approval, confirmation, or mode transition is required before beginning a clear implementation request. +You are an ordinary writable coding agent working in a shared Studio project. Building, testing, and delivering the requested agent is the primary task; runtime capabilities and the Sapiom authoring guide remain your main implementation references. Map, plan, and delegation tools support that delivery. You can plan and implement in the same session; no role, approval, confirmation, or mode transition is required before beginning a clear implementation request. Respect read-only requests: inspect without mutating project state. -Use agent_map_read when the current project architecture is relevant. When the work materially changes agents, meaningful subagents, responsibilities, ownership, contracts, shared resources, connectors, artifacts, sequencing boundaries, or cross-agent data flow, validate and record the change with agent_map_validate and agent_map_propose. Re-read and reconcile explicitly if another session changed the shared map concurrently. +Current Studio orientation (takes precedence over older orientation in the base prompt): this project has sapiom for runtime capabilities, sapiom-dev for authoring/testing, and agent-map for shared project tools. Proceed directly on a clear initial request without stopping for an invitation. With no task, offer one relevant next step from actual workspace state; do not assume a sample exists. Discover project tools by name if deferred, and discover their schemas before constructing calls. -Keep internal implementation details local: library choices, ordinary implementation steps, incidental model or tool calls, and refactors that do not change a meaningful project boundary do not belong in the Agent Map. Proceed directly when the user's request is already scoped for implementation. +Agent Map: this is the shared architecture, not the automatically rendered per-agent Canvas, and it does not update itself from code edits. Use agent_map_read when a request concerns project structure or changes a meaningful boundary. During implementation, if the map is empty and the request or inspected files establish agents and data flow, record an evidence-backed initial map once those boundaries are clear; do not wait for the user to request a diagram or invent placeholder nodes. Keep responsibilities, ownership, contracts, shared resources, connectors, artifacts, and cross-agent relationships current. For example, adding a saved summary.md changes the output contract: record the artifact and its writes/reads relationships even if no agent was added. Distinguish manual handoffs from implemented automatic calls; a map edge does not implement execution. -Focused assignments, map-node references, bootstrap context, and focused briefs are context only. They never grant or remove authority. Use project_subsession_delegate when decomposition improves delivery, and release your coordinator-owned child bindings when they are no longer needed. If dormant coordinator bindings exhaust durable history, use its bounded project-wide release-dormant operation. It releases only bindings atomically rechecked as exited or failed, regardless of parent liveness. The operation is destructive and preserves ordinary session history, but ends automatic resume through each released binding and expires its prior request keys. A later delegation of the same key requires a fresh request key and creates a fresh binding/session. Never relabel, close, or otherwise reconcile unrelated user-created sessions. +Map sequence: read -> agent_map_validate -> agent_map_propose with the same valid batch. An empty proposal starts with proposalId: null and expectedVersion: 0; otherwise copy the proposal ID/version from the read. Use draftRef for new nodes and returned IDs afterward. Validation alone does not save the map; successful propose does. Re-read and reconcile explicitly on conflicts, preserving unrelated work. Retry identical requests with the same request ID; changed requests need a fresh ID. Before reporting completion of an implementation request, check that the map reflects meaningful changes made in this turn, including new artifacts and changed contracts; verify persisted changes with a read. If updating fails, report the gap instead of claiming the map is current. Library choices, incidental tool/model calls, ordinary steps, and boundary-preserving refactors stay out of the map. + +Build plans: use a shared plan for substantial multi-agent work with assignments, dependencies, sequencing, or acceptance criteria; maintain an existing relevant plan. Small edits do not need a new plan. Start with build_plan_read({ kind: "current" }) for the current plan and exact map/plan references. After a map exists, use build_plan_validate then build_plan_apply with the same complete replacement, preserving unrelated plan content. Use returned IDs/digests, not guesses. After the map version changes, re-read and use build_plan_rebase before further plan edits, explicitly reconciling invalidated references. Apply/rebase already attempt canonical brief refresh; inspect that result and use build_plan_brief_refresh for focused briefs or to retry failed refresh independently. These are context documents, not execution or approval gates. + +Writable subsessions: use project_subsession_delegate when independent implementation work can proceed in parallel, or a bounded task benefits from focused context. Give each child a concrete outcome, owned files/boundaries, non-goals, deliverables, and checks. Children share the working directory, not isolated worktrees; avoid overlapping edits. A map/plan/focus is optional. Use stable requestKey/delegationKey values for identical retries. A ready session or acknowledged kickoff is not completed work: agree on written deliverables and inspect/test them; this tool does not return a finished implementation. Focused assignments, map-node references, bootstrap context, and briefs never grant or remove authority. + +Release your coordinator-owned child bindings only when their work is no longer needed. For exhausted dormant history, the bounded project-wide release-dormant operation releases only bindings atomically rechecked as exited or failed, regardless of parent liveness. This preserves ordinary conversation history but forfeits automatic resume through those bindings and expires prior request keys; later delegation needs a fresh request key and creates a fresh binding/session. Never relabel, close, or otherwise reconcile unrelated user-created sessions. `; /** diff --git a/packages/harness/src/server/agent-map-mcp-tools.ts b/packages/harness/src/server/agent-map-mcp-tools.ts index 031a335f8..47afa6e54 100644 --- a/packages/harness/src/server/agent-map-mcp-tools.ts +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -46,16 +46,16 @@ const batchSchema = z ), proposalId: preserveInvalidForService( proposalBatchRequestSchema.shape.proposalId, - ), + ).describe("Copy proposal.id from agent_map_read; null only when its proposal is null."), expectedVersion: preserveInvalidForService( proposalBatchRequestSchema.shape.expectedVersion, - ), + ).describe("Copy proposal.version from the read; 0 only for an empty proposal. Re-read after a conflict."), requestId: preserveInvalidForService( proposalBatchRequestSchema.shape.requestId, - ), + ).describe("Caller-chosen retry identity. Reuse for an identical batch; use a fresh ID when the batch changes."), operations: preserveInvalidForService( proposalBatchRequestSchema.shape.operations, - ), + ).describe("Complete atomic batch. New nodes use draftRef; existing nodes use IDs from the read. Preserve unrelated architecture."), }) .strict(); @@ -84,18 +84,18 @@ const delegationFocusSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("map-node"), map: mapVersionRefSchema, plan: planVersionRefSchema.nullable(), nodeId: versionId }).strict(), z.object({ kind: z.literal("brief"), brief: briefVersionRefSchema }).strict(), -]); +]).describe("Optional exact context, not permission. Unlike build_plan_* input refs, these refs include projectId; copy real returned IDs/digests."); const delegationKey = z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/u); const projectSubsessionRequestSchema = z.object({ schemaVersion: z.literal(1), - requestKey: delegationKey, + requestKey: delegationKey.describe("Stable caller-owned key for identical retries of this operation; changed operation content needs a fresh key."), operation: z.discriminatedUnion("kind", [ z.object({ kind: z.literal("delegate"), delegations: z.array(z.object({ - delegationKey, - outcome: z.string().min(1).max(4_096), - kickoffContext: z.string().min(1).max(16_384).optional(), + delegationKey: delegationKey.describe("Stable child-task identity within this parent. Identical content reuses its session; different work needs a new key."), + outcome: z.string().min(1).max(4_096).describe("Concrete implementation outcome; the child is an ordinary writable coding session."), + kickoffContext: z.string().min(1).max(16_384).optional().describe("Owned files, boundaries, non-goals, written deliverables, and verification. Children share the parent's cwd, not isolated worktrees."), focus: delegationFocusSchema.optional(), }).strict()).min(1).max(16), }).strict(), @@ -105,17 +105,17 @@ const projectSubsessionRequestSchema = z.object({ z.object({ kind: z.literal("self") }).strict(), z.object({ kind: z.literal("child"), delegationKey }).strict(), ]), - expectedContextEpoch: z.number().int().positive(), - expectedContextDigest: digest, + expectedContextEpoch: z.number().int().positive().describe("Exact epoch from the current focused-context result; never guess."), + expectedContextDigest: digest.describe("Exact digest paired with expectedContextEpoch."), focus: delegationFocusSchema.nullable(), }).strict(), z.object({ kind: z.literal("release"), - delegationKeys: z.array(delegationKey).min(1).max(16), + delegationKeys: z.array(delegationKey).min(1).max(16).describe("Only this parent's owned children. Release closes their real sessions; do not release useful active work."), }).strict(), z.object({ kind: z.literal("release-dormant"), - limit: z.number().int().min(1).max(16), + limit: z.number().int().min(1).max(16).describe("Maximum exited/failed coordinator bindings to evict project-wide. Preserves conversation history but forfeits automatic resume identity."), }).strict(), ]), }).strict(); @@ -250,7 +250,7 @@ export function createAgentMapToolServer( "agent_map_read", { description: - "Read the current confirmed workspace and shared Agent Map proposal.", + "Read shared project architecture before creating or changing agents, responsibilities, contracts, resources, artifacts, or data flow. Returns workspace and proposal (null if empty), including stable IDs and the numeric proposal version for validate/propose. This is not the automatic per-agent Canvas. For exact map/plan digests use build_plan_read({kind:'current'}).", inputSchema: z.object({}).strict(), annotations: { readOnlyHint: true, openWorldHint: false }, }, @@ -273,7 +273,7 @@ export function createAgentMapToolServer( "agent_map_validate", { description: - "Validate a complete proposal batch without mutating shared state or allocating IDs.", + "Preview a complete Agent Map change batch without persisting it or allocating IDs. First agent_map_read; use its proposal ID/version, or null/0 when empty. Use draftRef for new nodes and their relationships. Correct reported issues, then pass the same valid batch to agent_map_propose. Validation alone never updates the visible map.", inputSchema: batchSchema, annotations: { readOnlyHint: true, openWorldHint: false }, }, @@ -291,7 +291,7 @@ export function createAgentMapToolServer( "agent_map_propose", { description: - "Atomically apply an idempotent batch to the shared Proposed Agent Map.", + "Persist an atomic, idempotent Agent Map batch and update the shared visible graph; this is not an approval request or code execution. Read then validate first. Reuse the request ID only for an identical retry; re-read/reconcile stale versions without overwriting unrelated work. Record meaningful artifact/contract changes, not just new agents, and confirm persisted state with agent_map_read.", inputSchema: batchSchema, annotations: { readOnlyHint: false, @@ -312,7 +312,7 @@ export function createAgentMapToolServer( server.registerTool( "build_plan_read", { - description: "Read the current shared build plan or one exact immutable historical version.", + description: "Read with {kind:'current'} for the shared build plan (possibly null), current exact map/plan references, history, and diagnostics. Use these IDs/digests for plan authoring; agent_map_read does not provide map digests. Read {kind:'exact',planId,versionId,semanticDigest} for one immutable historical plan. An absent plan does not block coding or delegation.", inputSchema: buildPlanReadToolInputSchema, annotations: { readOnlyHint: true, openWorldHint: false }, }, @@ -325,7 +325,7 @@ export function createAgentMapToolServer( server.registerTool( "build_plan_validate", { - description: "Preview and validate an exact-source build plan replacement without changing durable state.", + description: "Validate a complete build-plan replacement without changing durable state. Read current references first; a map must exist. Supply all content collections, preserve unrelated intent, and use clientRef for new plan-owned IDs. expectedPlan is null only for the first plan. Rebase an existing plan after any map-version change, then validate and apply the identical request.", inputSchema: buildPlanApplyRequestSchema, annotations: { readOnlyHint: true, openWorldHint: false }, }, @@ -338,7 +338,7 @@ export function createAgentMapToolServer( server.registerTool( "build_plan_apply", { - description: "Atomically append an idempotent shared build plan version using exact map and plan expectations.", + description: "Persist a validated complete build-plan replacement for substantial coordinated work, not every small edit. Use exact references from build_plan_read; replacements must preserve unrelated content. Identical request-ID retries are idempotent. The plan commits before best-effort canonical briefRefresh: inspect that separate result and retry failed refresh independently. This does not launch sessions or execute code.", inputSchema: buildPlanApplyRequestSchema, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, }, @@ -359,7 +359,7 @@ export function createAgentMapToolServer( server.registerTool( "build_plan_rebase", { - description: "Rebase the exact current build plan to the exact current map with explicit remap or removal resolutions.", + description: "Rebind the current build plan after any map-version change before further plan edits. Read current state: fromMap is plan.map, toMap is current.map, expectedPlan is current.buildPlan; input refs omit projectId. Use resolutions:[] if all references remain valid, otherwise explicitly remap/remove invalidated references without silently dropping intent. Commits before best-effort canonical brief refresh.", inputSchema: buildPlanRebaseRequestSchema, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, }, @@ -380,7 +380,7 @@ export function createAgentMapToolServer( server.registerTool( "build_plan_brief_refresh", { - description: "Compile or refresh exact-source canonical or focused briefs without changing plan-authoring results.", + description: "Compile context briefs from exact current matching map and plan versions. Use canonical focus for workstreams or focused selections for bounded ad-hoc assignments. Apply/rebase already attempt canonical refresh; retry this tool independently if that refresh failed. Refresh does not change plan intent, launch a session, or grant implementation authority; a running child's context refresh is a delegation operation.", inputSchema: agentBriefRefreshRequestSchema, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, }, @@ -393,7 +393,7 @@ export function createAgentMapToolServer( server.registerTool( "project_subsession_delegate", { - description: "Create, reuse, or release a bounded batch of ordinary writable project subsessions, reclaim a bounded project-wide set of coordinator-owned dormant bindings, or refresh exact focused context, using caller-owned idempotency keys.", + description: "Delegate bounded implementation work to ordinary writable children when parallel work or focused context helps. operation.kind='delegate' uses stable requestKey and per-child delegationKey, outcome, and optional kickoffContext/focus; no map or plan is required. Children share cwd, so specify non-overlapping ownership and written deliverables. Ready/acknowledged is kickoff state, not completed work; inspect and test deliverables. Other operations refresh exact focused context, release owned children (closing sessions), or release-dormant project-wide (forfeiting dormant resume bindings, preserving history). Never reconcile unrelated user sessions.", inputSchema: projectSubsessionRequestSchema, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, }, diff --git a/packages/harness/src/server/agent-map-mcp-wiring.test.ts b/packages/harness/src/server/agent-map-mcp-wiring.test.ts index 9b615948f..bf4f92088 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -653,6 +653,51 @@ it("creates one ordinary Plan Agents session for a newly opened project and neve expect(launches).toHaveLength(1); }); +it.each([false, true])("retains a first project's scope during preparation and refresh (fresh catalog: %s)", async (fresh) => { + await fs.writeFile(path.join(root, "settings.json"), JSON.stringify({ recentDirs: [] })); + if (fresh) { + await fs.writeFile(path.join(root, "studio-projects.json"), JSON.stringify({ schemaVersion: 1, projects: [] })); + } + const preparing = deferred(); + const release = deferred(); + const launch = vi.fn((opts: LaunchOpts): SpawnSpec => ({ command: "bash", args: [], env: {}, cwd: opts.cwd })); + const adapter: HarnessAdapter = { + id: "claude-code", eventSource: "hooks", doctor: async () => [], launch, + resume: (_id, opts) => launch(opts), listPastSessions: async () => [], canResume: async () => true, + }; + const cwd = path.join(projectRoot, "new-project"); + server = await startServer({ + port: 0, bootToken: "boot-token", telemetryOptIn: false, authMode: "disabled", + adapters: { "claude-code": adapter }, stateRoot: root, launchDir: projectRoot, + autoCreateSession: false, + buildLaunchOpts: async (_id, req) => { + await fs.mkdir(req.cwd, { recursive: true }); + preparing.resolve(); + await release.promise; + return {}; + }, + }); + const headers = { "content-type": "application/json", "x-harness-token": "boot-token" }; + const creating = fetch(`http://127.0.0.1:${server.port}/api/sessions`, { + method: "POST", headers, body: JSON.stringify({ cwd, harness: "claude-code", initialPrompt: "Build ticket triage." }), + }); + await preparing.promise; + try { + const state = await (await fetch(`http://127.0.0.1:${server.port}/api/state`, { + headers, signal: AbortSignal.timeout(2000), + })).json(); + expect(launch).not.toHaveBeenCalled(); + expect(state.sessions).toHaveLength(0); + } finally { + release.resolve(); + } + const response = await creating; + expect(response.status).toBe(201); + expect(launch).toHaveBeenCalledOnce(); + expect(server.sessionManager.list()).toHaveLength(1); + expect(server.sessionManager.listPendingCreates()).toEqual([]); +}); + it("does not spawn an automatic duplicate when an explicit first session wins the bootstrap claim", async () => { const launches: LaunchOpts[] = []; const adapter: HarnessAdapter = { @@ -717,7 +762,7 @@ it("does not spawn an automatic duplicate when an explicit first session wins th body: JSON.stringify({ cwd: freshRoot, harness: "claude-code", - initialUserInputPending: true, + initialPrompt: "Build a ticket triage agent.", }), }); expect(explicitResponse.status).toBe(201); @@ -739,6 +784,7 @@ it("does not spawn an automatic duplicate when an explicit first session wins th }); expect(explicit.title).toBe("Plan Agents"); expect(launches).toHaveLength(1); + expect(launches[0]?.initialPrompt).toBe("Build a ticket triage agent."); }); it("automatically seeds one durable map through the real E2 tools without replaying after duplicate readiness or restart", async () => { diff --git a/packages/harness/src/server/agent-map-mcp.test.ts b/packages/harness/src/server/agent-map-mcp.test.ts index c2f503025..0f6d6eeef 100644 --- a/packages/harness/src/server/agent-map-mcp.test.ts +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -24,6 +24,7 @@ import { AgentMapMcpProjectUnavailableError, createAgentMapToolServer, } from "./agent-map-mcp-tools.js"; +import { PROJECT_AGENT_PROMPT_APPENDIX } from "../profiles/project-agent.js"; const projectId = "project_00000000-0000-4000-8000-000000000001"; const clients: Client[] = []; @@ -119,6 +120,22 @@ describe("Agent Map Streamable HTTP MCP", () => { "build_plan_validate", "project_subsession_delegate", ]); + for (const tool of tools.tools) { + expect(PROJECT_AGENT_PROMPT_APPENDIX).toContain(tool.name); + } + const descriptions = Object.fromEntries(tools.tools.map(({ name, description }) => [name, description])); + expect(descriptions.agent_map_read).toContain("null if empty"); + expect(descriptions.agent_map_validate).toContain("Validation alone never updates"); + expect(descriptions.agent_map_propose).toContain("not an approval request"); + expect(descriptions.build_plan_read).toContain("current"); + expect(descriptions.build_plan_validate).toContain("preserve unrelated intent"); + expect(descriptions.build_plan_apply).toContain("retry failed refresh independently"); + expect(descriptions.build_plan_rebase).toContain("resolutions:[]"); + expect(descriptions.build_plan_brief_refresh).toContain("does not change plan intent"); + expect(descriptions.project_subsession_delegate).toContain("not completed work"); + const mapInput = tools.tools.find(({ name }) => name === "agent_map_propose")!.inputSchema; + expect(JSON.stringify(mapInput.properties?.proposalId)).toContain("agent_map_read"); + expect(JSON.stringify(mapInput.properties?.expectedVersion)).toContain("0"); const nonStrict = tools.tools.filter((tool) => !(tool.inputSchema.additionalProperties === false || (Array.isArray(tool.inputSchema.anyOf) && tool.inputSchema.anyOf.every((variant) => typeof variant === "object" && variant !== null && "additionalProperties" in variant && diff --git a/packages/harness/src/server/attachments.ts b/packages/harness/src/server/attachments.ts new file mode 100644 index 000000000..bdd08346b --- /dev/null +++ b/packages/harness/src/server/attachments.ts @@ -0,0 +1,89 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { resolveWithinRoot } from "../core/path-safety.js"; +import { + HARNESS_UPLOADS_DIR, + MAX_INLINE_ATTACHMENT_BYTES, + type AttachFileRequest, + type AttachFileResponse, +} from "../shared/types.js"; + +export class AttachmentError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +/** Validate standard padded base64 in one pass and return decoded size. */ +function decodedBase64Size(encoded: string): number | null { + if (encoded.length === 0 || encoded.length % 4 !== 0) return null; + const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; + const contentLength = encoded.length - padding; + for (let index = 0; index < encoded.length; index += 1) { + const code = encoded.charCodeAt(index); + const isDataCharacter = + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + code === 43 || + code === 47; + if (index < contentLength ? !isDataCharacter : code !== 61) return null; + } + return (encoded.length / 4) * 3 - padding; +} + +/** Validate size before allocating decoded bytes or creating any files. */ +export function validateAttachment(request: AttachFileRequest): { + mediaType: string; + encoded: string; + bytes: number; +} { + const match = /^data:([a-z0-9.+/-]+);base64,([\s\S]+)$/i.exec( + request.dataUrl, + ); + if (!match) + throw new AttachmentError(400, "dataUrl must be a base64 data: URL"); + const encoded = match[2]!; + const bytes = decodedBase64Size(encoded); + if (bytes === null) + throw new AttachmentError(400, "attachment payload is not valid base64"); + if (bytes === 0) + throw new AttachmentError(400, "attachment payload is empty"); + if (bytes > MAX_INLINE_ATTACHMENT_BYTES) { + throw new AttachmentError( + 413, + `Attachment is ${bytes} bytes; the limit is ${MAX_INLINE_ATTACHMENT_BYTES} bytes`, + ); + } + return { mediaType: match[1]!.toLowerCase(), encoded, bytes }; +} + +/** Both first-turn and later uploads use the same containment/filename rules. */ +export async function writeAttachment( + cwd: string, + request: AttachFileRequest, +): Promise { + const { mediaType, encoded, bytes } = validateAttachment(request); + const uploadsDir = resolveWithinRoot(cwd, HARNESS_UPLOADS_DIR); + if (!uploadsDir) + throw new AttachmentError(500, "could not resolve the uploads directory"); + await fs.mkdir(uploadsDir, { recursive: true }); + const [realCwd, realUploadsDir] = await Promise.all([ + fs.realpath(cwd), + fs.realpath(uploadsDir), + ]); + if (!resolveWithinRoot(realCwd, realUploadsDir)) { + throw new AttachmentError(400, "uploads directory escapes the session cwd"); + } + const requestedExtension = path.extname(path.basename(request.filename)); + const extension = /^\.[a-z0-9]{1,12}$/i.test(requestedExtension) + ? requestedExtension.toLowerCase() + : ".bin"; + const filePath = path.join(realUploadsDir, `${randomUUID()}${extension}`); + await fs.writeFile(filePath, Buffer.from(encoded, "base64"), { flag: "wx" }); + return { path: filePath, mediaType, bytes }; +} diff --git a/packages/harness/src/server/created-agent-wiring.test.ts b/packages/harness/src/server/created-agent-wiring.test.ts new file mode 100644 index 000000000..a46b961c5 --- /dev/null +++ b/packages/harness/src/server/created-agent-wiring.test.ts @@ -0,0 +1,219 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; +import type { + AppState, + BusMessage, + HarnessAdapter, + LaunchOpts, + SpawnSpec, +} from "../shared/types.js"; +import { startServer, type HarnessServer } from "./index.js"; + +let root: string | undefined; +let server: HarnessServer | undefined; +let socket: WebSocket | undefined; +afterEach(async () => { + socket?.close(); + await server?.close(); + if (root) await fs.rm(root, { recursive: true, force: true, maxRetries: 5 }); +}); + +it( + "publishes a scaffolded sibling immediately, preserves the conversation, and restores membership on restart", + { timeout: 25_000 }, + async () => { + root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "created-agent-wiring-")), + ); + const stateRoot = path.join(root, "state"); + const projectRoot = path.join(root, "original"); + const reviewer = path.join(root, "reviewer"); + const unrelated = path.join(root, "unrelated"); + const tokenPath = path.join(root, "test-ingest-token"); + await fs.mkdir(stateRoot); + await fs.mkdir(projectRoot); + await fs.writeFile(path.join(projectRoot, "sapiom.json"), "{}"); + await fs.writeFile( + path.join(projectRoot, "package.json"), + JSON.stringify({ name: "original" }), + ); + await fs.writeFile( + path.join(stateRoot, "settings.json"), + JSON.stringify({ recentDirs: [projectRoot] }), + ); + const launch = (opts: LaunchOpts): SpawnSpec => ({ + command: process.execPath, + args: [ + "-e", + "require('fs').writeFileSync(process.env.SAPIOM_TEST_TOKEN_PATH, process.env.SAPIOM_HARNESS_INGEST_TOKEN); setInterval(() => {}, 1000)", + ], + cwd: opts.cwd, + env: { SAPIOM_TEST_TOKEN_PATH: tokenPath }, + }); + const adapter: HarnessAdapter = { + id: "claude-code", + eventSource: "hooks", + doctor: async () => [], + launch, + resume: (_id, opts) => launch(opts), + listPastSessions: async () => [], + canResume: async () => true, + }; + const start = () => + startServer({ + port: 0, + bootToken: "test-token", + telemetryOptIn: false, + stateRoot, + launchDir: projectRoot, + adapters: { "claude-code": adapter }, + autoCreateSession: false, + loadSystemPrompt: async () => "", + machineId: "test-machine", + authMode: "disabled", + }); + const headers = { + "X-Harness-Token": "test-token", + "Content-Type": "application/json", + }; + server = await start(); + const state = async (): Promise => + ( + await fetch(`http://127.0.0.1:${server!.port}/api/state`, { headers }) + ).json() as Promise; + const session = await server.sessionManager.create({ + cwd: projectRoot, + harness: "claude-code", + }); + const projectId = session.agentMapIdentity!.projectId; + let token = ""; + await vi.waitFor(async () => { + token = await fs.readFile(tokenPath, "utf8"); + expect(token).not.toBe(""); + }); + const started = await fetch(`http://127.0.0.1:${server.port}/ingest`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + hookEvent: "SessionStart", + harnessSessionId: session.id, + payload: { + session_id: "original-claude-conversation", + source: "startup", + }, + }), + }); + expect(started.status).toBe(200); + socket = new WebSocket( + `ws://127.0.0.1:${server.port}/ws/events?token=test-token`, + ); + await new Promise((resolve, reject) => { + socket!.once("open", resolve); + socket!.once("error", reject); + }); + let changed = 0; + socket.on("message", (raw) => { + if ( + (JSON.parse(raw.toString()) as BusMessage).type === "workflows.changed" + ) + changed++; + }); + for (const dir of [reviewer, unrelated]) { + await fs.mkdir(dir); + await fs.writeFile(path.join(dir, "sapiom.json"), "{}"); + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ name: path.basename(dir) }), + ); + } + const response = await fetch(`http://127.0.0.1:${server.port}/ingest`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + hookEvent: "PostToolUse", + harnessSessionId: session.id, + payload: { + session_id: "original-claude-conversation", + tool_name: "mcp__sapiom-dev__sapiom_dev_agents_scaffold", + tool_input: { dir: reviewer }, + tool_response: [ + { + type: "text", + text: JSON.stringify({ + targetDir: reviewer, + projectName: "reviewer", + template: "default", + gitInitialized: true, + dependenciesInstalled: true, + }), + }, + ], + }, + }), + }); + expect(response.status).toBe(200); + await vi.waitFor( + async () => { + const current = await state(); + expect( + current.workflows.find((workflow) => workflow.path === reviewer) + ?.studioBindings, + ).toMatchObject([{ projectId }]); + expect( + current.workflows.some((workflow) => workflow.path === unrelated), + ).toBe(false); + expect(current.studioProjects).toHaveLength(1); + expect(current.sessions.map((value) => value.id)).toEqual([session.id]); + expect(current.sessions[0]?.agentSessionId).toBe( + "original-claude-conversation", + ); + expect(changed).toBeGreaterThan(0); + }, + { timeout: 8_000 }, + ); + const binding = (await state()).workflows.find( + (workflow) => workflow.path === reviewer, + )!.studioBindings![0]!; + const selection = await fetch( + `http://127.0.0.1:${server.port}/api/projects/${projectId}/current-workspace`, + { + method: "PUT", + headers, + body: JSON.stringify({ selection: { kind: "agent", ...binding } }), + }, + ); + expect(selection.status).toBe(200); + expect(await selection.json()).toMatchObject({ + selection: { kind: "agent", ...binding }, + }); + socket.close(); + await server.close(); + server = await start(); + expect( + (await state()).workflows.find((workflow) => workflow.path === reviewer) + ?.studioBindings, + ).toEqual([binding]); + expect((await state()).studioProjects).toHaveLength(1); + // The exact sibling watcher remains alive without resuming its old session. + await fs.unlink(path.join(reviewer, "sapiom.json")); + await vi.waitFor( + async () => { + expect( + (await state()).workflows.some( + (workflow) => workflow.path === reviewer, + ), + ).toBe(false); + }, + { timeout: 8_000 }, + ); + }, +); diff --git a/packages/harness/src/server/first-request.test.ts b/packages/harness/src/server/first-request.test.ts new file mode 100644 index 000000000..ddf810ab9 --- /dev/null +++ b/packages/harness/src/server/first-request.test.ts @@ -0,0 +1,136 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { prepareFirstRequest } from "./first-request.js"; +import type { AgentScaffoldDeps } from "./scaffold.js"; + +let root: string; +let deps: AgentScaffoldDeps; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "harness-first-request-")); + deps = { + listProjectDirs: () => [root], + resolveAgent: () => null, + scaffoldAgent: vi.fn(async ({ targetDir }) => { + await writeFile(join(targetDir, "AGENTS.md"), "Project instructions"); + return { dependenciesInstalled: true }; + }), + onScaffolded: vi.fn(async () => {}), + }; +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +it("scaffolds with the existing server guards, then returns only the user's first task", async () => { + const cwd = join(root, "ticket-triage"); + const prompt = "--help\nBuild a local support-ticket triage project."; + expect( + await prepareFirstRequest( + { + cwd, + harness: "claude-code", + scaffold: { template: "default" }, + initialPrompt: prompt, + }, + deps, + ), + ).toBe(prompt); + expect(deps.scaffoldAgent).toHaveBeenCalledExactlyOnceWith({ + targetDir: cwd, + template: "default", + }); + expect(deps.onScaffolded).toHaveBeenCalledExactlyOnceWith(cwd); + expect(await readFile(join(cwd, "AGENTS.md"), "utf8")).toBe( + "Project instructions", + ); +}); + +it("materializes clipboard bytes and preserves mixed attachment order before launch", async () => { + const cwd = join(root, "ticket-triage"); + const prompt = await prepareFirstRequest( + { + cwd, + harness: "codex", + scaffold: { template: "default" }, + initialPrompt: "Use my files.", + initialAttachments: [ + { kind: "path", path: "/native/first brief.pdf" }, + { + kind: "inline", + filename: "../../screenshot.PNG", + dataUrl: "data:image/png;base64,cGl4ZWxz", + }, + { kind: "path", path: "/native/last.txt" }, + ], + }, + deps, + ); + const [upload] = await readdir(join(cwd, ".sapiom/uploads")); + const uploadedPath = join(cwd, ".sapiom/uploads", upload!); + expect(upload).toMatch(/^[a-f0-9-]+\.png$/); + expect(await readFile(uploadedPath, "utf8")).toBe("pixels"); + expect(prompt).toBe( + `Use my files.\n\nAttached files (read each as context):\n"/native/first brief.pdf"\n${uploadedPath}\n/native/last.txt`, + ); +}); + +it("rejects invalid attachments before creating the project or starting a session", async () => { + await expect( + prepareFirstRequest( + { + cwd: join(root, "invalid"), + harness: "claude-code", + scaffold: { template: "default" }, + initialAttachments: [ + { + kind: "inline", + filename: "bad.txt", + dataUrl: "data:text/plain;base64,not base64", + }, + ], + }, + deps, + ), + ).rejects.toMatchObject({ status: 400 }); + expect(deps.scaffoldAgent).not.toHaveBeenCalled(); + expect(await readdir(root)).toEqual([]); +}); + +it("refuses an unregistered parent and never overwrites an existing project", async () => { + await expect( + prepareFirstRequest( + { + cwd: join(root, "unregistered", "agent"), + harness: "claude-code", + scaffold: { template: "default" }, + }, + deps, + ), + ).rejects.toMatchObject({ status: 409 }); + const request = { + cwd: join(root, "agent"), + harness: "claude-code" as const, + scaffold: { template: "default" }, + }; + await prepareFirstRequest(request, deps); + await expect(prepareFirstRequest(request, deps)).rejects.toMatchObject({ + status: 409, + }); + expect(deps.scaffoldAgent).toHaveBeenCalledOnce(); +}); + +it("an attachment-only request supplies context without a synthetic scaffold instruction", async () => { + expect( + await prepareFirstRequest( + { + cwd: root, + harness: "claude-code", + initialAttachments: [{ kind: "path", path: "/native/brief.txt" }], + }, + deps, + ), + ).toBe("Attached files (read each as context):\n/native/brief.txt"); + expect(deps.scaffoldAgent).not.toHaveBeenCalled(); +}); diff --git a/packages/harness/src/server/first-request.ts b/packages/harness/src/server/first-request.ts new file mode 100644 index 000000000..a00bd5d83 --- /dev/null +++ b/packages/harness/src/server/first-request.ts @@ -0,0 +1,70 @@ +import * as fs from "node:fs/promises"; +import { basename, dirname } from "node:path"; +import { + MAX_INLINE_ATTACHMENTS_TOTAL_BYTES, + type CreateSessionRequest, +} from "../shared/types.js"; +import { buildIdeaWithAttachments } from "../shared/initial-prompt.js"; +import { + AttachmentError, + validateAttachment, + writeAttachment, +} from "./attachments.js"; +import { scaffoldAgentProject, type AgentScaffoldDeps } from "./scaffold.js"; + +export function validateInitialAttachments( + attachments: CreateSessionRequest["initialAttachments"], +): void { + let bytes = 0; + for (const attachment of attachments ?? []) { + if (attachment.kind !== "inline") continue; + bytes += validateAttachment(attachment).bytes; + if (bytes > MAX_INLINE_ATTACHMENTS_TOTAL_BYTES) { + throw new AttachmentError( + 413, + "Pasted files exceed the per-session attachment limit", + ); + } + } +} + +/** Prepare files before the CLI exists; there is no synthetic PTY Enter. */ +export async function prepareFirstRequest( + request: CreateSessionRequest, + scaffoldDeps: AgentScaffoldDeps, +): Promise { + validateInitialAttachments(request.initialAttachments); + if (request.scaffold) { + await scaffoldAgentProject(scaffoldDeps, { + root: dirname(request.cwd), + name: basename(request.cwd), + template: request.scaffold.template, + }); + } + const resolved: { path: string }[] = []; + const uploaded: string[] = []; + try { + for (const attachment of request.initialAttachments ?? []) { + if (attachment.kind === "path") { + resolved.push({ path: attachment.path }); + } else { + try { + const file = await writeAttachment(request.cwd, attachment); + uploaded.push(file.path); + resolved.push(file); + } catch (error) { + throw new AttachmentError( + error instanceof AttachmentError ? error.status : 500, + `Couldn't attach ${attachment.filename}: ${(error as Error).message}`, + ); + } + } + } + return buildIdeaWithAttachments(request.initialPrompt ?? "", resolved); + } catch (error) { + // Only our UUID-named uploads are disposable. Keep a completed scaffold + // (and all pre-existing files); no coding session has started yet. + await Promise.all(uploaded.map((file) => fs.unlink(file).catch(() => {}))); + throw error; + } +} diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 1e75455c8..5ee552bb0 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -7,6 +7,12 @@ * src/shared/types.ts for the full protocol contract. */ +import { SubsessionCoordinatorStore, type SubsessionCoordinatorStoreEvent } from "../core/subsession-coordinator-store.js"; +import { SubsessionCoordinator, type SubsessionCoordinatorEvent } from "../core/subsession-coordinator.js"; +import { CodexRolloutBroker } from "../core/collector/codex-rollout-broker.js"; +import { AgentBriefService } from "../core/agent-brief-service.js"; +import { BuildPlanStore } from "../core/build-plan-store.js"; +import { BuildPlanService } from "../core/build-plan-service.js"; import { createServer as createHttpServer, type Server as HttpServer, @@ -31,7 +37,7 @@ import type { SystemPromptDelivery, WorkflowInfo, } from "../shared/types.js"; -import { JSON_BODY_LIMIT_BYTES } from "../shared/types.js"; +import { CREATE_SESSION_JSON_LIMIT_BYTES, JSON_BODY_LIMIT_BYTES } from "../shared/types.js"; import type { ProjectBootstrapLifecycleEvent, ProjectAgentSession, @@ -72,6 +78,7 @@ import { import { AgentSourceScanBudget } from "../core/agent-source-discovery.js"; import { DEFAULT_MACROS } from "../core/macros.js"; import { createEventStore } from "../core/collector/store.js"; +import { CreatedAgentRegistration } from "../core/created-agent-registration.js"; import { createClaudeTranscriptEnricher, createSessionRecordReader, @@ -95,7 +102,6 @@ import { normalizeHookEvent } from "../core/collector/normalizer.js"; import { enrichTurnCompleted } from "../core/collector/transcript.js"; import { createSeqCounter } from "../core/collector/seq.js"; import { tailCodexRollout, type CodexTailerHandle } from "../core/collector/codex-tailer.js"; -import { CodexRolloutBroker } from "../core/collector/codex-rollout-broker.js"; import { getOrCreateMachineId } from "../cli/machine-id.js"; import { loadSettings, pruneDeadRecentDirs } from "../cli/settings.js"; import type { HarnessIdentity } from "../cli/auth.js"; @@ -171,17 +177,6 @@ import { createSystemGraphRouter } from "./system-graph.js"; import { createAgentMapRouter } from "./agent-map.js"; import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; import { AgentMapProposalService } from "../core/agent-map-proposal-service.js"; -import { BuildPlanService } from "../core/build-plan-service.js"; -import { AgentBriefService } from "../core/agent-brief-service.js"; -import { BuildPlanStore } from "../core/build-plan-store.js"; -import { - SubsessionCoordinator, - type SubsessionCoordinatorEvent, -} from "../core/subsession-coordinator.js"; -import { - SubsessionCoordinatorStore, - type SubsessionCoordinatorStoreEvent, -} from "../core/subsession-coordinator-store.js"; import { AgentMapCapabilityRegistry, type AgentMapCapabilityEvent, @@ -227,7 +222,8 @@ import { moveTargetDirs, remapSessions, } from "./agent-move.js"; -import { createAgentScaffoldRouter } from "./scaffold.js"; +import { createAgentScaffoldRouter, type AgentScaffoldDeps } from "./scaffold.js"; +import { prepareFirstRequest } from "./first-request.js"; import { createMacrosRouter } from "./macros.js"; import { createFsRouter } from "./fs.js"; import { createRunsRouter } from "./runs.js"; @@ -892,6 +888,7 @@ export const startServer = async ( const settings = await loadSettings(statePaths.settings); return [ ...pendingProjectCwds, + ...(sessionManager ? sessionManager.listPendingCreates().map((session) => session.cwd) : []), ...settings.recentDirs, ...(sessionManager ? sessionManager.list().map((session) => session.cwd) @@ -926,6 +923,19 @@ export const startServer = async ( })), ); const retainedProjectSessionRoots = new Set(); + // Pending launches contribute their trusted PROJECT root just like live + // sessions, not a descendant cwd that would mint a competing project. + const pendingCwds = [ + ...pendingProjectCwds, + ...(sessionManager ? sessionManager.listPendingCreates() : []).flatMap((session) => { + if (!session.agentMapIdentity) return [session.cwd]; + const root = projectSessionRoot( + { cwd: session.cwd, projectId: session.agentMapIdentity.projectId }, + durableRootCandidates, + ); + return root ? [root] : []; + }), + ]; const sessions = sessionManager ? sessionManager.list().flatMap((session) => { if (!session.agentMapIdentity) { @@ -962,7 +972,7 @@ export const startServer = async ( }) : []; const candidates = [ - ...pendingProjectCwds, + ...pendingCwds, ...settings.recentDirs, ...sessions.map((session) => session.cwd), ]; @@ -982,7 +992,7 @@ export const startServer = async ( const visibleRoots = projectRoots({ recentDirs: settings.recentDirs, sessions, - pendingCwds: [...pendingProjectCwds], + pendingCwds, pinnedRoots: durableRoots, agentPaths: [ ...workflowsCache.map((workflow) => workflow.path), @@ -1309,6 +1319,56 @@ export const startServer = async ( }, }); + const scaffoldDeps: AgentScaffoldDeps = { + listProjectDirs: async () => { + const stored = await loadSettings(statePaths.settings); + return moveTargetDirs( + [ + ...stored.recentDirs, + ...(stored.projectRoot ? [stored.projectRoot] : []), + // THE HOST'S DEFAULT, which the move route does not need and this + // one does. `AppState.defaultProjectRoot` is where the SPA puts a + // NEW project when the user has saved no `projectRoot` of their own + // — `/projects` under Electron — and the host does not + // persist it into settings. Without it, the first template a user + // ever starts from is refused at its own suggested destination + // ("Studio doesn't show that folder as a project"), and the flow + // cannot bootstrap: `recentDirs` only learns a root once a session + // has been created there, and creation now happens FIRST. + defaultProjectRoot ?? launchDir, + ...sessionManager.list().map((session) => session.cwd), + ], + workflowsCache.map((w) => w.path), + ); + }, + resolveAgent: (agentPath) => + workflowsCache.find((w) => resolve(w.path) === agentPath) ?? null, + scaffoldAgent: async ({ targetDir, template }) => { + // `installDependencies: true` for the same reason the MCP tool passes + // it: the Canvas bundles the project on its first, unprompted render + // and resolves `@sapiom/agent`/`zod` from the project's own + // node_modules, so a never-installed agent opens on a "Could not + // resolve …" error. Best-effort inside agent-core — a failed install + // still returns a created project. + const result = await scaffold({ + targetDir, + template, + templatesDir: agentCoreTemplatesDir(), + installDependencies: true, + }); + return { dependenciesInstalled: result.dependenciesInstalled }; + }, + // Rescan the PROJECT root, not the agent directory: the registry has to + // learn the new agent under the project the rail draws it in, and the + // scan broadcasts `workflows.changed` so the row is there before the + // dialog's caller opens a session on it. + onScaffolded: async (agentDir) => { + await scanWorkflowsAndBroadcast(dirname(agentDir), "agent-created", { + dirty: true, + }); + }, + }; + const innerBuildLaunchOpts = options.buildLaunchOpts ?? createDefaultBuildLaunchOpts( @@ -1327,8 +1387,13 @@ export const startServer = async ( context, ) => { await pendingGeneratedRemovals.get(harnessSessionId); + // Scope/bootstrap ownership is already resolved; prepare the user's new + // project before config generation and PTY spawn, never during resume. + const initialPrompt = context?.resume + ? undefined + : await prepareFirstRequest(req, scaffoldDeps); if (!context?.agentMapIdentity) { - return innerBuildLaunchOpts(harnessSessionId, req, context); + return { ...(await innerBuildLaunchOpts(harnessSessionId, req, context)), ...(initialPrompt ? { initialPrompt } : {}) }; } if (!agentMapMcpUrl) { throw new Error("Agent Map MCP endpoint is not bound"); @@ -1346,7 +1411,7 @@ export const startServer = async ( ...context, agentMapMcp: agentMapMcpMetadata, }); - return { ...generated, agentMapMcp: agentMapMcpMetadata }; + return { ...generated, agentMapMcp: agentMapMcpMetadata, ...(initialPrompt ? { initialPrompt } : {}) }; } catch (error) { agentMapCapabilities.revokeSession(harnessSessionId); throw error; @@ -1499,6 +1564,7 @@ export const startServer = async ( } const metadata = await projectBootstrap?.claimProject( identity, + Boolean(request.initialPrompt || request.initialAttachments?.length) || request.initialUserInputPending === true || Boolean(request.rehydrateFrom), ); @@ -2725,15 +2791,7 @@ export const startServer = async ( ); const listWorkspaceScopesAndRetain = async () => { - const scopes = await workspaceScopeCatalog.list(); - const retained = new Set(scopes.map((scope) => scope.workspaceKey)); - systemGraphWatcher.retain(retained); - systemGraphStore.retain(retained); - for (const workspaceKey of activeSystemGraphScopes.keys()) { - if (!retained.has(workspaceKey)) { - activeSystemGraphScopes.delete(workspaceKey); - } - } + let scopes = await workspaceScopeCatalog.list(); try { const studioScopes = await studioWorkspaceScopeCatalog.list(); const reconciliation = await studioProjectCatalog.reconcile(studioScopes); @@ -2747,15 +2805,23 @@ export const startServer = async ( byRoot.set(resolve(scope.cwd), scope); } } - return [...byRoot.values()].sort((left, right) => + scopes = [...byRoot.values()].sort((left, right) => left.cwd.localeCompare(right.cwd), ); } catch { // Agent Map is additive in E1. A bad/unavailable new catalog cannot // strand the legacy rail or System Graph during coexistence. console.error("[harness] Studio project catalog is unavailable"); - return scopes; } + const retained = new Set(scopes.map((scope) => scope.workspaceKey)); + systemGraphWatcher.retain(retained); + systemGraphStore.retain(retained); + for (const workspaceKey of activeSystemGraphScopes.keys()) { + if (!retained.has(workspaceKey)) { + activeSystemGraphScopes.delete(workspaceKey); + } + } + return scopes; }; /** Enrich only the bound workflow before a Canvas render. Canvas extraction @@ -3085,6 +3151,57 @@ export const startServer = async ( const studioWorkspacePreferences = new StudioWorkspacePreferenceStore( join(statePaths.agentMap, "studio-workspace-preferences.json"), ); + // These leases observe exact, explicitly associated agent directories. They + // do not open those directories as new Studio projects or broaden cwd. + const createdAgentWatcher = new WorkspaceWatcherManager({ + sharedWatchBroker: sharedWorkspaceWatchBroker, + listSourceRoots: (_key, cwd) => [cwd], + listSourceObservations: (_key, cwd) => discoveryObservationsForRoot(cwd), + onPotentialChange: (agentPath) => { + if (coordinatorActive) prepareDirtyWorkflowRoot(agentPath); + }, + onChange: async (agentPath) => { + if (!coordinatorActive) return; + await scanWorkflowsAndBroadcast(agentPath, "workspace-change", { + dirty: true, + }); + }, + }); + const createdAgentRegistration = new CreatedAgentRegistration({ + preferences: studioWorkspacePreferences, + events: eventStore, + authorize: async (event, runtimeEpoch) => { + const session = sessionManager.get(event.harnessSessionId); + if ( + !session || + !session.agentMapIdentity || + localProjectPrincipal(event.userId, event.machineId) !== + session.agentMapIdentity.userId || + (runtimeEpoch !== undefined && + !sessionManager.acceptsIngestRuntimeEpoch(session.id, runtimeEpoch)) + ) return null; + const authorized = await isProjectSessionDispatchAuthorized({ + session, + currentPrincipal: () => localProjectPrincipal(projectUserId, machineId), + resolveProject: (projectId) => studioProjectCatalog.resolveIdentity(projectId), + }); + if ( + !authorized || + (runtimeEpoch !== undefined && + !sessionManager.acceptsIngestRuntimeEpoch(session.id, runtimeEpoch)) + ) return null; + return { projectId: session.agentMapIdentity.projectId, cwd: session.cwd }; + }, + projectForPath: async (agentPath) => + (await studioProjectCatalog.resolveIdentityForPath(agentPath))?.projectId ?? + null, + watch: (agentPath) => createdAgentWatcher.start(agentPath, agentPath), + scan: async (agentPath) => { + await scanWorkflowsAndBroadcast(agentPath, "agent-created", { dirty: true }); + // Backfill can change membership without changing any filesystem row. + bus.publish({ type: "workflows.changed" }); + }, + }); const agentMapProposalService = new AgentMapProposalService( agentMapWorkspaceStore, { @@ -3592,6 +3709,11 @@ export const startServer = async ( // (base64 data URLs, up to ~13 MiB encoded) can be parsed — see // JSON_BODY_LIMIT_BYTES. This is the parser that actually gates every /api // route; the rest router mounts its own with the same limit for standalone use. + app.post( + "/api/sessions", + createBootTokenMiddleware(options.bootToken), + express.json({ limit: CREATE_SESSION_JSON_LIMIT_BYTES }), + ); app.use( "/api", createBootTokenMiddleware(options.bootToken), @@ -3716,7 +3838,14 @@ export const startServer = async ( app.use( "/api", createSystemGraphRouter({ - scopeResolver: workspaceScopeCatalog, + scopeResolver: { + resolve: async (workspaceKey) => { + const scope = (await listWorkspaceScopesAndRetain()).find( + (candidate) => candidate.workspaceKey === workspaceKey, + ); + return scope ? { workspaceKey, root: canonicalGraphPath(scope.cwd) } : null; + }, + }, store: systemGraphStore, onScopeAccess: (scope) => { const firstAccess = !activeSystemGraphScopes.has(scope.workspaceKey); @@ -3979,57 +4108,7 @@ export const startServer = async ( // against the SAME directory list the move route drops into — so "a folder // the rail can show" and "a folder the studio will create a project in" stay // one answer. - app.use( - createAgentScaffoldRouter({ - listProjectDirs: async () => { - const stored = await loadSettings(statePaths.settings); - return moveTargetDirs( - [ - ...stored.recentDirs, - ...(stored.projectRoot ? [stored.projectRoot] : []), - // THE HOST'S DEFAULT, which the move route does not need and this - // one does. `AppState.defaultProjectRoot` is where the SPA puts a - // NEW project when the user has saved no `projectRoot` of their own - // — `/projects` under Electron — and the host does not - // persist it into settings. Without it, the first template a user - // ever starts from is refused at its own suggested destination - // ("Studio doesn't show that folder as a project"), and the flow - // cannot bootstrap: `recentDirs` only learns a root once a session - // has been created there, and creation now happens FIRST. - ...(defaultProjectRoot ? [defaultProjectRoot] : []), - ...sessionManager.list().map((session) => session.cwd), - ], - workflowsCache.map((w) => w.path), - ); - }, - resolveAgent: (agentPath) => - workflowsCache.find((w) => resolve(w.path) === agentPath) ?? null, - scaffoldAgent: async ({ targetDir, template }) => { - // `installDependencies: true` for the same reason the MCP tool passes - // it: the Canvas bundles the project on its first, unprompted render - // and resolves `@sapiom/agent`/`zod` from the project's own - // node_modules, so a never-installed agent opens on a "Could not - // resolve …" error. Best-effort inside agent-core — a failed install - // still returns a created project. - const result = await scaffold({ - targetDir, - template, - templatesDir: agentCoreTemplatesDir(), - installDependencies: true, - }); - return { dependenciesInstalled: result.dependenciesInstalled }; - }, - // Rescan the PROJECT root, not the agent directory: the registry has to - // learn the new agent under the project the rail draws it in, and the - // scan broadcasts `workflows.changed` so the row is there before the - // dialog's caller opens a session on it. - onScaffolded: async (agentDir) => { - await scanWorkflowsAndBroadcast(dirname(agentDir), "agent-created", { - dirty: true, - }); - }, - }), - ); + app.use(createAgentScaffoldRouter(scaffoldDeps)); app.use( createWorkflowsRouter(enrichedWorkflowRegistry), createFsRouter(), @@ -4168,6 +4247,11 @@ export const startServer = async ( } }, onEventPersisted: (event: AnalyticsEvent, runtimeEpoch) => { + void createdAgentRegistration + .onEventPersisted(event, runtimeEpoch) + .catch(() => { + console.error("[harness] created agent registration failed"); + }); void projectBootstrap!.onEventPersisted(event, runtimeEpoch).catch(() => { console.error("[harness] project bootstrap completion failed"); }); @@ -4383,6 +4467,7 @@ export const startServer = async ( await settle(() => sessionManager.beginShutdown()); const bootstrapClosing = settle(() => projectBootstrap?.close()); + const registrationClosing = settle(() => createdAgentRegistration.close()); coordinatorActive = false; coordinatorEpoch += 1; clearInterval(sessionSweepTimer); @@ -4393,6 +4478,7 @@ export const startServer = async ( ); await settle(() => canvasWatcher.stopAll()); await settle(() => workspaceWatcher.stopAll()); + await settle(() => createdAgentWatcher.stopAll()); await settle(() => systemGraphWatcher.stopAll()); activeSystemGraphScopes.clear(); await settle(() => systemGraphInvocations.clear()); @@ -4420,6 +4506,7 @@ export const startServer = async ( await Promise.race([killsSettled, shutdownTimeout]); if (shutdownTimerHandle !== undefined) clearTimeout(shutdownTimerHandle); await bootstrapClosing; + await registrationClosing; await settle(() => sessionManager.flush()); await settle(async () => { await recordBackfill; @@ -4463,6 +4550,13 @@ export const startServer = async ( let actualPort = options.port; try { + // Before the first browser state read: restore creator ownership from the + // durable local completion stream, including pre-fix scaffolded siblings. + await createdAgentRegistration + .recover(sessionManager.list().map((session) => session.id)) + .catch(() => { + console.error("[harness] created agent recovery failed"); + }); await new Promise((resolve, reject) => { httpServer.once("error", reject); httpServer.listen(options.port, host, () => { diff --git a/packages/harness/src/server/rest.test.ts b/packages/harness/src/server/rest.test.ts index 32b3a5d78..016c3d819 100644 --- a/packages/harness/src/server/rest.test.ts +++ b/packages/harness/src/server/rest.test.ts @@ -505,6 +505,57 @@ describe("createRestRouter", () => { }); describe("POST /sessions", () => { + it("preserves the initial task and scaffold request without an input-route round trip", async () => { + const sessionManager = fakeSessionManager(); + vi.mocked(sessionManager.create).mockResolvedValue(exitedSession()); + start({ sessionManager }); + const request = { + cwd: "/tmp/proj", harness: "claude-code", + initialPrompt: "Build ticket triage.\nUse my files.", + scaffold: { template: "default" }, + initialAttachments: [{ kind: "path", path: "/tmp/brief.pdf" }], + }; + const res = await fetch(`${baseUrl}/sessions`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request), + }); + expect(res.status).toBe(201); + expect(sessionManager.create).toHaveBeenCalledExactlyOnceWith(request); + expect(sessionManager.submitInput).not.toHaveBeenCalled(); + }); + + it.each([ + { initialPrompt: "bad\0argument" }, + { initialPrompt: "x".repeat(32_001) }, + { initialAttachments: [{ kind: "path", path: "bad\0path" }] }, + { initialAttachments: [{ kind: "inline", filename: "bad.png", dataUrl: "data:image/png;base64,invalid!" }] }, + { scaffold: { template: "../../escape" } }, + ])("rejects malformed first-turn input before session creation", async (input) => { + const sessionManager = fakeSessionManager(); + start({ sessionManager }); + const res = await fetch(`${baseUrl}/sessions`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ cwd: "/tmp/proj", harness: "claude-code", ...input }), + }); + expect(res.status).toBe(400); + expect(sessionManager.create).not.toHaveBeenCalled(); + }); + + it("accepts multiple initial files above the individual-upload JSON limit", async () => { + const sessionManager = fakeSessionManager(); + vi.mocked(sessionManager.create).mockResolvedValue(exitedSession()); + start({ sessionManager }); + const dataUrl = `data:application/octet-stream;base64,${Buffer.alloc(6 * 1024 * 1024).toString("base64")}`; + const res = await fetch(`${baseUrl}/sessions`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ + cwd: "/tmp/proj", harness: "codex", + initialAttachments: [1, 2].map((i) => ({ kind: "inline", filename: `file${i}.bin`, dataUrl })), + }), + }); + expect(res.status).toBe(201); + expect(sessionManager.create).toHaveBeenCalledOnce(); + }); + it("calls onSessionCreated with the new session's cwd and id", async () => { const onSessionCreated = vi.fn(); const sessionManager = fakeSessionManager(); diff --git a/packages/harness/src/server/rest.ts b/packages/harness/src/server/rest.ts index 97578c0e0..63180e7d3 100644 --- a/packages/harness/src/server/rest.ts +++ b/packages/harness/src/server/rest.ts @@ -7,16 +7,13 @@ import express, { Router } from "express"; import rateLimit from "express-rate-limit"; import { z } from "zod"; - import { randomUUID } from "node:crypto"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; + import type { AdoptSessionRequest, AnalyticsEvent, AppState, AttachFileRequest, - AttachFileResponse, BackgroundTask, BindWorkflowRequest, CreateSessionRequest, @@ -36,9 +33,8 @@ import type { import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; import type { StudioProjectSummary } from "../shared/agent-map.js"; import { - HARNESS_UPLOADS_DIR, + CREATE_SESSION_JSON_LIMIT_BYTES, JSON_BODY_LIMIT_BYTES, - MAX_INLINE_ATTACHMENT_BYTES, SPAWNABLE_HARNESS_KINDS, EDITOR_KINDS, } from "../shared/types.js"; @@ -65,29 +61,11 @@ import { getHarnessAdapter, listHarnessAdapters, } from "../core/adapters/registry.js"; -import { resolveWithinRoot } from "../core/path-safety.js"; +import { AttachmentError, writeAttachment } from "./attachments.js"; +import { ScaffoldError } from "./scaffold.js"; +import { validateInitialAttachments } from "./first-request.js"; import { loadSettings, saveSettings } from "../cli/settings.js"; -const DATA_URL_RE = /^data:([a-z0-9.+/-]+);base64,([\s\S]+)$/i; - -/** Validate standard padded base64 in one pass and return decoded size. */ -function decodedBase64Size(encoded: string): number | null { - if (encoded.length === 0 || encoded.length % 4 !== 0) return null; - const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; - const contentLength = encoded.length - padding; - for (let index = 0; index < encoded.length; index += 1) { - const code = encoded.charCodeAt(index); - const isDataCharacter = - (code >= 65 && code <= 90) || - (code >= 97 && code <= 122) || - (code >= 48 && code <= 57) || - code === 43 || - code === 47; - if (index < contentLength ? !isDataCharacter : code !== 61) return null; - } - return (encoded.length / 4) * 3 - padding; -} - // Derived from SPAWNABLE_HARNESS_KINDS (shared/types.ts) so the zod // validation and the TypeScript type can never drift from each other. // Adding a new spawnable harness means updating that one constant; the @@ -97,6 +75,12 @@ const createSessionSchema = z cwd: z.string().min(1), harness: z.enum(SPAWNABLE_HARNESS_KINDS), profile: z.string().optional(), + initialPrompt: z.string().max(32_000).refine((text) => !text.includes("\0")).optional(), + initialAttachments: z.array(z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("path"), path: z.string().min(1).max(4096).refine((text) => !text.includes("\0")) }).strict(), + z.object({ kind: z.literal("inline"), dataUrl: z.string().min(1), filename: z.string().trim().min(1).max(255) }).strict(), + ])).max(100).optional(), + scaffold: z.object({ template: z.string().regex(/^[a-z0-9][a-z0-9-]*$/i) }).strict().optional(), initialUserInputPending: z.boolean().optional(), rehydrateFrom: z.string().min(1).optional(), theme: z.enum(["light", "dark"]).optional(), @@ -351,6 +335,7 @@ export function createRestRouter(options: RestRouterOptions): Router { standardHeaders: true, legacyHeaders: false, }); + router.post("/sessions", express.json({ limit: CREATE_SESSION_JSON_LIMIT_BYTES })); router.use(express.json({ limit: JSON_BODY_LIMIT_BYTES })); router.get("/state", async (_req, res, next) => { @@ -483,7 +468,7 @@ export function createRestRouter(options: RestRouterOptions): Router { } }); - router.post("/sessions", async (req, res, next) => { + router.post("/sessions", attachmentUploadRateLimiter, async (req, res, next) => { const parsed = createSessionSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); @@ -496,10 +481,15 @@ export function createRestRouter(options: RestRouterOptions): Router { // sessionManager.create() writes the initial harness-context.json // itself (before spawning) so every entry point gets it, not just // this REST route — see SessionManager.create(). + validateInitialAttachments(request.initialAttachments); const session = await sessionManager.create(request); res.status(201).json(session); options.onSessionCreated?.(request.cwd, session.id); } catch (err) { + if (err instanceof ScaffoldError || err instanceof AttachmentError) { + res.status(err.status).json({ error: err.message }); + return; + } if ( err instanceof AdapterNotFoundError || err instanceof SpawnTargetError @@ -538,70 +528,13 @@ export function createRestRouter(options: RestRouterOptions): Router { return; } - const match = DATA_URL_RE.exec(parsed.data.dataUrl); - if (!match) { - res.status(400).json({ error: "dataUrl must be a base64 data: URL" }); - return; - } - - const mediaType = match[1]!.toLowerCase(); - const encoded = match[2]!; - const decodedSize = decodedBase64Size(encoded); - if (decodedSize === null) { - res - .status(400) - .json({ error: "attachment payload is not valid base64" }); - return; - } - if (decodedSize === 0) { - res.status(400).json({ error: "attachment payload is empty" }); - return; - } - if (decodedSize > MAX_INLINE_ATTACHMENT_BYTES) { - res.status(413).json({ - error: `Attachment is ${decodedSize} bytes; the limit is ${MAX_INLINE_ATTACHMENT_BYTES} bytes`, - }); - return; - } - const buffer = Buffer.from(encoded, "base64"); - - const uploadsDir = resolveWithinRoot(session.cwd, HARNESS_UPLOADS_DIR); - if (!uploadsDir) { - res - .status(500) - .json({ error: "could not resolve the uploads directory" }); - return; - } - const requestedExtension = path.extname( - path.basename(parsed.data.filename), - ); - const extension = /^\.[a-z0-9]{1,12}$/i.test(requestedExtension) - ? requestedExtension.toLowerCase() - : ".bin"; try { - await fs.mkdir(uploadsDir, { recursive: true }); - const [realCwd, realUploadsDir] = await Promise.all([ - fs.realpath(session.cwd), - fs.realpath(uploadsDir), - ]); - if (!resolveWithinRoot(realCwd, realUploadsDir)) { - res.status(400).json({ - error: "uploads directory escapes the session cwd", - }); + res.json(await writeAttachment(session.cwd, parsed.data)); + } catch (error) { + if (error instanceof AttachmentError) { + res.status(error.status).json({ error: error.message }); return; } - const filePath = path.join( - realUploadsDir, - `${randomUUID()}${extension}`, - ); - await fs.writeFile(filePath, buffer); - const response: AttachFileResponse = { - path: filePath, - mediaType, - bytes: buffer.byteLength, - }; - res.json(response); - } catch (error) { next(error); } }, diff --git a/packages/harness/src/server/scaffold.ts b/packages/harness/src/server/scaffold.ts index 2717e0f50..2279d86d4 100644 --- a/packages/harness/src/server/scaffold.ts +++ b/packages/harness/src/server/scaffold.ts @@ -217,133 +217,85 @@ async function removeFailedScaffold(dir: string): Promise { * 500 — the scaffold itself failed; the directory it may have created is * removed first, so the retry meets the same clean state the first attempt did. */ -export function createAgentScaffoldRouter( +export class ScaffoldError extends Error { + constructor(readonly status: number, message: string) { + super(message); + } +} + +/** One guarded implementation for the agent dialog and first-run launch. */ +export async function scaffoldAgentProject( deps: AgentScaffoldDeps, -): ExpressRouter { + input: { root?: unknown; name?: unknown; template?: unknown }, +): Promise { + const { root, name } = input; + const template = input.template ?? "default"; + if (typeof root !== "string" || !path.isAbsolute(root) || hasTraversalSegment(root)) { + throw new ScaffoldError(400, "root must be an absolute path"); + } + const nameRefusal = refuseAgentName(name); + if (nameRefusal != null) throw new ScaffoldError(400, nameRefusal); + if (typeof template !== "string" || !TEMPLATE_NAME.test(template)) { + throw new ScaffoldError(400, `Unknown template '${String(template)}'.`); + } + const requested = path.resolve(root); + const projectDir = (await deps.listProjectDirs()).find( + (dir) => typeof dir === "string" && dir.trim() !== "" && samePath(dir, requested), + ); + if (projectDir == null) { + throw new ScaffoldError(409, `Can't create an agent in ${requested} — Studio doesn't show that folder as a project.`); + } + const projectLabel = path.basename(path.resolve(projectDir)) || projectDir; + const child = childPath(path.resolve(projectDir), name as string); + // Reassert containment at the filesystem sink, using the server's root. + const target = child == null ? null : resolveWithinRoot(path.resolve(projectDir), child); + if (target == null) { + throw new ScaffoldError(400, `'${String(name)}' isn't a folder name.`); + } + const existing = deps.resolveAgent(target); + if (existing != null) { + throw new ScaffoldError(409, `${projectLabel} already has an agent called ${existing.name}.`); + } + const diskRefusal = await refuseScaffoldOnDisk(target, projectLabel); + if (diskRefusal != null) throw new ScaffoldError(409, diskRefusal); + const claimRefusal = await claimTarget(target, path.resolve(projectDir), projectLabel); + if (claimRefusal != null) throw new ScaffoldError(409, claimRefusal); + + let result: { dependenciesInstalled: boolean }; + try { + result = await deps.scaffoldAgent({ targetDir: target, template }); + } catch (err) { + await removeFailedScaffold(target); + throw new ScaffoldError(500, (err as Error).message || `Couldn't create ${String(name)}.`); + } + await deps.onScaffolded(target); + return { + ok: true, + path: target, + name: path.basename(target), + template, + dependenciesInstalled: result.dependenciesInstalled, + }; +} + +export function createAgentScaffoldRouter(deps: AgentScaffoldDeps): ExpressRouter { const router = Router(); - /** - * A create is the most expensive request this server serves — a template - * copy, an `npm install` and a `git init` per call — so it is the one worth - * bounding. The window is far above anything a person clicking a dialog can - * reach; it exists so a stuck client cannot turn a create loop into a disk - * full of half-built projects. Same shape as the attachment-upload limiter - * in `server/rest.ts`. - */ const scaffoldRateLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, }); - router.post("/api/agents/scaffold", scaffoldRateLimiter, async (req, res, next) => { - const body = (req.body ?? {}) as { - root?: unknown; - name?: unknown; - template?: unknown; - }; - const { root, name } = body; - const template = body.template ?? "default"; - - if (typeof root !== "string" || !path.isAbsolute(root) || hasTraversalSegment(root)) { - res.status(400).json({ error: "root must be an absolute path" }); - return; - } - const nameRefusal = refuseAgentName(name); - if (nameRefusal != null) { - res.status(400).json({ error: nameRefusal }); - return; - } - if (typeof template !== "string" || !TEMPLATE_NAME.test(template)) { - res.status(400).json({ error: `Unknown template '${String(template)}'.` }); - return; - } - try { - // THE DESTINATION BARRIER, identical in shape to the move route's: the - // requested root is matched against the directories the rail can show, - // and the DIRECTORY FROM THAT LIST is what the scaffold writes into. - const requested = path.resolve(root); - const projectDir = (await deps.listProjectDirs()).find( - (dir) => - typeof dir === "string" && dir.trim() !== "" && samePath(dir, requested), - ); - if (projectDir == null) { - res.status(409).json({ - error: `Can't create an agent in ${requested} — Studio doesn't show that folder as a project.`, - }); - return; - } - const projectLabel = path.basename(path.resolve(projectDir)) || projectDir; - // THE JOIN, on `path.resolve` of the LIST's entry rather than the - // request's. `childPath` is the rule: one plain child of this project, - // nothing else — it is what refuses every escaping name, and the - // co-located test proves that by posting them. - // - // `resolveWithinRoot` is a SINK-LOCAL RE-ASSERTION on top of it, and - // deliberately unreachable: nothing `childPath` returns can fail it - // today, so no test can make it fire (stubbing it out leaves the suite - // green — said plainly rather than dressed up as a second guard). It - // earns its place twice over anyway: it is the containment check in the - // form static analysis recognizes — CodeQL reads `childPath`'s - // `dirname(...) === root` comparison as no barrier at all and flags every - // `fs` call below as path injection — and it survives a reordering of the - // guards above it, which is exactly the edit that would make the rule - // reachable again. Same shape `server/canvas.ts` uses for its - // user-supplied sub-path. - const child = childPath(path.resolve(projectDir), name as string); - const target = - child == null ? null : resolveWithinRoot(path.resolve(projectDir), child); - if (target == null) { - res.status(400).json({ error: `'${String(name)}' isn't a folder name.` }); - return; - } - - const existing = deps.resolveAgent(target); - if (existing != null) { - res.status(409).json({ - error: `${projectLabel} already has an agent called ${existing.name}.`, - }); - return; - } - const diskRefusal = await refuseScaffoldOnDisk(target, projectLabel); - if (diskRefusal != null) { - res.status(409).json({ error: diskRefusal }); - return; - } - // THE CLAIM, and the last word on who owns this name. Everything above is - // a reason to refuse early; this is the one act that cannot be raced. - const claimRefusal = await claimTarget(target, path.resolve(projectDir), projectLabel); - if (claimRefusal != null) { - res.status(409).json({ error: claimRefusal }); - return; - } - - let result: { dependenciesInstalled: boolean }; - try { - result = await deps.scaffoldAgent({ targetDir: target, template }); - } catch (err) { - await removeFailedScaffold(target); - res.status(500).json({ - error: (err as Error).message || `Couldn't create ${String(name)}.`, - }); + res.json(await scaffoldAgentProject(deps, req.body ?? {})); + } catch (err) { + if (err instanceof ScaffoldError) { + res.status(err.status).json({ error: err.message }); return; } - - // Before the response, deliberately: the agent is in the rail by the time - // the caller can act on the result. - await deps.onScaffolded(target); - res.json({ - ok: true, - path: target, - name: path.basename(target), - template, - dependenciesInstalled: result.dependenciesInstalled, - } satisfies AgentScaffoldResponse); - } catch (err) { next(err); } }); - return router; } diff --git a/packages/harness/src/server/served-system-prompt.test.ts b/packages/harness/src/server/served-system-prompt.test.ts index 4c7d8b804..57208077a 100644 --- a/packages/harness/src/server/served-system-prompt.test.ts +++ b/packages/harness/src/server/served-system-prompt.test.ts @@ -14,10 +14,10 @@ import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { FocusedSessionContextProjection } from "../core/focused-session-context.js"; import { startServer, type HarnessServer } from "./index.js"; -import { DEFAULT_SYSTEM_PROMPT } from "../profiles/default.js"; import { PROJECT_AGENT_PROMPT_APPENDIX } from "../profiles/project-agent.js"; -import type { FocusedSessionContextProjection } from "../core/focused-session-context.js"; +import { DEFAULT_SYSTEM_PROMPT } from "../profiles/default.js"; import type { HarnessAdapter, HarnessKind, @@ -68,48 +68,52 @@ describe("served system prompt reaches the launched session", () => { server = undefined; // Retried like the other server specs: a session's exit-time generated-dir // removal is fire-and-forget and can still be running here (ENOTEMPTY). - await rm(dir, { - recursive: true, - force: true, - maxRetries: 10, - retryDelay: 50, - }); + await rm(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); }); - async function boot( - loadSystemPrompt: () => Promise, - ): Promise { + async function boot(loadSystemPrompt: () => Promise): Promise { return startServer({ port: 0, bootToken: "test-token", telemetryOptIn: false, autoCreateSession: false, - adapters: { "claude-code": fakeAdapter("claude-code") }, + adapters: { "claude-code": fakeAdapter("claude-code"), codex: fakeAdapter("codex") }, stateRoot: dir, loadSystemPrompt, }); } async function systemPromptFile(harnessSessionId: string): Promise { - return readFile( - join(generatedRoot, harnessSessionId, "system-prompt.txt"), - "utf8", - ); + return readFile(join(generatedRoot, harnessSessionId, "system-prompt.txt"), "utf8"); } it("writes the served prompt, not the bundled one, on create", async () => { server = await boot(async () => SERVED_PROMPT); - const session = await server.sessionManager.create({ - cwd, - harness: "claude-code", - }); + const session = await server.sessionManager.create({ cwd, harness: "claude-code" }); const prompt = await systemPromptFile(session.id); expect(prompt).toContain(SERVED_PROMPT); expect(prompt).toContain(PROJECT_AGENT_PROMPT_APPENDIX); }); + it.each(["claude-code", "codex"])( + "upgrades the known stale served orientation at the %s launch boundary", + async (harness) => { + const legacy = (await readFile(new URL("../profiles/fixtures/legacy-system-prompt.md", import.meta.url), "utf8")).trim(); + server = await boot(async () => legacy); + const session = await server.sessionManager.create({ cwd, harness }); + const prompt = await systemPromptFile(session.id); + expect(prompt).toContain(DEFAULT_SYSTEM_PROMPT); + expect(prompt).toContain(PROJECT_AGENT_PROMPT_APPENDIX); + expect(prompt).not.toContain("two Sapiom MCP servers"); + expect(prompt).not.toContain("then stop"); + expect(prompt).toContain("ctx.sapiom.llm.run"); + expect(prompt).toContain("sapiom_dev_agents_*"); + expect(prompt).toContain("build_plan_rebase"); + }, + ); + it("adds an optional focused overlay after the unchanged common project prompt", async () => { server = await boot(async () => SERVED_PROMPT); const focused = ( @@ -130,25 +134,16 @@ describe("served system prompt reaches the launched session", () => { let served = SERVED_PROMPT; server = await boot(async () => served); - const session = await server.sessionManager.create({ - cwd, - harness: "claude-code", - }); + const session = await server.sessionManager.create({ cwd, harness: "claude-code" }); // A resume needs an agent session id; the fake adapter reports none, so record one // the way the hook ingest would. - await server.sessionManager.setAgentSessionId( - session.id, - "agent-session-1", - ); + await server.sessionManager.setAgentSessionId(session.id, "agent-session-1"); await server.sessionManager.kill(session.id); served = "# Redeployed prompt"; await server.sessionManager.resume(session.id); - const prompt = await systemPromptFile(session.id); - expect(prompt).toContain("# Redeployed prompt"); - expect(prompt).not.toContain(SERVED_PROMPT); - expect(prompt).toContain(PROJECT_AGENT_PROMPT_APPENDIX); + expect(await systemPromptFile(session.id)).toBe(`# Redeployed prompt\n\n${PROJECT_AGENT_PROMPT_APPENDIX}\n`); }); it("falls back to the bundled profile when the load fails", async () => { @@ -158,13 +153,8 @@ describe("served system prompt reaches the launched session", () => { throw new Error("backend unreachable"); }); - const session = await server.sessionManager.create({ - cwd, - harness: "claude-code", - }); + const session = await server.sessionManager.create({ cwd, harness: "claude-code" }); - const prompt = await systemPromptFile(session.id); - expect(prompt).toContain(DEFAULT_SYSTEM_PROMPT); - expect(prompt).toContain(PROJECT_AGENT_PROMPT_APPENDIX); + expect(await systemPromptFile(session.id)).toBe(`${DEFAULT_SYSTEM_PROMPT}\n\n${PROJECT_AGENT_PROMPT_APPENDIX}\n`); }); }); diff --git a/packages/harness/src/server/studio-workspace-wiring.test.ts b/packages/harness/src/server/studio-workspace-wiring.test.ts index f5d7bd8e9..446f87b68 100644 --- a/packages/harness/src/server/studio-workspace-wiring.test.ts +++ b/packages/harness/src/server/studio-workspace-wiring.test.ts @@ -1,13 +1,16 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { StudioCurrentWorkspaceResponse, StudioProjectSummary, } from "../shared/agent-map.js"; -import type { AppState } from "../shared/types.js"; +import type { AppState, HarnessAdapter } from "../shared/types.js"; +import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; +import { SystemGraphStore } from "../core/system-graph-store.js"; +import { SystemGraphWatcherManager } from "../core/system-graph-watcher.js"; import { startServer, type HarnessServer } from "./index.js"; describe("real Studio workspace wiring", () => { @@ -19,6 +22,56 @@ describe("real Studio workspace wiring", () => { server = undefined; if (root) await fs.rm(root, { recursive: true, force: true }); root = undefined; + vi.restoreAllMocks(); + }); + + it("resolves and retains a published durable root when only its descendant session remains", async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "studio-root-scope-wiring-")); + const projectRoot = path.join(root, "project"); + const descendant = path.join(projectRoot, "src"); + await fs.mkdir(descendant, { recursive: true }); + const catalog = new StudioProjectCatalog(path.join(root, "studio-projects.json")); + const project = (await catalog.reconcile([ + { workspaceKey: "legacy-project", cwd: projectRoot }, + ])).projects[0]!; + await fs.writeFile(path.join(root, "settings.json"), JSON.stringify({ recentDirs: [projectRoot] })); + const adapter: HarnessAdapter = { + id: "claude-code", + eventSource: "hooks", + doctor: async () => [], + launch: (opts) => ({ command: "bash", args: [], env: {}, cwd: opts.cwd }), + resume: (_id, opts) => ({ command: "bash", args: [], env: {}, cwd: opts.cwd }), + listPastSessions: async () => [], + canResume: async () => true, + }; + server = await startServer({ + port: 0, + bootToken: "test-token", + telemetryOptIn: false, + adapters: { "claude-code": adapter }, + stateRoot: root, + launchDir: projectRoot, + autoCreateSession: false, + loadSystemPrompt: async () => "", + }); + const session = await server.sessionManager.create({ cwd: descendant, harness: "claude-code" }); + expect(session.agentMapIdentity?.projectId).toBe(project.projectId); + await fs.writeFile(path.join(root, "settings.json"), JSON.stringify({ recentDirs: [] })); + const watcherRetain = vi.spyOn(SystemGraphWatcherManager.prototype, "retain"); + const storeRetain = vi.spyOn(SystemGraphStore.prototype, "retain"); + const headers = { "X-Harness-Token": "test-token" }; + const baseUrl = `http://127.0.0.1:${server.port}`; + const state = await (await fetch(`${baseUrl}/api/state`, { headers })).json() as AppState; + const scope = state.workspaceScopes?.find(({ cwd }) => cwd === projectRoot); + expect(scope?.projectId).toBe(project.projectId); + const graph = await fetch(`${baseUrl}/api/workspaces/${scope!.workspaceKey}/system-graph`, { headers }); + expect(graph.status).toBe(200); + expect(watcherRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); + expect(storeRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); + await fetch(`${baseUrl}/api/state`, { headers }); + expect(watcherRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); + expect(storeRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); + expect((await fs.readFile(path.join(root, "settings.json"), "utf8"))).not.toContain(projectRoot); }); it("publishes opaque AppState bindings and restores one across a null-definition move and restart", async () => { diff --git a/packages/harness/src/shared/initial-prompt.ts b/packages/harness/src/shared/initial-prompt.ts new file mode 100644 index 000000000..b603d0d16 --- /dev/null +++ b/packages/harness/src/shared/initial-prompt.ts @@ -0,0 +1,18 @@ +/** Paths are context, never shell commands. Match native terminal drop quoting. */ +export function quotePathForTerminal(path: string): string { + if (/^[A-Za-z0-9_\-./~:\\]+$/.test(path)) return path; + if (/^[A-Za-z]:[\\/]/.test(path)) return `"${path}"`; + return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export function buildIdeaWithAttachments( + idea: string, + attachments: readonly { path: string; name?: string }[], +): string | undefined { + const trimmed = idea.trim(); + if (attachments.length === 0) return trimmed || undefined; + const context = + "Attached files (read each as context):\n" + + attachments.map(({ path }) => quotePathForTerminal(path)).join("\n"); + return trimmed ? `${trimmed}\n\n${context}` : context; +} diff --git a/packages/harness/src/shared/project-roots.ts b/packages/harness/src/shared/project-roots.ts index c0f4f9ca3..8e93a7f77 100644 --- a/packages/harness/src/shared/project-roots.ts +++ b/packages/harness/src/shared/project-roots.ts @@ -239,10 +239,8 @@ export function holdingProjectFor( * * A PROJECT IS A DIRECTORY YOU CHOSE THAT HOLDS AGENTS. * - * Two clauses, and dropping either one is what filled a real rail. Measured - * against a captured `~/.sapiom/harness` (`org-dogfood.json` in the design - * prototype: 75 agents, 8 recentDirs, 41 distinct session cwds), the sources - * below offer 41 candidate roots and this function returns 8. + * Both clauses keep session working directories from accumulating as + * duplicate project rows. * * RULE 1, "you chose": an agent's OWN directory is not a project. The project * is the directory that HOLDS agents; the agent is the thing inside it. A root @@ -251,9 +249,8 @@ export function holdingProjectFor( * one node, because nothing else is inside it. And it renders the agent TWICE * whenever some other open project also contains it, once correctly nested and * once again at top level under a different label, because `buildProjectTree` - * deliberately files an agent under EVERY root that contains it. Three agents - * were on screen twice this way on one real machine. So an agent-rooted entry - * whose agent another project already shows is dropped, and one nothing shows + * deliberately files an agent under EVERY root that contains it. An agent-rooted + * entry whose agent another project already shows is dropped, and one nothing shows * is replaced by its nearest non-agent ancestor. * * This is not a new rule. `project-membership.agentNeedsOwnProject` has diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index daa043c52..9ec832835 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -104,6 +104,11 @@ export const MAX_INLINE_ATTACHMENTS_TOTAL_BYTES = 50 * 1024 * 1024; */ export const JSON_BODY_LIMIT_BYTES = 15 * 1024 * 1024; +/** First-turn uploads arrive together, bounded by the composer's 50 MiB cap. + * Other endpoints keep the smaller JSON limit. */ +export const CREATE_SESSION_JSON_LIMIT_BYTES = + Math.ceil(MAX_INLINE_ATTACHMENTS_TOTAL_BYTES * 4 / 3) + 1024 * 1024; + /** * Workspace-state convention: Agent Studio mirrors this session's binding, * the full agent registry, and its own identity here, relative to the @@ -326,6 +331,9 @@ export interface LaunchOpts { * --plugin-dir (e.g. codex) silently ignore this field. */ pluginDir?: string; + /** User-authored first turn, passed to the interactive CLI at fresh launch. + * Never persisted in session metadata or replayed by resume. */ + initialPrompt?: string; /** Only consulted by `launchTask` — the one-shot prompt a headless * background task runs, then exits. Unused by `launch`/`resume`. */ prompt?: string; @@ -1106,6 +1114,16 @@ export interface CreateSessionRequest { harness: HarnessKind; /** Profile id; omit for default. */ profile?: string; + /** First user turn. The CLI owns delivery after its trust/login screens. */ + initialPrompt?: string; + /** Files to materialize before spawning, in the user's selected order. */ + initialAttachments?: Array< + | { kind: "path"; path: string } + | ({ kind: "inline" } & AttachFileRequest) + >; + /** Create a new project at cwd using the same guarded scaffold as agent +. + * Omitted for sessions in existing projects. */ + scaffold?: { template: string }; /** * Content-free lifecycle hint: the UI already owns a real first input that * will be delivered after readiness/attachments. A new-project bootstrap diff --git a/packages/harness/vitest.config.ts b/packages/harness/vitest.config.ts index 51547410f..4f4f6d3c5 100644 --- a/packages/harness/vitest.config.ts +++ b/packages/harness/vitest.config.ts @@ -13,6 +13,9 @@ import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { + "@shared/initial-prompt": fileURLToPath( + new URL("src/shared/initial-prompt.ts", import.meta.url), + ), // Resolve "@shared/types" to the package's canonical contract so web // unit tests and server tests always build against the same source of // truth. Mirrors the alias in web/vite.config.ts. diff --git a/packages/harness/web/e2e/dialog-shell.spec.ts b/packages/harness/web/e2e/dialog-shell.spec.ts new file mode 100644 index 000000000..263de90a0 --- /dev/null +++ b/packages/harness/web/e2e/dialog-shell.spec.ts @@ -0,0 +1,308 @@ +/** + * ONE dialog contract, asserted against every dialog that goes through it. + * + * Studio had eleven hand-rolled dialogs and no primitive; each re-decided + * whether Tab could leave, where focus landed, whether the backdrop dismissed, + * and whether the page behind it was still reachable, and they did not agree. + * `components/Dialog.tsx` is the one shell now, so this file is written as a + * TABLE rather than as four spec files: a dialog that dismissed differently + * from its siblings is exactly the bug the shell exists to prevent, and only a + * spec that runs the same assertions over all of them can see it. + * + * Adding a dialog to the shell means adding a row here. If the row cannot be + * written, the dialog is not on the shell — with one stated exception: + * `CloneAgentConfirm` is on the shell and has no row, because its only door is + * a `sapiom://agent/` deep link that mock mode has no way to open. It is + * covered by inspection and by the shell's own rows, not by a row of its own. + */ +import { expect, test } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; + +import { openProjectMenu } from "./mock-navigation"; + +interface DialogCase { + name: string; + /** Opens the dialog and leaves it on screen. */ + open: (page: Page) => Promise; + /** The dialog surface. */ + surface: (page: Page) => Locator; + /** + * The control that opened it — focus must come back here on close. + * + * OMITTED where the door is a control that unmounts when it is used, which is + * `CreateAgentDialog`: its project-row menu closes on the click that opens the + * dialog, so there is no node left to return focus to and the honest outcome + * is that focus falls back to the document. Asserting THAT is the point of + * making this optional rather than dropping the case — a dialog that left + * focus on a detached node would fail either way. + */ + trigger?: (page: Page) => Locator; + /** + * The control that must hold focus the moment the dialog opens. Never the + * header's close button: a dialog that opens on its own dismiss control + * reads as already half-cancelled, and Enter closes it. + */ + opensFocusedOn: (page: Page) => Locator; + /** Something focusable on the page BEHIND the dialog, which must be inert + * and unreachable while it is up. */ + behind: (page: Page) => Locator; +} + +const CASES: DialogCase[] = [ + { + name: "StartDialog (Add existing agents)", + open: async (page) => { + await page.goto("/"); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await page.getByTestId("add-existing-agents").click(); + await expect(page.locator(".modal-start")).toBeVisible(); + }, + surface: (page) => page.locator(".modal-start"), + trigger: (page) => page.getByTestId("add-existing-agents"), + opensFocusedOn: (page) => page.getByTestId("folder-field-input"), + behind: (page) => page.getByTestId("rail-create-new"), + }, + { + name: "RemoveProjectConfirm", + open: async (page) => { + await page.goto("/"); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await openProjectMenu(page, "acme-app"); + await page.getByTestId("project-remove-acme-app").click(); + await expect(page.getByTestId("remove-project-confirm")).toBeVisible(); + }, + surface: (page) => page.getByTestId("remove-project-confirm"), + trigger: (page) => page.getByTestId("project-menu-acme-app"), + // The SAFE action, on a destructive dialog: Enter keeps the project. + opensFocusedOn: (page) => page.getByRole("button", { name: "Keep project" }), + behind: (page) => page.getByTestId("rail-create-new"), + }, + { + name: "CreateAgentDialog", + open: async (page) => { + await page.goto("/?seed=0&mockStudioProjects=absent"); + await expect(page.getByTestId("workspace-group-acme-app")).toBeVisible(); + await openProjectMenu(page, "acme-app"); + await page.getByTestId("project-create-agent-acme-app").click(); + await expect(page.getByTestId("create-agent-dialog")).toBeVisible(); + }, + surface: (page) => page.getByTestId("create-agent-dialog"), + opensFocusedOn: (page) => page.getByTestId("create-agent-name"), + behind: (page) => page.getByTestId("rail-create-new"), + }, + { + name: "EndSessionConfirm", + open: async (page) => { + await page.goto("/"); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await page.getByTestId("session-menu").click(); + await page.getByTestId("session-end-btn").click(); + await expect(page.getByTestId("end-session-confirm")).toBeVisible(); + }, + surface: (page) => page.getByTestId("end-session-confirm"), + trigger: (page) => page.getByTestId("session-menu"), + // The SAFE action: Enter keeps the session. + opensFocusedOn: (page) => page.getByRole("button", { name: "Keep session" }), + behind: (page) => page.getByTestId("rail-create-new"), + }, + { + name: "TemplateUseDialog", + open: async (page) => { + await page.goto("/?mockState=fresh"); + await expect(page.getByTestId("new-session-composer")).toBeVisible(); + await page.getByTestId("composer-browse-templates").click(); + await expect(page.getByTestId("templates-panel")).toBeVisible(); + // Opened from the template's own detail view rather than from the card's + // spec-sheet popover: that popover light-dismisses on the same press that + // closes the dialog, so its button is gone by the time focus should come + // back to it, and "restores focus to the trigger" has no subject. + await page.getByTestId("template-card-open-hello-agent").click(); + await expect(page.getByTestId("template-detail")).toBeVisible(); + await page.getByTestId("template-use-btn").click(); + await expect(page.getByTestId("template-use-dialog")).toBeVisible(); + }, + surface: (page) => page.getByTestId("template-use-dialog"), + trigger: (page) => page.getByTestId("template-use-btn"), + opensFocusedOn: (page) => page.getByTestId("folder-field-input"), + behind: (page) => page.getByTestId("template-detail-back"), + }, +]; + +/** Whether the element that currently has focus is inside the dialog. */ +const focusIsInside = (surface: Locator): Promise => + surface.evaluate((element) => element.contains(document.activeElement)); + +/** A short, stable description of the focused element, so a failure names WHICH + * control escaped rather than only reporting `false`. */ +const focusedDescription = (page: Page): Promise => + page.evaluate(() => { + const active = document.activeElement as HTMLElement | null; + if (!active) return "none"; + const id = + active.getAttribute("data-testid") ?? + active.getAttribute("aria-label") ?? + active.className; + return `${active.tagName.toLowerCase()}[${id}]`; + }); + +/** + * Focus after the dialog has gone: back on the trigger where there is one, and + * released to the document where the trigger no longer exists. Never left on a + * control inside the dialog that just unmounted, which is what "focus was lost" + * actually looks like. + */ +async function expectFocusRestored( + page: Page, + dialog: DialogCase, +): Promise { + if (dialog.trigger) { + await expect(dialog.trigger(page)).toBeFocused(); + return; + } + expect(await focusedDescription(page)).toBe("body[]"); +} + +for (const dialog of CASES) { + test.describe(dialog.name, () => { + test.beforeEach(async ({ page }) => { + await dialog.open(page); + }); + + test("opens focused on its subject, never on its own close button", async ({ + page, + }) => { + await expect(dialog.opensFocusedOn(page)).toBeFocused(); + }); + + test("Escape dismisses it and hands focus back to what opened it", async ({ + page, + }) => { + await page.keyboard.press("Escape"); + await expect(dialog.surface(page)).toHaveCount(0); + await expectFocusRestored(page, dialog); + }); + + test("a press on the backdrop dismisses it; a press inside does not", async ({ + page, + }) => { + // Inside first: a dialog that closed on its own content would pass a + // backdrop-only assertion while being unusable. + await dialog.surface(page).click({ position: { x: 6, y: 6 } }); + await expect(dialog.surface(page)).toBeVisible(); + + // The panel is centred, so the backdrop's top-left corner is outside it. + await page.locator(".modal-backdrop").click({ position: { x: 5, y: 5 } }); + await expect(dialog.surface(page)).toHaveCount(0); + }); + + test("the close control dismisses it and hands focus back", async ({ + page, + }) => { + await dialog.surface(page).getByRole("button", { name: "Close" }).click(); + await expect(dialog.surface(page)).toHaveCount(0); + await expectFocusRestored(page, dialog); + }); + + test("Tab is contained: focus never leaves the dialog, forwards or back", async ({ + page, + }) => { + const surface = dialog.surface(page); + // Enough presses to walk past the end of any of these dialogs and wrap + // several times. One press would pass on a dialog with no trap at all, + // because the first Tab out of a form field lands on the next field. + for (let press = 0; press < 14; press += 1) { + await page.keyboard.press("Tab"); + expect( + await focusIsInside(surface), + `focus left the dialog after ${press + 1} Tab presses, onto ${await focusedDescription(page)}`, + ).toBe(true); + } + for (let press = 0; press < 14; press += 1) { + await page.keyboard.press("Shift+Tab"); + expect( + await focusIsInside(surface), + `focus left the dialog after ${press + 1} Shift+Tab presses, onto ${await focusedDescription(page)}`, + ).toBe(true); + } + }); + + test("the page behind it is inert, so nothing back there takes focus", async ({ + page, + }) => { + const behind = dialog.behind(page); + await expect(behind).toHaveCount(1); + expect( + await behind.evaluate((element) => element.closest("[inert]") !== null), + ).toBe(true); + // And the flag is real, not decorative: an inert subtree refuses focus + // even when something calls focus() on it directly. + expect( + await behind.evaluate((element) => { + (element as HTMLElement).focus(); + return document.activeElement === element; + }), + ).toBe(false); + }); + + test("the surface carries one accessible name, taken from its own heading", async ({ + page, + }) => { + const surface = dialog.surface(page); + await expect(surface).toHaveAttribute("aria-modal", "true"); + const labelledBy = await surface.getAttribute("aria-labelledby"); + expect(labelledBy).toBeTruthy(); + // A hand-written aria-label beside a visible title is what drifted on + // four of the eleven; the shell offers no way to write one. + expect(await surface.getAttribute("aria-label")).toBeNull(); + const heading = surface.locator(`[id="${labelledBy}"]`); + await expect(heading).toBeVisible(); + expect((await heading.textContent())?.trim()).toBeTruthy(); + }); + + test("Tab belongs to whatever opened OVER it, not to this dialog", async ({ + page, + }) => { + // A layer that mounts AFTER this dialog is not inert — the background + // sweep ran before it existed — and the trap is a document listener, so + // every open dialog sees every Tab. Without a topmost-layer guard, this + // dialog's trap preventDefaults and pulls focus onto its own first + // control, which is behind the newer scrim: the next keystroke lands in a + // field nobody can see. + // + // The command palette is the real case: App.tsx's Cmd-K handler opens it + // over an open dialog, and it carries no `role`, so only its + // `.modal-backdrop` identifies it as a layer at all. + // + // TWO presses, not ten, and the number is the claim. The guard makes this + // dialog decline the Tab; it cannot make the palette contain focus, + // because the palette has no trap and neither surface inerts the other. + // Native order therefore does walk out of the palette eventually — see + // the PR's note on App.tsx's missing dialog guard. What is asserted here + // is the part this shell owns: it does not SEIZE the Tab. + const surface = dialog.surface(page); + await page.keyboard.press("ControlOrMeta+k"); + const palette = page.getByTestId("command-palette-input"); + await expect(palette).toBeVisible(); + // A path-shaped query is the branch where the palette does NOT handle Tab + // itself, so nothing but the guard keeps focus out of the dialog. + await palette.fill("/Users/demo"); + for (let press = 0; press < 2; press += 1) { + await page.keyboard.press("Tab"); + expect( + await focusIsInside(surface), + `the dialog's trap seized Tab from the palette after ${press + 1} presses, onto ${await focusedDescription(page)}`, + ).toBe(false); + } + }); + + test("the background goes back to normal when it closes", async ({ + page, + }) => { + await page.keyboard.press("Escape"); + await expect(dialog.surface(page)).toHaveCount(0); + // A dialog that leaves `inert` behind freezes the whole app, which is a + // worse failure than never setting it. + expect(await page.locator("[inert]").count()).toBe(0); + }); + }); +} diff --git a/packages/harness/web/e2e/new-session-composer.spec.ts b/packages/harness/web/e2e/new-session-composer.spec.ts index 4994aac31..0563f0aa9 100644 --- a/packages/harness/web/e2e/new-session-composer.spec.ts +++ b/packages/harness/web/e2e/new-session-composer.spec.ts @@ -11,14 +11,14 @@ import type { Page } from "@playwright/test"; import { selectMockSessionFromPalette } from "./mock-navigation"; -const lastInjectText = (page: Page): Promise => +const initialTaskText = (page: Page): Promise => page.evaluate( () => ( window as unknown as { - __HARNESS_TEST__?: { lastInjectInput?: { req?: { text?: string } } }; + __HARNESS_TEST__?: { lastInitialInput?: { text?: string } }; } - ).__HARNESS_TEST__?.lastInjectInput?.req?.text ?? "", + ).__HARNESS_TEST__?.lastInitialInput?.text ?? "", ); const injectCallCount = (page: Page): Promise => @@ -37,8 +37,8 @@ const sessionEvidence = ( activeSessionId: string | null; createSessionCalls: number; injectInputCalls: number; - injectedSessionId: string | null; - injectedText: string; + initialSessionId: string | null; + initialText: string; }> => page.evaluate(() => { const testState = ( @@ -46,7 +46,7 @@ const sessionEvidence = ( __HARNESS_TEST__?: { createSessionCalls?: unknown[]; injectInputCalls?: unknown[]; - lastInjectInput?: { id?: string; req?: { text?: string } }; + lastInitialInput?: { id?: string; text?: string }; }; } ).__HARNESS_TEST__; @@ -57,8 +57,8 @@ const sessionEvidence = ( ?.getAttribute("data-session-id") || null, createSessionCalls: testState?.createSessionCalls?.length ?? 0, injectInputCalls: testState?.injectInputCalls?.length ?? 0, - injectedSessionId: testState?.lastInjectInput?.id ?? null, - injectedText: testState?.lastInjectInput?.req?.text ?? "", + initialSessionId: testState?.lastInitialInput?.id ?? null, + initialText: testState?.lastInitialInput?.text ?? "", }; }); @@ -98,10 +98,11 @@ test("describing an outcome starts a session and hands the agent that outcome", await expect(page.getByTestId("new-session-composer")).toHaveCount(0); await expect(page.getByTestId("agent-view")).toBeVisible(); - // The typed outcome rode into the scaffold prompt handed to the agent. + // The exact user task is a launch argument, without a scaffold wrapper. await expect - .poll(() => lastInjectText(page)) - .toContain("Diff our competitors' pricing pages"); + .poll(() => initialTaskText(page)) + .toBe("Diff our competitors' pricing pages every morning."); + expect(await injectCallCount(page)).toBe(0); }); test("Enter keeps a new-agent prompt in its exact session while the project map is inspected", async ({ @@ -164,16 +165,16 @@ test("Enter keeps a new-agent prompt in its exact session while the project map await expect(page.getByTestId("new-session-composer")).toHaveCount(0); await expect - .poll(async () => (await sessionEvidence(page)).injectedText) + .poll(async () => (await sessionEvidence(page)).initialText) .toContain(idea); const evidence = await sessionEvidence(page); expect(evidence.createSessionCalls).toBe(before.createSessionCalls + 1); - expect(evidence.injectedSessionId).not.toBeNull(); - expect(evidence.injectedSessionId).not.toBe("sess-competing-plan-agents"); - expect(evidence.activeSessionId).toBe(evidence.injectedSessionId); + expect(evidence.initialSessionId).not.toBeNull(); + expect(evidence.initialSessionId).not.toBe("sess-competing-plan-agents"); + expect(evidence.activeSessionId).toBe(evidence.initialSessionId); expect(evidence.activeSessionId).not.toBe(before.activeSessionId); - expect(evidence.injectInputCalls).toBe(before.injectInputCalls + 1); + expect(evidence.injectInputCalls).toBe(before.injectInputCalls); expect( await page.evaluate( () => @@ -242,7 +243,7 @@ test("returning to an in-progress standalone session does not restore the projec ); const awaySessionId = (await sessionEvidence(page)).activeSessionId!; await expect - .poll(async () => (await sessionEvidence(page)).injectedText) + .poll(async () => (await sessionEvidence(page)).initialText) .toContain(idea); // Let the session we deliberately visited finish its own normal restore; // only map restoration caused by returning to the explicit session is under @@ -290,7 +291,7 @@ test("a picked file reaches the first request without naming the project", async await page.getByTestId("composer-send").click(); await expect - .poll(() => lastInjectText(page)) + .poll(() => initialTaskText(page)) .toContain('"/Users/test/My Files/requirements.pdf"'); const createRequest = await page.evaluate( @@ -389,21 +390,21 @@ test("picker, drop, and pathless clipboard files reach one ordered first request await page.getByTestId("composer-send").click(); await expect - .poll(() => lastInjectText(page)) + .poll(() => initialTaskText(page)) .toContain("mock-screenshot.png"); const proof = await page.evaluate(() => { const testState = ( window as unknown as { __HARNESS_TEST__?: { attachFileCalls?: unknown[]; - lastInjectInput?: { req?: { text?: string } }; + lastInitialInput?: { text?: string }; lastCreateSession?: { req?: { cwd?: string } }; }; } ).__HARNESS_TEST__; return { calls: testState?.attachFileCalls ?? [], - text: testState?.lastInjectInput?.req?.text ?? "", + text: testState?.lastInitialInput?.text ?? "", cwd: testState?.lastCreateSession?.req?.cwd ?? "", }; }); @@ -554,9 +555,9 @@ test("re-adding and removing files keeps only the intended first-request paths", await page.getByTestId("composer-input").fill("Use selected context."); await page.getByTestId("composer-send").click(); await expect - .poll(() => lastInjectText(page)) + .poll(() => initialTaskText(page)) .toContain("/Users/test/keep.pdf"); - expect(await lastInjectText(page)).not.toContain("remove.txt"); + expect(await initialTaskText(page)).not.toContain("remove.txt"); }); test("an attachment-only start uses the fallback project and sends the file", async ({ @@ -578,7 +579,7 @@ test("an attachment-only start uses the fallback project and sends the file", as await page.getByTestId("composer-send").click(); await expect - .poll(() => lastInjectText(page)) + .poll(() => initialTaskText(page)) .toContain("/Users/test/brief.pdf"); const cwd = await page.evaluate( () => @@ -662,11 +663,11 @@ test("an upload failure rolls back, retains the queue, sends nothing, and retrie injected: state?.lastInjectInput != null, }; }); - expect(failedProof).toEqual({ creates: 1, kills: 1, injected: false }); + expect(failedProof).toEqual({ creates: 1, kills: 0, injected: false }); await page.getByTestId("composer-send").click(); await expect - .poll(() => lastInjectText(page)) + .poll(() => initialTaskText(page)) .toContain("mock-retry-screenshot.png"); const createCount = await page.evaluate( () => @@ -683,7 +684,7 @@ for (const agent of [ { id: "claude-code", label: "Claude Code" }, { id: "codex", label: "Codex" }, ] as const) { - test(`holds the prompt until ${agent.label} is ready, then sends it exactly once`, async ({ + test(`passes the first task at ${agent.label} launch without pasting before or after readiness`, async ({ page, }) => { // Make the next session never reach ready on its own — the stand-in for a @@ -711,7 +712,7 @@ for (const agent of [ // The session exists (workbench shown) but the prompt is HELD, not // injected, because the session never became ready. await expect(page.getByTestId("agent-view")).toBeVisible(); - expect(await lastInjectText(page)).toBe(""); + expect(await initialTaskText(page)).toBe(prompt); expect(await injectCallCount(page)).toBe(0); const createdHarness = await page.evaluate( () => @@ -725,16 +726,8 @@ for (const agent of [ ); expect(createdHarness).toBe(agent.id); - // The provider-neutral hint points at terminal setup while preserving the - // original prompt. - await expect(page.getByTestId("toast")).toContainText( - /signing in or dismiss any trust or setup prompt/i, - { timeout: 8_000 }, - ); - expect(await lastInjectText(page)).toBe(""); - - // A readiness status releases the prompt. Repeating the status event must - // not inject the held intent a second time. + // The native CLI, not a browser readiness timer, owns the pending task. + // Repeated readiness notifications must never paste or re-submit it. await page.evaluate(() => ( window as unknown as { @@ -742,8 +735,8 @@ for (const agent of [ } ).__HARNESS_TEST__?.promoteReady?.(), ); - await expect.poll(() => lastInjectText(page)).toContain(prompt); - await expect.poll(() => injectCallCount(page)).toBe(1); + await expect.poll(() => initialTaskText(page)).toContain(prompt); + await expect.poll(() => injectCallCount(page)).toBe(0); await page.evaluate(() => ( @@ -753,7 +746,7 @@ for (const agent of [ ).__HARNESS_TEST__?.promoteReady?.(), ); await page.waitForTimeout(500); - expect(await injectCallCount(page)).toBe(1); + expect(await injectCallCount(page)).toBe(0); }); } diff --git a/packages/harness/web/e2e/open-project.spec.ts b/packages/harness/web/e2e/open-project.spec.ts index 37f80d93d..dd14596e9 100644 --- a/packages/harness/web/e2e/open-project.spec.ts +++ b/packages/harness/web/e2e/open-project.spec.ts @@ -51,7 +51,7 @@ test.describe("the header + opens a project", () => { await expect(page.getByTestId("project-row-blank-slate")).toHaveCount(0); await page.getByTestId("rail-add-project").click(); - await expect(page.locator(".modal-start-title")).toHaveText( + await expect(page.locator(".modal-title")).toHaveText( "Add a project", ); await page.getByTestId("folder-field-input").fill(BLANK); @@ -210,7 +210,7 @@ test.describe("the two questions stay two controls", () => { page, }) => { await page.getByTestId("add-existing-agents").click(); - await expect(page.locator(".modal-start-title")).toHaveText( + await expect(page.locator(".modal-title")).toHaveText( "Add existing agents", ); await page.getByTestId("folder-field-input").fill("/Users/demo/rfq-agent"); diff --git a/packages/harness/web/e2e/project-map-navigation.spec.ts b/packages/harness/web/e2e/project-map-navigation.spec.ts index 880cb931e..5fcedaec8 100644 --- a/packages/harness/web/e2e/project-map-navigation.spec.ts +++ b/packages/harness/web/e2e/project-map-navigation.spec.ts @@ -41,9 +41,89 @@ async function navigationEvidence(page: Page): Promise { } test.describe("SAP-3148 project Agent Map navigation", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/?seed=0&mockFixtures=deep&mockStudioProjects=present"); + test.beforeEach(async ({ page }, testInfo) => { + const sibling = testInfo.title.startsWith("a scaffolded sibling") + ? "&mockCreatedSibling=1" + : ""; + const empty = testInfo.title.includes("without a live conversation") + ? "&mockNoLiveSessions=1" + : ""; + await page.goto( + `/?seed=0&mockFixtures=deep&mockStudioProjects=present${sibling}${empty}`, + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + }); + + test("a scaffolded sibling stays under its creating project and keeps the same conversation", async ({ + page, + }) => { + const project = page.getByTestId("workspace-group-acme-app"); + const sibling = project.getByTestId("workflow-report-reviewer"); + await expect(sibling).toBeVisible(); + await expect(page.getByTestId("workflow-report-reviewer")).toHaveCount(1); + await expect( + page.getByTestId("workspace-group-report-reviewer"), + ).toHaveCount(0); + const before = await navigationEvidence(page); + await sibling.click(); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); + expect(await navigationEvidence(page)).toEqual(before); + await openProjectMap(page, "acme-app"); + await sibling.click(); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); + expect(await navigationEvidence(page)).toEqual(before); + await page.reload(); + await expect(project.getByTestId("workflow-report-reviewer")).toBeVisible(); + await sibling.click(); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); + expect(await navigationEvidence(page)).toEqual(before); + await page.getByTestId("history-trigger").click(); + await page.getByTestId("filing-group-by").selectOption("group"); + await page.keyboard.press("Escape"); + await expect(project.getByTestId("workflow-report-reviewer")).toBeVisible(); + await project.getByTestId("workflow-report-reviewer").click(); + expect(await navigationEvidence(page)).toEqual(before); + }); + + test("a scaffolded sibling without a live conversation starts at its original project root", async ({ + page, + }) => { + await page + .getByTestId("workspace-group-acme-app") + .getByTestId("workflow-report-reviewer") + .click(); + await page.getByTestId("open-agent-start-session").click(); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); + const calls = await page.evaluate( + () => + ( + window as unknown as { + __HARNESS_TEST__?: { + createSessionCalls?: Array<{ req: { cwd: string } }>; + }; + } + ).__HARNESS_TEST__?.createSessionCalls ?? [], + ); + expect(calls).toHaveLength(1); + expect(calls[0]?.req.cwd).toBe("/Users/demo/acme-app"); + await expect( + page.getByTestId("workspace-group-report-reviewer"), + ).toHaveCount(0); + }); + + test("a scaffolded sibling is hidden with its closed project", async ({ + page, + }) => { + const project = page.getByTestId("workspace-group-acme-app"); + await expect(project.getByTestId("workflow-report-reviewer")).toBeVisible(); + await project.getByTestId("project-menu-acme-app").click(); + await page.getByTestId("project-remove-acme-app").click(); + await page.getByTestId("remove-project-confirm-btn").click(); + await expect(project).toHaveCount(0); + await expect(page.getByTestId("workflow-report-reviewer")).toHaveCount(0); + await page.reload(); await expect(page.locator(".rail-workflows")).toBeVisible(); + await expect(page.getByTestId("workflow-report-reviewer")).toHaveCount(0); }); test("the project name opens the durable map without touching its active conversation", async ({ diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index d6545af75..f61b62f29 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -50,6 +50,7 @@ import { import type { JSX } from "react"; import type { AppState, + CreateSessionRequest, HarnessKind, HarnessSession, MacroDef, @@ -121,7 +122,7 @@ import { secretsDisabledReason, type ProjectRef, } from "./lib/canvas-altitude"; -import { mostSpecificStudioScope } from "./lib/agent-map"; +import { mostSpecificStudioScope, studioScopeForAgent } from "./lib/agent-map"; import { inputContractFromCanvasGraph } from "./lib/run-input"; import { agentUrl } from "./lib/urls"; import { @@ -159,8 +160,6 @@ import { type NavigationVisit, } from "./lib/navigation-history"; import { - buildIdeaWithAttachments, - materializeAttachments, type NewSessionAttachment, } from "./lib/new-session-attachments"; import { @@ -251,6 +250,9 @@ const HELD_PROMPT_TIMEOUT_MS = 10 * 60_000; const HELD_PROMPT_HINT_DELAY_MS = 4_000; interface CreateSessionAtOptions { + initialPrompt?: CreateSessionRequest["initialPrompt"]; + initialAttachments?: CreateSessionRequest["initialAttachments"]; + scaffold?: CreateSessionRequest["scaffold"]; /** Keep the create-new queue mounted while inline files are materialized. */ keepComposerOpen?: boolean; /** Keep an explicit new-agent builder active when its root joins Studio. */ @@ -1195,8 +1197,8 @@ export const App = (): JSX.Element => { ); const scope = workflow && state?.studioProjects - ? mostSpecificStudioScope( - workflow.path, + ? studioScopeForAgent( + workflow, state.workspaceScopes ?? [], state.studioProjects, ) @@ -1340,11 +1342,12 @@ export const App = (): JSX.Element => { (candidate) => candidate.projectId === projectId, ); if (!project) return null; - return mostSpecificStudioScope( - path, - workspaceScopes.filter((scope) => scope.projectId === projectId), - [project], + const workflow = state.workflows.find((candidate) => + samePath(candidate.path, path), ); + return workflow + ? studioScopeForAgent(workflow, workspaceScopes, [project], projectId) + : null; }; const selectedStudioProject = effectiveStudioSelection ? (state.studioProjects?.find( @@ -1368,15 +1371,21 @@ export const App = (): JSX.Element => { : []; const selectedStudioScope = effectiveStudioSelection && selectedStudioProject - ? mostSpecificStudioScope( - selectedStudioWorkflow?.path ?? + ? selectedStudioWorkflow + ? studioScopeForAgent( + selectedStudioWorkflow, + selectedStudioScopes, + [selectedStudioProject], + selectedStudioProject.projectId, + ) + : mostSpecificStudioScope( focusedAgentPath ?? - activeSession?.cwd ?? - selectedStudioScopes[0]?.cwd ?? - "", - selectedStudioScopes, - [selectedStudioProject], - ) + activeSession?.cwd ?? + selectedStudioScopes[0]?.cwd ?? + "", + selectedStudioScopes, + [selectedStudioProject], + ) : null; const planFirstSelection = selectedStudioScope ? effectiveStudioSelection @@ -1739,8 +1748,22 @@ export const App = (): JSX.Element => { * do not: that folder is the new project's root by construction, and * resolving it upward would drop the new agent into its parent project. */ - const sessionCwdForAgent = (agentPath: string): string => - projectRootForAgent(agentPath, knownProjectRoots()); + const sessionCwdForAgent = (agentPath: string): string => { + const workflow = state.workflows.find((candidate) => + samePath(candidate.path, agentPath), + ); + const scope = workflow + ? studioScopeForAgent( + workflow, + workspaceScopes, + state.studioProjects ?? [], + effectiveStudioSelection?.kind === "agent" + ? effectiveStudioSelection.projectId + : undefined, + ) + : null; + return scope?.cwd ?? projectRootForAgent(agentPath, knownProjectRoots()); + }; // The ONE choke point for session creation: sets the focus to the new // session's folder (so the main panel shows it) and fires telemetry once. @@ -1779,6 +1802,9 @@ export const App = (): JSX.Element => { { cwd, harness: agentHarness, + ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), + ...(options.initialAttachments?.length ? { initialAttachments: options.initialAttachments } : {}), + ...(options.scaffold ? { scaffold: options.scaffold } : {}), ...((options.initialUserInputPending ?? options.standaloneBuilder) ? { initialUserInputPending: true } : {}), @@ -2179,33 +2205,20 @@ export const App = (): JSX.Element => { } // Terminal-first: the new session's canvas slides in once it paints. setRightCollapsed(true); - const session = await createSessionAt(cwd, agentHarness, { + await createSessionAt(cwd, agentHarness, { keepComposerOpen: true, standaloneBuilder: true, + scaffold: { template: "default" }, + initialPrompt: idea.trim(), + initialAttachments: attachments.map((attachment) => + attachment.kind === "path" + ? { kind: "path", path: attachment.path } + : { kind: "inline", filename: attachment.name, dataUrl: attachment.dataUrl }, + ), }); - try { - const resolved = await materializeAttachments( - session.id, - attachments, - harness.attachFile, - ); - sendScaffoldPrompt( - session, - cwd, - buildIdeaWithAttachments(idea, resolved), - ); - setComposing(false); - } catch (error) { - // The first prompt is registered only after every upload succeeds. Kill - // the provisional session on failure so retrying reuses the same folder - // and queue instead of leaving a blank tab behind. - await harness.closeSession(session.id).catch((rollbackError: unknown) => { - console.error("[harness] attachment rollback failed:", rollbackError); - }); - pendingStandaloneBuilderSessionsRef.current.delete(cwd); - harness.removePendingWorkspace(cwd); - throw error; - } + // Only a successful create clears the draft. Scaffolding and uploads + // complete server-side before the vendor receives its first user turn. + setComposing(false); }; const handleComposerUseTemplate = (template: GalleryTemplate): void => { @@ -2350,8 +2363,8 @@ export const App = (): JSX.Element => { preferred?: { projectId: string; agentId: string }, ): { projectId: string; agentId: string } | null => { const bindings = workflow.studioBindings ?? []; - const owningScope = mostSpecificStudioScope( - workflow.path, + const owningScope = studioScopeForAgent( + workflow, workspaceScopes, state.studioProjects ?? [], ); @@ -2535,7 +2548,9 @@ export const App = (): JSX.Element => { const inferredScope = selectedBinding || !state.studioProjects ? null - : mostSpecificStudioScope(path, workspaceScopes, state.studioProjects); + : workflow + ? studioScopeForAgent(workflow, workspaceScopes, state.studioProjects) + : null; const targetBinding = selectedBinding ?? workflow?.studioBindings?.find( @@ -2545,7 +2560,9 @@ export const App = (): JSX.Element => { const targetScope = studioScopeForAgentProject(path, targetProjectId); const live = state.sessions.filter((s) => s.status !== "exited"); const ownsPath = (s: HarnessSession): boolean => - (samePath(s.boundWorkflowPath ?? "", path) || isWithinDir(s.cwd, path)) && + (Boolean(targetProjectId) || + samePath(s.boundWorkflowPath ?? "", path) || + isWithinDir(s.cwd, path)) && sessionReachesFocus( s, path, diff --git a/packages/harness/web/src/components/CloneAgentConfirm.tsx b/packages/harness/web/src/components/CloneAgentConfirm.tsx index e07f537ec..2812426d6 100644 --- a/packages/harness/web/src/components/CloneAgentConfirm.tsx +++ b/packages/harness/web/src/components/CloneAgentConfirm.tsx @@ -1,8 +1,7 @@ import { useRef } from "react"; import type { JSX } from "react"; -import { useDismissable } from "../lib/use-dismissable"; -import { Icon } from "./Icon"; +import { Dialog } from "./Dialog"; /** * Confirm before cloning a deep-linked agent that isn't on this machine. Opening @@ -10,8 +9,9 @@ import { Icon } from "./Icon"; * clone it — a real git clone + npm install the coding agent runs — so it takes a * deliberate click rather than happening silently from a link. * - * Dismisses like the other modals: Escape and a backdrop click both cancel; only - * the explicit button clones. + * Dismisses like every other dialog, because it dismisses through the same + * shell: Escape and a backdrop click both cancel; only the explicit button + * clones. */ export function CloneAgentConfirm({ agentLabel, @@ -22,41 +22,35 @@ export function CloneAgentConfirm({ onCancel: () => void; onConfirm: () => void; }): JSX.Element { - const confirmRef = useRef(null); - useDismissable(true, { onDismiss: onCancel, containerRef: confirmRef }); + // Initial focus lands on the safe action; cloning takes a deliberate Tab or + // click. + const cancelRef = useRef(null); return ( -
-
e.stopPropagation()} - > -
- Clone this agent? - -
-
-

- {agentLabel} isn’t on this machine yet. Cloning checks it out locally — a git clone and - npm install the coding agent runs — then opens it here. -

-
-
- {/* Initial focus lands on the safe action; cloning takes a deliberate Tab or click. */} - -
-
-
+ + } + > +

+ {agentLabel} isn’t on this machine yet. Cloning checks it out locally — a git clone and + npm install the coding agent runs — then opens it here. +

+ ); } diff --git a/packages/harness/web/src/components/CreateAgentDialog.tsx b/packages/harness/web/src/components/CreateAgentDialog.tsx index 727b8eeb6..24819d7bd 100644 --- a/packages/harness/web/src/components/CreateAgentDialog.tsx +++ b/packages/harness/web/src/components/CreateAgentDialog.tsx @@ -24,7 +24,7 @@ * and the server rejects reads as a broken app. */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import type { JSX } from "react"; import { refuseAgentName } from "@shared/agent-name"; @@ -32,9 +32,7 @@ import { refuseAgentName } from "@shared/agent-name"; import { errorMessage } from "../lib/api"; import type { StarterTemplate } from "../lib/templates"; import { STARTER_TEMPLATES } from "../lib/templates"; -import { trackingAttrs } from "../lib/analytics/tracking-attrs"; -import { useDismissable } from "../lib/use-dismissable"; -import { Icon } from "./Icon"; +import { Dialog } from "./Dialog"; export function CreateAgentDialog({ projectLabel, @@ -74,15 +72,11 @@ export function CreateAgentDialog({ const [instruction, setInstruction] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const panelRef = useRef(null); + // The dialog opens on the field it is asking about. Handed to `Dialog` rather + // than focused from an effect here: initial focus is the shell's job, and one + // dialog reaching into the DOM on its own is how the eleven of them stopped + // agreeing in the first place. const nameRef = useRef(null); - // Never dismissable mid-flight: the agent is being written to disk, and - // pulling the dialog would leave the user with no report of how it went. - useDismissable(!busy, { onDismiss: onCancel, containerRef: panelRef }); - - useEffect(() => { - nameRef.current?.focus(); - }, []); // Only after something has been typed: an empty field on open is not a // mistake the user has made yet, and greeting them with "Give the agent a @@ -110,166 +104,28 @@ export function CreateAgentDialog({ }; return ( -
-
{ - // Return submits from the single-line field; the textarea keeps - // Return for newlines and takes ⌘/Ctrl+Return instead. - if (event.key !== "Enter") return; - const tag = (event.target as HTMLElement).tagName; - // A focused control already has its own answer to Return, and - // stealing it would make Return on Cancel submit the form — the - // opposite of what the user pressed. - if (tag === "BUTTON" || tag === "A") return; - if (tag === "TEXTAREA" && !(event.metaKey || event.ctrlKey)) return; - event.preventDefault(); - void submit(); - }} - > -
- Create an agent - -
- -
- {/* The destination, stated. `title` carries the full path for a root - long enough to ellipsize. */} -

- - In {projectLabel} - - {projectRoot} -

- -
- - { - setName(event.target.value); - setError(null); - }} - /> - {/* Both refusals land HERE, under the field that produces them — - the typed-name rule and the server's own sentence ("probes - already has an agent called hello-world"), which was showing at - the foot of the dialog, three fields away from the input the - user has to change. */} - {nameRefusal ? ( -

- {nameRefusal} -

- ) : error ? ( -

- {error} -

- ) : ( -

- It becomes a folder in {projectLabel}. -

- )} -
- -
- Template -
- {templates.map((starter) => ( - - ))} -
- {onBrowseTemplates && ( - /* The gallery is a CLONE, not a scaffold: it forks a published - template into a repo you own and needs an account. It is a - different operation with a different failure mode, so it keeps - its own journey rather than hiding behind this radio list. */ -

- {" "} - to start from a published agent instead. -

- )} -
- -
- -