From b931e79898c22b2d56ea00a9e92ccaf9c67048ed Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 4 Sep 2026 12:10:13 +0000 Subject: [PATCH 1/3] feat(harness): unify project agents and map navigation Refs: SAP-3148 --- .changeset/unified-project-agents.md | 6 + packages/harness/README.md | 140 +- .../agent-map-capability-registry.test.ts | 35 +- .../src/core/agent-map-capability-registry.ts | 44 +- .../core/agent-map-proposal-service.test.ts | 26 +- .../src/core/agent-map-proposal-service.ts | 22 +- packages/harness/src/core/paths.ts | 3 + .../harness/src/core/planner-greeting.test.ts | 2051 ++++++++++------ packages/harness/src/core/planner-greeting.ts | 2089 +++++++++++------ .../harness/src/core/planning-session.test.ts | 1234 +++++----- packages/harness/src/core/planning-session.ts | 618 +++-- .../src/core/project-bootstrap-outbox.test.ts | 160 ++ .../src/core/project-bootstrap-outbox.ts | 179 ++ .../harness/src/core/session-manager.test.ts | 2036 +++++++++++++--- packages/harness/src/core/session-manager.ts | 924 ++++++-- .../harness/src/core/session-record.test.ts | 362 ++- packages/harness/src/core/session-record.ts | 97 +- .../src/core/studio-project-catalog.ts | 42 +- .../harness/src/profiles/agent-map-planner.ts | 28 - .../harness/src/profiles/project-agent.ts | 14 + .../harness/src/server/agent-map-mcp-tools.ts | 62 +- .../src/server/agent-map-mcp-wiring.test.ts | 867 ++++++- .../harness/src/server/agent-map-mcp.test.ts | 83 +- packages/harness/src/server/agent-map.test.ts | 271 ++- packages/harness/src/server/agent-map.ts | 174 +- packages/harness/src/server/auth-routes.ts | 9 +- packages/harness/src/server/index.ts | 1078 +++++++-- packages/harness/src/server/ingest.test.ts | 51 +- .../server/project-bootstrap-outbox.test.ts | 394 ++++ .../src/server/rehydrate-session.test.ts | 126 +- packages/harness/src/server/rest.test.ts | 190 +- packages/harness/src/server/rest.ts | 170 +- .../src/server/served-system-prompt.test.ts | 50 +- packages/harness/src/shared/agent-map.ts | 116 +- packages/harness/src/shared/paths.ts | 123 + .../harness/src/shared/project-roots.test.ts | 161 ++ packages/harness/src/shared/project-roots.ts | 425 ++++ packages/harness/src/shared/types.ts | 36 +- .../web/e2e/agent-map-planning.spec.ts | 726 +++--- .../web/e2e/new-session-composer.spec.ts | 127 +- packages/harness/web/e2e/open-project.spec.ts | 81 +- .../harness/web/e2e/polsia-workspace.spec.ts | 11 +- .../harness/web/e2e/project-altitude.spec.ts | 105 +- packages/harness/web/e2e/project-axis.spec.ts | 123 +- .../harness/web/e2e/selection-canvas.spec.ts | 89 +- packages/harness/web/e2e/smoke.spec.ts | 7 +- packages/harness/web/src/App.tsx | 1042 ++++---- .../web/src/components/AgentMapInspector.tsx | 10 +- .../web/src/components/ProjectTreeRows.tsx | 94 +- .../harness/web/src/components/SessionBar.tsx | 146 +- .../web/src/components/WorkflowsRail.tsx | 171 +- .../harness/web/src/lib/agent-map.test.ts | 19 + packages/harness/web/src/lib/agent-map.ts | 40 +- packages/harness/web/src/lib/api.test.ts | 13 +- packages/harness/web/src/lib/api.ts | 270 ++- .../web/src/lib/canvas-altitude.test.ts | 56 +- .../harness/web/src/lib/canvas-altitude.ts | 31 +- packages/harness/web/src/lib/paths.ts | 112 +- packages/harness/web/src/lib/project-tree.ts | 361 +-- .../harness/web/src/lib/session-name.test.ts | 51 +- .../harness/web/src/lib/session-scope.test.ts | 562 ++++- packages/harness/web/src/lib/session-scope.ts | 128 +- .../web/src/lib/use-agent-map-entry.ts | 189 +- .../harness/web/src/lib/use-harness-state.ts | 316 +-- 64 files changed, 13430 insertions(+), 5946 deletions(-) create mode 100644 .changeset/unified-project-agents.md create mode 100644 packages/harness/src/core/project-bootstrap-outbox.test.ts create mode 100644 packages/harness/src/core/project-bootstrap-outbox.ts delete mode 100644 packages/harness/src/profiles/agent-map-planner.ts create mode 100644 packages/harness/src/profiles/project-agent.ts create mode 100644 packages/harness/src/server/project-bootstrap-outbox.test.ts create mode 100644 packages/harness/src/shared/paths.ts create mode 100644 packages/harness/src/shared/project-roots.test.ts create mode 100644 packages/harness/src/shared/project-roots.ts diff --git a/.changeset/unified-project-agents.md b/.changeset/unified-project-agents.md new file mode 100644 index 000000000..cbc601f62 --- /dev/null +++ b/.changeset/unified-project-agents.md @@ -0,0 +1,6 @@ +--- +"@sapiom/harness": minor +"@sapiom/harness-desktop": patch +--- + +Unify Agent Studio project sessions around one ordinary coding-agent identity, make the project name open the shared Agent Map, and seed new projects through a durable, retry-safe bootstrap in the first `Plan Agents` session. diff --git a/packages/harness/README.md b/packages/harness/README.md index f843a0837..c8bc684ec 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -41,14 +41,14 @@ session lifecycle) to improve Sapiom. Opt out any time; `--no-telemetry` disables collection entirely. Events are also written locally to `~/.sapiom/harness/events.ndjson` for your own inspection. -Project planner sessions add content-free `planner_session.*` and -`planner_greeting.*` lifecycle events. Those events contain bounded project, -session, attempt, resolution, queue-depth, and error-code fields only. They never -contain planner prompts, assistant text, local paths, or provider error text; -the same telemetry opt-in controls whether they leave the machine. Planner hook -projections reduce session-start source to a fixed enum, model identity to a -presence boolean, and usage to allowlisted, clamped token counters; arbitrary -provider strings and usage fields remain local. +Project bootstrap adds content-free `project_bootstrap.*` lifecycle events, and +navigation distinguishes `agent_map.entered` from `session.switched`. These +events contain bounded project/session/attempt identifiers, retry ordinals, +queue depths, outcomes, and error codes only. They never contain prompts, +assistant text, source text, local paths, connector payloads, secrets, or raw +provider errors. Hook projections reduce session-start source to a fixed enum, +model identity to a presence boolean, and usage to allowlisted, clamped token +counters; arbitrary provider strings and usage fields remain local. ## Outbound requests @@ -57,8 +57,8 @@ Agent Studio makes one Sapiom request of its own, separate from telemetry from what its other components do on their own (the app's product analytics, and `npx @sapiom/mcp@latest` fetching and running the local MCP server each session): -Planner-session bootstrap makes no additional network request. Its focused -context, greeting coordination, FIFO, and lifecycle persistence stay inside the +Project bootstrap makes no additional network request. Its attempt +coordination, durable input ordering, and lifecycle persistence stay inside the local server. Existing outbound surfaces remain the system-prompt fetch below, the coding agent's ordinary provider traffic, and opt-in telemetry. @@ -84,80 +84,50 @@ Architecture: a single Node process (Express + ws + node-pty) serves the built SPA, a small REST API, terminal WebSocket streams, and the local telemetry ingest endpoint. The interface contract lives in `src/shared/types.ts`. -### Agent Map planner sessions - -In Studio, the pinned **Plan Agents** tab opens the project's Agent Map. -**Plan Agents** names the planning entry point; **Agent Map** remains the name -of the proposal view and its underlying protocol. - -The authenticated local API owns planner identity; a model or generic session -request cannot assign itself the `map-planner` role. The public planner surface -is project-scoped: - -- `POST /api/projects/:projectId/planner-sessions` with - `{ "mode": "resume-or-create" }` deterministically reuses the latest owned - live/resumable planner or creates one. Use `{ "mode": "fresh" }` to always - create a new planner. -- `POST /api/projects/:projectId/planner-sessions/:sessionId/messages` durably - accepts planner input and releases it FIFO after greeting resolution. -- `POST /api/projects/:projectId/planner-sessions/:sessionId/greeting/retry` - retries an eligible failed automatic greeting. - -Planner metadata is part of the session registry. Its input FIFO and greeting -attempt state live at -`/agent-map/planner-sessions//input-queue.json`; corrupt -queue files are quarantined beside that file so one session cannot block boot. -An adjacent content-free `accepted-inputs.json` ledger commits PTY-accepted FIFO -entries before they are removed from the queue, so a failed queue rewrite can -finish after restart without replaying the message. A write-ahead dispatch -intent without that durable acknowledgement is never guessed or automatically -replayed: it is resolved at-most-once with a bounded -`planner_session.input_delivery_uncertain` event, then later FIFO entries may -continue. A PTY write and a filesystem write cannot provide true exactly-once -delivery without an idempotent external acknowledgement. -When vendor resume falls back to a replacement planner, the whole coordinator -directory is atomically handed to that exact successor before it can receive -input. A later replacement follows the queue-owning predecessor while its -focused rehydration brief may still come from an older recorded ancestor, so a -pre-ready exit cannot orphan or duplicate accepted FIFO work. - -The focused system context contains only bounded project/session identity, -current workspace pointer IDs, and binding references. The current workspace -store does not yet own revision, proposal, or build-plan detail records, so -their bounded digest, summary, status, and warning slots are honestly -`null`/empty until those records land. Local root paths and source inventories -are never included. - -The browser/host token gates every `/api` planner route and is never injected -into a coding-agent PTY. Each PTY instead receives a random `/ingest` capability -bound to its exact session ID; presenting it with another event `sessionId` is -rejected, it grants no `/api` authority, and it is rotated or revoked with the -process lifecycle. A vendor resume pointer is pinned to one harness session; -only a short-lived, one-shot `/clear` or `/resume` transition observed on the -trusted terminal/input path may rotate it, and a pointer already owned by -another harness session is always rejected. Current and rotated pointers are -reserved in a server-private, SHA-256-keyed, mode-`0600` sidecar next to the -session registry; raw historical aliases never enter a browser DTO. Planner -reuse and input additionally require the session cwd to remain one of the -project's current active root bindings and its owner to match the live signed-in -identity (or stable machine-local principal while signed out). - -**Migration note (breaking):** `POST /api/sessions` now rejects unknown fields, -including client-authored planner metadata. Generic -`POST /api/sessions/:id/input`, `POST /api/sessions/:id/resume`, and -`POST /api/sessions/adopt` reject planner sessions. Adopt also returns a -bounded `AGENT_SESSION_IDENTITY_RESERVED` 409 for any ordinary current-owner -conflict or durable historical alias (including a pre-`/clear` or -pre-`/resume` identity), before probing or spawning an agent. -Clients must open, message, and retry planners through the project-scoped -routes above. Generic coding-agent sessions also use the durable vendor-ID pin; -their only rotation exception is the same trusted `/clear`/`/resume` gesture. -On upgrade, if legacy `sessions.json` rows contain the same vendor resume -pointer, the first persisted row keeps it and later duplicate rows are repaired -to `agentSessionId: null`. This does not delete the provider's transcript or -conversation history, but the losing local row can no longer resume or adopt -that fenced identity. Start a fresh session in the losing row's directory to -continue there. +### Project sessions and Agent Map bootstrap + +Every session whose working directory resolves to a Studio project is an +ordinary writable coding session with the same server-derived +`{ projectId, userId, sessionId }` principal, project-agent prompt appendix, and +Agent Map tools. Assignment or bootstrap metadata is context only and cannot +change the prompt profile, tools, filesystem policy, or implementation +authority. + +Clicking a project name opens its durable Agent Map without creating, resuming, +focusing, or prompting a session. Every tab represents one real session ID and +opens that session's ordinary conversation and Canvas/Steps experience. A new +project's first ordinary session is initially titled **Plan Agents**; the title +does not confer a role and can be renamed like any other session. + +When a new project gains its first active root binding, Studio durably schedules +one evidence-first map bootstrap for that first session. The model reads the +current map and uses the same structured tools available to every project +session. It only proposes an initial map while the durable map remains +meaningfully empty. Attempt IDs, retry ordinals, readiness and model-turn +timeouts, terminal outcomes, and input-delivery acknowledgements survive +restart. Real user input has priority: an initial prompt prevents bootstrap +from starting, and later input preempts a pending or still-staged attempt. If +the bootstrap Enter may already have crossed the PTY boundary, the user input +is durably accepted and held until a correlated completion or process restart +proves that turn cannot overlap; prompts are never concatenated or blindly +interleaved. Opening the map never schedules bootstrap. + +Bootstrap state lives under +`/agent-map/project-bootstrap/`. Valid pre-upgrade planner metadata +and queue files are read and normalized without changing the session ID, +provider binding, working directory, title, transcript, or Canvas. Malformed or +ambiguous legacy identity is retained and rejected safely rather than deleting +or duplicating the session. The older +`/api/projects/:projectId/planner-sessions...` endpoints remain bounded rolling +compatibility aliases into the ordinary session/bootstrap services; new clients +use the generic session routes. The aliases are scheduled for removal in +SAP-3152. + +The browser/host token gates `/api` routes and is never injected into a coding +agent PTY. Each PTY instead receives session-bound ingest and Agent Map +capabilities. Project scope is re-derived from trusted server state before every +launch or resume; capabilities rotate on resume, revoke on exit or principal +change, expire when inactive, and fail closed outside their project. ### Agent Map MCP @@ -173,7 +143,7 @@ renews its inactivity lease, while session exit, resume rotation, signed-in principal changes, and server shutdown revoke it. Consumers should not copy, persist, log, or reuse the capability outside the launched session. -Every trusted Agent Map role receives the same three project-wide tools: +Every project session receives the same three project-wide tools: - `agent_map_read` reads the current confirmed workspace and shared proposal. - `agent_map_validate` validates one complete operation batch without mutating diff --git a/packages/harness/src/core/agent-map-capability-registry.test.ts b/packages/harness/src/core/agent-map-capability-registry.test.ts index c83f3957b..1118d18d9 100644 --- a/packages/harness/src/core/agent-map-capability-registry.test.ts +++ b/packages/harness/src/core/agent-map-capability-registry.test.ts @@ -1,23 +1,26 @@ import { describe, expect, it, vi } from "vitest"; -import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { + PlanningSessionIdentity, + ProjectAgentSession, +} from "../shared/agent-map.js"; import { AgentMapCapabilityError, AgentMapCapabilityRegistry, } from "./agent-map-capability-registry.js"; -const identity = (sessionId = "session-1"): PlanningSessionIdentity => ({ +const identity = (sessionId = "session-1"): ProjectAgentSession => ({ projectId: "project-a", sessionId, userId: "user-a", - role: "agent-builder", - assignment: { kind: "unplanned" }, }); describe("AgentMapCapabilityRegistry", () => { it("stores only a digest and rotates one generation per session", () => { const tokens = ["a".repeat(43), "b".repeat(43)]; - const registry = new AgentMapCapabilityRegistry({ randomToken: () => tokens.shift()! }); + const registry = new AgentMapCapabilityRegistry({ + randomToken: () => tokens.shift()!, + }); const first = registry.issue(identity()); expect(registry.resolve(first.token).identity).toEqual(identity()); const second = registry.rotate(identity()); @@ -27,6 +30,23 @@ describe("AgentMapCapabilityRegistry", () => { ); }); + it("strips legacy origin metadata before it enters live capability authority", () => { + const legacy: PlanningSessionIdentity = { + ...identity(), + role: "agent-builder", + assignment: { kind: "planned", agentId: "agent-1" }, + }; + const registry = new AgentMapCapabilityRegistry({ + randomToken: () => "legacy-session-token", + }); + + const issued = registry.issue(legacy); + + expect(issued.identity).toEqual(identity()); + expect(issued.identity).not.toHaveProperty("role"); + expect(issued.identity).not.toHaveProperty("assignment"); + }); + it("fails closed for expired, revoked and unknown tokens without emitting material", () => { let now = 10; const onEvent = vi.fn(); @@ -38,11 +58,14 @@ describe("AgentMapCapabilityRegistry", () => { }); const issued = registry.issue(identity()); now = 15; - expect(() => registry.resolve(issued.token)).toThrowError(AgentMapCapabilityError); + expect(() => registry.resolve(issued.token)).toThrowError( + AgentMapCapabilityError, + ); expect(() => registry.resolve("other")).toThrowError( expect.objectContaining({ code: "invalid_capability" }), ); expect(JSON.stringify(onEvent.mock.calls)).not.toContain("secret-token"); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain("role"); }); it("slides expiry on authenticated use but remains bounded by lifecycle revocation", () => { diff --git a/packages/harness/src/core/agent-map-capability-registry.ts b/packages/harness/src/core/agent-map-capability-registry.ts index a22668b86..6e4703680 100644 --- a/packages/harness/src/core/agent-map-capability-registry.ts +++ b/packages/harness/src/core/agent-map-capability-registry.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes } from "node:crypto"; -import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { ProjectAgentSession } from "../shared/agent-map.js"; export type AgentMapCapabilityRejection = | "invalid_capability" @@ -15,7 +15,7 @@ export class AgentMapCapabilityError extends Error { } export interface ResolvedAgentMapCapability { - identity: PlanningSessionIdentity; + identity: ProjectAgentSession; generation: number; expiresAt: number; } @@ -30,7 +30,6 @@ export interface AgentMapCapabilityEvent { | "agent_map.capability.rotated" | "agent_map.capability.revoked" | "agent_map.capability.rejected"; - role?: PlanningSessionIdentity["role"]; reason?: AgentMapCapabilityRejection; } @@ -45,6 +44,15 @@ interface Entry extends ResolvedAgentMapCapability { digest: string; } +/** Drop any legacy role/assignment properties before they enter authority. */ +const neutralPrincipal = ( + identity: ProjectAgentSession, +): ProjectAgentSession => ({ + projectId: identity.projectId, + userId: identity.userId, + sessionId: identity.sessionId, +}); + const DEFAULT_TTL_MS = 12 * 60 * 60 * 1_000; const MAX_REVOKED_DIGESTS = 4_096; @@ -58,38 +66,41 @@ export class AgentMapCapabilityRegistry { private readonly now: () => number; private readonly randomToken: () => string; - constructor(private readonly options: AgentMapCapabilityRegistryOptions = {}) { + constructor( + private readonly options: AgentMapCapabilityRegistryOptions = {}, + ) { this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; this.now = options.now ?? Date.now; this.randomToken = options.randomToken ?? (() => randomBytes(32).toString("base64url")); } - issue(identity: PlanningSessionIdentity): IssuedAgentMapCapability { - this.revokeSession(identity.sessionId); + issue(identity: ProjectAgentSession): IssuedAgentMapCapability { + const principal = neutralPrincipal(identity); + this.revokeSession(principal.sessionId); const token = this.randomToken(); const digest = this.digest(token); if (!token || this.active.has(digest) || this.revoked.has(digest)) { throw new AgentMapCapabilityError("invalid_capability"); } - const generation = (this.generations.get(identity.sessionId) ?? 0) + 1; - this.generations.set(identity.sessionId, generation); + const generation = (this.generations.get(principal.sessionId) ?? 0) + 1; + this.generations.set(principal.sessionId, generation); const entry: Entry = { digest, - identity: structuredClone(identity), + identity: principal, generation, expiresAt: this.now() + this.ttlMs, }; this.active.set(digest, entry); - this.currentBySession.set(identity.sessionId, digest); - this.emit({ name: "agent_map.capability.issued", role: identity.role }); + this.currentBySession.set(principal.sessionId, digest); + this.emit({ name: "agent_map.capability.issued" }); return { token, ...this.publicEntry(entry) }; } - rotate(identity: PlanningSessionIdentity): IssuedAgentMapCapability { + rotate(identity: ProjectAgentSession): IssuedAgentMapCapability { this.revokeSession(identity.sessionId); const issued = this.issue(identity); - this.emit({ name: "agent_map.capability.rotated", role: identity.role }); + this.emit({ name: "agent_map.capability.rotated" }); return issued; } @@ -121,18 +132,19 @@ export class AgentMapCapabilityRegistry { revokeSession(sessionId: string): void { const digest = this.currentBySession.get(sessionId); if (!digest) return; - const entry = this.active.get(digest); this.active.delete(digest); this.currentBySession.delete(sessionId); this.revoked.add(digest); this.pruneRevoked(); - this.emit({ name: "agent_map.capability.revoked", role: entry?.identity.role }); + this.emit({ name: "agent_map.capability.revoked" }); } isGenerationLive(sessionId: string, generation: number): boolean { const digest = this.currentBySession.get(sessionId); const entry = digest ? this.active.get(digest) : undefined; - return !!entry && entry.generation === generation && entry.expiresAt > this.now(); + return ( + !!entry && entry.generation === generation && entry.expiresAt > this.now() + ); } private publicEntry(entry: Entry): ResolvedAgentMapCapability { diff --git a/packages/harness/src/core/agent-map-proposal-service.test.ts b/packages/harness/src/core/agent-map-proposal-service.test.ts index fbfb93986..4de1e89aa 100644 --- a/packages/harness/src/core/agent-map-proposal-service.test.ts +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -9,6 +9,7 @@ import type { PlanNodeId, PlanRelationshipId, PlanningSessionIdentity, + ProjectAgentSession, ProposalBatchRequest, ProposalOperationId, } from "../shared/agent-map.js"; @@ -32,11 +33,10 @@ class Ids implements AgentMapPermanentIdAllocator { allocateOperationId = () => this.next("operation") as ProposalOperationId; } -const identity = (sessionId: string): PlanningSessionIdentity => ({ +const identity = (sessionId: string): ProjectAgentSession => ({ projectId, userId: "user-1", sessionId, - role: "map-planner", }); const addNode = ( @@ -119,8 +119,8 @@ describe("AgentMapProposalService", () => { expect(snapshot.proposal?.history[0]?.actor).toEqual({ userId: "user-1", sessionId: "session-1", - role: "map-planner", - assignment: null, + role: "agent-builder", + assignment: { kind: "unplanned" }, }); expect(accepted).toHaveBeenCalledOnce(); }); @@ -148,7 +148,6 @@ describe("AgentMapProposalService", () => { "name", "operationCount", "projectId", - "role", "sessionId", ]); }); @@ -323,12 +322,13 @@ describe("AgentMapProposalService", () => { }); }); - it("uses the same write path for planner, assigned, and unplanned builders", async () => { + it("ignores former origin metadata and uses one neutral write authority", async () => { const { service } = await fixture(); - const first = await service.propose( - identity("planner"), - addNode("planner", 0, null), - ); + const planner: PlanningSessionIdentity = { + ...identity("planner"), + role: "map-planner", + }; + const first = await service.propose(planner, addNode("planner", 0, null)); const assigned: PlanningSessionIdentity = { projectId, userId: "user-1", @@ -353,14 +353,14 @@ describe("AgentMapProposalService", () => { { userId: "user-1", sessionId: "planner", - role: "map-planner", - assignment: null, + role: "agent-builder", + assignment: { kind: "unplanned" }, }, { userId: "user-1", sessionId: "assigned", role: "agent-builder", - assignment: { kind: "planned", agentId: "planned-agent" }, + assignment: { kind: "unplanned" }, }, { userId: "user-1", diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts index 10308a737..bfd210c16 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -10,7 +10,7 @@ import { type MapProposalId, type PlanNodeId, type PlanRelationshipId, - type PlanningSessionIdentity, + type ProjectAgentSession, type ProposalActor, type ProposalBatchRequest, type ProposalBatchResult, @@ -103,7 +103,6 @@ export interface AgentMapProposalServiceOptions { | "agent_map.proposal.storage_failed"; projectId: StudioProjectId; sessionId: string; - role: PlanningSessionIdentity["role"]; operationCount: number; latencyMs: number; }) => void | Promise; @@ -111,16 +110,16 @@ export interface AgentMapProposalServiceOptions { receiptRetentionLimit?: number; } -const actorFor = (identity: PlanningSessionIdentity): ProposalActor => { +const actorFor = (identity: ProjectAgentSession): ProposalActor => { try { + // ProposalActor is an E2 persistence compatibility boundary until + // SAP-3149 migrates the aggregate. These fixed legacy discriminator values + // are never consulted for authority; the server-derived principal above is. return parseProposalActor({ userId: identity.userId, sessionId: identity.sessionId, - role: identity.role, - assignment: - identity.role === "agent-builder" - ? structuredClone(identity.assignment) - : null, + role: "agent-builder", + assignment: { kind: "unplanned" }, }); } catch { throw new AgentMapProposalValidationError( @@ -378,7 +377,7 @@ export class AgentMapProposalService { }; } - async validate(identity: PlanningSessionIdentity, input: unknown) { + async validate(identity: ProjectAgentSession, input: unknown) { actorFor(identity); const parsed = parseProposalBatchRequest(input); if (!parsed.ok) throw new AgentMapProposalValidationError(parsed.issues, 0); @@ -434,7 +433,7 @@ export class AgentMapProposalService { } async propose( - identity: PlanningSessionIdentity, + identity: ProjectAgentSession, input: unknown, ): Promise { const startedAt = Date.now(); @@ -678,7 +677,7 @@ export class AgentMapProposalService { } private emitOutcome( - identity: PlanningSessionIdentity, + identity: ProjectAgentSession, name: Parameters< NonNullable >[0]["name"], @@ -691,7 +690,6 @@ export class AgentMapProposalService { name, projectId: identity.projectId, sessionId: identity.sessionId, - role: identity.role, operationCount, latencyMs: Math.max(0, Date.now() - startedAt), }), diff --git a/packages/harness/src/core/paths.ts b/packages/harness/src/core/paths.ts index 587bb026a..e7e28483f 100644 --- a/packages/harness/src/core/paths.ts +++ b/packages/harness/src/core/paths.ts @@ -28,6 +28,8 @@ export interface HarnessStatePaths { studioProjects: string; pendingSecrets: string; agentMap: string; + projectBootstrap: string; + /** @deprecated Read-only migration source; remove in SAP-3152. */ plannerSessions: string; generated: string; records: string; @@ -60,6 +62,7 @@ export function resolveStatePaths(stateRoot?: string): HarnessStatePaths { studioProjects: join(root, relativeToHome(HARNESS_PATHS.studioProjects)), pendingSecrets: join(root, relativeToHome(HARNESS_PATHS.pendingSecrets)), agentMap: join(root, relativeToHome(HARNESS_PATHS.agentMap)), + projectBootstrap: join(root, "agent-map", "project-bootstrap"), plannerSessions: join(root, "agent-map", "planner-sessions"), generated: join(root, relativeToHome(HARNESS_PATHS.generated)), records: join(root, relativeToHome(HARNESS_PATHS.records)), diff --git a/packages/harness/src/core/planner-greeting.test.ts b/packages/harness/src/core/planner-greeting.test.ts index dd1b46c9e..d8b914e66 100644 --- a/packages/harness/src/core/planner-greeting.test.ts +++ b/packages/harness/src/core/planner-greeting.test.ts @@ -4,959 +4,1652 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + ProjectAgentSession, + ProjectBootstrapLifecycleEvent, + ProjectBootstrapMetadata, + ProjectBootstrapState, +} from "../shared/agent-map.js"; import type { AnalyticsEvent, HarnessSession } from "../shared/types.js"; import type { SessionManager } from "./session-manager.js"; import { - PlannerGreetingCoordinator, - PlannerGreetingRetryUnavailableError, - plannerGreetingPrompt, + SessionBackgroundInputPreemptedError, + SessionInputGuardRejectedError, +} from "./session-manager.js"; +import { + ProjectBootstrapCoordinator as ProjectBootstrapCoordinatorImpl, + ProjectBootstrapCoordinatorClosedError, + ProjectBootstrapDispatchForbiddenError, + ProjectBootstrapRetryUnavailableError, + projectBootstrapPrompt, + type ProjectBootstrapCoordinatorOptions, } from "./planner-greeting.js"; -function event( +const activeCoordinators = new Set(); + +class ProjectBootstrapCoordinator extends ProjectBootstrapCoordinatorImpl { + constructor(options: ProjectBootstrapCoordinatorOptions) { + super(options); + activeCoordinators.add(this); + } + + override async close(): Promise { + try { + await super.close(); + } finally { + activeCoordinators.delete(this); + } + } +} + +const PROJECT_ID = "project_00000000-0000-7000-8000-000000000001"; +const USER_ID = "user-1"; +const NOW = "2026-09-01T00:00:00.000Z"; + +interface SubmittedInput { + sessionId: string; + text: string; + submit: boolean | undefined; + background: boolean | undefined; +} + +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"; + }>; + uncertainInputIds?: string[]; + uncertainInputs?: Array<{ + id: string; + sessionId: string; + text: string; + acceptedAt: string; + }>; +} + +function analyticsEvent( sessionId: string, type: AnalyticsEvent["type"], payload: Record, + eventId = `event-${type}`, ): AnalyticsEvent { return { - eventId: `event-${type}`, + eventId, seq: 1, - ts: "2026-09-01T00:00:00.000Z", - userId: "user-1", + ts: NOW, + userId: USER_ID, tenantId: null, machineId: "machine-1", harnessSessionId: sessionId, - agentSessionId: "agent-1", + agentSessionId: "provider-conversation-1", harness: "codex", type, payload, }; } -function plannerSession(id = "session-1"): HarnessSession { +function projectSession( + id = "session-1", + bootstrap: ProjectBootstrapState = { status: "pending" }, +): HarnessSession { + const identity: ProjectAgentSession = { + projectId: PROJECT_ID, + sessionId: id, + userId: USER_ID, + }; return { id, - agentSessionId: "agent-1", + agentSessionId: "provider-conversation-1", harness: "codex", cwd: "/private/project", - title: "project", + title: "Plan Agents", status: "running", - createdAt: "2026-09-01T00:00:00.000Z", - lastActiveAt: "2026-09-01T00:00:00.000Z", + createdAt: NOW, + lastActiveAt: NOW, exitCode: null, boundWorkflowPath: null, ready: true, - planning: { - identity: { - projectId: "project-1", - sessionId: id, - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "pending" }, + agentMapIdentity: identity, + projectBootstrap: { + projectId: identity.projectId, + userId: identity.userId, + targetSessionId: identity.sessionId, + bootstrap: structuredClone(bootstrap), queuedInputIds: [], }, }; } -describe("PlannerGreetingCoordinator", () => { +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`); +} + +async function flushCoordinator( + coordinator: ProjectBootstrapCoordinator, + key: string, +): Promise { + const writes = ( + coordinator as unknown as { writes: Map> } + ).writes; + const pending = writes.get(key); + if (pending) await pending.catch(() => {}); + await Promise.resolve(); +} + +describe("ProjectBootstrapCoordinator", () => { let root: string; + let legacyRoot: string; let session: HarnessSession; - let submitted: string[]; + let sessions: Map; + let submitted: SubmittedInput[]; let manager: SessionManager; beforeEach(async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), "planner-greeting-")); - session = plannerSession(); + root = await fs.mkdtemp(path.join(os.tmpdir(), "project-bootstrap-")); + legacyRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "planner-greeting-legacy-"), + ); + session = projectSession(); + sessions = new Map([[session.id, session]]); submitted = []; manager = { - get: (id: string) => (id === session.id ? session : undefined), - setPlanningMetadata: async (_id: string, metadata: NonNullable) => { - session.planning = structuredClone(metadata); + 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); }, - submitInput: async (_id: string, text: string) => { - submitted.push(text); + submitInput: async ( + id: string, + text: string, + submit?: boolean, + canWrite?: () => boolean | Promise, + background?: boolean, + ) => { + if (canWrite && !(await canWrite())) return false; + submitted.push({ sessionId: id, text, submit, background }); return true; }, + preemptBackgroundInput: () => false, } as unknown as SessionManager; }); afterEach(async () => { + await Promise.all( + [...activeCoordinators].map((coordinator) => coordinator.close()), + ); vi.useRealTimers(); await fs.rm(root, { recursive: true, force: true }); + await fs.rm(legacyRoot, { recursive: true, force: true }); }); - it("persists one ready-gated greeting, then releases accepted input FIFO", async () => { - const coordinator = new PlannerGreetingCoordinator({ + it("durably schedules one project lifecycle and atomically claims its first ordinary session", async () => { + const first = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - deliveryTimeoutMs: 60_000, }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.onSessionStatus(session); - expect(submitted).toHaveLength(1); - const greeting = submitted[0]!; - await coordinator.enqueue(session.id, "first user message"); - await coordinator.enqueue(session.id, "second user message"); - expect(submitted).toEqual([greeting]); - - const localPrompt = coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: greeting }), + await expect(first.scheduleProject(PROJECT_ID, USER_ID)).resolves.toBe( + true, ); - expect(localPrompt.payload).toMatchObject({ - prompt: greeting, - plannerOrigin: "infrastructure", - }); - expect(coordinator.redactForTelemetry(localPrompt).payload).not.toHaveProperty( - "prompt", + await expect(first.scheduleProject(PROJECT_ID, USER_ID)).resolves.toBe( + false, ); - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: "What should we build?" }), - ); - expect(submitted).toEqual([ - greeting, - "first user message", - "second user message", - ]); - expect(session.planning).toMatchObject({ - greeting: { status: "delivered", messageId: "event-turn.completed" }, + 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: [], }); - const durable = JSON.parse( + 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, session.id, "input-queue.json"), + path.join(root, "projects", `${PROJECT_ID}.json`), "utf8", ), - ) as { inputs: unknown[] }; - expect(durable.inputs).toEqual([]); + ) 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("rejects a planner session identity that could escape the queue root", async () => { - session = plannerSession("../outside-planner-root"); - const coordinator = new PlannerGreetingCoordinator({ + 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, - deliveryTimeoutMs: 60_000, }); + 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.register(session, { emptyProject: true, mode: "created" }), - ).rejects.toThrow("invalid planner session storage identity"); - await expect(fs.readdir(root)).resolves.toEqual([]); + 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("recovers a generating restart without duplicating onboarding", async () => { - const first = new PlannerGreetingCoordinator({ + it("rejects a foreign project-intent claimant and can recover a missing claimed target", async () => { + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - deliveryTimeoutMs: 60_000, }); - await first.register(session, { emptyProject: true, mode: "created" }); - await first.onSessionStatus(session); - await first.enqueue(session.id, "continue with my request"); - const greeting = submitted[0]!; + await coordinator.scheduleProject(PROJECT_ID, USER_ID); + await expect( + coordinator.claimProject({ + ...session.agentMapIdentity!, + userId: "foreign-user", + }), + ).rejects.toBeInstanceOf(ProjectBootstrapDispatchForbiddenError); - const restarted = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await restarted.register(session, { emptyProject: true, mode: "boot" }); + 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); - expect(submitted).toEqual([greeting, "continue with my request"]); - expect(session.planning).toMatchObject({ - greeting: { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [], - }); + const recovered = await coordinator.claimProject( + replacement.agentMapIdentity!, + ); + expect(recovered?.targetSessionId).toBe(replacement.id); + expect(recovered?.bootstrap).toEqual({ status: "pending" }); }); - it("lets queued user work win an in-flight greeting failure", async () => { - const coordinator = new PlannerGreetingCoordinator({ + it("preserves a failed pre-spawn tombstone while letting the next ordinary session claim bootstrap", async () => { + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - deliveryTimeoutMs: 60_000, }); - await coordinator.register(session, { emptyProject: false, mode: "created" }); - await coordinator.onSessionStatus(session); - const greeting = submitted[0]!; - await coordinator.enqueue(session.id, "review the existing plan"); - coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: greeting }), - ); + await coordinator.scheduleProject(PROJECT_ID, USER_ID); + const first = await coordinator.claimProject(session.agentMapIdentity!); + session.projectBootstrap = first!; + session.status = "exited"; + session.agentSessionId = null; - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: null }), + await expect( + coordinator.needsProjectSession(PROJECT_ID, USER_ID), + ).resolves.toBe(true); + + const replacement = projectSession("session-replacement"); + sessions.set(replacement.id, replacement); + const recovered = await coordinator.claimProject( + replacement.agentMapIdentity!, ); - expect(submitted).toEqual([greeting, "review the existing plan"]); - expect(session.planning?.greeting).toEqual({ - status: "skipped", - reason: "user-proceeded", + expect(sessions.get(session.id)).toBe(session); + expect(recovered).toMatchObject({ + projectId: PROJECT_ID, + targetSessionId: replacement.id, + bootstrap: { status: "pending" }, }); }); - it("bounds retry to failed, retryable sessions without accepted user work", async () => { - session.planning!.greeting = { - status: "failed", - retryable: true, - errorCode: "model_turn_failed", - }; - const coordinator = new PlannerGreetingCoordinator({ + it("refuses replacement when an abandoned target still owns durable input", async () => { + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - deliveryTimeoutMs: 60_000, }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.retry(session.id); - expect(submitted).toHaveLength(1); - expect(session.planning?.greeting.status).toBe("generating"); - await expect(coordinator.retry(session.id)).rejects.toBeInstanceOf( - PlannerGreetingRetryUnavailableError, - ); - }); - - it("keeps a same-process generating attempt live on idempotent registration", async () => { - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, + 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: [], }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.onSessionStatus(session); - const generating = structuredClone(session.planning!.greeting); + session.status = "exited"; + session.agentSessionId = null; - await coordinator.register(session, { emptyProject: true, mode: "live" }); + 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(session.planning?.greeting).toEqual(generating); - expect(submitted).toHaveLength(1); + 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("keeps resume-suppressed skipped state authoritative over a stale queue file", async () => { - const first = new PlannerGreetingCoordinator({ + it("records real input already pending at claim time as higher priority", async () => { + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - deliveryTimeoutMs: 60_000, }); - await first.register(session, { emptyProject: true, mode: "created" }); - await first.onSessionStatus(session); - await first.enqueue(session.id, "continue from durable input"); - session.planning!.greeting = { status: "skipped", reason: "user-proceeded" }; + await coordinator.scheduleProject(PROJECT_ID, USER_ID); - const resumed = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await resumed.register(session, { emptyProject: true, mode: "resumed" }); + const claimed = await coordinator.claimProject( + session.agentMapIdentity!, + true, + ); - expect(session.planning).toMatchObject({ - greeting: { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [], + expect(claimed?.bootstrap).toEqual({ + status: "skipped", + reason: "user-proceeded", }); - expect(submitted.at(-1)).toBe("continue from durable input"); }); - it("uses its accepted ledger to finish a failed dequeue after restart without duplicate or loss", async () => { - session.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - let failAcceptedDequeue = true; - const first = new PlannerGreetingCoordinator({ + it("keeps pending readiness and model-turn deadlines distinct", async () => { + vi.useFakeTimers(); + session.ready = false; + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - deliveryTimeoutMs: 60_000, - writeState: async (file, value) => { - const state = value as { - dispatchingInputId: string | null; - inputs: unknown[]; - }; - if ( - failAcceptedDequeue && - submitted.length === 1 && - state.dispatchingInputId === null && - state.inputs.length === 0 - ) { - failAcceptedDequeue = false; - throw new Error("injected queue cleanup failure"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, + readinessTimeoutMs: 100, + deliveryTimeoutMs: 1_000, }); - await first.register(session, { emptyProject: true, mode: "created" }); - await expect( - first.enqueue(session.id, "deliver exactly once"), - ).resolves.toBeDefined(); - expect(submitted).toEqual(["deliver exactly once"]); - const durableBeforeRestart = JSON.parse( - await fs.readFile( - path.join(root, session.id, "input-queue.json"), - "utf8", - ), - ) as { dispatchingInputId: string | null; inputs: Array<{ id: string }> }; - expect(durableBeforeRestart.dispatchingInputId).toBe( - durableBeforeRestart.inputs[0]!.id, - ); - const acceptedBeforeRestart = JSON.parse( - await fs.readFile( - path.join(root, session.id, "accepted-inputs.json"), - "utf8", - ), - ) as { inputIds: string[] }; - expect(acceptedBeforeRestart.inputIds).toEqual([ - durableBeforeRestart.inputs[0]!.id, - ]); + await coordinator.register(session, { + emptyProject: true, + mode: "created", + }); + await vi.advanceTimersByTimeAsync(60); + await coordinator.register(session, { emptyProject: true, mode: "live" }); + await vi.advanceTimersByTimeAsync(41); + await flushCoordinator(coordinator, session.id); + + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "failed", + retryable: true, + errorCode: "session_not_ready", + }); + expect(submitted).toEqual([]); - const restarted = new PlannerGreetingCoordinator({ + session = projectSession("session-turn-timeout"); + sessions.set(session.id, session); + const turnCoordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - deliveryTimeoutMs: 60_000, + readinessTimeoutMs: 50, + deliveryTimeoutMs: 200, + generateId: () => "attempt-turn-timeout", }); - await restarted.register(session, { emptyProject: true, mode: "boot" }); - - expect(submitted).toEqual(["deliver exactly once"]); - expect(session.planning?.queuedInputIds).toEqual([]); - const durableAfterRestart = JSON.parse( - await fs.readFile( - path.join(root, session.id, "input-queue.json"), - "utf8", - ), - ) as { dispatchingInputId: string | null; inputs: unknown[] }; - expect(durableAfterRestart).toMatchObject({ - dispatchingInputId: null, - inputs: [], + await turnCoordinator.register(session, { + emptyProject: true, + mode: "created", + }); + await vi.advanceTimersByTimeAsync(51); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "generating", + attemptId: "attempt-turn-timeout", + }); + await vi.advanceTimersByTimeAsync(150); + await flushCoordinator(turnCoordinator, session.id); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "failed", + retryable: false, + errorCode: "delivery_timeout", }); }); - it("does not publish a phantom dispatch intent when its durable write fails", async () => { - session.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - let failIntentWrite = true; - const lifecycle: unknown[] = []; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - writeState: async (file, value) => { - const state = value as { - dispatchingInputId: string | null; - inputs: unknown[]; - }; - if ( - failIntentWrite && - state.dispatchingInputId !== null && - state.inputs.length === 1 - ) { - failIntentWrite = false; - throw new Error("transient dispatch-intent write failure"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); + it("rechecks durable map content and skips a no-longer-empty project without a model turn", async () => { + const lifecycle: ProjectBootstrapLifecycleEvent[] = []; + const coordinator = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + isMeaningfullyEmpty: async (projectId) => { + expect(projectId).toBe(PROJECT_ID); + return false; }, - onEvent: (value) => { - lifecycle.push(value); + onEvent: (event) => { + lifecycle.push(event); }, }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.enqueue(session.id, "first was never submitted"); - expect(submitted).toEqual([]); - await coordinator.enqueue(session.id, "second triggers a safe retry"); + await coordinator.register(session, { + emptyProject: true, + mode: "created", + }); - expect(submitted).toEqual([ - "first was never submitted", - "second triggers a safe retry", - ]); - expect(session.planning?.queuedInputIds).toEqual([]); - expect(lifecycle).not.toContainEqual( + expect(submitted).toEqual([]); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "skipped", + reason: "map-not-empty", + }); + expect(lifecycle).toContainEqual( expect.objectContaining({ - name: "planner_session.input_delivery_uncertain", + name: "project_bootstrap.skipped", + reason: "map-not-empty", + queueDepth: 0, }), ); + expect((await readState(root, session.id)).emptyProject).toBe(false); }); - it("does not resurrect an enqueue whose primary queue write was rejected", async () => { - session.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - let rejectFirstEnqueue = true; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - writeState: async (file, value) => { - const state = value as { - dispatchingInputId: string | null; - inputs: Array<{ text: string }>; - }; - if ( - rejectFirstEnqueue && - state.dispatchingInputId === null && - state.inputs.some((input) => input.text === "rejected input") - ) { - rejectFirstEnqueue = false; - throw new Error("primary enqueue write rejected"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - - await expect( - coordinator.enqueue(session.id, "rejected input"), - ).rejects.toThrow("planner state persistence failed"); - await coordinator.enqueue(session.id, "accepted input"); - - expect(submitted).toEqual(["accepted input"]); - expect(session.planning?.queuedInputIds).toEqual([]); - expect( - await fs.readFile( - path.join(root, session.id, "input-queue.json"), - "utf8", - ), - ).not.toContain("rejected input"); - }); - - it("keeps dispatching when only the secondary sessions projection fails", async () => { - session.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - const setPlanningMetadata = vi.fn(async () => { - throw new Error("sessions.json projection unavailable"); - }); - manager.setPlanningMetadata = setPlanningMetadata; - const coordinator = new PlannerGreetingCoordinator({ + it("correlates one unique evidence-first bootstrap and ignores duplicate readiness and completion signals", async () => { + const lifecycle: ProjectBootstrapLifecycleEvent[] = []; + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, + generateId: () => "attempt-unique-1", + deliveryTimeoutMs: 60_000, + onEvent: (event) => { + lifecycle.push(event); + }, }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await expect( - coordinator.enqueue(session.id, "deliver from the authoritative queue"), - ).resolves.toMatchObject({ queuedInputIds: [] }); - - expect(submitted).toEqual(["deliver from the authoritative queue"]); - expect(setPlanningMetadata).toHaveBeenCalled(); - const durable = JSON.parse( - await fs.readFile( - path.join(root, session.id, "input-queue.json"), - "utf8", - ), - ) as { dispatchingInputId: string | null; inputs: unknown[] }; - expect(durable).toMatchObject({ - dispatchingInputId: null, - inputs: [], + await coordinator.register(session, { + emptyProject: true, + mode: "created", }); - }); + await coordinator.register(session, { emptyProject: true, mode: "live" }); + await coordinator.onSessionStatus(session); + await coordinator.onSessionStatus(session); - it("resolves an orphaned dispatch as uncertain and lets the later FIFO continue", async () => { - session.ready = false; - session.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - let firstSubmitAttempted = false; - let failRollback = true; - manager.submitInput = async (_id: string, text: string) => { - if (!firstSubmitAttempted) { - firstSubmitAttempted = true; - throw new Error("process ended before PTY acceptance was knowable"); - } - submitted.push(text); - return true; - }; - const first = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - writeState: async (file, value) => { - const state = value as { - dispatchingInputId: string | null; - inputs: unknown[]; - }; - if ( - failRollback && - firstSubmitAttempted && - state.dispatchingInputId === null && - state.inputs.length === 2 - ) { - failRollback = false; - throw new Error("simulated crash before intent rollback"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, + expect(submitted).toHaveLength(1); + expect(submitted[0]).toMatchObject({ + sessionId: session.id, + submit: true, + background: true, }); - await first.register(session, { emptyProject: true, mode: "created" }); - await first.enqueue(session.id, "delivery became uncertain"); - await first.enqueue(session.id, "must still make progress"); - session.ready = true; - await first.onSessionStatus(session); + expect(submitted[0]!.text).toContain("attempt-unique-1"); - const lifecycle: unknown[] = []; - const restarted = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - onEvent: (value) => { - lifecycle.push(value); - }, + const local = coordinator.decorateLocalEvent( + analyticsEvent(session.id, "prompt.submitted", { + prompt: submitted[0]!.text, + }), + ); + expect(local.payload).toMatchObject({ + projectBootstrapOrigin: "infrastructure", + projectBootstrapAttemptId: "attempt-unique-1", }); - await restarted.register(session, { emptyProject: true, mode: "boot" }); - expect(submitted).toEqual(["must still make progress"]); - expect(session.planning?.queuedInputIds).toEqual([]); - expect(lifecycle).toContainEqual( - expect.objectContaining({ - name: "planner_session.input_delivery_uncertain", - errorCode: "delivery_uncertain", - queueDepth: 1, - }), + await coordinator.onEventPersisted( + analyticsEvent( + session.id, + "turn.completed", + { assistantText: "Evidence-supported map seed complete." }, + "turn-bootstrap-1", + ), + ); + await coordinator.onEventPersisted( + analyticsEvent( + session.id, + "turn.completed", + { assistantText: "Duplicate completion." }, + "turn-bootstrap-duplicate", + ), ); - expect(JSON.stringify(lifecycle)).not.toContain("delivery became uncertain"); - }); - it("compacts a stale accepted-ledger entry before acknowledging later input", async () => { - session.planning!.greeting = { + expect(session.projectBootstrap?.bootstrap).toEqual({ status: "delivered", - messageId: "greeting-message", - }; - let failFirstCleanup = true; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - writeAcceptedLedger: async (file, value) => { - const ledger = value as { inputIds: string[] }; - if (failFirstCleanup && ledger.inputIds.length === 0) { - failFirstCleanup = false; - throw new Error("injected accepted-ledger cleanup failure"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, + messageId: "turn-bootstrap-1", }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - - await coordinator.enqueue(session.id, "first accepted input"); - await coordinator.enqueue(session.id, "second accepted input"); - - expect(submitted).toEqual(["first accepted input", "second accepted input"]); - expect(session.planning?.queuedInputIds).toEqual([]); expect( - JSON.parse( - await fs.readFile( - path.join(root, session.id, "accepted-inputs.json"), - "utf8", - ), - ), - ).toEqual({ schemaVersion: 1, inputIds: [] }); + lifecycle.filter((event) => event.name === "project_bootstrap.attempted"), + ).toHaveLength(1); + expect( + lifecycle.filter((event) => event.name === "project_bootstrap.delivered"), + ).toHaveLength(1); }); - it("bounds pending readiness, then drains its durable FIFO when readiness arrives", async () => { + it("retains attempt tombstones so a late timed-out turn cannot complete its retry", async () => { vi.useFakeTimers(); - session.ready = false; - const coordinator = new PlannerGreetingCoordinator({ + const ids = ["attempt-1", "attempt-2"]; + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, + generateId: () => ids.shift() ?? "unexpected-id", deliveryTimeoutMs: 100, }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.enqueue(session.id, "queued while booting"); - await vi.advanceTimersByTimeAsync(101); - await (coordinator as unknown as { writes: Map> }) - .writes.get(session.id); - expect(session.planning?.greeting).toEqual({ - status: "skipped", - reason: "user-proceeded", - }); - expect(submitted).toEqual([]); - - session.ready = true; - await coordinator.onSessionStatus(session); - expect(submitted).toEqual(["queued while booting"]); - expect(session.planning?.queuedInputIds).toEqual([]); - }); - it("contains timer persistence rejection with only a bounded local classification", async () => { - vi.useFakeTimers(); - session.ready = false; - let rejectWrites = false; - const localErrors = vi.spyOn(console, "error").mockImplementation(() => {}); - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 100, - writeState: async (file, value) => { - if (rejectWrites) { - throw new Error("/private/customer provider-secret"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, + await coordinator.register(session, { + emptyProject: true, + mode: "created", }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - - rejectWrites = true; + const firstPrompt = submitted[0]!.text; + coordinator.decorateLocalEvent( + analyticsEvent(session.id, "prompt.submitted", { prompt: firstPrompt }), + ); await vi.advanceTimersByTimeAsync(101); - await vi.waitFor(() => { - expect(localErrors).toHaveBeenCalledWith( - "[harness] planner greeting timeout transition failed: persistence_failed", - ); + await flushCoordinator(coordinator, session.id); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "failed", + retryable: false, + errorCode: "delivery_timeout", }); - expect(session.planning?.greeting).toEqual({ - status: "failed", + await expect(coordinator.retry(session.id)).rejects.toBeInstanceOf( + ProjectBootstrapRetryUnavailableError, + ); + await coordinator.onEventPersisted( + analyticsEvent(session.id, "turn.completed", { + assistantText: "Late output from attempt one.", + }), + ); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "failed", retryable: true, - errorCode: "persistence_failed", + errorCode: "delivery_timeout", }); - expect(JSON.stringify(localErrors.mock.calls)).not.toContain( - "private/customer", + + await coordinator.retry(session.id); + const retryPrompt = submitted[1]!.text; + expect(retryPrompt).not.toBe(firstPrompt); + expect(retryPrompt).toContain("automatic retry 1 of 2"); + coordinator.decorateLocalEvent( + analyticsEvent(session.id, "prompt.submitted", { prompt: retryPrompt }), ); - expect(JSON.stringify(localErrors.mock.calls)).not.toContain( - "provider-secret", + + await coordinator.onEventPersisted( + analyticsEvent( + session.id, + "turn.completed", + { assistantText: "Retry map seed complete." }, + "turn-retry", + ), + ); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "delivered", + messageId: "turn-retry", + }); + const durable = await readState(root, session.id); + expect(durable.retryCount).toBe(1); + expect(durable.attempts).toEqual([ + { attemptId: "attempt-1", retryOrdinal: 0, status: "retired" }, + { attemptId: "attempt-2", retryOrdinal: 1, status: "completed" }, + ]); + await expect(coordinator.retry(session.id)).rejects.toBeInstanceOf( + ProjectBootstrapRetryUnavailableError, ); - localErrors.mockRestore(); }); - it("keeps a queued planner message out of the PTY when its live dispatch authority is rebound before readiness", async () => { - session.ready = false; - let authorized = true; - const coordinator = new PlannerGreetingCoordinator({ + it("holds user input behind an uncertain timed-out turn and drains it once after process restart", async () => { + vi.useFakeTimers(); + const ids = ["attempt-before-process-restart", "input-after-restart"]; + const first = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - canDispatch: async () => authorized, - deliveryTimeoutMs: 60_000, + generateId: () => ids.shift() ?? "unexpected-id", + deliveryTimeoutMs: 100, }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.enqueue(session.id, "private queued plan request"); - expect(submitted).toEqual([]); + await first.register(session, { emptyProject: true, mode: "created" }); + const bootstrapPrompt = submitted[0]!.text; + first.decorateLocalEvent( + analyticsEvent(session.id, "prompt.submitted", { + prompt: bootstrapPrompt, + }), + ); - authorized = false; - session.ready = true; - await coordinator.onSessionStatus(session); + await vi.advanceTimersByTimeAsync(101); + await flushCoordinator(first, session.id); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "failed", + retryable: false, + errorCode: "delivery_timeout", + }); - expect(submitted).toEqual([]); - expect(session.planning?.queuedInputIds).toHaveLength(1); - }); + await expect( + first.enqueue(session.id, "implement the durable request directly"), + ).resolves.toMatchObject({ queuedInputIds: ["input-after-restart"] }); + expect(submitted.map((entry) => entry.text)).toEqual([bootstrapPrompt]); - it("does not extend the readiness deadline on live re-registration", async () => { - vi.useFakeTimers(); + // Server restart ends the old PTY before this exact persisted session is + // resumed. That process boundary, unlike the timeout itself, proves the + // uncertain turn cannot overlap the durable FIFO on the replacement PTY. + session.status = "exited"; session.ready = false; - const coordinator = new PlannerGreetingCoordinator({ + await first.onSessionStatus(session); + await first.close(); + session.status = "running"; + session.ready = true; + const restarted = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - deliveryTimeoutMs: 100, }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await vi.advanceTimersByTimeAsync(60); - await coordinator.register(session, { emptyProject: true, mode: "live" }); - await vi.advanceTimersByTimeAsync(41); - await (coordinator as unknown as { writes: Map> }) - .writes.get(session.id); + await restarted.register(session, { emptyProject: true, mode: "boot" }); + await restarted.register(session, { emptyProject: true, mode: "live" }); + await restarted.onSessionStatus(session); - expect(session.planning?.greeting).toEqual({ - status: "failed", - retryable: true, - errorCode: "session_not_ready", + expect(submitted.map((entry) => entry.text)).toEqual([ + bootstrapPrompt, + "implement the durable request directly", + ]); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "skipped", + reason: "user-proceeded", }); + expect(session.projectBootstrap?.queuedInputIds).toEqual([]); + expect((await readState(root, session.id)).inputs).toEqual([]); }); - it("classifies an exit from pending and clears stale correlation state", async () => { - session.ready = false; - const coordinator = new PlannerGreetingCoordinator({ + it("recovers an ambiguous generating restart as a non-retryable tombstone without blindly submitting again", async () => { + const first = new ProjectBootstrapCoordinator({ root, sessionManager: manager, + generateId: () => "attempt-before-restart", deliveryTimeoutMs: 60_000, }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - session.status = "exited"; - await coordinator.onSessionStatus(session); + await first.register(session, { emptyProject: true, mode: "created" }); + expect(submitted).toHaveLength(1); + expect(session.projectBootstrap?.bootstrap.status).toBe("generating"); - expect(session.planning?.greeting).toEqual({ + const lifecycle: ProjectBootstrapLifecycleEvent[] = []; + const restarted = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + onEvent: (event) => { + lifecycle.push(event); + }, + }); + await restarted.register(session, { emptyProject: true, mode: "boot" }); + + expect(submitted).toHaveLength(1); + expect(session.projectBootstrap?.bootstrap).toEqual({ status: "failed", retryable: false, - errorCode: "session_exited", + errorCode: "delivery_timeout", }); - const decorated = coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { - prompt: plannerGreetingPrompt(true), + await expect(restarted.retry(session.id)).rejects.toBeInstanceOf( + ProjectBootstrapRetryUnavailableError, + ); + expect((await readState(root, session.id)).attempts).toEqual([ + { + attemptId: "attempt-before-restart", + retryOrdinal: 0, + status: "retired", + }, + ]); + expect(lifecycle).toContainEqual( + expect.objectContaining({ + name: "project_bootstrap.recovered", + sessionId: session.id, }), ); - expect(decorated.payload).not.toHaveProperty("plannerOrigin"); }); - it("uses unique prompts and FIFO tombstones so a late old turn cannot deliver a retry", async () => { - vi.useFakeTimers(); - const ids = ["attempt-1", "attempt-2"]; - const coordinator = new PlannerGreetingCoordinator({ + 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, + })}\n`, + ); + + const coordinator = new ProjectBootstrapCoordinator({ root, + legacyRoot, sessionManager: manager, - generateId: () => ids.shift() ?? "state-write", - deliveryTimeoutMs: 100, }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - const oldPrompt = submitted[0]!; - await vi.advanceTimersByTimeAsync(101); - await (coordinator as unknown as { writes: Map> }) - .writes.get(session.id); - expect(session.planning?.greeting).toEqual({ - status: "failed", - retryable: true, - errorCode: "delivery_timeout", + await coordinator.register(session, { emptyProject: true, mode: "boot" }); + + const migrated = await readState(root, session.id); + expect(migrated).toMatchObject({ + schemaVersion: 2, + 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(await fs.readdir(legacyDirectory)).toEqual(["input-queue.json"]); - await coordinator.retry(session.id); - const retryPrompt = submitted[1]!; - expect(retryPrompt).not.toBe(oldPrompt); - const lateOldPrompt = coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: oldPrompt }), + session.ready = true; + await coordinator.onSessionStatus(session); + expect(submitted.map((entry) => entry.text)).toEqual([ + "first durable user request", + ]); + coordinator.decorateLocalEvent( + analyticsEvent(session.id, "prompt.submitted", { + prompt: "first durable user request", + }), ); - expect(lateOldPrompt.payload.plannerAttemptId).toBe("attempt-1"); await coordinator.onEventPersisted( - event(session.id, "turn.completed", { - assistantText: "Late answer from the first attempt", + analyticsEvent(session.id, "turn.completed", { + assistantText: "First request complete.", }), ); - expect(session.planning?.greeting).toEqual({ - status: "generating", - attemptId: "attempt-2", + expect(submitted.map((entry) => entry.text)).toEqual([ + "first durable user request", + "second durable user request", + ]); + expect(session.projectBootstrap?.queuedInputIds).toEqual([]); + expect((await readState(root, session.id)).inputs).toEqual([]); + }); + + it("lets API input preempt a pending bootstrap and preserves its FIFO order", async () => { + session.ready = false; + const ids = ["input-1", "input-2"]; + const lifecycle: ProjectBootstrapLifecycleEvent[] = []; + const coordinator = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + generateId: () => ids.shift() ?? "unexpected-id", + onEvent: (event) => { + lifecycle.push(event); + }, + }); + await coordinator.register(session, { + emptyProject: true, + mode: "created", }); - const retryEvent = coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: retryPrompt }), + await coordinator.enqueue(session.id, "first user request"); + await coordinator.enqueue(session.id, "second user request"); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "skipped", + reason: "user-proceeded", + }); + expect(submitted).toEqual([]); + + session.ready = true; + await coordinator.onSessionStatus(session); + expect(submitted.map((entry) => entry.text)).toEqual([ + "first user request", + ]); + await coordinator.onSessionStatus(session); + expect(submitted.map((entry) => entry.text)).toEqual([ + "first user request", + ]); + coordinator.decorateLocalEvent( + analyticsEvent(session.id, "prompt.submitted", { + prompt: "first user request", + }), ); - expect(retryEvent.payload.plannerAttemptId).toBe("attempt-2"); await coordinator.onEventPersisted( - event(session.id, "turn.completed", { - assistantText: "What kind of agent architecture should we build?", + analyticsEvent(session.id, "turn.completed", { + assistantText: "First request complete.", + }), + ); + expect(submitted.map((entry) => entry.text)).toEqual([ + "first user request", + "second user request", + ]); + expect(submitted.every((entry) => entry.background !== true)).toBe(true); + expect(lifecycle).toContainEqual( + expect.objectContaining({ + name: "project_bootstrap.preempted", + reason: "user-proceeded", + queueDepth: 1, }), ); - - expect(session.planning?.greeting).toEqual({ - status: "delivered", - messageId: "event-turn.completed", - }); }); - it("pre-registers correlation before submit and removes it on a proven false return", async () => { - const holder: { coordinator?: PlannerGreetingCoordinator } = {}; - const synchronousPrompts: AnalyticsEvent[] = []; - manager.submitInput = async (_id, prompt) => { - synchronousPrompts.push( - holder.coordinator!.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt }), - ), - ); + it("durably prioritizes API input that arrives between background text and Enter", async () => { + let announceStaged!: () => void; + const staged = new Promise((resolve) => { + announceStaged = resolve; + }); + let releaseStaged!: () => void; + const released = new Promise((resolve) => { + releaseStaged = resolve; + }); + manager.preemptBackgroundInput = () => { + releaseStaged(); + return true; + }; + manager.submitInput = async ( + id: string, + text: string, + submit?: boolean, + canWrite?: () => boolean | Promise, + background?: boolean, + ) => { + if (background) { + announceStaged(); + await released; + if (canWrite && !(await canWrite())) { + throw new SessionInputGuardRejectedError(true); + } + } + submitted.push({ sessionId: id, text, submit, background }); return true; }; - const coordinator = new PlannerGreetingCoordinator({ + const ids = ["attempt-staged", "input-priority"]; + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - generateId: () => "attempt-synchronous", + generateId: () => ids.shift() ?? "unexpected-id", deliveryTimeoutMs: 60_000, }); - holder.coordinator = coordinator; - await coordinator.register(session, { emptyProject: true, mode: "created" }); - expect(synchronousPrompts[0]?.payload.plannerAttemptId).toBe( - "attempt-synchronous", + + const registering = coordinator.register(session, { + emptyProject: true, + mode: "created", + }); + await staged; + const enqueueing = coordinator.enqueue( + session.id, + "implement the requested change now", ); + await Promise.all([registering, enqueueing]); - session = plannerSession("session-false"); - manager.submitInput = async () => false; - const rejected = new PlannerGreetingCoordinator({ + expect(submitted).toEqual([ + { + sessionId: session.id, + text: "implement the requested change now", + submit: true, + background: undefined, + }, + ]); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "skipped", + reason: "user-proceeded", + }); + expect((await readState(root, session.id)).attempts).toEqual([ + { + attemptId: "attempt-staged", + retryOrdinal: 0, + status: "retired", + }, + ]); + }); + + it("tombstones an in-flight bootstrap when durable API input arrives", async () => { + const ids = ["attempt-1", "input-1"]; + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, + generateId: () => ids.shift() ?? "unexpected-id", deliveryTimeoutMs: 60_000, }); - await rejected.register(session, { emptyProject: true, mode: "created" }); - const decorated = rejected.decorateLocalEvent( - event(session.id, "prompt.submitted", { - prompt: plannerGreetingPrompt(true), + await coordinator.register(session, { + emptyProject: true, + mode: "created", + }); + const bootstrapPrompt = submitted[0]!.text; + coordinator.decorateLocalEvent( + analyticsEvent(session.id, "prompt.submitted", { + prompt: bootstrapPrompt, }), ); - expect(decorated.payload).not.toHaveProperty("plannerOrigin"); - expect(session.planning?.greeting).toEqual({ - status: "failed", - retryable: false, - errorCode: "session_exited", + await coordinator.enqueue(session.id, "build this directly now"); + expect(submitted.map((entry) => entry.text)).toEqual([bootstrapPrompt]); + expect(session.projectBootstrap?.queuedInputIds).toEqual(["input-1"]); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "skipped", + reason: "user-proceeded", + }); + + await coordinator.onEventPersisted( + analyticsEvent(session.id, "turn.completed", { + assistantText: "Late bootstrap output.", + }), + ); + expect(submitted.map((entry) => entry.text)).toEqual([ + bootstrapPrompt, + "build this directly now", + ]); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "skipped", + reason: "user-proceeded", }); }); - it("consumes unmatched prompt completions before the active greeting barrier", async () => { - const coordinator = new PlannerGreetingCoordinator({ + it("keeps durable API input queued through raw preemption and resumes it after the user turn", async () => { + session.ready = false; + let preemptOnce = true; + manager.submitInput = async ( + id: string, + text: string, + submit?: boolean, + canWrite?: () => boolean | Promise, + background?: boolean, + ) => { + if (preemptOnce) { + preemptOnce = false; + coordinator.onTerminalInput(id); + throw new SessionBackgroundInputPreemptedError(true); + } + if (canWrite && !(await canWrite())) return false; + submitted.push({ sessionId: id, text, submit, background }); + return true; + }; + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - generateId: () => "attempt-1", - deliveryTimeoutMs: 60_000, + generateId: () => "input-after-raw-turn", + }); + manager.setProjectBootstrapMetadata = async ( + id: string, + metadata: ProjectBootstrapMetadata, + ) => { + const target = sessions.get(id); + if (!target) throw new Error("session missing"); + target.projectBootstrap = structuredClone(metadata); + // Match production SessionManager: every durable projection emits a + // re-entrant status callback. The coordinator must keep the raw turn's + // ownership even when this callback is queued during persistence. + void coordinator.onSessionStatus(target); + }; + await coordinator.register(session, { + emptyProject: true, + mode: "created", }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - const greetingPrompt = submitted[0]!; + await coordinator.enqueue(session.id, "durable API request"); + + session.ready = true; + await coordinator.onSessionStatus(session); + expect(submitted).toEqual([]); + expect(session.projectBootstrap?.queuedInputIds).toEqual([ + "input-after-raw-turn", + ]); + expect((await readState(root, session.id)).dispatchingInputId).toBeNull(); + await flushCoordinator(coordinator, session.id); + expect(submitted).toEqual([]); coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: "unmatched user turn" }), - ); - coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: greetingPrompt }), + analyticsEvent(session.id, "prompt.submitted", { + prompt: "raw terminal request", + }), ); await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: "user answer" }), + analyticsEvent(session.id, "turn.completed", { + assistantText: "The user's raw terminal turn completed.", + }), ); - expect(session.planning?.greeting).toEqual({ - status: "generating", - attemptId: "attempt-1", + expect(submitted.map((entry) => entry.text)).toEqual([ + "durable API request", + ]); + expect(session.projectBootstrap?.queuedInputIds).toEqual([]); + }); + + it("raw terminal input synchronously preempts bootstrap before or during dispatch", async () => { + const beforeRegistration = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, }); - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: "greeting answer" }), - ); - expect(session.planning?.greeting.status).toBe("delivered"); + beforeRegistration.onTerminalInput(session.id); + await beforeRegistration.register(session, { + emptyProject: true, + mode: "created", + }); + expect(submitted).toEqual([]); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "skipped", + reason: "user-proceeded", + }); + + session = projectSession("session-raw-in-flight"); + sessions.set(session.id, session); + const lifecycle: ProjectBootstrapLifecycleEvent[] = []; + const inFlight = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + generateId: () => "attempt-raw", + deliveryTimeoutMs: 60_000, + onEvent: (event) => { + lifecycle.push(event); + }, + }); + await inFlight.register(session, { emptyProject: true, mode: "created" }); + expect(submitted).toHaveLength(1); + inFlight.onTerminalInput(session.id); + inFlight.onTerminalInput(session.id); + await flushCoordinator(inFlight, session.id); + + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "skipped", + reason: "user-proceeded", + }); + expect( + lifecycle.filter((event) => event.name === "project_bootstrap.preempted"), + ).toHaveLength(1); }); - it("consumes a late completion while failed before a retry begins", async () => { - vi.useFakeTimers(); - const ids = ["attempt-1", "attempt-2"]; - const coordinator = new PlannerGreetingCoordinator({ + it("tombstones an orphaned dispatch without replay and lets the later FIFO progress", async () => { + session.projectBootstrap!.bootstrap = { + status: "delivered", + messageId: "bootstrap-turn", + }; + await writeState(root, session.id, { + schemaVersion: 2, + metadata: { + ...structuredClone(session.projectBootstrap!), + queuedInputIds: ["input-uncertain", "input-safe-next"], + }, + inputs: [ + { + id: "input-uncertain", + sessionId: session.id, + text: "possibly accepted already", + acceptedAt: NOW, + }, + { + id: "input-safe-next", + sessionId: session.id, + text: "definitely send next", + acceptedAt: NOW, + }, + ], + dispatchingInputId: "input-uncertain", + retryCount: 0, + emptyProject: true, + attempts: [], + }); + const lifecycle: ProjectBootstrapLifecycleEvent[] = []; + const restarted = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - generateId: () => ids.shift() ?? "state-write", - deliveryTimeoutMs: 100, + onEvent: (event) => { + lifecycle.push(event); + }, }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: submitted[0]! }), - ); - await vi.advanceTimersByTimeAsync(101); - await (coordinator as unknown as { writes: Map> }) - .writes.get(session.id); - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: "late old answer" }), + await restarted.register(session, { emptyProject: true, mode: "boot" }); + await restarted.register(session, { emptyProject: true, mode: "live" }); + + expect(submitted.map((entry) => entry.text)).toEqual([ + "definitely send next", + ]); + expect(lifecycle).toContainEqual({ + name: "project_bootstrap.input_delivery_uncertain", + projectId: PROJECT_ID, + sessionId: session.id, + inputId: "input-uncertain", + errorCode: "delivery_uncertain", + queueDepth: 2, + }); + expect(JSON.stringify(lifecycle)).not.toContain( + "possibly accepted already", ); - await coordinator.retry(session.id); - coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: submitted[1]! }), + const persisted = await readState(root, session.id); + expect(persisted.inputs).toEqual([]); + expect(persisted.uncertainInputIds).toEqual(["input-uncertain"]); + expect(persisted.uncertainInputs).toEqual([ + expect.objectContaining({ + id: "input-uncertain", + text: "possibly accepted already", + }), + ]); + expect( + lifecycle.filter( + (event) => event.name === "project_bootstrap.input_delivery_uncertain", + ), + ).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", ); - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: "retry answer" }), + 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(session.planning?.greeting.status).toBe("delivered"); + expect(await fs.readdir(directory)).toEqual(["input-queue.json"]); + expect(session.projectBootstrap?.queuedInputIds).toEqual([ + "unknown-undelivered-input", + ]); }); - it("quarantines a corrupt queue without preventing local registration", async () => { - const dir = path.join(root, session.id); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, "input-queue.json"), "{secret-corrupt"); - session.planning!.queuedInputIds = ["stale-registry-input"]; - const coordinator = new PlannerGreetingCoordinator({ + 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, - deliveryTimeoutMs: 60_000, }); await expect( - coordinator.register(session, { emptyProject: true, mode: "boot" }), - ).resolves.toBeUndefined(); - const names = await fs.readdir(dir); - expect(names).toContain("input-queue.json"); - expect(names.some((name) => name.startsWith("input-queue.corrupt-"))).toBe(true); - expect(session.planning?.queuedInputIds).toEqual([]); + 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({ + root, + sessionManager: manager, + }); + await falseDispatch.register(session, { + emptyProject: true, + mode: "created", + }); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "failed", + retryable: false, + errorCode: "session_exited", + }); + + session = projectSession("session-injection-failure"); + sessions.set(session.id, session); + manager.submitInput = async () => { + throw new Error("raw provider failure"); + }; + const injectionFailure = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + }); + await injectionFailure.register(session, { + emptyProject: true, + mode: "created", + }); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "failed", + retryable: true, + errorCode: "injection_failed", + }); + + session = projectSession("session-empty-turn"); + sessions.set(session.id, session); + submitted = []; + manager.submitInput = async (id: string, text: string) => { + submitted.push({ sessionId: id, text, submit: true, background: true }); + return true; + }; + const emptyTurn = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + generateId: () => "attempt-empty-turn", + deliveryTimeoutMs: 60_000, + }); + await emptyTurn.register(session, { emptyProject: true, mode: "created" }); + emptyTurn.decorateLocalEvent( + analyticsEvent(session.id, "prompt.submitted", { + prompt: submitted[0]!.text, + }), + ); + await emptyTurn.onEventPersisted( + analyticsEvent(session.id, "turn.completed", { assistantText: " " }), + ); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "failed", + retryable: true, + errorCode: "model_turn_failed", + }); + + session = projectSession("session-exited-pending"); + session.ready = false; + sessions.set(session.id, session); + const exited = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + readinessTimeoutMs: 60_000, + }); + await exited.register(session, { emptyProject: true, mode: "created" }); + session.status = "exited"; + await exited.onSessionStatus(session); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "failed", + retryable: false, + errorCode: "session_exited", + }); + + session = projectSession("session-already-exited"); + session.ready = false; + session.status = "exited"; + sessions.set(session.id, session); + const alreadyExited = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + readinessTimeoutMs: 60_000, + }); + await alreadyExited.register(session, { + emptyProject: true, + mode: "boot", + }); + expect(session.projectBootstrap?.bootstrap).toEqual({ + status: "failed", + retryable: false, + errorCode: "session_exited", + }); + expect( + (alreadyExited as unknown as { timers: Map }).timers + .size, + ).toBe(0); }); - it("durably classifies queue persistence failure without raw error content", async () => { - const events: unknown[] = []; - const coordinator = new PlannerGreetingCoordinator({ + it("durably bounds persistence failures without exposing storage content", async () => { + const lifecycle: ProjectBootstrapLifecycleEvent[] = []; + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, writeState: async () => { - throw new Error("/private/path provider secret"); + throw new Error("/private/customer/path provider-secret"); }, - onEvent: (value) => { - events.push(value); + onEvent: (event) => { + lifecycle.push(event); }, }); await expect( coordinator.register(session, { emptyProject: true, mode: "created" }), - ).rejects.toThrow("planner state persistence failed"); - expect(session.planning?.greeting).toEqual({ + ).rejects.toThrow("project bootstrap state persistence failed"); + expect(session.projectBootstrap?.bootstrap).toEqual({ status: "failed", retryable: true, errorCode: "persistence_failed", }); - expect( - ( - coordinator as unknown as { - states: Map }>; - } - ).states.get(session.id)?.metadata.greeting, - ).toEqual(session.planning?.greeting); - await expect(coordinator.retry(session.id)).rejects.toThrow( - "planner state persistence failed", - ); - expect(session.planning?.greeting).toEqual({ - status: "failed", - retryable: true, - errorCode: "persistence_failed", + const serialized = JSON.stringify(lifecycle); + expect(serialized).toContain("project_bootstrap.failed"); + expect(serialized).not.toContain("private/customer"); + expect(serialized).not.toContain("provider-secret"); + }); + + it("revalidates dispatch authority before bootstrap and queued user input", async () => { + session.ready = false; + let authorized = true; + const coordinator = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + canDispatch: async () => authorized, + readinessTimeoutMs: 60_000, + }); + await coordinator.register(session, { + emptyProject: true, + mode: "created", }); - expect(JSON.stringify(events)).not.toContain("private/path"); - expect(JSON.stringify(events)).not.toContain("provider secret"); + await coordinator.enqueue(session.id, "durable user request"); + authorized = false; + session.ready = true; + await coordinator.onSessionStatus(session); + + expect(submitted).toEqual([]); + expect(session.projectBootstrap?.queuedInputIds).toHaveLength(1); + await expect( + coordinator.enqueue(session.id, "foreign follow-up"), + ).rejects.toBeInstanceOf(ProjectBootstrapDispatchForbiddenError); }); - it("emits bounded lifecycle codes without prompts, paths, or provider errors", async () => { - const lifecycle: unknown[] = []; + it("acknowledges a durable enqueue when authority changes after its commit", async () => { + session.ready = false; + let authorized = true; + const coordinator = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + generateId: () => "input-committed-before-rebind", + canDispatch: () => authorized, + readinessTimeoutMs: 60_000, + }); + await coordinator.register(session, { + emptyProject: true, + mode: "created", + }); + manager.setProjectBootstrapMetadata = async ( + id: string, + metadata: ProjectBootstrapMetadata, + ) => { + const target = sessions.get(id); + if (!target) throw new Error("session missing"); + target.projectBootstrap = structuredClone(metadata); + if (metadata.queuedInputIds.length > 0) authorized = false; + }; + session.ready = true; + + await expect( + coordinator.enqueue(session.id, "durably accepted before rebind"), + ).resolves.toMatchObject({ + queuedInputIds: ["input-committed-before-rebind"], + }); + expect(submitted).toEqual([]); + expect((await readState(root, session.id)).inputs).toEqual([ + expect.objectContaining({ + id: "input-committed-before-rebind", + text: "durably accepted before rebind", + }), + ]); + }); + + it("emits neutral content-free lifecycle events and redacts local hook content", async () => { + const lifecycle: ProjectBootstrapLifecycleEvent[] = []; manager.submitInput = async () => { throw new Error("provider said /private/customer secret-token"); }; - const coordinator = new PlannerGreetingCoordinator({ + const coordinator = new ProjectBootstrapCoordinator({ root, sessionManager: manager, - onEvent: (value) => { - lifecycle.push(value); + generateId: () => "attempt-telemetry", + onEvent: (event) => { + lifecycle.push(event); }, }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.onSessionStatus(session); + await coordinator.register(session, { + emptyProject: true, + mode: "created", + }); - expect(lifecycle).toEqual([ - expect.objectContaining({ - name: "planner_greeting.attempted", - projectId: "project-1", - sessionId: session.id, + expect(lifecycle.map((event) => event.name)).toEqual([ + "project_bootstrap.scheduled", + "project_bootstrap.attempted", + "project_bootstrap.failed", + ]); + const serializedLifecycle = JSON.stringify(lifecycle); + expect(serializedLifecycle).not.toMatch(/planner|builder/i); + expect(serializedLifecycle).not.toContain("Agent Studio project bootstrap"); + expect(serializedLifecycle).not.toContain("private/customer"); + expect(serializedLifecycle).not.toContain("secret-token"); + + session = projectSession("session-telemetry-redaction"); + sessions.set(session.id, session); + submitted = []; + manager.submitInput = async (id: string, text: string) => { + submitted.push({ sessionId: id, text, submit: true, background: true }); + return true; + }; + const redactor = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + generateId: () => "attempt-local-only", + }); + await redactor.register(session, { emptyProject: true, mode: "created" }); + const local = redactor.decorateLocalEvent( + analyticsEvent(session.id, "prompt.submitted", { + prompt: submitted[0]!.text, + path: "/private/source.ts", + connectorPayload: "secret connector body", }), - expect.objectContaining({ - name: "planner_greeting.failed", - errorCode: "injection_failed", + ); + expect(local.payload.prompt).toBe(submitted[0]!.text); + expect(local.payload.projectBootstrapAttemptId).toBe("attempt-local-only"); + + const remotePrompt = redactor.redactForTelemetry(local); + expect(remotePrompt.agentSessionId).toBeNull(); + expect(remotePrompt.payload).toEqual({ + projectBootstrap: true, + origin: "infrastructure", + projectBootstrapAttemptId: "attempt-local-only", + }); + expect(JSON.stringify(remotePrompt)).not.toContain("private/source"); + expect(JSON.stringify(remotePrompt)).not.toContain("connector body"); + + const remoteTurn = redactor.redactForTelemetry( + analyticsEvent(session.id, "turn.completed", { + assistantText: "raw provider output secret", + model: "covert-provider-channel", + usage: { inputTokens: 10, outputTokens: 20 }, + sourceText: "customer source", }), - ]); - const serialized = JSON.stringify(lifecycle); - expect(serialized).not.toContain("Agent Studio control turn"); - expect(serialized).not.toContain("/private/customer"); - expect(serialized).not.toContain("secret-token"); + ); + expect(remoteTurn.payload).toEqual({ + projectBootstrap: true, + hasAssistantText: true, + modelReported: true, + usage: { inputTokens: 10, outputTokens: 20 }, + }); + expect(JSON.stringify(remoteTurn)).not.toContain("provider output"); + expect(JSON.stringify(remoteTurn)).not.toContain("covert-provider"); + expect(JSON.stringify(remoteTurn)).not.toContain("customer source"); + + session = projectSession("session-ordinary-after-bootstrap"); + session.projectBootstrap!.bootstrap = { + status: "delivered", + messageId: "message-bootstrap-complete", + }; + sessions.set(session.id, session); + const ordinaryEvent = analyticsEvent(session.id, "prompt.submitted", { + prompt: "ordinary project work", + }); + expect(redactor.redactForTelemetry(ordinaryEvent)).toEqual(ordinaryEvent); }); -}); -describe("plannerGreetingPrompt", () => { - it("keeps the automatic greeting scoped to collaborative planning and one question", () => { - const empty = plannerGreetingPrompt(true); - const existing = plannerGreetingPrompt(false); - for (const prompt of [empty, existing]) { - expect(prompt).toContain("project planning agent"); - expect(prompt).toContain("agents, responsibilities, data flow, resources, and connectors"); - expect(prompt).toContain("exactly one open-ended question"); - expect(prompt).toContain("Do not propose an architecture"); - expect(prompt).toContain("invoke tools"); + it("closes timer and work admission without allowing a late lifecycle transition", async () => { + vi.useFakeTimers(); + session.ready = false; + const lifecycle: ProjectBootstrapLifecycleEvent[] = []; + const coordinator = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + readinessTimeoutMs: 10, + onEvent: (event) => { + lifecycle.push(event); + }, + }); + await coordinator.register(session, { + emptyProject: true, + mode: "created", + }); + expect( + (coordinator as unknown as { timers: Map }).timers.size, + ).toBe(1); + + await coordinator.close(); + await coordinator.close(); + expect( + (coordinator as unknown as { timers: Map }).timers.size, + ).toBe(0); + await vi.advanceTimersByTimeAsync(20); + expect( + lifecycle.filter((event) => event.name === "project_bootstrap.failed"), + ).toEqual([]); + await expect(coordinator.retry(session.id)).rejects.toBeInstanceOf( + ProjectBootstrapCoordinatorClosedError, + ); + await expect( + coordinator.enqueue(session.id, "must not be admitted"), + ).rejects.toBeInstanceOf(ProjectBootstrapCoordinatorClosedError); + await expect( + coordinator.scheduleProject(PROJECT_ID, USER_ID), + ).rejects.toBeInstanceOf(ProjectBootstrapCoordinatorClosedError); + }); + + it("tracks an in-flight status authorization check and clears all lifecycle state on close", async () => { + session.ready = false; + let blockAuthorization = false; + let authorizationStarted!: () => void; + const started = new Promise((resolve) => { + authorizationStarted = resolve; + }); + let releaseAuthorization!: () => void; + const released = new Promise((resolve) => { + releaseAuthorization = resolve; + }); + const coordinator = new ProjectBootstrapCoordinator({ + root, + sessionManager: manager, + readinessTimeoutMs: 60_000, + canDispatch: async () => { + if (!blockAuthorization) return true; + authorizationStarted(); + await released; + return true; + }, + }); + await coordinator.register(session, { + emptyProject: true, + mode: "created", + }); + session.ready = true; + blockAuthorization = true; + const status = coordinator.onSessionStatus(session); + await started; + + let closeSettled = false; + const closing = coordinator.close().then(() => { + closeSettled = true; + }); + await Promise.resolve(); + expect(closeSettled).toBe(false); + releaseAuthorization(); + await Promise.all([status, closing]); + expect(closeSettled).toBe(true); + + const internals = coordinator as unknown as Record< + string, + Map | Set + >; + for (const key of [ + "states", + "writes", + "expected", + "observedAttempts", + "activeTurns", + "correlationOverflow", + "timers", + "provisionalProjectClaims", + "provisionalSessionClaims", + "pendingApiPreemptions", + "registeredSessions", + "terminalPreemptions", + "reportedTerminalPreemptions", + ]) { + expect(internals[key]?.size, key).toBe(0); } - expect(empty).toContain( - "what kind of agent architecture the user wants to build", - ); - expect(existing).toContain("current plan exists"); - const attempted = plannerGreetingPrompt(true, "attempt-private-1"); - expect(attempted).toContain("Internal attempt ID: attempt-private-1"); - expect(attempted).toContain("Never mention this ID"); - expect(empty).not.toContain("attempt-private-1"); + }); +}); + +describe("projectBootstrapPrompt", () => { + it("is evidence-first, tool-capable, role-neutral, and direct-build safe", () => { + const prompt = projectBootstrapPrompt(); + + expect(prompt).toContain("Read the current Agent Map first"); + expect(prompt).toContain("meaningfully empty"); + expect(prompt).toContain("explicit evidence"); + expect(prompt).toContain("structured Agent Map tools"); + expect(prompt).toContain("Validate before proposing"); + expect(prompt).toContain("Never guess"); + expect(prompt).toContain("prioritize"); + expect(prompt).toContain("proceed directly with implementation"); + expect(prompt).toContain("no confirmation or mode transition is required"); + expect(prompt).not.toMatch( + /map-planner|agent-builder|planning-only|no-code/i, + ); + }); + + it("makes retries uniquely correlatable without exposing authority in the prompt", () => { + const first = projectBootstrapPrompt(0, "attempt-1"); + const retry = projectBootstrapPrompt(1, "attempt-2"); + + expect(first).not.toBe(retry); + expect(first).toContain("Internal correlation key: attempt-1"); + expect(retry).toContain("Internal correlation key: attempt-2"); + expect(retry).toContain("automatic retry 1 of 2"); + expect(retry).toContain("Never repeat or expose this key"); + expect(first).not.toMatch(/projectId|userId|sessionId|capability|bearer/i); }); }); diff --git a/packages/harness/src/core/planner-greeting.ts b/packages/harness/src/core/planner-greeting.ts index facd4e80c..fc5265b66 100644 --- a/packages/harness/src/core/planner-greeting.ts +++ b/packages/harness/src/core/planner-greeting.ts @@ -3,22 +3,24 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { - PlannerGreetingErrorCode, - PlannerLifecycleEvent, - PlannerQueuedInput, - PlannerSessionMetadata, + ProjectBootstrapErrorCode, + ProjectBootstrapLifecycleEvent, + ProjectBootstrapQueuedInput, + ProjectBootstrapMetadata, + ProjectAgentSession, } from "../shared/agent-map.js"; import type { AnalyticsEvent, HarnessSession } from "../shared/types.js"; import { + SessionBackgroundInputPreemptedError, SessionInputGuardRejectedError, SessionManager, SessionNotReadyError, } from "./session-manager.js"; -interface PersistedPlannerState { - schemaVersion: 1; - metadata: PlannerSessionMetadata; - inputs: PlannerQueuedInput[]; +interface PersistedProjectBootstrapState { + schemaVersion: 2; + 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 @@ -27,6 +29,19 @@ interface PersistedPlannerState { dispatchingInputId: string | null; retryCount: number; emptyProject: boolean; + attempts: Array<{ + attemptId: string; + retryOrdinal: number; + status: "active" | "retired" | "completed"; + }>; + /** 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[]; } interface AcceptedInputLedger { @@ -34,8 +49,18 @@ interface AcceptedInputLedger { inputIds: string[]; } +interface PersistedProjectBootstrapIntent { + schemaVersion: 1; + projectId: string; + userId: string; + targetSessionId: string | null; + status: "scheduled" | "claimed"; + createdAt: string; + updatedAt: string; +} + interface ExpectedPrompt { - kind: "greeting" | "user"; + kind: "bootstrap" | "user"; id: string; text: string; /** A failed/timed-out greeting stays as a FIFO tombstone so a late hook @@ -43,61 +68,77 @@ interface ExpectedPrompt { retired?: boolean; } -interface ObservedGreetingAttempt { - id: string; - retired: boolean; -} +type ObservedProjectTurn = + | { kind: "bootstrap"; id: string; retired: boolean } + | { kind: "user"; id: string } + | { kind: "external" }; + +type ActiveCoordinatorTurn = + | { kind: "bootstrap"; id: string } + | { kind: "user"; id: string }; interface AttemptTimer { key: "pending" | string; handle: ReturnType; } -export type PlannerRegistrationMode = +export type ProjectBootstrapRegistrationMode = | "boot" | "created" | "live" - | "resumed" - | "rehydrated"; + | "resumed"; -export interface PlannerRegistrationContext { +export interface ProjectBootstrapRegistrationContext { emptyProject: boolean; - mode: PlannerRegistrationMode; + mode: ProjectBootstrapRegistrationMode; } -export interface PlannerGreetingCoordinatorOptions { +export interface ProjectBootstrapCoordinatorOptions { root: string; + /** @deprecated Read-only migration source for pre-SAP-3148 queue files. */ + legacyRoot?: string; sessionManager: SessionManager; now?: () => string; generateId?: () => string; - /** Applies both while waiting for readiness and while awaiting a model turn. */ + /** Maximum wait for the target session to become interactive. */ + readinessTimeoutMs?: number; + /** Maximum wait for the model turn after the request reaches the PTY. */ deliveryTimeoutMs?: number; /** Test seam for classifying queue-store failures without exposing raw errors. */ writeState?: (file: string, state: unknown) => Promise; /** Test seam for accepted-ledger cleanup/commit failures. */ writeAcceptedLedger?: (file: string, state: unknown) => Promise; - /** Test seam for the atomic predecessor-to-successor queue handoff. */ - moveStateDirectory?: (source: string, target: string) => Promise; /** Live authorization gate checked immediately before every PTY dispatch. */ canDispatch?: (session: HarnessSession) => boolean | Promise; - onEvent?: (event: PlannerLifecycleEvent) => Promise | void; + /** Rechecks E2 semantic state immediately before the bootstrap attempt. */ + isMeaningfullyEmpty?: (projectId: string) => boolean | Promise; + onEvent?: (event: ProjectBootstrapLifecycleEvent) => Promise | void; +} + +export class ProjectBootstrapRetryUnavailableError extends Error { + readonly code = "project_bootstrap_retry_unavailable"; + + constructor() { + super("project bootstrap retry is not available"); + this.name = "ProjectBootstrapRetryUnavailableError"; + } } -export class PlannerGreetingRetryUnavailableError extends Error { - readonly code = "greeting_retry_unavailable"; +export class ProjectBootstrapDispatchForbiddenError extends Error { + readonly code = "project_bootstrap_dispatch_forbidden"; constructor() { - super("greeting retry is not available"); - this.name = "PlannerGreetingRetryUnavailableError"; + super("project bootstrap is no longer authorized for this session"); + this.name = "ProjectBootstrapDispatchForbiddenError"; } } -export class PlannerDispatchForbiddenError extends Error { - readonly code = "planner_dispatch_forbidden"; +export class ProjectBootstrapCoordinatorClosedError extends Error { + readonly code = "project_bootstrap_coordinator_closed"; constructor() { - super("planner session is no longer authorized for this project binding"); - this.name = "PlannerDispatchForbiddenError"; + super("project bootstrap coordinator is closed"); + this.name = "ProjectBootstrapCoordinatorClosedError"; } } @@ -107,35 +148,77 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isTerminal(metadata: PlannerSessionMetadata): boolean { +function isTerminal(metadata: ProjectBootstrapMetadata): boolean { return ( - metadata.greeting.status === "delivered" || - metadata.greeting.status === "skipped" + 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. + */ +export function projectBootstrapOwnsInput( + metadata: ProjectBootstrapMetadata | null | undefined, +): boolean { + return Boolean( + metadata && (!isTerminal(metadata) || metadata.queuedInputIds.length > 0), ); } -function isPersistedPlannerState( +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", + ].includes(value.errorCode) + ); + case "skipped": + return ( + value.reason === "user-proceeded" || value.reason === "map-not-empty" + ); + default: + return false; + } +} + +function parsePersistedProjectBootstrapState( value: unknown, session: HarnessSession, -): value is PersistedPlannerState { - if (!isRecord(value) || value.schemaVersion !== 1 || !session.planning) { - return false; - } +): PersistedProjectBootstrapState | null { + if (!isRecord(value) || !session.projectBootstrap) return null; const metadata = value.metadata; - if (!isRecord(metadata) || !isRecord(metadata.identity)) return false; - const expected = session.planning.identity; + 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 ( - metadata.identity.role !== "map-planner" || - metadata.identity.sessionId !== expected.sessionId || - metadata.identity.projectId !== expected.projectId || - metadata.identity.userId !== expected.userId || + (value.schemaVersion !== 1 && value.schemaVersion !== 2) || + projectId !== expected.projectId || + userId !== expected.userId || + targetSessionId !== expected.targetSessionId || !Array.isArray(metadata.queuedInputIds) || !metadata.queuedInputIds.every((id) => typeof id === "string") || - !isRecord(metadata.greeting) || - typeof metadata.greeting.status !== "string" || - !["pending", "generating", "delivered", "failed", "skipped"].includes( - metadata.greeting.status, - ) || + !validBootstrapState(bootstrap) || !Array.isArray(value.inputs) || (value.dispatchingInputId !== undefined && value.dispatchingInputId !== null && @@ -145,114 +228,117 @@ function isPersistedPlannerState( (value.retryCount as number) > MAX_RETRIES || typeof value.emptyProject !== "boolean" ) { - return false; + 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( - (input) => - 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", - ) || + !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] as Record).id, - ) || + metadata.queuedInputIds.some((id, index) => id !== inputs[index]?.id) || (typeof value.dispatchingInputId === "string" && - value.dispatchingInputId !== - (inputs[0] as Record | undefined)?.id) + value.dispatchingInputId !== inputs[0]?.id) ) { - return false; + return null; } - const greeting = metadata.greeting; - switch (greeting.status) { - case "pending": - return true; - case "generating": - return typeof greeting.attemptId === "string" && greeting.attemptId !== ""; - case "delivered": - return typeof greeting.messageId === "string" && greeting.messageId !== ""; - case "failed": - return ( - typeof greeting.retryable === "boolean" && - typeof greeting.errorCode === "string" && - [ - "session_not_ready", - "session_exited", - "injection_failed", - "model_turn_failed", - "delivery_timeout", - "persistence_failed", - ].includes(greeting.errorCode) - ); - case "skipped": - return greeting.reason === "user-proceeded"; - default: - return false; + const attempts = Array.isArray(value.attempts) + ? value.attempts.filter( + ( + attempt, + ): attempt is PersistedProjectBootstrapState["attempts"][number] => + isRecord(attempt) && + typeof attempt.attemptId === "string" && + Number.isSafeInteger(attempt.retryOrdinal) && + ["active", "retired", "completed"].includes(String(attempt.status)), + ) + : []; + 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); + const normalizedBootstrap = structuredClone( + bootstrap, + ) as ProjectBootstrapMetadata["bootstrap"]; + if (attempts.length === 0 && normalizedBootstrap.status === "generating") { + attempts.push({ + attemptId: normalizedBootstrap.attemptId, + retryOrdinal: Math.max(0, Number(value.retryCount) || 0), + status: "active", + }); } -} - -/** A directory rename can commit before the successor rewrites the embedded - * session identity. The trusted `rehydratedFrom` link is the recovery marker: - * accept only an otherwise-valid predecessor state with the same scoped - * project/user/role, then re-key every identity-bearing field in memory. */ -function adoptRehydratedState( - value: unknown, - session: HarnessSession, -): PersistedPlannerState | null { - if (!session.planning || !session.rehydratedFrom) return null; - const predecessorId = session.rehydratedFrom; - const predecessor: HarnessSession = { - ...session, - id: predecessorId, - planning: { - ...structuredClone(session.planning), - identity: { - ...structuredClone(session.planning.identity), - sessionId: predecessorId, - }, - }, - }; - if (!isPersistedPlannerState(value, predecessor)) return null; return { - ...structuredClone(value), + schemaVersion: 2, metadata: { - ...structuredClone(value.metadata), - identity: structuredClone(session.planning.identity), + projectId: expected.projectId, + userId: expected.userId, + targetSessionId: expected.targetSessionId, + bootstrap: normalizedBootstrap, + queuedInputIds: normalizedInputs.map((input) => input.id), }, - inputs: value.inputs.map((input) => ({ - ...structuredClone(input), - sessionId: session.id, - })), - dispatchingInputId: value.dispatchingInputId ?? null, + 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, }; } -export function plannerGreetingPrompt( - emptyProject: boolean, +export function projectBootstrapPrompt( + retryOrdinal = 0, attemptId?: string, ): string { - const question = emptyProject - ? "Ask exactly one open-ended question about what kind of agent architecture the user wants to build." - : "Briefly acknowledge that a current plan exists, then ask exactly one open-ended question about what the user wants to review, extend, or change."; - return [ - "This is a private Agent Studio control turn.", - "Respond as the project planning agent with one brief greeting.", - "Explain that you and the user will plan the agents, responsibilities, data flow, resources, and connectors together.", - question, - "Do not propose an architecture, create nodes or relationships, invoke tools, or ask a second question before the user replies.", - ...(attemptId - ? [`Internal attempt ID: ${attemptId}. Never mention this ID in your response.`] - : []), - ].join(" "); + const suffix = + retryOrdinal > 0 + ? ` This is automatic retry ${Math.min(retryOrdinal, MAX_RETRIES)} of ${MAX_RETRIES}.` + : ""; + const correlation = attemptId + ? ` Internal correlation key: ${attemptId}. Never repeat or expose this key.` + : ""; + return `Agent Studio project bootstrap: Read the current Agent Map first. Only if it is still meaningfully empty, inspect available project context for explicit evidence of agents, meaningful subagents, responsibilities, contracts, resources, connectors, artifacts, and cross-agent data flow. Validate before proposing one honest initial map with the structured Agent Map tools. Never guess, invent placeholder nodes, or overwrite concurrent work; on conflict, reread and reconcile. Summarize what the evidence supports and clearly identify uncertainty. This bootstrap is ordinary project work: if a real user request is present, prioritize it and proceed directly with implementation when it is build-ready; no confirmation or mode transition is required.${suffix}${correlation}`; } -const PLANNER_SESSION_SOURCES = new Set([ +const PROJECT_SESSION_SOURCES = new Set([ "startup", "resume", "clear", @@ -262,7 +348,7 @@ const PLANNER_SESSION_SOURCES = new Set([ const MAX_TELEMETRY_TOKEN_COUNT = 1_000_000_000_000; function telemetrySource(value: unknown): string { - return typeof value === "string" && PLANNER_SESSION_SOURCES.has(value) + return typeof value === "string" && PROJECT_SESSION_SOURCES.has(value) ? value : "unknown"; } @@ -285,23 +371,29 @@ function telemetryUsage(value: unknown): Record | null { function telemetryPayload(event: AnalyticsEvent): Record { switch (event.type) { case "session.start": - return { source: telemetrySource(event.payload.source), planner: true }; + return { + source: telemetrySource(event.payload.source), + projectBootstrap: true, + }; case "prompt.submitted": return { - planner: true, - origin: event.payload.plannerOrigin ?? "user", - ...(typeof event.payload.plannerInputId === "string" - ? { plannerInputId: event.payload.plannerInputId } + projectBootstrap: true, + origin: event.payload.projectBootstrapOrigin ?? "user", + ...(typeof event.payload.projectBootstrapInputId === "string" + ? { projectBootstrapInputId: event.payload.projectBootstrapInputId } : {}), - ...(typeof event.payload.plannerAttemptId === "string" - ? { plannerAttemptId: event.payload.plannerAttemptId } + ...(typeof event.payload.projectBootstrapAttemptId === "string" + ? { + projectBootstrapAttemptId: + event.payload.projectBootstrapAttemptId, + } : {}), }; case "tool.call": - return { planner: true, toolObserved: true }; + return { projectBootstrap: true, toolObserved: true }; case "turn.completed": return { - planner: true, + projectBootstrap: true, hasAssistantText: typeof event.payload.assistantText === "string" && event.payload.assistantText.length > 0, @@ -313,45 +405,58 @@ function telemetryPayload(event: AnalyticsEvent): Record { usage: telemetryUsage(event.payload.usage), }; default: - return { planner: true }; + return { projectBootstrap: true }; } } -export class PlannerGreetingCoordinator { +export class ProjectBootstrapCoordinator { private readonly root: string; + private readonly legacyRoot: string | null; private readonly now: () => string; private readonly generateId: () => string; + private readonly readinessTimeoutMs: number; private readonly deliveryTimeoutMs: number; - private readonly states = new Map(); + private readonly states = new Map(); private readonly writes = new Map>(); private readonly expected = new Map(); - private readonly observedAttempts = new Map< - string, - Array - >(); + private readonly observedAttempts = new Map(); + /** At most one coordinator-owned prompt may have crossed Enter without a + * correlated turn completion. This is deliberately separate from durable + * FIFO acceptance: accepted user input remains durable even while its turn + * temporarily owns the live CLI. */ + private readonly activeTurns = new Map(); private readonly correlationOverflow = new Set(); private readonly timers = new Map(); - /** Status hooks can race a freshly spawned PTY ahead of register/handoff. */ + /** 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(); + /** Status hooks can race a freshly spawned PTY ahead of registration. */ private readonly registeredSessions = new Set(); - /** A successfully handed-off predecessor can never dispatch or mutate its - * retired FIFO again, even if a late status/hook callback arrives. */ - private readonly retiredSessions = new Set(); - /** Successors loaded after a crash between the atomic directory move and - * the best-effort embedded-identity rewrite. */ - private readonly adoptedSessions = new Set(); - - constructor(private readonly options: PlannerGreetingCoordinatorOptions) { + /** Set synchronously by raw terminal input, including before registration. */ + private readonly terminalPreemptions = new Set(); + private readonly reportedTerminalPreemptions = new Set(); + private closed = false; + + constructor(private readonly options: ProjectBootstrapCoordinatorOptions) { this.root = path.resolve(options.root); + this.legacyRoot = options.legacyRoot + ? path.resolve(options.legacyRoot) + : null; this.now = options.now ?? (() => new Date().toISOString()); this.generateId = options.generateId ?? randomUUID; - this.deliveryTimeoutMs = options.deliveryTimeoutMs ?? 45_000; + 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 planner session storage identity"); + throw new Error("invalid project bootstrap storage identity"); } return directory; } @@ -360,15 +465,36 @@ export class PlannerGreetingCoordinator { return path.join(this.sessionDirectory(sessionId), "input-queue.json"); } + private legacyFile(sessionId: string, name: string): string | null { + if (!this.legacyRoot) return null; + const directory = path.resolve(this.legacyRoot, sessionId); + if (!directory.startsWith(`${this.legacyRoot}${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 emit(event: PlannerLifecycleEvent): void { + 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 planner semantics. + // Telemetry is best effort and must never change bootstrap semantics. } } @@ -380,7 +506,48 @@ export class PlannerGreetingCoordinator { } } - private serialize(sessionId: string, operation: () => Promise): Promise { + private assertOpen(): void { + if (this.closed) throw new ProjectBootstrapCoordinatorClosedError(); + } + + private hasPendingApiInput(sessionId: string): boolean { + return (this.pendingApiPreemptions.get(sessionId) ?? 0) > 0; + } + + private notePendingApiInput(sessionId: string): void { + this.pendingApiPreemptions.set( + sessionId, + (this.pendingApiPreemptions.get(sessionId) ?? 0) + 1, + ); + } + + private clearPendingApiInput(sessionId: string): void { + const remaining = (this.pendingApiPreemptions.get(sessionId) ?? 1) - 1; + if (remaining <= 0) this.pendingApiPreemptions.delete(sessionId); + else this.pendingApiPreemptions.set(sessionId, remaining); + } + + private hasInputHold(sessionId: string): boolean { + return ( + this.activeTurns.has(sessionId) || this.terminalPreemptions.has(sessionId) + ); + } + + private clearActiveTurn( + sessionId: string, + kind: ActiveCoordinatorTurn["kind"], + id: string, + ): void { + const active = this.activeTurns.get(sessionId); + if (active?.kind === kind && active.id === id) { + this.activeTurns.delete(sessionId); + } + } + + 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); @@ -398,12 +565,14 @@ export class PlannerGreetingCoordinator { private newState( session: HarnessSession, emptyProject: boolean, - ): PersistedPlannerState { - if (!session.planning) throw new Error("planner metadata missing"); + ): PersistedProjectBootstrapState { + if (!session.projectBootstrap) { + throw new Error("project bootstrap metadata missing"); + } return { - schemaVersion: 1, + schemaVersion: 2, metadata: { - ...structuredClone(session.planning), + ...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. @@ -413,57 +582,99 @@ export class PlannerGreetingCoordinator { dispatchingInputId: null, retryCount: 0, emptyProject, + attempts: [], + uncertainInputIds: [], + uncertainInputs: [], }; } - private async quarantine(sessionId: string): Promise { - const file = this.file(sessionId); - const quarantine = path.join( - path.dirname(file), - `input-queue.corrupt-${this.now().replace(/[^0-9A-Za-z]/g, "-")}-${randomUUID()}.json`, - ); - await fs.rename(file, quarantine).catch(() => {}); - } - private async load( session: HarnessSession, emptyProject = true, - ): Promise { + ): 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: PersistedPlannerState; + let state: PersistedProjectBootstrapState; try { const parsed: unknown = JSON.parse( await fs.readFile(this.file(session.id), "utf8"), ); - if (isPersistedPlannerState(parsed, session)) { - state = { - ...parsed, - // Backward-compatible with queue files written by the first SAP-3055 - // review head before dispatch intent became explicit. - dispatchingInputId: parsed.dispatchingInputId ?? null, - }; - } else { - const adopted = adoptRehydratedState(parsed, session); - if (!adopted) throw new Error("invalid planner state"); - state = adopted; - this.adoptedSessions.add(session.id); - } + 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") { - // A single damaged or unreadable session queue is local corruption, - // never a reason to prevent the rest of the harness from booting. - await this.quarantine(session.id); + // 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); } - state = this.newState(session, emptyProject); } this.states.set(session.id, structuredClone(state)); return state; } - private async writeState(file: string, state: PersistedPlannerState): Promise { + private async writeState( + file: string, + state: PersistedProjectBootstrapState, + ): Promise { if (this.options.writeState) { await this.options.writeState(file, structuredClone(state)); return; @@ -477,6 +688,240 @@ export class PlannerGreetingCoordinator { 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 + ) { + if (target?.status === "exited" && target.agentSessionId === null) { + this.provisionalProjectClaims.delete(projectId); + this.provisionalSessionClaims.delete(intent.targetSessionId); + } else { + return false; + } + } + // A create that failed before the provider ever owned a conversation + // leaves an exited registry tombstone. Preserve that record, but let the + // next ordinary session take over the still-unfulfilled project intent. + const replaceable = + !target || + (target.status === "exited" && target.agentSessionId === null); + return ( + replaceable && + !(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 + ) { + if (target?.status === "exited" && target.agentSessionId === null) { + this.provisionalProjectClaims.delete(identity.projectId); + this.provisionalSessionClaims.delete(intent.targetSessionId!); + } else { + return null; + } + } + if ( + target && + !(target.status === "exited" && target.agentSessionId === null) + ) { + 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> { @@ -534,13 +979,13 @@ export class PlannerGreetingCoordinator { } private async recordAcceptedInput( - state: PersistedPlannerState, + state: PersistedProjectBootstrapState, inputId: string, ): Promise { - const sessionId = state.metadata.identity.sessionId; + const sessionId = state.metadata.targetSessionId; const accepted = await this.acceptedInputIds(sessionId); if (accepted === null) { - throw new Error("planner input acceptance ledger unavailable"); + 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 @@ -552,14 +997,21 @@ export class PlannerGreetingCoordinator { } private async reconcileAcceptedInputs( - state: PersistedPlannerState, - ): Promise { - const sessionId = state.metadata.identity.sessionId; + state: PersistedProjectBootstrapState, + ): Promise { + const sessionId = state.metadata.targetSessionId; const accepted = await this.acceptedInputIds(sessionId); if (accepted === null || accepted.size === 0) return state; const remaining = state.inputs.filter((input) => !accepted.has(input.id)); - if (remaining.length === state.inputs.length) return state; - const reconciled: PersistedPlannerState = { + const remainingUncertain = state.uncertainInputs.filter( + (input) => !accepted.has(input.id), + ); + if ( + remaining.length === state.inputs.length && + remainingUncertain.length === state.uncertainInputs.length + ) + return state; + const reconciled: PersistedProjectBootstrapState = { ...structuredClone(state), inputs: remaining, dispatchingInputId: @@ -570,6 +1022,10 @@ export class PlannerGreetingCoordinator { ...structuredClone(state.metadata), queuedInputIds: remaining.map((input) => input.id), }, + uncertainInputIds: state.uncertainInputIds.filter( + (inputId) => !accepted.has(inputId), + ), + uncertainInputs: remainingUncertain, }; await this.persist(sessionId, reconciled); // The queue commit is now authoritative. Ledger cleanup is best effort: @@ -582,38 +1038,61 @@ export class PlannerGreetingCoordinator { } private async resolveUncertainDispatch( - state: PersistedPlannerState, - ): Promise { + state: PersistedProjectBootstrapState, + ): Promise { const inputId = state.dispatchingInputId; if (inputId === null || state.inputs[0]?.id !== inputId) return state; - const remaining = state.inputs.slice(1); - const resolved: PersistedPlannerState = { + const input = state.inputs[0]; + if (!input) return state; + const resolved: PersistedProjectBootstrapState = { ...structuredClone(state), - inputs: remaining, + inputs: state.inputs.slice(1), dispatchingInputId: null, metadata: { ...structuredClone(state.metadata), - queuedInputIds: remaining.map((input) => input.id), + queuedInputIds: state.metadata.queuedInputIds.slice(1), }, + uncertainInputIds: [...state.uncertainInputIds, inputId], + uncertainInputs: [...state.uncertainInputs, structuredClone(input)], }; - await this.persist(state.metadata.identity.sessionId, resolved); + await this.persist(state.metadata.targetSessionId, resolved); this.emit({ - name: "planner_session.input_delivery_uncertain", - projectId: state.metadata.identity.projectId, - sessionId: state.metadata.identity.sessionId, + name: "project_bootstrap.input_delivery_uncertain", + projectId: state.metadata.projectId, + sessionId: state.metadata.targetSessionId, inputId, errorCode: "delivery_uncertain", - queueDepth: remaining.length, + queueDepth: state.inputs.length, }); - // There was no readable acceptance proof for this ID. Any stale ledger is - // cleanup residue or was quarantined; reset it after the queue resolution. - await this.writeAcceptedInputIds( - state.metadata.identity.sessionId, - [], - ).catch(() => {}); return resolved; } + /** Stop timers and settle all queued persistence before server teardown. */ + async close(): Promise { + if (this.closed) return; + this.closed = true; + for (const { handle } of this.timers.values()) clearTimeout(handle); + this.timers.clear(); + while (this.writes.size > 0) { + await Promise.allSettled([...this.writes.values()]); + } + // An operation that was already between awaits when close began must not + // leave any late timer or correlation state behind. + for (const { handle } of this.timers.values()) clearTimeout(handle); + this.timers.clear(); + this.expected.clear(); + this.observedAttempts.clear(); + this.activeTurns.clear(); + this.correlationOverflow.clear(); + this.pendingApiPreemptions.clear(); + this.provisionalProjectClaims.clear(); + this.provisionalSessionClaims.clear(); + this.states.clear(); + this.registeredSessions.clear(); + this.terminalPreemptions.clear(); + this.reportedTerminalPreemptions.clear(); + } + private clearTimer(sessionId: string, key?: string): void { const timer = this.timers.get(sessionId); if (!timer || (key !== undefined && timer.key !== key)) return; @@ -622,24 +1101,28 @@ export class PlannerGreetingCoordinator { } private armTimer(sessionId: string, key: "pending" | string): void { + if (this.closed) return; if (this.timers.get(sessionId)?.key === key) return; this.clearTimer(sessionId); - const handle = setTimeout(() => { - void this.fail( - sessionId, - key, - key === "pending" ? "session_not_ready" : "delivery_timeout", - true, - ).catch(() => { - // Timer callbacks have no request boundary to receive a rejection. - // persist() already projects/emits `persistence_failed` wherever one - // durable store remains; log only a fixed local classification here, - // never the provider/storage error or planner content. - console.error( - "[harness] planner greeting timeout transition failed: persistence_failed", - ); - }); - }, this.deliveryTimeoutMs); + const handle = setTimeout( + () => { + void this.fail( + sessionId, + key, + key === "pending" ? "session_not_ready" : "delivery_timeout", + key === "pending", + ).catch(() => { + // Timer callbacks have no request boundary to receive a rejection. + // persist() already projects/emits `persistence_failed` wherever one + // durable store remains; log only a fixed local classification here, + // never the provider/storage error or planner content. + console.error( + "[harness] project bootstrap timeout transition failed: persistence_failed", + ); + }); + }, + key === "pending" ? this.readinessTimeoutMs : this.deliveryTimeoutMs, + ); handle.unref?.(); this.timers.set(sessionId, { key, handle }); } @@ -650,7 +1133,7 @@ export class PlannerGreetingCoordinator { this.expected.set( sessionId, expected.map((entry) => - entry.kind === "greeting" && entry.id === attemptId + entry.kind === "bootstrap" && entry.id === attemptId ? { ...entry, retired: true } : entry, ), @@ -661,17 +1144,58 @@ export class PlannerGreetingCoordinator { this.observedAttempts.set( sessionId, observed.map((entry) => - entry?.id === attemptId ? { ...entry, retired: true } : entry, + entry.kind === "bootstrap" && entry.id === attemptId + ? { ...entry, retired: true } + : entry, ), ); } } + private markAttempt( + state: PersistedProjectBootstrapState, + attemptId: string, + status: "active" | "retired" | "completed", + ): void { + const attempt = state.attempts.find( + (candidate) => candidate.attemptId === attemptId, + ); + if (attempt) attempt.status = status; + } + + /** + * An API request reached the server before the background Enter crossed the + * PTY. Return the lifecycle to pending until enqueue() durably commits that + * user input; if enqueue fails, a later registration can safely retry because + * this attempt is proven not to have been submitted. + */ + private async yieldStagedBootstrapToApiInput( + state: PersistedProjectBootstrapState, + attemptId: string, + ): Promise { + const sessionId = state.metadata.targetSessionId; + this.clearActiveTurn(sessionId, "bootstrap", attemptId); + this.clearTimer(sessionId, attemptId); + this.removeExpectedGreeting(sessionId, attemptId); + this.retireAttemptCorrelation(sessionId, attemptId); + this.markAttempt(state, attemptId, "retired"); + state.metadata.bootstrap = { status: "pending" }; + await this.persist(sessionId, state); + } + private removeExpectedGreeting(sessionId: string, attemptId: string): void { + this.removeExpectedPrompt(sessionId, "bootstrap", attemptId); + } + + private removeExpectedPrompt( + sessionId: string, + kind: ExpectedPrompt["kind"], + id: string, + ): void { const expected = this.expected.get(sessionId); if (!expected) return; const remaining = expected.filter( - (entry) => !(entry.kind === "greeting" && entry.id === attemptId), + (entry) => !(entry.kind === kind && entry.id === id), ); if (remaining.length === 0) this.expected.delete(sessionId); else this.expected.set(sessionId, remaining); @@ -685,27 +1209,29 @@ export class PlannerGreetingCoordinator { private async persist( sessionId: string, - state: PersistedPlannerState, + state: PersistedProjectBootstrapState, ): Promise { try { await this.writeState(this.file(sessionId), state); } catch { if (!isTerminal(state.metadata)) { const attemptId = - state.metadata.greeting.status === "generating" - ? state.metadata.greeting.attemptId + state.metadata.bootstrap.status === "generating" + ? state.metadata.bootstrap.attemptId : undefined; this.clearTimer(sessionId); - state.metadata.greeting = { - status: "failed", - retryable: true, - errorCode: "persistence_failed", - }; + state.metadata.bootstrap = state.inputs.length + ? { status: "skipped", reason: "user-proceeded" } + : { + status: "failed", + retryable: true, + 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.setPlanningMetadata( + await this.options.sessionManager.setProjectBootstrapMetadata( sessionId, state.metadata, ); @@ -722,10 +1248,11 @@ export class PlannerGreetingCoordinator { // projection as authoritative on restart. Only a total two-store // outage retains the last committed cache. } - if (fallbackCommitted) this.states.set(sessionId, structuredClone(state)); + if (fallbackCommitted) + this.states.set(sessionId, structuredClone(state)); this.emit({ - name: "planner_greeting.failed", - projectId: state.metadata.identity.projectId, + name: "project_bootstrap.failed", + projectId: state.metadata.projectId, sessionId, ...(attemptId ? { attemptId } : {}), errorCode: "persistence_failed", @@ -733,7 +1260,7 @@ export class PlannerGreetingCoordinator { queueDepth: state.inputs.length, }); } - throw new Error("planner state persistence failed"); + throw new Error("project bootstrap state persistence failed"); } // The queue file contains the full coordinator state and is authoritative. @@ -747,7 +1274,7 @@ export class PlannerGreetingCoordinator { // transition/registration instead. this.states.set(sessionId, structuredClone(state)); await this.options.sessionManager - .setPlanningMetadata(sessionId, state.metadata) + .setProjectBootstrapMetadata(sessionId, state.metadata) .catch(() => {}); } @@ -758,255 +1285,134 @@ export class PlannerGreetingCoordinator { * queue greeting is newer than a stale non-terminal manager snapshot. */ private mergeRegistration( - state: PersistedPlannerState, + state: PersistedProjectBootstrapState, session: HarnessSession, ): void { - if (!session.planning) return; - const managerTerminal = isTerminal(session.planning); + if (!session.projectBootstrap) return; + const managerTerminal = isTerminal(session.projectBootstrap); const queueTerminal = isTerminal(state.metadata); if (managerTerminal && !queueTerminal) { - state.metadata.greeting = structuredClone(session.planning.greeting); + state.metadata.bootstrap = structuredClone( + session.projectBootstrap.bootstrap, + ); } state.metadata.queuedInputIds = state.inputs.map((input) => input.id); } - private async retireHandoffPredecessor( - predecessor: HarnessSession, - source?: PersistedPlannerState, - ): Promise { - const predecessorId = predecessor.id; - const retired: PersistedPlannerState | undefined = source - ? { - ...structuredClone(source), - inputs: [], - dispatchingInputId: null, - metadata: { - ...structuredClone(source.metadata), - queuedInputIds: [], - }, - } - : undefined; - if (retired) this.states.set(predecessorId, retired); - const metadata = - retired?.metadata ?? - (predecessor.planning - ? { ...structuredClone(predecessor.planning), queuedInputIds: [] } - : undefined); - if (metadata) { - await this.options.sessionManager - .setPlanningMetadata(predecessorId, metadata) - .catch(() => {}); - } - this.clearTimer(predecessorId); - this.clearCorrelation(predecessorId); - this.registeredSessions.delete(predecessorId); - this.retiredSessions.add(predecessorId); - } - - /** Atomically move a predecessor's entire coordinator directory into its - * rehydrated replacement. There is never a point with two durable FIFO - * copies: before rename only the predecessor exists; after rename only the - * exact successor exists. The embedded identity rewrite is best effort and - * recoverable through `adoptRehydratedState` after a crash. Accepted entries - * are removed and unresolved dispatch intent is classified uncertain before - * the move, so neither can become replayable under the successor. */ - private async handoffRehydratedInputs( + async register( session: HarnessSession, - replacementState: PersistedPlannerState, - ): Promise<{ state: PersistedPlannerState; moved: boolean }> { - const predecessorId = session.rehydratedFrom; - if (!predecessorId || predecessorId === session.id) { - return { state: replacementState, moved: false }; - } - const predecessor = this.options.sessionManager.get(predecessorId); - const sourceIdentity = predecessor?.planning?.identity; - const targetIdentity = session.planning?.identity; + context: ProjectBootstrapRegistrationContext, + ): Promise { + if (this.closed || !session.projectBootstrap) return; + const claimedProjectId = this.provisionalSessionClaims.get(session.id); if ( - !predecessor || - !sourceIdentity || - !targetIdentity || - sourceIdentity.role !== "map-planner" || - targetIdentity.role !== "map-planner" || - sourceIdentity.projectId !== targetIdentity.projectId || - sourceIdentity.userId !== targetIdentity.userId + claimedProjectId && + this.provisionalProjectClaims.get(claimedProjectId) === session.id ) { - return { state: replacementState, moved: false }; + this.provisionalProjectClaims.delete(claimedProjectId); + this.provisionalSessionClaims.delete(session.id); } - - return this.serialize(predecessorId, async () => { - // A canonical or adoptable target file proves a prior atomic move - // already committed. On a later boot the old HarnessSession may register - // first and recreate an empty source queue; never rename that directory - // over the authoritative target. A non-empty second source is an - // impossible/conflicting dual owner and fails closed. - try { - await fs.access(this.file(session.id)); - let recreatedSource: PersistedPlannerState | undefined; - try { - await fs.access(this.file(predecessorId)); - recreatedSource = await this.load(predecessor); - recreatedSource = await this.reconcileAcceptedInputs(recreatedSource); - if (recreatedSource.dispatchingInputId !== null) { - recreatedSource = await this.resolveUncertainDispatch(recreatedSource); - } - if (recreatedSource.inputs.length > 0) { - throw new Error("conflicting planner handoff queues"); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - await this.retireHandoffPredecessor(predecessor, recreatedSource); - return { state: replacementState, moved: true }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - - try { - await fs.access(this.file(predecessorId)); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { - state: replacementState, - moved: this.adoptedSessions.has(session.id), - }; - } - throw error; - } - let source = await this.load(predecessor); - source = await this.reconcileAcceptedInputs(source); - if (source.dispatchingInputId !== null) { - source = await this.resolveUncertainDispatch(source); - } - - const transferred = source.inputs.map((input) => ({ - ...structuredClone(input), - sessionId: session.id, - })); - const transferredIds = new Set(transferred.map((input) => input.id)); - const mergedInputs = [ - ...transferred, - ...replacementState.inputs.filter( - (input) => !transferredIds.has(input.id), - ), - ]; - const replacement: PersistedPlannerState = { - ...structuredClone(source), - inputs: mergedInputs, - dispatchingInputId: null, - metadata: { - ...structuredClone(source.metadata), - identity: structuredClone(targetIdentity), - queuedInputIds: mergedInputs.map((input) => input.id), - }, - }; - const sourceDirectory = path.dirname(this.file(predecessorId)); - const targetDirectory = path.dirname(this.file(session.id)); - const move = this.options.moveStateDirectory ?? fs.rename; - await move(sourceDirectory, targetDirectory); - - // The rename above is the only ownership commit. Publish the re-keyed - // cache immediately; rewriting the moved file is merely canonicalization - // because a crash can always adopt its predecessor identity in place. - this.states.set(session.id, structuredClone(replacement)); - this.states.delete(predecessorId); - try { - await this.writeState(this.file(session.id), replacement); - this.adoptedSessions.delete(session.id); - } catch { - this.adoptedSessions.add(session.id); - } - await this.writeAcceptedInputIds(session.id, []).catch(() => {}); - - await this.retireHandoffPredecessor(predecessor, source); - return { state: replacement, moved: true }; - }); - } - - async register( - session: HarnessSession, - context: PlannerRegistrationContext, - ): Promise { - if (!session.planning) return; let shouldStart = false; let shouldDrain = false; await this.serialize(session.id, async () => { + const firstRegistration = !this.registeredSessions.has(session.id); + let terminalTransitionEmitted = false; const cached = this.states.has(session.id); - let state = await this.load(session, context.emptyProject); - let handoffMoved = false; - // A crash can occur after the replacement queue commit but before the - // predecessor retirement commit. `rehydratedFrom` is persisted on the - // HarnessSession, so repeat this idempotent handoff during process boot - // as well as the original rehydration callback. - if (session.rehydratedFrom) { - const handoff = await this.handoffRehydratedInputs(session, state); - state = handoff.state; - handoffMoved = handoff.moved; - } + const state = await this.load(session, context.emptyProject); this.mergeRegistration(state, session); + if ( + this.terminalPreemptions.has(session.id) && + !isTerminal(state.metadata) + ) { + const attemptId = + state.metadata.bootstrap.status === "generating" + ? state.metadata.bootstrap.attemptId + : undefined; + if (attemptId) this.markAttempt(state, attemptId, "retired"); + state.metadata.bootstrap = { + status: "skipped", + reason: "user-proceeded", + }; + } + // Only a process-boot load proves an in-flight dispatch was abandoned. // Live re-registration is idempotent and must not fail its active turn. if ( context.mode === "boot" && !cached && - state.metadata.greeting.status === "generating" + state.metadata.bootstrap.status === "generating" ) { - const attemptId = state.metadata.greeting.attemptId; - state.metadata.greeting = state.inputs.length + const attemptId = state.metadata.bootstrap.attemptId; + this.markAttempt(state, attemptId, "retired"); + state.metadata.bootstrap = state.inputs.length ? { status: "skipped", reason: "user-proceeded" } : { status: "failed", - retryable: true, + retryable: false, errorCode: "delivery_timeout", }; if (state.inputs.length) { + terminalTransitionEmitted = true; this.emit({ - name: "planner_greeting.skipped", - projectId: state.metadata.identity.projectId, + name: "project_bootstrap.skipped", + projectId: state.metadata.projectId, sessionId: session.id, attemptId, reason: "user-proceeded", queueDepth: state.inputs.length, }); } else { + terminalTransitionEmitted = true; this.emit({ - name: "planner_greeting.failed", - projectId: state.metadata.identity.projectId, + name: "project_bootstrap.failed", + projectId: state.metadata.projectId, sessionId: session.id, attemptId, errorCode: "delivery_timeout", - retryable: true, + retryable: false, queueDepth: 0, }); } } - if (handoffMoved) { - // The directory rename already durably committed FIFO ownership. A - // failed canonical rewrite must not reject the exact successor and let - // a later open mint a second owner; future pre-PTY transitions retry - // the normal authoritative queue write. - this.states.set(session.id, structuredClone(state)); - try { - await this.writeState(this.file(session.id), state); - this.adoptedSessions.delete(session.id); - } catch { - this.adoptedSessions.add(session.id); - } - await this.options.sessionManager - .setPlanningMetadata(session.id, state.metadata) - .catch(() => {}); - } else { - await this.persist(session.id, state); + await this.persist(session.id, state); + if (firstRegistration && context.mode === "created") { + this.emit({ + name: "project_bootstrap.scheduled", + projectId: state.metadata.projectId, + sessionId: session.id, + }); + } else if (firstRegistration && context.mode === "boot") { + this.emit({ + name: "project_bootstrap.recovered", + projectId: state.metadata.projectId, + sessionId: session.id, + }); } this.registeredSessions.add(session.id); - if (state.metadata.greeting.status === "pending") { - const allowed = await this.canDispatch(session); - if ( - session.ready && - session.status === "running" && - allowed - ) shouldStart = true; - else if (allowed) this.armTimer(session.id, "pending"); + if ( + firstRegistration && + !terminalTransitionEmitted && + state.metadata.bootstrap.status === "skipped" + ) { + this.emit({ + name: "project_bootstrap.skipped", + projectId: state.metadata.projectId, + sessionId: session.id, + reason: state.metadata.bootstrap.reason, + queueDepth: state.inputs.length, + }); + } + if (state.metadata.bootstrap.status === "pending") { + if (session.status === "exited") { + await this.setFailure(state, "pending", "session_exited", false); + } else { + const allowed = await this.canDispatch(session); + if (session.ready && session.status === "running" && allowed) + shouldStart = true; + else if (allowed) this.armTimer(session.id, "pending"); + else await this.setFailure(state, "pending", "session_exited", false); + } } else if (isTerminal(state.metadata)) { shouldDrain = session.ready && @@ -1019,78 +1425,174 @@ export class PlannerGreetingCoordinator { } async onSessionStatus(session: HarnessSession): Promise { - if (!session.planning) return; - if ( - !this.registeredSessions.has(session.id) || - this.retiredSessions.has(session.id) - ) return; - if ( - session.ready && - session.status === "running" && - (await this.canDispatch(session)) - ) { - this.clearTimer(session.id, "pending"); - const state = this.states.get(session.id); - if (state && isTerminal(state.metadata)) await this.drainSession(session.id); - else await this.startGreeting(session.id, false); - return; - } - if (session.status === "exited") { - const timer = this.timers.get(session.id); - this.clearTimer(session.id); - this.expected.delete(session.id); - this.observedAttempts.delete(session.id); - this.correlationOverflow.delete(session.id); - await this.fail( - session.id, - timer?.key ?? "pending", - "session_exited", - false, - ); - } + if (this.closed || !session.projectBootstrap) return; + let action: "start" | "drain" | null = null; + await this.serialize(session.id, async () => { + if (this.closed || !this.registeredSessions.has(session.id)) { + return; + } + const current = this.options.sessionManager.get(session.id); + if (!current?.projectBootstrap) return; + const state = await this.load(current); + if (current.status === "exited") { + const timer = this.timers.get(session.id); + this.clearTimer(session.id); + this.clearCorrelation(session.id); + this.activeTurns.delete(session.id); + this.terminalPreemptions.delete(session.id); + this.reportedTerminalPreemptions.delete(session.id); + const expectedKey = + timer?.key ?? + (state.metadata.bootstrap.status === "generating" + ? state.metadata.bootstrap.attemptId + : "pending"); + await this.setFailure(state, expectedKey, "session_exited", false); + return; + } + if ( + current.ready && + current.status === "running" && + (await this.canDispatch(current)) + ) { + if (this.closed) return; + this.clearTimer(session.id, "pending"); + action = isTerminal(state.metadata) ? "drain" : "start"; + } + }); + if (this.closed) return; + if (action === "drain") await this.drainSession(session.id); + else if (action === "start") await this.startGreeting(session.id, false); } - private async startGreeting(sessionId: string, retry: boolean): Promise { + private async startGreeting( + sessionId: string, + retry: boolean, + ): Promise { await this.serialize(sessionId, async () => { - if (this.retiredSessions.has(sessionId)) return; + if (this.closed) return; const session = this.options.sessionManager.get(sessionId); - if (!session?.planning) return; + if (!session?.projectBootstrap) return; const state = await this.load(session); + if (this.closed) return; + if (this.activeTurns.has(sessionId)) { + if (retry) throw new ProjectBootstrapRetryUnavailableError(); + return; + } + if (this.terminalPreemptions.has(sessionId)) { + if (!isTerminal(state.metadata)) { + const attemptId = + state.metadata.bootstrap.status === "generating" + ? state.metadata.bootstrap.attemptId + : undefined; + if (attemptId) this.markAttempt(state, attemptId, "retired"); + state.metadata.bootstrap = { + status: "skipped", + reason: "user-proceeded", + }; + await this.persist(sessionId, state); + } + return; + } if (!(await this.canDispatch(session))) { - if (retry) throw new PlannerDispatchForbiddenError(); - if (state.metadata.greeting.status === "pending") { + if (retry) throw new ProjectBootstrapDispatchForbiddenError(); + if (state.metadata.bootstrap.status === "pending") { await this.setFailure(state, "pending", "session_exited", false); } return; } + let mapStillEmpty: boolean; + try { + mapStillEmpty = + (await this.options.isMeaningfullyEmpty?.( + state.metadata.projectId, + )) ?? state.emptyProject; + } catch { + const expected = + state.metadata.bootstrap.status === "generating" + ? state.metadata.bootstrap.attemptId + : "pending"; + await this.setFailure(state, expected, "persistence_failed", true); + return; + } + if (!mapStillEmpty) { + state.emptyProject = false; + state.metadata.bootstrap = { + status: "skipped", + reason: "map-not-empty", + }; + await this.persist(sessionId, state); + this.emit({ + name: "project_bootstrap.skipped", + projectId: state.metadata.projectId, + sessionId, + reason: "map-not-empty", + queueDepth: state.inputs.length, + }); + return; + } if (retry) { if ( - state.metadata.greeting.status !== "failed" || - !state.metadata.greeting.retryable || + state.metadata.bootstrap.status !== "failed" || + !state.metadata.bootstrap.retryable || state.inputs.length > 0 || + this.activeTurns.has(sessionId) || state.retryCount >= MAX_RETRIES ) { - throw new PlannerGreetingRetryUnavailableError(); + throw new ProjectBootstrapRetryUnavailableError(); } state.retryCount += 1; - } else if (state.metadata.greeting.status !== "pending") { + } else if (state.metadata.bootstrap.status !== "pending") { return; } this.clearTimer(sessionId, "pending"); const attemptId = this.generateId(); - state.metadata.greeting = { status: "generating", attemptId }; + state.metadata.bootstrap = { status: "generating", attemptId }; + state.attempts.push({ + attemptId, + retryOrdinal: state.retryCount, + status: "active", + }); + state.attempts = state.attempts.slice(-8); await this.persist(sessionId, state); this.emit({ - name: retry ? "planner_greeting.retried" : "planner_greeting.attempted", - projectId: state.metadata.identity.projectId, + name: retry + ? "project_bootstrap.retried" + : "project_bootstrap.attempted", + projectId: state.metadata.projectId, sessionId, attemptId, + retryOrdinal: state.retryCount, queueDepth: state.inputs.length, }); - const prompt = plannerGreetingPrompt(state.emptyProject, attemptId); + const prompt = projectBootstrapPrompt(state.retryCount, attemptId); + if (this.closed) return; if (!(await this.canDispatch(session))) { await this.setFailure(state, attemptId, "session_exited", false); - if (retry) throw new PlannerDispatchForbiddenError(); + if (retry) throw new ProjectBootstrapDispatchForbiddenError(); + return; + } + if (this.terminalPreemptions.has(sessionId)) { + this.markAttempt(state, attemptId, "retired"); + state.metadata.bootstrap = { + status: "skipped", + reason: "user-proceeded", + }; + await this.persist(sessionId, state); + if (!this.reportedTerminalPreemptions.has(sessionId)) { + this.reportedTerminalPreemptions.add(sessionId); + this.emit({ + name: "project_bootstrap.preempted", + projectId: state.metadata.projectId, + sessionId, + attemptId, + reason: "user-proceeded", + queueDepth: state.inputs.length, + }); + } + return; + } + if (this.hasPendingApiInput(sessionId)) { + await this.yieldStagedBootstrapToApiInput(state, attemptId); return; } // Register before crossing the PTY boundary. A prompt hook may arrive @@ -1098,27 +1600,70 @@ export class PlannerGreetingCoordinator { // resolved; registering afterward loses the only safe correlation. const queue = this.expected.get(sessionId) ?? []; queue.push({ - kind: "greeting", + kind: "bootstrap", id: attemptId, text: prompt, retired: false, }); this.expected.set(sessionId, queue); + this.activeTurns.set(sessionId, { + kind: "bootstrap", + id: attemptId, + }); try { const accepted = await this.options.sessionManager.submitInput( sessionId, prompt, true, - () => this.canDispatch(session), + async () => + !this.closed && + !this.hasPendingApiInput(sessionId) && + (await this.canDispatch(session)), + true, ); if (!accepted) { // A false return proves the prompt did not cross the PTY boundary. + this.clearActiveTurn(sessionId, "bootstrap", attemptId); this.removeExpectedGreeting(sessionId, attemptId); await this.setFailure(state, attemptId, "session_exited", false); return; } + if (this.closed) return; this.armTimer(sessionId, attemptId); } catch (error) { + if ( + this.hasPendingApiInput(sessionId) && + (error instanceof SessionBackgroundInputPreemptedError || + error instanceof SessionInputGuardRejectedError) + ) { + this.clearActiveTurn(sessionId, "bootstrap", attemptId); + await this.yieldStagedBootstrapToApiInput(state, attemptId); + return; + } + if (error instanceof SessionBackgroundInputPreemptedError) { + this.clearActiveTurn(sessionId, "bootstrap", attemptId); + this.removeExpectedGreeting(sessionId, attemptId); + this.clearTimer(sessionId, attemptId); + this.retireAttemptCorrelation(sessionId, attemptId); + this.markAttempt(state, attemptId, "retired"); + state.metadata.bootstrap = { + status: "skipped", + reason: "user-proceeded", + }; + await this.persist(sessionId, state); + if (!this.reportedTerminalPreemptions.has(sessionId)) { + this.reportedTerminalPreemptions.add(sessionId); + this.emit({ + name: "project_bootstrap.preempted", + projectId: state.metadata.projectId, + sessionId, + attemptId, + reason: "user-proceeded", + queueDepth: state.inputs.length, + }); + } + return; + } if ( error instanceof SessionNotReadyError || (error instanceof SessionInputGuardRejectedError && !error.staged) @@ -1127,6 +1672,10 @@ export class PlannerGreetingCoordinator { // after staging is intentionally retained/retired as uncertain. this.removeExpectedGreeting(sessionId, attemptId); } + // submitInput only resolves after Enter. Every rejection proves that + // this attempt does not own a running model turn, even if its text had + // briefly been staged in the composer. + this.clearActiveTurn(sessionId, "bootstrap", attemptId); await this.setFailure( state, attemptId, @@ -1138,34 +1687,41 @@ export class PlannerGreetingCoordinator { !(error instanceof SessionInputGuardRejectedError), ); if (retry && error instanceof SessionInputGuardRejectedError) { - throw new PlannerDispatchForbiddenError(); + throw new ProjectBootstrapDispatchForbiddenError(); } } }); } private async setFailure( - state: PersistedPlannerState, + state: PersistedProjectBootstrapState, expectedKey: "pending" | string, - errorCode: PlannerGreetingErrorCode, + errorCode: ProjectBootstrapErrorCode, retryable: boolean, ): Promise { - const greeting = state.metadata.greeting; + const greeting = state.metadata.bootstrap; const matches = (expectedKey === "pending" && greeting.status === "pending") || (greeting.status === "generating" && greeting.attemptId === expectedKey); if (!matches) return; - const sessionId = state.metadata.identity.sessionId; - const attemptId = greeting.status === "generating" ? greeting.attemptId : undefined; + const sessionId = state.metadata.targetSessionId; + const attemptId = + greeting.status === "generating" ? greeting.attemptId : undefined; this.clearTimer(sessionId, expectedKey); - if (attemptId) this.retireAttemptCorrelation(sessionId, attemptId); + if (attemptId) { + this.retireAttemptCorrelation(sessionId, attemptId); + this.markAttempt(state, attemptId, "retired"); + } if (state.inputs.length > 0) { - state.metadata.greeting = { status: "skipped", reason: "user-proceeded" }; + state.metadata.bootstrap = { + status: "skipped", + reason: "user-proceeded", + }; await this.persist(sessionId, state); this.clearCorrelation(sessionId); this.emit({ - name: "planner_greeting.skipped", - projectId: state.metadata.identity.projectId, + name: "project_bootstrap.skipped", + projectId: state.metadata.projectId, sessionId, ...(attemptId ? { attemptId } : {}), reason: "user-proceeded", @@ -1174,12 +1730,19 @@ export class PlannerGreetingCoordinator { await this.drain(state); return; } - state.metadata.greeting = { status: "failed", retryable, errorCode }; + state.metadata.bootstrap = { status: "failed", retryable, errorCode }; await this.persist(sessionId, state); - if (!retryable) this.clearCorrelation(sessionId); + const awaitsTimedOutTurnCompletion = + errorCode === "delivery_timeout" && + attemptId !== undefined && + this.activeTurns.get(sessionId)?.kind === "bootstrap" && + this.activeTurns.get(sessionId)?.id === attemptId; + if (!retryable && !awaitsTimedOutTurnCompletion) { + this.clearCorrelation(sessionId); + } this.emit({ - name: "planner_greeting.failed", - projectId: state.metadata.identity.projectId, + name: "project_bootstrap.failed", + projectId: state.metadata.projectId, sessionId, ...(attemptId ? { attemptId } : {}), errorCode, @@ -1191,34 +1754,55 @@ export class PlannerGreetingCoordinator { private async fail( sessionId: string, expectedKey: "pending" | string, - errorCode: PlannerGreetingErrorCode, + errorCode: ProjectBootstrapErrorCode, retryable: boolean, ): Promise { await this.serialize(sessionId, async () => { - if (this.retiredSessions.has(sessionId)) return; + if (this.closed) return; const session = this.options.sessionManager.get(sessionId); - if (!session?.planning) return; + if (!session?.projectBootstrap) return; const state = await this.load(session); await this.setFailure(state, expectedKey, errorCode, retryable); }); } retry(sessionId: string): Promise { + if (this.closed) + return Promise.reject(new ProjectBootstrapCoordinatorClosedError()); return this.startGreeting(sessionId, true); } - async enqueue(sessionId: string, text: string): Promise { - return this.serialize(sessionId, async () => { - if (this.retiredSessions.has(sessionId)) { - throw new PlannerDispatchForbiddenError(); - } + enqueue(sessionId: string, text: string): Promise { + if (this.closed) + return Promise.reject(new ProjectBootstrapCoordinatorClosedError()); + const known = this.options.sessionManager.get(sessionId); + if (!known?.projectBootstrap) { + return Promise.reject(new Error("project bootstrap session not found")); + } + if (known.status === "exited") { + return Promise.reject(new ProjectBootstrapDispatchForbiddenError()); + } + + // This signal is intentionally installed before waiting for the coordinator + // lock: startGreeting may currently be between its background text write and + // delayed Enter. Cancelling that staging window gives durable user input + // priority without ever splicing the two prompts together. + this.notePendingApiInput(sessionId); + this.options.sessionManager.preemptBackgroundInput(sessionId); + + const operation = this.serialize(sessionId, async () => { + this.assertOpen(); const session = this.options.sessionManager.get(sessionId); - if (!session?.planning) throw new Error("planner session not found"); + if (!session?.projectBootstrap) + throw new Error("project bootstrap session not found"); + if (session.status === "exited") { + throw new ProjectBootstrapDispatchForbiddenError(); + } if (!(await this.canDispatch(session))) { - throw new PlannerDispatchForbiddenError(); + throw new ProjectBootstrapDispatchForbiddenError(); } const state = await this.load(session); - const input: PlannerQueuedInput = { + const input: ProjectBootstrapQueuedInput = { id: this.generateId(), sessionId, text, @@ -1226,40 +1810,121 @@ export class PlannerGreetingCoordinator { }; state.inputs.push(input); state.metadata.queuedInputIds.push(input.id); - if (state.metadata.greeting.status === "failed") { - state.metadata.greeting = { status: "skipped", reason: "user-proceeded" }; + const bootstrap = state.metadata.bootstrap; + const preempt = + bootstrap.status === "pending" || + bootstrap.status === "generating" || + bootstrap.status === "failed"; + const attemptId = + bootstrap.status === "generating" ? bootstrap.attemptId : undefined; + if (preempt) { + state.metadata.bootstrap = { + status: "skipped", + reason: "user-proceeded", + }; + if (attemptId) this.markAttempt(state, attemptId, "retired"); + } + await this.persist(sessionId, state); + if (preempt) { + this.clearTimer(sessionId); + if (attemptId) { + this.retireAttemptCorrelation(sessionId, attemptId); + } else if (bootstrap.status === "pending") { + this.clearCorrelation(sessionId); + } this.emit({ - name: "planner_greeting.skipped", - projectId: state.metadata.identity.projectId, + name: "project_bootstrap.preempted", + projectId: state.metadata.projectId, sessionId, + ...(attemptId ? { attemptId } : {}), reason: "user-proceeded", queueDepth: state.inputs.length, }); } - await this.persist(sessionId, state); - if (isTerminal(state.metadata)) await this.drain(state, true); + // Durable acceptance is the API acknowledgement boundary. A project or + // session rebind after this commit may pause dispatch, but must not turn + // the accepted request into a client-visible failure that invites a + // duplicate retry. + if (isTerminal(state.metadata)) await this.drain(state); // drain() advances through immutable queue-state clones. Return the // latest authoritative projection rather than the pre-drain object so a // 202 response never reports IDs that were already durably dequeued. - return structuredClone(this.states.get(sessionId)?.metadata ?? state.metadata); + return structuredClone( + this.states.get(sessionId)?.metadata ?? state.metadata, + ); }); + return operation.finally(() => this.clearPendingApiInput(sessionId)); + } + + /** + * Raw PTY input is already owned by the user and is never copied into this + * store. It synchronously retires bootstrap in memory, then durably records + * preemption on the coordinator queue before any later automatic attempt. + */ + onTerminalInput(sessionId: string): void { + if (this.closed) return; + this.terminalPreemptions.add(sessionId); + const session = this.options.sessionManager.get(sessionId); + if (session?.projectBootstrap && !isTerminal(session.projectBootstrap)) { + session.projectBootstrap.bootstrap = { + status: "skipped", + reason: "user-proceeded", + }; + } + const state = this.states.get(sessionId); + const bootstrap = state?.metadata.bootstrap; + if ( + !state || + !bootstrap || + (bootstrap.status !== "pending" && + bootstrap.status !== "generating" && + bootstrap.status !== "failed") + ) { + return; + } + const attemptId = + bootstrap.status === "generating" ? bootstrap.attemptId : undefined; + state.metadata.bootstrap = { status: "skipped", reason: "user-proceeded" }; + this.clearTimer(sessionId); + if (attemptId) { + this.retireAttemptCorrelation(sessionId, attemptId); + this.markAttempt(state, attemptId, "retired"); + } + this.states.set(sessionId, structuredClone(state)); + void this.serialize(sessionId, async () => { + const latest = this.states.get(sessionId); + if (!latest) return; + await this.persist(sessionId, structuredClone(latest)); + if (!this.reportedTerminalPreemptions.has(sessionId)) { + this.reportedTerminalPreemptions.add(sessionId); + this.emit({ + name: "project_bootstrap.preempted", + projectId: latest.metadata.projectId, + sessionId, + ...(attemptId ? { attemptId } : {}), + reason: "user-proceeded", + queueDepth: latest.inputs.length, + }); + } + }).catch(() => {}); } private async drainSession(sessionId: string): Promise { await this.serialize(sessionId, async () => { - if (this.retiredSessions.has(sessionId)) return; + if (this.closed) return; const session = this.options.sessionManager.get(sessionId); - if (!session?.planning) return; + if (!session?.projectBootstrap) return; const state = await this.load(session); if (isTerminal(state.metadata)) await this.drain(state); }); } private async drain( - initialState: PersistedPlannerState, - throwOnForbidden = false, + initialState: PersistedProjectBootstrapState, ): Promise { - let state: PersistedPlannerState; + const sessionId = initialState.metadata.targetSessionId; + if (this.hasInputHold(sessionId)) return; + let state: PersistedProjectBootstrapState; try { // An accepted-input ledger is the commit/ack boundary. If the prior // process reached the PTY but failed to rewrite the FIFO, finish that @@ -1268,137 +1933,143 @@ export class PlannerGreetingCoordinator { } catch { return; } - while (state.inputs.length > 0) { - const input = state.inputs[0]!; - const session = this.options.sessionManager.get(input.sessionId); - if ( - !session?.ready || - session.status !== "running" - ) return; - if (!(await this.canDispatch(session))) { - if (throwOnForbidden) throw new PlannerDispatchForbiddenError(); + const input = state.inputs[0]; + if (!input || this.hasInputHold(sessionId)) return; + const session = this.options.sessionManager.get(input.sessionId); + if (!session?.ready || session.status !== "running") return; + if (!(await this.canDispatch(session))) return; + + // A durable intent without a durable acceptance acknowledgement is + // irreducibly ambiguous across a crash. Never guess by replaying it: a + // later accepted ledger can complete the dequeue, while automatic replay + // could duplicate a user message already written to the PTY. + if (state.dispatchingInputId !== null) { + try { + state = await this.resolveUncertainDispatch(state); + } catch { return; } + if (state.dispatchingInputId !== null) return; + const next = state.inputs[0]; + if (!next || this.hasInputHold(sessionId)) return; + return this.drain(state); + } - // A durable intent without a durable acceptance acknowledgement is - // irreducibly ambiguous across a crash. Never guess by replaying it: a - // later accepted ledger can complete the dequeue, while automatic replay - // could duplicate a user message already written to the PTY. - if (state.dispatchingInputId !== null) { - try { - state = await this.resolveUncertainDispatch(state); - } catch { - return; - } - continue; - } - - const prepared: PersistedPlannerState = { + const prepared: PersistedProjectBootstrapState = { + ...structuredClone(state), + dispatchingInputId: input.id, + }; + try { + await this.persist(input.sessionId, prepared); + } catch { + // No external side effect occurred before the intent commit. + return; + } + state = prepared; + if (!(await this.canDispatch(session))) { + const rollback: PersistedProjectBootstrapState = { ...structuredClone(state), - dispatchingInputId: input.id, + dispatchingInputId: null, }; - try { - await this.persist(input.sessionId, prepared); - } catch { - // No external side effect occurred before the intent commit. - return; - } - state = prepared; - if (!(await this.canDispatch(session))) { - const rollback: PersistedPlannerState = { - ...structuredClone(state), - dispatchingInputId: null, - }; - await this.persist(input.sessionId, rollback).catch(() => {}); - if (throwOnForbidden) throw new PlannerDispatchForbiddenError(); - return; - } - let accepted = false; - try { - accepted = await this.options.sessionManager.submitInput( - input.sessionId, - input.text, - true, - () => this.canDispatch(session), - ); - } catch (error) { - const rollback: PersistedPlannerState = { - ...structuredClone(state), - dispatchingInputId: null, - }; - await this.persist(input.sessionId, rollback).catch(() => {}); - if ( - throwOnForbidden && - error instanceof SessionInputGuardRejectedError - ) { - throw new PlannerDispatchForbiddenError(); - } - return; - } - if (!accepted) { - const rollback: PersistedPlannerState = { - ...structuredClone(state), - dispatchingInputId: null, - }; - await this.persist(input.sessionId, rollback).catch(() => {}); - return; - } - // Register local correlation only after acceptance. The accepted ledger - // then commits the external side effect before the FIFO is rewritten. - const queue = this.expected.get(input.sessionId) ?? []; - queue.push({ kind: "user", id: input.id, text: input.text }); - this.expected.set(input.sessionId, queue); - try { - await this.recordAcceptedInput(state, input.id); - } catch { - // The durable intent remains unresolved and will not be replayed after - // restart. True PTY exactly-once is impossible without this ack. - return; - } + await this.persist(input.sessionId, rollback).catch(() => {}); + return; + } - const committed: PersistedPlannerState = { + // Register correlation and the live turn gate before crossing the PTY + // boundary. Prompt hooks may run before submitInput resolves. + const queue = this.expected.get(input.sessionId) ?? []; + queue.push({ kind: "user", id: input.id, text: input.text }); + this.expected.set(input.sessionId, queue); + this.activeTurns.set(input.sessionId, { kind: "user", id: input.id }); + let accepted = false; + try { + accepted = await this.options.sessionManager.submitInput( + input.sessionId, + input.text, + true, + () => this.canDispatch(session), + ); + } catch { + this.clearActiveTurn(input.sessionId, "user", input.id); + this.removeExpectedPrompt(input.sessionId, "user", input.id); + const rollback: PersistedProjectBootstrapState = { ...structuredClone(state), - inputs: state.inputs.slice(1), dispatchingInputId: null, - metadata: { - ...structuredClone(state.metadata), - queuedInputIds: state.metadata.queuedInputIds.slice(1), - }, }; - try { - await this.persist(input.sessionId, committed); - } catch { - // The accepted ledger is durable. A restart will finish this exact - // dequeue without submitting the input twice. - return; - } - state = committed; - await this.writeAcceptedInputIds(input.sessionId, []).catch(() => {}); + await this.persist(input.sessionId, rollback).catch(() => {}); + return; } + if (!accepted) { + this.clearActiveTurn(input.sessionId, "user", input.id); + this.removeExpectedPrompt(input.sessionId, "user", input.id); + const rollback: PersistedProjectBootstrapState = { + ...structuredClone(state), + dispatchingInputId: null, + }; + await this.persist(input.sessionId, rollback).catch(() => {}); + return; + } + try { + await this.recordAcceptedInput(state, input.id); + } catch { + // The durable intent remains unresolved and will not be replayed after + // restart. True PTY exactly-once is impossible without this ack. + return; + } + + const committed: PersistedProjectBootstrapState = { + ...structuredClone(state), + inputs: state.inputs.slice(1), + dispatchingInputId: null, + metadata: { + ...structuredClone(state.metadata), + queuedInputIds: state.metadata.queuedInputIds.slice(1), + }, + uncertainInputIds: state.uncertainInputIds.filter( + (inputId) => inputId !== input.id, + ), + }; + try { + await this.persist(input.sessionId, committed); + } catch { + // The accepted ledger is durable. A restart will finish this exact + // dequeue without submitting the input twice. + return; + } + await this.writeAcceptedInputIds(input.sessionId, []).catch(() => {}); + // The next FIFO entry is intentionally not submitted here. Its dispatch + // is released only by this turn's correlated completion. } /** Add local-only correlation without removing transcript content. */ decorateLocalEvent(event: AnalyticsEvent): AnalyticsEvent { - if (this.retiredSessions.has(event.harnessSessionId)) return event; const session = this.options.sessionManager.get(event.harnessSessionId); - if (!session?.planning || event.type !== "prompt.submitted") return event; - const prompt = typeof event.payload.prompt === "string" ? event.payload.prompt : ""; + if (!session?.projectBootstrap || event.type !== "prompt.submitted") + return event; + const prompt = + typeof event.payload.prompt === "string" ? event.payload.prompt : ""; const queue = this.expected.get(event.harnessSessionId) ?? []; const index = queue.findIndex((entry) => entry.text === prompt); const [match] = index < 0 ? [] : queue.splice(index, 1); if (queue.length === 0) this.expected.delete(event.harnessSessionId); const observed = this.observedAttempts.get(event.harnessSessionId) ?? []; - // One barrier per observed planner prompt, including unmatched/user - // prompts. turn.completed has no attempt token, so skipping those barriers - // would let their completion shift a later greeting attempt instead. + // One barrier per observed prompt. turn.completed has no attempt token, so + // skipping ordinary/user barriers would let their completion release or + // satisfy a later coordinator-owned turn. if (observed.length >= 256) { this.clearCorrelation(event.harnessSessionId); this.correlationOverflow.add(event.harnessSessionId); } else { observed.push( - match?.kind === "greeting" - ? { id: match.id, retired: match.retired === true } - : null, + match?.kind === "bootstrap" + ? { + kind: "bootstrap", + id: match.id, + retired: match.retired === true, + } + : match?.kind === "user" + ? { kind: "user", id: match.id } + : { kind: "external" }, ); this.observedAttempts.set(event.harnessSessionId, observed); } @@ -1407,23 +2078,33 @@ export class PlannerGreetingCoordinator { ...event, payload: { ...event.payload, - plannerOrigin: match.kind === "greeting" ? "infrastructure" : "user", - ...(match.kind === "greeting" - ? { plannerAttemptId: match.id } - : { plannerInputId: match.id }), + projectBootstrapOrigin: + match.kind === "bootstrap" ? "infrastructure" : "user", + ...(match.kind === "bootstrap" + ? { projectBootstrapAttemptId: match.id } + : { projectBootstrapInputId: match.id }), }, }; } - /** Product telemetry receives no planning content, paths, or provider text. */ + /** Bootstrap telemetry receives no prompt, path, or provider content. */ redactForTelemetry(event: AnalyticsEvent): AnalyticsEvent { const session = this.options.sessionManager.get(event.harnessSessionId); - return session?.planning + const nextObserved = this.observedAttempts.get(event.harnessSessionId)?.[0]; + const correlatedAttempt = + typeof event.payload.projectBootstrapAttemptId === "string" || + nextObserved?.kind === "bootstrap"; + const activeBootstrapLifecycle = + session?.projectBootstrap !== undefined && + !isTerminal(session.projectBootstrap); + const coordinatedUserInput = + typeof event.payload.projectBootstrapInputId === "string"; + return correlatedAttempt || activeBootstrapLifecycle || coordinatedUserInput ? { ...event, // The normalized hook envelope is attacker-controlled too: every // hook can supply `payload.session_id`. The harness session ID is the - // server-owned planner correlation key, so provider identity is not + // server-owned project bootstrap correlation key, so provider identity is not // needed in remote planner telemetry at all. agentSessionId: null, payload: telemetryPayload(event), @@ -1432,53 +2113,96 @@ export class PlannerGreetingCoordinator { } async onEventPersisted(event: AnalyticsEvent): Promise { - if (event.type !== "turn.completed") return; + if (this.closed || event.type !== "turn.completed") return; await this.serialize(event.harnessSessionId, async () => { - if (this.retiredSessions.has(event.harnessSessionId)) return; + if (this.closed) return; // Consume exactly one prompt barrier for every completion before looking // at lifecycle state. In particular, a late completion while attempt 1 // is failed must not remain queued to satisfy a later retry. const observed = this.observedAttempts.get(event.harnessSessionId); - const completedAttempt = observed?.shift(); + const completedTurn = observed?.shift(); if (observed?.length === 0) { this.observedAttempts.delete(event.harnessSessionId); } + if (completedTurn?.kind === "bootstrap") { + this.clearActiveTurn( + event.harnessSessionId, + "bootstrap", + completedTurn.id, + ); + } else if (completedTurn?.kind === "user") { + this.clearActiveTurn(event.harnessSessionId, "user", completedTurn.id); + } else if (completedTurn?.kind === "external") { + // An observed ordinary prompt plus its completion is the only safe + // evidence that raw terminal ownership has ended. A bootstrap + // completion must never release input queued behind a person's turn. + this.terminalPreemptions.delete(event.harnessSessionId); + this.reportedTerminalPreemptions.delete(event.harnessSessionId); + } const session = this.options.sessionManager.get(event.harnessSessionId); - if (!session?.planning) return; + if (!session?.projectBootstrap) return; const state = await this.load(session); if (this.correlationOverflow.delete(event.harnessSessionId)) { - if (state.metadata.greeting.status === "generating") { + if (state.metadata.bootstrap.status === "generating") { await this.setFailure( state, - state.metadata.greeting.attemptId, + state.metadata.bootstrap.attemptId, "model_turn_failed", true, ); } return; } - if (state.metadata.greeting.status !== "generating") return; - const attemptId = state.metadata.greeting.attemptId; - if (!completedAttempt) return; + if (state.metadata.bootstrap.status !== "generating") { + if ( + completedTurn?.kind === "bootstrap" && + state.metadata.bootstrap.status === "failed" && + state.metadata.bootstrap.errorCode === "delivery_timeout" && + !state.metadata.bootstrap.retryable && + state.inputs.length === 0 && + state.retryCount < MAX_RETRIES + ) { + // The timeout itself could not prove whether the submitted turn was + // still running. Its correlated completion is the missing proof: it + // is now safe to expose a bounded retry without ever overlapping the + // provider turn. + state.metadata.bootstrap = { + status: "failed", + retryable: true, + errorCode: "delivery_timeout", + }; + await this.persist(event.harnessSessionId, state); + } + // A raw terminal turn may have preempted a staged durable API input. + // Its completion is the first proof that the ordinary composer is safe + // to receive that FIFO again. + if (isTerminal(state.metadata) && state.inputs.length > 0) { + await this.drain(state); + } + return; + } + const attemptId = state.metadata.bootstrap.attemptId; + if (completedTurn?.kind !== "bootstrap") return; // Stop/turn.completed carries no attempt token. Consume correlations in // prompt-observation order: a retired older turn is a tombstone, never // evidence that the currently generating retry completed. - if (completedAttempt.retired || completedAttempt.id !== attemptId) return; + if (completedTurn.retired || completedTurn.id !== attemptId) return; const text = event.payload.assistantText; if (typeof text !== "string" || text.trim() === "") { await this.setFailure(state, attemptId, "model_turn_failed", true); return; } this.clearTimer(event.harnessSessionId, attemptId); - state.metadata.greeting = { + state.metadata.bootstrap = { status: "delivered", messageId: event.eventId, }; + this.markAttempt(state, attemptId, "completed"); await this.persist(event.harnessSessionId, state); this.clearCorrelation(event.harnessSessionId); this.emit({ - name: "planner_greeting.delivered", - projectId: state.metadata.identity.projectId, + name: "project_bootstrap.delivered", + projectId: state.metadata.projectId, sessionId: event.harnessSessionId, attemptId, queueDepth: state.inputs.length, @@ -1487,3 +2211,24 @@ export class PlannerGreetingCoordinator { }); } } + +/** + * @deprecated Rolling compatibility aliases for persisted clients and tests. + * Remove with the bounded planner HTTP aliases in SAP-3152. + */ +export { + ProjectBootstrapCoordinator as PlannerGreetingCoordinator, + ProjectBootstrapDispatchForbiddenError as PlannerDispatchForbiddenError, + ProjectBootstrapRetryUnavailableError as PlannerGreetingRetryUnavailableError, +}; +export type PlannerRegistrationMode = ProjectBootstrapRegistrationMode; + +/** @deprecated SAP-3152 removes the planner-named compatibility export. */ +export function plannerGreetingPrompt( + _emptyProject?: boolean, + retryOrdinal: number | string = 0, +): string { + return typeof retryOrdinal === "string" + ? projectBootstrapPrompt(0, retryOrdinal) + : projectBootstrapPrompt(retryOrdinal); +} diff --git a/packages/harness/src/core/planning-session.test.ts b/packages/harness/src/core/planning-session.test.ts index 6976fcbad..e8bdfd18b 100644 --- a/packages/harness/src/core/planning-session.test.ts +++ b/packages/harness/src/core/planning-session.test.ts @@ -1,28 +1,33 @@ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; - import { describe, expect, it, vi } from "vitest"; -import { AGENT_MAP_PLANNER_SESSION_START_MESSAGE } from "../profiles/agent-map-planner.js"; import type { AgentMapWorkspaceState } from "../shared/agent-map.js"; -import type { HarnessSession, SessionRecord } from "../shared/types.js"; +import type { + CreateSessionRequest, + HarnessSession, + SessionRecord, +} from "../shared/types.js"; import type { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; import { buildFocusedPlannerContext, + buildFocusedProjectContext, + isCurrentProjectRoot, isPlannerDispatchAuthorized, + isProjectSessionDispatchAuthorized, + isWithinCurrentProject, localPlanningPrincipal, - PlanningSessionError, + localProjectPrincipal, PlanningSessionService, + ProjectSessionService, + type ProjectSessionLifecycleEvent, } from "./planning-session.js"; import type { SessionManager } from "./session-manager.js"; -import { PlannerGreetingCoordinator } from "./planner-greeting.js"; import type { StudioProjectCatalog, StudioProjectIdentity, } from "./studio-project-catalog.js"; const projectId = "project_00000000-0000-4000-8000-000000000001"; +const projectRoot = "/Users/private/customer-secret-project"; const project: StudioProjectIdentity = { projectId, @@ -32,7 +37,7 @@ const project: StudioProjectIdentity = { { id: "root_00000000-0000-4000-8000-000000000001", repositoryId: "repo-private", - localRootRef: "/Users/private/customer-secret-project", + localRootRef: projectRoot, status: "active", }, ], @@ -60,87 +65,90 @@ function session( id, agentSessionId: null, harness: "codex", - cwd: project.rootBindings[0]!.localRootRef, - title: "Private research", + cwd: projectRoot, + title: "Ordinary session", status: "running", createdAt: "2026-09-01T00:00:00.000Z", lastActiveAt: "2026-09-01T00:00:00.000Z", exitCode: null, boundWorkflowPath: null, ready: false, - planning: { - identity: { projectId, sessionId: id, userId: "user-1", role: "map-planner" }, - greeting: { status: "pending" }, - queuedInputIds: [], - }, + agentMapIdentity: { projectId, sessionId: id, userId: "user-1" }, ...overrides, }; } -function fixture( - existing: HarnessSession[] = [], - initialProject: StudioProjectIdentity = project, - initialWorkspace: AgentMapWorkspaceState = workspace, -) { +interface FixtureOptions { + existing?: HarnessSession[]; + initialProject?: StudioProjectIdentity | null; + stampCreatedIdentity?: boolean; + createImpl?: ( + request: CreateSessionRequest, + createdId: string, + ) => Promise; + resumeImpl?: (id: string) => Promise; +} + +function fixture(options: FixtureOptions = {}) { + const existing = options.existing ?? []; + const created: HarnessSession[] = []; let next = 0; - let resolvedProject = initialProject; + let resolvedProject = + options.initialProject === undefined ? project : options.initialProject; let currentUserId: string | null = "user-1"; - const contexts: string[] = []; - const sessionStartMessages: Array = []; - const created: HarnessSession[] = []; - const create = vi.fn(async (request, trusted) => { + const lifecycleEvents: ProjectSessionLifecycleEvent[] = []; + const legacyRegistration = vi.fn(); + const legacyEvents = vi.fn(); + const create = vi.fn(async (request: CreateSessionRequest) => { const id = `new-${++next}`; - contexts.push(trusted.promptAppendix(id)); - sessionStartMessages.push(trusted.sessionStartSystemMessage?.(id) ?? null); - const value = session(id, { - harness: request.harness, - cwd: request.cwd, - planning: trusted.planning(id), - rehydratedFrom: - trusted.handoffFromSessionId ?? request.rehydrateFrom ?? null, - }); + const value = options.createImpl + ? await options.createImpl(request, id) + : session(id, { + cwd: request.cwd, + harness: request.harness, + ...(request.theme ? { theme: request.theme } : {}), + ...(options.stampCreatedIdentity === false + ? { agentMapIdentity: undefined } + : { + agentMapIdentity: { + projectId, + sessionId: id, + userId: localProjectPrincipal(currentUserId, "machine-1"), + }, + }), + }); created.push(value); return value; }); - const resume = vi.fn(async (id, trusted) => { - const value = existing.find((candidate) => candidate.id === id)!; - value.planning = structuredClone(trusted.planning); - contexts.push(trusted.promptAppendix); - return value; - }); - const kill = vi.fn(async (id: string) => { + const resume = vi.fn(async (id: string) => { + if (options.resumeImpl) return options.resumeImpl(id); const value = [...existing, ...created].find( (candidate) => candidate.id === id, ); - if (value) value.status = "exited"; - return Boolean(value); + if (!value) throw new Error("missing session"); + value.status = "running"; + return value; }); + const kill = vi.fn(async () => true); const manager = { create, resume, list: () => [...existing, ...created], isLive: (id: string) => [...existing, ...created].some( - (candidate) => candidate.id === id && candidate.status === "running", + (candidate) => candidate.id === id && candidate.status !== "exited", ), - get: (id: string) => [...existing, ...created].find((candidate) => candidate.id === id), - setPlanningMetadata: async ( - id: string, - metadata: NonNullable, - ) => { - const value = [...existing, ...created].find((candidate) => candidate.id === id); - if (value) value.planning = structuredClone(metadata); - }, - submitInput: vi.fn(async () => true), + get: (id: string) => + [...existing, ...created].find((candidate) => candidate.id === id), kill, } as unknown as SessionManager; - const service = new PlanningSessionService({ + const service = new ProjectSessionService({ catalog: { resolveIdentity: async (id: string) => id === projectId ? resolvedProject : null, } as unknown as StudioProjectCatalog, workspaceStore: { - readOrCreate: async () => initialWorkspace, + readOrCreate: async () => workspace, } as unknown as AgentMapWorkspaceStore, sessionManager: manager, readRecord: async () => null, @@ -148,17 +156,22 @@ function fixture( currentUserId: () => currentUserId, machineId: "machine-1", defaultHarness: "codex", + onPlannerSession: legacyRegistration, + onEvent: legacyEvents, + onProjectSessionEvent: (event) => { + lifecycleEvents.push(event); + }, }); return { service, create, resume, kill, - contexts, - sessionStartMessages, - manager, created, - setProject: (value: StudioProjectIdentity) => { + lifecycleEvents, + legacyRegistration, + legacyEvents, + setProject: (value: StudioProjectIdentity | null) => { resolvedProject = value; }, setUserId: (value: string | null) => { @@ -167,81 +180,161 @@ function fixture( }; } -describe("planner session context and identity", () => { +describe("neutral project-session compatibility exports", () => { + it("keeps planner-named APIs as aliases of the neutral implementation", () => { + expect(PlanningSessionService).toBe(ProjectSessionService); + expect(localPlanningPrincipal).toBe(localProjectPrincipal); + expect(isPlannerDispatchAuthorized).toBe( + isProjectSessionDispatchAuthorized, + ); + expect(isCurrentProjectRoot).toBe(isWithinCurrentProject); + expect(buildFocusedPlannerContext).toBe(buildFocusedProjectContext); + }); + it("uses the authenticated user or a stable machine-local principal", () => { - expect(localPlanningPrincipal("user-1", "machine-1")).toBe("user-1"); - expect(localPlanningPrincipal(null, "machine-1")).toBe("local:machine-1"); + expect(localProjectPrincipal("user-1", "machine-1")).toBe("user-1"); + expect(localProjectPrincipal(null, "machine-1")).toBe("local:machine-1"); }); +}); - it("serializes only allowlisted focused context and never a local path", () => { - const context = buildFocusedPlannerContext({ - project, - workspace, - sessionId: "session-1", - userId: "user-1", - onboardOnFirstResponse: true, - }); - expect(context).toContain(projectId); - expect(context).toContain(project.rootBindings[0]!.id); - expect(context).toContain('"role":"map-planner"'); - expect(context).toContain('"empty":true'); - expect(context).toContain("In your first response, briefly explain"); - expect(context).not.toContain("/Users/private"); - expect(context).not.toContain("private-workspace-key"); - expect(context).not.toContain("localRootRef"); - expect(context).not.toContain("prompt"); - expect(context.length).toBeLessThan(16_384); +describe("project root containment", () => { + it("accepts the root and descendants of every active binding", () => { + const multiRoot: StudioProjectIdentity = { + ...project, + rootBindings: [ + ...project.rootBindings, + { + id: "root_00000000-0000-4000-8000-000000000002", + repositoryId: "repo-secondary", + localRootRef: "/Users/private/secondary", + status: "active", + }, + ], + }; + + expect(isWithinCurrentProject(multiRoot, projectRoot)).toBe(true); + expect( + isWithinCurrentProject(multiRoot, `${projectRoot}/agents/research`), + ).toBe(true); + expect( + isWithinCurrentProject(multiRoot, "/Users/private/secondary/packages/a"), + ).toBe(true); }); - it("bounds revision summaries, proposal/build status, and warnings", () => { - const populated: AgentMapWorkspaceState = { - ...workspace, - confirmedRevisionId: "revision-1", - activeProposalId: "proposal-1", - projectBuildPlanId: "build-plan-1", + it("rejects prefix siblings, parents, inactive roots, and mixed path families", () => { + const withInactive: StudioProjectIdentity = { + ...project, + rootBindings: [ + ...project.rootBindings, + { + id: "root_00000000-0000-4000-8000-000000000003", + repositoryId: null, + localRootRef: "/Users/private/inactive", + status: "missing", + }, + ], }; - const context = buildFocusedPlannerContext({ - project, - workspace: populated, - sessionId: "session-1", - userId: "user-1", - onboardOnFirstResponse: false, - details: { - confirmedRevision: { - digest: "d".repeat(2_000), - summaries: Array.from({ length: 80 }, (_, index) => - `node-${index}-${"s".repeat(400)}`, - ), + + expect(isWithinCurrentProject(withInactive, `${projectRoot}-old`)).toBe( + false, + ); + expect(isWithinCurrentProject(withInactive, "/Users/private")).toBe(false); + expect( + isWithinCurrentProject(withInactive, "/Users/private/inactive/agent"), + ).toBe(false); + expect( + isWithinCurrentProject(withInactive, "C:\\Users\\private\\project"), + ).toBe(false); + }); + + it("normalizes Windows separators and compares on segment boundaries", () => { + const windowsProject: StudioProjectIdentity = { + ...project, + rootBindings: [ + { + ...project.rootBindings[0]!, + localRootRef: "C:\\Users\\private\\project", + }, + ], + }; + + expect( + isWithinCurrentProject( + windowsProject, + "C:/Users/private/project/agents/research", + ), + ).toBe(true); + expect( + isWithinCurrentProject(windowsProject, "C:\\Users\\private\\project-old"), + ).toBe(false); + }); +}); + +describe("role-neutral dispatch authorization", () => { + it("authorizes only the exact neutral principal inside its current project", async () => { + const ordinary = session("ordinary", { + cwd: `${projectRoot}/packages/research`, + }); + + await expect( + isProjectSessionDispatchAuthorized({ + session: ordinary, + currentPrincipal: () => "user-1", + resolveProject: async () => project, + }), + ).resolves.toBe(true); + await expect( + isProjectSessionDispatchAuthorized({ + session: ordinary, + currentPrincipal: () => "user-2", + resolveProject: async () => project, + }), + ).resolves.toBe(false); + await expect( + isProjectSessionDispatchAuthorized({ + session: session("foreign", { + agentMapIdentity: { + projectId: "project_foreign", + sessionId: "foreign", + userId: "user-1", + }, + }), + currentPrincipal: () => "user-1", + resolveProject: async () => null, + }), + ).resolves.toBe(false); + }); + + it("does not authorize planner-era metadata without a neutral identity", async () => { + const legacy = session("legacy", { + agentMapIdentity: undefined, + planning: { + identity: { + projectId, + sessionId: "legacy", + userId: "user-1", + role: "map-planner", }, - activeProposal: { status: "draft", summary: "proposal summary" }, - projectBuildPlan: { status: "pending", summary: "build summary" }, - warnings: Array.from({ length: 40 }, (_, index) => `warning-${index}`), + greeting: { status: "pending" }, + queuedInputIds: [], }, }); - const parsed = JSON.parse(context.split("\n")[2]!) as { - project: { - confirmedRevision: { digest: string; summaries: string[] }; - activeProposal: { status: string }; - projectBuildPlan: { status: string }; - warnings: string[]; - }; - }; - expect(parsed.project.confirmedRevision.digest).toHaveLength(512); - expect(parsed.project.confirmedRevision.summaries).toHaveLength(32); - expect(parsed.project.confirmedRevision.summaries[0]!.length).toBeLessThanOrEqual(256); - expect(parsed.project.activeProposal.status).toBe("draft"); - expect(parsed.project.projectBuildPlan.status).toBe("pending"); - expect(parsed.project.warnings).toHaveLength(16); - expect(context.length).toBeLessThan(16_384); - expect(context).not.toContain(project.rootBindings[0]!.localRootRef); + await expect( + isProjectSessionDispatchAuthorized({ + session: legacy, + currentPrincipal: () => "user-1", + resolveProject: async () => project, + }), + ).resolves.toBe(false); }); - it("rechecks the live principal after an awaited dispatch binding lookup", async () => { + it("rechecks principal and session identity after the project lookup await", async () => { let userId = "user-1"; + const ordinary = session("dispatch-race"); let resolveProject!: (value: StudioProjectIdentity | null) => void; - const authorization = isPlannerDispatchAuthorized({ - session: session("planner-dispatch"), + const authorization = isProjectSessionDispatchAuthorized({ + session: ordinary, currentPrincipal: () => userId, resolveProject: () => new Promise((resolve) => { @@ -250,78 +343,145 @@ describe("planner session context and identity", () => { }); await Promise.resolve(); - userId = "user-b"; + userId = "user-2"; + ordinary.agentMapIdentity = { + projectId, + sessionId: ordinary.id, + userId: "user-2", + }; resolveProject(project); await expect(authorization).resolves.toBe(false); }); }); -describe("PlanningSessionService", () => { - it("always creates a new, server-scoped planner for explicit fresh", async () => { - const { service, create, contexts, sessionStartMessages } = fixture(); - const first = await service.open(projectId, { mode: "fresh" }); - const second = await service.open(projectId, { mode: "fresh" }); - - expect(first.resolution).toBe("created"); - expect(second.session.id).not.toBe(first.session.id); - expect(create).toHaveBeenCalledTimes(2); - expect(first.session.planning).toEqual({ - identity: { - projectId, - sessionId: first.session.id, - userId: "user-1", - role: "map-planner", +describe("focused project context compatibility projection", () => { + it("is role-neutral, path-free, bounded, and ignores planner onboarding", () => { + const context = buildFocusedProjectContext({ + project, + workspace, + sessionId: "session-1", + userId: "user-1", + onboardOnFirstResponse: true, + details: { + warnings: Array.from( + { length: 40 }, + (_, index) => `warning-${index}-${"w".repeat(400)}`, + ), }, - greeting: { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [], }); - expect(contexts.every((value) => !value.includes(project.rootBindings[0]!.localRootRef))).toBe(true); - expect(contexts).toEqual([ - expect.stringContaining( - "Let the user's first real message be the first visible conversation turn", - ), - expect.stringContaining( - "Let the user's first real message be the first visible conversation turn", - ), - ]); - expect(contexts.join("\n")).not.toContain( - "This is a private Agent Studio control turn", - ); - expect(sessionStartMessages).toEqual([null, null]); + const parsed = JSON.parse(context.split("\n")[2]!) as { + identity: Record; + project: { warnings: string[] }; + }; + + expect(parsed.identity).toEqual({ + projectId, + sessionId: "session-1", + userId: "user-1", + }); + expect(parsed.project.warnings).toHaveLength(16); + expect(parsed.project.warnings[0]!.length).toBeLessThanOrEqual(256); + expect(context).not.toContain('"role"'); + expect(context).not.toContain("map-planner"); + expect(context).not.toContain("planning agent"); + expect(context).not.toContain("first response"); + expect(context).not.toContain(projectRoot); + expect(context).not.toContain("private-workspace-key"); + expect(context).not.toContain("localRootRef"); + expect(context.length).toBeLessThan(16_384); }); +}); - it("uses native Claude startup orientation without repeating it in turn one", async () => { - const { service, contexts, sessionStartMessages } = fixture(); +describe("ProjectSessionService", () => { + it("creates through the ordinary SessionManager path with no trusted override", async () => { + const { + service, + create, + lifecycleEvents, + legacyRegistration, + legacyEvents, + } = fixture(); - await service.open(projectId, { + const result = await service.open(projectId, { mode: "fresh", harness: "claude-code", + theme: "dark", }); - expect(sessionStartMessages).toEqual([ - AGENT_MAP_PLANNER_SESSION_START_MESSAGE, + expect(result).toMatchObject({ + resolution: "created", + session: { + id: "new-1", + agentMapIdentity: { + projectId, + sessionId: "new-1", + userId: "user-1", + }, + }, + }); + expect(result.session.planning).toBeUndefined(); + expect(create).toHaveBeenCalledWith({ + cwd: projectRoot, + harness: "claude-code", + theme: "dark", + }); + expect(create.mock.calls[0]).toHaveLength(1); + expect(legacyRegistration).not.toHaveBeenCalled(); + expect(legacyEvents).not.toHaveBeenCalled(); + expect(lifecycleEvents).toEqual([ + { + name: "project_session.created", + projectId, + sessionId: "new-1", + resolution: "created", + }, ]); - expect(contexts[0]).not.toContain( - "In your first response, briefly explain", - ); }); - it("does not replay first-time onboarding for an already-planned project", async () => { - const { service, contexts } = fixture([], project, { - ...workspace, - confirmedRevisionId: "revision-1", + it("uses the same deterministic outer launch root for the rolling alias", async () => { + const innerRoot = `${projectRoot}/packages/app`; + const { service, create } = fixture({ + initialProject: { + ...project, + rootBindings: [ + { + id: "root_00000000-0000-4000-8000-000000000099", + repositoryId: "repo-inner", + localRootRef: innerRoot, + status: "active", + }, + ...project.rootBindings, + ], + }, }); await service.open(projectId, { mode: "fresh" }); - expect(contexts[0]).not.toContain( - "In your first response, briefly explain", - ); + expect(create).toHaveBeenCalledWith({ + cwd: projectRoot, + harness: "codex", + }); }); - it("serializes concurrent resume-or-create so both callers resolve one planner", async () => { + it("fails closed without restamping and stops the exact unclaimed session it created", async () => { + const { service, created, kill } = fixture({ + stampCreatedIdentity: false, + }); + + await expect( + service.open(projectId, { mode: "fresh" }), + ).rejects.toMatchObject({ code: "forbidden" }); + expect(created).toHaveLength(1); + expect(created[0]!.agentMapIdentity).toBeUndefined(); + expect(created[0]!.planning).toBeUndefined(); + expect(kill).toHaveBeenCalledOnce(); + expect(kill).toHaveBeenCalledWith("new-1"); + }); + + it("serializes concurrent resume-or-create calls without duplicate creation", async () => { const { service, create } = fixture(); + const [first, second] = await Promise.all([ service.open(projectId, { mode: "resume-or-create" }), service.open(projectId, { mode: "resume-or-create" }), @@ -335,483 +495,391 @@ describe("PlanningSessionService", () => { }); }); - it("keeps the latest live owned session and rejects cross-project replay", async () => { + it("returns the most recently active live ordinary session, including a descendant cwd", async () => { const older = session("older", { + cwd: `${projectRoot}/packages/older`, lastActiveAt: "2026-09-01T01:00:00.000Z", }); const latest = session("latest", { + cwd: `${projectRoot}/packages/latest`, lastActiveAt: "2026-09-01T02:00:00.000Z", }); - const { service, create } = fixture([older, latest]); + const { service, create, resume } = fixture({ + existing: [older, latest], + }); await expect( service.open(projectId, { mode: "resume-or-create" }), - ).resolves.toMatchObject({ resolution: "live", session: { id: "latest" } }); + ).resolves.toMatchObject({ + resolution: "live", + session: { id: "latest" }, + }); expect(create).not.toHaveBeenCalled(); - await expect( - service.requireOwned("other-project", latest.id), - ).rejects.toThrow(PlanningSessionError); + expect(resume).not.toHaveBeenCalled(); }); - it("accepts a planner on any current active root, not only the launch root", async () => { - const multiRoot: StudioProjectIdentity = { - ...project, - rootBindings: [ - ...project.rootBindings, - { - id: "root_00000000-0000-4000-8000-000000000002", - repositoryId: "repo-secondary", - localRootRef: "/Users/private/secondary-root", - status: "active", + it("never adopts or restamps a cwd-only manual or planner-era session", async () => { + const manual = session("manual", { agentMapIdentity: undefined }); + const legacy = session("legacy", { + agentMapIdentity: undefined, + planning: { + identity: { + projectId, + sessionId: "legacy", + userId: "user-1", + role: "map-planner", }, - ], - }; - const secondary = session("secondary", { - cwd: "/Users/private/secondary-root", + greeting: { status: "delivered", messageId: "message-1" }, + queuedInputIds: [], + }, }); - const { service, create } = fixture([secondary], multiRoot); - - await expect( - service.open(projectId, { mode: "resume-or-create" }), - ).resolves.toMatchObject({ resolution: "live", session: { id: secondary.id } }); - await expect(service.requireOwned(projectId, secondary.id)).resolves.toBe( - secondary, - ); - expect(create).not.toHaveBeenCalled(); - }); - - it("never returns or resumes a stale-root candidate after a move and rehydrates at the current root", async () => { - const moved: StudioProjectIdentity = { - ...project, - rootBindings: project.rootBindings.map((binding) => ({ - ...binding, - localRootRef: "/Users/private/moved-project", - })), - }; - const stale = session("stale-root", { - cwd: project.rootBindings[0]!.localRootRef, - status: "running", - agentSessionId: "old-vendor", + const before = structuredClone([manual, legacy]); + const { service, create, resume, kill } = fixture({ + existing: [manual, legacy], }); - const { service, resume, create } = fixture([stale], moved); - ( - service as unknown as { - options: { readRecord: () => Promise }; - } - ).options.readRecord = async () => ({ turnCount: 1 } as SessionRecord); const result = await service.open(projectId, { mode: "resume-or-create", }); - expect(result.resolution).toBe("rehydrated"); + expect(result).toMatchObject({ resolution: "created" }); + expect(create).toHaveBeenCalledTimes(1); expect(resume).not.toHaveBeenCalled(); - expect(create).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: "/Users/private/moved-project", - rehydrateFrom: stale.id, - }), - expect.any(Object), - ); + expect(kill).not.toHaveBeenCalled(); + expect([manual, legacy]).toEqual(before); }); - it("re-resolves current bindings for every scoped operation", async () => { - const owned = session("owned-root"); - const { service, setProject } = fixture([owned]); - await expect(service.requireOwned(projectId, owned.id)).resolves.toBe(owned); + it("resumes an exited session under the same harness ID without trusted metadata or rehydration", async () => { + const prior = session("resume-me", { + status: "exited", + agentSessionId: "provider-session", + cwd: `${projectRoot}/packages/research`, + title: "User title", + boundWorkflowPath: `${projectRoot}/agents/research`, + rehydratedFrom: "older-session", + }); + const { service, create, resume, kill } = fixture({ existing: [prior] }); - setProject({ - ...project, - rootBindings: project.rootBindings.map((binding) => ({ - ...binding, - localRootRef: "/Users/private/moved-project", - })), + const result = await service.open(projectId, { + mode: "resume-or-create", }); - await expect(service.requireOwned(projectId, owned.id)).rejects.toMatchObject({ - code: "forbidden", + expect(result).toEqual({ session: prior, resolution: "resumed" }); + expect(result.session).toMatchObject({ + id: "resume-me", + agentSessionId: "provider-session", + cwd: `${projectRoot}/packages/research`, + title: "User title", + boundWorkflowPath: `${projectRoot}/agents/research`, + rehydratedFrom: "older-session", }); + expect(resume).toHaveBeenCalledWith("resume-me"); + expect(resume.mock.calls[0]).toHaveLength(1); + expect(create).not.toHaveBeenCalled(); + expect(kill).not.toHaveBeenCalled(); }); - it("isolates planners across authenticated, local, and replacement principals", async () => { - const accountA = session("account-a"); - const { service, setUserId } = fixture([accountA]); - await expect(service.requireOwned(projectId, accountA.id)).resolves.toBe(accountA); - - setUserId(null); - await expect(service.requireOwned(projectId, accountA.id)).rejects.toMatchObject({ - code: "forbidden", + it("surfaces resume failure without creating a copied session", async () => { + const prior = session("not-resumable", { + status: "exited", + agentSessionId: "provider-session", }); - const local = await service.open(projectId, { mode: "fresh" }); - expect(local.session.planning?.identity.userId).toBe("local:machine-1"); - - setUserId("user-b"); - await expect(service.requireOwned(projectId, local.session.id)).rejects.toMatchObject({ - code: "forbidden", + const failure = new Error("provider cannot resume exact session"); + const { service, create, resume, kill } = fixture({ + existing: [prior], + resumeImpl: async () => { + throw failure; + }, }); - const accountB = await service.open(projectId, { mode: "fresh" }); - expect(accountB.session.planning?.identity.userId).toBe("user-b"); + + await expect( + service.open(projectId, { mode: "resume-or-create" }), + ).rejects.toBe(failure); + expect(resume).toHaveBeenCalledWith(prior.id); + expect(create).not.toHaveBeenCalled(); + expect(kill).not.toHaveBeenCalled(); }); - it("kills a newly created planner if principal or binding changes mid-open", async () => { - const principalSwitch = fixture(); - ( - principalSwitch.service as unknown as { - options: { onPlannerSession: () => void }; - } - ).options.onPlannerSession = () => principalSwitch.setUserId(null); + it("deduplicates repeated projections of the same persisted session ID", async () => { + const first = session("duplicate", { + status: "exited", + agentSessionId: "provider-session", + }); + const repeated = structuredClone(first); + const failure = new Error("not resumable"); + const { service, resume, create } = fixture({ + existing: [first, repeated], + resumeImpl: async () => { + throw failure; + }, + }); + await expect( - principalSwitch.service.open(projectId, { mode: "fresh" }), - ).rejects.toMatchObject({ code: "forbidden" }); - expect(principalSwitch.kill).toHaveBeenCalledWith("new-1"); - - const bindingSwitch = fixture(); - ( - bindingSwitch.service as unknown as { - options: { onPlannerSession: () => void }; - } - ).options.onPlannerSession = () => - bindingSwitch.setProject({ - ...project, - rootBindings: project.rootBindings.map((binding) => ({ - ...binding, - localRootRef: "/Users/private/moved-during-open", - })), - }); + service.open(projectId, { mode: "resume-or-create" }), + ).rejects.toBe(failure); + expect(resume).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + }); + + it("rejects a resume adapter returning a different harness ID without killing either session", async () => { + const prior = session("expected", { + status: "exited", + agentSessionId: "provider-session", + }); + const replacement = session("replacement"); + const { service, create, kill } = fixture({ + existing: [prior], + resumeImpl: async () => replacement, + }); + await expect( - bindingSwitch.service.open(projectId, { mode: "fresh" }), + service.open(projectId, { mode: "resume-or-create" }), ).rejects.toMatchObject({ code: "forbidden" }); - expect(bindingSwitch.kill).toHaveBeenCalledWith("new-1"); + expect(create).not.toHaveBeenCalled(); + expect(kill).not.toHaveBeenCalled(); }); - it("reattaches focused context and suppresses onboarding on vendor resume", async () => { - const prior = session("resume-me", { + it("revalidates scope after resume, stops that exact process, and never starts a second candidate", async () => { + const newest = session("newest", { status: "exited", - agentSessionId: "vendor-session", + agentSessionId: "provider-newest", + lastActiveAt: "2026-09-01T02:00:00.000Z", + }); + const older = session("older", { + status: "exited", + agentSessionId: "provider-older", + lastActiveAt: "2026-09-01T01:00:00.000Z", + }); + const setup = fixture({ + existing: [older, newest], + resumeImpl: async (id) => { + setup.setProject({ + ...project, + rootBindings: project.rootBindings.map((binding) => ({ + ...binding, + localRootRef: "/Users/private/moved-during-resume", + })), + }); + const resumed = id === newest.id ? newest : older; + resumed.status = "running"; + return resumed; + }, }); - const { service, resume, contexts } = fixture([prior]); - const result = await service.open(projectId, { mode: "resume-or-create" }); + await expect( + setup.service.open(projectId, { mode: "resume-or-create" }), + ).rejects.toMatchObject({ code: "forbidden" }); + expect(setup.resume).toHaveBeenCalledTimes(1); + expect(setup.resume).toHaveBeenCalledWith(newest.id); + expect(setup.create).not.toHaveBeenCalled(); + expect(setup.kill).toHaveBeenCalledOnce(); + expect(setup.kill).toHaveBeenCalledWith(newest.id); + }); - expect(result.resolution).toBe("resumed"); - expect(resume).toHaveBeenCalledTimes(1); - expect(result.session.planning?.greeting).toEqual({ - status: "skipped", - reason: "user-proceeded", + it("stops the exact resumed process when the trusted principal changes during resume", async () => { + const prior = session("prior", { + status: "exited", + agentSessionId: "provider-prior", }); - expect(contexts[0]).toContain(projectId); - expect(contexts[0]).not.toContain( - "In your first response, briefly explain", - ); - expect(contexts[0]).not.toContain(project.rootBindings[0]!.localRootRef); + const setup = fixture({ + existing: [prior], + resumeImpl: async () => { + prior.status = "running"; + setup.setUserId("user-2"); + return prior; + }, + }); + + await expect( + setup.service.open(projectId, { mode: "resume-or-create" }), + ).rejects.toMatchObject({ code: "forbidden" }); + expect(setup.resume).toHaveBeenCalledOnce(); + expect(setup.kill).toHaveBeenCalledOnce(); + expect(setup.kill).toHaveBeenCalledWith(prior.id); + expect(setup.create).not.toHaveBeenCalled(); }); - it("rehydrates recorded history when vendor resume is unavailable", async () => { - const prior = session("recorded", { + it("does not copy a project-owned session whose cwd is outside current bindings", async () => { + const stale = session("stale-root", { + cwd: "/Users/private/old-project-root", status: "exited", - agentSessionId: "stale-vendor-session", + agentSessionId: "provider-session", }); - const { service, resume, create, contexts, sessionStartMessages } = fixture([ - prior, - ]); - resume.mockRejectedValueOnce(new Error("not resumable")); - (service as unknown as { options: { readRecord: () => Promise } }).options.readRecord = - async () => ({ turnCount: 1 } as SessionRecord); + const { service, create, resume, kill } = fixture({ existing: [stale] }); - const result = await service.open(projectId, { mode: "resume-or-create" }); + await expect( + service.open(projectId, { mode: "resume-or-create" }), + ).rejects.toMatchObject({ code: "forbidden" }); + expect(create).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(kill).not.toHaveBeenCalled(); + }); - expect(result.resolution).toBe("rehydrated"); - expect(create).toHaveBeenCalledWith( - expect.objectContaining({ rehydrateFrom: prior.id }), - expect.any(Object), - ); - expect(result.session.planning?.greeting.status).toBe("skipped"); - expect(contexts[0]).not.toContain( - "In your first response, briefly explain", + it("re-resolves current scope for every requireOwned call", async () => { + const owned = session("owned", { cwd: `${projectRoot}/packages/a` }); + const { service, setProject } = fixture({ existing: [owned] }); + + await expect(service.requireOwned(projectId, owned.id)).resolves.toBe( + owned, ); - expect(sessionStartMessages).toEqual([null]); + setProject({ + ...project, + rootBindings: project.rootBindings.map((binding) => ({ + ...binding, + localRootRef: "/Users/private/moved-project", + })), + }); + await expect( + service.requireOwned(projectId, owned.id), + ).rejects.toMatchObject({ code: "forbidden" }); }); - it("hands a restarted pre-ready FIFO to a rehydrated planner exactly once and retires the old queue", async () => { - const prior = session("queued-predecessor", { - status: "exited", - ready: false, - agentSessionId: "missing-vendor-history", + it("rejects malformed, foreign-project, and foreign-user principals", async () => { + const malformed = session("malformed", { + agentMapIdentity: { + projectId, + sessionId: "different-id", + userId: "user-1", + }, }); - const { service, resume, manager } = fixture([prior]); - const root = await fs.mkdtemp(path.join(os.tmpdir(), "planner-handoff-")); - const beforeRestart = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, + const foreignProject = session("foreign-project", { + agentMapIdentity: { + projectId: "project_foreign", + sessionId: "foreign-project", + userId: "user-1", + }, + }); + const foreignUser = session("foreign-user", { + agentMapIdentity: { + projectId, + sessionId: "foreign-user", + userId: "user-2", + }, }); - await beforeRestart.register(prior, { - emptyProject: true, - mode: "created", + const { service } = fixture({ + existing: [malformed, foreignProject, foreignUser], }); - await beforeRestart.enqueue(prior.id, "queued before readiness"); - // New coordinator instance is the process-restart boundary. Vendor resume - // fails, so PlanningSessionService creates a replacement with - // rehydratedFrom=prior.id and registration performs the durable handoff. - const afterRestart = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - resume.mockRejectedValueOnce(new Error("vendor history unavailable")); - const options = ( - service as unknown as { - options: { - readRecord: () => Promise; - onPlannerSession: PlanningSessionService["options"]["onPlannerSession"]; - }; - } - ).options; - options.readRecord = async () => ({ turnCount: 1 } as SessionRecord); - options.onPlannerSession = (value, context) => - afterRestart.register(value, context); - - try { - const result = await service.open(projectId, { - mode: "resume-or-create", - }); - expect(result.resolution).toBe("rehydrated"); - result.session.ready = true; - await afterRestart.onSessionStatus(result.session); - - expect(manager.submitInput).toHaveBeenCalledTimes(1); - expect(manager.submitInput).toHaveBeenCalledWith( - result.session.id, - "queued before readiness", - true, - expect.any(Function), - ); - const replacementQueue = JSON.parse( - await fs.readFile( - path.join(root, result.session.id, "input-queue.json"), - "utf8", - ), - ) as { inputs: unknown[] }; - await expect( - fs.access(path.join(root, prior.id, "input-queue.json")), - ).rejects.toMatchObject({ code: "ENOENT" }); - expect(replacementQueue.inputs).toEqual([]); - - // A full later boot registers the historical predecessor first, which - // recreates its empty queue file. Registering the successor must detect - // its already-committed target directory and never rename the recreated - // source over it. - const secondBoot = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await secondBoot.register(prior, { emptyProject: true, mode: "boot" }); - await expect( - secondBoot.register(result.session, { - emptyProject: true, - mode: "boot", - }), - ).resolves.toBeUndefined(); - await secondBoot.onSessionStatus(result.session); - expect(manager.submitInput).toHaveBeenCalledTimes(1); - const durableAfterSecondBoot = JSON.parse( - await fs.readFile( - path.join(root, result.session.id, "input-queue.json"), - "utf8", - ), - ) as { inputs: unknown[] }; - expect(durableAfterSecondBoot.inputs).toEqual([]); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } + await expect( + service.requireOwned(projectId, malformed.id), + ).rejects.toMatchObject({ code: "forbidden" }); + await expect( + service.requireOwned(projectId, foreignProject.id), + ).rejects.toMatchObject({ code: "forbidden" }); + await expect( + service.requireOwned(projectId, foreignUser.id), + ).rejects.toMatchObject({ code: "forbidden" }); }); - it.each(["atomic-move", "canonical-rewrite"] as const)( - "keeps exactly one FIFO successor when the %s boundary fails", - async (failurePoint) => { - const prior = session("handoff-fault-source", { - status: "exited", - ready: false, - agentSessionId: "missing-vendor-history", - }); - const { service, resume, manager } = fixture([prior]); - const root = await fs.mkdtemp(path.join(os.tmpdir(), "planner-handoff-fault-")); - const seed = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await seed.register(prior, { emptyProject: true, mode: "created" }); - await seed.enqueue(prior.id, "survive handoff fault"); - - let rejectMove = failurePoint === "atomic-move"; - const firstSuccessor = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - moveStateDirectory: async (source, target) => { - if (rejectMove) { - rejectMove = false; - throw new Error("injected atomic handoff failure"); - } - await fs.rename(source, target); - }, - ...(failurePoint === "canonical-rewrite" - ? { - writeState: async (file: string, value: unknown) => { - if (file.includes(`${path.sep}new-1${path.sep}`)) { - throw new Error("injected canonical rewrite failure"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, - } - : {}), - }); - resume.mockRejectedValue(new Error("vendor history unavailable")); - const options = ( - service as unknown as { - options: { - readRecord: (id: string) => Promise; - onPlannerSession: PlanningSessionService["options"]["onPlannerSession"]; - }; - } - ).options; - options.readRecord = async (id) => - id === prior.id ? ({ turnCount: 1 } as SessionRecord) : null; - options.onPlannerSession = (value, context) => - firstSuccessor.register(value, context); - - try { - if (failurePoint === "atomic-move") { - await expect( - service.open(projectId, { mode: "resume-or-create" }), - ).rejects.toThrow("injected atomic handoff failure"); - } else { - const first = await service.open(projectId, { - mode: "resume-or-create", - }); - // Exit before readiness. A same-process reopen must follow this exact - // queue-owning successor, even though its canonical rewrite failed. - await manager.kill(first.session.id); - } - - const finalCoordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - options.onPlannerSession = (value, context) => - finalCoordinator.register(value, context); - const result = await service.open(projectId, { - mode: "resume-or-create", - }); - expect(result).toMatchObject({ - resolution: "rehydrated", - session: { - id: "new-2", - rehydratedFrom: - failurePoint === "canonical-rewrite" ? "new-1" : prior.id, + it("revalidates the principal after an awaited create and stops only that stale-principal session", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let harnessSession!: HarnessSession; + const setup = fixture({ + createImpl: async (request, id) => { + await gate; + harnessSession = session(id, { + cwd: request.cwd, + agentMapIdentity: { + projectId, + sessionId: id, + userId: "user-1", }, }); - expect(result.session.planning?.queuedInputIds).toHaveLength(1); - result.session.ready = true; - await finalCoordinator.onSessionStatus(result.session); - - expect(manager.submitInput).toHaveBeenCalledTimes(1); - expect(manager.submitInput).toHaveBeenCalledWith( - result.session.id, - "survive handoff fault", - true, - expect.any(Function), - ); - await expect( - fs.access(path.join(root, prior.id, "input-queue.json")), - ).rejects.toMatchObject({ code: "ENOENT" }); - await expect( - fs.access(path.join(root, "new-1", "input-queue.json")), - ).rejects.toMatchObject({ code: "ENOENT" }); - const durable = JSON.parse( - await fs.readFile( - path.join(root, result.session.id, "input-queue.json"), - "utf8", - ), - ) as { inputs: unknown[] }; - expect(durable.inputs).toEqual([]); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }, - ); + return harnessSession; + }, + }); + const opening = setup.service.open(projectId, { mode: "fresh" }); - it("rehydrates a delivered greeting-only record without generating a duplicate", async () => { - const prior = session("greeting-only", { - status: "exited", - agentSessionId: "missing-vendor-history", + await vi.waitFor(() => expect(setup.create).toHaveBeenCalledTimes(1)); + setup.setUserId("user-2"); + release(); + + await expect(opening).rejects.toMatchObject({ code: "forbidden" }); + expect(harnessSession.agentMapIdentity?.userId).toBe("user-1"); + expect(setup.kill).toHaveBeenCalledOnce(); + expect(setup.kill).toHaveBeenCalledWith(harnessSession.id); + }); + + it("revalidates identity and project bindings after awaited creation and stops the created session", async () => { + const setup = fixture({ + createImpl: async (request, id) => { + setup.setProject({ + ...project, + rootBindings: project.rootBindings.map((binding) => ({ + ...binding, + localRootRef: "/Users/private/moved-during-create", + })), + }); + return session(id, { cwd: request.cwd }); + }, }); - prior.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - const { service, resume, manager } = fixture([prior]); - resume.mockRejectedValueOnce(new Error("vendor history unavailable")); - const record: SessionRecord = { - harnessSessionId: prior.id, - mergedSessionIds: [prior.id], - agentSessionId: prior.agentSessionId, - harness: prior.harness, - cwd: null, - startedAt: "2026-09-01T00:00:00.000Z", - endedAt: "2026-09-01T00:01:00.000Z", - turns: [ - { - index: 1, - prompt: null, - promptAt: null, - toolCalls: [], - assistantText: "What system should we plan together?", - model: null, - usage: null, - completedAt: "2026-09-01T00:00:30.000Z", - incomplete: false, - }, - ], - turnCount: 0, - eventCount: 2, - reconstructed: true, - archivedAt: null, - limitations: [], - }; - const root = await fs.mkdtemp(path.join(os.tmpdir(), "planner-wired-")); - const coordinator = new PlannerGreetingCoordinator({ - root, + + await expect( + setup.service.open(projectId, { mode: "fresh" }), + ).rejects.toMatchObject({ code: "forbidden" }); + expect(setup.created).toHaveLength(1); + expect(setup.kill).toHaveBeenCalledOnce(); + expect(setup.kill).toHaveBeenCalledWith(setup.created[0]!.id); + }); + + it("fails with bounded errors for missing projects, roots, and sessions", async () => { + const missingProject = fixture({ initialProject: null }); + await expect( + missingProject.service.open(projectId, { mode: "fresh" }), + ).rejects.toMatchObject({ code: "project_not_found" }); + + const missingRoot = fixture({ + initialProject: { + ...project, + rootBindings: project.rootBindings.map((binding) => ({ + ...binding, + status: "missing", + })), + }, + }); + await expect( + missingRoot.service.open(projectId, { mode: "fresh" }), + ).rejects.toMatchObject({ code: "project_launch_unavailable" }); + + const ordinary = fixture(); + await expect( + ordinary.service.requireOwned(projectId, "missing"), + ).rejects.toMatchObject({ code: "session_not_found" }); + }); + + it("retains the deprecated constructor/API shape without reading legacy stores", async () => { + const readRecord = vi.fn(async () => null as SessionRecord | null); + const readWorkspace = vi.fn(async () => workspace); + const manager = { + create: vi.fn(async (request: CreateSessionRequest) => + session("created", { cwd: request.cwd }), + ), + resume: vi.fn(), + list: () => [], + isLive: () => false, + get: () => undefined, + } as unknown as SessionManager; + const legacy = new PlanningSessionService({ + catalog: { + resolveIdentity: async () => project, + } as unknown as StudioProjectCatalog, + workspaceStore: { + readOrCreate: readWorkspace, + } as unknown as AgentMapWorkspaceStore, sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - const options = ( - service as unknown as { - options: { - readRecord: () => Promise; - onPlannerSession: PlanningSessionService["options"]["onPlannerSession"]; - }; - } - ).options; - options.readRecord = async () => record; - options.onPlannerSession = (value, context) => - coordinator.register(value, context); - - try { - const result = await service.open(projectId, { mode: "resume-or-create" }); - expect(result.resolution).toBe("rehydrated"); - expect(result.session.planning?.greeting).toEqual({ - status: "delivered", - messageId: "greeting-message", - }); - expect(manager.submitInput).not.toHaveBeenCalled(); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } + readRecord, + userId: "user-1", + machineId: "machine-1", + defaultHarness: "codex", + }); + + await expect( + legacy.open(projectId, { mode: "fresh" }), + ).resolves.toMatchObject({ resolution: "created" }); + expect(readRecord).not.toHaveBeenCalled(); + expect(readWorkspace).not.toHaveBeenCalled(); }); }); diff --git a/packages/harness/src/core/planning-session.ts b/packages/harness/src/core/planning-session.ts index 8d6e3f87b..22c741c6d 100644 --- a/packages/harness/src/core/planning-session.ts +++ b/packages/harness/src/core/planning-session.ts @@ -1,10 +1,11 @@ +import * as path from "node:path"; + import type { AgentMapWorkspaceState, PlannerLifecycleEvent, - PlannerGreetingState, PlannerSessionRequest, PlannerSessionResponse, - PlannerSessionMetadata, + ProjectAgentSession, StudioProjectId, } from "../shared/agent-map.js"; import type { @@ -13,16 +14,16 @@ import type { SessionRecord, } from "../shared/types.js"; import type { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { preferredProjectRoot } from "../shared/project-roots.js"; +import { canonicalGraphPath } from "./canonical-graph-path.js"; +import type { PlannerRegistrationMode } from "./planner-greeting.js"; import type { SessionManager } from "./session-manager.js"; import type { StudioProjectCatalog, StudioProjectIdentity, } from "./studio-project-catalog.js"; -import type { PlannerRegistrationMode } from "./planner-greeting.js"; -import { canonicalGraphPath } from "./canonical-graph-path.js"; -import { AGENT_MAP_PLANNER_SESSION_START_MESSAGE } from "../profiles/agent-map-planner.js"; -export interface PlannerFocusedContextDetails { +export interface FocusedProjectContextDetails { confirmedRevision?: { digest?: string | null; summaries?: readonly string[]; @@ -38,29 +39,60 @@ export interface PlannerFocusedContextDetails { warnings?: readonly string[]; } -export interface PlanningSessionServiceOptions { +/** + * @deprecated Compatibility name for callers compiled against the E1 planner + * service. Focused data is context only and never selects a role or authority. + */ +export type PlannerFocusedContextDetails = FocusedProjectContextDetails; + +export interface ProjectSessionLifecycleEvent { + name: "project_session.created" | "project_session.resumed"; + projectId: StudioProjectId; + sessionId: string; + resolution: Exclude; +} + +export interface ProjectSessionServiceOptions { catalog: StudioProjectCatalog; - workspaceStore: AgentMapWorkspaceStore; + /** @deprecated Retained for the bounded planner-route compatibility API. */ + workspaceStore?: AgentMapWorkspaceStore; sessionManager: SessionManager; - readRecord: (id: string) => Promise; + /** @deprecated Rehydration is intentionally unsupported by this service. */ + readRecord?: (id: string) => Promise; userId: string | null; /** Live authenticated identity. When omitted, `userId` remains the static * principal for tests/embedded callers. */ currentUserId?: () => string | null; machineId: string; defaultHarness: CreateSessionRequest["harness"]; + /** @deprecated Focused context is composed by the ordinary session path. */ readFocusedContext?: ( projectId: StudioProjectId, workspace: AgentMapWorkspaceState, - ) => Promise; + ) => Promise; + /** + * @deprecated Project bootstrap registration is owned by SessionManager's + * ordinary create/resume preparation path. This callback is retained only + * so rolling server code remains source-compatible. + */ onPlannerSession?: ( session: HarnessSession, context: { emptyProject: boolean; mode: PlannerRegistrationMode }, ) => Promise | void; + onProjectSessionEvent?: ( + event: ProjectSessionLifecycleEvent, + ) => Promise | void; + /** + * @deprecated Planner-named telemetry is no longer emitted. Remove this + * rolling-compatibility option with the planner HTTP aliases in SAP-3152. + */ onEvent?: (event: PlannerLifecycleEvent) => Promise | void; } -export class PlanningSessionError extends Error { +/** @deprecated Use ProjectSessionServiceOptions. */ +export type PlanningSessionServiceOptions = ProjectSessionServiceOptions; + +export class ProjectSessionError extends Error { constructor( readonly code: | "project_not_found" @@ -69,78 +101,137 @@ export class PlanningSessionError extends Error { | "forbidden", ) { super(code.replace(/_/g, " ")); - this.name = "PlanningSessionError"; + this.name = "ProjectSessionError"; } } -export function localPlanningPrincipal( +export function localProjectPrincipal( userId: string | null, machineId: string, ): string { return userId ?? `local:${machineId}`; } +/** @deprecated Use localProjectPrincipal. */ +export const localPlanningPrincipal = localProjectPrincipal; + function launchRoot(project: StudioProjectIdentity): string { - const binding = project.rootBindings.find((entry) => entry.status === "active"); - if (!binding) throw new PlanningSessionError("project_launch_unavailable"); - return binding.localRootRef; + const root = preferredProjectRoot( + project.rootBindings + .filter((entry) => entry.status === "active") + .map((entry) => entry.localRootRef), + ); + if (!root) throw new ProjectSessionError("project_launch_unavailable"); + return root; } -export function isCurrentProjectRoot( +function isWindowsPath(value: string): boolean { + return ( + /^[A-Za-z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value) + ); +} + +function isWithinRoot(root: string, candidate: string): boolean { + if (root.trim() === "" || candidate.trim() === "") return false; + try { + const canonicalRoot = canonicalGraphPath(root); + const canonicalCandidate = canonicalGraphPath(candidate); + const windows = isWindowsPath(canonicalRoot); + if (windows !== isWindowsPath(canonicalCandidate)) return false; + const api = windows ? path.win32 : path.posix; + const relative = api.relative(canonicalRoot, canonicalCandidate); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${api.sep}`) && + !api.isAbsolute(relative)) + ); + } catch { + return false; + } +} + +/** + * Whether a session cwd is equal to or descends from a current active project + * root. Durable project identity remains the authority boundary; containment + * is an additional server-side launch/resume safety check. + */ +export function isWithinCurrentProject( project: StudioProjectIdentity, cwd: string, ): boolean { - const candidate = canonicalGraphPath(cwd); return project.rootBindings.some( (binding) => - binding.status === "active" && - canonicalGraphPath(binding.localRootRef) === candidate, + binding.status === "active" && isWithinRoot(binding.localRootRef, cwd), + ); +} + +/** @deprecated Use isWithinCurrentProject. */ +export const isCurrentProjectRoot = isWithinCurrentProject; + +function samePrincipal( + identity: ProjectAgentSession | null | undefined, + expected: ProjectAgentSession, +): boolean { + return Boolean( + identity && + identity.projectId === expected.projectId && + identity.userId === expected.userId && + identity.sessionId === expected.sessionId, ); } -export async function isPlannerDispatchAuthorized(input: { +export async function isProjectSessionDispatchAuthorized(input: { session: HarnessSession; currentPrincipal: () => string; resolveProject: ( projectId: StudioProjectId, ) => Promise; }): Promise { - const identity = input.session.planning?.identity; - const expectedPrincipal = input.currentPrincipal(); - if (!identity || identity.userId !== expectedPrincipal) return false; - const project = await input.resolveProject(identity.projectId); + const identity = input.session.agentMapIdentity; + if (!identity || identity.sessionId !== input.session.id) return false; + const expected: ProjectAgentSession = { + projectId: identity.projectId, + sessionId: identity.sessionId, + userId: identity.userId, + }; + if (input.currentPrincipal() !== expected.userId) return false; + let project: StudioProjectIdentity | null; + try { + project = await input.resolveProject(expected.projectId); + } catch { + return false; + } return Boolean( project && - input.currentPrincipal() === expectedPrincipal && - isCurrentProjectRoot(project, input.session.cwd), + input.currentPrincipal() === expected.userId && + input.session.id === expected.sessionId && + samePrincipal(input.session.agentMapIdentity, expected) && + isWithinCurrentProject(project, input.session.cwd), ); } -function isTerminalGreeting(value: PlannerGreetingState): boolean { - return value.status === "delivered" || value.status === "skipped"; -} +/** @deprecated Use isProjectSessionDispatchAuthorized. */ +export const isPlannerDispatchAuthorized = isProjectSessionDispatchAuthorized; -function planningFor( - projectId: StudioProjectId, - sessionId: string, - userId: string, - greeting: PlannerGreetingState, -): PlannerSessionMetadata { - return { - identity: { projectId, sessionId, userId, role: "map-planner" }, - greeting, - queuedInputIds: [], - }; -} - -export function buildFocusedPlannerContext(input: { +export interface FocusedProjectContextInput { project: StudioProjectIdentity; workspace: AgentMapWorkspaceState; sessionId: string; userId: string; - onboardOnFirstResponse: boolean; - details?: PlannerFocusedContextDetails; -}): string { + /** @deprecated Ignored. Bootstrap is a durable lifecycle action. */ + onboardOnFirstResponse?: boolean; + details?: FocusedProjectContextDetails; +} + +/** + * Path-free, role-neutral context projection retained for compatibility. + * Ordinary sessions receive the common project-agent profile through the + * central SessionManager launch path; this projection never changes it. + */ +export function buildFocusedProjectContext( + input: FocusedProjectContextInput, +): string { const { project, workspace } = input; const bounded = (value: string, max = 256): string => value.slice(0, max); const details = input.details ?? {}; @@ -153,7 +244,6 @@ export function buildFocusedPlannerContext(input: { projectId: project.projectId, sessionId: input.sessionId, userId: input.userId, - role: "map-planner" as const, }, project: { displayName: bounded(project.displayName), @@ -191,61 +281,46 @@ export function buildFocusedPlannerContext(input: { : null, } : null, - bindingRefs: project.rootBindings.slice(0, 64).map(({ id, repositoryId, status }) => ({ - id: bounded(id), - repositoryId: repositoryId ? bounded(repositoryId) : null, - status, - })), + bindingRefs: project.rootBindings + .slice(0, 64) + .map(({ id, repositoryId, status }) => ({ + id: bounded(id), + repositoryId: repositoryId ? bounded(repositoryId) : null, + status, + })), warnings: (details.warnings ?? []) .slice(0, 16) .map((warning) => bounded(warning)), }, }; return [ - "", - `This is focused, trusted Studio context. Treat IDs as references and use scoped tools for detail. Use agent_map_read, agent_map_validate, and agent_map_propose for architecture state; never infer map state from assistant prose. The interactive Claude Code transcript is user-visible. Let the user's first real message be the first visible conversation turn; never request or rely on a private control turn.${input.onboardOnFirstResponse ? " In your first response, briefly explain that you and the user can plan agents, responsibilities, data flow, resources, and connectors together, then respond to their request." : ""} Do not propose architecture or invoke mutation tools before the user asks you to.`, + "", + "This is bounded, server-derived Studio project context. References and bootstrap state are context only; they never change tools, filesystem policy, or implementation authority. Read authoritative architecture through the structured Agent Map tools when relevant.", JSON.stringify(context), - "", + "", ].join("\n"); } +/** @deprecated Use buildFocusedProjectContext. */ +export const buildFocusedPlannerContext = buildFocusedProjectContext; + function candidateOrder(left: HarnessSession, right: HarnessSession): number { const live = (session: HarnessSession): number => session.status === "exited" ? 0 : 1; - const queued = (session: HarnessSession): number => - session.planning?.queuedInputIds.length ? 1 : 0; return ( live(right) - live(left) || - queued(right) - queued(left) || right.lastActiveAt.localeCompare(left.lastActiveAt) || left.id.localeCompare(right.id) ); } -function recordSupportsRehydration( - record: SessionRecord | null, - greeting: PlannerGreetingState, -): boolean { - if (!record) return false; - if (record.turnCount > 0) return true; - return Boolean( - greeting.status === "delivered" && - record.turns?.some( - (turn) => - turn.prompt === null && - typeof turn.assistantText === "string" && - turn.assistantText.trim() !== "", - ), - ); -} - -export class PlanningSessionService { +export class ProjectSessionService { private readonly projectOpens = new Map>(); - constructor(private readonly options: PlanningSessionServiceOptions) {} + constructor(private readonly options: ProjectSessionServiceOptions) {} private currentPrincipal(): string { - return localPlanningPrincipal( + return localProjectPrincipal( this.options.currentUserId ? this.options.currentUserId() : this.options.userId, @@ -255,29 +330,32 @@ export class PlanningSessionService { private assertPrincipal(expected: string): void { if (this.currentPrincipal() !== expected) { - throw new PlanningSessionError("forbidden"); + throw new ProjectSessionError("forbidden"); } } - private emit(event: PlannerLifecycleEvent): void { + private async guarded( + principal: string, + operation: () => Promise, + ): Promise { + this.assertPrincipal(principal); try { - void Promise.resolve(this.options.onEvent?.(event)).catch(() => {}); - } catch { - // Lifecycle telemetry is best effort and content-free. + const result = await operation(); + this.assertPrincipal(principal); + return result; + } catch (error) { + this.assertPrincipal(principal); + throw error; } } - private async focusedDetails( - project: StudioProjectIdentity, - workspace: AgentMapWorkspaceState, - ): Promise { + private emit(event: ProjectSessionLifecycleEvent): void { try { - return await this.options.readFocusedContext?.( - project.projectId, - workspace, + void Promise.resolve(this.options.onProjectSessionEvent?.(event)).catch( + () => {}, ); } catch { - return { warnings: ["focused_context_unavailable"] }; + // Lifecycle telemetry is best effort and content-free. } } @@ -286,109 +364,79 @@ export class PlanningSessionService { projectId: StudioProjectId, principal = this.currentPrincipal(), ): boolean { - const identity = session.planning?.identity; + const identity = session.agentMapIdentity; return Boolean( identity && - identity.role === "map-planner" && - identity.sessionId === session.id && - identity.projectId === projectId && - identity.userId === principal, + identity.sessionId === session.id && + identity.projectId === projectId && + identity.userId === principal, ); } - private async project(projectId: StudioProjectId): Promise { - const project = await this.options.catalog.resolveIdentity(projectId); - if (!project) throw new PlanningSessionError("project_not_found"); + private async project( + projectId: StudioProjectId, + principal: string, + ): Promise { + const project = await this.guarded(principal, () => + this.options.catalog.resolveIdentity(projectId), + ); + if (!project) throw new ProjectSessionError("project_not_found"); return project; } private async assertRunnable( projectId: StudioProjectId, - cwd: string, + session: HarnessSession, principal: string, ): Promise { this.assertPrincipal(principal); - const current = await this.project(projectId); + if (!this.owns(session, projectId, principal)) { + throw new ProjectSessionError("forbidden"); + } + const current = await this.project(projectId, principal); this.assertPrincipal(principal); - if (!isCurrentProjectRoot(current, cwd)) { - throw new PlanningSessionError("forbidden"); + if ( + !this.owns(session, projectId, principal) || + !isWithinCurrentProject(current, session.cwd) + ) { + throw new ProjectSessionError("forbidden"); } } private async create( - project: StudioProjectIdentity, + projectId: StudioProjectId, request: PlannerSessionRequest, - greeting: PlannerGreetingState, - rehydrateFrom?: string, - mode: "created" | "rehydrated" = "created", - principal = this.currentPrincipal(), - handoffFromSessionId = rehydrateFrom, + principal: string, ): Promise { - const workspace = await this.options.workspaceStore.readOrCreate( - project.projectId, - ); - const emptyProject = - workspace.confirmedRevisionId === null && - workspace.activeProposalId === null && - workspace.projectBuildPlanId === null; - const harness = request.harness ?? this.options.defaultHarness; - const cwd = launchRoot(project); - const details = await this.focusedDetails(project, workspace); - const session = await this.options.sessionManager.create( - { - cwd, - harness, - ...(request.theme ? { theme: request.theme } : {}), - ...(rehydrateFrom ? { rehydrateFrom } : {}), - }, - { - planning: (sessionId) => - planningFor(project.projectId, sessionId, principal, greeting), - promptAppendix: (sessionId) => - buildFocusedPlannerContext({ - project, - workspace, - sessionId, - userId: principal, - // Claude gets native SessionStart orientation before turn one. - // Other CLIs retain the hidden first-response instruction until - // they expose an equivalent display-only startup channel. - onboardOnFirstResponse: - mode === "created" && emptyProject && harness !== "claude-code", - ...(details ? { details } : {}), - }), - ...(mode === "created" && harness === "claude-code" - ? { - sessionStartSystemMessage: () => - AGENT_MAP_PLANNER_SESSION_START_MESSAGE, - } - : {}), - ...(handoffFromSessionId ? { handoffFromSessionId } : {}), - }, - ); - if (this.currentPrincipal() !== principal) { - // Authentication changed while the process was being spawned. Do not - // return a planner minted for the old principal into the new caller's - // request; terminate the just-created PTY before failing closed. - await this.options.sessionManager.kill(session.id).catch(() => false); - throw new PlanningSessionError("forbidden"); - } - this.emit({ - name: mode === "created" ? "planner_session.created" : "planner_session.resumed", - projectId: project.projectId, - sessionId: session.id, - resolution: mode, - }); + const project = await this.project(projectId, principal); + let session: HarnessSession | undefined; try { - await this.options.onPlannerSession?.(session, { - emptyProject, - mode, + this.assertPrincipal(principal); + session = await this.options.sessionManager.create({ + cwd: launchRoot(project), + harness: request.harness ?? this.options.defaultHarness, + ...(request.theme ? { theme: request.theme } : {}), }); - await this.assertRunnable(project.projectId, session.cwd, principal); + this.assertPrincipal(principal); + // The central ordinary-session path derives the neutral identity. This + // compatibility service never stamps a missing identity after the fact. + await this.assertRunnable(projectId, session, principal); } catch (error) { - await this.options.sessionManager.kill(session.id).catch(() => false); + // This route owns only the session it just created. If authorization + // changes during the awaited launch, stop that exact process without + // touching pre-existing/manual project sessions. + if (session && this.options.sessionManager.isLive(session.id)) { + await this.options.sessionManager.kill(session.id).catch(() => false); + } + this.assertPrincipal(principal); throw error; } + this.emit({ + name: "project_session.created", + projectId, + sessionId: session.id, + resolution: "created", + }); return session; } @@ -408,30 +456,6 @@ export class PlanningSessionService { return next; } - private async rehydrationHistorySource( - candidate: HarnessSession, - projectId: StudioProjectId, - principal: string, - ): Promise { - const visited = new Set(); - let current: HarnessSession | undefined = candidate; - while (current && !visited.has(current.id) && visited.size < 32) { - visited.add(current.id); - const record = await this.options.readRecord(current.id).catch(() => null); - if (recordSupportsRehydration(record, current.planning!.greeting)) { - return current.id; - } - const predecessorId = current.rehydratedFrom; - if (!predecessorId) return undefined; - const predecessor = this.options.sessionManager.get(predecessorId); - if (!predecessor || !this.owns(predecessor, projectId, principal)) { - return undefined; - } - current = predecessor; - } - return undefined; - } - open( projectId: StudioProjectId, request: PlannerSessionRequest, @@ -446,152 +470,97 @@ export class PlanningSessionService { request: PlannerSessionRequest, ): Promise { const principal = this.currentPrincipal(); - const project = await this.project(projectId); - this.assertPrincipal(principal); - // Validate that a launch target exists even when an existing candidate is - // reused. Candidate eligibility itself spans every active project root. + const project = await this.project(projectId, principal); + // Preserve the bounded compatibility endpoint's launch error while all + // actual creation remains on SessionManager's ordinary path. launchRoot(project); if (request.mode === "fresh") { return { - session: await this.create( - project, - request, - // Claude Code has no hidden assistant-first turn. A pending greeting - // is dispatched as ordinary PTY input and therefore appears as a - // synthetic user message in the raw CLI. Keep onboarding in the - // hidden prompt appendix above and let the user's real input lead. - { status: "skipped", reason: "user-proceeded" }, - undefined, - "created", - principal, - ), + session: await this.create(projectId, request, principal), resolution: "created", }; } + this.assertPrincipal(principal); + const seen = new Set(); const candidates = this.options.sessionManager .list() - .filter((session) => this.owns(session, projectId, principal)) + .filter((session) => { + if (!this.owns(session, projectId, principal) || seen.has(session.id)) { + return false; + } + seen.add(session.id); + return true; + }) .sort(candidateOrder); + let resumeFailure: unknown; + let inCurrentScope = 0; for (const candidate of candidates) { - const atCurrentLaunchRoot = isCurrentProjectRoot(project, candidate.cwd); - if ( - atCurrentLaunchRoot && - this.options.sessionManager.isLive(candidate.id) - ) { - const workspace = await this.options.workspaceStore.readOrCreate( - project.projectId, - ); - this.assertPrincipal(principal); + try { + await this.assertRunnable(projectId, candidate, principal); + } catch (error) { + if ( + error instanceof ProjectSessionError && + error.code === "forbidden" && + this.currentPrincipal() === principal + ) { + continue; + } + throw error; + } + inCurrentScope += 1; + if (this.options.sessionManager.isLive(candidate.id)) { this.emit({ - name: "planner_session.resumed", + name: "project_session.resumed", projectId, sessionId: candidate.id, resolution: "live", }); - await this.options.onPlannerSession?.(candidate, { - emptyProject: - workspace.confirmedRevisionId === null && - workspace.activeProposalId === null && - workspace.projectBuildPlanId === null, - mode: "live", - }); - await this.assertRunnable(projectId, candidate.cwd, principal); return { session: candidate, resolution: "live" }; } - if (atCurrentLaunchRoot && candidate.agentSessionId) { - const workspace = await this.options.workspaceStore.readOrCreate( - project.projectId, - ); + + let resumed: HarnessSession; + try { this.assertPrincipal(principal); - const prior = candidate.planning!.greeting; - const planning = { - ...candidate.planning!, - greeting: isTerminalGreeting(prior) - ? prior - : ({ status: "skipped", reason: "user-proceeded" } as const), - }; - const details = await this.focusedDetails(project, workspace); + resumed = await this.options.sessionManager.resume(candidate.id); + } catch (error) { this.assertPrincipal(principal); - const resumed = await this.options.sessionManager - .resume(candidate.id, { - planning, - promptAppendix: buildFocusedPlannerContext({ - project, - workspace, - sessionId: candidate.id, - userId: principal, - onboardOnFirstResponse: false, - ...(details ? { details } : {}), - }), - }) - .catch(() => null); - if (resumed) { - if (this.currentPrincipal() !== principal) { - await this.options.sessionManager.kill(resumed.id).catch(() => false); - throw new PlanningSessionError("forbidden"); - } - this.emit({ - name: "planner_session.resumed", - projectId, - sessionId: resumed.id, - resolution: "resumed", - }); - try { - await this.options.onPlannerSession?.(resumed, { - emptyProject: - workspace.confirmedRevisionId === null && - workspace.activeProposalId === null && - workspace.projectBuildPlanId === null, - mode: "resumed", - }); - await this.assertRunnable(projectId, resumed.cwd, principal); - } catch (error) { - await this.options.sessionManager.kill(resumed.id).catch(() => false); - throw error; - } - return { session: resumed, resolution: "resumed" }; + resumeFailure ??= error; + continue; + } + // Once resume succeeds, never try another record: doing so could leave + // two live processes if this post-await authorization check fails. + try { + this.assertPrincipal(principal); + if (resumed.id !== candidate.id) { + throw new ProjectSessionError("forbidden"); } - // A stale vendor record may still be safely rehydrated below. + await this.assertRunnable(projectId, resumed, principal); + } catch (error) { + if (this.options.sessionManager.isLive(resumed.id)) { + await this.options.sessionManager.kill(resumed.id).catch(() => false); + } + this.assertPrincipal(principal); + throw error; } - const prior = candidate.planning!.greeting; - // A durable planner FIFO is itself rehydration-worthy even before the - // vendor emits history. This keeps a replacement that exits before - // readiness as the exact predecessor for the next launch instead of - // skipping back to an older record and orphaning its moved queue. - const hasQueuedInput = candidate.planning!.queuedInputIds.length > 0; - const historySource = await this.rehydrationHistorySource( - candidate, + this.emit({ + name: "project_session.resumed", projectId, - principal, - ); - if (!historySource && !hasQueuedInput) continue; - const greeting = isTerminalGreeting(prior) - ? prior - : ({ status: "skipped", reason: "user-proceeded" } as const); - return { - session: await this.create( - project, - { ...request, harness: candidate.harness }, - greeting, - historySource, - "rehydrated", - principal, - candidate.id, - ), - resolution: "rehydrated", - }; + sessionId: resumed.id, + resolution: "resumed", + }); + return { session: resumed, resolution: "resumed" }; + } + + // An existing project-owned record is never copied into a new ID. Surface + // its exact resume/scope failure and leave the record untouched. + if (resumeFailure !== undefined) throw resumeFailure; + if (candidates.length > 0 && inCurrentScope === 0) { + throw new ProjectSessionError("forbidden"); } return { - session: await this.create( - project, - request, - { status: "skipped", reason: "user-proceeded" }, - undefined, - "created", - principal, - ), + session: await this.create(projectId, request, principal), resolution: "created", }; } @@ -600,15 +569,20 @@ export class PlanningSessionService { projectId: StudioProjectId, sessionId: string, ): Promise { - const session = this.options.sessionManager.get(sessionId); const principal = this.currentPrincipal(); - if (!session) throw new PlanningSessionError("session_not_found"); - if (!this.owns(session, projectId, principal)) { - throw new PlanningSessionError("forbidden"); - } - // Ownership metadata alone is insufficient after a project root moves or - // the old root is rebound to another project. Resolve on every operation. - await this.assertRunnable(projectId, session.cwd, principal); + const session = this.options.sessionManager.get(sessionId); + if (!session) throw new ProjectSessionError("session_not_found"); + await this.assertRunnable(projectId, session, principal); return session; } } + +/** + * @deprecated Rolling compatibility alias for the bounded planner HTTP routes. + * Remove the alias and those routes together in SAP-3152. + */ +export const PlanningSessionService = ProjectSessionService; +/** @deprecated Use ProjectSessionService. */ +export type PlanningSessionService = ProjectSessionService; +/** @deprecated Use ProjectSessionError. */ +export const PlanningSessionError = ProjectSessionError; diff --git a/packages/harness/src/core/project-bootstrap-outbox.test.ts b/packages/harness/src/core/project-bootstrap-outbox.test.ts new file mode 100644 index 000000000..d42e8c8d2 --- /dev/null +++ b/packages/harness/src/core/project-bootstrap-outbox.test.ts @@ -0,0 +1,160 @@ +import { randomUUID } from "node:crypto"; +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 { + ProjectBootstrapOutbox, + ProjectBootstrapOutboxError, +} from "./project-bootstrap-outbox.js"; +import { StudioProjectCatalog } from "./studio-project-catalog.js"; + +describe("ProjectBootstrapOutbox", () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ); + }); + + async function fixture() { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "project-bootstrap-outbox-"), + ); + roots.push(root); + const outbox = new ProjectBootstrapOutbox(path.join(root, "outbox")); + const lifecycle = { + beforeProjectsCreatedCommit: ( + projects: Parameters[0], + ) => outbox.stage(projects), + }; + return { + root, + outbox, + lifecycle, + catalogPath: path.join(root, "studio-projects.json"), + }; + } + + it("stages an explicit project before its catalog commit survives a restart", async () => { + const { catalogPath, lifecycle, outbox } = await fixture(); + const catalog = new StudioProjectCatalog( + catalogPath, + undefined, + undefined, + lifecycle, + ); + + const project = await catalog.create("Explicit project"); + const restarted = new ProjectBootstrapOutbox( + path.join(path.dirname(catalogPath), "outbox"), + ); + + expect(await restarted.pending()).toEqual([ + { + projectId: project.projectId, + projectCreatedAt: project.createdAt, + }, + ]); + await outbox.complete(project.projectId); + expect(await restarted.pending()).toEqual([]); + }); + + it("ignores a strict stale writer temporary without blocking a valid marker", async () => { + const { catalogPath, lifecycle } = await fixture(); + const catalog = new StudioProjectCatalog( + catalogPath, + undefined, + undefined, + lifecycle, + ); + const project = await catalog.create("Interrupted writer"); + const outboxRoot = path.join(path.dirname(catalogPath), "outbox"); + const staleTemporary = path.join( + outboxRoot, + `${project.projectId}.json.tmp-123-${randomUUID()}`, + ); + await fs.writeFile(staleTemporary, "partial marker", { mode: 0o600 }); + + const restarted = new ProjectBootstrapOutbox(outboxRoot); + await expect(restarted.pending()).resolves.toEqual([ + { + projectId: project.projectId, + projectCreatedAt: project.createdAt, + }, + ]); + await expect(fs.stat(staleTemporary)).resolves.toBeDefined(); + }); + + it("fails closed without deleting a file outside the writer temp format", async () => { + const { root, outbox } = await fixture(); + const outboxRoot = path.join(root, "outbox"); + await fs.mkdir(outboxRoot, { recursive: true }); + const unknown = path.join(outboxRoot, "project-marker.tmp-unknown"); + await fs.writeFile(unknown, "unrecognized", { mode: 0o600 }); + + await expect(outbox.pending()).rejects.toBeInstanceOf( + ProjectBootstrapOutboxError, + ); + await expect(fs.stat(unknown)).resolves.toBeDefined(); + }); + + it("stages only reconcile-created projects and never enrolls a legacy catalog project", async () => { + const { root, catalogPath, lifecycle, outbox } = await fixture(); + const legacyRoot = path.join(root, "legacy-project"); + const newRoot = path.join(root, "new-project"); + await Promise.all([fs.mkdir(legacyRoot), fs.mkdir(newRoot)]); + const legacy = await new StudioProjectCatalog(catalogPath).reconcile([ + { workspaceKey: "legacy-root", cwd: legacyRoot }, + ]); + const legacyProjectId = legacy.projects[0]!.projectId; + + const catalog = new StudioProjectCatalog( + catalogPath, + undefined, + undefined, + lifecycle, + ); + const reconciled = await catalog.reconcile([ + { workspaceKey: "legacy-root", cwd: legacyRoot }, + { workspaceKey: "new-root", cwd: newRoot }, + ]); + const newProjectId = reconciled.workspaceScopes.find( + (scope) => scope.cwd === newRoot, + )!.projectId!; + + expect(newProjectId).not.toBe(legacyProjectId); + expect(await outbox.pending()).toEqual([ + expect.objectContaining({ projectId: newProjectId }), + ]); + expect( + (await outbox.pending()).some( + (entry) => entry.projectId === legacyProjectId, + ), + ).toBe(false); + }); + + it("aborts catalog creation when the write-ahead marker cannot commit", async () => { + const { catalogPath } = await fixture(); + const catalog = new StudioProjectCatalog( + catalogPath, + undefined, + undefined, + { + beforeProjectsCreatedCommit: async () => { + throw new Error("simulated outbox outage"); + }, + }, + ); + + await expect(catalog.create("Must remain absent")).rejects.toThrow( + "simulated outbox outage", + ); + expect(await new StudioProjectCatalog(catalogPath).list()).toEqual([]); + }); +}); diff --git a/packages/harness/src/core/project-bootstrap-outbox.ts b/packages/harness/src/core/project-bootstrap-outbox.ts new file mode 100644 index 000000000..85a706869 --- /dev/null +++ b/packages/harness/src/core/project-bootstrap-outbox.ts @@ -0,0 +1,179 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import type { + StudioProjectId, + StudioProjectSummary, +} from "../shared/agent-map.js"; +import { isStudioProjectId } from "./studio-project-catalog.js"; + +interface PersistedProjectBootstrapOutboxEntry { + schemaVersion: 1; + projectId: StudioProjectId; + projectCreatedAt: string; +} + +const OUTBOX_TEMP_FILE_RE = + /^(project_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.json\.tmp-[1-9][0-9]*-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/; + +export interface ProjectBootstrapOutboxEntry { + projectId: StudioProjectId; + projectCreatedAt: string; +} + +export class ProjectBootstrapOutboxError extends Error { + readonly code = "project_bootstrap_outbox_unavailable"; + + constructor() { + super("project bootstrap outbox is unavailable"); + this.name = "ProjectBootstrapOutboxError"; + } +} + +function isTimestamp(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + return new Date(value).toISOString() === value; + } catch { + return false; + } +} + +function parseEntry( + value: unknown, + expectedProjectId: StudioProjectId, +): ProjectBootstrapOutboxEntry | null { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + Object.keys(value).sort().join(",") !== + "projectCreatedAt,projectId,schemaVersion" || + !("schemaVersion" in value) || + value.schemaVersion !== 1 || + !("projectId" in value) || + value.projectId !== expectedProjectId || + !("projectCreatedAt" in value) || + !isTimestamp(value.projectCreatedAt) + ) { + return null; + } + return { + projectId: expectedProjectId, + projectCreatedAt: value.projectCreatedAt, + }; +} + +/** + * Write-ahead marker for the catalog -> bootstrap-intent boundary. + * + * A marker is committed before a new Studio project enters the catalog. The + * marker is removed only after ProjectBootstrapCoordinator has durably + * scheduled that project. Therefore either side of a process crash is safe: + * an orphan marker has no catalog project and can be discarded, while a + * committed project with a marker is recovered without guessing that older + * catalog projects should be enrolled. + */ +export class ProjectBootstrapOutbox { + private readonly root: string; + + constructor(root: string) { + this.root = path.resolve(root); + } + + private file(projectId: StudioProjectId): string { + if (!isStudioProjectId(projectId)) throw new ProjectBootstrapOutboxError(); + const file = path.resolve(this.root, `${projectId}.json`); + if (!file.startsWith(`${this.root}${path.sep}`)) { + throw new ProjectBootstrapOutboxError(); + } + return file; + } + + async stage( + projects: readonly Pick[], + ): Promise { + try { + await fs.mkdir(this.root, { recursive: true, mode: 0o700 }); + for (const project of projects) { + const file = this.file(project.projectId); + try { + const existing = parseEntry( + JSON.parse(await fs.readFile(file, "utf8")) as unknown, + project.projectId, + ); + if (!existing || existing.projectCreatedAt !== project.createdAt) { + throw new ProjectBootstrapOutboxError(); + } + continue; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const entry: PersistedProjectBootstrapOutboxEntry = { + schemaVersion: 1, + projectId: project.projectId, + projectCreatedAt: project.createdAt, + }; + const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; + try { + await fs.writeFile(temporary, `${JSON.stringify(entry, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await fs.rename(temporary, file); + } finally { + await fs.rm(temporary, { force: true }).catch(() => {}); + } + } + } catch (error) { + if (error instanceof ProjectBootstrapOutboxError) throw error; + throw new ProjectBootstrapOutboxError(); + } + } + + async pending(): Promise { + try { + const names = await fs.readdir(this.root); + const entries: ProjectBootstrapOutboxEntry[] = []; + for (const name of names.sort()) { + const temporary = OUTBOX_TEMP_FILE_RE.exec(name); + if (temporary && isStudioProjectId(temporary[1])) { + // A process may die after writing a private temporary marker but + // before its atomic rename. The corresponding catalog transaction + // cannot have committed yet. Ignore this exact writer-owned shape; + // deleting it could race another process that still owns the active + // catalog transaction. Unknown files still fail closed below. + continue; + } + const match = /^(project_[0-9a-f-]+)\.json$/.exec(name); + if (!match || !isStudioProjectId(match[1])) { + throw new ProjectBootstrapOutboxError(); + } + const projectId = match[1]; + const entry = parseEntry( + JSON.parse( + await fs.readFile(this.file(projectId), "utf8"), + ) as unknown, + projectId, + ); + if (!entry) throw new ProjectBootstrapOutboxError(); + entries.push(entry); + } + return entries; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + if (error instanceof ProjectBootstrapOutboxError) throw error; + throw new ProjectBootstrapOutboxError(); + } + } + + async complete(projectId: StudioProjectId): Promise { + try { + await fs.rm(this.file(projectId), { force: true }); + } catch (error) { + if (error instanceof ProjectBootstrapOutboxError) throw error; + throw new ProjectBootstrapOutboxError(); + } + } +} diff --git a/packages/harness/src/core/session-manager.test.ts b/packages/harness/src/core/session-manager.test.ts index c2f6162e4..c7d1db856 100644 --- a/packages/harness/src/core/session-manager.test.ts +++ b/packages/harness/src/core/session-manager.test.ts @@ -13,8 +13,12 @@ import type { import { CodexAdapter } from "./adapters/codex.js"; import { ExternalHarnessError, SessionNotResumeableError } from "./errors.js"; import { + ProjectBootstrapClaimUnavailableError, + ProjectSessionScopeUnavailableError, + SessionBackgroundInputPreemptedError, SessionInputGuardRejectedError, SessionManager, + SessionManagerClosingError, sanitizeExitTail, type PtySpawnFn, type SessionManagerOptions, @@ -28,7 +32,9 @@ import { IngestCredentialRegistry } from "./ingest-credentials.js"; * 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) => { @@ -50,7 +56,9 @@ function createFakePty(pid?: number) { }; } -function createFakeAdapter(overrides: Partial = {}): HarnessAdapter { +function createFakeAdapter( + overrides: Partial = {}, +): HarnessAdapter { return { id: "claude-code", eventSource: "hooks", @@ -79,6 +87,17 @@ function createFakeAdapter(overrides: Partial = {}): HarnessAdap }; } +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe("SessionManager", () => { let dir: string; let sessionsPath: string; @@ -102,9 +121,14 @@ describe("SessionManager", () => { opts: { adapter?: HarnessAdapter; spawnPty?: PtySpawnFn; + loadSpawnPty?: SessionManagerOptions["loadSpawnPty"]; buildLaunchOpts?: SessionManagerOptions["buildLaunchOpts"]; resolveAgentMapIdentity?: SessionManagerOptions["resolveAgentMapIdentity"]; + prepareProjectSession?: SessionManagerOptions["prepareProjectSession"]; onAgentMapSessionExit?: SessionManagerOptions["onAgentMapSessionExit"]; + onTerminalInput?: SessionManagerOptions["onTerminalInput"]; + onProjectAgentIdentityMigration?: SessionManagerOptions["onProjectAgentIdentityMigration"]; + onProjectBootstrapSession?: SessionManagerOptions["onProjectBootstrapSession"]; writeWorkspaceContext?: SessionManagerOptions["writeWorkspaceContext"]; prepareWorkspaceContext?: SessionManagerOptions["prepareWorkspaceContext"]; ensureCanvasTemplate?: SessionManagerOptions["ensureCanvasTemplate"]; @@ -112,23 +136,23 @@ describe("SessionManager", () => { platform?: SessionManagerOptions["platform"]; ingestCredentials?: SessionManagerOptions["ingestCredentials"]; writeSessionRegistry?: SessionManagerOptions["writeSessionRegistry"]; - writeAgentSessionOwnerRegistry?: - SessionManagerOptions["writeAgentSessionOwnerRegistry"]; + writeAgentSessionOwnerRegistry?: SessionManagerOptions["writeAgentSessionOwnerRegistry"]; /** Pid given to every fake pty this manager spawns — see createFakePty(). */ fakePid?: number; } = {}, ) { const adapter = opts.adapter ?? createFakeAdapter(); const spawns: ReturnType[] = []; - const spawnPty: PtySpawnFn = - opts.spawnPty ?? - ((file, args) => { - const fake = createFakePty(opts.fakePid); - spawns.push(fake); - void file; - void args; - return fake.pty as unknown as ReturnType; - }); + const spawnPty: PtySpawnFn | undefined = opts.loadSpawnPty + ? undefined + : (opts.spawnPty ?? + ((file, args) => { + const fake = createFakePty(opts.fakePid); + spawns.push(fake); + void file; + void args; + return fake.pty as unknown as ReturnType; + })); const manager = new SessionManager({ adapters: { "claude-code": adapter }, ingestUrl: "http://127.0.0.1:4100", @@ -137,9 +161,14 @@ describe("SessionManager", () => { new IngestCredentialRegistry(() => "boot-token"), sessionsPath, spawnPty, + loadSpawnPty: opts.loadSpawnPty, buildLaunchOpts: opts.buildLaunchOpts, resolveAgentMapIdentity: opts.resolveAgentMapIdentity, + prepareProjectSession: opts.prepareProjectSession, onAgentMapSessionExit: opts.onAgentMapSessionExit, + onTerminalInput: opts.onTerminalInput, + onProjectAgentIdentityMigration: opts.onProjectAgentIdentityMigration, + onProjectBootstrapSession: opts.onProjectBootstrapSession, writeWorkspaceContext: opts.writeWorkspaceContext, prepareWorkspaceContext: opts.prepareWorkspaceContext, ensureCanvasTemplate: opts.ensureCanvasTemplate, @@ -154,10 +183,16 @@ describe("SessionManager", () => { it("creates a session, spawns via the adapter's SpawnSpec, and marks it running", async () => { const { manager, adapter } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); expect(adapter.launch).toHaveBeenCalledWith( - expect.objectContaining({ cwd: "/tmp/proj", harnessSessionId: session.id }), + expect.objectContaining({ + cwd: "/tmp/proj", + harnessSessionId: session.id, + }), ); expect(session.status).toBe("running"); expect(session.cwd).toBe("/tmp/proj"); @@ -166,12 +201,53 @@ describe("SessionManager", () => { expect(manager.list()).toHaveLength(1); }); + 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({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); expect(session.status).toBe("running"); - const raw = JSON.parse(await readFile(sessionsPath, "utf8")) as HarnessSession[]; + const raw = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as HarnessSession[]; expect(raw).toHaveLength(1); expect(raw[0]?.id).toBe(session.id); expect(raw[0]?.status).toBe("running"); @@ -183,9 +259,282 @@ describe("SessionManager", () => { expect(reloaded.get(session.id)?.status).toBe("exited"); }); + it("normalizes legacy planner and manual identities without changing durable session state", async () => { + const planner = { + id: "planner-session", + agentSessionId: "provider-planner", + harness: "claude-code", + cwd: "/tmp/project/packages/planner", + title: "Planner renamed by user", + status: "exited", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-02T00:00:00.000Z", + theme: "dark", + exitCode: 0, + exitTail: null, + boundWorkflowPath: "/tmp/project/packages/planner/src/workflow.ts", + rehydratedFrom: "planner-ancestor", + ready: false, + planning: { + identity: { + projectId: "project-1", + userId: "user-1", + sessionId: "planner-session", + role: "map-planner", + }, + greeting: { status: "generating", attemptId: "attempt-7" }, + queuedInputIds: ["input-1"], + }, + } as const; + const manual = { + id: "manual-session", + agentSessionId: "provider-manual", + harness: "claude-code", + cwd: "/tmp/project/packages/manual", + title: "Manual coding session", + status: "exited", + createdAt: "2026-01-03T00:00:00.000Z", + lastActiveAt: "2026-01-04T00:00:00.000Z", + theme: "light", + exitCode: 23, + exitTail: "provider-visible transcript failure", + boundWorkflowPath: "/tmp/project/packages/manual/src/workflow.ts", + rehydratedFrom: "manual-ancestor", + ready: false, + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: "manual-session", + role: "agent-builder", + assignment: { kind: "unplanned" }, + contextThatMustNotBecomeAuthority: "ignored", + }, + } as const; + await writeFile(sessionsPath, JSON.stringify([planner, manual]), "utf8"); + const migrations = vi.fn(); + const { manager } = makeManager({ + onProjectAgentIdentityMigration: migrations, + }); + + await manager.init(); + + expect(manager.list()).toHaveLength(2); + expect(manager.get(planner.id)).toMatchObject({ + id: planner.id, + agentSessionId: planner.agentSessionId, + cwd: planner.cwd, + title: planner.title, + status: planner.status, + createdAt: planner.createdAt, + lastActiveAt: planner.lastActiveAt, + theme: planner.theme, + exitCode: planner.exitCode, + exitTail: planner.exitTail, + boundWorkflowPath: planner.boundWorkflowPath, + rehydratedFrom: planner.rehydratedFrom, + ready: planner.ready, + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: planner.id, + }, + projectBootstrap: { + projectId: "project-1", + userId: "user-1", + targetSessionId: planner.id, + bootstrap: { status: "generating", attemptId: "attempt-7" }, + queuedInputIds: ["input-1"], + }, + }); + expect(manager.get(planner.id)?.planning).toBeUndefined(); + expect(manager.get(manual.id)).toMatchObject({ + id: manual.id, + agentSessionId: manual.agentSessionId, + cwd: manual.cwd, + title: manual.title, + status: manual.status, + createdAt: manual.createdAt, + lastActiveAt: manual.lastActiveAt, + theme: manual.theme, + exitCode: manual.exitCode, + exitTail: manual.exitTail, + boundWorkflowPath: manual.boundWorkflowPath, + rehydratedFrom: manual.rehydratedFrom, + ready: manual.ready, + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: manual.id, + }, + }); + expect(manager.get(manual.id)?.agentMapIdentity).toEqual({ + projectId: "project-1", + userId: "user-1", + sessionId: manual.id, + }); + expect(migrations.mock.calls).toEqual([ + [{ sessionId: planner.id, outcome: "migrated" }], + [{ sessionId: manual.id, outcome: "migrated" }], + ]); + + const persisted = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as HarnessSession[]; + expect(persisted.map((session) => session.id)).toEqual([ + planner.id, + manual.id, + ]); + expect(persisted[0]?.agentSessionId).toBe(planner.agentSessionId); + expect(persisted[1]?.agentSessionId).toBe(manual.agentSessionId); + }); + + it("preserves malformed or conflicting legacy identity records without deleting or duplicating them", async () => { + const malformed = { + id: "malformed-session", + agentSessionId: "provider-malformed", + harness: "claude-code", + cwd: "/tmp/project/malformed", + title: "Malformed identity", + status: "exited", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-01T00:00:00.000Z", + exitCode: 0, + boundWorkflowPath: null, + ready: false, + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: "wrong-session", + }, + } as const; + const conflicting = { + id: "conflicting-session", + agentSessionId: "provider-conflicting", + harness: "claude-code", + cwd: "/tmp/project/conflicting", + title: "Conflicting identity", + status: "exited", + createdAt: "2026-01-02T00:00:00.000Z", + lastActiveAt: "2026-01-02T00:00:00.000Z", + exitCode: 0, + boundWorkflowPath: null, + ready: false, + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: "conflicting-session", + }, + planning: { + identity: { + projectId: "project-2", + userId: "user-2", + sessionId: "conflicting-session", + role: "map-planner", + }, + greeting: { status: "pending" }, + queuedInputIds: [], + }, + } as const; + await writeFile( + sessionsPath, + JSON.stringify([malformed, conflicting]), + "utf8", + ); + const migrations = vi.fn(); + const { manager, adapter, spawns } = makeManager({ + onProjectAgentIdentityMigration: migrations, + }); + + await manager.init(); + + expect(manager.list()).toHaveLength(2); + expect(manager.get(malformed.id)).toEqual(malformed); + expect(manager.get(conflicting.id)).toEqual(conflicting); + expect(migrations.mock.calls).toEqual([ + [{ sessionId: malformed.id, outcome: "rejected" }], + [{ sessionId: conflicting.id, outcome: "rejected" }], + ]); + await expect(manager.resume(malformed.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); + await expect(manager.resume(conflicting.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); + expect(adapter.canResume).not.toHaveBeenCalled(); + expect(spawns).toEqual([]); + const persisted = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as unknown[]; + expect(persisted).toEqual([malformed, conflicting]); + }); + + it("preserves present-but-malformed legacy planner and bootstrap records", async () => { + const base = { + agentSessionId: "provider-session", + harness: "claude-code", + cwd: "/tmp/project", + title: "Preserve me", + status: "exited", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-01T00:00:00.000Z", + exitCode: 0, + boundWorkflowPath: null, + ready: false, + } as const; + const malformedPlanning = { + ...base, + id: "malformed-planning", + planning: { greeting: { status: "pending" }, queuedInputIds: [] }, + }; + const malformedBootstrap = { + ...base, + id: "malformed-bootstrap", + agentSessionId: "provider-bootstrap", + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: "malformed-bootstrap", + }, + projectBootstrap: "not-an-object", + }; + await writeFile( + sessionsPath, + JSON.stringify([malformedPlanning, malformedBootstrap]), + "utf8", + ); + const migrations = vi.fn(); + const { manager, adapter, spawns } = makeManager({ + onProjectAgentIdentityMigration: migrations, + }); + + await manager.init(); + + expect(manager.list()).toHaveLength(2); + expect(manager.get(malformedPlanning.id)).toEqual(malformedPlanning); + expect(manager.get(malformedBootstrap.id)).toEqual(malformedBootstrap); + expect(migrations.mock.calls).toEqual([ + [{ sessionId: malformedPlanning.id, outcome: "rejected" }], + [{ sessionId: malformedBootstrap.id, outcome: "rejected" }], + ]); + await expect(manager.resume(malformedPlanning.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); + await expect(manager.resume(malformedBootstrap.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); + expect(adapter.canResume).not.toHaveBeenCalled(); + expect(spawns).toEqual([]); + expect(JSON.parse(await readFile(sessionsPath, "utf8")) as unknown).toEqual( + [malformedPlanning, malformedBootstrap], + ); + }); + it("routes write() and resize() to the underlying pty", async () => { const { manager, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); expect(manager.write(session.id, "echo hi\r")).toBe(true); expect(spawns[0]?.pty.write).toHaveBeenCalledWith("echo hi\r"); @@ -208,10 +557,17 @@ describe("SessionManager", () => { it("splits non-empty submitted text into a text write, then a separate \\r after a delay", async () => { const { manager, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setReady(session.id); - const submitPromise = manager.submitInput(session.id, "hello world", true); + const submitPromise = manager.submitInput( + session.id, + "hello world", + true, + ); // Text lands immediately; the trailing Enter must NOT be part of the // same write (that's exactly the bracketed-paste bug this fixes). @@ -230,7 +586,10 @@ describe("SessionManager", () => { it("writes a bare \\r in a single call for submit:true with empty text (no splitting needed)", async () => { const { manager, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setReady(session.id); const ok = await manager.submitInput(session.id, "", true); @@ -242,7 +601,10 @@ describe("SessionManager", () => { it("writes only the text, with no \\r at all, when submit is false", async () => { const { manager, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setReady(session.id); const ok = await manager.submitInput(session.id, "draft text", false); @@ -254,13 +616,20 @@ describe("SessionManager", () => { it("brackets the text once the app has turned bracketed paste on", async () => { const { manager, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setReady(session.id); // The TUI announces mode 2004 in its own output; only then is it safe to // send the markers rather than have them rendered as literal text. spawns[0]?.emitData("\x1b[?1049;2004h"); - const submitPromise = manager.submitInput(session.id, "step context\n\nDebug this step", true); + const submitPromise = manager.submitInput( + session.id, + "step context\n\nDebug this step", + true, + ); await vi.advanceTimersByTimeAsync(300); expect(await submitPromise).toBe(true); @@ -274,12 +643,19 @@ describe("SessionManager", () => { it("writes the text raw again once the app turns bracketed paste back off", async () => { const { manager, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setReady(session.id); spawns[0]?.emitData("\x1b[?2004h"); spawns[0]?.emitData("\x1b[?2004l"); - const submitPromise = manager.submitInput(session.id, "hello world", true); + const submitPromise = manager.submitInput( + session.id, + "hello world", + true, + ); await vi.advanceTimersByTimeAsync(300); expect(await submitPromise).toBe(true); @@ -289,11 +665,16 @@ describe("SessionManager", () => { it("never brackets a submit:false draft — the text is the user's to edit", async () => { const { manager, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setReady(session.id); spawns[0]?.emitData("\x1b[?2004h"); - expect(await manager.submitInput(session.id, "draft text", false)).toBe(true); + expect(await manager.submitInput(session.id, "draft text", false)).toBe( + true, + ); expect(spawns[0]?.pty.write).toHaveBeenCalledTimes(1); expect(spawns[0]?.pty.write).toHaveBeenCalledWith("draft text"); }); @@ -308,15 +689,25 @@ describe("SessionManager", () => { adapter: createFakeAdapter({ assumesBracketedPaste: true }), platform: "win32", }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setReady(session.id); spawns[0]?.emitData("welcome banner, no mode announcements"); - const submitPromise = manager.submitInput(session.id, "line one\n\nline two", true); + const submitPromise = manager.submitInput( + session.id, + "line one\n\nline two", + true, + ); await vi.advanceTimersByTimeAsync(300); expect(await submitPromise).toBe(true); - expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(1, "\x1b[200~line one\n\nline two\x1b[201~"); + expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith( + 1, + "\x1b[200~line one\n\nline two\x1b[201~", + ); expect(spawns[0]?.pty.write).toHaveBeenNthCalledWith(2, "\r"); }); @@ -327,12 +718,19 @@ describe("SessionManager", () => { adapter: createFakeAdapter({ assumesBracketedPaste: true }), platform: "win32", }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setReady(session.id); spawns[0]?.emitData("\x1b[?2004h"); spawns[0]?.emitData("\x1b[?2004l"); - const submitPromise = manager.submitInput(session.id, "hello\nworld", true); + const submitPromise = manager.submitInput( + session.id, + "hello\nworld", + true, + ); await vi.advanceTimersByTimeAsync(300); expect(await submitPromise).toBe(true); @@ -341,12 +739,17 @@ describe("SessionManager", () => { it("returns false for an unknown session without ever touching a pty", async () => { const { manager } = makeManager(); - expect(await manager.submitInput("unknown-id", "hello", true)).toBe(false); + expect(await manager.submitInput("unknown-id", "hello", true)).toBe( + false, + ); }); it("does not write the trailing \\r if the session's pty is gone by the time the delay elapses", async () => { const { manager, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setReady(session.id); const submitPromise = manager.submitInput(session.id, "hello", true); @@ -361,9 +764,89 @@ 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); + 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({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setReady(session.id); const remainsAuthorized = vi .fn<() => boolean>() @@ -393,9 +876,14 @@ describe("SessionManager", () => { 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" }); + 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]?.emitData( + "\x1b[31merror\x1b[0m: unknown option '--plugin-dir'\n", + ); spawns[0]?.emitExit(1); await manager.flush(); @@ -404,13 +892,18 @@ describe("SessionManager", () => { 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).toContain( + "error: unknown option '--plugin-dir'", + ); expect(exited?.exitTail).not.toContain("\x1b"); }); 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); spawns[0]?.emitData("all good, bye\n"); spawns[0]?.emitExit(0); @@ -421,7 +914,10 @@ describe("SessionManager", () => { 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); // Only cursor/clear control noise — nothing a human could read. spawns[0]?.emitData("\x1b[2J\x1b[H"); @@ -433,9 +929,14 @@ describe("SessionManager", () => { 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); - spawns[0]?.emitData("some normal session output the user was looking at\n"); + 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); @@ -475,20 +976,29 @@ describe("SessionManager", () => { vi.useRealTimers(); }); - it("a fresh session starts not-ready even though its pty is already \"running\"", async () => { + 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); expect(session.status).toBe("running"); expect(session.ready).toBe(false); }); it("write() (raw keystrokes) is never gated on readiness — a human must be able to answer a blocking prompt themselves", async () => { - const { manager, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const onTerminalInput = vi.fn(); + const { manager, spawns } = makeManager({ onTerminalInput }); + 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); }); it( @@ -496,7 +1006,10 @@ describe("SessionManager", () => { "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" }); + 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 @@ -523,9 +1036,12 @@ describe("SessionManager", () => { 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" }); - let authorized = true; - const submitPromise = manager.submitInput( + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + let authorized = true; + const submitPromise = manager.submitInput( session.id, "planner message", true, @@ -546,7 +1062,10 @@ describe("SessionManager", () => { 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" }); + 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); @@ -559,7 +1078,10 @@ describe("SessionManager", () => { 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" }); + 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); @@ -582,8 +1104,16 @@ describe("SessionManager", () => { 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" }); + 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); @@ -600,8 +1130,16 @@ describe("SessionManager", () => { 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" }); + 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); @@ -618,8 +1156,16 @@ describe("SessionManager", () => { 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" }); + 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); @@ -635,12 +1181,21 @@ describe("SessionManager", () => { 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" }); + 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); + const assertion = + expect(submitPromise).rejects.toThrow(/not ready yet/i); await vi.advanceTimersByTimeAsync(8_000); await assertion; @@ -673,7 +1228,10 @@ describe("SessionManager", () => { 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); spawns[0]?.emitData("\x1b[1;1H› Ask Codex to do anything\r\n"); await vi.advanceTimersByTimeAsync(749); @@ -687,7 +1245,10 @@ describe("SessionManager", () => { 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); await vi.advanceTimersByTimeAsync(1_000); expect(manager.get(session.id)?.ready).toBe(false); @@ -702,12 +1263,16 @@ describe("SessionManager", () => { 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" }); + 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"; + 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); @@ -719,27 +1284,38 @@ describe("SessionManager", () => { }); 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 detectBlockingPrompt = vi.fn((scrollback: string) => + scrollback.includes("Sign in with ChatGPT"), + ); const { manager, spawns } = makeManager({ adapter: immediateAdapter(detectBlockingPrompt), }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); // 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[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"; + "\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")); + expect(detectBlockingPrompt).toHaveBeenCalledWith( + expect.stringContaining("Sign in with ChatGPT"), + ); // A positive composer frame clears the old-screen latch. spawns[0]?.emitData( @@ -890,7 +1466,10 @@ describe("SessionManager", () => { it("restarts the settle window when later startup output arrives", async () => { const { manager, spawns } = makeManager({ adapter: immediateAdapter() }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); spawns[0]?.emitData("early Codex banner\r\n"); await vi.advanceTimersByTimeAsync(600); @@ -911,7 +1490,10 @@ describe("SessionManager", () => { const { manager, spawns } = makeManager({ adapter: immediateAdapter(detectBlockingPrompt), }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + 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 @@ -935,14 +1517,21 @@ describe("SessionManager", () => { const { manager, spawns } = makeManager({ adapter: immediateAdapter(() => showingPrompt), }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + 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); showingPrompt = true; spawns[0]?.emitData("Sign in with ChatGPT\r\n"); - const submitPromise = manager.submitInput(session.id, "held prompt", true); + const submitPromise = manager.submitInput( + session.id, + "held prompt", + true, + ); expect(spawns[0]?.pty.write).not.toHaveBeenCalled(); // `ready` remains the single latched status transition, but injection @@ -964,7 +1553,10 @@ describe("SessionManager", () => { }); const readyStatuses: boolean[] = []; manager.onStatusChange((session) => readyStatuses.push(session.ready)); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + 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); @@ -981,7 +1573,10 @@ describe("SessionManager", () => { 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" }); + 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"); @@ -1004,7 +1599,10 @@ describe("SessionManager", () => { const { manager, spawns } = makeManager({ adapter: createFakeAdapter({ detectBlockingPrompt }), }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); spawns[0]?.emitData("legacy composer\r\n"); await vi.advanceTimersByTimeAsync(1_000); @@ -1029,7 +1627,9 @@ describe("SessionManager", () => { vi.restoreAllMocks(); }); - const hookTimeoutAdapter = (detect: (scrollback: string) => boolean = () => false) => + const hookTimeoutAdapter = ( + detect: (scrollback: string) => boolean = () => false, + ) => createFakeAdapter({ readyFallback: "hook-timeout", detectBlockingPrompt: vi.fn(detect), @@ -1039,8 +1639,13 @@ describe("SessionManager", () => { // 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" }); + 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); @@ -1048,12 +1653,19 @@ describe("SessionManager", () => { await vi.advanceTimersByTimeAsync(2_500); expect(manager.get(session.id)?.ready).toBe(true); - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("marking ready by fallback")); + 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" }); + 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); @@ -1063,8 +1675,13 @@ describe("SessionManager", () => { 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" }); + 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); @@ -1078,26 +1695,39 @@ describe("SessionManager", () => { 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" }); + 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" }); + 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")); + 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); spawns[0]?.emitData("output\r\n"); await vi.advanceTimersByTimeAsync(30_000); @@ -1107,7 +1737,10 @@ describe("SessionManager", () => { 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); spawns[0]?.emitData("hello "); spawns[0]?.emitData("world"); @@ -1128,7 +1761,10 @@ describe("SessionManager", () => { 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 session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); const activity: string[] = []; manager.onActivity((id) => activity.push(id)); @@ -1179,7 +1815,10 @@ describe("SessionManager", () => { it("marks a session exited when its pty exits, and notifies status listeners", async () => { const { manager, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); const statuses: HarnessSession["status"][] = []; manager.onStatusChange((s) => { @@ -1195,7 +1834,10 @@ describe("SessionManager", () => { it("kill() signals the pty for a running session and returns a Promise resolving true; returns Promise otherwise", async () => { const { manager, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); // kill() now returns Promise — fire-and-forget still works via void, // but here we confirm the resolved value and that the signal was sent. @@ -1235,9 +1877,14 @@ describe("SessionManager", () => { it("resume() requires a known agentSessionId and respawns via adapter.resume", async () => { const { manager, adapter, spawns } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); - await expect(manager.resume(session.id)).rejects.toThrow(/no agentSessionId/); + await expect(manager.resume(session.id)).rejects.toThrow( + /no agentSessionId/, + ); await manager.setAgentSessionId(session.id, "agent-uuid-1"); spawns[0]?.emitExit(0); @@ -1246,12 +1893,17 @@ describe("SessionManager", () => { const resumed = await manager.resume(session.id); expect(adapter.resume).toHaveBeenCalledWith( "agent-uuid-1", - expect.objectContaining({ harnessSessionId: session.id, cwd: "/tmp/proj" }), + expect.objectContaining({ + harnessSessionId: session.id, + cwd: "/tmp/proj", + }), ); expect(resumed.status).toBe("running"); expect(resumed.id).toBe(session.id); - await expect(manager.resume("does-not-exist")).rejects.toThrow(/Unknown session/); + await expect(manager.resume("does-not-exist")).rejects.toThrow( + /Unknown session/, + ); }); it("pins vendor session identity write-once for live and resumed records", async () => { @@ -1261,9 +1913,15 @@ describe("SessionManager", () => { harness: "claude-code", }); - expect(await manager.setAgentSessionId(live.id, "agent-live", "startup")).toBe(true); - expect(await manager.setAgentSessionId(live.id, "agent-live", "compact")).toBe(true); - expect(await manager.setAgentSessionId(live.id, "agent-other", "clear")).toBe(false); + expect( + await manager.setAgentSessionId(live.id, "agent-live", "startup"), + ).toBe(true); + expect( + await manager.setAgentSessionId(live.id, "agent-live", "compact"), + ).toBe(true); + expect( + await manager.setAgentSessionId(live.id, "agent-other", "clear"), + ).toBe(false); expect(manager.get(live.id)?.agentSessionId).toBe("agent-live"); const resumed = await manager.registerHistorical({ @@ -1273,8 +1931,12 @@ describe("SessionManager", () => { title: "resumed", lastActiveAt: "2026-01-01T00:00:00.000Z", }); - expect(await manager.setAgentSessionId(resumed.id, "agent-resumed")).toBe(true); - expect(await manager.setAgentSessionId(resumed.id, "agent-other", "resume")).toBe(false); + expect(await manager.setAgentSessionId(resumed.id, "agent-resumed")).toBe( + true, + ); + expect( + await manager.setAgentSessionId(resumed.id, "agent-other", "resume"), + ).toBe(false); expect(manager.get(resumed.id)?.agentSessionId).toBe("agent-resumed"); }); @@ -1284,17 +1946,23 @@ describe("SessionManager", () => { cwd: "/tmp/proj", harness: "claude-code", }); - expect(await manager.setAgentSessionId(session.id, "agent-before", "startup")).toBe(true); + expect( + await manager.setAgentSessionId(session.id, "agent-before", "startup"), + ).toBe(true); expect(manager.write(session.id, "/cl")).toBe(true); expect(manager.write(session.id, "x")).toBe(true); expect(manager.write(session.id, "\x7f")).toBe(true); expect(manager.write(session.id, "ear\r")).toBe(true); - expect(await manager.setAgentSessionId(session.id, "agent-after", "clear")).toBe(true); + expect( + await manager.setAgentSessionId(session.id, "agent-after", "clear"), + ).toBe(true); expect(manager.get(session.id)?.agentSessionId).toBe("agent-after"); // The gesture was consumed by the accepted rotation. - expect(await manager.setAgentSessionId(session.id, "agent-replayed", "clear")).toBe(false); + expect( + await manager.setAgentSessionId(session.id, "agent-replayed", "clear"), + ).toBe(false); expect(manager.get(session.id)?.agentSessionId).toBe("agent-after"); }); @@ -1304,13 +1972,19 @@ describe("SessionManager", () => { cwd: "/tmp/proj", harness: "claude-code", }); - expect(await manager.setAgentSessionId(session.id, "agent-before", "startup")).toBe(true); + expect( + await manager.setAgentSessionId(session.id, "agent-before", "startup"), + ).toBe(true); expect(manager.write(session.id, "please /clear\r")).toBe(true); - expect(await manager.setAgentSessionId(session.id, "agent-contained", "clear")).toBe(false); + expect( + await manager.setAgentSessionId(session.id, "agent-contained", "clear"), + ).toBe(false); expect(manager.write(session.id, "context\n/clear\r")).toBe(true); - expect(await manager.setAgentSessionId(session.id, "agent-multiline", "clear")).toBe(false); + expect( + await manager.setAgentSessionId(session.id, "agent-multiline", "clear"), + ).toBe(false); // xterm may split the wrappers and preserve CR inside a multiline paste. // No pasted line — including an inner exact command — may arm rotation. @@ -1318,13 +1992,23 @@ describe("SessionManager", () => { expect(manager.write(session.id, "0~context\r/cl")).toBe(true); expect(manager.write(session.id, "ear\r\x1b[20")).toBe(true); expect(manager.write(session.id, "1~\r")).toBe(true); - expect(await manager.setAgentSessionId(session.id, "agent-pasted", "clear")).toBe(false); + expect( + await manager.setAgentSessionId(session.id, "agent-pasted", "clear"), + ).toBe(false); // Ctrl-U safely abandons the prior draft and lets the exact replacement // line authorize, while an unrelated hook source cannot consume it. expect(manager.write(session.id, "draft\x15/clear\r")).toBe(true); - expect(await manager.setAgentSessionId(session.id, "agent-wrong-source", "resume")).toBe(false); - expect(await manager.setAgentSessionId(session.id, "agent-after", "clear")).toBe(true); + expect( + await manager.setAgentSessionId( + session.id, + "agent-wrong-source", + "resume", + ), + ).toBe(false); + expect( + await manager.setAgentSessionId(session.id, "agent-after", "clear"), + ).toBe(true); }); it("consumes a trusted transition when the matching hook repeats the same identity", async () => { @@ -1333,11 +2017,17 @@ describe("SessionManager", () => { cwd: "/tmp/proj", harness: "claude-code", }); - expect(await manager.setAgentSessionId(session.id, "agent-same", "startup")).toBe(true); + expect( + await manager.setAgentSessionId(session.id, "agent-same", "startup"), + ).toBe(true); expect(manager.write(session.id, "/clear\r")).toBe(true); - expect(await manager.setAgentSessionId(session.id, "agent-same", "clear")).toBe(true); - expect(await manager.setAgentSessionId(session.id, "agent-later", "clear")).toBe(false); + expect( + await manager.setAgentSessionId(session.id, "agent-same", "clear"), + ).toBe(true); + expect( + await manager.setAgentSessionId(session.id, "agent-later", "clear"), + ).toBe(false); expect(manager.get(session.id)?.agentSessionId).toBe("agent-same"); }); @@ -1350,7 +2040,9 @@ describe("SessionManager", () => { harness: "claude-code", }); manager.setReady(session.id); - expect(await manager.setAgentSessionId(session.id, "agent-before", "startup")).toBe(true); + expect( + await manager.setAgentSessionId(session.id, "agent-before", "startup"), + ).toBe(true); const submitted = manager.submitInput(session.id, "/resume", true); await vi.advanceTimersByTimeAsync(300); @@ -1361,7 +2053,9 @@ describe("SessionManager", () => { // their later trusted selection refreshes it without model authority. await vi.advanceTimersByTimeAsync(60_000); expect(manager.write(session.id, "\x1b[B\r")).toBe(true); - expect(await manager.setAgentSessionId(session.id, "agent-picked", "resume")).toBe(true); + expect( + await manager.setAgentSessionId(session.id, "agent-picked", "resume"), + ).toBe(true); expect(manager.get(session.id)?.agentSessionId).toBe("agent-picked"); const direct = manager.submitInput( @@ -1371,7 +2065,9 @@ describe("SessionManager", () => { ); await vi.advanceTimersByTimeAsync(300); expect(await direct).toBe(true); - expect(await manager.setAgentSessionId(session.id, "agent-direct", "resume")).toBe(true); + expect( + await manager.setAgentSessionId(session.id, "agent-direct", "resume"), + ).toBe(true); expect(manager.get(session.id)?.agentSessionId).toBe("agent-direct"); } finally { vi.useRealTimers(); @@ -1429,14 +2125,22 @@ describe("SessionManager", () => { cwd: "/tmp/second", harness: "claude-code", }); - expect(await manager.setAgentSessionId(first.id, "agent-first", "startup")).toBe(true); - expect(await manager.setAgentSessionId(second.id, "agent-second", "startup")).toBe(true); + expect( + await manager.setAgentSessionId(first.id, "agent-first", "startup"), + ).toBe(true); + expect( + await manager.setAgentSessionId(second.id, "agent-second", "startup"), + ).toBe(true); expect(manager.write(first.id, "/clear\r")).toBe(true); - expect(await manager.setAgentSessionId(first.id, "agent-second", "clear")).toBe(false); + expect( + await manager.setAgentSessionId(first.id, "agent-second", "clear"), + ).toBe(false); expect(manager.get(first.id)?.agentSessionId).toBe("agent-first"); // Rejection is still a one-shot use; a unique second target cannot reuse it. - expect(await manager.setAgentSessionId(first.id, "agent-third", "clear")).toBe(false); + expect( + await manager.setAgentSessionId(first.id, "agent-third", "clear"), + ).toBe(false); }); it("rejects another session's owned identity on the initial pin", async () => { @@ -1449,15 +2153,23 @@ describe("SessionManager", () => { cwd: "/tmp/second", harness: "claude-code", }); - expect(await manager.setAgentSessionId(first.id, "agent-owned", "startup")).toBe(true); + expect( + await manager.setAgentSessionId(first.id, "agent-owned", "startup"), + ).toBe(true); - expect(await manager.setAgentSessionId(second.id, "agent-owned", "startup")).toBe(false); + expect( + await manager.setAgentSessionId(second.id, "agent-owned", "startup"), + ).toBe(false); expect(manager.get(second.id)?.agentSessionId).toBeNull(); expect(manager.write(second.id, "/clear\r")).toBe(true); - expect(await manager.setAgentSessionId(second.id, "agent-owned", "clear")).toBe(false); + expect( + await manager.setAgentSessionId(second.id, "agent-owned", "clear"), + ).toBe(false); // The owned-target rejection consumes the grant even before an initial pin. - expect(await manager.setAgentSessionId(second.id, "agent-unique", "clear")).toBe(false); + expect( + await manager.setAgentSessionId(second.id, "agent-unique", "clear"), + ).toBe(false); expect(manager.get(second.id)?.agentSessionId).toBeNull(); }); @@ -1467,15 +2179,21 @@ describe("SessionManager", () => { cwd: "/tmp/first", harness: "claude-code", }); - expect(await manager.setAgentSessionId(first.id, "vendor-old", "startup")).toBe(true); + expect( + await manager.setAgentSessionId(first.id, "vendor-old", "startup"), + ).toBe(true); expect(manager.write(first.id, "/clear\r")).toBe(true); - expect(await manager.setAgentSessionId(first.id, "vendor-new", "clear")).toBe(true); + expect( + await manager.setAgentSessionId(first.id, "vendor-new", "clear"), + ).toBe(true); const second = await manager.create({ cwd: "/tmp/second", harness: "claude-code", }); - expect(await manager.setAgentSessionId(second.id, "vendor-old", "startup")).toBe(false); + expect( + await manager.setAgentSessionId(second.id, "vendor-old", "startup"), + ).toBe(false); const ledgerPath = `${sessionsPath}.agent-session-owners.json`; const ledger = await readFile(ledgerPath, "utf8"); @@ -1490,13 +2208,21 @@ describe("SessionManager", () => { cwd: "/tmp/third", harness: "claude-code", }); - expect(await restarted.setAgentSessionId(third.id, "vendor-old", "startup")).toBe(false); + expect( + await restarted.setAgentSessionId(third.id, "vendor-old", "startup"), + ).toBe(false); }); it("serializes concurrent first claims so exactly one session owns a vendor id", async () => { const { manager } = makeManager(); - const first = await manager.create({ cwd: "/tmp/first", harness: "claude-code" }); - const second = await manager.create({ cwd: "/tmp/second", harness: "claude-code" }); + const first = await manager.create({ + cwd: "/tmp/first", + harness: "claude-code", + }); + const second = await manager.create({ + cwd: "/tmp/second", + harness: "claude-code", + }); const results = await Promise.all([ manager.setAgentSessionId(first.id, "vendor-race", "startup"), @@ -1513,13 +2239,22 @@ describe("SessionManager", () => { it("fails closed across a sidecar-committed registry failure and lets only the reserved owner retry", async () => { let rejectRegistry = false; - const writeSessionRegistry = vi.fn(async (file: string, serialized: string) => { - if (rejectRegistry) throw new Error("injected sessions registry failure"); - await writeFile(file, serialized, "utf8"); - }); + const writeSessionRegistry = vi.fn( + async (file: string, serialized: string) => { + if (rejectRegistry) + throw new Error("injected sessions registry failure"); + await writeFile(file, serialized, "utf8"); + }, + ); const { manager } = makeManager({ writeSessionRegistry }); - const owner = await manager.create({ cwd: "/tmp/owner", harness: "claude-code" }); - const attacker = await manager.create({ cwd: "/tmp/attacker", harness: "claude-code" }); + const owner = await manager.create({ + cwd: "/tmp/owner", + harness: "claude-code", + }); + const attacker = await manager.create({ + cwd: "/tmp/attacker", + harness: "claude-code", + }); rejectRegistry = true; await expect( @@ -1556,9 +2291,16 @@ describe("SessionManager", () => { ).toBe(true); const { manager: finalRestart } = makeManager(); await finalRestart.init(); - const later = await finalRestart.create({ cwd: "/tmp/later", harness: "claude-code" }); + const later = await finalRestart.create({ + cwd: "/tmp/later", + harness: "claude-code", + }); expect( - await finalRestart.setAgentSessionId(later.id, "vendor-partial", "startup"), + await finalRestart.setAgentSessionId( + later.id, + "vendor-partial", + "startup", + ), ).toBe(false); }); @@ -1750,11 +2492,7 @@ describe("SessionManager", () => { harness: "claude-code", }); expect( - await restarted.setAgentSessionId( - claimant.id, - duplicate, - "startup", - ), + await restarted.setAgentSessionId(claimant.id, duplicate, "startup"), ).toBe(false); expect(restarted.get(claimant.id)?.agentSessionId).toBeNull(); expect( @@ -1766,145 +2504,733 @@ describe("SessionManager", () => { id: first.id, harness: "claude-code", }), - ]); - }); + ]); + }); + + it("fails boot closed when the private ownership ledger is malformed", async () => { + await writeFile( + `${sessionsPath}.agent-session-owners.json`, + JSON.stringify({ version: 1, owners: { "not-a-digest": "session-1" } }), + ); + const { manager } = makeManager(); + + await expect(manager.init()).rejects.toThrow(/invalid entry/); + }); + + it("hard-expires trusted rotation authority and clears it across relaunch", async () => { + vi.useFakeTimers(); + try { + const { manager, spawns } = makeManager(); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + expect( + await manager.setAgentSessionId(session.id, "agent-before", "startup"), + ).toBe(true); + + expect(manager.write(session.id, "/clear\r")).toBe(true); + await vi.advanceTimersByTimeAsync(30_000); + expect( + await manager.setAgentSessionId(session.id, "agent-expired", "clear"), + ).toBe(false); + + expect(manager.write(session.id, "/resume\r")).toBe(true); + await vi.advanceTimersByTimeAsync(5 * 60_000); + // Input at the hard cap cannot revive an indefinitely stale picker. + expect(manager.write(session.id, "\x1b[B\r")).toBe(true); + expect( + await manager.setAgentSessionId( + session.id, + "agent-picker-expired", + "resume", + ), + ).toBe(false); + + expect(manager.write(session.id, "/resume\r")).toBe(true); + spawns[0]?.emitExit(0); + await manager.resume(session.id); + expect( + await manager.setAgentSessionId( + session.id, + "agent-after-relaunch", + "resume", + ), + ).toBe(false); + expect(manager.get(session.id)?.agentSessionId).toBe("agent-before"); + } finally { + vi.useRealTimers(); + } + }); + + 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)); + return { settingsFile: `/generated/${harnessSessionId}/settings.json` }; + }); + const { manager, adapter } = makeManager({ buildLaunchOpts }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); + + expect(buildLaunchOpts).toHaveBeenCalledWith( + session.id, + expect.objectContaining({ cwd: "/tmp/proj", harness: "claude-code" }), + ); + expect(adapter.launch).toHaveBeenCalledWith( + expect.objectContaining({ + settingsFile: `/generated/${session.id}/settings.json`, + }), + ); + }); + + it("keeps a trusted planner FIFO predecessor distinct from its history brief source", async () => { + const buildLaunchOpts = vi.fn( + async (_harnessSessionId: string, request: CreateSessionRequest) => ({ + rehydratedFrom: request.rehydrateFrom, + }), + ); + const { manager } = makeManager({ buildLaunchOpts }); + + const session = await manager.create( + { + cwd: "/tmp/proj", + harness: "claude-code", + rehydrateFrom: "recorded-history-ancestor", + }, + { handoffFromSessionId: "queue-owning-predecessor" }, + ); + + expect(buildLaunchOpts).toHaveBeenCalledWith( + session.id, + expect.objectContaining({ + rehydrateFrom: "recorded-history-ancestor", + }), + ); + expect(session.rehydratedFrom).toBe("queue-owning-predecessor"); + }); + + it("also awaits an async buildLaunchOpts on resume()", async () => { + const buildLaunchOpts = vi.fn(async (harnessSessionId: string) => ({ + mcpConfigFile: `/generated/${harnessSessionId}/mcp-config.json`, + })); + 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.resume(session.id); + + expect(adapter.resume).toHaveBeenLastCalledWith( + "agent-uuid-1", + expect.objectContaining({ + mcpConfigFile: `/generated/${session.id}/mcp-config.json`, + }), + ); + }); + + 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.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.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({ + 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); + }, + ); + + 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("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 {}; + }, + }); + + 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(); + + firstLaunch.resolve(); + const [first, second] = await Promise.all([automatic, explicit]); + + 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); + }); + + 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("fails boot closed when the private ownership ledger is malformed", async () => { - await writeFile( - `${sessionsPath}.agent-session-owners.json`, - JSON.stringify({ version: 1, owners: { "not-a-digest": "session-1" } }), + 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 }, ); - const { manager } = makeManager(); + firstLaunch.resolve(); - await expect(manager.init()).rejects.toThrow(/invalid entry/); + 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(); }); - it("hard-expires trusted rotation authority and clears it across relaunch", async () => { - vi.useFakeTimers(); - try { - const { manager, spawns } = makeManager(); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - expect(await manager.setAgentSessionId(session.id, "agent-before", "startup")).toBe(true); + 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 {}; + }, + }); - expect(manager.write(session.id, "/clear\r")).toBe(true); - await vi.advanceTimersByTimeAsync(30_000); - expect(await manager.setAgentSessionId(session.id, "agent-expired", "clear")).toBe(false); + 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); + + firstLaunch.resolve(); + await expect(first).rejects.toBeInstanceOf(SessionManagerClosingError); + await expect(queued).rejects.toBeInstanceOf(SessionManagerClosingError); + await flushed; + + expect(prepareProjectSession).toHaveBeenCalledOnce(); + expect(buildCount).toBe(1); + expect(adapter.launch).toHaveBeenCalledOnce(); + expect(manager.list()).toEqual([ + expect.objectContaining({ status: "exited" }), + ]); + }); - expect(manager.write(session.id, "/resume\r")).toBe(true); - await vi.advanceTimersByTimeAsync(5 * 60_000); - // Input at the hard cap cannot revive an indefinitely stale picker. - expect(manager.write(session.id, "\x1b[B\r")).toBe(true); - expect(await manager.setAgentSessionId(session.id, "agent-picker-expired", "resume")).toBe(false); + 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, + }); - expect(manager.write(session.id, "/resume\r")).toBe(true); - spawns[0]?.emitExit(0); - await manager.resume(session.id); - expect(await manager.setAgentSessionId(session.id, "agent-after-relaunch", "resume")).toBe(false); - expect(manager.get(session.id)?.agentSessionId).toBe("agent-before"); - } finally { - vi.useRealTimers(); - } + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).rejects.toBeInstanceOf(ProjectSessionScopeUnavailableError); + + 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(onAgentMapSessionExit).toHaveBeenCalledWith(manager.list()[0]!.id); }); - 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)); - return { settingsFile: `/generated/${harnessSessionId}/settings.json` }; + 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 { manager, adapter } = makeManager({ buildLaunchOpts }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + 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(); - expect(buildLaunchOpts).toHaveBeenCalledWith( - session.id, - expect.objectContaining({ cwd: "/tmp/proj", harness: "claude-code" }), - ); - expect(adapter.launch).toHaveBeenCalledWith( - expect.objectContaining({ settingsFile: `/generated/${session.id}/settings.json` }), + await expect(manager.resume(session.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, ); + + 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("keeps a trusted planner FIFO predecessor distinct from its history brief source", async () => { - const buildLaunchOpts = vi.fn( - async (_harnessSessionId: string, request: CreateSessionRequest) => ({ - rehydratedFrom: request.rehydrateFrom, - }), + 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, ); - const { manager } = makeManager({ buildLaunchOpts }); - - const session = await manager.create( - { - cwd: "/tmp/proj", - harness: "claude-code", - rehydrateFrom: "recorded-history-ancestor", - }, - { handoffFromSessionId: "queue-owning-predecessor" }, + 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, + }); - expect(buildLaunchOpts).toHaveBeenCalledWith( - session.id, - expect.objectContaining({ - rehydrateFrom: "recorded-history-ancestor", - }), + 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(session.rehydratedFrom).toBe("queue-owning-predecessor"); + 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("also awaits an async buildLaunchOpts on resume()", async () => { - const buildLaunchOpts = vi.fn(async (harnessSessionId: string) => ({ - mcpConfigFile: `/generated/${harnessSessionId}/mcp-config.json`, - })); - 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); + 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(); - await manager.resume(session.id); + const resuming = manager.resume(session.id); + await vi.waitFor(() => expect(loadSpawnPty).toHaveBeenCalledTimes(2)); + expect(resolveAgentMapIdentity).toHaveBeenCalledTimes(3); + inScope = false; + resumeLoader.resolve(spawnPty); - expect(adapter.resume).toHaveBeenLastCalledWith( - "agent-uuid-1", - expect.objectContaining({ mcpConfigFile: `/generated/${session.id}/mcp-config.json` }), + await expect(resuming).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, ); + expect(resolveAgentMapIdentity).toHaveBeenCalledTimes(4); + 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("derives trusted Agent Map 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) => ({ + 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, - role: "agent-builder" as const, - assignment: { kind: "unplanned" as const }, - })); - const { manager, spawns } = makeManager({ - buildLaunchOpts, + }); + const resolveAgentMapIdentity = vi + .fn() + .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) + .mockImplementationOnce(async (sessionId: string) => identity(sessionId)) + .mockResolvedValueOnce(undefined); + const { manager, adapter, spawns } = makeManager({ resolveAgentMapIdentity, - onAgentMapSessionExit, }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); - expect(session.agentMapIdentity).toMatchObject({ - sessionId: session.id, - role: "agent-builder", - assignment: { kind: "unplanned" }, + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", }); - expect(buildLaunchOpts).toHaveBeenLastCalledWith( - session.id, - expect.anything(), - expect.objectContaining({ agentMapIdentity: session.agentMapIdentity }), - ); - await manager.setAgentSessionId(session.id, "agent-uuid-map"); + await manager.setAgentSessionId(session.id, "provider-project-session"); spawns[0]?.emitExit(0); await manager.flush(); - expect(onAgentMapSessionExit).toHaveBeenCalledWith(session.id); - await manager.resume(session.id); - expect(buildLaunchOpts).toHaveBeenLastCalledWith( + const beforeResume = structuredClone(manager.get(session.id)); + + await expect(manager.resume(session.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); + + expect(resolveAgentMapIdentity).toHaveBeenLastCalledWith( session.id, - expect.anything(), - expect.objectContaining({ resume: true, agentMapIdentity: session.agentMapIdentity }), + 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); }); it("registerHistorical() creates an exited placeholder session resumable later", async () => { @@ -1941,11 +3267,15 @@ describe("SessionManager", () => { } it("throws SessionNotResumeableError instead of spawning when the agent no longer holds the conversation", async () => { - const adapter = createFakeAdapter({ canResume: vi.fn(async () => false) }); + const adapter = createFakeAdapter({ + canResume: vi.fn(async () => false), + }); const { manager, spawns } = makeManager({ adapter }); const session = await registerPhantom(manager); - await expect(manager.resume(session.id)).rejects.toThrow(SessionNotResumeableError); + 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); @@ -1953,7 +3283,9 @@ describe("SessionManager", () => { }); it("names the agent and the reason so the 409 tells the user WHY", async () => { - const adapter = createFakeAdapter({ canResume: vi.fn(async () => false) }); + const adapter = createFakeAdapter({ + canResume: vi.fn(async () => false), + }); const { manager } = makeManager({ adapter }); const session = await registerPhantom(manager); @@ -1968,7 +3300,9 @@ describe("SessionManager", () => { 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 { manager } = makeManager({ + adapter: createFakeAdapter({ canResume }), + }); const session = await registerPhantom(manager); await manager.resume(session.id); @@ -1977,10 +3311,14 @@ describe("SessionManager", () => { }); it("leaves a rejected session's record untouched — still exited, same lastActiveAt", async () => { - const { manager } = makeManager({ adapter: createFakeAdapter({ canResume: vi.fn(async () => false) }) }); + const { manager } = makeManager({ + adapter: createFakeAdapter({ canResume: vi.fn(async () => false) }), + }); const session = await registerPhantom(manager); - await expect(manager.resume(session.id)).rejects.toThrow(SessionNotResumeableError); + await expect(manager.resume(session.id)).rejects.toThrow( + SessionNotResumeableError, + ); const after = manager.get(session.id); expect(after?.status).toBe("exited"); @@ -1998,7 +3336,9 @@ describe("SessionManager", () => { }); const session = await registerPhantom(manager); - await expect(manager.resume(session.id)).rejects.toThrow("node-pty exploded"); + await expect(manager.resume(session.id)).rejects.toThrow( + "node-pty exploded", + ); const after = manager.get(session.id); expect(after?.status).toBe("exited"); @@ -2009,18 +3349,28 @@ describe("SessionManager", () => { 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 session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const { manager } = makeManager({ + adapter: createFakeAdapter({ canResume }), + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); await manager.kill(session.id); - await expect(manager.resume(session.id)).rejects.toThrow(SessionNotResumeableError); + 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); expect(session.boundWorkflowPath).toBeNull(); expect(manager.get(session.id)?.boundWorkflowPath).toBeNull(); }); @@ -2034,7 +3384,10 @@ describe("SessionManager", () => { // 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 session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); expect(writeWorkspaceContext).toHaveBeenCalledTimes(1); expect(writeWorkspaceContext).toHaveBeenCalledWith(session); @@ -2067,7 +3420,9 @@ describe("SessionManager", () => { }); const { manager } = makeManager({ writeWorkspaceContext }); - await expect(manager.create({ cwd: "/tmp/proj", harness: "claude-code" })).rejects.toThrow("disk full"); + 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). @@ -2090,7 +3445,11 @@ describe("SessionManager", () => { void args; return createFakePty().pty as unknown as ReturnType; }; - const { manager } = makeManager({ buildLaunchOpts, prepareWorkspaceContext, spawnPty }); + const { manager } = makeManager({ + buildLaunchOpts, + prepareWorkspaceContext, + spawnPty, + }); const session = await manager.registerHistorical({ agentSessionId: "agent-uuid-9", @@ -2102,7 +3461,9 @@ describe("SessionManager", () => { await manager.resume(session.id); expect(prepareWorkspaceContext).toHaveBeenCalledTimes(1); - expect(prepareWorkspaceContext).toHaveBeenCalledWith(manager.get(session.id)); + expect(prepareWorkspaceContext).toHaveBeenCalledWith( + manager.get(session.id), + ); expect(order).toEqual(["prompt", "prepare", "spawn"]); }); @@ -2120,7 +3481,9 @@ describe("SessionManager", () => { lastActiveAt: "2026-01-01T00:00:00.000Z", }); - await expect(manager.resume(session.id)).rejects.toThrow("context path unreadable"); + await expect(manager.resume(session.id)).rejects.toThrow( + "context path unreadable", + ); expect(spawns).toHaveLength(0); expect(manager.get(session.id)).toMatchObject({ @@ -2137,7 +3500,9 @@ describe("SessionManager", () => { // 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(); + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).resolves.toBeDefined(); const historical = await manager.registerHistorical({ agentSessionId: "agent-uuid-9", @@ -2202,7 +3567,9 @@ describe("SessionManager", () => { 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(); + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).resolves.toBeDefined(); }); }); @@ -2258,7 +3625,9 @@ describe("SessionManager", () => { }); const { manager } = makeManager({ ensureCanvasTemplate }); - await expect(manager.create({ cwd: "/tmp/proj", harness: "claude-code" })).rejects.toThrow("read-only fs"); + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).rejects.toThrow("read-only fs"); expect(manager.list()[0]?.status).toBe("exited"); }); @@ -2270,16 +3639,18 @@ describe("SessionManager", () => { const statuses: string[] = []; manager.onStatusChange((s) => statuses.push(s.status)); - await expect(manager.create({ cwd: "/tmp/proj", harness: "claude-code" })).rejects.toThrow( - "posix_spawnp failed", - ); + 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"); // 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[]; + const raw = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as HarnessSession[]; expect(raw[0]?.status).toBe("exited"); }); @@ -2304,7 +3675,10 @@ describe("SessionManager", () => { 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" }); + 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 @@ -2329,8 +3703,14 @@ describe("SessionManager", () => { // 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" }); + 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[] = []; @@ -2344,13 +3724,21 @@ describe("SessionManager", () => { expect(manager.attach(session.id, () => {})).toBeUndefined(); await manager.flush(); - const raw = JSON.parse(await readFile(sessionsPath, "utf8")) as HarnessSession[]; + const raw = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as HarnessSession[]; expect(raw[0]?.status).toBe("exited"); }); 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" }); + const { manager } = makeManager({ + fakePid: 4242, + isPidAlive: () => true, + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.sweepDeadSessions(); @@ -2360,7 +3748,10 @@ describe("SessionManager", () => { 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.sweepDeadSessions(); @@ -2370,7 +3761,10 @@ describe("SessionManager", () => { 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" }); + 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 @@ -2392,7 +3786,10 @@ describe("SessionManager", () => { 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); const statuses: (string | null)[] = []; manager.onStatusChange((s) => { @@ -2405,13 +3802,20 @@ describe("SessionManager", () => { 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"); + const raw = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as HarnessSession[]; + expect(raw.find((s) => s.id === session.id)?.boundWorkflowPath).toBe( + "/tmp/leasing", + ); }); it("unbinds with null, persisting and notifying again", async () => { const { manager } = makeManager(); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setBoundWorkflowPath(session.id, "/tmp/leasing"); const statuses: (string | null)[] = []; @@ -2431,14 +3835,19 @@ describe("SessionManager", () => { const statuses: string[] = []; manager.onStatusChange(() => statuses.push("fired")); - expect(() => manager.setBoundWorkflowPath("does-not-exist", "/tmp/leasing")).not.toThrow(); + expect(() => + manager.setBoundWorkflowPath("does-not-exist", "/tmp/leasing"), + ).not.toThrow(); await manager.flush(); expect(statuses).toEqual([]); }); 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" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); manager.setBoundWorkflowPath(session.id, "/tmp/leasing"); await manager.flush(); @@ -2461,10 +3870,15 @@ describe("SessionManager", () => { return fake.pty as unknown as ReturnType; }; const { manager } = makeManager({ spawnPty }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = 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_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); }); @@ -2555,7 +3969,8 @@ describe("SessionManager", () => { * "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"; + 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 ?? {}); @@ -2591,7 +4006,10 @@ describe("SessionManager", () => { fakePid: 9999, isPidAlive: () => pidAlive, }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); expect(session.status).toBe("running"); // Confirm the pty exists and won't emit onExit on its own — the exit @@ -2620,8 +4038,14 @@ describe("SessionManager", () => { }); 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 session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const { manager, spawns } = makeManager({ + fakePid: 8888, + isPidAlive: () => false, + }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); const killPromise = manager.kill(session.id); // Drive the real onExit — this fires before the escalation timer. @@ -2640,7 +4064,10 @@ describe("SessionManager", () => { fakePid: 7777, isPidAlive: () => pidAlive, }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); const killPromise = manager.kill(session.id); expect(spawns[0]?.pty.kill).toHaveBeenCalledTimes(1); // initial SIGTERM (no arg) @@ -2659,7 +4086,10 @@ describe("SessionManager", () => { }); 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 { 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" }); @@ -2691,7 +4121,10 @@ describe("SessionManager", () => { // 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 }); + 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" }); @@ -2722,7 +4155,10 @@ describe("SessionManager", () => { // Always "alive" — simulates an EPERM zombie that survives all probes. isPidAlive: () => true, }); - const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + const session = await manager.create({ + cwd: "/tmp/proj", + harness: "claude-code", + }); const killPromise = manager.kill(session.id); // Initial signal sent. @@ -2766,7 +4202,9 @@ describe("SessionManager", () => { lastActiveAt: new Date().toISOString(), }); - await expect(manager.resume(session.id)).rejects.toThrow(ExternalHarnessError); + await expect(manager.resume(session.id)).rejects.toThrow( + ExternalHarnessError, + ); }); it("resume() ExternalHarnessError has code HARNESS_EXTERNAL and names the harness label", async () => { @@ -2805,7 +4243,9 @@ describe("SessionManager", () => { lastActiveAt: new Date().toISOString(), }); - await expect(manager.submitInput(session.id, "hello")).rejects.toThrow(ExternalHarnessError); + await expect(manager.submitInput(session.id, "hello")).rejects.toThrow( + ExternalHarnessError, + ); }); it("submitInput() ExternalHarnessError has code HARNESS_EXTERNAL", async () => { diff --git a/packages/harness/src/core/session-manager.ts b/packages/harness/src/core/session-manager.ts index 6e88c4045..f0029ebfb 100644 --- a/packages/harness/src/core/session-manager.ts +++ b/packages/harness/src/core/session-manager.ts @@ -22,8 +22,9 @@ import { type SpawnSpec, } from "../shared/types.js"; import type { - PlannerSessionMetadata, - PlanningSessionIdentity, + ProjectAgentSession, + ProjectBootstrapErrorCode, + ProjectBootstrapMetadata, } from "../shared/agent-map.js"; import { expandHome } from "./paths.js"; import { @@ -66,12 +67,234 @@ 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"; + } +} + +export class ProjectSessionScopeUnavailableError extends Error { + readonly code = "PROJECT_SESSION_SCOPE_UNAVAILABLE"; + + constructor(readonly sessionId: string) { + super("the session's Studio project scope could not be revalidated"); + this.name = "ProjectSessionScopeUnavailableError"; + } +} + +/** 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"; + } +} + +type PersistedIdentityMigration = { + identity?: ProjectAgentSession; + bootstrap?: ProjectBootstrapMetadata; + outcome: "unchanged" | "migrated" | "rejected"; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseProjectAgentSession( + value: unknown, + expectedSessionId: string, +): ProjectAgentSession | null { + if ( + !isRecord(value) || + typeof value.projectId !== "string" || + value.projectId === "" || + typeof value.userId !== "string" || + value.userId === "" || + value.sessionId !== expectedSessionId + ) { + return null; + } + return { + projectId: value.projectId, + userId: value.userId, + sessionId: expectedSessionId, + }; +} + +function sameProjectAgent( + left: ProjectAgentSession, + right: ProjectAgentSession, +): boolean { + return ( + left.projectId === right.projectId && + left.userId === right.userId && + left.sessionId === right.sessionId + ); +} + +function parseBootstrapState( + value: unknown, +): ProjectBootstrapMetadata["bootstrap"] | null { + if (!isRecord(value) || typeof value.status !== "string") return null; + switch (value.status) { + case "pending": + return { status: "pending" }; + case "generating": + return typeof value.attemptId === "string" && value.attemptId !== "" + ? { status: "generating", attemptId: value.attemptId } + : null; + case "delivered": + return typeof value.messageId === "string" && value.messageId !== "" + ? { status: "delivered", messageId: value.messageId } + : null; + 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", + ].includes(value.errorCode) + ? { + status: "failed", + retryable: value.retryable, + errorCode: value.errorCode as ProjectBootstrapErrorCode, + } + : null; + case "skipped": + return value.reason === "user-proceeded" || + value.reason === "map-not-empty" + ? { status: "skipped", reason: value.reason } + : null; + default: + return null; + } +} + +/** + * Accept both the final neutral shape and persisted planner-era metadata. Extra + * role/assignment keys are dropped; conflicting principals are never trusted. + */ +function migratePersistedProjectIdentity( + session: HarnessSession, +): PersistedIdentityMigration { + const raw = session as HarnessSession & { + agentMapIdentity?: unknown; + planning?: unknown; + projectBootstrap?: unknown; + }; + const direct = parseProjectAgentSession(raw.agentMapIdentity, session.id); + const planning = isRecord(raw.planning) ? raw.planning : null; + const planned = + planning && isRecord(planning.identity) + ? parseProjectAgentSession(planning.identity, session.id) + : null; + // A present-but-invalid authority record is ambiguous. Never repair it from + // a second field and silently choose one principal: retain the complete + // persisted session for operator recovery and fail scope revalidation when + // somebody later tries to resume it. + if (raw.agentMapIdentity !== undefined && !direct) { + return { outcome: "rejected" }; + } + if (raw.planning !== undefined && (!planning || !planned)) { + return { identity: direct ?? undefined, outcome: "rejected" }; + } + if (direct && planned && !sameProjectAgent(direct, planned)) { + return { outcome: "rejected" }; + } + let identity = direct ?? planned ?? undefined; + + let bootstrap: ProjectBootstrapMetadata | undefined; + const current = isRecord(raw.projectBootstrap) ? raw.projectBootstrap : null; + if (raw.projectBootstrap !== undefined && !current) { + return { identity, outcome: "rejected" }; + } + if (current) { + const currentIdentity = parseProjectAgentSession( + { + projectId: current.projectId, + userId: current.userId, + sessionId: current.targetSessionId, + }, + session.id, + ); + const state = parseBootstrapState(current.bootstrap); + if ( + !currentIdentity || + !state || + !Array.isArray(current.queuedInputIds) || + !current.queuedInputIds.every((id) => typeof id === "string") || + (identity && !sameProjectAgent(identity, currentIdentity)) + ) { + return { identity, outcome: "rejected" }; + } + identity ??= currentIdentity; + bootstrap = { + projectId: currentIdentity.projectId, + userId: currentIdentity.userId, + targetSessionId: currentIdentity.sessionId, + bootstrap: state, + queuedInputIds: [...current.queuedInputIds], + }; + } else if (planning && planned) { + const state = parseBootstrapState(planning.greeting); + if ( + !state || + !Array.isArray(planning.queuedInputIds) || + !planning.queuedInputIds.every((id) => typeof id === "string") + ) { + return { identity, outcome: "rejected" }; + } + bootstrap = { + projectId: planned.projectId, + userId: planned.userId, + targetSessionId: planned.sessionId, + bootstrap: state, + queuedInputIds: [...planning.queuedInputIds], + }; + } + + const hadLegacyIdentity = + isRecord(raw.agentMapIdentity) && + ("role" in raw.agentMapIdentity || "assignment" in raw.agentMapIdentity); + return { + ...(identity ? { identity } : {}), + ...(bootstrap ? { bootstrap } : {}), + outcome: + hadLegacyIdentity || raw.planning !== undefined || (!!current && !direct) + ? "migrated" + : "unchanged", + }; +} + // node-pty is a native module. Load it lazily so a missing/broken prebuild on // an unsupported platform surfaces as a spawn-time error instead of crashing // the whole server at import time. type IPty = import("node-pty").IPty; type PtyForkOptions = import("node-pty").IPtyForkOptions; -export type PtySpawnFn = (file: string, args: string[], options: PtyForkOptions) => IPty; +export type PtySpawnFn = ( + file: string, + args: string[], + options: PtyForkOptions, +) => IPty; let defaultSpawn: PtySpawnFn | undefined; let defaultSpawnError: Error | undefined; @@ -90,7 +313,9 @@ let defaultSpawnError: Error | undefined; export async function ensureSpawnHelperExecutable(): Promise { if (process.platform === "win32") return; try { - const nodePtyPkgJson = createRequire(import.meta.url).resolve("node-pty/package.json"); + const nodePtyPkgJson = createRequire(import.meta.url).resolve( + "node-pty/package.json", + ); const helperPath = join( dirname(nodePtyPkgJson), "prebuilds", @@ -304,17 +529,22 @@ export type SessionActivityListener = (harnessSessionId: string) => void; */ export type LaunchOptsBuilder = ( harnessSessionId: string, - req: Pick, + req: Pick< + CreateSessionRequest, + "cwd" | "harness" | "profile" | "rehydrateFrom" | "theme" + >, context?: { promptAppendix?: string; /** Native CLI notice shown before a fresh session's first prompt. */ sessionStartSystemMessage?: string; - agentMapIdentity?: PlanningSessionIdentity; + agentMapIdentity?: ProjectAgentSession; /** Server-composed secret launch metadata, never accepted from REST. */ agentMapMcp?: { url: string; bearerToken: string }; resume?: boolean; }, -) => Omit | Promise>; +) => + | Omit + | Promise>; const defaultBuildLaunchOpts: LaunchOptsBuilder = () => ({}); @@ -330,13 +560,37 @@ export interface SessionManagerOptions { sessionsPath?: string; /** Injectable for tests. Defaults to a lazily-loaded node-pty. */ spawnPty?: PtySpawnFn; + /** Async node-pty loader seam used only when `spawnPty` is absent. Production + * uses the module loader; tests use this to prove authorization is checked + * after that final await and before PTY admission. */ + loadSpawnPty?: () => Promise; buildLaunchOpts?: LaunchOptsBuilder; /** Revalidates cwd containment and current principal before every spawn. */ resolveAgentMapIdentity?: ( sessionId: string, cwd: string, - persisted?: PlanningSessionIdentity, - ) => Promise; + persisted?: ProjectAgentSession, + ) => Promise; + /** Claims new-project lifecycle metadata after trusted scope resolution. */ + prepareProjectSession?: ( + identity: ProjectAgentSession, + request: CreateSessionRequest, + ) => Promise<{ + initialTitle?: string; + projectBootstrap?: ProjectBootstrapMetadata; + }>; + /** Synchronous notification before a real terminal write crosses the PTY. */ + onTerminalInput?: (sessionId: string) => void; + /** Content-free observability for persisted identity normalization. */ + onProjectAgentIdentityMigration?: (event: { + sessionId: string; + outcome: "migrated" | "rejected"; + }) => void; + /** Registers only sessions that carry a durable bootstrap lifecycle. */ + onProjectBootstrapSession?: ( + session: HarnessSession, + mode: "created" | "resumed", + ) => Promise | void; /** Revokes launch capabilities/transports after every exit path. */ onAgentMapSessionExit?: (sessionId: string) => void | Promise; now?: () => string; @@ -393,10 +647,10 @@ export interface SessionManagerOptions { } export interface TrustedSessionCreateOptions { - /** Server-authored only. Never populated from CreateSessionRequest. */ - planning?: (sessionId: string) => PlannerSessionMetadata; /** Future E5 seam for a server-authored planned builder assignment. */ - agentMapIdentity?: (sessionId: string) => PlanningSessionIdentity; + agentMapIdentity?: (sessionId: string) => ProjectAgentSession; + /** Server-owned initial title. Generic POST /sessions cannot set it. */ + initialTitle?: string; /** Focused trusted context composed into the existing system prompt. */ promptAppendix?: (sessionId: string) => string; /** Server-authored native CLI orientation for a newly created session. */ @@ -404,11 +658,13 @@ export interface TrustedSessionCreateOptions { /** Server-owned coordinator predecessor. This may differ from the older * history record used to build the rehydration brief. */ handoffFromSessionId?: string; + /** Internal auto-create guard. Ordinary/user-requested creates omit this and + * remain valid even when the project's first-session lifecycle already has + * an owner. */ + requireProjectBootstrapClaim?: boolean; } export interface TrustedSessionResumeOptions { - /** Server-authored only. Used to suppress fresh-only lifecycle work. */ - planning?: PlannerSessionMetadata; /** Recomputed focused context for the resumed process. */ promptAppendix?: string; } @@ -503,11 +759,14 @@ export class SessionManager { * leaking historical aliases through the browser DTO. */ private readonly agentSessionOwnersPath: string; private readonly spawnPty: PtySpawnFn | undefined; + private readonly loadSpawnPty: () => Promise; private readonly buildLaunchOpts: LaunchOptsBuilder; - private readonly resolveAgentMapIdentity: - | SessionManagerOptions["resolveAgentMapIdentity"]; - private readonly onAgentMapSessionExit: - | SessionManagerOptions["onAgentMapSessionExit"]; + private readonly resolveAgentMapIdentity: SessionManagerOptions["resolveAgentMapIdentity"]; + private readonly prepareProjectSession: SessionManagerOptions["prepareProjectSession"]; + private readonly onAgentMapSessionExit: SessionManagerOptions["onAgentMapSessionExit"]; + private readonly onTerminalInput: (sessionId: string) => void; + private readonly onProjectAgentIdentityMigration: SessionManagerOptions["onProjectAgentIdentityMigration"]; + private readonly onProjectBootstrapSession: SessionManagerOptions["onProjectBootstrapSession"]; private readonly now: () => string; private readonly generateId: () => string; private readonly writeSessionRegistry: @@ -516,14 +775,34 @@ export class SessionManager { private readonly writeAgentSessionOwnerRegistry: | ((file: string, serialized: string) => Promise) | undefined; - private readonly writeWorkspaceContext: (session: HarnessSession) => Promise; - private readonly prepareWorkspaceContext: (session: HarnessSession) => Promise; + 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(); + /** 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(); + /** 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; + } + >(); private readonly statusEmitter = new EventEmitter(); private readonly activityEmitter = new EventEmitter(); /** Epoch ms of the last `onActivity` broadcast per session — see `recordActivity()`. */ @@ -539,8 +818,12 @@ export class SessionManager { * 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 initialized = false; + private closing = false; constructor(options: SessionManagerOptions) { this.adapters = options.adapters; @@ -550,20 +833,31 @@ export class SessionManager { this.revokeIngestToken = (sessionId) => options.ingestCredentials.revoke(sessionId); this.collectorUrl = options.collectorUrl; - this.sessionsPath = expandHome(options.sessionsPath ?? HARNESS_PATHS.sessions); + this.sessionsPath = expandHome( + options.sessionsPath ?? HARNESS_PATHS.sessions, + ); this.agentSessionOwnersPath = `${this.sessionsPath}.agent-session-owners.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.now = options.now ?? (() => new Date().toISOString()); this.generateId = options.generateId ?? randomUUID; this.writeSessionRegistry = options.writeSessionRegistry; this.writeAgentSessionOwnerRegistry = options.writeAgentSessionOwnerRegistry; - this.writeWorkspaceContext = options.writeWorkspaceContext ?? (async () => {}); - this.prepareWorkspaceContext = options.prepareWorkspaceContext ?? (async () => {}); - this.ensureCanvasTemplate = options.ensureCanvasTemplate ?? (async () => {}); + 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. @@ -587,6 +881,36 @@ export class SessionManager { } 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. + delete session.planning; + 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; @@ -635,94 +959,182 @@ export class SessionManager { // 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); + if (info?.mode === "external") + throw new ExternalHarnessError(harness, info.label); throw new AdapterNotFoundError(harness); } 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); + } + } + + 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; + } + async create( req: CreateSessionRequest, trusted: TrustedSessionCreateOptions = {}, ): Promise { + if (this.closing) throw new SessionManagerClosingError(); const id = this.generateId(); const adapter = this.getAdapter(req.harness); - const planning = trusted.planning?.(id); - const trustedIdentity = trusted.agentMapIdentity?.(id) ?? planning?.identity; + const trustedIdentity = trusted.agentMapIdentity?.(id); const agentMapIdentity = this.resolveAgentMapIdentity ? await this.resolveAgentMapIdentity(id, req.cwd, trustedIdentity) : trustedIdentity; - const promptAppendix = trusted.promptAppendix?.(id); - const sessionStartSystemMessage = trusted.sessionStartSystemMessage?.(id); - const launchContext = - promptAppendix || sessionStartSystemMessage || agentMapIdentity - ? { - ...(promptAppendix ? { promptAppendix } : {}), - ...(sessionStartSystemMessage ? { sessionStartSystemMessage } : {}), - ...(agentMapIdentity ? { agentMapIdentity } : {}), - } - : undefined; - const opts: LaunchOpts = { - harnessSessionId: id, - cwd: req.cwd, - ...(await (launchContext - ? this.buildLaunchOpts(id, req, launchContext) - : this.buildLaunchOpts(id, req))), - }; - let spec: SpawnSpec; - try { - spec = adapter.launch(opts); - } catch (error) { - await Promise.resolve(this.onAgentMapSessionExit?.(id)).catch(() => {}); - throw error; - } - const session: HarnessSession = { - id, - agentSessionId: null, - harness: req.harness, - cwd: req.cwd, - title: 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, - ...(planning ? { planning } : {}), - ...(agentMapIdentity - ? { agentMapIdentity: structuredClone(agentMapIdentity) } - : {}), + 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 sessionStartSystemMessage = + trusted.sessionStartSystemMessage?.(id); + const launchContext = + promptAppendix || sessionStartSystemMessage || agentMapIdentity + ? { + ...(promptAppendix ? { promptAppendix } : {}), + ...(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) { + await Promise.resolve( + this.onProjectBootstrapSession?.(session, "created"), + ).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; }; - 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); - } 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(); } /** @@ -788,6 +1200,7 @@ export class SessionManager { id: string, trusted: TrustedSessionResumeOptions = {}, ): Promise { + if (this.closing) throw new SessionManagerClosingError(); const session = this.sessions.get(id); if (!session) throw new UnknownSessionError(id); if (!session.agentSessionId) { @@ -796,6 +1209,9 @@ export class SessionManager { if (this.ptys.has(id)) { throw new SessionAlreadyLiveError(id); } + if (this.rejectedProjectSessionMetadata.has(id)) { + throw new ProjectSessionScopeUnavailableError(id); + } const adapter = this.getAdapter(session.harness); // Pre-flight against the agent's OWN store before touching the record. // Holding an agentSessionId only means our SessionStart hook fired once; @@ -805,62 +1221,72 @@ export class SessionManager { // Failing here instead keeps the record exactly as it was — unspawned, // and (see below) with its real lastActiveAt intact. if (!(await adapter.canResume(session.agentSessionId, session.cwd))) { - const label = listHarnessAdapters().find((a) => a.id === session.harness)?.label ?? session.harness; + const label = + listHarnessAdapters().find((a) => a.id === session.harness)?.label ?? + session.harness; throw new SessionNotResumeableError( id, `${label} no longer has the conversation for this session (${session.agentSessionId}) in ${session.cwd}. ` + `Sessions that ended before their first prompt are never written to the coding agent's history, so there is nothing to resume — start a new session in this directory instead.`, ); } - if (trusted.planning) { - session.planning = structuredClone(trusted.planning); - } - const trustedIdentity = trusted.planning?.identity; + const trustedIdentity = session.agentMapIdentity; const agentMapIdentity = this.resolveAgentMapIdentity - ? await this.resolveAgentMapIdentity( - id, - session.cwd, - trustedIdentity ?? session.agentMapIdentity, - ) - : trustedIdentity ?? session.agentMapIdentity; + ? await this.resolveAgentMapIdentity(id, session.cwd, trustedIdentity) + : trustedIdentity; if (agentMapIdentity) session.agentMapIdentity = structuredClone(agentMapIdentity); - else delete session.agentMapIdentity; - const launchContext = - trusted.promptAppendix || agentMapIdentity - ? { - ...(trusted.promptAppendix - ? { promptAppendix: trusted.promptAppendix } - : {}), - ...(agentMapIdentity ? { agentMapIdentity } : {}), - resume: true as const, - } - : undefined; - const opts: LaunchOpts = { - harnessSessionId: id, - cwd: session.cwd, - ...(await (launchContext - ? this.buildLaunchOpts(id, session, launchContext) - : this.buildLaunchOpts(id, session))), - }; + else if (trustedIdentity) throw new ProjectSessionScopeUnavailableError(id); + // Claim the pre-PTY resume window before generated launch state is built. + // Exit observers may finish asynchronous bookkeeping after kill() resolves; + // they must see this lifecycle as starting, not schedule cleanup against + // files that the resumed process is currently regenerating. + const lastActiveBeforeResume = session.lastActiveAt; + const statusBeforeResume = session.status; + const exitCodeBeforeResume = session.exitCode; + session.status = "starting"; + session.exitCode = null; + session.lastActiveAt = this.now(); + let opts: LaunchOpts; let spec: SpawnSpec; try { + const launchContext = + trusted.promptAppendix || agentMapIdentity + ? { + ...(trusted.promptAppendix + ? { promptAppendix: trusted.promptAppendix } + : {}), + ...(agentMapIdentity ? { agentMapIdentity } : {}), + resume: true as const, + } + : undefined; + opts = { + harnessSessionId: id, + cwd: session.cwd, + ...(await (launchContext + ? this.buildLaunchOpts(id, session, launchContext) + : this.buildLaunchOpts(id, session))), + }; spec = adapter.resume(session.agentSessionId, opts); } catch (error) { + // Resume preparation may rotate project capabilities or write generated + // launch state before the process exists. No starting state was exposed + // or persisted yet, so restore the exact prior record while releasing + // any prepared authority. + session.status = statusBeforeResume; + session.exitCode = exitCodeBeforeResume; + session.lastActiveAt = lastActiveBeforeResume; await Promise.resolve(this.onAgentMapSessionExit?.(id)).catch(() => {}); throw error; } - // Kept so the failure path below can put it back: `lastActiveAt` is - // stamped here only to keep sweepDeadSessions() from reaping this record + // The prior value is kept so the failure path below can put it back: + // `lastActiveAt` is stamped only to keep sweepDeadSessions() from reaping + // this record // during the pre-pty window (it reaps non-exited records with no pty once // they're older than the grace period). If the resume never produces a // pty, that stamp is not activity and must not survive — otherwise a // session idle since last night reports "Ran for 6h 25m" purely because // someone clicked Resume. - const lastActiveBeforeResume = session.lastActiveAt; - session.status = "starting"; - session.exitCode = null; - session.lastActiveAt = this.now(); try { await this.persist(); this.emitStatus(session); @@ -874,7 +1300,18 @@ export class SessionManager { // — a session from before the canvas kit existed, or one whose canvas // file was somehow deleted, still gets a live pane on resume. await this.ensureCanvasTemplate(session.cwd); - await this.spawn(session, spec); + await this.spawn(session, spec, () => + this.revalidateAgentMapIdentity( + session.id, + session.cwd, + agentMapIdentity, + ), + ); + if (session.projectBootstrap) { + await Promise.resolve( + this.onProjectBootstrapSession?.(session, "resumed"), + ).catch(() => {}); + } } catch (err) { // Same best-effort reconciliation as create(): the first persist can be // the failure, and a failed repair must not replace that original error. @@ -979,6 +1416,11 @@ export class SessionManager { 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 @@ -998,7 +1440,10 @@ export class SessionManager { 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)) { + if ( + typeof handle.pty.pid === "number" && + !this.isPidAlive(handle.pty.pid) + ) { this.markExited(session.id, handle, null); } continue; @@ -1008,16 +1453,56 @@ export class SessionManager { // 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); + if (!(ageMs < NO_PTY_SWEEP_GRACE_MS)) + void this.transitionExited(session, null); } } + /** + * 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) { + staged.handle.pty.write("\x15"); + this.observeTrustedTerminalInput(staged.handle, "\x15"); + } + return true; + } + write(id: string, data: string): boolean { const handle = this.ptys.get(id); if (!handle) return false; + const session = this.sessions.get(id); + if (session && session.status !== "exited") { + try { + this.onTerminalInput(id); + } catch { + // Input priority is local correctness; lifecycle telemetry/persistence + // callbacks are best effort and cannot block a person's terminal. + } + } + this.terminalInputEpochs.set( + id, + (this.terminalInputEpochs.get(id) ?? 0) + 1, + ); + const staged = this.stagedInputs.get(id); + if (staged?.handle === handle && !staged.preempted) { + staged.preempted = true; + if (staged.textWritten) { + // Remove the server-staged line before forwarding the person's bytes, + // so the two inputs can never be submitted as one corrupted prompt. + handle.pty.write("\x15"); + this.observeTrustedTerminalInput(handle, "\x15"); + } + } handle.pty.write(data); this.observeTrustedTerminalInput(handle, data); - const session = this.sessions.get(id); if (session) { session.lastActiveAt = this.now(); void this.persist(); @@ -1045,6 +1530,7 @@ export class SessionManager { text: string, submit = true, canWrite?: () => boolean | Promise, + background = false, ): Promise { const remainsAuthorized = async (): Promise => { if (!canWrite) return true; @@ -1056,6 +1542,7 @@ export class SessionManager { }; const session = this.sessions.get(id); if (!session) return false; + const initialTerminalInputEpoch = this.terminalInputEpochs.get(id) ?? 0; // An external-harness session (e.g. conductor) never has a pty — surfacing // HARNESS_EXTERNAL here gives a 409 "managed by the X app" instead of a @@ -1063,7 +1550,8 @@ export class SessionManager { const handle = this.ptys.get(id); if (!handle) { const info = listHarnessAdapters().find((a) => a.id === session.harness); - if (info?.mode === "external") throw new ExternalHarnessError(session.harness, info.label); + if (info?.mode === "external") + throw new ExternalHarnessError(session.harness, info.label); return false; } if (!this.isReadyEnough(session, handle)) { @@ -1080,6 +1568,12 @@ export class SessionManager { if (canWrite && !(await remainsAuthorized())) { throw new SessionInputGuardRejectedError(false); } + if ( + background && + (this.terminalInputEpochs.get(id) ?? 0) !== initialTerminalInputEpoch + ) { + throw new SessionBackgroundInputPreemptedError(false); + } if (!submit) { handle.pty.write(text); @@ -1108,27 +1602,47 @@ export class SessionManager { ? handle.bracketedPaste.enabled : this.platform === "win32" && (this.adapters[session.harness]?.assumesBracketedPaste ?? false); - handle.pty.write(paste ? wrapPaste(text) : text); - // Observe the server-owned plaintext rather than the bracketed-paste - // transport wrapper. Embedded newlines invalidate the line, so prompt - // text containing `/clear` cannot impersonate an exact slash command. - this.observeTrustedSubmittedText(handle, text); - await sleep(SUBMIT_DELAY_MS); - // The pty may have been killed/replaced while we were waiting. - if (this.ptys.get(id) !== handle) return false; - // A project/account can change during the deliberate text→Enter delay. - // Do not submit the staged text under stale authority. - if (canWrite && !(await remainsAuthorized())) { - // Text was staged but not submitted. Clear the composer before - // releasing control so a later keypress cannot submit project-scoped - // content into the now-stale planner. Ctrl-U is a local line-clear, - // not an Enter/submission gesture. - handle.pty.write("\x15"); - this.observeTrustedTerminalInput(handle, "\x15"); - throw new SessionInputGuardRejectedError(true); + const staged = { + handle, + background, + preempted: false, + textWritten: false, + }; + if (this.stagedInputs.has(id)) { + throw new SessionBackgroundInputPreemptedError(false); + } + this.stagedInputs.set(id, staged); + try { + handle.pty.write(paste ? wrapPaste(text) : text); + staged.textWritten = true; + // Observe the server-owned plaintext rather than the bracketed-paste + // transport wrapper. Embedded newlines invalidate the line, so prompt + // text containing `/clear` cannot impersonate an exact slash command. + this.observeTrustedSubmittedText(handle, text); + await sleep(SUBMIT_DELAY_MS); + if (staged.preempted) { + throw new SessionBackgroundInputPreemptedError(true); + } + // The pty may have been killed/replaced while we were waiting. + if (this.ptys.get(id) !== handle) return false; + // A project/account can change during the deliberate text→Enter delay. + // Do not submit the staged text under stale authority. + if (canWrite && !(await remainsAuthorized())) { + // Text was staged but not submitted. Clear the composer before + // releasing control so a later keypress cannot submit project-scoped + // content into the now-stale session. Ctrl-U is a local line-clear, + // not an Enter/submission gesture. + handle.pty.write("\x15"); + this.observeTrustedTerminalInput(handle, "\x15"); + throw new SessionInputGuardRejectedError(true); + } + handle.pty.write("\r"); + this.observeTrustedTerminalInput(handle, "\r"); + } finally { + if (this.stagedInputs.get(id) === staged) { + this.stagedInputs.delete(id); + } } - handle.pty.write("\r"); - this.observeTrustedTerminalInput(handle, "\r"); } session.lastActiveAt = this.now(); @@ -1241,7 +1755,10 @@ 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; @@ -1340,15 +1857,15 @@ export class SessionManager { const session = this.sessions.get(id); if (!session) return false; const handle = this.ptys.get(id); - const transitionSource = source === "clear" || source === "resume" ? source : null; + const transitionSource = + source === "clear" || source === "resume" ? source : null; let authorization = handle?.agentSessionRotation ?? null; if (handle && authorization && authorization.expiresAt <= Date.now()) { handle.agentSessionRotation = null; authorization = null; } const matchesAuthorization = - transitionSource !== null && - authorization?.source === transitionSource; + transitionSource !== null && authorization?.source === transitionSource; // A matching clear/resume SessionStart consumes the user gesture even // when this is the first vendor id, Claude keeps the same id, or the @@ -1373,7 +1890,8 @@ export class SessionManager { // pointers. A fresh session can therefore never reclaim A's old id and // merge its events/transcript with A after a restart. if (ownerId !== undefined && ownerId !== id) return false; - if (ownerId === undefined) await this.reserveAgentSessionIdentity(digest, id); + if (ownerId === undefined) + await this.reserveAgentSessionIdentity(digest, id); const candidate = { ...session, agentSessionId }; let releaseFence: () => void = () => {}; @@ -1433,7 +1951,9 @@ export class SessionManager { const possibleControls = handle.trustedInputPasting ? [BRACKETED_PASTE_END] : [BRACKETED_PASTE_START, BRACKETED_PASTE_END]; - if (possibleControls.some((candidate) => candidate.startsWith(control))) { + if ( + possibleControls.some((candidate) => candidate.startsWith(control)) + ) { if (control === BRACKETED_PASTE_START) { handle.trustedInputPasting = true; handle.trustedInputLine = ""; @@ -1467,7 +1987,9 @@ export class SessionManager { } if (char === "\r") { if (!handle.trustedInputInvalid) { - const transition = this.rotationForTrustedLine(handle.trustedInputLine); + const transition = this.rotationForTrustedLine( + handle.trustedInputLine, + ); if (transition) { const now = Date.now(); handle.agentSessionRotation = { @@ -1564,14 +2086,14 @@ export class SessionManager { this.emitStatus(session); } - /** Persist a coordinator-owned metadata projection before exposing it. */ - async setPlanningMetadata( + /** Persist the neutral project-bootstrap projection before exposing it. */ + async setProjectBootstrapMetadata( id: string, - metadata: PlannerSessionMetadata, + metadata: ProjectBootstrapMetadata, ): Promise { const session = this.sessions.get(id); if (!session) throw new UnknownSessionError(id); - session.planning = structuredClone(metadata); + session.projectBootstrap = structuredClone(metadata); await this.persist(); this.emitStatus(session); } @@ -1744,7 +2266,10 @@ export class SessionManager { * keystrokes) must never wait on this, since a human answering the very * prompt this is waiting out is exactly how a session becomes ready. */ - private async waitUntilReady(id: string, timeoutMs: number): Promise { + private async waitUntilReady( + id: string, + timeoutMs: number, + ): Promise { const deadline = Date.now() + timeoutMs; for (;;) { const handle = this.ptys.get(id); @@ -1759,6 +2284,13 @@ export class SessionManager { /** Waits for all in-flight registry writes to settle. Useful before process * shutdown (and in tests that assert against the on-disk registry). */ async flush(): Promise { + // Project-session creation owns setup and registry writes that may span + // several awaits. Shutdown closes admission first, then waits for every + // already-queued publication to settle before considering persistence + // drained. + while (this.projectCreateQueues.size > 0) { + await Promise.all([...this.projectCreateQueues.values()]); + } await this.agentSessionIdentityQueue; await this.writeQueue; } @@ -1784,9 +2316,21 @@ export class SessionManager { this.emitStatus(session); } - private async spawn(session: HarnessSession, spec: SpawnSpec): Promise { + private async spawn( + session: HarnessSession, + spec: SpawnSpec, + revalidateAdmission?: () => Promise, + ): Promise { + if (this.closing) throw new SessionManagerClosingError(); const adapter = this.getAdapter(session.harness); - const spawnFn = this.spawnPty ?? (await loadDefaultSpawn()); + const spawnFn = this.spawnPty ?? (await this.loadSpawnPty()); + // Loading node-pty is lazy and asynchronous. Revalidate the project + // principal only after that final setup await; a binding or authenticated + // user can change while the module loads. The closing check follows the + // authorization await and then admission remains synchronous, so + // beginShutdown/killAll cannot miss a newly admitted process either. + await revalidateAdmission?.(); + if (this.closing) throw new SessionManagerClosingError(); const env: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) env[key] = value; @@ -1926,7 +2470,11 @@ export class SessionManager { const poll = setInterval(() => { const current = this.sessions.get(id); - if (!current || this.ptys.get(id) !== handle || current.status !== "running") { + if ( + !current || + this.ptys.get(id) !== handle || + current.status !== "running" + ) { clearInterval(poll); return; } @@ -1974,7 +2522,11 @@ export class SessionManager { * 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 { + 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 @@ -2011,11 +2563,16 @@ export class SessionManager { private transitionExited( session: HarnessSession, exitCode: number | null, - { stampLastActive = true, exitTail = null }: { stampLastActive?: boolean; exitTail?: string | null } = {}, + { + stampLastActive = true, + exitTail = null, + }: { stampLastActive?: boolean; exitTail?: string | null } = {}, ): Promise { this.revokeIngestToken(session.id); try { - void Promise.resolve(this.onAgentMapSessionExit?.(session.id)).catch(() => {}); + void Promise.resolve(this.onAgentMapSessionExit?.(session.id)).catch( + () => {}, + ); } catch { // Capability cleanup never delays durable session reconciliation. } @@ -2046,9 +2603,7 @@ export class SessionManager { private serializeAgentSessionIdentity( operation: () => Promise, ): Promise { - const next = this.agentSessionIdentityQueue - .catch(() => {}) - .then(operation); + const next = this.agentSessionIdentityQueue.catch(() => {}).then(operation); this.agentSessionIdentityQueue = next.then( () => {}, () => {}, @@ -2087,7 +2642,9 @@ export class SessionManager { record.owners === null || Array.isArray(record.owners) ) { - throw new Error("agent-session owner ledger has an unsupported version"); + 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) { @@ -2100,7 +2657,9 @@ export class SessionManager { ownerId.length === 0 || ownerId.length > 256 ) { - throw new Error("agent-session owner ledger contains an invalid entry"); + throw new Error( + "agent-session owner ledger contains an invalid entry", + ); } this.agentSessionOwners.set(digest, ownerId); } @@ -2169,9 +2728,8 @@ export class SessionManager { return; } await mkdir(dirname(this.agentSessionOwnersPath), { recursive: true }); - const tmpPath = `${this.agentSessionOwnersPath}.tmp-${process.pid}-${ - this.agentSessionOwnerWriteSeq++ - }`; + const tmpPath = `${this.agentSessionOwnersPath}.tmp-${process.pid}-${this + .agentSessionOwnerWriteSeq++}`; await writeFile(tmpPath, serialized, { encoding: "utf8", mode: 0o600 }); await rename(tmpPath, this.agentSessionOwnersPath); } diff --git a/packages/harness/src/core/session-record.test.ts b/packages/harness/src/core/session-record.test.ts index 3411295f3..e2ae05bde 100644 --- a/packages/harness/src/core/session-record.test.ts +++ b/packages/harness/src/core/session-record.test.ts @@ -47,10 +47,18 @@ function event(spec: EventSpec): AnalyticsEvent { }; } -const prompt = (ts: string, text: string, rest: Partial = {}): AnalyticsEvent => +const prompt = ( + ts: string, + text: string, + rest: Partial = {}, +): AnalyticsEvent => event({ type: "prompt.submitted", ts, payload: { prompt: text }, ...rest }); -const tool = (ts: string, name: string, rest: Partial = {}): AnalyticsEvent => +const tool = ( + ts: string, + name: string, + rest: Partial = {}, +): AnalyticsEvent => event({ type: "tool.call", ts, @@ -58,7 +66,11 @@ const tool = (ts: string, name: string, rest: Partial = {}): Analytic ...rest, }); -const completed = (ts: string, text: string | null, rest: Partial = {}): AnalyticsEvent => +const completed = ( + ts: string, + text: string | null, + rest: Partial = {}, +): AnalyticsEvent => event({ type: "turn.completed", ts, @@ -73,12 +85,20 @@ const completed = (ts: string, text: string | null, rest: Partial = { describe("foldSessionRecord", () => { it("folds prompt → tool calls → completion into one closed turn", () => { const record = foldSessionRecord([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd: "/repo" } }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd: "/repo" }, + }), prompt("2026-07-01T10:00:01.000Z", "add the screening step"), tool("2026-07-01T10:00:02.000Z", "Read"), tool("2026-07-01T10:00:03.000Z", "Edit"), completed("2026-07-01T10:00:04.000Z", "Added it."), - event({ type: "session.end", ts: "2026-07-01T10:00:05.000Z", payload: { reason: "exit" } }), + event({ + type: "session.end", + ts: "2026-07-01T10:00:05.000Z", + payload: { reason: "exit" }, + }), ]); expect(record.cwd).toBe("/repo"); @@ -96,7 +116,10 @@ describe("foldSessionRecord", () => { completedAt: "2026-07-01T10:00:04.000Z", incomplete: false, }); - expect(record.turns[0].toolCalls.map((call) => call.name)).toEqual(["Read", "Edit"]); + expect(record.turns[0].toolCalls.map((call) => call.name)).toEqual([ + "Read", + "Edit", + ]); }); it("orders by (ts, seq), so a seq restart across a resume doesn't reorder turns", () => { @@ -110,8 +133,13 @@ describe("foldSessionRecord", () => { completed("2026-07-01T11:00:02.000Z", "second reply", { seq: 1 }), ]); - expect(record.turns.map((turn) => turn.prompt)).toEqual(["first", "second"]); - expect(record.turns[1].toolCalls.map((call) => call.name)).toEqual(["Bash"]); + expect(record.turns.map((turn) => turn.prompt)).toEqual([ + "first", + "second", + ]); + expect(record.turns[1].toolCalls.map((call) => call.name)).toEqual([ + "Bash", + ]); // Sorting by seq alone would have put the resume's events first. expect(record.turns[0].assistantText).toBe("first reply"); }); @@ -121,7 +149,10 @@ describe("foldSessionRecord", () => { completed("2026-07-01T10:00:00.000Z", "reply", { seq: 2 }), prompt("2026-07-01T10:00:00.000Z", "ask", { seq: 1 }), ]); - expect(ordered.map((e) => e.type)).toEqual(["prompt.submitted", "turn.completed"]); + expect(ordered.map((e) => e.type)).toEqual([ + "prompt.submitted", + "turn.completed", + ]); }); it("keeps a trailing open turn and marks it incomplete", () => { @@ -132,7 +163,11 @@ describe("foldSessionRecord", () => { ]); expect(record.turns).toHaveLength(1); - expect(record.turns[0]).toMatchObject({ prompt: "deploy it", incomplete: true, completedAt: null }); + expect(record.turns[0]).toMatchObject({ + prompt: "deploy it", + incomplete: true, + completedAt: null, + }); expect(record.turns[0].toolCalls).toHaveLength(1); expect(record.limitations).toContain("incomplete-final-turn"); }); @@ -140,7 +175,11 @@ describe("foldSessionRecord", () => { it("session.end never closes the open turn — a session that died mid-turn says so", () => { const record = foldSessionRecord([ prompt("2026-07-01T10:00:00.000Z", "deploy it"), - event({ type: "session.end", ts: "2026-07-01T10:00:01.000Z", payload: { reason: "other" } }), + event({ + type: "session.end", + ts: "2026-07-01T10:00:01.000Z", + payload: { reason: "other" }, + }), ]); expect(record.turns[0].incomplete).toBe(true); @@ -164,20 +203,26 @@ describe("foldSessionRecord", () => { }); it("gives a turn.completed with nothing open its own promptless turn", () => { - const record = foldSessionRecord([completed("2026-07-01T10:00:00.000Z", "unprompted")]); + const record = foldSessionRecord([ + completed("2026-07-01T10:00:00.000Z", "unprompted"), + ]); expect(record.turns).toHaveLength(1); - expect(record.turns[0]).toMatchObject({ prompt: null, assistantText: "unprompted", incomplete: false }); + expect(record.turns[0]).toMatchObject({ + prompt: null, + assistantText: "unprompted", + incomplete: false, + }); }); - it("hides planner control input while retaining its assistant greeting", () => { + it("hides project-bootstrap control input while retaining its assistant response", () => { const record = foldSessionRecord([ event({ type: "prompt.submitted", ts: "2026-07-01T10:00:00.000Z", payload: { - prompt: "private infrastructure greeting instruction", - plannerOrigin: "infrastructure", - plannerAttemptId: "attempt-1", + prompt: "private infrastructure bootstrap instruction", + projectBootstrapOrigin: "infrastructure", + projectBootstrapAttemptId: "attempt-1", }, }), completed("2026-07-01T10:00:01.000Z", "What would you like to build?"), @@ -190,7 +235,7 @@ describe("foldSessionRecord", () => { assistantText: "What would you like to build?", }); expect(JSON.stringify(record)).not.toContain( - "private infrastructure greeting instruction", + "private infrastructure bootstrap instruction", ); }); @@ -203,9 +248,15 @@ describe("foldSessionRecord", () => { ]); expect(record.turns).toHaveLength(2); - expect(record.turns[0]).toMatchObject({ prompt: "first", incomplete: true }); + expect(record.turns[0]).toMatchObject({ + prompt: "first", + incomplete: true, + }); expect(record.turns[0].toolCalls).toHaveLength(1); - expect(record.turns[1]).toMatchObject({ prompt: "actually, do this instead", incomplete: false }); + expect(record.turns[1]).toMatchObject({ + prompt: "actually, do this instead", + incomplete: false, + }); // The incomplete turn isn't the LAST one, so that limitation doesn't apply. expect(record.limitations).not.toContain("incomplete-final-turn"); }); @@ -246,15 +297,28 @@ describe("foldSessionRecord", () => { it("flags missing assistant text — the Codex shape (no Stop-hook message)", () => { const record = foldSessionRecord([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd: "/repo" } }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd: "/repo" }, + }), prompt("2026-07-01T10:00:01.000Z", "summarize this"), tool("2026-07-01T10:00:02.000Z", "shell"), // The codex tailer emits Stop with no last_assistant_message, so the // normalizer records assistantText: null and no model/usage. - event({ type: "turn.completed", ts: "2026-07-01T10:00:03.000Z", payload: { stopHookActive: false, assistantText: null } }), + event({ + type: "turn.completed", + ts: "2026-07-01T10:00:03.000Z", + payload: { stopHookActive: false, assistantText: null }, + }), ]); - expect(record.turns[0]).toMatchObject({ assistantText: null, model: null, usage: null, incomplete: false }); + expect(record.turns[0]).toMatchObject({ + assistantText: null, + model: null, + usage: null, + incomplete: false, + }); expect(record.turns[0].toolCalls).toHaveLength(1); expect(record.limitations).toContain("missing-assistant-text"); }); @@ -262,7 +326,11 @@ describe("foldSessionRecord", () => { it("ignores UI-interaction events, counting them but never rendering them as turns", () => { const record = foldSessionRecord([ prompt("2026-07-01T10:00:00.000Z", "go"), - event({ type: "macro.invoked", ts: "2026-07-01T10:00:01.000Z", payload: { surface: "ui" } }), + event({ + type: "macro.invoked", + ts: "2026-07-01T10:00:01.000Z", + payload: { surface: "ui" }, + }), completed("2026-07-01T10:00:02.000Z", "done"), ]); @@ -272,7 +340,13 @@ describe("foldSessionRecord", () => { it("is empty, not broken, for a session with no events", () => { const record = foldSessionRecord([]); - expect(record).toMatchObject({ turns: [], turnCount: 0, eventCount: 0, limitations: [], reconstructed: true }); + expect(record).toMatchObject({ + turns: [], + turnCount: 0, + eventCount: 0, + limitations: [], + reconstructed: true, + }); }); }); @@ -295,7 +369,11 @@ describe("createSessionRecordReader", () => { }); async function writeEvents(events: AnalyticsEvent[]): Promise { - await fs.writeFile(filePath, events.map((e) => `${JSON.stringify(e)}\n`).join(""), "utf8"); + await fs.writeFile( + filePath, + events.map((e) => `${JSON.stringify(e)}\n`).join(""), + "utf8", + ); } it("reads one session's record out of an interleaved log", async () => { @@ -329,7 +407,10 @@ describe("createSessionRecordReader", () => { ]); // A half-written line with no trailing newline — exactly what a crash // between write() and flush leaves behind. - const torn = JSON.stringify(tool("2026-07-01T10:00:02.000Z", "Read")).slice(0, 60); + const torn = JSON.stringify(tool("2026-07-01T10:00:02.000Z", "Read")).slice( + 0, + 60, + ); await fs.appendFile(filePath, torn, "utf8"); const reader = createSessionRecordReader(createEventStore(filePath)); @@ -343,7 +424,9 @@ describe("createSessionRecordReader", () => { it("looks a record up by the agent's session id too (transcript-only rows)", async () => { await writeEvents([ prompt("2026-07-01T10:00:00.000Z", "go", { agentSessionId: "agent-1" }), - completed("2026-07-01T10:00:01.000Z", "done", { agentSessionId: "agent-1" }), + completed("2026-07-01T10:00:01.000Z", "done", { + agentSessionId: "agent-1", + }), ]); const reader = createSessionRecordReader(createEventStore(filePath)); @@ -354,10 +437,22 @@ describe("createSessionRecordReader", () => { it("merges harness sessions that share an agent session (a resumed conversation)", async () => { await writeEvents([ - prompt("2026-07-01T10:00:00.000Z", "first", { session: "sess-a", agentSessionId: "agent-1" }), - completed("2026-07-01T10:00:01.000Z", "first reply", { session: "sess-a", agentSessionId: "agent-1" }), - prompt("2026-07-01T11:00:00.000Z", "second", { session: "sess-b", agentSessionId: "agent-1" }), - completed("2026-07-01T11:00:01.000Z", "second reply", { session: "sess-b", agentSessionId: "agent-1" }), + prompt("2026-07-01T10:00:00.000Z", "first", { + session: "sess-a", + agentSessionId: "agent-1", + }), + completed("2026-07-01T10:00:01.000Z", "first reply", { + session: "sess-a", + agentSessionId: "agent-1", + }), + prompt("2026-07-01T11:00:00.000Z", "second", { + session: "sess-b", + agentSessionId: "agent-1", + }), + completed("2026-07-01T11:00:01.000Z", "second reply", { + session: "sess-b", + agentSessionId: "agent-1", + }), ]); const reader = createSessionRecordReader(createEventStore(filePath)); @@ -379,20 +474,33 @@ describe("createSessionRecordReader", () => { const reader = createSessionRecordReader(createEventStore(filePath)); expect(await reader.read("nope")).toBeNull(); - const absent = createSessionRecordReader(createEventStore(path.join(tmpDir, "gone.ndjson"))); + const absent = createSessionRecordReader( + createEventStore(path.join(tmpDir, "gone.ndjson")), + ); expect(await absent.read("sess-a")).toBeNull(); expect(await absent.turnCounts()).toEqual(new Map()); }); it("exposes exact turn counts keyed by both harness and agent session id", async () => { await writeEvents([ - prompt("2026-07-01T10:00:00.000Z", "one", { session: "sess-a", agentSessionId: "agent-1" }), - completed("2026-07-01T10:00:01.000Z", "ok", { session: "sess-a", agentSessionId: "agent-1" }), - prompt("2026-07-01T10:00:02.000Z", "two", { session: "sess-a", agentSessionId: "agent-1" }), + prompt("2026-07-01T10:00:00.000Z", "one", { + session: "sess-a", + agentSessionId: "agent-1", + }), + completed("2026-07-01T10:00:01.000Z", "ok", { + session: "sess-a", + agentSessionId: "agent-1", + }), + prompt("2026-07-01T10:00:02.000Z", "two", { + session: "sess-a", + agentSessionId: "agent-1", + }), prompt("2026-07-01T10:00:03.000Z", "elsewhere", { session: "sess-b" }), ]); - const counts = await createSessionRecordReader(createEventStore(filePath)).turnCounts(); + const counts = await createSessionRecordReader( + createEventStore(filePath), + ).turnCounts(); expect(counts.get("sess-a")).toBe(2); expect(counts.get("agent-1")).toBe(2); expect(counts.get("sess-b")).toBe(1); @@ -414,14 +522,26 @@ describe("createSessionRecordReader", () => { it("still renders when the vendor transcript is gone — enrichment is optional", async () => { await writeEvents([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd: "/repo/does-not-exist" } }), - prompt("2026-07-01T10:00:01.000Z", "go", { agentSessionId: "agent-missing" }), - event({ type: "turn.completed", ts: "2026-07-01T10:00:02.000Z", payload: { assistantText: null } }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd: "/repo/does-not-exist" }, + }), + prompt("2026-07-01T10:00:01.000Z", "go", { + agentSessionId: "agent-missing", + }), + event({ + type: "turn.completed", + ts: "2026-07-01T10:00:02.000Z", + payload: { assistantText: null }, + }), ]); const reader = createSessionRecordReader(createEventStore(filePath), { // Points at a home directory with no ~/.claude/projects at all. - enrichFinalTurn: createClaudeTranscriptEnricher({ homeDir: path.join(tmpDir, "home") }), + enrichFinalTurn: createClaudeTranscriptEnricher({ + homeDir: path.join(tmpDir, "home"), + }), }); const record = await reader.read("sess-a"); @@ -434,7 +554,12 @@ describe("createSessionRecordReader", () => { const cwd = "/repo/enriched"; const homeDir = path.join(tmpDir, "home"); // Mirrors Claude Code's own encoding of a project path (see the adapter). - const projectDir = path.join(homeDir, ".claude", "projects", cwd.replace(/:/g, "").replace(/[/.]/g, "-")); + const projectDir = path.join( + homeDir, + ".claude", + "projects", + cwd.replace(/:/g, "").replace(/[/.]/g, "-"), + ); await fs.mkdir(projectDir, { recursive: true }); await fs.writeFile( path.join(projectDir, "agent-enriched.jsonl"), @@ -451,8 +576,14 @@ describe("createSessionRecordReader", () => { ); await writeEvents([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd } }), - prompt("2026-07-01T10:00:01.000Z", "go", { agentSessionId: "agent-enriched" }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd }, + }), + prompt("2026-07-01T10:00:01.000Z", "go", { + agentSessionId: "agent-enriched", + }), event({ type: "turn.completed", ts: "2026-07-01T10:00:02.000Z", @@ -483,19 +614,32 @@ describe("createSessionRecordReader", () => { // missing. Patching the one code instead of recomputing under-reported it. const cwd = "/repo/reopens-gap"; const homeDir = path.join(tmpDir, "home"); - const projectDir = path.join(homeDir, ".claude", "projects", cwd.replace(/:/g, "").replace(/[/.]/g, "-")); + const projectDir = path.join( + homeDir, + ".claude", + "projects", + cwd.replace(/:/g, "").replace(/[/.]/g, "-"), + ); await fs.mkdir(projectDir, { recursive: true }); await fs.writeFile( path.join(projectDir, "agent-gap.jsonl"), `${JSON.stringify({ type: "assistant", - message: { role: "assistant", model: "claude-opus-4-6", content: "the final word" }, + message: { + role: "assistant", + model: "claude-opus-4-6", + content: "the final word", + }, })}\n`, "utf8", ); await writeEvents([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd } }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd }, + }), prompt("2026-07-01T10:00:01.000Z", "go", { agentSessionId: "agent-gap" }), tool("2026-07-01T10:00:02.000Z", "Read", { agentSessionId: "agent-gap" }), event({ @@ -536,15 +680,25 @@ describe("createSessionRecordReader", () => { path.join(projectDir, "agent-symlinked.jsonl"), `${JSON.stringify({ type: "assistant", - message: { role: "assistant", model: "claude-opus-4-6", content: "found via realpath" }, + message: { + role: "assistant", + model: "claude-opus-4-6", + content: "found via realpath", + }, })}\n`, "utf8", ); await writeEvents([ // The event carries the UNRESOLVED cwd, as the hook payload does. - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd } }), - prompt("2026-07-01T10:00:01.000Z", "go", { agentSessionId: "agent-symlinked" }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd }, + }), + prompt("2026-07-01T10:00:01.000Z", "go", { + agentSessionId: "agent-symlinked", + }), event({ type: "turn.completed", ts: "2026-07-01T10:00:02.000Z", @@ -564,20 +718,35 @@ describe("createSessionRecordReader", () => { it("never lets enrichment overwrite what our own events recorded", async () => { const cwd = "/repo/ours-wins"; const homeDir = path.join(tmpDir, "home"); - const projectDir = path.join(homeDir, ".claude", "projects", cwd.replace(/:/g, "").replace(/[/.]/g, "-")); + const projectDir = path.join( + homeDir, + ".claude", + "projects", + cwd.replace(/:/g, "").replace(/[/.]/g, "-"), + ); await fs.mkdir(projectDir, { recursive: true }); await fs.writeFile( path.join(projectDir, "agent-ours.jsonl"), `${JSON.stringify({ type: "assistant", - message: { role: "assistant", model: "some-other-model", content: "transcript text" }, + message: { + role: "assistant", + model: "some-other-model", + content: "transcript text", + }, })}\n`, "utf8", ); await writeEvents([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd } }), - prompt("2026-07-01T10:00:01.000Z", "go", { agentSessionId: "agent-ours" }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd }, + }), + prompt("2026-07-01T10:00:01.000Z", "go", { + agentSessionId: "agent-ours", + }), event({ type: "turn.completed", ts: "2026-07-01T10:00:02.000Z", @@ -615,7 +784,9 @@ describe("createSessionRecordReader with a record archive", () => { const ARCHIVED_AT = "2026-07-01T11:00:00.000Z"; beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "harness-record-archive-")); + tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), "harness-record-archive-"), + ); filePath = path.join(tmpDir, "events.ndjson"); recordsRoot = path.join(tmpDir, "records"); }); @@ -625,11 +796,18 @@ describe("createSessionRecordReader with a record archive", () => { }); function makeArchive(): RecordArchive { - return createRecordArchive({ root: recordsRoot, now: () => Date.parse(ARCHIVED_AT) }); + return createRecordArchive({ + root: recordsRoot, + now: () => Date.parse(ARCHIVED_AT), + }); } async function writeLines(events: AnalyticsEvent[]): Promise { - await fs.writeFile(filePath, events.map((e) => `${JSON.stringify(e)}\n`).join(""), "utf8"); + await fs.writeFile( + filePath, + events.map((e) => `${JSON.stringify(e)}\n`).join(""), + "utf8", + ); } /** The conversation every test here starts from: two completed turns, the @@ -646,11 +824,19 @@ describe("createSessionRecordReader with a record archive", () => { type: "tool.call", ts: "2026-07-01T10:00:02.000Z", agentSessionId: "agent-1", - payload: { toolName: "Edit", toolInput: LONG_INPUT, toolResponseSummary: "ok" }, + payload: { + toolName: "Edit", + toolInput: LONG_INPUT, + toolResponseSummary: "ok", + }, + }), + completed("2026-07-01T10:00:03.000Z", "first reply", { + agentSessionId: "agent-1", }), - completed("2026-07-01T10:00:03.000Z", "first reply", { agentSessionId: "agent-1" }), prompt("2026-07-01T10:00:04.000Z", "second", { agentSessionId: "agent-1" }), - completed("2026-07-01T10:00:05.000Z", "second reply", { agentSessionId: "agent-1" }), + completed("2026-07-01T10:00:05.000Z", "second reply", { + agentSessionId: "agent-1", + }), ]; /** Fold the conversation and archive it, as the server does at session end. */ @@ -669,13 +855,18 @@ describe("createSessionRecordReader with a record archive", () => { // What a 30-day sweep leaves behind for this session: nothing. await fs.writeFile(filePath, "", "utf8"); - const record = await createSessionRecordReader(createEventStore(filePath), { archive }).read("sess-a"); + const record = await createSessionRecordReader(createEventStore(filePath), { + archive, + }).read("sess-a"); expect(record?.turns.map((t) => t.prompt)).toEqual(["first", "second"]); expect(record?.archivedAt).toBe(ARCHIVED_AT); expect(record?.limitations).toContain("compacted-archive"); // Reachable by the agent's own session id too, which is all a // transcript-sourced history row has. - const byAgent = await createSessionRecordReader(createEventStore(filePath), { archive }).read("agent-1"); + const byAgent = await createSessionRecordReader( + createEventStore(filePath), + { archive }, + ).read("agent-1"); expect(byAgent?.harnessSessionId).toBe("sess-a"); }); @@ -687,7 +878,9 @@ describe("createSessionRecordReader with a record archive", () => { // sweepNdjson truncates oldest-first: the last two lines survive. await writeLines(conversation().slice(-2)); - const record = await createSessionRecordReader(createEventStore(filePath), { archive }).read("sess-a"); + const record = await createSessionRecordReader(createEventStore(filePath), { + archive, + }).read("sess-a"); // The archive is the only source that still has the first turn. expect(record?.turns.map((t) => t.prompt)).toEqual(["first", "second"]); expect(record?.archivedAt).toBe(ARCHIVED_AT); @@ -698,7 +891,9 @@ describe("createSessionRecordReader with a record archive", () => { const archive = makeArchive(); await archiveNow(archive); - const record = await createSessionRecordReader(createEventStore(filePath), { archive }).read("sess-a"); + const record = await createSessionRecordReader(createEventStore(filePath), { + archive, + }).read("sess-a"); expect(record?.archivedAt).toBeNull(); // The whole tool input, not the archive's 512-character excerpt. expect(record?.turns[0].toolCalls[0].input).toBe(LONG_INPUT); @@ -718,7 +913,9 @@ describe("createSessionRecordReader with a record archive", () => { completed("2026-07-01T12:00:01.000Z", "third reply"), ]); - const record = await createSessionRecordReader(createEventStore(filePath), { archive }).read("sess-a"); + const record = await createSessionRecordReader(createEventStore(filePath), { + archive, + }).read("sess-a"); expect(record?.archivedAt).toBeNull(); expect(record?.turns.map((t) => t.prompt)).toEqual(["second", "third"]); }); @@ -726,7 +923,9 @@ describe("createSessionRecordReader with a record archive", () => { it("scans events when nothing was ever archived, and reports honestly when neither source has anything", async () => { await writeLines(conversation()); const archive = makeArchive(); - const reader = createSessionRecordReader(createEventStore(filePath), { archive }); + const reader = createSessionRecordReader(createEventStore(filePath), { + archive, + }); expect((await reader.read("sess-a"))?.turns).toHaveLength(2); expect(await reader.read("sess-never-existed")).toBeNull(); @@ -739,7 +938,9 @@ describe("createSessionRecordReader with a record archive", () => { await writeLines(conversation()); const archive = makeArchive(); await archiveNow(archive); - const reader = createSessionRecordReader(createEventStore(filePath), { archive }); + const reader = createSessionRecordReader(createEventStore(filePath), { + archive, + }); await fs.writeFile(filePath, "", "utf8"); expect(await reader.readFromEvents("sess-a")).toBeNull(); @@ -751,7 +952,9 @@ describe("createSessionRecordReader with a record archive", () => { await writeLines(conversation()); const archive = makeArchive(); await archiveNow(archive); - const reader = createSessionRecordReader(createEventStore(filePath), { archive }); + const reader = createSessionRecordReader(createEventStore(filePath), { + archive, + }); await fs.writeFile(filePath, "", "utf8"); const counts = await reader.turnCounts(); @@ -772,18 +975,31 @@ describe("createSessionRecordReader with a record archive", () => { completed("2026-07-01T12:00:01.000Z", "third reply"), ]); - const counts = await createSessionRecordReader(createEventStore(filePath), { archive }).turnCounts(); + const counts = await createSessionRecordReader(createEventStore(filePath), { + archive, + }).turnCounts(); expect(counts.get("sess-a")).toBe(3); }); it("lists conversations newest-first for the backfill, merging resumed segments", async () => { await writeLines([ - prompt("2026-07-01T10:00:00.000Z", "old", { session: "sess-old", agentSessionId: "agent-old" }), - prompt("2026-07-02T10:00:00.000Z", "first", { session: "sess-a", agentSessionId: "agent-1" }), - prompt("2026-07-03T10:00:00.000Z", "resumed", { session: "sess-b", agentSessionId: "agent-1" }), + prompt("2026-07-01T10:00:00.000Z", "old", { + session: "sess-old", + agentSessionId: "agent-old", + }), + prompt("2026-07-02T10:00:00.000Z", "first", { + session: "sess-a", + agentSessionId: "agent-1", + }), + prompt("2026-07-03T10:00:00.000Z", "resumed", { + session: "sess-b", + agentSessionId: "agent-1", + }), ]); - const ids = await createSessionRecordReader(createEventStore(filePath)).conversationIds(); + const ids = await createSessionRecordReader( + createEventStore(filePath), + ).conversationIds(); // sess-a and sess-b are one conversation, named by where it began, and it // sorts ahead of the older one on its most recent activity. expect(ids).toEqual(["sess-a", "sess-old"]); diff --git a/packages/harness/src/core/session-record.ts b/packages/harness/src/core/session-record.ts index af962a1bf..b8fb919b3 100644 --- a/packages/harness/src/core/session-record.ts +++ b/packages/harness/src/core/session-record.ts @@ -78,7 +78,9 @@ function readUsage(value: unknown): SessionRecordTurn["usage"] { * same millisecond. See the module header for why `seq` alone is wrong. * Array.prototype.sort is stable, so events matching on both keep file order. */ -export function sortEventsForFold(events: readonly AnalyticsEvent[]): AnalyticsEvent[] { +export function sortEventsForFold( + events: readonly AnalyticsEvent[], +): AnalyticsEvent[] { return [...events].sort((a, b) => { if (a.ts !== b.ts) return a.ts < b.ts ? -1 : 1; const seqA = typeof a.seq === "number" ? a.seq : 0; @@ -145,10 +147,13 @@ export function foldSessionRecord( } for (const event of ordered) { - if (!seenSessionIds.includes(event.harnessSessionId)) seenSessionIds.push(event.harnessSessionId); + if (!seenSessionIds.includes(event.harnessSessionId)) + seenSessionIds.push(event.harnessSessionId); if (startedAt === null) startedAt = event.ts; - if (harness === null && typeof event.harness === "string") harness = event.harness; - if (agentSessionId === null) agentSessionId = stringOrNull(event.agentSessionId); + if (harness === null && typeof event.harness === "string") + harness = event.harness; + if (agentSessionId === null) + agentSessionId = stringOrNull(event.agentSessionId); const payload = event.payload ?? {}; switch (event.type) { @@ -162,17 +167,21 @@ export function foldSessionRecord( // Keep it, marked incomplete — dropping it would lose real tool calls. close(null); open = { - // Planner greeting control is retained locally for diagnostics but + // Project bootstrap control is retained locally for diagnostics but // projected as an assistant-initiated turn: its private instruction // must never appear as a user message or inflate the human turn count. prompt: + payload.projectBootstrapOrigin === "infrastructure" || payload.plannerOrigin === "infrastructure" ? null : typeof payload.prompt === "string" ? payload.prompt : "", promptAt: - payload.plannerOrigin === "infrastructure" ? null : event.ts, + payload.projectBootstrapOrigin === "infrastructure" || + payload.plannerOrigin === "infrastructure" + ? null + : event.ts, toolCalls: [], }; break; @@ -187,7 +196,9 @@ export function foldSessionRecord( name: stringOrNull(payload.toolName), input: stringOrNull(payload.toolInput), responseSummary, - responseTruncated: responseSummary !== null && PAYLOAD_TRUNCATION_MARKER.test(responseSummary), + responseTruncated: + responseSummary !== null && + PAYLOAD_TRUNCATION_MARKER.test(responseSummary), at: event.ts, }); break; @@ -216,7 +227,9 @@ export function foldSessionRecord( close(null); - const merged = options.mergedSessionIds ? [...options.mergedSessionIds] : seenSessionIds; + const merged = options.mergedSessionIds + ? [...options.mergedSessionIds] + : seenSessionIds; return { harnessSessionId: options.harnessSessionId ?? merged[0] ?? "", mergedSessionIds: merged, @@ -247,15 +260,27 @@ export function foldSessionRecord( * recorded). Patching individual codes after the fact got that wrong and * under-reported a real gap. */ -function computeLimitations(turns: readonly SessionRecordTurn[]): SessionRecordLimitation[] { +function computeLimitations( + turns: readonly SessionRecordTurn[], +): SessionRecordLimitation[] { const limitations: SessionRecordLimitation[] = []; - if (turns.some((turn) => turn.toolCalls.some((call) => call.responseTruncated))) { + if ( + turns.some((turn) => turn.toolCalls.some((call) => call.responseTruncated)) + ) { limitations.push("truncated-tool-output"); } - if (turns.some((turn) => turn.toolCalls.length > 0 && turn.assistantText !== null)) { + if ( + turns.some( + (turn) => turn.toolCalls.length > 0 && turn.assistantText !== null, + ) + ) { limitations.push("assistant-narration-gap"); } - if (turns.some((turn) => turn.completedAt !== null && turn.assistantText === null)) { + if ( + turns.some( + (turn) => turn.completedAt !== null && turn.assistantText === null, + ) + ) { limitations.push("missing-assistant-text"); } if (turns.length > 0 && turns[turns.length - 1].incomplete) { @@ -332,14 +357,16 @@ export interface SessionRecordReader { function resolveSessionIds(index: EventIndex, id: string): string[] { const ids: string[] = []; const add = (candidate: string): void => { - if (!ids.includes(candidate) && index.bySession.has(candidate)) ids.push(candidate); + if (!ids.includes(candidate) && index.bySession.has(candidate)) + ids.push(candidate); }; const direct = index.bySession.get(id); if (direct) { add(id); for (const agentSessionId of direct.agentSessionIds) { - for (const sibling of index.byAgentSession.get(agentSessionId) ?? []) add(sibling); + for (const sibling of index.byAgentSession.get(agentSessionId) ?? []) + add(sibling); } } else { // Not a harnessSessionId we know — try it as an agent session id. @@ -379,7 +406,10 @@ function resolveSessionIds(index: EventIndex, id: string): string[] { */ export function createSessionRecordReader( store: EventReader, - options: { enrichFinalTurn?: FinalTurnEnricher; archive?: ArchivedRecordSource } = {}, + options: { + enrichFinalTurn?: FinalTurnEnricher; + archive?: ArchivedRecordSource; + } = {}, ): SessionRecordReader { const archive = options.archive; @@ -412,19 +442,26 @@ export function createSessionRecordReader( * reach that state. */ async read(id: string): Promise { - const archived = archive ? await archive.read(id).catch(() => null) : null; + const archived = archive + ? await archive.read(id).catch(() => null) + : null; if (!archived) return reader.readFromEvents(id); const index = await store.index(); const sessionIds = resolveSessionIds(index, id); - const entries = sessionIds.map((sessionId) => index.bySession.get(sessionId)).filter(isPresent); + const entries = sessionIds + .map((sessionId) => index.bySession.get(sessionId)) + .filter(isPresent); const firstTs = earliest(entries.map((entry) => entry.firstTs)); const lastTs = latest(entries.map((entry) => entry.lastTs)); const logHoldsTheBeginning = - firstTs !== null && (archived.startedAt === null || firstTs <= archived.startedAt); + firstTs !== null && + (archived.startedAt === null || firstTs <= archived.startedAt); const logHasNewerEvents = - lastTs !== null && archived.archivedAt !== null && lastTs > archived.archivedAt; + lastTs !== null && + archived.archivedAt !== null && + lastTs > archived.archivedAt; if (!logHoldsTheBeginning && !logHasNewerEvents) return archived; return (await reader.readFromEvents(id)) ?? archived; }, @@ -435,7 +472,8 @@ export function createSessionRecordReader( if (sessionIds.length === 0) return null; const events: AnalyticsEvent[] = []; - for await (const event of store.read({ harnessSessionId: sessionIds })) events.push(event); + for await (const event of store.read({ harnessSessionId: sessionIds })) + events.push(event); if (events.length === 0) return null; const record = foldSessionRecord(events, { @@ -452,8 +490,15 @@ export function createSessionRecordReader( // here would be a fabrication dressed as an enhancement. Nothing our own // events recorded is ever overwritten. if (!final || final.completedAt === null) return record; - if (final.assistantText !== null && final.model !== null && final.usage !== null) return record; - const enrichment = await options.enrichFinalTurn(record).catch(() => null); + if ( + final.assistantText !== null && + final.model !== null && + final.usage !== null + ) + return record; + const enrichment = await options + .enrichFinalTurn(record) + .catch(() => null); if (!enrichment) return record; const enriched: SessionRecordTurn = { ...final, @@ -523,7 +568,9 @@ export function createSessionRecordReader( primaries.set( primary, latest( - resolveSessionIds(index, primary).map((id) => index.bySession.get(id)?.lastTs ?? null), + resolveSessionIds(index, primary).map( + (id) => index.bySession.get(id)?.lastTs ?? null, + ), ), ); } @@ -585,7 +632,9 @@ export function createClaudeTranscriptEnricher( // returns the resolved candidate first and the raw one as a fallback, so // this tries both rather than betting on either. for (const projectDir of await projectDirsFor(homeDir, record.cwd)) { - const turn = await readLastAssistantTurn(path.join(projectDir, `${record.agentSessionId}.jsonl`)); + const turn = await readLastAssistantTurn( + path.join(projectDir, `${record.agentSessionId}.jsonl`), + ); if (turn) return turn; } return null; diff --git a/packages/harness/src/core/studio-project-catalog.ts b/packages/harness/src/core/studio-project-catalog.ts index 312491ec5..a0a760807 100644 --- a/packages/harness/src/core/studio-project-catalog.ts +++ b/packages/harness/src/core/studio-project-catalog.ts @@ -306,6 +306,25 @@ function storageError(): StudioProjectCatalogError { /** Internal deterministic seams used only by file-lock race regressions. */ export type StudioProjectCatalogLockTestHooks = DurableFileLockTestHooks; +export interface StudioProjectCatalogLifecycleHooks { + /** + * Runs under the catalog lock before newly allocated project identities are + * committed. A durable write-ahead consumer can make the subsequent catalog + * commit recoverable without changing the public project schema. + */ + beforeProjectsCreatedCommit?: ( + projects: readonly StudioProjectSummary[], + ) => void | Promise; + /** + * Runs after both the catalog file and this instance reflect the committed + * identities. Delivery may be retried, so consumers must be project-keyed + * and idempotent. + */ + afterProjectsCreatedCommit?: ( + projects: readonly StudioProjectSummary[], + ) => void | Promise; +} + /** * Durable, serialized owner of Studio project identity. Catalog reads never * run package inventory or source discovery; callers provide the already @@ -320,6 +339,7 @@ export class StudioProjectCatalog { private readonly catalogPath: string, private readonly now: () => Date = () => new Date(), private readonly lockTestHooks: StudioProjectCatalogLockTestHooks = {}, + private readonly lifecycleHooks: StudioProjectCatalogLifecycleHooks = {}, ) {} private enqueue(operation: () => Promise): Promise { @@ -441,7 +461,8 @@ export class StudioProjectCatalog { try { const root = canonicalGraphPath(binding.localRootRef); const relative = path.relative(root, canonical); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) + return relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) ? [{ project, specificity: root.length }] : []; } catch { @@ -482,9 +503,14 @@ export class StudioProjectCatalog { updatedAt: timestamp, }; const next = [...cloneProjects(this.projects!), project]; + await this.lifecycleHooks.beforeProjectsCreatedCommit?.([ + publicSummary(project), + ]); await this.persist(next); this.projects = next; - return publicSummary(project); + const summary = publicSummary(project); + await this.lifecycleHooks.afterProjectsCreatedCommit?.([summary]); + return summary; }); } @@ -577,6 +603,7 @@ export class StudioProjectCatalog { } const reconciledScopes: WorkspaceScopeSummary[] = []; + const createdProjects: StudioProjectIdentity[] = []; for (const [canonical, scope] of dedupedScopes) { const matchingProjects = next.filter( (candidate) => @@ -608,6 +635,7 @@ export class StudioProjectCatalog { updatedAt: timestamp, }; next.push(project); + createdProjects.push(project); changed = true; } else { let projectChanged = false; @@ -641,8 +669,18 @@ export class StudioProjectCatalog { } if (changed) { + if (createdProjects.length > 0) { + await this.lifecycleHooks.beforeProjectsCreatedCommit?.( + createdProjects.map(publicSummary), + ); + } await this.persist(next); this.projects = next; + if (createdProjects.length > 0) { + await this.lifecycleHooks.afterProjectsCreatedCommit?.( + createdProjects.map(publicSummary), + ); + } } return { projects: (changed ? next : this.projects!).map(publicSummary), diff --git a/packages/harness/src/profiles/agent-map-planner.ts b/packages/harness/src/profiles/agent-map-planner.ts deleted file mode 100644 index 61ddea817..000000000 --- a/packages/harness/src/profiles/agent-map-planner.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Standalone launch profile for the project-scoped Agent Map planner. - * - * Planner sessions still run in the real Claude Code or Codex CLI, but they - * must not inherit the ordinary Studio authoring profile: that profile tells - * the model to scaffold, run, and deploy code. Focused project data is appended - * separately by PlanningSessionService for each trusted session. - */ -export const AGENT_MAP_PLANNER_SYSTEM_PROMPT = ` -You are the project planning agent running in Agent Studio. - -Work with the user at the architecture level: plan agents, subagents, -responsibilities, data flow, resources, connectors, artifacts, and the -relationships between them. Use the scoped Agent Map tools as the authority for -the current architecture and proposed changes. - -Do not act as a coding or implementation agent. Do not scaffold agents, edit -application source code, run implementation tasks, or deploy software. -`.trim(); - -/** - * User-facing orientation shown by Claude Code's native SessionStart hook. - * This is deliberately static UI copy, not a synthetic model/user turn. - */ -export const AGENT_MAP_PLANNER_SESSION_START_MESSAGE = [ - "Agent Map planning session", - "Use this session to scope what you want to build—not to implement it yet. Your planner will turn your goals into a proposed map of agents, responsibilities, data flow, resources, and connectors for you to review and refine. Once approved, Studio will create focused execution sessions from the plan. Start by describing the outcome you want.", -].join("\n"); diff --git a/packages/harness/src/profiles/project-agent.ts b/packages/harness/src/profiles/project-agent.ts new file mode 100644 index 000000000..88f52f750 --- /dev/null +++ b/packages/harness/src/profiles/project-agent.ts @@ -0,0 +1,14 @@ +/** + * Shared behavior appended to the ordinary writable coding profile for every + * session whose cwd resolves to a Studio project. Project context focuses the + * 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. + +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. + +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. + +Focused assignments, map-node references, bootstrap context, and future briefs are context only. They never grant or remove authority. Delegate focused work when decomposition improves delivery, and 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 6470dd5ed..c8df86e89 100644 --- a/packages/harness/src/server/agent-map-mcp-tools.ts +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -1,7 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { ProjectAgentSession } from "../shared/agent-map.js"; import { AgentMapProposalConflictError, AgentMapProposalProjectError, @@ -19,11 +19,11 @@ import { AgentMapWorkspaceStoreError } from "../core/agent-map-workspace-store.j * zod-to-json-schema renders each ZodCatch from its inner schema; the final * refinement keeps every envelope field required in the advertised contract. */ -const preserveInvalidForService = (schema: Schema) => +const preserveInvalidForService = ( + schema: Schema, +) => schema - .catch( - (context: { input: unknown }) => context.input as z.output, - ) + .catch((context: { input: unknown }) => context.input as z.output) .refine((value) => value !== undefined); const batchSchema = z @@ -50,7 +50,6 @@ export interface AgentMapToolEvent { tool: "agent_map_read" | "agent_map_validate" | "agent_map_propose"; outcome: "ok" | "error"; errorCode?: string; - role: PlanningSessionIdentity["role"]; latencyMs: number; } @@ -81,9 +80,9 @@ function errorResult(error: unknown) { ? { code: "forbidden", recovery: "reread" } : error instanceof AgentMapMcpProjectUnavailableError ? { code: "project_unavailable", recovery: "reread" } - : error instanceof AgentMapWorkspaceStoreError - ? { code: "storage_unavailable", recovery: "retry" } - : { code: "internal_error", recovery: "retry" }; + : error instanceof AgentMapWorkspaceStoreError + ? { code: "storage_unavailable", recovery: "retry" } + : { code: "internal_error", recovery: "retry" }; return { isError: true, content: [{ type: "text" as const, text: JSON.stringify(details) }], @@ -98,13 +97,16 @@ function toolResult(value: object, message: string) { }; } -/** Registers the identical project-wide surface for every trusted role. */ +/** Registers the identical project-wide surface for every trusted session. */ export function createAgentMapToolServer( - identity: PlanningSessionIdentity, + identity: ProjectAgentSession, service: AgentMapProposalService, options: AgentMapMcpToolsOptions = {}, ): McpServer { - const server = new McpServer({ name: "sapiom-studio-agent-map", version: "1" }); + const server = new McpServer({ + name: "sapiom-studio-agent-map", + version: "1", + }); const emit = (event: AgentMapToolEvent): void => { try { options.onEvent?.(event); @@ -123,7 +125,6 @@ export function createAgentMapToolServer( emit({ tool, outcome: "ok", - role: identity.role, latencyMs: Math.max(0, Date.now() - startedAt), }); return value; @@ -133,7 +134,6 @@ export function createAgentMapToolServer( tool, outcome: "error", errorCode: String(result.structuredContent.code), - role: identity.role, latencyMs: Math.max(0, Date.now() - startedAt), }); return result; @@ -143,7 +143,8 @@ export function createAgentMapToolServer( server.registerTool( "agent_map_read", { - description: "Read the current confirmed workspace and shared Agent Map proposal.", + description: + "Read the current confirmed workspace and shared Agent Map proposal.", inputSchema: z.object({}).strict(), annotations: { readOnlyHint: true, openWorldHint: false }, }, @@ -152,36 +153,53 @@ export function createAgentMapToolServer( const snapshot = options.readSnapshot ? await options.readSnapshot() : await service.read(identity.projectId); - const proposal = (snapshot as { proposal?: { version?: number } | null }).proposal; - return toolResult(snapshot, `Agent Map proposal version ${proposal?.version ?? 0}.`); + const proposal = ( + snapshot as { proposal?: { version?: number } | null } + ).proposal; + return toolResult( + snapshot, + `Agent Map proposal version ${proposal?.version ?? 0}.`, + ); }), ); server.registerTool( "agent_map_validate", { - description: "Validate a complete proposal batch without mutating shared state or allocating IDs.", + description: + "Validate a complete proposal batch without mutating shared state or allocating IDs.", inputSchema: batchSchema, annotations: { readOnlyHint: true, openWorldHint: false }, }, async (request) => instrument("agent_map_validate", async () => { const result = await service.validate(identity, request); - return toolResult(result, `Proposal batch is valid at version ${result.currentVersion}.`); + return toolResult( + result, + `Proposal batch is valid at version ${result.currentVersion}.`, + ); }), ); server.registerTool( "agent_map_propose", { - description: "Atomically apply an idempotent batch to the shared Proposed Agent Map.", + description: + "Atomically apply an idempotent batch to the shared Proposed Agent Map.", inputSchema: batchSchema, - annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: false, + }, }, async (request) => instrument("agent_map_propose", async () => { const result = await service.propose(identity, request); - return toolResult(result, `Accepted Agent Map proposal version ${result.version}.`); + return toolResult( + result, + `Accepted Agent Map proposal version ${result.version}.`, + ); }), ); 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 1f5753d24..0e4f077a4 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -12,7 +12,7 @@ import type { LaunchOpts, SpawnSpec, } from "../shared/types.js"; -import { AGENT_MAP_PLANNER_SESSION_START_MESSAGE } from "../profiles/agent-map-planner.js"; +import { PROJECT_AGENT_PROMPT_APPENDIX } from "../profiles/project-agent.js"; import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; import { startServer, type HarnessServer } from "./index.js"; @@ -21,6 +21,14 @@ let projectRoot: string; let projectId: string; let server: HarnessServer | undefined; +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), "agent-map-mcp-wiring-")); projectRoot = path.join(root, "project"); @@ -138,7 +146,133 @@ it("uses the actual ephemeral port and revokes private MCP launch authority on e expect(rejected.status).toBe(401); }); -it("gives a signed-out local planner its scoped Agent Map tools", async () => { +it("keeps an evicted descendant session resumable in its durable canonical project after restart", async () => { + 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, + }; + const webDir = path.join(root, "web"); + const descendant = path.join(projectRoot, "packages", "worker"); + await Promise.all([ + fs.mkdir(webDir), + fs.mkdir(descendant, { recursive: true }), + ]); + await fs.writeFile(path.join(webDir, "index.html"), ""); + server = await startServer({ + port: 0, + bootToken: "boot-token", + telemetryOptIn: false, + identity: null, + machineId: "machine-1", + adapters: { "claude-code": adapter }, + stateRoot: root, + launchDir: projectRoot, + webDir, + autoCreateSession: false, + loadSystemPrompt: async () => "ordinary coding prompt", + }); + + const created = await server.sessionManager.create({ + cwd: descendant, + harness: "claude-code", + }); + expect(created).toMatchObject({ + cwd: descendant, + title: "worker", + agentMapIdentity: { + projectId, + sessionId: created.id, + userId: "local:machine-1", + }, + }); + + const repeatedOpen = await fetch( + `http://127.0.0.1:${server.port}/api/settings`, + { + method: "PATCH", + headers: { + "content-type": "application/json", + "x-harness-token": "boot-token", + }, + body: JSON.stringify({ recentDirs: [projectRoot] }), + }, + ); + expect(repeatedOpen.status).toBe(200); + expect(server.sessionManager.list()).toHaveLength(1); + const catalog = new StudioProjectCatalog( + path.join(root, "studio-projects.json"), + ); + expect(await catalog.list()).toHaveLength(1); + await expect( + catalog.resolveIdentityForPath(descendant), + ).resolves.toMatchObject({ projectId }); + + await server.sessionManager.setAgentSessionId( + created.id, + "provider-descendant-session", + ); + await server.sessionManager.kill(created.id); + const evicted = await fetch(`http://127.0.0.1:${server.port}/api/settings`, { + method: "PATCH", + headers: { + "content-type": "application/json", + "x-harness-token": "boot-token", + }, + body: JSON.stringify({ recentDirs: [] }), + }); + expect(evicted.status).toBe(200); + const reconciledState = await fetch( + `http://127.0.0.1:${server.port}/api/state`, + { headers: { "x-harness-token": "boot-token" } }, + ); + expect(reconciledState.status).toBe(200); + await server.close(); + server = undefined; + + server = await startServer({ + port: 0, + bootToken: "boot-token", + telemetryOptIn: false, + identity: null, + machineId: "machine-1", + adapters: { "claude-code": adapter }, + stateRoot: root, + launchDir: projectRoot, + webDir, + autoCreateSession: false, + loadSystemPrompt: async () => "ordinary coding prompt", + }); + const restartedCatalog = new StudioProjectCatalog( + path.join(root, "studio-projects.json"), + ); + await expect( + restartedCatalog.resolveIdentityForPath(descendant), + ).resolves.toMatchObject({ projectId }); + await expect( + restartedCatalog.resolveIdentity(projectId), + ).resolves.toMatchObject({ + rootBindings: [expect.objectContaining({ status: "active" })], + }); + const resumed = await server.sessionManager.resume(created.id); + expect(resumed).toMatchObject({ + id: created.id, + agentSessionId: "provider-descendant-session", + status: "running", + agentMapIdentity: { projectId }, + }); +}); + +it("gives every signed-out project session the same coding prompt and Agent Map tools", async () => { const codingPrompt = "You are the coding agent running in Agent Studio. Follow the scaffold, run, and deploy authoring loop."; const loadSystemPrompt = vi.fn(async () => codingPrompt); @@ -188,24 +322,22 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { const created = (await response.json()) as { session: { id: string; - planning: { - identity: { role: string; userId: string }; - greeting: { status: string; reason?: string }; + projectBootstrap?: unknown; + planning?: unknown; + agentMapIdentity?: { + projectId: string; + sessionId: string; + userId: string; }; - agentMapIdentity?: { role: string; userId: string }; }; }; - expect(created.session.planning.identity).toMatchObject({ - role: "map-planner", + expect(created.session.agentMapIdentity).toEqual({ + projectId, + sessionId: created.session.id, userId: "local:machine-1", }); - expect(created.session.agentMapIdentity).toEqual( - created.session.planning.identity, - ); - expect(created.session.planning.greeting).toEqual({ - status: "skipped", - reason: "user-proceeded", - }); + expect(created.session.planning).toBeUndefined(); + expect(created.session.projectBootstrap).toBeUndefined(); const launchOpts = launches[0]!; const metadata = launchOpts.agentMapMcp; @@ -218,32 +350,23 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { `Bearer ${metadata!.bearerToken}`, ); const systemPrompt = await fs.readFile(launchOpts!.systemPromptFile!, "utf8"); - expect(systemPrompt).toContain(""); - expect(systemPrompt).toContain( - "Do not act as a coding or implementation agent", - ); - expect(systemPrompt).toContain( - "Let the user's first real message be the first visible conversation turn", - ); - expect(systemPrompt).not.toContain("In your first response, briefly explain"); - expect(systemPrompt).not.toContain(codingPrompt); - expect(systemPrompt).not.toContain("You are the coding agent"); + expect(systemPrompt).toContain(codingPrompt); + expect(systemPrompt).toContain(PROJECT_AGENT_PROMPT_APPENDIX); + expect(systemPrompt).toContain("plan and implement in the same session"); + expect(systemPrompt).toContain("Proceed directly"); + expect(systemPrompt).not.toContain("map-planner"); + expect(systemPrompt).not.toContain("not to implement it yet"); + expect(systemPrompt).not.toContain("stop before implementation"); expect(systemPrompt).not.toContain( "This is a private Agent Studio control turn", ); - expect(loadSystemPrompt).not.toHaveBeenCalled(); - expect(AGENT_MAP_PLANNER_SESSION_START_MESSAGE).toBe( - [ - "Agent Map planning session", - "Use this session to scope what you want to build—not to implement it yet. Your planner will turn your goals into a proposed map of agents, responsibilities, data flow, resources, and connectors for you to review and refine. Once approved, Studio will create focused execution sessions from the plan. Start by describing the outcome you want.", - ].join("\n"), - ); - const plannerEmitter = await fs.readFile( + expect(loadSystemPrompt).toHaveBeenCalledTimes(1); + const compatibilityEmitter = await fs.readFile( path.join(path.dirname(launchOpts.settingsFile!), "emit.cjs"), "utf8", ); - expect(plannerEmitter).toContain( - `const sessionStartSystemMessage = ${JSON.stringify(AGENT_MAP_PLANNER_SESSION_START_MESSAGE)};`, + expect(compatibilityEmitter).toContain( + "const sessionStartSystemMessage = null;", ); const client = new Client({ name: "signed-out-planner-test", version: "1" }); @@ -319,25 +442,25 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { cwd: projectRoot, harness: "claude-code", }); - expect(ordinary.agentMapIdentity).toMatchObject({ - role: "agent-builder", + expect(ordinary.agentMapIdentity).toEqual({ + projectId, + sessionId: ordinary.id, userId: "local:machine-1", - assignment: { kind: "unplanned" }, }); + expect(ordinary.planning).toBeUndefined(); const ordinaryLaunch = launches[1]!; expect(ordinaryLaunch.agentMapMcp).toBeDefined(); - expect(await fs.readFile(ordinaryLaunch.systemPromptFile!, "utf8")).toBe( - codingPrompt, + const ordinaryPrompt = await fs.readFile( + ordinaryLaunch.systemPromptFile!, + "utf8", ); + expect(ordinaryPrompt).toBe(systemPrompt); const ordinaryEmitter = await fs.readFile( path.join(path.dirname(ordinaryLaunch.settingsFile!), "emit.cjs"), "utf8", ); expect(ordinaryEmitter).toContain("const sessionStartSystemMessage = null;"); - expect(ordinaryEmitter).not.toContain( - JSON.stringify(AGENT_MAP_PLANNER_SESSION_START_MESSAGE), - ); - expect(loadSystemPrompt).toHaveBeenCalledOnce(); + expect(loadSystemPrompt).toHaveBeenCalledTimes(2); const ordinaryConfig = JSON.parse( await fs.readFile(ordinaryLaunch.mcpConfigFile!, "utf8"), ); @@ -345,3 +468,659 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { `Bearer ${ordinaryLaunch.agentMapMcp!.bearerToken}`, ); }); + +it("creates one ordinary Plan Agents session for a newly opened project and never from a map read", async () => { + const launches: LaunchOpts[] = []; + const launch = (opts: LaunchOpts): SpawnSpec => { + launches.push(opts); + return { 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 webDir = path.join(root, "web"); + await fs.mkdir(webDir); + await fs.writeFile(path.join(webDir, "index.html"), ""); + server = await startServer({ + port: 0, + bootToken: "boot-token", + telemetryOptIn: false, + identity: null, + machineId: "machine-1", + adapters: { "claude-code": adapter }, + stateRoot: root, + launchDir: projectRoot, + webDir, + autoCreateSession: false, + loadSystemPrompt: async () => "ordinary coding prompt", + }); + const request = (pathname: string, init?: RequestInit) => + fetch(`http://127.0.0.1:${server!.port}/api${pathname}`, { + ...init, + headers: { + "content-type": "application/json", + "x-harness-token": "boot-token", + ...init?.headers, + }, + }); + const freshRoot = path.join(root, "fresh-project"); + await fs.mkdir(freshRoot); + expect(server.sessionManager.list()).toEqual([]); + expect(launches).toEqual([]); + + const firstOpen = await request("/settings", { + method: "PATCH", + body: JSON.stringify({ recentDirs: [freshRoot, projectRoot] }), + }); + expect(firstOpen.status).toBe(200); + expect(server.sessionManager.list()).toHaveLength(1); + expect(launches).toHaveLength(1); + const [firstSession] = server.sessionManager.list(); + const freshProjectId = firstSession!.agentMapIdentity!.projectId; + expect(firstSession).toMatchObject({ + title: "Plan Agents", + cwd: freshRoot, + agentMapIdentity: { + projectId: freshProjectId, + sessionId: firstSession!.id, + userId: "local:machine-1", + }, + projectBootstrap: { + projectId: freshProjectId, + targetSessionId: firstSession!.id, + userId: "local:machine-1", + }, + }); + expect(firstSession?.planning).toBeUndefined(); + + const queuedUserInput = await request(`/sessions/${firstSession!.id}/input`, { + method: "POST", + body: JSON.stringify({ + text: "Implement the requested feature directly", + submit: true, + }), + }); + expect(queuedUserInput.status).toBe(200); + expect(firstSession!.projectBootstrap).toMatchObject({ + bootstrap: { status: "skipped", reason: "user-proceeded" }, + queuedInputIds: [expect.any(String)], + }); + + const repeatedOpen = await request("/settings", { + method: "PATCH", + body: JSON.stringify({ recentDirs: [freshRoot, projectRoot] }), + }); + expect(repeatedOpen.status).toBe(200); + expect(server.sessionManager.list()).toHaveLength(1); + expect(launches).toHaveLength(1); + + const mapRead = await request( + `/projects/${freshProjectId}/agent-map/workspace`, + ); + expect(mapRead.status).toBe(200); + expect(server.sessionManager.list()).toHaveLength(1); + expect(launches).toHaveLength(1); +}); + +it("does not spawn an automatic duplicate when an explicit first session wins the bootstrap claim", async () => { + const launches: LaunchOpts[] = []; + const adapter: HarnessAdapter = { + id: "claude-code", + eventSource: "hooks", + doctor: async () => [], + launch: (opts) => { + launches.push(opts); + return { command: "bash", args: [], env: {}, cwd: opts.cwd }; + }, + resume: (_id, opts) => ({ + command: "bash", + args: [], + env: {}, + cwd: opts.cwd, + }), + listPastSessions: async () => [], + canResume: async () => true, + }; + const webDir = path.join(root, "web"); + const freshRoot = path.join(root, "explicit-first-project"); + await Promise.all([fs.mkdir(webDir), fs.mkdir(freshRoot)]); + await fs.writeFile(path.join(webDir, "index.html"), ""); + const needed = deferred(); + const releaseAutomaticCreate = deferred(); + server = await startServer({ + port: 0, + bootToken: "boot-token", + telemetryOptIn: false, + identity: null, + machineId: "machine-1", + adapters: { "claude-code": adapter }, + stateRoot: root, + launchDir: projectRoot, + webDir, + autoCreateSession: false, + loadSystemPrompt: async () => "ordinary coding prompt", + projectBootstrapTestHooks: { + afterProjectSessionNeeded: async () => { + needed.resolve(); + await releaseAutomaticCreate.promise; + }, + }, + }); + const request = (pathname: string, init?: RequestInit) => + fetch(`http://127.0.0.1:${server!.port}/api${pathname}`, { + ...init, + headers: { + "content-type": "application/json", + "x-harness-token": "boot-token", + ...init?.headers, + }, + }); + + const opening = request("/settings", { + method: "PATCH", + body: JSON.stringify({ recentDirs: [freshRoot, projectRoot] }), + }); + await needed.promise; + const explicitResponse = await request("/sessions", { + method: "POST", + body: JSON.stringify({ + cwd: freshRoot, + harness: "claude-code", + initialUserInputPending: true, + }), + }); + expect(explicitResponse.status).toBe(201); + const explicit = (await explicitResponse.json()) as { + id: string; + title: string; + projectBootstrap?: { bootstrap: { status: string; reason?: string } }; + }; + releaseAutomaticCreate.resolve(); + expect((await opening).status).toBe(200); + + expect(server.sessionManager.list()).toHaveLength(1); + expect(server.sessionManager.list()[0]).toMatchObject({ + id: explicit.id, + title: "Plan Agents", + projectBootstrap: { + bootstrap: { status: "skipped", reason: "user-proceeded" }, + }, + }); + expect(explicit.title).toBe("Plan Agents"); + expect(launches).toHaveLength(1); +}); + +it("automatically seeds one durable map through the real E2 tools without replaying after duplicate readiness or restart", async () => { + const launches: LaunchOpts[] = []; + const tokenPathFor = (sessionId: string) => + path.join(root, `${sessionId}.ingest.json`); + const inputPathFor = (sessionId: string) => + path.join(root, `${sessionId}.pty-input`); + const launch = (opts: LaunchOpts): SpawnSpec => { + launches.push(opts); + return { + command: "bash", + args: [ + "-c", + 'printf \'{"ingestToken":"%s"}\' "$SAPIOM_HARNESS_INGEST_TOKEN" > "$SAPIOM_TEST_INGEST_TOKEN_PATH"; while IFS= read -r line; do printf "%s\\n" "$line" >> "$SAPIOM_TEST_INPUT_PATH"; done', + ], + env: { + SAPIOM_TEST_INGEST_TOKEN_PATH: tokenPathFor(opts.harnessSessionId), + SAPIOM_TEST_INPUT_PATH: inputPathFor(opts.harnessSessionId), + }, + 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 webDir = path.join(root, "web"); + const freshRoot = path.join(root, "automatic-bootstrap-project"); + await Promise.all([fs.mkdir(webDir), fs.mkdir(freshRoot)]); + await fs.writeFile(path.join(webDir, "index.html"), ""); + + const boot = () => + startServer({ + port: 0, + bootToken: "boot-token", + telemetryOptIn: false, + identity: null, + machineId: "machine-1", + adapters: { "claude-code": adapter }, + stateRoot: root, + launchDir: projectRoot, + webDir, + autoCreateSession: false, + loadSystemPrompt: async () => "ordinary coding prompt", + }); + const request = (pathname: string, init?: RequestInit) => + fetch(`http://127.0.0.1:${server!.port}/api${pathname}`, { + ...init, + headers: { + "content-type": "application/json", + "x-harness-token": "boot-token", + ...init?.headers, + }, + }); + const readIngestToken = async (sessionId: string): Promise => { + let token = ""; + await vi.waitFor(async () => { + const parsed = JSON.parse( + await fs.readFile(tokenPathFor(sessionId), "utf8"), + ) as { ingestToken?: string }; + token = parsed.ingestToken ?? ""; + expect(token).not.toBe(""); + }); + return token; + }; + const postHook = async ( + sessionId: string, + token: string, + hookEvent: "SessionStart" | "UserPromptSubmit" | "Stop", + payload: Record, + ): Promise => { + 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, harnessSessionId: sessionId, payload }), + }); + expect(response.status).toBe(200); + }; + const capturedInputs = async (sessionId: string): Promise => { + const text = await fs.readFile(inputPathFor(sessionId), "utf8"); + return text.trimEnd().split("\n"); + }; + const connect = async (opts: LaunchOpts): Promise => { + const metadata = opts.agentMapMcp!; + const client = new Client({ + name: "automatic-bootstrap-script", + version: "1", + }); + await client.connect( + new StreamableHTTPClientTransport(new URL(metadata.url), { + requestInit: { + headers: { Authorization: `Bearer ${metadata.bearerToken}` }, + }, + }), + ); + return client; + }; + + server = await boot(); + const opened = await request("/settings", { + method: "PATCH", + body: JSON.stringify({ recentDirs: [freshRoot, projectRoot] }), + }); + expect(opened.status).toBe(200); + const [session] = server.sessionManager.list(); + expect(session).toMatchObject({ + cwd: freshRoot, + title: "Plan Agents", + projectBootstrap: { + bootstrap: { status: "pending" }, + }, + }); + const automaticProjectId = session!.agentMapIdentity!.projectId; + const providerSessionId = "provider-automatic-bootstrap"; + const firstIngestToken = await readIngestToken(session!.id); + + await postHook(session!.id, firstIngestToken, "SessionStart", { + session_id: providerSessionId, + source: "startup", + cwd: freshRoot, + }); + await postHook(session!.id, firstIngestToken, "SessionStart", { + session_id: providerSessionId, + source: "startup", + cwd: freshRoot, + }); + let bootstrapPrompt = ""; + await vi.waitFor(async () => { + const inputs = await capturedInputs(session!.id); + expect(inputs).toHaveLength(1); + bootstrapPrompt = inputs[0]!; + expect(bootstrapPrompt).toContain("Agent Studio project bootstrap"); + expect(bootstrapPrompt).toContain("Read the current Agent Map first"); + }); + await postHook(session!.id, firstIngestToken, "UserPromptSubmit", { + session_id: providerSessionId, + prompt: bootstrapPrompt, + }); + + const batch = { + schemaVersion: 1, + proposalId: null, + expectedVersion: 0, + requestId: "automatic-bootstrap-seed-v1", + operations: [ + { + kind: "add-node", + draftRef: "market-research", + node: { + kind: "agent", + name: "Market Research", + purpose: "Research the top ten stocks trading today", + ownerAgent: null, + contractRefs: ["ResearchReport"], + }, + }, + ], + }; + const firstClient = await connect(launches[0]!); + let firstProposal: Awaited>; + try { + const initial = await firstClient.callTool({ + name: "agent_map_read", + arguments: {}, + }); + expect(initial.isError).not.toBe(true); + expect(initial.structuredContent).toMatchObject({ proposal: null }); + const validated = await firstClient.callTool({ + name: "agent_map_validate", + arguments: batch, + }); + expect(validated.isError).not.toBe(true); + expect(validated.structuredContent).toMatchObject({ currentVersion: 0 }); + firstProposal = await firstClient.callTool({ + name: "agent_map_propose", + arguments: batch, + }); + expect(firstProposal.isError).not.toBe(true); + expect(firstProposal.structuredContent).toMatchObject({ version: 1 }); + } finally { + await firstClient.close(); + } + + await postHook(session!.id, firstIngestToken, "Stop", { + session_id: providerSessionId, + last_assistant_message: "Seeded the evidence-supported initial Agent Map.", + }); + await postHook(session!.id, firstIngestToken, "Stop", { + session_id: providerSessionId, + last_assistant_message: "Duplicate lifecycle signal.", + }); + await vi.waitFor(() => { + expect( + server!.sessionManager.get(session!.id)?.projectBootstrap, + ).toMatchObject({ bootstrap: { status: "delivered" } }); + }); + + const durableFile = path.join( + root, + "agent-map", + "projects", + automaticProjectId, + "workspace.json", + ); + const durableBeforeRestart = JSON.parse( + await fs.readFile(durableFile, "utf8"), + ) as { + proposal: { version: number; nodes: unknown[]; history: unknown[] }; + }; + expect(durableBeforeRestart.proposal).toMatchObject({ version: 1 }); + expect(durableBeforeRestart.proposal.nodes).toHaveLength(1); + expect(durableBeforeRestart.proposal.history).toHaveLength(1); + expect(await capturedInputs(session!.id)).toHaveLength(1); + + await server.close(); + server = undefined; + server = await boot(); + expect(launches).toHaveLength(1); + expect(server.sessionManager.get(session!.id)).toMatchObject({ + id: session!.id, + agentSessionId: providerSessionId, + status: "exited", + projectBootstrap: { bootstrap: { status: "delivered" } }, + }); + + await fs.rm(tokenPathFor(session!.id), { force: true }); + await server.sessionManager.resume(session!.id); + expect(launches).toHaveLength(2); + const resumedIngestToken = await readIngestToken(session!.id); + expect(resumedIngestToken).not.toBe(firstIngestToken); + await postHook(session!.id, resumedIngestToken, "SessionStart", { + session_id: providerSessionId, + source: "resume", + cwd: freshRoot, + }); + await postHook(session!.id, resumedIngestToken, "SessionStart", { + session_id: providerSessionId, + source: "resume", + cwd: freshRoot, + }); + await vi.waitFor(() => { + expect(server!.sessionManager.get(session!.id)?.ready).toBe(true); + }); + + const resumedClient = await connect(launches[1]!); + try { + const restored = await resumedClient.callTool({ + name: "agent_map_read", + arguments: {}, + }); + expect(restored.structuredContent).toMatchObject({ + proposal: { + version: 1, + nodes: [expect.objectContaining({ name: "Market Research" })], + history: [expect.objectContaining({ requestId: batch.requestId })], + }, + }); + const replayed = await resumedClient.callTool({ + name: "agent_map_propose", + arguments: batch, + }); + expect(replayed.structuredContent).toEqual( + firstProposal!.structuredContent, + ); + } finally { + await resumedClient.close(); + } + expect(await capturedInputs(session!.id)).toHaveLength(1); + const durableAfterRestart = JSON.parse( + await fs.readFile(durableFile, "utf8"), + ) as { + proposal: { version: number; nodes: unknown[]; history: unknown[] }; + }; + expect(durableAfterRestart.proposal).toMatchObject({ version: 1 }); + expect(durableAfterRestart.proposal.nodes).toHaveLength(1); + expect(durableAfterRestart.proposal.history).toHaveLength(1); +}); + +it("initializes every newly opened root once when one settings update creates multiple projects", async () => { + const launches: LaunchOpts[] = []; + const adapter: HarnessAdapter = { + id: "claude-code", + eventSource: "hooks", + doctor: async () => [], + launch: (opts) => { + launches.push(opts); + return { command: "bash", args: [], env: {}, cwd: opts.cwd }; + }, + resume: (_id, opts) => ({ + command: "bash", + args: [], + env: {}, + cwd: opts.cwd, + }), + listPastSessions: async () => [], + canResume: async () => true, + }; + const webDir = path.join(root, "web"); + const firstRoot = path.join(root, "first-project"); + const secondRoot = path.join(root, "second-project"); + await Promise.all([ + fs.mkdir(webDir), + fs.mkdir(firstRoot), + fs.mkdir(secondRoot), + ]); + await fs.writeFile(path.join(webDir, "index.html"), ""); + server = await startServer({ + port: 0, + bootToken: "boot-token", + telemetryOptIn: false, + identity: null, + machineId: "machine-1", + adapters: { "claude-code": adapter }, + stateRoot: root, + launchDir: projectRoot, + webDir, + autoCreateSession: false, + loadSystemPrompt: async () => "ordinary coding prompt", + }); + + const response = await fetch(`http://127.0.0.1:${server.port}/api/settings`, { + method: "PATCH", + headers: { + "content-type": "application/json", + "x-harness-token": "boot-token", + }, + body: JSON.stringify({ + recentDirs: [firstRoot, secondRoot, projectRoot], + }), + }); + + expect(response.status).toBe(200); + const sessions = server.sessionManager.list(); + expect(sessions).toHaveLength(2); + expect( + sessions + .map((session) => ({ + cwd: session.cwd, + title: session.title, + projectId: session.agentMapIdentity?.projectId, + })) + .sort((left, right) => left.cwd.localeCompare(right.cwd)), + ).toEqual([ + { cwd: firstRoot, title: "Plan Agents", projectId: expect.any(String) }, + { cwd: secondRoot, title: "Plan Agents", projectId: expect.any(String) }, + ]); + expect( + new Set(sessions.map((session) => session.agentMapIdentity?.projectId)) + .size, + ).toBe(2); + expect(launches).toHaveLength(2); + + const repeated = await fetch(`http://127.0.0.1:${server.port}/api/settings`, { + method: "PATCH", + headers: { + "content-type": "application/json", + "x-harness-token": "boot-token", + }, + body: JSON.stringify({ + recentDirs: [firstRoot, secondRoot, projectRoot], + }), + }); + expect(repeated.status).toBe(200); + expect(server.sessionManager.list()).toHaveLength(2); + expect(launches).toHaveLength(2); +}); + +it("recovers a durable scheduled project intent at server boot", async () => { + const nestedRoot = path.join(projectRoot, "nested-binding"); + await fs.mkdir(nestedRoot); + const catalog = new StudioProjectCatalog( + path.join(root, "studio-projects.json"), + ); + const identity = await catalog.resolveIdentity(projectId); + await catalog.moveRootBinding( + projectId, + identity!.rootBindings[0]!.id, + nestedRoot, + ); + // Keep the narrower binding first in persisted array order. Recovery must + // still choose the canonical outermost root rather than UUID/array order. + await catalog.addRootBinding(projectId, projectRoot); + await fs.writeFile( + path.join(root, "settings.json"), + JSON.stringify({ recentDirs: [nestedRoot, projectRoot] }), + ); + const intentDirectory = path.join( + root, + "agent-map", + "project-bootstrap", + "projects", + ); + await fs.mkdir(intentDirectory, { recursive: true }); + await fs.writeFile( + path.join(intentDirectory, `${projectId}.json`), + `${JSON.stringify({ + schemaVersion: 1, + projectId, + userId: "local:machine-1", + targetSessionId: null, + status: "scheduled", + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + })}\n`, + ); + const launches: LaunchOpts[] = []; + const launch = (opts: LaunchOpts): SpawnSpec => { + launches.push(opts); + return { 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 webDir = path.join(root, "web"); + await fs.mkdir(webDir); + await fs.writeFile(path.join(webDir, "index.html"), ""); + + server = await startServer({ + port: 0, + bootToken: "boot-token", + telemetryOptIn: false, + identity: null, + machineId: "machine-1", + adapters: { "claude-code": adapter }, + stateRoot: root, + launchDir: projectRoot, + webDir, + loadSystemPrompt: async () => "ordinary coding prompt", + }); + + // Default auto-create is intentionally enabled. Give its detached create + // path enough time to expose a duplicate if recovery failed to suppress it. + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(launches).toHaveLength(1); + expect(server.sessionManager.list()).toEqual([ + expect.objectContaining({ + title: "Plan Agents", + cwd: projectRoot, + agentMapIdentity: expect.objectContaining({ projectId }), + projectBootstrap: expect.objectContaining({ + projectId, + userId: "local:machine-1", + }), + }), + ]); + const recovered = JSON.parse( + await fs.readFile(path.join(intentDirectory, `${projectId}.json`), "utf8"), + ) as { status: string; targetSessionId: string | null }; + expect(recovered).toEqual( + expect.objectContaining({ + status: "claimed", + targetSessionId: server.sessionManager.list()[0]!.id, + }), + ); +}); diff --git a/packages/harness/src/server/agent-map-mcp.test.ts b/packages/harness/src/server/agent-map-mcp.test.ts index 321d95e25..8da435600 100644 --- a/packages/harness/src/server/agent-map-mcp.test.ts +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -8,7 +8,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { ProjectAgentSession } from "../shared/agent-map.js"; import { AgentMapCapabilityRegistry } from "../core/agent-map-capability-registry.js"; import { AgentMapProposalService } from "../core/agent-map-proposal-service.js"; import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; @@ -26,7 +26,9 @@ const clients: Client[] = []; const cleanups: Array<() => Promise> = []; afterEach(async () => { - await Promise.all(clients.splice(0).map((client) => client.close().catch(() => {}))); + await Promise.all( + clients.splice(0).map((client) => client.close().catch(() => {})), + ); await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); }); @@ -34,7 +36,7 @@ async function fixture( options: Partial< Pick< AgentMapMcpRouterOptions, - "createToolServer" | "createTransport" | "readSnapshotFor" + "createToolServer" | "createTransport" | "onEvent" | "readSnapshotFor" > > = {}, ) { @@ -70,23 +72,11 @@ async function connect(url: URL, token: string) { } describe("Agent Map Streamable HTTP MCP", () => { - it.each([ - { projectId, sessionId: "planner", userId: "user", role: "map-planner" }, - { - projectId, - sessionId: "planned", - userId: "user", - role: "agent-builder", - assignment: { kind: "planned", agentId: "agent-1" }, - }, - { - projectId, - sessionId: "manual", - userId: "user", - role: "agent-builder", - assignment: { kind: "unplanned" }, - }, - ])("exposes the same strict tools to $role/$sessionId", async (identity) => { + it.each([ + { projectId, sessionId: "first", userId: "user" }, + { projectId, sessionId: "created", userId: "user" }, + { projectId, sessionId: "resumed", userId: "user" }, + ])("exposes the same strict tools to $sessionId", async (identity) => { const { capabilities, url } = await fixture(); const issued = capabilities.issue(identity); const client = await connect(url, issued.token); @@ -96,9 +86,17 @@ describe("Agent Map Streamable HTTP MCP", () => { "agent_map_read", "agent_map_validate", ]); - expect(tools.tools.every((tool) => tool.inputSchema.additionalProperties === false)).toBe(true); - const validate = tools.tools.find(({ name }) => name === "agent_map_validate")!; - const propose = tools.tools.find(({ name }) => name === "agent_map_propose")!; + expect( + tools.tools.every( + (tool) => tool.inputSchema.additionalProperties === false, + ), + ).toBe(true); + const validate = tools.tools.find( + ({ name }) => name === "agent_map_validate", + )!; + const propose = tools.tools.find( + ({ name }) => name === "agent_map_propose", + )!; const operationItems = ( validate.inputSchema as { properties?: { @@ -128,12 +126,12 @@ describe("Agent Map Streamable HTTP MCP", () => { }); it("reads, validates without mutation, proposes once, and rejects a rotated token", async () => { - const { capabilities, url } = await fixture(); - const identity: PlanningSessionIdentity = { + const onEvent = vi.fn(); + const { capabilities, url } = await fixture({ onEvent }); + const identity: ProjectAgentSession = { projectId, - sessionId: "planner", + sessionId: "session-1", userId: "user", - role: "map-planner", }; const first = capabilities.issue(identity); const client = await connect(url, first.token); @@ -181,17 +179,36 @@ describe("Agent Map Streamable HTTP MCP", () => { }, ], }; - const validated = await client.callTool({ name: "agent_map_validate", arguments: request }); + const validated = await client.callTool({ + name: "agent_map_validate", + arguments: request, + }); expect(validated.isError).not.toBe(true); - const before = await client.callTool({ name: "agent_map_read", arguments: {} }); + const before = await client.callTool({ + name: "agent_map_read", + arguments: {}, + }); expect(before.structuredContent).toMatchObject({ proposal: null }); - const proposed = await client.callTool({ name: "agent_map_propose", arguments: request }); + const proposed = await client.callTool({ + name: "agent_map_propose", + arguments: request, + }); expect(proposed.structuredContent).toMatchObject({ version: 1 }); - const replayed = await client.callTool({ name: "agent_map_propose", arguments: request }); + const replayed = await client.callTool({ + name: "agent_map_propose", + arguments: request, + }); expect(replayed.structuredContent).toEqual(proposed.structuredContent); + expect(onEvent).toHaveBeenCalled(); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain("role"); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain( + "Research sources", + ); capabilities.rotate(identity); - await expect(client.callTool({ name: "agent_map_read", arguments: {} })).rejects.toThrow(); + await expect( + client.callTool({ name: "agent_map_read", arguments: {} }), + ).rejects.toThrow(); }); it("returns a bounded terminal recovery when the capability project is unavailable", async () => { @@ -204,7 +221,6 @@ describe("Agent Map Streamable HTTP MCP", () => { projectId, sessionId: "missing-project", userId: "user", - role: "map-planner", }); const client = await connect(url, issued.token); @@ -251,7 +267,6 @@ describe("Agent Map Streamable HTTP MCP", () => { projectId, sessionId: "failed-initialize", userId: "user", - role: "map-planner", }); const response = await fetch(url, { diff --git a/packages/harness/src/server/agent-map.test.ts b/packages/harness/src/server/agent-map.test.ts index 2e1a75771..6869ad42e 100644 --- a/packages/harness/src/server/agent-map.test.ts +++ b/packages/harness/src/server/agent-map.test.ts @@ -9,14 +9,18 @@ import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; import { StudioWorkspacePreferenceStore } from "../core/studio-workspace-preferences.js"; import { - PlannerGreetingRetryUnavailableError, - type PlannerGreetingCoordinator, + ProjectBootstrapRetryUnavailableError, + type ProjectBootstrapCoordinator, } from "../core/planner-greeting.js"; +import { ProjectSessionScopeUnavailableError } from "../core/session-manager.js"; import { - PlanningSessionError, - type PlanningSessionService, + ProjectSessionError, + type ProjectSessionService, } from "../core/planning-session.js"; -import type { AgentMapWorkspaceResponse } from "../shared/agent-map.js"; +import type { + AgentMapWorkspaceResponse, + StudioProjectSummary, +} from "../shared/agent-map.js"; import type { HarnessSession } from "../shared/types.js"; import { createBootTokenMiddleware } from "./auth.js"; import { createAgentMapRouter } from "./agent-map.js"; @@ -36,9 +40,15 @@ describe("createAgentMapRouter", () => { ); }); - async function start(planner?: { - planningSessions: PlanningSessionService; - plannerGreeting: PlannerGreetingCoordinator; + async function start(projectLifecycle?: { + projectSessions?: ProjectSessionService; + projectBootstrap?: ProjectBootstrapCoordinator; + submitSessionInput?: (sessionId: string, text: string) => Promise; + onProjectCreated?: (project: StudioProjectSummary) => Promise | void; + onRootBound?: ( + project: StudioProjectSummary, + root: string, + ) => Promise | void; }) { const stateRoot = await fs.mkdtemp( path.join(os.tmpdir(), "agent-map-router-"), @@ -83,7 +93,10 @@ describe("createAgentMapRouter", () => { ], isWorkflowScanComplete: () => true, listWorkspaceScopes, - ...planner, + ...projectLifecycle, + ...(projectLifecycle && !projectLifecycle.submitSessionInput + ? { submitSessionInput: async () => true } + : {}), }), ); server = app.listen(0); @@ -142,7 +155,9 @@ describe("createAgentMapRouter", () => { expect(publicJson).not.toContain(fixture.privateRoot); expect(publicJson).not.toContain("workspace-private-alias"); expect(fixture.onEvent).toHaveBeenCalledTimes(1); - expect(fixture.listWorkspaceScopes).toHaveBeenCalledTimes(2); + // Rendering durable map state is not a project-discovery boundary and + // therefore cannot create/schedule another project lifecycle. + expect(fixture.listWorkspaceScopes).not.toHaveBeenCalled(); }); it("creates a zero-binding project without eagerly creating map state", async () => { @@ -174,6 +189,35 @@ describe("createAgentMapRouter", () => { ).rejects.toMatchObject({ code: "ENOENT" }); }); + it("acknowledges a committed project identity when lifecycle scheduling must retry", async () => { + const onProjectCreated = vi.fn(async () => { + throw new Error("transient coordinator failure"); + }); + const fixture = await start({ onProjectCreated }); + + const response = await fetch(`${fixture.baseUrl}/api/projects`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Harness-Token": "test-token", + }, + body: JSON.stringify({ displayName: "Durably created" }), + }); + const created = (await response.json()) as StudioProjectSummary; + + expect(response.status).toBe(202); + expect(response.headers.get("X-Sapiom-Project-Initialization")).toBe( + "pending", + ); + expect(created.displayName).toBe("Durably created"); + expect(onProjectCreated).toHaveBeenCalledOnce(); + expect( + (await fixture.catalog.list()).filter( + (project) => project.projectId === created.projectId, + ), + ).toHaveLength(1); + }); + it("preserves identity when the authenticated boundary moves and adds root bindings", async () => { const fixture = await start(); const movedRoot = path.join(fixture.stateRoot, "moved-market-research"); @@ -237,6 +281,63 @@ describe("createAgentMapRouter", () => { expect(restarted.projects[0]?.bindings).toHaveLength(2); }); + it("acknowledges durable root mutations and converges lifecycle retries without duplicate bindings", async () => { + const onRootBound = vi + .fn<(project: StudioProjectSummary, root: string) => Promise>() + .mockRejectedValueOnce(new Error("transient add failure")) + .mockResolvedValueOnce() + .mockRejectedValueOnce(new Error("transient move failure")) + .mockResolvedValueOnce(); + const fixture = await start({ onRootBound }); + const addedRoot = path.join(fixture.stateRoot, "publisher-repository"); + const movedRoot = path.join(fixture.stateRoot, "moved-market-research"); + await Promise.all([fs.mkdir(addedRoot), fs.mkdir(movedRoot)]); + fixture.scopes.splice( + 0, + fixture.scopes.length, + { workspaceKey: "workspace-publisher", cwd: addedRoot }, + { workspaceKey: "workspace-moved", cwd: movedRoot }, + ); + const headers = { + "Content-Type": "application/json", + "X-Harness-Token": "test-token", + }; + const addRoute = `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/root-bindings`; + const add = () => + fetch(addRoute, { + method: "POST", + headers, + body: JSON.stringify({ root: addedRoot }), + }); + + const pendingAdd = await add(); + expect(pendingAdd.status).toBe(202); + expect(pendingAdd.headers.get("X-Sapiom-Project-Initialization")).toBe( + "pending", + ); + expect((await add()).status).toBe(201); + + const moveRoute = `${addRoute}/${fixture.project.bindings[0]!.id}`; + const move = () => + fetch(moveRoute, { + method: "PUT", + headers, + body: JSON.stringify({ root: movedRoot }), + }); + const pendingMove = await move(); + expect(pendingMove.status).toBe(202); + expect(pendingMove.headers.get("X-Sapiom-Project-Initialization")).toBe( + "pending", + ); + expect((await move()).status).toBe(200); + + expect(onRootBound).toHaveBeenCalledTimes(4); + const persisted = await fixture.catalog.resolve(fixture.project.projectId); + expect(persisted?.bindings).toHaveLength(2); + expect(JSON.stringify(persisted)).not.toContain(addedRoot); + expect(JSON.stringify(persisted)).not.toContain(movedRoot); + }); + it("does not expose root association without the boot token or allow list", async () => { const fixture = await start(); const unknownRoot = path.join(fixture.stateRoot, "not-opened"); @@ -442,7 +543,7 @@ describe("createAgentMapRouter", () => { expect(await fs.readFile(workspacePath, "utf8")).toBe("{bad-json"); }); - it("protects planner routes and accepts only project-scoped intent", async () => { + it("keeps legacy planner routes as project-scoped aliases to neutral services", async () => { let fixtureProjectId = ""; const plannerSession = { id: "planner-session-1", @@ -462,42 +563,52 @@ describe("createAgentMapRouter", () => { resolution: "created" as const, })); const requireOwned = vi.fn(() => plannerSession); - const enqueue = vi.fn(async () => ({ - identity: { + const enqueue = vi.fn(async () => { + const metadata = { projectId: fixtureProjectId, - sessionId: plannerSession.id, userId: "user-1", - role: "map-planner" as const, - }, - greeting: { status: "pending" as const }, - queuedInputIds: ["input-1"], - })); + targetSessionId: plannerSession.id, + bootstrap: { + status: "skipped" as const, + reason: "user-proceeded" as const, + }, + queuedInputIds: ["input-1"], + }; + plannerSession.projectBootstrap = metadata; + return metadata; + }); const retry = vi.fn(async () => { - if (!plannerSession.planning) throw new Error("missing planner metadata"); - plannerSession.planning = { - ...plannerSession.planning, - greeting: { status: "generating", attemptId: "attempt-2" }, + if (!plannerSession.projectBootstrap) { + throw new Error("missing project bootstrap metadata"); + } + plannerSession.projectBootstrap = { + ...plannerSession.projectBootstrap, + bootstrap: { status: "generating", attemptId: "attempt-2" }, }; }); + const submitSessionInput = vi.fn(async () => true); const fixture = await start({ - planningSessions: { + projectSessions: { open, requireOwned, - } as unknown as PlanningSessionService, - plannerGreeting: { + } as unknown as ProjectSessionService, + projectBootstrap: { enqueue, retry, - } as unknown as PlannerGreetingCoordinator, + } as unknown as ProjectBootstrapCoordinator, + submitSessionInput, }); fixtureProjectId = fixture.project.projectId; - plannerSession.planning = { - identity: { - projectId: fixtureProjectId, - sessionId: plannerSession.id, - userId: "user-1", - role: "map-planner", - }, - greeting: { + plannerSession.agentMapIdentity = { + projectId: fixtureProjectId, + sessionId: plannerSession.id, + userId: "user-1", + }; + plannerSession.projectBootstrap = { + projectId: fixtureProjectId, + targetSessionId: plannerSession.id, + userId: "user-1", + bootstrap: { status: "failed", retryable: true, errorCode: "model_turn_failed", @@ -555,13 +666,10 @@ describe("createAgentMapRouter", () => { expect(message.status).toBe(202); expect(await message.json()).toEqual({ metadata: { - identity: { - projectId: fixture.project.projectId, - sessionId: plannerSession.id, - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "pending" }, + projectId: fixture.project.projectId, + targetSessionId: plannerSession.id, + userId: "user-1", + bootstrap: { status: "skipped", reason: "user-proceeded" }, queuedInputIds: ["input-1"], }, }); @@ -574,6 +682,32 @@ describe("createAgentMapRouter", () => { "Build a support triage system", ); + const followUp = await fetch(`${route}/${plannerSession.id}/messages`, { + method: "POST", + headers: { + "content-type": "application/json", + "X-Harness-Token": "test-token", + }, + body: JSON.stringify({ text: "Keep this behind the durable FIFO" }), + }); + expect(followUp.status).toBe(202); + expect(enqueue).toHaveBeenLastCalledWith( + plannerSession.id, + "Keep this behind the durable FIFO", + ); + expect(enqueue).toHaveBeenCalledTimes(2); + expect(submitSessionInput).not.toHaveBeenCalled(); + + plannerSession.projectBootstrap = { + ...plannerSession.projectBootstrap!, + bootstrap: { + status: "failed", + retryable: true, + errorCode: "model_turn_failed", + }, + queuedInputIds: [], + }; + const retryResponse = await fetch( `${route}/${plannerSession.id}/greeting/retry`, { @@ -588,8 +722,8 @@ describe("createAgentMapRouter", () => { expect(retryResponse.status).toBe(202); expect(await retryResponse.json()).toEqual({ metadata: { - ...plannerSession.planning, - greeting: { status: "generating", attemptId: "attempt-2" }, + ...plannerSession.projectBootstrap, + bootstrap: { status: "generating", attemptId: "attempt-2" }, }, }); expect(retry).toHaveBeenCalledWith(plannerSession.id); @@ -597,21 +731,21 @@ describe("createAgentMapRouter", () => { it("rejects foreign planner messages and bounds unavailable retries", async () => { const requireOwned = vi.fn<() => Promise>(async () => { - throw new PlanningSessionError("forbidden"); + throw new ProjectSessionError("forbidden"); }); const enqueue = vi.fn(async () => ({}) as never); const retry = vi.fn(async () => { - throw new PlannerGreetingRetryUnavailableError(); + throw new ProjectBootstrapRetryUnavailableError(); }); const fixture = await start({ - planningSessions: { + projectSessions: { open: vi.fn(), requireOwned, - } as unknown as PlanningSessionService, - plannerGreeting: { + } as unknown as ProjectSessionService, + projectBootstrap: { enqueue, retry, - } as unknown as PlannerGreetingCoordinator, + } as unknown as ProjectBootstrapCoordinator, }); const headers = { "content-type": "application/json", @@ -640,8 +774,41 @@ describe("createAgentMapRouter", () => { ); expect(retryResponse.status).toBe(409); expect(await retryResponse.json()).toEqual({ - code: "greeting_retry_unavailable", - error: "greeting retry is not available", + code: "project_bootstrap_retry_unavailable", + error: "project bootstrap retry is not available", + }); + }); + + it("returns a bounded compatibility error when project scope cannot be revalidated", async () => { + const requireOwned = vi.fn(async () => { + throw new ProjectSessionScopeUnavailableError("ordinary-session"); + }); + const fixture = await start({ + projectSessions: { + open: vi.fn(), + requireOwned, + } as unknown as ProjectSessionService, + projectBootstrap: { + enqueue: vi.fn(), + retry: vi.fn(), + } as unknown as ProjectBootstrapCoordinator, + }); + const response = await fetch( + `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/ordinary-session/messages`, + { + method: "POST", + headers: { + "content-type": "application/json", + "X-Harness-Token": "test-token", + }, + body: JSON.stringify({ text: "continue" }), + }, + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "PROJECT_SESSION_SCOPE_UNAVAILABLE", + error: "the session's Studio project scope could not be revalidated", }); }); }); diff --git a/packages/harness/src/server/agent-map.ts b/packages/harness/src/server/agent-map.ts index cf83faa24..a7e47f9d6 100644 --- a/packages/harness/src/server/agent-map.ts +++ b/packages/harness/src/server/agent-map.ts @@ -7,6 +7,7 @@ import { type AgentMapWorkspaceResponse, type PlannerMessageRequest, type PlannerSessionRequest, + type StudioProjectSummary, type StudioWorkspaceSelection, } from "../shared/agent-map.js"; import { SPAWNABLE_HARNESS_KINDS, type WorkflowInfo } from "../shared/types.js"; @@ -25,13 +26,15 @@ import { StudioWorkspacePreferenceStoreError, } from "../core/studio-workspace-preferences.js"; import { - PlanningSessionError, - type PlanningSessionService, + ProjectSessionError, + type ProjectSessionService, } from "../core/planning-session.js"; +import { ProjectSessionScopeUnavailableError } from "../core/session-manager.js"; import { - PlannerDispatchForbiddenError, - PlannerGreetingRetryUnavailableError, - type PlannerGreetingCoordinator, + ProjectBootstrapDispatchForbiddenError, + ProjectBootstrapRetryUnavailableError, + projectBootstrapOwnsInput, + type ProjectBootstrapCoordinator, } from "../core/planner-greeting.js"; export interface AgentMapRouterOptions { @@ -50,8 +53,16 @@ export interface AgentMapRouterOptions { listWorkspaceScopes: () => | readonly WorkspaceScopeSummary[] | Promise; - planningSessions?: PlanningSessionService; - plannerGreeting?: PlannerGreetingCoordinator; + projectSessions?: ProjectSessionService; + projectBootstrap?: ProjectBootstrapCoordinator; + /** New-project lifecycle hooks; never called by Agent Map reads. */ + onProjectCreated?: (project: StudioProjectSummary) => Promise | void; + onRootBound?: ( + project: StudioProjectSummary, + root: string, + ) => Promise | void; + /** Neutral ordinary-session input boundary used by rolling aliases. */ + submitSessionInput?: (sessionId: string, text: string) => Promise; } const plannerSessionSchema = z @@ -66,15 +77,19 @@ const plannerMessageSchema = z .object({ text: z.string().min(1).max(100_000) }) .strict() satisfies z.ZodType; -function sendPlanningError( +function sendProjectSessionError( res: import("express").Response, error: unknown, ): boolean { - if (error instanceof PlannerDispatchForbiddenError) { + if (error instanceof ProjectBootstrapDispatchForbiddenError) { res.status(403).json({ code: error.code, error: error.message }); return true; } - if (!(error instanceof PlanningSessionError)) return false; + if (error instanceof ProjectSessionScopeUnavailableError) { + res.status(409).json({ code: error.code, error: error.message }); + return true; + } + if (!(error instanceof ProjectSessionError)) return false; const status = error.code === "project_not_found" || error.code === "session_not_found" ? 404 @@ -160,9 +175,9 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { res.status(400).json(errorBody("malformed_state")); return; } + let project: StudioProjectSummary; try { - const project = await options.catalog.create(parsed.data.displayName); - res.status(201).setHeader("Cache-Control", "no-store").json(project); + project = await options.catalog.create(parsed.data.displayName); } catch (error) { const bounded = error instanceof StudioProjectCatalogError @@ -171,6 +186,21 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { res .status(bounded === "storage_unavailable" ? 503 : 400) .json(errorBody(bounded)); + return; + } + try { + await options.onProjectCreated?.(project); + res.status(201).setHeader("Cache-Control", "no-store").json(project); + } catch { + // The catalog commit already won. Report that stable identity instead of + // a 503 that invites a non-idempotent retry and creates a second project; + // lifecycle scheduling is project-keyed and may converge on root bind or + // startup recovery. + res + .status(202) + .setHeader("Cache-Control", "no-store") + .setHeader("X-Sapiom-Project-Initialization", "pending") + .json(project); } }); @@ -184,6 +214,8 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { res.status(400).json(errorBody("malformed_state")); return; } + let updated: StudioProjectSummary; + let root: string; try { const project = await options.catalog.resolve(req.params.projectId); if (!project) { @@ -195,12 +227,10 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { res.status(404).json(errorBody("project_not_found")); return; } - const updated = await options.catalog.addRootBinding( - project.projectId, - scope.cwd, - { legacyWorkspaceKey: scope.workspaceKey }, - ); - res.status(201).setHeader("Cache-Control", "no-store").json(updated); + root = scope.cwd; + updated = await options.catalog.addRootBinding(project.projectId, root, { + legacyWorkspaceKey: scope.workspaceKey, + }); } catch (error) { const bounded = error instanceof StudioProjectCatalogError @@ -209,6 +239,20 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { res .status(bounded === "storage_unavailable" ? 503 : 400) .json(errorBody(bounded)); + return; + } + try { + await options.onRootBound?.(updated, root); + res.status(201).setHeader("Cache-Control", "no-store").json(updated); + } catch { + // The binding commit is already durable and idempotent. Preserve its + // stable identity while telling the client only that lifecycle work is + // pending; retrying this same association cannot append another binding. + res + .status(202) + .setHeader("Cache-Control", "no-store") + .setHeader("X-Sapiom-Project-Initialization", "pending") + .json(updated); } }); @@ -220,6 +264,8 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { res.status(400).json(errorBody("malformed_state")); return; } + let updated: StudioProjectSummary; + let root: string; try { const project = await options.catalog.resolve(req.params.projectId); if (!project) { @@ -231,13 +277,13 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { res.status(404).json(errorBody("project_not_found")); return; } - const updated = await options.catalog.moveRootBinding( + root = scope.cwd; + updated = await options.catalog.moveRootBinding( project.projectId, req.params.bindingId, - scope.cwd, + root, scope.workspaceKey, ); - res.status(200).setHeader("Cache-Control", "no-store").json(updated); } catch (error) { const bounded = error instanceof StudioProjectCatalogError @@ -246,25 +292,34 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { res .status(bounded === "storage_unavailable" ? 503 : 400) .json(errorBody(bounded)); + return; + } + try { + await options.onRootBound?.(updated, root); + res.status(200).setHeader("Cache-Control", "no-store").json(updated); + } catch { + res + .status(202) + .setHeader("Cache-Control", "no-store") + .setHeader("X-Sapiom-Project-Initialization", "pending") + .json(updated); } }, ); const projectContext = async (projectId: string) => { - const reconciled = await options.catalog.reconcile( - await options.listWorkspaceScopes(), - ); const project = await options.catalog.resolve(projectId); if (!project) return null; + const identity = await options.catalog.resolveIdentity(project.projectId); + if (!identity) return null; return { project, - roots: reconciled.workspaceScopes - .filter((scope) => scope.projectId === project.projectId) - .map((scope) => scope.cwd), + roots: identity.rootBindings + .filter((binding) => binding.status === "active") + .map((binding) => binding.localRootRef), }; }; router.get("/projects/:projectId/agent-map/workspace", async (req, res) => { try { - await options.catalog.reconcile(await options.listWorkspaceScopes()); const project = await options.catalog.resolve(req.params.projectId); if (!project) { res.status(404).json(errorBody("project_not_found")); @@ -358,11 +413,12 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { } }); + /** @deprecated Rolling alias to ordinary project-session open; remove in SAP-3152. */ router.post( "/projects/:projectId/planner-sessions", async (req, res, next) => { - if (!options.planningSessions || !options.plannerGreeting) { - res.status(501).json({ error: "Planner sessions are unavailable" }); + if (!options.projectSessions) { + res.status(501).json({ error: "Project sessions are unavailable" }); return; } const parsed = plannerSessionSchema.safeParse(req.body); @@ -371,23 +427,23 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { return; } try { - await options.catalog.reconcile(await options.listWorkspaceScopes()); - const result = await options.planningSessions.open( + const result = await options.projectSessions.open( req.params.projectId, parsed.data, ); res.status(result.resolution === "created" ? 201 : 200).json(result); } catch (error) { - if (!sendPlanningError(res, error)) next(error); + if (!sendProjectSessionError(res, error)) next(error); } }, ); + /** @deprecated Rolling alias to ordinary project-session input; remove in SAP-3152. */ router.post( "/projects/:projectId/planner-sessions/:sessionId/messages", async (req, res, next) => { - if (!options.planningSessions || !options.plannerGreeting) { - res.status(501).json({ error: "Planner sessions are unavailable" }); + if (!options.projectSessions || !options.submitSessionInput) { + res.status(501).json({ error: "Project sessions are unavailable" }); return; } const parsed = plannerMessageSchema.safeParse(req.body); @@ -396,27 +452,42 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { return; } try { - await options.planningSessions.requireOwned( + const session = await options.projectSessions.requireOwned( req.params.projectId, req.params.sessionId, ); - const metadata = await options.plannerGreeting.enqueue( - req.params.sessionId, - parsed.data.text, - ); - res.status(202).json({ metadata }); + if ( + options.projectBootstrap && + projectBootstrapOwnsInput(session.projectBootstrap) + ) { + await options.projectBootstrap.enqueue( + req.params.sessionId, + parsed.data.text, + ); + } else if ( + !(await options.submitSessionInput( + req.params.sessionId, + parsed.data.text, + )) + ) { + res + .status(404) + .json({ error: "Project session has no live process" }); + return; + } + res.status(202).json({ metadata: session.projectBootstrap ?? null }); } catch (error) { - if (!sendPlanningError(res, error)) next(error); + if (!sendProjectSessionError(res, error)) next(error); } }, ); - /** @deprecated Compatibility-only for sessions created before synthetic greeting removal. */ + /** @deprecated Rolling alias; remove after persisted clients migrate in SAP-3152. */ router.post( "/projects/:projectId/planner-sessions/:sessionId/greeting/retry", async (req, res, next) => { - if (!options.planningSessions || !options.plannerGreeting) { - res.status(501).json({ error: "Planner sessions are unavailable" }); + if (!options.projectSessions || !options.projectBootstrap) { + res.status(501).json({ error: "Project bootstrap is unavailable" }); return; } if (Object.keys((req.body ?? {}) as object).length > 0) { @@ -424,18 +495,21 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { return; } try { - const session = await options.planningSessions.requireOwned( + const session = await options.projectSessions.requireOwned( req.params.projectId, req.params.sessionId, ); - await options.plannerGreeting.retry(req.params.sessionId); + if (!session.projectBootstrap) { + throw new ProjectBootstrapRetryUnavailableError(); + } + await options.projectBootstrap.retry(req.params.sessionId); res.status(202).json({ - metadata: session.planning, + metadata: session.projectBootstrap, }); } catch (error) { - if (error instanceof PlannerGreetingRetryUnavailableError) { + if (error instanceof ProjectBootstrapRetryUnavailableError) { res.status(409).json({ code: error.code, error: error.message }); - } else if (!sendPlanningError(res, error)) next(error); + } else if (!sendProjectSessionError(res, error)) next(error); } }, ); diff --git a/packages/harness/src/server/auth-routes.ts b/packages/harness/src/server/auth-routes.ts index 7c737c4ef..3705d13a1 100644 --- a/packages/harness/src/server/auth-routes.ts +++ b/packages/harness/src/server/auth-routes.ts @@ -118,6 +118,8 @@ export interface AuthRoutesOptions { performBrowserAuthImpl?: typeof performBrowserAuth; /** Server-private identity projection for authorization consumers. It is * intentionally not added to AuthState or any browser response. */ + onProjectUserChanged?: (userId: string | null) => void; + /** @deprecated Use onProjectUserChanged; remove in SAP-3152. */ onPlanningUserChanged?: (userId: string | null) => void; } @@ -136,8 +138,11 @@ export function createAuthRouter(opts: AuthRoutesOptions): Router { authEnabled = true, environment, performBrowserAuthImpl = performBrowserAuth, + onProjectUserChanged, onPlanningUserChanged, } = opts; + const notifyProjectUserChanged = + onProjectUserChanged ?? onPlanningUserChanged; // Track any in-flight start() call so a second concurrent POST /api/auth/start // returns a clear error rather than racing two browser-open flows. @@ -215,7 +220,7 @@ export function createAuthRouter(opts: AuthRoutesOptions): Router { // CLI identity uses tenantId as HarnessIdentity.userId. Update the // private live principal in the same committed transition as auth; // never expose it in the public auth status payload. - onPlanningUserChanged?.(result.tenantId); + notifyProjectUserChanged?.(result.tenantId); bus.publish({ type: "auth.changed", authenticated: true, @@ -270,7 +275,7 @@ export function createAuthRouter(opts: AuthRoutesOptions): Router { apiKeyProvider.clear(); authState.set({ authenticated: false, organizationName: null }); - onPlanningUserChanged?.(null); + notifyProjectUserChanged?.(null); bus.publish({ type: "auth.changed", authenticated: false, diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 00382cab1..65ea74f1b 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -31,11 +31,25 @@ import type { WorkflowInfo, } from "../shared/types.js"; import { JSON_BODY_LIMIT_BYTES } from "../shared/types.js"; -import type { PlannerLifecycleEvent } from "../shared/agent-map.js"; +import type { + ProjectBootstrapLifecycleEvent, + ProjectAgentSession, + StudioProjectSummary, +} from "../shared/agent-map.js"; +import { + preferredProjectRoot, + projectRoots, + projectSessionRoot, +} from "../shared/project-roots.js"; import { unhandledRequestErrorHandler } from "./error-handler.js"; import { expandHome, resolveStatePaths } from "../core/paths.js"; import { + AdapterNotFoundError, + ExternalHarnessError, + ProjectBootstrapClaimUnavailableError, + ProjectSessionScopeUnavailableError, SessionManager, + SessionManagerClosingError, type LaunchOptsBuilder, } from "../core/session-manager.js"; import { TaskManager } from "../core/task-manager.js"; @@ -98,7 +112,7 @@ import { sweepGeneratedDirs, } from "../core/inject/retention.js"; import { DEFAULT_SYSTEM_PROMPT } from "../profiles/default.js"; -import { AGENT_MAP_PLANNER_SYSTEM_PROMPT } from "../profiles/agent-map-planner.js"; +import { PROJECT_AGENT_PROMPT_APPENDIX } from "../profiles/project-agent.js"; import { fetchSystemPromptForActiveEnvironment } from "../profiles/system-prompt-fetch.js"; import { agentCoreTemplatesDir } from "../core/agent-core-templates.js"; import { CanvasWatcherManager } from "../core/canvas-watcher.js"; @@ -162,7 +176,11 @@ import { AgentMapCapabilityRegistry, type AgentMapCapabilityEvent, } from "../core/agent-map-capability-registry.js"; -import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; +import { + StudioProjectCatalog, + type ReconciledStudioProjects, +} from "../core/studio-project-catalog.js"; +import { ProjectBootstrapOutbox } from "../core/project-bootstrap-outbox.js"; import { createAgentMapMcpRouter, type AgentMapMcpRouter, @@ -170,11 +188,15 @@ import { import { AgentMapMcpProjectUnavailableError } from "./agent-map-mcp-tools.js"; import { StudioWorkspacePreferenceStore } from "../core/studio-workspace-preferences.js"; import { - isPlannerDispatchAuthorized, - localPlanningPrincipal, - PlanningSessionService, + isProjectSessionDispatchAuthorized, + localProjectPrincipal, + ProjectSessionService, } from "../core/planning-session.js"; -import { PlannerGreetingCoordinator } from "../core/planner-greeting.js"; +import { + ProjectBootstrapCoordinator, + ProjectBootstrapCoordinatorClosedError, + projectBootstrapOwnsInput, +} from "../core/planner-greeting.js"; import { IngestCredentialRegistry } from "../core/ingest-credentials.js"; import { createStaticRouter } from "./static.js"; import { createTerminalWebSocketHandler } from "./terminal-ws.js"; @@ -361,6 +383,12 @@ export interface HarnessServerOptions { workflowPath: string, ) => void | Promise; }; + /** Internal deterministic seam for project/bootstrap crash-window tests. */ + projectBootstrapTestHooks?: { + beforeSchedule?: (projectId: string) => void | Promise; + afterProjectSessionNeeded?: (projectId: string) => void | Promise; + afterListenBeforeRecovery?: (port: number) => void | Promise; + }; } export interface HarnessServer { @@ -571,13 +599,10 @@ function createDefaultBuildLaunchOpts( // resolves to the bundled DEFAULT_SYSTEM_PROMPT on any failure rather than // throwing; the `.catch` covers an injected loader that does not, because a // session must never fail to start over the text of its prompt. - const promptPromise = - context?.agentMapIdentity?.role === "map-planner" - ? Promise.resolve(AGENT_MAP_PLANNER_SYSTEM_PROMPT) - : loadSystemPrompt().catch((err: unknown) => { - console.error("[harness] system-prompt load failed:", err); - return DEFAULT_SYSTEM_PROMPT; - }); + const promptPromise = loadSystemPrompt().catch((err: unknown) => { + console.error("[harness] system-prompt load failed:", err); + return DEFAULT_SYSTEM_PROMPT; + }); const [settings, mcpConfigFile, prompt, pluginDir] = await Promise.all([ generateClaudeSettings({ harnessSessionId, @@ -600,7 +625,11 @@ function createDefaultBuildLaunchOpts( promptPromise, generateSkillsPlugin(harnessSessionId, { generatedRoot }), ]); - const appendices = [viaSystemPrompt ? brief : null, context?.promptAppendix] + const appendices = [ + viaSystemPrompt ? brief : null, + context?.agentMapIdentity ? PROJECT_AGENT_PROMPT_APPENDIX : null, + context?.promptAppendix, + ] .filter( (value): value is string => typeof value === "string" && value.trim() !== "", @@ -661,10 +690,29 @@ export const startServer = async ( organizationName: identity?.organizationName ?? null, }); const statePaths = resolveStatePaths(options.stateRoot); + const projectBootstrapOutbox = new ProjectBootstrapOutbox( + join(statePaths.projectBootstrap, "project-outbox"), + ); + let afterStudioProjectsCreatedCommit = async ( + _projects: readonly StudioProjectSummary[], + ): Promise => {}; + let convergeReconciledProjectLifecycle = async ( + _reconciled: ReconciledStudioProjects, + ): Promise => {}; const studioProjectCatalog = new StudioProjectCatalog( statePaths.studioProjects, + undefined, + undefined, + { + beforeProjectsCreatedCommit: (projects) => + projectBootstrapOutbox.stage(projects), + afterProjectsCreatedCommit: (projects) => + afterStudioProjectsCreatedCommit(projects), + }, ); - let emitAgentMapCapabilityEvent = (_event: AgentMapCapabilityEvent): void => {}; + let emitAgentMapCapabilityEvent = ( + _event: AgentMapCapabilityEvent, + ): void => {}; const agentMapCapabilities = new AgentMapCapabilityRegistry({ onEvent: (event) => emitAgentMapCapabilityEvent(event), }); @@ -675,7 +723,7 @@ export const startServer = async ( // Authentication may change in-app without restarting Studio. Keep the // planning principal live and server-private; browser auth DTOs expose only // their existing boolean/organization fields. - let planningUserId = identity?.userId ?? null; + let projectUserId = identity?.userId ?? null; // One-way identity migration: seed ~/.sapiom/analytics.json from the // legacy harness machine-id so existing installs keep the same anonymous_id @@ -820,6 +868,131 @@ export const startServer = async ( console.error("[harness] recent-dirs prune failed:", err); } let workflowsCache: RegistryWorkflowInfo[] = await workflowRegistry.list(); + // Assigned once after callback construction; those callbacks need the live + // binding before SessionManager itself can be instantiated. + // eslint-disable-next-line prefer-const + let sessionManager!: SessionManager; + let projectBootstrap: ProjectBootstrapCoordinator | null = null; + const pendingProjectCwds = new Set(); + const rawProjectRoots = async (): Promise => { + const settings = await loadSettings(statePaths.settings); + return [ + ...pendingProjectCwds, + ...settings.recentDirs, + ...(sessionManager + ? sessionManager.list().map((session) => session.cwd) + : []), + ]; + }; + // Legacy System Graph routes retain every explicitly known root. Studio's + // durable project catalog uses the canonical derivation below; keeping the + // two catalogs separate avoids changing the existing graph authority while + // project/session identity converges on one server/client contract. + const workspaceScopeCatalog = new LocalWorkspaceScopeCatalog(rawProjectRoots); + const studioWorkspaceScopeCatalog = new LocalWorkspaceScopeCatalog( + async () => { + const settings = await loadSettings(statePaths.settings); + const durableProjects = await studioProjectCatalog.list(); + const durableIdentities = ( + await Promise.all( + durableProjects.map((project) => + studioProjectCatalog.resolveIdentity(project.projectId), + ), + ) + ).filter((project) => project !== null); + const durableRoots = durableIdentities.flatMap((project) => + project.rootBindings.map((binding) => binding.localRootRef), + ); + const durableRootCandidates = durableIdentities.flatMap((project) => + project.rootBindings + .filter((binding) => binding.status === "active") + .map((binding) => ({ + projectId: project.projectId, + cwd: binding.localRootRef, + })), + ); + const retainedProjectSessionRoots = new Set(); + const sessions = sessionManager + ? sessionManager.list().flatMap((session) => { + if (!session.agentMapIdentity) { + return [ + { + cwd: session.cwd, + createdAt: session.lastActiveAt, + status: session.status, + }, + ]; + } + const root = projectSessionRoot( + { + cwd: session.cwd, + projectId: session.agentMapIdentity.projectId, + }, + durableRootCandidates, + ); + // A neutral project session contributes its trusted project root, + // never its descendant cwd. If its binding is stale, omit it from + // discovery rather than minting a replacement authority from the + // untrusted path. + if (root) { + retainedProjectSessionRoots.add(root); + return [ + { + cwd: root, + createdAt: session.lastActiveAt, + status: session.status, + }, + ]; + } + return []; + }) + : []; + const candidates = [ + ...pendingProjectCwds, + ...settings.recentDirs, + ...sessions.map((session) => session.cwd), + ]; + // Root identity must be final before launch, even when the asynchronous + // workflow scan has not populated its cache yet. Probe only the candidate + // roots themselves; deeper discovery remains the registry's job. + const directlyMarked = ( + await Promise.all( + candidates.map(async (candidate) => ({ + candidate, + marker: await inspectAgentProjectMarker(candidate), + })), + ) + ) + .filter(({ marker }) => marker.status === "valid") + .map(({ candidate }) => candidate); + const visibleRoots = projectRoots({ + recentDirs: settings.recentDirs, + sessions, + pendingCwds: [...pendingProjectCwds], + pinnedRoots: durableRoots, + agentPaths: [ + ...workflowsCache.map((workflow) => workflow.path), + ...directlyMarked, + ], + sort: "recent", + }); + // MRU/rail visibility is not an authority revocation mechanism. An + // existing project session must remain resumable after its recent-dir + // entry is evicted, including an otherwise empty project whose session + // cwd is below the durable root. The browser may still hide an explicitly + // removed project through its local closed-project projection. + return [ + ...visibleRoots, + ...[...retainedProjectSessionRoots].filter( + (root) => + !visibleRoots.some( + (visible) => + canonicalGraphPath(visible) === canonicalGraphPath(root), + ), + ), + ]; + }, + ); const initialInventorySnapshot = await workflowRegistry.inventorySnapshot(launchDir); type AcceptedCanonicalWorkflowRoot = { @@ -1003,6 +1176,7 @@ export const startServer = async ( // manager, because the "exited" handler archives through it. const recordsRoot = options.recordsRoot ?? statePaths.records; const recordArchive = createRecordArchive({ root: recordsRoot }); + const pendingRecordArchives = new Set>(); // Past-session transcripts, rebuilt from the events above rather than from // any vendor's history file — the same code path for claude-code and codex. @@ -1038,9 +1212,14 @@ export const startServer = async ( await recordArchive.sweep(); }; const archiveSessionRecordDetached = (harnessSessionId: string): void => { - void archiveSessionRecord(harnessSessionId).catch((err: unknown) => { - console.error("[harness] session record archive failed:", err); - }); + const operation = archiveSessionRecord(harnessSessionId) + .catch((err: unknown) => { + console.error("[harness] session record archive failed:", err); + }) + .finally(() => { + pendingRecordArchives.delete(operation); + }); + pendingRecordArchives.add(operation); }; // Exit-time deletion of generated/ (see the onStatusChange handler @@ -1161,7 +1340,52 @@ export const startServer = async ( } }; - const sessionManager = new SessionManager({ + const projectIdentityMigrationEvents: Array<{ + sessionId: string; + outcome: "migrated" | "rejected"; + }> = []; + let projectScopeResolutionQueue: Promise = Promise.resolve(); + const serializeProjectScopeResolution = ( + operation: () => Promise, + ): Promise => { + const result = projectScopeResolutionQueue.catch(() => {}).then(operation); + projectScopeResolutionQueue = result.then( + () => {}, + () => {}, + ); + return result; + }; + // A catalog mutation is durable before its lifecycle callback runs. Retain + // any not-yet-scheduled project IDs in memory so a retry can converge on the + // same project instead of creating another one after a transient failure. + const projectsAwaitingBootstrapSchedule = new Set(); + const scheduleBootstrapProjects = async ( + projectIds: Iterable, + userId: string, + scopeSessionId: string, + ): Promise => { + const ids = [...new Set(projectIds)].sort(); + for (const projectId of ids) { + projectsAwaitingBootstrapSchedule.add(projectId); + } + for (const projectId of ids) { + if (localProjectPrincipal(projectUserId, machineId) !== userId) { + throw new ProjectSessionScopeUnavailableError(scopeSessionId); + } + if (!projectBootstrap) { + throw new Error("project bootstrap coordinator is unavailable"); + } + await options.projectBootstrapTestHooks?.beforeSchedule?.(projectId); + await projectBootstrap.scheduleProject(projectId, userId); + await projectBootstrapOutbox.complete(projectId); + if (localProjectPrincipal(projectUserId, machineId) !== userId) { + throw new ProjectSessionScopeUnavailableError(scopeSessionId); + } + projectsAwaitingBootstrapSchedule.delete(projectId); + } + }; + + sessionManager = new SessionManager({ adapters, ingestUrl: `http://${host}:${options.port}`, ingestCredentials, @@ -1169,34 +1393,113 @@ export const startServer = async ( sessionsPath: options.sessionsPath ?? statePaths.sessions, buildLaunchOpts, resolveAgentMapIdentity: async (sessionId, cwd, persisted) => { - // Planner ownership already uses a stable machine-local principal when - // Studio runs with --no-auth. Capability issuance must use that same - // identity; requiring an authenticated user here silently removed the - // Agent Map server from every signed-out planner's MCP config. - const userId = localPlanningPrincipal(planningUserId, machineId); - const project = await studioProjectCatalog.resolveIdentityForPath(cwd); - if (!project) return undefined; - if ( - persisted?.sessionId === sessionId && - persisted.projectId === project.projectId && - persisted.userId === userId && - (persisted.role === "map-planner" || - (persisted.role === "agent-builder" && - persisted.assignment.kind === "planned")) - ) { - return structuredClone(persisted); + const userId = localProjectPrincipal(projectUserId, machineId); + return serializeProjectScopeResolution(async () => { + const assertPrincipal = (): void => { + if (localProjectPrincipal(projectUserId, machineId) !== userId) { + throw new ProjectSessionScopeUnavailableError(sessionId); + } + }; + const identityFor = (projectId: string): ProjectAgentSession => ({ + projectId, + sessionId, + userId, + }); + assertPrincipal(); + if ( + persisted && + (persisted.sessionId !== sessionId || persisted.userId !== userId) + ) { + throw new ProjectSessionScopeUnavailableError(sessionId); + } + + // Resume/final-spawn validation is read-only. In particular, never add + // a descendant cwd as a candidate root before proving that the current + // durable project still owns it. + let project = await studioProjectCatalog.resolveIdentityForPath(cwd); + assertPrincipal(); + if (persisted) { + if (!project || project.projectId !== persisted.projectId) { + throw new ProjectSessionScopeUnavailableError(sessionId); + } + return identityFor(project.projectId); + } + + if (project) { + if (projectsAwaitingBootstrapSchedule.has(project.projectId)) { + await scheduleBootstrapProjects( + [project.projectId], + userId, + sessionId, + ); + } + return identityFor(project.projectId); + } + + const before = new Set( + (await studioProjectCatalog.list()).map( + (candidate) => candidate.projectId, + ), + ); + assertPrincipal(); + let reconciled = await studioProjectCatalog.reconcile( + await studioWorkspaceScopeCatalog.list(), + ); + assertPrincipal(); + project = await studioProjectCatalog.resolveIdentityForPath(cwd); + + if (!project) { + pendingProjectCwds.add(cwd); + try { + reconciled = await studioProjectCatalog.reconcile( + await studioWorkspaceScopeCatalog.list(), + ); + assertPrincipal(); + project = await studioProjectCatalog.resolveIdentityForPath(cwd); + } finally { + pendingProjectCwds.delete(cwd); + } + } + assertPrincipal(); + const createdProjectIds = reconciled.projects + .map((candidate) => candidate.projectId) + .filter((projectId) => !before.has(projectId)); + await scheduleBootstrapProjects(createdProjectIds, userId, sessionId); + if (!project) return undefined; + return identityFor(project.projectId); + }); + }, + prepareProjectSession: async (identity, request) => { + if (localProjectPrincipal(projectUserId, machineId) !== identity.userId) { + throw new ProjectSessionScopeUnavailableError(identity.sessionId); } - return { - projectId: project.projectId, - sessionId, - userId, - role: "agent-builder", - assignment: { kind: "unplanned" }, - }; + const metadata = await projectBootstrap?.claimProject( + identity, + request.initialUserInputPending === true || + Boolean(request.rehydrateFrom), + ); + if (localProjectPrincipal(projectUserId, machineId) !== identity.userId) { + throw new ProjectSessionScopeUnavailableError(identity.sessionId); + } + return metadata + ? { initialTitle: "Plan Agents", projectBootstrap: metadata } + : {}; + }, + onTerminalInput: (sessionId) => + projectBootstrap?.onTerminalInput(sessionId), + onProjectAgentIdentityMigration: (event) => { + projectIdentityMigrationEvents.push(event); + }, + onProjectBootstrapSession: async (session, mode) => { + await projectBootstrap?.register(session, { + emptyProject: true, + mode, + }); }, onAgentMapSessionExit: async (sessionId) => { agentMapCapabilities.revokeSession(sessionId); await agentMapMcp?.revokeSession(sessionId); + await projectBootstrap?.releaseSessionClaim(sessionId); }, // Every session gets its initial harness-context.json regardless of // entry point (REST, autoCreateSession) — see SessionManager.create(). @@ -1205,11 +1508,6 @@ export const startServer = async ( ensureCanvasTemplate, }); await sessionManager.init(); - - const workspaceScopeCatalog = new LocalWorkspaceScopeCatalog(async () => [ - ...(await loadSettings(statePaths.settings)).recentDirs, - ...sessionManager.list().map((session) => session.cwd), - ]); const activeSystemGraphScopes = new Map(); const systemGraphInvocations = new CachedAgentInvocationProvider( new SourceAgentInvocationProvider(), @@ -2394,7 +2692,21 @@ export const startServer = async ( } } try { - return (await studioProjectCatalog.reconcile(scopes)).workspaceScopes; + const studioScopes = await studioWorkspaceScopeCatalog.list(); + const reconciliation = await studioProjectCatalog.reconcile(studioScopes); + await convergeReconciledProjectLifecycle(reconciliation); + const reconciled = reconciliation.workspaceScopes; + const byRoot = new Map( + reconciled.map((scope) => [resolve(scope.cwd), scope]), + ); + for (const scope of scopes) { + if (!byRoot.has(resolve(scope.cwd))) { + byRoot.set(resolve(scope.cwd), scope); + } + } + return [...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. @@ -2566,7 +2878,7 @@ export const startServer = async ( // Fire-and-forget: boot must not wait on it. The cost is one full index build // (~130 ms against a 50 MB log), which the first history open would have paid // anyway. - void backfillSessionRecords({ + const recordBackfill = backfillSessionRecords({ conversationIds: () => sessionRecordReader.conversationIds(), readFromEvents: (id) => sessionRecordReader.readFromEvents(id), archive: recordArchive, @@ -2621,7 +2933,9 @@ export const startServer = async ( // and createIngestRouter) so the uiTrack closure can reference it lazily. const seqCounter = createSeqCounter(); - const emitPlannerLifecycle = (event: PlannerLifecycleEvent): void => { + const emitProjectBootstrapLifecycle = ( + event: ProjectBootstrapLifecycleEvent, + ): void => { const session = sessionManager.get(event.sessionId); const analyticsEvent: AnalyticsEvent = { eventId: randomUUID(), @@ -2631,42 +2945,65 @@ export const startServer = async ( tenantId: identity?.tenantId ?? null, machineId, harnessSessionId: event.sessionId, - // Planner lifecycle correlation uses the server-owned harness session. + // Bootstrap lifecycle correlation uses the server-owned harness session. // Provider session identity is unnecessary and may originate in a hook. agentSessionId: null, harness: session?.harness ?? "claude-code", type: event.name, payload: { project_id: event.projectId, - ...("resolution" in event - ? { resolution: event.resolution } - : { + ...("queueDepth" in event + ? { queue_depth: Math.max(0, Math.min(10_000, event.queueDepth)), - ...("attemptId" in event && event.attemptId - ? { attempt_id: event.attemptId } - : {}), - ...(event.name === "planner_greeting.failed" - ? { - error_code: event.errorCode, - retryable: event.retryable, - } - : {}), - ...(event.name === "planner_greeting.skipped" - ? { reason: event.reason } - : {}), - ...(event.name === "planner_session.input_delivery_uncertain" - ? { - input_id: event.inputId, - error_code: event.errorCode, - } - : {}), - }), + } + : {}), + ...("attemptId" in event && event.attemptId + ? { attempt_id: event.attemptId } + : {}), + ...("retryOrdinal" in event + ? { retry_ordinal: event.retryOrdinal } + : {}), + ...("retryable" in event + ? { + error_code: event.errorCode, + retryable: event.retryable, + } + : {}), + ...("reason" in event ? { reason: event.reason } : {}), + ...("inputId" in event + ? { + input_id: event.inputId, + error_code: event.errorCode, + } + : {}), }, }; void eventStore.append(analyticsEvent).catch(() => {}); batcher.enqueue(analyticsEvent); }; + for (const migration of projectIdentityMigrationEvents.splice(0)) { + const migrationEvent: AnalyticsEvent = { + eventId: randomUUID(), + seq: seqCounter.next(migration.sessionId), + ts: new Date().toISOString(), + userId: identity?.userId ?? null, + tenantId: identity?.tenantId ?? null, + machineId, + harnessSessionId: migration.sessionId, + agentSessionId: null, + harness: + sessionManager.get(migration.sessionId)?.harness ?? "claude-code", + type: + migration.outcome === "migrated" + ? "project_agent.identity_migrated" + : "project_agent.identity_rejected", + payload: { outcome: migration.outcome }, + }; + void eventStore.append(migrationEvent).catch(() => {}); + batcher.enqueue(migrationEvent); + } + const agentMapWorkspaceStore = new AgentMapWorkspaceStore( statePaths.agentMap, { @@ -2727,7 +3064,6 @@ export const startServer = async ( type: "agent_map.capability", payload: { name: event.name, - ...(event.role ? { role: event.role } : {}), ...(event.reason ? { reason: event.reason } : {}), }, }; @@ -2758,7 +3094,6 @@ export const startServer = async ( payload: { tool: event.tool, outcome: event.outcome, - role: event.role, latency_ms: Math.max(0, Math.min(60_000, event.latencyMs)), ...(event.errorCode ? { error_code: event.errorCode } : {}), }, @@ -2780,8 +3115,9 @@ export const startServer = async ( const annotateStudioSelections = async ( workflows: readonly RegistryWorkflowInfo[], ): Promise => { - const scopes = await workspaceScopeCatalog.list(); + const scopes = await studioWorkspaceScopeCatalog.list(); const reconciled = await studioProjectCatalog.reconcile(scopes); + await convergeReconciledProjectLifecycle(reconciled); const projects = reconciled.projects; const annotations = new Map< string, @@ -2817,84 +3153,251 @@ export const startServer = async ( }); }; - const plannerGreeting = new PlannerGreetingCoordinator({ - root: statePaths.plannerSessions, + const isMeaningfullyEmptyProject = async ( + projectId: string, + ): Promise => { + const snapshot = await agentMapProposalService.read(projectId); + return ( + snapshot.workspace.confirmedRevisionId === null && + snapshot.workspace.projectBuildPlanId === null && + (snapshot.proposal === null || + (snapshot.proposal.nodes.length === 0 && + snapshot.proposal.relationships.length === 0)) + ); + }; + projectBootstrap = new ProjectBootstrapCoordinator({ + root: statePaths.projectBootstrap, + legacyRoot: statePaths.plannerSessions, sessionManager, canDispatch: (session) => - isPlannerDispatchAuthorized({ + isProjectSessionDispatchAuthorized({ session, - currentPrincipal: () => - localPlanningPrincipal(planningUserId, machineId), + currentPrincipal: () => localProjectPrincipal(projectUserId, machineId), resolveProject: (projectId) => studioProjectCatalog.resolveIdentity(projectId), }), - onEvent: emitPlannerLifecycle, + isMeaningfullyEmpty: isMeaningfullyEmptyProject, + onEvent: emitProjectBootstrapLifecycle, }); + afterStudioProjectsCreatedCommit = async (projects) => { + try { + await scheduleBootstrapProjects( + projects.map((project) => project.projectId), + localProjectPrincipal(projectUserId, machineId), + "unclaimed", + ); + } catch { + // The catalog and its write-ahead markers are already durable. Do not + // turn a successfully allocated project into an apparent create failure; + // every reconciliation boundary retries the project-keyed schedule. + console.error("[harness] project bootstrap scheduling deferred"); + } + }; + try { + const pending = await projectBootstrapOutbox.pending(); + const recoverableProjectIds: string[] = []; + for (const entry of pending) { + if (await studioProjectCatalog.resolve(entry.projectId)) { + recoverableProjectIds.push(entry.projectId); + } else { + // A write-ahead marker whose catalog transaction never committed is + // an expected crash artifact, not a project to recreate implicitly. + await projectBootstrapOutbox.complete(entry.projectId); + } + } + await scheduleBootstrapProjects( + recoverableProjectIds, + localProjectPrincipal(projectUserId, machineId), + "unclaimed", + ); + } catch { + // Leave every unresolved marker durable for the next boot. Logging uses a + // fixed classification because entries and storage paths are private. + console.error("[harness] project bootstrap outbox recovery failed"); + } for (const session of sessionManager.list()) { - if (!session.planning) continue; + if (!session.projectBootstrap) continue; let emptyProject = true; try { - const workspace = await agentMapWorkspaceStore.readOrCreate( - session.planning.identity.projectId, + emptyProject = await isMeaningfullyEmptyProject( + session.projectBootstrap.projectId, ); - emptyProject = - workspace.confirmedRevisionId === null && - workspace.activeProposalId === null && - workspace.projectBuildPlanId === null; } catch { // Registration still recovers generating state and preserves its FIFO; // the project route will surface any unavailable workspace later. } - await plannerGreeting + await projectBootstrap .register(session, { emptyProject, mode: "boot" }) - .catch((error: unknown) => { - console.error( - `[harness] planner registration failed for ${session.id}:`, - error instanceof Error ? error.message : "unknown error", - ); + .catch(() => { + // Session IDs and storage/provider errors stay out of logs. The + // coordinator emits its bounded lifecycle classification separately. + console.error("[harness] project bootstrap registration failed"); }); } - const planningSessions = new PlanningSessionService({ + const projectSessions = new ProjectSessionService({ catalog: studioProjectCatalog, - workspaceStore: agentMapWorkspaceStore, sessionManager, - readRecord: (id) => sessionRecordReader.read(id), userId: identity?.userId ?? null, - currentUserId: () => planningUserId, + currentUserId: () => projectUserId, machineId, defaultHarness: options.defaultHarnessKind ?? "claude-code", - // E1 owns the durable workspace pointers, but not the later revision, - // proposal, or build-plan detail records. Wire that shipped source - // explicitly so the focused-context contract emits honest null/empty - // detail slots today and has one allowlisted adapter boundary when those - // stores land; it must never fall back to scanning project files. - readFocusedContext: async (_projectId, workspace) => ({ - confirmedRevision: - workspace.confirmedRevisionId === null - ? null - : { digest: null, summaries: [] }, - activeProposal: - workspace.activeProposalId === null - ? null - : { status: null, summary: null }, - projectBuildPlan: - workspace.projectBuildPlanId === null - ? null - : { status: null, summary: null }, - warnings: [], - }), - onPlannerSession: (session, context) => - plannerGreeting.register(session, context), - onEvent: emitPlannerLifecycle, }); sessionManager.onStatusChange((session) => { - void plannerGreeting.onSessionStatus(session).catch((error: unknown) => { - console.error( - "[harness] planner greeting status transition failed:", - error, - ); + void projectBootstrap!.onSessionStatus(session).catch(() => { + console.error("[harness] project bootstrap status transition failed"); }); }); + const projectFirstSessionStarts = new Map>(); + const ensureProjectFirstSession = ( + projectId: string, + root: string, + ): Promise => { + const existing = projectFirstSessionStarts.get(projectId); + if (existing) return existing; + const operation = (async () => { + const userId = localProjectPrincipal(projectUserId, machineId); + if (!(await projectBootstrap!.needsProjectSession(projectId, userId))) { + return; + } + await options.projectBootstrapTestHooks?.afterProjectSessionNeeded?.( + projectId, + ); + try { + await sessionManager.create( + { + cwd: root, + harness: options.defaultHarnessKind ?? "claude-code", + }, + { requireProjectBootstrapClaim: true }, + ); + } catch (error) { + // An explicit ordinary create may have claimed and published the first + // session after needsProjectSession() returned. That is successful + // convergence, not a reason to spawn an unrequested second session. + if (error instanceof ProjectBootstrapClaimUnavailableError) return; + // Embedded/test hosts may intentionally register no local adapter, and + // an external-only adapter cannot own a Studio PTY. Keep the durable + // project bootstrap intent unclaimed so a later boot with an available + // ordinary adapter can converge it. Generic user-created sessions still + // surface these errors through their existing request path. + if ( + error instanceof AdapterNotFoundError || + error instanceof ExternalHarnessError + ) { + console.error( + "[harness] project bootstrap session start deferred: adapter_unavailable", + ); + return; + } + // Shutdown can race a queued reconciliation callback. The coordinator + // and outbox are already durable, so there is no work to resurrect in + // a server that is closing. + if (error instanceof SessionManagerClosingError) return; + throw error; + } + if (localProjectPrincipal(projectUserId, machineId) !== userId) { + throw new ProjectSessionScopeUnavailableError("unclaimed"); + } + })(); + projectFirstSessionStarts.set(projectId, operation); + void operation.then( + () => { + if (projectFirstSessionStarts.get(projectId) === operation) { + projectFirstSessionStarts.delete(projectId); + } + }, + () => { + if (projectFirstSessionStarts.get(projectId) === operation) { + projectFirstSessionStarts.delete(projectId); + } + }, + ); + return operation; + }; + + convergeReconciledProjectLifecycle = async (reconciled) => { + const userId = localProjectPrincipal(projectUserId, machineId); + // A prior post-commit attempt may have failed after its marker was + // durable. Reconciliation is an idempotent opportunity to finish those + // exact new-project lifecycles; projects without an intent remain inert. + await scheduleBootstrapProjects( + projectsAwaitingBootstrapSchedule, + userId, + "unclaimed", + ); + if (!agentMapMcpUrl) return; + for (const project of reconciled.projects) { + const launchRoot = preferredProjectRoot( + reconciled.workspaceScopes + .filter((scope) => scope.projectId === project.projectId) + .map((scope) => scope.cwd), + ); + if (launchRoot) { + await ensureProjectFirstSession(project.projectId, launchRoot); + } + } + }; + + const initializeOpenedProject = async ( + requestedRoot: string, + ): Promise => { + const userId = localProjectPrincipal(projectUserId, machineId); + pendingProjectCwds.add(requestedRoot); + let starts: Array<{ projectId: string; launchRoot: string }> = []; + try { + starts = await serializeProjectScopeResolution(async () => { + const before = new Set( + (await studioProjectCatalog.list()).map( + (project) => project.projectId, + ), + ); + if (localProjectPrincipal(projectUserId, machineId) !== userId) { + throw new ProjectSessionScopeUnavailableError("unclaimed"); + } + const reconciled = await studioProjectCatalog.reconcile( + await studioWorkspaceScopeCatalog.list(), + ); + const requestedProject = + await studioProjectCatalog.resolveIdentityForPath(requestedRoot); + if (localProjectPrincipal(projectUserId, machineId) !== userId) { + throw new ProjectSessionScopeUnavailableError("unclaimed"); + } + const createdProjectIds = reconciled.projects + .map((project) => project.projectId) + .filter((projectId) => !before.has(projectId)); + const scheduledProjectIds = [ + ...new Set([ + ...createdProjectIds, + ...projectsAwaitingBootstrapSchedule, + ]), + ]; + await scheduleBootstrapProjects( + scheduledProjectIds, + userId, + "unclaimed", + ); + + const projectsToStart = new Set(createdProjectIds); + if (requestedProject) projectsToStart.add(requestedProject.projectId); + for (const projectId of scheduledProjectIds) { + projectsToStart.add(projectId); + } + return [...projectsToStart].sort().flatMap((projectId) => { + const launchRoot = preferredProjectRoot( + reconciled.workspaceScopes + .filter((scope) => scope.projectId === projectId) + .map((scope) => scope.cwd), + ); + return launchRoot ? [{ projectId, launchRoot }] : []; + }); + }); + } finally { + pendingProjectCwds.delete(requestedRoot); + } + for (const start of starts) { + await ensureProjectFirstSession(start.projectId, start.launchRoot); + } + }; const app: Express = express(); app.disable("x-powered-by"); @@ -2943,6 +3446,7 @@ export const startServer = async ( writeWorkspaceContext: writeSessionContext, renderCanvas, onTelemetryOptInChange: (optIn) => batcher.setTelemetryOptIn(optIn), + onRecentDirAdded: initializeOpenedProject, onSessionCreated: (cwd, harnessSessionId) => { scanWorkflowsAndBroadcast(cwd, "session-create", { dirty: true }) .then(({ found }) => { @@ -2961,6 +3465,22 @@ export const startServer = async ( ); }); }, + submitSessionInput: async (sessionId, text, submit) => { + const bootstrap = sessionManager.get(sessionId)?.projectBootstrap; + if (projectBootstrapOwnsInput(bootstrap)) { + if (submit) { + await projectBootstrap!.enqueue(sessionId, text); + return true; + } + // A user-owned draft must also win over automatic bootstrap, but it + // must remain editable rather than entering the durable submit FIFO. + // Retire lifecycle ownership and clear any background text staged + // before Enter, then preserve ordinary submit:false semantics. + projectBootstrap!.onTerminalInput(sessionId); + sessionManager.preemptBackgroundInput(sessionId); + } + return sessionManager.submitInput(sessionId, text, submit); + }, launchDir, defaultProjectRoot, agentsBaseUrl: resolveAgentsBaseUrl(), @@ -2987,12 +3507,46 @@ export const startServer = async ( catalog: studioProjectCatalog, store: agentMapWorkspaceStore, preferences: studioWorkspacePreferences, - currentUserId: () => localPlanningPrincipal(planningUserId, machineId), + currentUserId: () => localProjectPrincipal(projectUserId, machineId), listWorkflows: () => workflowsCache, isWorkflowScanComplete, - listWorkspaceScopes: () => workspaceScopeCatalog.list(), - planningSessions, - plannerGreeting, + listWorkspaceScopes: () => studioWorkspaceScopeCatalog.list(), + projectSessions, + projectBootstrap, + onProjectCreated: async (project) => { + const userId = localProjectPrincipal(projectUserId, machineId); + await scheduleBootstrapProjects( + [project.projectId], + userId, + "unclaimed", + ); + }, + onRootBound: async (project, root) => { + const userId = localProjectPrincipal(projectUserId, machineId); + // Recover a project whose durable create committed just before its + // lifecycle callback failed, without enrolling an older project merely + // because one of its bindings changed. + if (projectsAwaitingBootstrapSchedule.has(project.projectId)) { + await scheduleBootstrapProjects( + [project.projectId], + userId, + "unclaimed", + ); + } + const identity = await studioProjectCatalog.resolveIdentity( + project.projectId, + ); + const launchRoot = identity + ? preferredProjectRoot( + identity.rootBindings + .filter((binding) => binding.status === "active") + .map((binding) => binding.localRootRef), + ) + : null; + await ensureProjectFirstSession(project.projectId, launchRoot ?? root); + }, + submitSessionInput: (sessionId, text) => + sessionManager.submitInput(sessionId, text, true), }), ); app.use( @@ -3378,8 +3932,8 @@ export const startServer = async ( bus, authEnabled, environment: process.env.SAPIOM_ENVIRONMENT, - onPlanningUserChanged: (userId) => { - planningUserId = userId; + onProjectUserChanged: (userId) => { + projectUserId = userId; for (const session of sessionManager.list()) { agentMapCapabilities.revokeSession(session.id); void agentMapMcp?.revokeSession(session.id); @@ -3411,8 +3965,9 @@ export const startServer = async ( store: eventStore, batcher, enrichFromTranscript: enrichTurnCompleted, - decorateEvent: (event) => plannerGreeting.decorateLocalEvent(event), - projectTelemetryEvent: (event) => plannerGreeting.redactForTelemetry(event), + decorateEvent: (event) => projectBootstrap!.decorateLocalEvent(event), + projectTelemetryEvent: (event) => + projectBootstrap!.redactForTelemetry(event), onNormalizedEvent: (event: AnalyticsEvent) => { // Synchronous and total — it counts turns and detaches any fold it // decides to start, so the ingest path never waits on a summary. @@ -3440,8 +3995,8 @@ export const startServer = async ( } }, onEventPersisted: (event: AnalyticsEvent) => { - void plannerGreeting.onEventPersisted(event).catch((error: unknown) => { - console.error("[harness] planner greeting completion failed:", error); + void projectBootstrap!.onEventPersisted(event).catch(() => { + console.error("[harness] project bootstrap completion failed"); }); const recordChanged = sessionRecordChangedMessage(event); if (recordChanged) bus.publish(recordChanged); @@ -3609,104 +4164,84 @@ export const startServer = async ( }, ]); - await new Promise((resolve, reject) => { - httpServer.once("error", reject); - httpServer.listen(options.port, host, () => { - httpServer.removeListener("error", reject); - resolve(); - }); - }); - - const address = httpServer.address(); - const actualPort = - typeof address === "object" && address ? address.port : options.port; - agentMapMcpUrl = `http://${host}:${actualPort}/mcp/agent-map`; - // Covers the ephemeral `port: 0` case where only the bound address is real. - portDetector.addExcludedPort(actualPort); - - // The app otherwise opens to an empty terminal pane — not fire-and-forget - // because a spawn failure here (e.g. claude not on PATH) is worth - // surfacing loudly, but also not awaited before returning: startServer() - // resolving shouldn't wait on a real pty spawn. - if (options.autoCreateSession ?? true) { - const harness = options.defaultHarnessKind ?? "claude-code"; - sessionManager - .create({ cwd: launchDir, harness }) - .then(async (session) => { - // Reuses the scan already kicked off above rather than scanning - // launchDir twice — only renders when it actually found something, - // same "discoverable" gate as the REST onSessionCreated path. - const { found } = await initialWorkflowScan; - if (found.length > 0) await autoRenderCanvas(session); - }) - .catch((err: unknown) => { - console.error("[harness] auto-create boot session failed:", err); - }); - } + let serverClose: Promise | null = null; + const closeServer = (): Promise => { + if (serverClose) return serverClose; + serverClose = (async () => { + const settle = async ( + operation: () => void | Promise, + ): Promise => { + try { + await operation(); + } catch { + // Shutdown is best-effort across independent resources. Keep this + // content-free so provider errors and private paths never leak. + console.error("[harness] server shutdown step failed"); + } + }; - return { - port: actualPort, - uiToken, - sessionManager, - close: async () => { + await settle(() => sessionManager.beginShutdown()); + const bootstrapClosing = settle(() => projectBootstrap?.close()); coordinatorActive = false; coordinatorEpoch += 1; - await workflowRegistry.retirePendingDiscovery(); - rejectPublication(new Error("Agent discovery coordinator is closed")); clearInterval(sessionSweepTimer); clearInterval(ndjsonRetentionTimer); - canvasWatcher.stopAll(); - workspaceWatcher.stopAll(); - systemGraphWatcher.stopAll(); + await settle(() => workflowRegistry.retirePendingDiscovery()); + await settle(() => + rejectPublication(new Error("Agent discovery coordinator is closed")), + ); + await settle(() => canvasWatcher.stopAll()); + await settle(() => workspaceWatcher.stopAll()); + await settle(() => systemGraphWatcher.stopAll()); activeSystemGraphScopes.clear(); - systemGraphInvocations.clear(); - systemGraphInventory.clear(); - systemGraphStore.clear(); - installWatcher.stopAll(); - for (const tailer of codexTailers.values()) tailer.stop(); + await settle(() => systemGraphInvocations.clear()); + await settle(() => systemGraphInventory.clear()); + await settle(() => systemGraphStore.clear()); + await settle(() => installWatcher.stopAll()); + for (const tailer of codexTailers.values()) { + await settle(() => tailer.stop()); + } codexTailers.clear(); - // Closing the HTTP/WS server doesn't touch unrelated child processes - // on its own — without this, every live claude/codex pty outlives - // the harness server itself (e.g. after Ctrl+C or in a script that - // expects the process to actually exit once close() resolves). - // Await kills with a bounded timeout so shutdown never hangs: if a - // process somehow survives both SIGTERM and SIGKILL within the - // escalation window (shouldn't happen), we still resolve and let the - // HTTP server close proceed. The SIGKILL escalation inside each kill() - // itself is bounded (KILL_ESCALATION_MS + KILL_ESCALATION_CONFIRM_MS - // = 2500ms); the outer timeout here is a final safety net above that. + + // Closing the HTTP/WS server doesn't touch unrelated child processes. + // Bound the process cleanup so shutdown cannot strand the listening + // socket if an adapter misses its own exit acknowledgement. const SHUTDOWN_KILL_TIMEOUT_MS = 5_000; const killsSettled = Promise.all([ - sessionManager.killAll(), - taskManager.killAll(), - ]); + settle(() => sessionManager.killAll()), + settle(() => taskManager.killAll()), + ]).then(() => {}); let shutdownTimerHandle: ReturnType | undefined; const shutdownTimeout = new Promise((resolve) => { shutdownTimerHandle = setTimeout(resolve, SHUTDOWN_KILL_TIMEOUT_MS); - // Unref so the timer never keeps the event loop alive when the kill - // path wins — mirrors SessionManager.kill()'s escalation timer pattern. shutdownTimerHandle.unref(); }); await Promise.race([killsSettled, shutdownTimeout]); - await agentMapMcp?.close(); - // Clear the timer when the kill path wins (common case) so it doesn't - // linger ref'd in the background after shutdown completes. if (shutdownTimerHandle !== undefined) clearTimeout(shutdownTimerHandle); - void batcher.close(); - // wss.close() stops NEW upgrades but never terminates the connections - // already open — and httpServer.close() then waits indefinitely for those - // sockets to drain. The main window's live /ws/events and /ws/terminal - // connections (plus any keep-alive HTTP socket) would therefore hang - // close() forever, so Electron's before-quit never reaches app.quit() and - // the process lingers as a zombie still holding the single-instance lock — - // which blocks the next launch (it hangs on "Starting Sapiom…"). Force - // each client shut, drop keep-alive HTTP conns, and bound the final wait - // so shutdown always completes. - for (const client of terminalWss.clients) client.terminate(); - for (const client of eventsWss.clients) client.terminate(); - terminalWss.close(); - eventsWss.close(); - httpServer.closeAllConnections?.(); + await bootstrapClosing; + await settle(() => sessionManager.flush()); + await settle(async () => { + await recordBackfill; + while (pendingRecordArchives.size > 0) { + await Promise.all([...pendingRecordArchives]); + } + }); + await settle(() => agentMapMcp?.close()); + await settle(() => batcher.close()); + + // Existing clients and keep-alive sockets otherwise make close() wait + // forever. Terminate them before releasing the listener, and make the + // final wait bounded just like child-process cleanup above. + for (const client of terminalWss.clients) { + await settle(() => client.terminate()); + } + for (const client of eventsWss.clients) { + await settle(() => client.terminate()); + } + await settle(() => terminalWss.close()); + await settle(() => eventsWss.close()); + await settle(() => httpServer.closeAllConnections?.()); + if (!httpServer.listening) return; const HTTP_CLOSE_TIMEOUT_MS = 3_000; await new Promise((resolve) => { let httpCloseTimer: ReturnType | undefined = @@ -3715,12 +4250,107 @@ export const startServer = async ( resolve(); }, HTTP_CLOSE_TIMEOUT_MS); httpCloseTimer.unref?.(); - httpServer.close((err) => { - if (err) console.error("[harness] httpServer.close error:", err); + httpServer.close((error) => { + if (error) console.error("[harness] http server shutdown failed"); if (httpCloseTimer !== undefined) clearTimeout(httpCloseTimer); resolve(); }); }); - }, + })(); + return serverClose; + }; + + let actualPort = options.port; + try { + await new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen(options.port, host, () => { + httpServer.removeListener("error", reject); + resolve(); + }); + }); + + const address = httpServer.address(); + actualPort = + typeof address === "object" && address ? address.port : options.port; + agentMapMcpUrl = `http://${host}:${actualPort}/mcp/agent-map`; + // Covers the ephemeral `port: 0` case where only the bound address is real. + portDetector.addExcludedPort(actualPort); + await options.projectBootstrapTestHooks?.afterListenBeforeRecovery?.( + actualPort, + ); + + // A project intent is persisted before its first PTY is created. Reconcile + // that crash window only after the MCP endpoint is bound: every ordinary + // project session receives its capability during launch preparation, so an + // earlier recovery attempt would fail before spawning and leave a tombstone. + for (const summary of await studioProjectCatalog.list()) { + const project = await studioProjectCatalog.resolveIdentity( + summary.projectId, + ); + const root = project + ? preferredProjectRoot( + project.rootBindings + .filter((binding) => binding.status === "active") + .map((binding) => binding.localRootRef), + ) + : null; + if (!project || !root) continue; + await ensureProjectFirstSession(summary.projectId, root).catch( + (error: unknown) => { + console.error( + `[harness] project bootstrap session recovery failed: ${ + error instanceof ProjectBootstrapCoordinatorClosedError + ? "coordinator_closed" + : error instanceof ProjectSessionScopeUnavailableError + ? "scope_unavailable" + : "session_start_failed" + }`, + ); + }, + ); + } + + // The app otherwise opens to an empty terminal pane — not fire-and-forget + // because a spawn failure here (e.g. claude not on PATH) is worth + // surfacing loudly, but also not awaited before returning: startServer() + // resolving shouldn't wait on a real pty spawn. + const launchProject = + await studioProjectCatalog.resolveIdentityForPath(launchDir); + const recoveredLaunchProjectSession = Boolean( + launchProject && + sessionManager + .list() + .some( + (session) => + session.status !== "exited" && + session.agentMapIdentity?.projectId === launchProject.projectId, + ), + ); + if ((options.autoCreateSession ?? true) && !recoveredLaunchProjectSession) { + const harness = options.defaultHarnessKind ?? "claude-code"; + sessionManager + .create({ cwd: launchDir, harness }) + .then(async (session) => { + // Reuses the scan already kicked off above rather than scanning + // launchDir twice — only renders when it actually found something, + // same "discoverable" gate as the REST onSessionCreated path. + const { found } = await initialWorkflowScan; + if (found.length > 0) await autoRenderCanvas(session); + }) + .catch((err: unknown) => { + console.error("[harness] auto-create boot session failed:", err); + }); + } + } catch (error) { + await closeServer(); + throw error; + } + + return { + port: actualPort, + uiToken, + sessionManager, + close: closeServer, }; }; diff --git a/packages/harness/src/server/ingest.test.ts b/packages/harness/src/server/ingest.test.ts index 6d6e765c1..f01dcdf1d 100644 --- a/packages/harness/src/server/ingest.test.ts +++ b/packages/harness/src/server/ingest.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeHookEvent } from "../core/collector/normalizer.js"; import { createSeqCounter } from "../core/collector/seq.js"; import { createEventStore } from "../core/collector/store.js"; -import { PlannerGreetingCoordinator } from "../core/planner-greeting.js"; +import { ProjectBootstrapCoordinator } from "../core/planner-greeting.js"; import { createSessionRecordReader } from "../core/session-record.js"; import type { SessionManager } from "../core/session-manager.js"; import type { AnalyticsEvent, HarnessSession } from "../shared/types.js"; @@ -124,7 +124,11 @@ describe("createIngestRouter", () => { }); it("rejects requests without a valid bearer token", async () => { - const res = await postIngest(baseUrl, { hookEvent: "SessionStart" }, "wrong-token"); + const res = await postIngest( + baseUrl, + { hookEvent: "SessionStart" }, + "wrong-token", + ); expect(res.status).toBe(401); expect(stored).toHaveLength(0); }); @@ -388,7 +392,9 @@ describe("createIngestRouter", () => { expect(decorated).toEqual([]); identityCommit.reject(new Error("sessions registry unavailable")); - await expect(startProcessing).rejects.toThrow("sessions registry unavailable"); + await expect(startProcessing).rejects.toThrow( + "sessions registry unavailable", + ); await followerProcessing; expect(context.agentSessionId).toBe("agent-prior"); @@ -477,7 +483,9 @@ describe("createIngestRouter", () => { it("ignores a SessionStart whose vendor identity conflicts with the pinned session", async () => { const ready: string[] = []; - start({ onSessionReady: (harnessSessionId) => ready.push(harnessSessionId) }); + start({ + onSessionReady: (harnessSessionId) => ready.push(harnessSessionId), + }); sessions.get("session-1")!.agentSessionId = "agent-pinned"; const res = await postIngest(baseUrl, { @@ -524,7 +532,9 @@ describe("createIngestRouter", () => { it("calls onSessionReady on session.start — the readiness signal SessionManager gates programmatic input on", async () => { const ready: string[] = []; - start({ onSessionReady: (harnessSessionId) => ready.push(harnessSessionId) }); + start({ + onSessionReady: (harnessSessionId) => ready.push(harnessSessionId), + }); const res = await postIngest(baseUrl, { hookEvent: "SessionStart", @@ -538,7 +548,9 @@ describe("createIngestRouter", () => { it("does not call onSessionReady for events other than SessionStart", async () => { const ready: string[] = []; - start({ onSessionReady: (harnessSessionId) => ready.push(harnessSessionId) }); + start({ + onSessionReady: (harnessSessionId) => ready.push(harnessSessionId), + }); await postIngest(baseUrl, { hookEvent: "UserPromptSubmit", @@ -646,22 +658,21 @@ describe("createIngestRouter", () => { expect(JSON.stringify(enqueued[0])).not.toContain("private control prompt"); }); - it("bounds hostile planner source, model, and usage before batching", async () => { - const planningSession = { + it("bounds hostile bootstrap source, model, and usage before batching", async () => { + const bootstrapSession = { agentSessionId: "agent-1", - planning: { - identity: { - projectId: "project-1", - sessionId: "session-1", - userId: "user-1", - role: "map-planner", - }, + projectBootstrap: { + projectId: "project-1", + userId: "user-1", + targetSessionId: "session-1", + bootstrap: { status: "generating", attemptId: "attempt-1" }, + queuedInputIds: [], }, } as unknown as HarnessSession; - const privacy = new PlannerGreetingCoordinator({ + const privacy = new ProjectBootstrapCoordinator({ root: "/unused", sessionManager: { - get: () => planningSession, + get: () => bootstrapSession, } as unknown as SessionManager, }); start({ @@ -708,10 +719,10 @@ describe("createIngestRouter", () => { await vi.waitFor(() => expect(enqueued).toHaveLength(3)); expect(enqueued.map((event) => event.payload)).toEqual([ - { planner: true, source: "unknown" }, - { planner: true, origin: "user" }, + { projectBootstrap: true, source: "unknown" }, + { projectBootstrap: true, origin: "user" }, { - planner: true, + projectBootstrap: true, hasAssistantText: true, modelReported: true, usage: { inputTokens: 1_000_000_000_000, outputTokens: null }, diff --git a/packages/harness/src/server/project-bootstrap-outbox.test.ts b/packages/harness/src/server/project-bootstrap-outbox.test.ts new file mode 100644 index 000000000..ef4efa506 --- /dev/null +++ b/packages/harness/src/server/project-bootstrap-outbox.test.ts @@ -0,0 +1,394 @@ +import { createServer as createHttpServer } from "node:http"; +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, vi } from "vitest"; + +import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; +import type { HarnessAdapter, LaunchOpts, SpawnSpec } from "../shared/types.js"; +import { startServer, type HarnessServer } from "./index.js"; + +describe("new-project bootstrap outbox recovery", () => { + let stateRoot: string; + let existingRoot: string; + let newRoot: string; + let webDir: string; + let existingProjectId: string; + let server: HarnessServer | undefined; + let launches: LaunchOpts[]; + + beforeEach(async () => { + stateRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "project-bootstrap-server-outbox-"), + ); + existingRoot = path.join(stateRoot, "existing-project"); + newRoot = path.join(stateRoot, "new-project"); + webDir = path.join(stateRoot, "web"); + await Promise.all([ + fs.mkdir(existingRoot), + fs.mkdir(newRoot), + fs.mkdir(webDir), + ]); + await fs.writeFile(path.join(webDir, "index.html"), ""); + const reconciled = await new StudioProjectCatalog( + path.join(stateRoot, "studio-projects.json"), + ).reconcile([{ workspaceKey: "existing", cwd: existingRoot }]); + existingProjectId = reconciled.projects[0]!.projectId; + await fs.writeFile( + path.join(stateRoot, "settings.json"), + JSON.stringify({ recentDirs: [existingRoot] }), + ); + launches = []; + }); + + afterEach(async () => { + await server?.close(); + vi.restoreAllMocks(); + await fs.rm(stateRoot, { recursive: true, force: true, maxRetries: 5 }); + }); + + const markerFile = (projectId: string) => + path.join( + stateRoot, + "agent-map", + "project-bootstrap", + "project-outbox", + `${projectId}.json`, + ); + + const intentFile = (projectId: string) => + path.join( + stateRoot, + "agent-map", + "project-bootstrap", + "projects", + `${projectId}.json`, + ); + + async function exists(file: string): Promise { + try { + await fs.stat(file); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + } + + function adapter(): HarnessAdapter { + const launch = (options: LaunchOpts): SpawnSpec => { + launches.push(options); + return { command: "bash", args: [], env: {}, cwd: options.cwd }; + }; + return { + id: "claude-code", + eventSource: "hooks", + doctor: async () => [], + launch, + resume: (_agentSessionId, options) => launch(options), + listPastSessions: async () => [], + canResume: async () => true, + }; + } + + async function boot(failBeforeSchedule = false): Promise { + return startServer({ + port: 0, + bootToken: "boot-token", + telemetryOptIn: false, + identity: null, + machineId: "machine-1", + adapters: { "claude-code": adapter() }, + stateRoot, + launchDir: existingRoot, + webDir, + autoCreateSession: false, + loadSystemPrompt: async () => "ordinary coding prompt", + ...(failBeforeSchedule + ? { + projectBootstrapTestHooks: { + beforeSchedule: async () => { + throw new Error("simulated crash before intent commit"); + }, + }, + } + : {}), + }); + } + + async function request( + active: HarnessServer, + pathname: string, + init: RequestInit, + ): Promise { + return fetch(`http://127.0.0.1:${active.port}/api${pathname}`, { + ...init, + headers: { + "content-type": "application/json", + "x-harness-token": "boot-token", + ...init.headers, + }, + }); + } + + it("recovers an explicit project committed before its bootstrap intent", async () => { + server = await boot(true); + const response = await request(server, "/projects", { + method: "POST", + body: JSON.stringify({ displayName: "Explicit project" }), + }); + expect(response.status).toBe(202); + const project = (await response.json()) as { projectId: string }; + expect(await exists(markerFile(project.projectId))).toBe(true); + expect(await exists(intentFile(project.projectId))).toBe(false); + + await server.close(); + server = undefined; + await new StudioProjectCatalog( + path.join(stateRoot, "studio-projects.json"), + ).addRootBinding(project.projectId, newRoot, { + legacyWorkspaceKey: "explicit-project-root", + }); + + server = await boot(); + + expect(launches).toHaveLength(1); + expect(server.sessionManager.list()).toEqual([ + expect.objectContaining({ + title: "Plan Agents", + cwd: newRoot, + agentMapIdentity: expect.objectContaining({ + projectId: project.projectId, + userId: "local:machine-1", + }), + projectBootstrap: expect.objectContaining({ + projectId: project.projectId, + userId: "local:machine-1", + }), + }), + ]); + expect(await exists(markerFile(project.projectId))).toBe(false); + expect(await exists(intentFile(project.projectId))).toBe(true); + expect( + server.sessionManager + .list() + .some( + (session) => + session.agentMapIdentity?.projectId === existingProjectId, + ), + ).toBe(false); + }); + + it("recovers a reconcile-created project without enrolling an older project", async () => { + server = await boot(true); + await request(server, "/settings", { + method: "PATCH", + body: JSON.stringify({ recentDirs: [newRoot, existingRoot] }), + }); + const catalog = new StudioProjectCatalog( + path.join(stateRoot, "studio-projects.json"), + ); + const created = await catalog.resolveIdentityForPath(newRoot); + expect(created).not.toBeNull(); + expect(created!.projectId).not.toBe(existingProjectId); + expect(await exists(markerFile(created!.projectId))).toBe(true); + expect(await exists(intentFile(created!.projectId))).toBe(false); + + await server.close(); + server = undefined; + server = await boot(); + + expect(launches).toHaveLength(1); + const sessions = server.sessionManager.list(); + expect(sessions).toEqual([ + expect.objectContaining({ + title: "Plan Agents", + cwd: newRoot, + agentMapIdentity: expect.objectContaining({ + projectId: created!.projectId, + userId: "local:machine-1", + }), + projectBootstrap: expect.objectContaining({ + projectId: created!.projectId, + }), + }), + ]); + expect( + sessions.some( + (session) => session.agentMapIdentity?.projectId === existingProjectId, + ), + ).toBe(false); + expect(await exists(markerFile(created!.projectId))).toBe(false); + expect(await exists(intentFile(created!.projectId))).toBe(true); + }); + + it("converges a project created by an unowned reconciliation without a restart", async () => { + server = await boot(); + await fs.writeFile( + path.join(stateRoot, "settings.json"), + JSON.stringify({ recentDirs: [newRoot, existingRoot] }), + ); + + const mapRead = await request( + server, + `/projects/${existingProjectId}/agent-map/workspace`, + { method: "GET" }, + ); + expect(mapRead.status).toBe(200); + expect(server.sessionManager.list()).toEqual([]); + expect(launches).toEqual([]); + await expect( + new StudioProjectCatalog( + path.join(stateRoot, "studio-projects.json"), + ).resolveIdentityForPath(newRoot), + ).resolves.toBeNull(); + + const state = await request(server, "/state", { method: "GET" }); + expect(state.status).toBe(200); + const created = await new StudioProjectCatalog( + path.join(stateRoot, "studio-projects.json"), + ).resolveIdentityForPath(newRoot); + expect(created).not.toBeNull(); + expect(launches).toHaveLength(1); + expect(server.sessionManager.list()).toEqual([ + expect.objectContaining({ + title: "Plan Agents", + cwd: newRoot, + agentMapIdentity: expect.objectContaining({ + projectId: created!.projectId, + }), + projectBootstrap: expect.objectContaining({ + projectId: created!.projectId, + }), + }), + ]); + expect(await exists(markerFile(created!.projectId))).toBe(false); + expect(await exists(intentFile(created!.projectId))).toBe(true); + }); + + it("defers automatic first-session creation until a local adapter is available", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + server = await startServer({ + port: 0, + bootToken: "boot-token", + telemetryOptIn: false, + identity: null, + machineId: "machine-1", + adapters: {}, + stateRoot, + launchDir: existingRoot, + webDir, + autoCreateSession: false, + loadSystemPrompt: async () => "ordinary coding prompt", + }); + + const response = await request(server, "/settings", { + method: "PATCH", + body: JSON.stringify({ recentDirs: [newRoot, existingRoot] }), + }); + expect(response.status).toBe(200); + const created = await new StudioProjectCatalog( + path.join(stateRoot, "studio-projects.json"), + ).resolveIdentityForPath(newRoot); + expect(created).not.toBeNull(); + expect(server.sessionManager.list()).toEqual([]); + expect(await exists(intentFile(created!.projectId))).toBe(true); + expect(error).toHaveBeenCalledWith( + "[harness] project bootstrap session start deferred: adapter_unavailable", + ); + + await server.close(); + server = undefined; + error.mockRestore(); + server = await boot(); + + expect(launches).toHaveLength(1); + expect(server.sessionManager.list()).toEqual([ + expect.objectContaining({ + title: "Plan Agents", + cwd: newRoot, + agentMapIdentity: expect.objectContaining({ + projectId: created!.projectId, + }), + projectBootstrap: expect.objectContaining({ + projectId: created!.projectId, + }), + }), + ]); + }); + + it("fully closes a post-listen startup failure before rejecting", async () => { + await fs.mkdir(path.dirname(intentFile(existingProjectId)), { + recursive: true, + }); + await fs.writeFile( + intentFile(existingProjectId), + `${JSON.stringify({ + schemaVersion: 1, + projectId: existingProjectId, + userId: "local:machine-1", + targetSessionId: null, + status: "scheduled", + createdAt: "2026-09-04T00:00:00.000Z", + updatedAt: "2026-09-04T00:00:00.000Z", + })}\n`, + ); + let rejectedPort = 0; + + await expect( + startServer({ + port: 0, + bootToken: "boot-token", + telemetryOptIn: false, + identity: null, + machineId: "machine-1", + adapters: { "claude-code": adapter() }, + stateRoot, + launchDir: existingRoot, + webDir, + autoCreateSession: false, + loadSystemPrompt: async () => "ordinary coding prompt", + projectBootstrapTestHooks: { + afterListenBeforeRecovery: (port) => { + rejectedPort = port; + throw new Error("simulated post-listen recovery failure"); + }, + }, + }), + ).rejects.toThrow("simulated post-listen recovery failure"); + + expect(rejectedPort).toBeGreaterThan(0); + const rebound = createHttpServer(); + await new Promise((resolve, reject) => { + rebound.once("error", reject); + rebound.listen(rejectedPort, "127.0.0.1", () => resolve()); + }); + await new Promise((resolve, reject) => { + rebound.close((error) => (error ? reject(error) : resolve())); + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(launches).toEqual([]); + await expect( + fs.readFile(intentFile(existingProjectId), "utf8").then(JSON.parse), + ).resolves.toMatchObject({ status: "scheduled", targetSessionId: null }); + }); + + it("makes concurrent server close calls share one complete teardown", async () => { + server = await boot(); + const port = server.port; + + await Promise.all([server.close(), server.close(), server.close()]); + server = undefined; + + const rebound = createHttpServer(); + await new Promise((resolve, reject) => { + rebound.once("error", reject); + rebound.listen(port, "127.0.0.1", () => resolve()); + }); + await new Promise((resolve, reject) => { + rebound.close((error) => (error ? reject(error) : resolve())); + }); + expect(launches).toEqual([]); + }); +}); diff --git a/packages/harness/src/server/rehydrate-session.test.ts b/packages/harness/src/server/rehydrate-session.test.ts index 8c2375b0a..549a5b494 100644 --- a/packages/harness/src/server/rehydrate-session.test.ts +++ b/packages/harness/src/server/rehydrate-session.test.ts @@ -35,7 +35,12 @@ const PRIOR_AGENT_SESSION = "prior-agent-session"; * is real and killable. Declares `launch-flag` exactly as both shipped * adapters do, so the delivery channel under test is the production one. */ function fakeAdapter(harness: HarnessKind): HarnessAdapter { - const spec = (opts: LaunchOpts): SpawnSpec => ({ command: "bash", args: [], env: {}, cwd: opts.cwd }); + const spec = (opts: LaunchOpts): SpawnSpec => ({ + command: "bash", + args: [], + env: {}, + cwd: opts.cwd, + }); return { id: harness, eventSource: harness === "codex" ? "transcript-tail" : "hooks", @@ -79,7 +84,12 @@ describe("portable continue — rehydrating a fresh session", () => { // Retried: a session's exit-time `removeGeneratedSessionDir` is // fire-and-forget, so it can still be deleting inside `generated/` while // this tears the whole scratch root down (ENOTEMPTY). - await rm(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + await rm(dir, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 50, + }); }); /** Seed events.ndjson with a prior session the harness recorded itself — @@ -105,11 +115,20 @@ describe("portable continue — rehydrating a fresh session", () => { n: number, type: AnalyticsEventType, payload: Record, - ): AnalyticsEvent => ({ ...base, eventId: `event-${n}`, seq: n, ts: at(n), type, payload }); + ): AnalyticsEvent => ({ + ...base, + eventId: `event-${n}`, + seq: n, + ts: at(n), + type, + payload, + }); const events: AnalyticsEvent[] = [ event(1, "session.start", { source: "startup", cwd }), - event(2, "prompt.submitted", { prompt: "make the retry backoff jittered" }), + event(2, "prompt.submitted", { + prompt: "make the retry backoff jittered", + }), event(3, "tool.call", { toolName: "Edit", toolInput: JSON.stringify({ file_path: join(cwd, "src/retry.ts") }), @@ -120,7 +139,9 @@ describe("portable continue — rehydrating a fresh session", () => { toolInput: JSON.stringify({ command: "pnpm build" }), toolResponseSummary: "built", }), - event(5, "turn.completed", { assistantText: "Jitter is in; the tests still need updating." }), + event(5, "turn.completed", { + assistantText: "Jitter is in; the tests still need updating.", + }), event(6, "session.end", { reason: "exit" }), ]; await mkdir(dir, { recursive: true }); @@ -143,7 +164,10 @@ describe("portable continue — rehydrating a fresh session", () => { } async function systemPrompt(harnessSessionId: string): Promise { - return readFile(join(generatedRoot, harnessSessionId, "system-prompt.txt"), "utf8"); + return readFile( + join(generatedRoot, harnessSessionId, "system-prompt.txt"), + "utf8", + ); } // The acceptance criterion "same code path works for claude-code and codex" @@ -177,7 +201,9 @@ describe("portable continue — rehydrating a fresh session", () => { expect(prompt).toContain("reconstruction, not restored context"); // What the session was doing. expect(prompt).toContain("make the retry backoff jittered"); - expect(prompt).toContain("Jitter is in; the tests still need updating."); + expect(prompt).toContain( + "Jitter is in; the tests still need updating.", + ); expect(prompt).toContain("src/retry.ts"); expect(prompt).toContain("pnpm build"); expect(prompt).toContain(cwd); @@ -196,7 +222,9 @@ describe("portable continue — rehydrating a fresh session", () => { rehydrateFrom: PRIOR_AGENT_SESSION, }); expect(session.rehydratedFrom).toBe(PRIOR_AGENT_SESSION); - expect(await systemPrompt(session.id)).toContain("make the retry backoff jittered"); + expect(await systemPrompt(session.id)).toContain( + "make the retry backoff jittered", + ); }); it("includes the rolling summary when one was produced, and degrades without it", async () => { @@ -208,7 +236,9 @@ describe("portable continue — rehydrating a fresh session", () => { harness, rehydrateFrom: PRIOR_SESSION, }); - expect(await systemPrompt(withoutSummary.id)).not.toContain("Rolling summary"); + expect(await systemPrompt(withoutSummary.id)).not.toContain( + "Rolling summary", + ); await mkdir(join(generatedRoot, PRIOR_SESSION), { recursive: true }); await writeFile( @@ -247,7 +277,9 @@ describe("portable continue — rehydrating a fresh session", () => { server = await boot(harness); const session = await server.sessionManager.create({ cwd, harness }); expect(session.rehydratedFrom).toBeNull(); - expect(await systemPrompt(session.id)).not.toContain("reconstruction, not restored context"); + expect(await systemPrompt(session.id)).not.toContain( + "reconstruction, not restored context", + ); }); }); } @@ -257,7 +289,10 @@ describe("portable continue — rehydrating a fresh session", () => { * is the proof that a future harness with no prompt flag gets working * rehydration from one line in its adapter rather than silence. */ function promptFlaglessAdapter(): HarnessAdapter { - return { ...fakeAdapter("claude-code"), systemPromptDelivery: "post-ready-injection" }; + return { + ...fakeAdapter("claude-code"), + systemPromptDelivery: "post-ready-injection", + }; } async function bootFlagless(): Promise { @@ -284,7 +319,9 @@ describe("portable continue — rehydrating a fresh session", () => { // A brief exists and will be delivered, so the session says so — but not // through a file this adapter never reads. expect(session.rehydratedFrom).toBe(PRIOR_SESSION); - expect(await systemPrompt(session.id)).not.toContain("reconstruction, not restored context"); + expect(await systemPrompt(session.id)).not.toContain( + "reconstruction, not restored context", + ); // Nothing injected yet: the pty is running but not `ready`, which is the // state a TUI sitting on a trust prompt is in. expect(submitInput).not.toHaveBeenCalled(); @@ -321,37 +358,70 @@ describe("portable continue — rehydrating a fresh session", () => { expect(submitInput).toHaveBeenCalledTimes(1); }); - it("injects nothing for a session that was never rehydrated", async () => { + it("injects no rehydration brief for a session that was never rehydrated", async () => { server = await bootFlagless(); const submitInput = vi.spyOn(server.sessionManager, "submitInput"); - const session = await server.sessionManager.create({ cwd, harness: "claude-code" }); + const session = await server.sessionManager.create({ + cwd, + harness: "claude-code", + }); server.sessionManager.setReady(session.id); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(submitInput).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(submitInput).toHaveBeenCalledTimes(1)); + expect(submitInput.mock.calls[0]?.[1]).toContain( + "Agent Studio project bootstrap", + ); + expect(submitInput.mock.calls[0]?.[1]).not.toContain( + "reconstruction, not restored context", + ); }); }); it("is reachable over POST /api/sessions", async () => { await seedPriorSession("claude-code"); server = await boot("claude-code"); - const response = await fetch(`http://127.0.0.1:${server.port}/api/sessions`, { - method: "POST", - headers: { "content-type": "application/json", "x-harness-token": "test-token" }, - body: JSON.stringify({ cwd, harness: "claude-code", rehydrateFrom: PRIOR_SESSION }), - }); + const response = await fetch( + `http://127.0.0.1:${server.port}/api/sessions`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-harness-token": "test-token", + }, + body: JSON.stringify({ + cwd, + harness: "claude-code", + rehydrateFrom: PRIOR_SESSION, + }), + }, + ); expect(response.status).toBe(201); - const session = (await response.json()) as { id: string; rehydratedFrom: string | null }; + const session = (await response.json()) as { + id: string; + rehydratedFrom: string | null; + }; expect(session.rehydratedFrom).toBe(PRIOR_SESSION); - expect(await systemPrompt(session.id)).toContain("make the retry backoff jittered"); + expect(await systemPrompt(session.id)).toContain( + "make the retry backoff jittered", + ); }); it("rejects an empty rehydrateFrom rather than silently ignoring it", async () => { server = await boot("claude-code"); - const response = await fetch(`http://127.0.0.1:${server.port}/api/sessions`, { - method: "POST", - headers: { "content-type": "application/json", "x-harness-token": "test-token" }, - body: JSON.stringify({ cwd, harness: "claude-code", rehydrateFrom: "" }), - }); + const response = await fetch( + `http://127.0.0.1:${server.port}/api/sessions`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-harness-token": "test-token", + }, + body: JSON.stringify({ + cwd, + harness: "claude-code", + rehydrateFrom: "", + }), + }, + ); expect(response.status).toBe(400); }); }); diff --git a/packages/harness/src/server/rest.test.ts b/packages/harness/src/server/rest.test.ts index 250f9bd76..0e9788288 100644 --- a/packages/harness/src/server/rest.test.ts +++ b/packages/harness/src/server/rest.test.ts @@ -23,7 +23,9 @@ import type { WorkflowInfo, } from "../shared/types.js"; import { + ProjectSessionScopeUnavailableError, SessionManager, + SessionManagerClosingError, SessionNotReadyError, UnknownSessionError, } from "../core/session-manager.js"; @@ -48,11 +50,13 @@ function fakeSessionManager(initial: HarnessSession[] = []) { getAgentSessionOwner: vi.fn((agentSessionId: string) => Array.from(sessions.values()).find( (session) => session.agentSessionId === agentSessionId, - )), + ), + ), isAgentSessionIdentityReserved: vi.fn((agentSessionId: string) => Array.from(sessions.values()).some( (session) => session.agentSessionId === agentSessionId, - )), + ), + ), create: vi.fn(), resume: vi.fn(), kill: vi.fn(() => true), @@ -368,6 +372,27 @@ describe("createRestRouter", () => { expect(await reread.json()).toMatchObject({ rollingSummary: true }); }); + it("replays idempotent project initialization for every submitted root", async () => { + const onRecentDirAdded = vi.fn(async () => {}); + start({ onRecentDirAdded }); + const update = () => + fetch(`${baseUrl}/settings`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ recentDirs: ["/tmp/a", "/tmp/b"] }), + }); + + expect((await update()).status).toBe(200); + expect((await update()).status).toBe(200); + + expect(onRecentDirAdded.mock.calls).toEqual([ + ["/tmp/a"], + ["/tmp/b"], + ["/tmp/a"], + ["/tmp/b"], + ]); + }); + it("calls onTelemetryOptInChange only when telemetryOptIn actually changes", async () => { start(); await fetch(`${baseUrl}/settings`, { @@ -407,7 +432,10 @@ describe("createRestRouter", () => { const res = await fetch(`${baseUrl}/settings`, { method: "PATCH", headers: { "content-type": "application/json" }, - body: JSON.stringify({ helpSeen: true, telemetryNoticeDismissed: true }), + body: JSON.stringify({ + helpSeen: true, + telemetryNoticeDismissed: true, + }), }); expect(await res.json()).toMatchObject({ helpSeen: true, @@ -593,6 +621,36 @@ describe("createRestRouter", () => { "HARNESS_EXTERNAL", ); }); + + it.each([ + { + error: new ProjectSessionScopeUnavailableError("secret-session-id"), + code: "PROJECT_SESSION_SCOPE_UNAVAILABLE", + message: "the session's Studio project scope could not be revalidated", + }, + { + error: new SessionManagerClosingError(), + code: "SESSION_MANAGER_CLOSING", + message: "session manager is shutting down", + }, + ])("maps $code to a bounded 409", async ({ error, code, message }) => { + const sessionManager = fakeSessionManager(); + (sessionManager.create as ReturnType).mockRejectedValue( + error, + ); + start({ sessionManager }); + + const res = await fetch(`${baseUrl}/sessions`, { + method: "POST", + headers: { ...TOKEN_HEADER, "content-type": "application/json" }, + body: JSON.stringify({ cwd: "/tmp/proj", harness: "claude-code" }), + }); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body).toEqual({ code, error: message }); + expect(JSON.stringify(body)).not.toContain("secret-session-id"); + }); }); describe("POST /sessions/:id/attachments", () => { @@ -767,9 +825,14 @@ describe("createRestRouter", () => { expect(res.status).toBe(400); }); - it("requires planner input to use the project-scoped FIFO", async () => { + it("accepts ordinary input for a former planner session without a role-specific 409", async () => { const planner = exitedSession({ id: "planner-1", + agentMapIdentity: { + projectId: "project-1", + sessionId: "planner-1", + userId: "user-1", + }, planning: { identity: { projectId: "project-1", @@ -789,11 +852,13 @@ describe("createRestRouter", () => { headers: { ...TOKEN_HEADER, "content-type": "application/json" }, body: JSON.stringify({ text: "bypass" }), }); - expect(res.status).toBe(409); - expect(await res.json()).toMatchObject({ - code: "planner_session_requires_scoped_route", - }); - expect(sessionManager.submitInput).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(sessionManager.submitInput).toHaveBeenCalledWith( + planner.id, + "bypass", + true, + ); }); it("404s when submitInput reports no live pty for the session", async () => { @@ -965,9 +1030,14 @@ describe("createRestRouter", () => { }); describe("POST /sessions/:id/resume — error class → HTTP status mapping", () => { - it("requires planner resume to use the trusted project resolver", async () => { + it("resumes a former planner through the ordinary endpoint without a role-specific 409", async () => { const planner = exitedSession({ id: "planner-1", + agentMapIdentity: { + projectId: "project-1", + sessionId: "planner-1", + userId: "user-1", + }, planning: { identity: { projectId: "project-1", @@ -980,17 +1050,22 @@ describe("createRestRouter", () => { }, }); const sessionManager = fakeSessionManager([planner]); + (sessionManager.resume as ReturnType).mockResolvedValue({ + ...planner, + status: "running", + }); start({ sessionManager }); const res = await fetch(`${baseUrl}/sessions/planner-1/resume`, { method: "POST", headers: TOKEN_HEADER, }); - expect(res.status).toBe(409); + expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ - code: "planner_session_requires_scoped_route", + id: planner.id, + status: "running", }); - expect(sessionManager.resume).not.toHaveBeenCalled(); + expect(sessionManager.resume).toHaveBeenCalledWith(planner.id); }); it("404s when resume() throws UnknownSessionError (class-based dispatch, not string match)", async () => { @@ -1062,6 +1137,25 @@ describe("createRestRouter", () => { expect(body.code).toBe("SESSION_NOT_RESUMEABLE"); }); + it("409s with a bounded code when project scope cannot be revalidated", async () => { + const sessionManager = fakeSessionManager(); + (sessionManager.resume as ReturnType).mockRejectedValue( + new ProjectSessionScopeUnavailableError("private-session-id"), + ); + start({ sessionManager }); + + const res = await fetch(`${baseUrl}/sessions/private-session-id/resume`, { + method: "POST", + headers: TOKEN_HEADER, + }); + + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: "the session's Studio project scope could not be revalidated", + code: "PROJECT_SESSION_SCOPE_UNAVAILABLE", + }); + }); + it("400s when resume() throws AdapterNotFoundError (persisted session with unknown harness kind — C2)", async () => { // Simulates a sessions.json entry with harness: "future-harness" that // has no registered adapter — should be a 400 not a 500. @@ -1400,10 +1494,42 @@ describe("createRestRouter", () => { expect(sessionManager.resume).toHaveBeenCalledWith("sess-existing"); }); - it("requires an existing foreign-owned planner to use its scoped route without mutation", async () => { + it("bounds a scope-revalidation failure on the ordinary adopt path", async () => { + const existing = exitedSession({ + id: "sess-existing", + agentSessionId: body.agentSessionId, + }); + const sessionManager = fakeSessionManager([existing]); + (sessionManager.resume as ReturnType).mockRejectedValue( + new ProjectSessionScopeUnavailableError(existing.id), + ); + start({ + sessionManager, + adapters: { + "claude-code": historyAdapter({ canResume: async () => true }), + }, + }); + + const res = await adopt(body); + + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: "the session's Studio project scope could not be revalidated", + code: "PROJECT_SESSION_SCOPE_UNAVAILABLE", + }); + expect(sessionManager.registerHistorical).not.toHaveBeenCalled(); + expect(sessionManager.resume).toHaveBeenCalledWith(existing.id); + }); + + it("reuses a former planner owner through ordinary adopt without duplicating its record", async () => { const planner = exitedSession({ id: "planner-existing", agentSessionId: body.agentSessionId, + agentMapIdentity: { + projectId: "foreign-project", + sessionId: "planner-existing", + userId: "foreign-user", + }, planning: { identity: { projectId: "foreign-project", @@ -1417,6 +1543,10 @@ describe("createRestRouter", () => { }); const original = structuredClone(planner.planning); const sessionManager = fakeSessionManager([planner]); + (sessionManager.resume as ReturnType).mockResolvedValue({ + ...planner, + status: "running", + }); const canResume = vi.fn(async () => true); start({ sessionManager, @@ -1425,19 +1555,22 @@ describe("createRestRouter", () => { }, }); - const res = await adopt({ ...body, cwd: "/tmp/client-supplied-alias" }); + const res = await adopt(body); - expect(res.status).toBe(409); + expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ - code: "planner_session_requires_scoped_route", + id: planner.id, + status: "running", }); - expect(canResume).not.toHaveBeenCalled(); + expect(canResume).toHaveBeenCalledWith(body.agentSessionId, body.cwd); expect(sessionManager.registerHistorical).not.toHaveBeenCalled(); - expect(sessionManager.resume).not.toHaveBeenCalled(); - expect(sessionManager.get("planner-existing")?.planning).toEqual(original); + expect(sessionManager.resume).toHaveBeenCalledWith(planner.id); + expect(sessionManager.get("planner-existing")?.planning).toEqual( + original, + ); }); - it("rejects a rotated planner's durable old alias even though its current pointer changed", async () => { + it("rejects a durable rotated provider alias independently of its former role", async () => { const planner = exitedSession({ id: "planner-rotated", agentSessionId: "vendor-new", @@ -1454,10 +1587,17 @@ describe("createRestRouter", () => { }); const sessionManager = fakeSessionManager([planner]); ( - sessionManager.getAgentSessionOwner as unknown as ReturnType + sessionManager.getAgentSessionOwner as unknown as ReturnType< + typeof vi.fn + > ).mockImplementation((agentSessionId: string) => agentSessionId === body.agentSessionId ? planner : undefined, ); + ( + sessionManager.isAgentSessionIdentityReserved as unknown as ReturnType< + typeof vi.fn + > + ).mockReturnValue(true); const canResume = vi.fn(async () => true); start({ sessionManager, @@ -1468,7 +1608,7 @@ describe("createRestRouter", () => { expect(response.status).toBe(409); expect(await response.json()).toMatchObject({ - code: "planner_session_requires_scoped_route", + code: "AGENT_SESSION_IDENTITY_RESERVED", }); expect(canResume).not.toHaveBeenCalled(); expect(sessionManager.registerHistorical).not.toHaveBeenCalled(); @@ -1483,7 +1623,9 @@ describe("createRestRouter", () => { const original = structuredClone(owner); const sessionManager = fakeSessionManager([owner]); ( - sessionManager.getAgentSessionOwner as unknown as ReturnType + sessionManager.getAgentSessionOwner as unknown as ReturnType< + typeof vi.fn + > ).mockImplementation((agentSessionId: string) => agentSessionId === body.agentSessionId ? owner : undefined, ); diff --git a/packages/harness/src/server/rest.ts b/packages/harness/src/server/rest.ts index 4d727d821..f77db492f 100644 --- a/packages/harness/src/server/rest.ts +++ b/packages/harness/src/server/rest.ts @@ -49,10 +49,19 @@ import { SessionNotResumeableError, SpawnTargetError, } from "../core/errors.js"; -import { SessionNotReadyError, UnknownSessionError, type SessionManager } from "../core/session-manager.js"; +import { + ProjectSessionScopeUnavailableError, + SessionManagerClosingError, + SessionNotReadyError, + UnknownSessionError, + type SessionManager, +} from "../core/session-manager.js"; import { normalizeCwd } from "./cwd-normalize.js"; import type { SessionRecordReader } from "../core/session-record.js"; -import { getHarnessAdapter, listHarnessAdapters } from "../core/adapters/registry.js"; +import { + getHarnessAdapter, + listHarnessAdapters, +} from "../core/adapters/registry.js"; import { resolveWithinRoot } from "../core/path-safety.js"; import { loadSettings, saveSettings } from "../cli/settings.js"; @@ -80,13 +89,16 @@ function decodedBase64Size(encoded: string): number | null { // validation and the TypeScript type can never drift from each other. // Adding a new spawnable harness means updating that one constant; the // validator here and the HarnessKind type both pick up the change automatically. -const createSessionSchema = z.object({ - cwd: z.string().min(1), - harness: z.enum(SPAWNABLE_HARNESS_KINDS), - profile: z.string().optional(), - rehydrateFrom: z.string().min(1).optional(), - theme: z.enum(["light", "dark"]).optional(), -}).strict() satisfies z.ZodType; +const createSessionSchema = z + .object({ + cwd: z.string().min(1), + harness: z.enum(SPAWNABLE_HARNESS_KINDS), + profile: z.string().optional(), + initialUserInputPending: z.boolean().optional(), + rehydrateFrom: z.string().min(1).optional(), + theme: z.enum(["light", "dark"]).optional(), + }) + .strict() satisfies z.ZodType; const injectInputSchema = z.object({ text: z.string(), @@ -195,7 +207,11 @@ export interface RestRouterOptions { adapters: Partial>; version: string; /** Sapiom identity from CLI auth; null when unauthenticated / --no-auth. */ - identity: { userId: string; tenantId: string; organizationName: string } | null; + identity: { + userId: string; + tenantId: string; + organizationName: string; + } | null; listWorkflows: () => Promise; /** Workspace identities backing the folder projection and system-graph route. */ listWorkspaceScopes?: () => @@ -227,11 +243,23 @@ export interface RestRouterOptions { /** Called after a settings PATCH persists a changed telemetryOptIn, so the * live collector batcher can be gated without a server restart. */ onTelemetryOptInChange?: (optIn: boolean) => void; + /** Called only for roots newly added to the user's durable project list. */ + onRecentDirAdded?: (root: string) => Promise | void; /** Called (fire-and-forget) after a session is created, with its cwd and id * — lets the integrator scan that directory for workflows (so opening a * session in a new project discovers them without a manual "+ Connect") * and, when the scan discovers one, render the new session's canvas. */ onSessionCreated?: (cwd: string, harnessSessionId: string) => void; + /** + * Optional lifecycle-aware input boundary. It lets a pending project + * bootstrap yield durably to real user input; ordinary sessions fall back + * to SessionManager.submitInput. + */ + submitSessionInput?: ( + sessionId: string, + text: string, + submit: boolean, + ) => Promise; /** The directory the CLI was launched against — surfaced in AppState so the * SPA can prefill the new-session modal with it. */ launchDir: string; @@ -392,6 +420,15 @@ export function createRestRouter(options: RestRouterOptions): Router { ) { options.onTelemetryOptInChange?.(parsed.data.telemetryOptIn); } + if (parsed.data.recentDirs) { + // The settings write commits before project initialization. Invoke the + // idempotent callback for every submitted root so retrying a request + // after an initialization failure can finish the already-durable + // project instead of being skipped as "not newly added". + for (const root of parsed.data.recentDirs) { + await options.onRecentDirAdded?.(root); + } + } res.json(updated); } catch (err) { next(err); @@ -447,14 +484,21 @@ export function createRestRouter(options: RestRouterOptions): Router { res.status(201).json(session); options.onSessionCreated?.(request.cwd, session.id); } catch (err) { - if (err instanceof AdapterNotFoundError || err instanceof SpawnTargetError) { + if ( + err instanceof AdapterNotFoundError || + err instanceof SpawnTargetError + ) { // Both are user-actionable ("install claude", "restart to repair") — // the dialog renders this message verbatim, so a 500 here buried the // one string that tells the user what to do. res.status(400).json({ error: err.message, code: err.code }); return; } - if (err instanceof ExternalHarnessError) { + if ( + err instanceof ExternalHarnessError || + err instanceof ProjectSessionScopeUnavailableError || + err instanceof SessionManagerClosingError + ) { res.status(409).json({ error: err.message, code: err.code }); return; } @@ -619,7 +663,9 @@ export function createRestRouter(options: RestRouterOptions): Router { // agent session — they carry live status the transcript can't know. const registryRows = sessionManager .list() - .filter((session) => session.cwd === cwd && session.agentSessionId != null); + .filter( + (session) => session.cwd === cwd && session.agentSessionId != null, + ); // Only rows the scan did NOT account for need a direct probe — the // phantoms, plus the narrow case of a transcript that exists but holds // no line our parser understands (see ClaudeCodeAdapter.canResume, which @@ -628,7 +674,12 @@ export function createRestRouter(options: RestRouterOptions): Router { registryRows.map(async (session) => foundInStore.has(`${session.harness}\u0000${session.agentSessionId!}`) ? true - : agentHoldsConversation(adapters, session.harness, session.agentSessionId!, session.cwd), + : agentHoldsConversation( + adapters, + session.harness, + session.agentSessionId!, + session.cwd, + ), ), ); @@ -647,7 +698,10 @@ export function createRestRouter(options: RestRouterOptions): Router { }); for (const record of transcripts) { if (byAgentSessionId.has(record.agentSessionId)) continue; - byAgentSessionId.set(record.agentSessionId, { ...record, resumeMode: "agent-resume" }); + byAgentSessionId.set(record.agentSessionId, { + ...record, + resumeMode: "agent-resume", + }); } const merged = Array.from(byAgentSessionId.values()).sort((a, b) => @@ -668,7 +722,9 @@ export function createRestRouter(options: RestRouterOptions): Router { for (const summary of merged) { const turnCount = turnCounts.get(summary.agentSessionId) ?? - (summary.harnessSessionId ? turnCounts.get(summary.harnessSessionId) : undefined); + (summary.harnessSessionId + ? turnCounts.get(summary.harnessSessionId) + : undefined); if (turnCount !== undefined) summary.turnCount = turnCount; } } @@ -691,7 +747,10 @@ export function createRestRouter(options: RestRouterOptions): Router { res.status(404).json({ error: err.message }); return true; } - if (err instanceof AdapterNotFoundError || err instanceof SpawnTargetError) { + if ( + err instanceof AdapterNotFoundError || + err instanceof SpawnTargetError + ) { // AdapterNotFoundError: a persisted session with an unknown harness kind // (e.g. from a future or removed harness type) cannot be resumed. // SpawnTargetError: the agent binary can't be spawned on Windows (not on @@ -703,10 +762,13 @@ export function createRestRouter(options: RestRouterOptions): Router { if ( err instanceof ExternalHarnessError || err instanceof AgentSessionIdentityReservedError || + err instanceof ProjectSessionScopeUnavailableError || err instanceof SessionAlreadyLiveError || err instanceof SessionNotResumeableError ) { - res.status(409).json({ error: err.message, code: (err as { code: string }).code }); + res + .status(409) + .json({ error: err.message, code: (err as { code: string }).code }); return true; } return false; @@ -721,35 +783,27 @@ export function createRestRouter(options: RestRouterOptions): Router { router.post("/sessions/adopt", async (req, res, next) => { const parsed = adoptSessionSchema.safeParse(req.body); if (!parsed.success) { - res.status(400).json({ error: parsed.error.issues.map((i) => i.message).join("; ") }); + res + .status(400) + .json({ error: parsed.error.issues.map((i) => i.message).join("; ") }); return; } const { agentSessionId, harness, title, lastActiveAt } = parsed.data; const cwd = normalizeCwd(parsed.data.cwd); try { // Resolve an already-owned registry row before probing or mutating any - // adapter state. Generic adoption must never bypass the project/user - // authority and focused-context checks on the scoped planner route. + // adapter state. Project scope is re-derived by SessionManager on the + // ordinary resume path; a legacy planner origin is metadata, never a + // separate authority or lifecycle route. const durableOwner = sessionManager.getAgentSessionOwner(agentSessionId); const identityReserved = sessionManager.isAgentSessionIdentityReserved(agentSessionId); const identityOwners = sessionManager .list() .filter((session) => session.agentSessionId === agentSessionId); - if ( - durableOwner?.planning !== undefined || - identityOwners.some((session) => session.planning !== undefined) - ) { - res.status(409).json({ - code: "planner_session_requires_scoped_route", - error: "Planner sessions must be resumed through their project route", - }); - return; - } - // For ordinary sessions, cwd remains part of the historical-record - // identity. It is deliberately checked only after the vendor id has - // been fenced from every planner owner above: client-supplied cwd must - // not alias around the scoped planner route. + // Cwd remains part of the historical-record identity. The client cannot + // use it to choose Agent Map authority: the server resolves the current + // canonical project again before the resumed process is spawned. const existing = identityOwners.find( (session) => normalizeCwd(session.cwd) === cwd, ); @@ -765,7 +819,9 @@ export function createRestRouter(options: RestRouterOptions): Router { // Never take the client's word for resumability — it's re-derived from // the agent's own store here, so a stale history row (transcript deleted // between the list and the click) can't leave a phantom record behind. - if (!(await agentHoldsConversation(adapters, harness, agentSessionId, cwd))) { + if ( + !(await agentHoldsConversation(adapters, harness, agentSessionId, cwd)) + ) { const label = getHarnessAdapter(harness).label; res.status(409).json({ error: `${label} no longer has the conversation for session ${agentSessionId} in ${cwd} — there is nothing to resume. Start a new session in this directory instead.`, @@ -808,7 +864,9 @@ export function createRestRouter(options: RestRouterOptions): Router { */ router.get("/sessions/:id/record", async (req, res, next) => { if (!options.sessionRecords) { - res.status(501).json({ error: "session records are not available on this server" }); + res + .status(501) + .json({ error: "session records are not available on this server" }); return; } try { @@ -824,13 +882,6 @@ export function createRestRouter(options: RestRouterOptions): Router { }); router.post("/sessions/:id/resume", async (req, res, next) => { - if (sessionManager.get(req.params.id)?.planning) { - res.status(409).json({ - code: "planner_session_requires_scoped_route", - error: "Planner sessions must be resumed through their project route", - }); - return; - } try { const session = await sessionManager.resume(req.params.id); res.json(session); @@ -856,20 +907,19 @@ export function createRestRouter(options: RestRouterOptions): Router { res.status(400).json({ error: parsed.error.message }); return; } - if (sessionManager.get(req.params.id)?.planning) { - res.status(409).json({ - code: "planner_session_requires_scoped_route", - error: "Planner input must use the project-scoped message route", - }); - return; - } try { const submit = parsed.data.submit ?? true; - const ok = await sessionManager.submitInput( - req.params.id, - parsed.data.text, - submit, - ); + const ok = options.submitSessionInput + ? await options.submitSessionInput( + req.params.id, + parsed.data.text, + submit, + ) + : await sessionManager.submitInput( + req.params.id, + parsed.data.text, + submit, + ); if (!ok) { res.status(404).json({ error: "session not found or has no live pty" }); return; @@ -883,6 +933,14 @@ export function createRestRouter(options: RestRouterOptions): Router { res.status(409).json({ error: err.message, code: err.code }); return; } + if ( + err instanceof Error && + "code" in err && + err.code === "project_bootstrap_dispatch_forbidden" + ) { + res.status(403).json({ error: err.message, code: err.code }); + return; + } next(err); } }); diff --git a/packages/harness/src/server/served-system-prompt.test.ts b/packages/harness/src/server/served-system-prompt.test.ts index 6f2e6f706..b9624ffd5 100644 --- a/packages/harness/src/server/served-system-prompt.test.ts +++ b/packages/harness/src/server/served-system-prompt.test.ts @@ -16,6 +16,7 @@ import { join } from "node:path"; 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 { HarnessAdapter, HarnessKind, @@ -66,10 +67,17 @@ 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", @@ -82,31 +90,48 @@ describe("served system prompt reaches the launched session", () => { } 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", + }); - expect(await systemPromptFile(session.id)).toBe(SERVED_PROMPT); + const prompt = await systemPromptFile(session.id); + expect(prompt).toContain(SERVED_PROMPT); + expect(prompt).toContain(PROJECT_AGENT_PROMPT_APPENDIX); }); it("re-reads it on resume, so a redeployed prompt reaches a continued session", async () => { 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); - expect(await systemPromptFile(session.id)).toBe("# Redeployed prompt"); + const prompt = await systemPromptFile(session.id); + expect(prompt).toContain("# Redeployed prompt"); + expect(prompt).not.toContain(SERVED_PROMPT); + expect(prompt).toContain(PROJECT_AGENT_PROMPT_APPENDIX); }); it("falls back to the bundled profile when the load fails", async () => { @@ -116,8 +141,13 @@ 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", + }); - expect(await systemPromptFile(session.id)).toBe(DEFAULT_SYSTEM_PROMPT); + const prompt = await systemPromptFile(session.id); + expect(prompt).toContain(DEFAULT_SYSTEM_PROMPT); + expect(prompt).toContain(PROJECT_AGENT_PROMPT_APPENDIX); }); }); diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index dba536280..4976fc1b4 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -261,6 +261,20 @@ export interface SessionPrincipal { userId: string; } +/** + * Server-derived authority for an ordinary session inside a Studio project. + * + * Optional assignment, bootstrap, and focused-context metadata deliberately + * live outside this principal: they may describe why a session exists, but + * they cannot change which project tools or execution policy it receives. + */ +export type ProjectAgentSession = Readonly; + +/** + * @deprecated Persisted rolling-compatibility metadata only. Live Agent Map + * authority uses {@link ProjectAgentSession}; role and assignment must never + * participate in authorization or capability composition. + */ export type PlanningSessionIdentity = | (SessionPrincipal & { role: "map-planner" }) | (SessionPrincipal & { @@ -272,6 +286,11 @@ export type PlanningSessionIdentity = assignment: { kind: "unplanned" }; }); +/** + * Legacy E2 persisted attribution shape. SAP-3148 keeps the codec stable while + * live authority moves to ProjectAgentSession; SAP-3149 owns its durable + * role-neutral replacement. + */ export interface ProposalActor { userId: string; sessionId: string; @@ -333,7 +352,7 @@ export interface AgentMapReadSnapshot { proposal: MapChangeProposal | null; } -export type PlannerGreetingErrorCode = +export type ProjectBootstrapErrorCode = | "session_not_ready" | "session_exited" | "injection_failed" @@ -341,15 +360,26 @@ export type PlannerGreetingErrorCode = | "delivery_timeout" | "persistence_failed"; -export type PlannerGreetingState = +export type ProjectBootstrapState = | { status: "pending" } | { status: "generating"; attemptId: string } | { status: "delivered"; messageId: string } | { status: "failed"; retryable: boolean; - errorCode: PlannerGreetingErrorCode; + errorCode: ProjectBootstrapErrorCode; } + | { + status: "skipped"; + reason: "user-proceeded" | "map-not-empty"; + }; + +/** @deprecated Persisted planner-era bootstrap error vocabulary. */ +export type PlannerGreetingErrorCode = ProjectBootstrapErrorCode; + +/** @deprecated Persisted planner-era bootstrap state. */ +export type PlannerGreetingState = + | Exclude | { status: "skipped"; reason: "user-proceeded" }; export interface PlannerSessionMetadata { @@ -358,6 +388,79 @@ export interface PlannerSessionMetadata { queuedInputIds: string[]; } +/** + * Lifecycle context for the one automatic map seed owned by a newly created + * project. It is deliberately separate from ProjectAgentSession authority. + */ +export interface ProjectBootstrapMetadata { + projectId: StudioProjectId; + userId: string; + targetSessionId: string; + bootstrap: ProjectBootstrapState; + queuedInputIds: string[]; +} + +export interface ProjectBootstrapQueuedInput { + id: string; + sessionId: string; + text: string; + acceptedAt: string; +} + +export type ProjectBootstrapRegistrationMode = + | "boot" + | "created" + | "live" + | "resumed"; + +/** Content-free lifecycle telemetry for project bootstrap reliability. */ +export type ProjectBootstrapLifecycleEvent = + | { + name: "project_bootstrap.scheduled" | "project_bootstrap.recovered"; + projectId: StudioProjectId; + sessionId: string; + } + | { + name: "project_bootstrap.attempted" | "project_bootstrap.retried"; + projectId: StudioProjectId; + sessionId: string; + attemptId: string; + retryOrdinal: number; + queueDepth: number; + } + | { + name: "project_bootstrap.delivered"; + projectId: StudioProjectId; + sessionId: string; + attemptId: string; + queueDepth: number; + } + | { + name: "project_bootstrap.failed"; + projectId: StudioProjectId; + sessionId: string; + attemptId?: string; + errorCode: ProjectBootstrapErrorCode; + retryable: boolean; + queueDepth: number; + } + | { + name: "project_bootstrap.preempted" | "project_bootstrap.skipped"; + projectId: StudioProjectId; + sessionId: string; + attemptId?: string; + reason: "user-proceeded" | "map-not-empty"; + queueDepth: number; + } + | { + name: "project_bootstrap.input_delivery_uncertain"; + projectId: StudioProjectId; + sessionId: string; + inputId: string; + errorCode: "delivery_uncertain"; + queueDepth: number; + }; + export interface PlannerQueuedInput { id: string; sessionId: string; @@ -380,9 +483,12 @@ export interface PlannerMessageRequest { text: string; } -/** Authoritative coordinator state returned after a planner mutation. */ +/** + * @deprecated Rolling planner-route response. The route now delegates to the + * neutral project-bootstrap coordinator and never recreates planner identity. + */ export interface PlannerSessionMetadataResponse { - metadata: PlannerSessionMetadata; + metadata: ProjectBootstrapMetadata | null; } /** diff --git a/packages/harness/src/shared/paths.ts b/packages/harness/src/shared/paths.ts new file mode 100644 index 000000000..e602602a6 --- /dev/null +++ b/packages/harness/src/shared/paths.ts @@ -0,0 +1,123 @@ +/** + * Path helpers for the ABSOLUTE paths the server hands out. + * + * Shared, not browser-side: `@shared/project-roots` derives project roots with + * them and both the SPA and the server run that derivation, so a second copy + * would be two definitions of path equality. Pure string operations only — no + * `node:path`, no DOM — which is what lets one file serve both hosts. + * + * The server builds them with `path.join`, so they arrive in the host's native + * shape — backslash-separated on Windows. The SPA cannot ask `node:path` which + * host that was; it infers the separator from the string itself, which works + * because a Windows absolute path always contains at least one `\` (`C:\…`) + * and a POSIX one never does. + * + * Joins preserve the input's native separator (what gets POSTed back must + * match what the server sent), but every COMPARISON normalizes both + * separators first: paths that were joined in the browser before this module + * existed shipped in mixed form (`C:\Users\x\projects/newsletter-autopilot`), + * and those still have to compare equal to their native spellings. + */ + +/** The separator `p` itself uses. `\` anywhere marks a Windows path — POSIX + * filenames may legally contain `\`, but never in the absolute paths the + * server supplies. */ +export function sepOf(p: string): "\\" | "/" { + return p.includes("\\") ? "\\" : "/"; +} + +/** `` in the root's native separator, with no doubled + * separator when the root carries a trailing one. */ +export function joinPath(root: string, name: string): string { + const trimmedRoot = root.trim().replace(/[\\/]+$/, ""); + return `${trimmedRoot}${sepOf(root)}${name.trim()}`; +} + +/** Last non-empty segment under either separator, or the input when it has + * none (a relative name is its own basename). */ +export function basenameOf(p: string): string { + return p.split(/[\\/]/).filter(Boolean).pop() ?? p; +} + +/** + * Parent of an absolute path, or null at a filesystem root (`/`, `C:\`, bare + * `C:`) and for separator-free relative strings. Mirrors `path.dirname` + * without pulling node:path into the browser bundle. + * + * Needed because GET /api/fs/list reports one level DOWN: a path can only + * learn whether it is itself an agent project by asking its parent. + */ +export function parentOf(input: string): string | null { + const trimmed = input.replace(/[\\/]+$/, ""); + if (trimmed === "" || /^[A-Za-z]:$/.test(trimmed)) return null; + const lastSep = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + if (lastSep < 0) return null; + const cut = trimmed.slice(0, lastSep); + // First-level paths keep their root spelled out — `/Users` → `/`, + // `C:\Users` → `C:\` — so the result is always itself a listable path. + if (/^[A-Za-z]:$/.test(cut)) return cut + trimmed[lastSep]; + return cut || "/"; +} + +/** `/a/b/` → `/a/b` under either separator, so a user's trailing slash never + * breaks a path comparison. Bare roots (`/`, `C:\`) pass through unchanged — + * stripping them would leave something that isn't a path. */ +export function stripTrailingSep(p: string): string { + const trimmed = p.replace(/[\\/]+$/, ""); + if (trimmed === p) return p; + if (trimmed === "") return p[0]; + if (/^[A-Za-z]:$/.test(trimmed)) return trimmed + p[trimmed.length]; + return trimmed; +} + +/** Whether `child` IS `parent` or sits beneath it — never a mere string + * prefix, so `/a/scratch-2` is not within `/a/scratch`. Separator-insensitive + * on both sides, so a mixed-form path still matches its native spelling. */ +/** + * Whether two paths name the same directory, ignoring separator form and a + * trailing separator. + * + * Needed because the client and the server no longer agree byte-for-byte: the + * server `path.resolve()`s every cwd it stores (server/cwd-normalize.ts) while + * the SPA holds whatever the user typed or a recentDirs entry recorded — so a + * `C:/…`-typed path, or one with a trailing slash, fails a raw `===` against + * the very session it just created (empty tab strip, unhighlighted rail row). + */ +export function samePath(a: string, b: string): boolean { + return ( + stripTrailingSep(a.replace(/\\/g, "/")) === + stripTrailingSep(b.replace(/\\/g, "/")) + ); +} + +export function isWithinDir(parent: string, child: string): boolean { + const p = stripTrailingSep(parent.replace(/\\/g, "/")); + const c = stripTrailingSep(child.replace(/\\/g, "/")); + if (c === p) return true; + // A filesystem root keeps its trailing separator (stripTrailingSep's + // contract), so appending another would test "C://…" and never match — + // every session under a root-level workspace looked like an orphan. + return p.endsWith("/") ? c.startsWith(p) : c.startsWith(`${p}/`); +} + +/** Whether typed input is trying to be an absolute path (`/…`, `~…`, or a + * Windows drive like `C:\…` / `C:/…`) rather than a search query. */ +export function looksAbsolutePath(input: string): boolean { + return ( + input.startsWith("/") || + input.startsWith("~") || + /^[A-Za-z]:[\\/]/.test(input) + ); +} + +/** "/Users/…/onboarding-flow" — middle-truncates a long path so a chip row + * never hard-clips a chip mid-glyph; the full path stays in the tooltip. */ +export function middleTruncatePath(path: string): string { + const sep = sepOf(path); + const segments = path.split(/[\\/]/).filter(Boolean); + if (segments.length <= 2) return path; + // POSIX first segments lost their leading `/` to the split; a drive letter + // (`C:`) never had one. + const prefix = sep === "\\" ? "" : sep; + return `${prefix}${segments[0]}${sep}…${sep}${segments[segments.length - 1]}`; +} diff --git a/packages/harness/src/shared/project-roots.test.ts b/packages/harness/src/shared/project-roots.test.ts new file mode 100644 index 000000000..6955d86d9 --- /dev/null +++ b/packages/harness/src/shared/project-roots.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; + +import { + preferredProjectRoot, + projectRoots, + projectSessionRoot, + projectToOpen, + type ProjectRootSources, +} from "./project-roots.js"; + +function sources( + overrides: Partial = {}, +): ProjectRootSources { + return { + recentDirs: [], + sessions: [], + pendingCwds: [], + agentPaths: [], + sort: "recent", + ...overrides, + }; +} + +describe("shared project-root resolution", () => { + it("chooses the canonical outermost multi-root binding deterministically", () => { + expect( + preferredProjectRoot([ + "/workspace/project/packages/zeta", + "/workspace/project", + "/workspace/project/packages/alpha", + ]), + ).toBe("/workspace/project"); + expect( + preferredProjectRoot(["/workspace/project-b", "/workspace/project-a"]), + ).toBe("/workspace/project-a"); + }); + + it("maps a descendant project session to its nearest durable root", () => { + expect( + projectSessionRoot( + { cwd: "/workspace/project/packages/app", projectId: "project-1" }, + [ + { projectId: "project-1", cwd: "/workspace/project" }, + { projectId: "project-1", cwd: "/workspace/project/packages" }, + { projectId: "project-2", cwd: "/workspace/project/packages/app" }, + ], + ), + ).toBe("/workspace/project/packages"); + }); + + it("keeps an evicted durable root instead of promoting its session cwd", () => { + const root = projectSessionRoot( + { cwd: "/workspace/project/packages/app", projectId: "project-1" }, + [{ projectId: "project-1", cwd: "/workspace/project" }], + ); + + expect( + projectRoots( + sources({ + sessions: [ + { + cwd: root!, + createdAt: "2026-01-01T00:00:00.000Z", + status: "running", + }, + ], + pinnedRoots: ["/workspace/project"], + agentPaths: ["/workspace/project/packages/app/agent"], + }), + ), + ).toEqual(["/workspace/project"]); + }); + + it("deduplicates equivalent separator forms and keeps the first trusted spelling", () => { + const pending = "C:\\work\\property-ops\\"; + + expect( + projectRoots( + sources({ + pendingCwds: [pending], + recentDirs: ["C:/work/property-ops"], + sessions: [ + { + cwd: "C:\\work\\property-ops", + createdAt: "2026-01-01T00:00:00.000Z", + status: "running", + }, + ], + }), + ), + ).toEqual([pending]); + }); + + it("uses a lexical path tie-break when session recency is identical", () => { + expect( + projectRoots( + sources({ + sessions: [ + { + cwd: "/workspace/zeta", + createdAt: "2026-01-01T00:00:00.000Z", + status: "exited", + }, + { + cwd: "/workspace/alpha", + createdAt: "2026-01-01T00:00:00.000Z", + status: "exited", + }, + ], + agentPaths: [ + "/workspace/zeta/zeta-agent", + "/workspace/alpha/alpha-agent", + ], + }), + ), + ).toEqual(["/workspace/alpha", "/workspace/zeta"]); + }); + + it("recognizes an agent root across separator forms before promoting it", () => { + expect( + projectToOpen( + "C:/work/property-ops/tenant-screening", + sources({ + recentDirs: ["C:\\work\\property-ops\\tenant-screening"], + agentPaths: ["C:\\work\\property-ops\\tenant-screening"], + }), + ), + ).toBe("C:/work/property-ops"); + }); + + it("preserves a durable project root when later discovery marks it as an agent", () => { + expect( + projectRoots( + sources({ + recentDirs: ["/workspace/property-ops"], + pinnedRoots: ["/workspace/property-ops"], + agentPaths: ["/workspace/property-ops"], + }), + ), + ).toEqual(["/workspace/property-ops"]); + }); + + it("does not resurrect a durable nested root from an exited session alone", () => { + expect( + projectRoots( + sources({ + recentDirs: ["/workspace"], + pinnedRoots: ["/workspace/removed-project"], + sessions: [ + { + cwd: "/workspace/removed-project", + createdAt: "2026-01-01T00:00:00.000Z", + status: "exited", + }, + ], + agentPaths: ["/workspace/removed-project/agent"], + }), + ), + ).toEqual(["/workspace"]); + }); +}); diff --git a/packages/harness/src/shared/project-roots.ts b/packages/harness/src/shared/project-roots.ts new file mode 100644 index 000000000..697c586cd --- /dev/null +++ b/packages/harness/src/shared/project-roots.ts @@ -0,0 +1,425 @@ +/** + * Canonical project-root derivation shared by Studio's browser and server. + * + * UI grouping, trusted project scope, and session capability composition must + * resolve the same roots from the same inputs. This module is deliberately + * pure and host-neutral: callers supply settings, sessions, pending creation + * roots, and registered agent paths; no filesystem or browser API is used. + */ +import type { SessionStatus } from "./types.js"; +import { + basenameOf, + isWithinDir, + parentOf, + stripTrailingSep, +} from "./paths.js"; + +/** + * Row order within a container. "name" is A-Z; "recent" is + * newest-activity-first. It reaches this module because project ORDER is part + * of the derivation's output, and the rail renders that order verbatim. + */ +export type RailSort = "recent" | "name"; + +const isUnder = (childPath: string, root: string): boolean => + isWithinDir(root, childPath); + +/** Comparison form: forward slashes, no trailing separator. Never rendered and + * never POSTed — what the server sent keeps its native spelling. */ +const canonical = (p: string): string => + stripTrailingSep(p.replace(/\\/g, "/")); + +/** Everything the rail knows about which folders are projects. */ +export interface ProjectRootSources { + /** Upstream's workspace list — most-recently-used project directories, + * newest first, already deduped and pruned of dead paths at every boot. */ + recentDirs: readonly string[]; + /** Session cwds widen the candidate set for folders `recentDirs` has not yet + * recorded, and carry the recency signal for them. `status` separates the + * two very different claims a cwd can make: see rule 2. */ + sessions: readonly { + cwd: string; + createdAt: string; + status?: SessionStatus; + }[]; + /** Folders whose agent is mid-creation: known before any session or agent + * exists under them. */ + pendingCwds: readonly string[]; + /** Existing durable Studio roots. A root already carrying project identity + * must not be reclassified when later discovery learns that the directory is + * itself an agent; that would strand persisted sessions in a new project. */ + pinnedRoots?: readonly string[]; + /** + * Every registered agent's OWN directory. + * + * Required, not optional. The rule below cannot be stated without it, and a + * caller that forgets it would silently get the old accumulating behaviour + * back with every test still green. + */ + agentPaths: readonly string[]; + sort: RailSort; +} + +export interface DurableProjectRoot { + projectId: string; + cwd: string; +} + +/** + * Choose one deterministic launch root for a multi-root project. + * + * The outermost active binding wins so a project-wide session starts with the + * broadest trusted context. Canonical lexical order breaks equal-depth ties; + * callers retain the winning root's original host spelling. + */ +export function preferredProjectRoot(roots: readonly string[]): string | null { + return ( + [...roots] + .filter((root) => canonical(root) !== "") + .sort((left, right) => { + const canonicalLeft = canonical(left); + const canonicalRight = canonical(right); + return ( + canonicalLeft.length - canonicalRight.length || + canonicalLeft.localeCompare(canonicalRight) || + left.localeCompare(right) + ); + })[0] ?? null + ); +} + +/** + * Resolve a neutral project session back to its trusted durable root. + * + * Session cwd may be any descendant used for ordinary coding work. Both the + * server scope catalog and the browser rail must contribute the durable root, + * rather than independently promoting that descendant into a second project. + */ +export function projectSessionRoot( + session: { cwd: string; projectId: string }, + roots: readonly DurableProjectRoot[], +): string | null { + const matches = roots.filter( + (root) => + root.projectId === session.projectId && + isWithinDir(root.cwd, session.cwd), + ); + if (matches.length === 0) return null; + return matches.sort( + (left, right) => + canonical(right.cwd).length - canonical(left.cwd).length || + left.cwd.localeCompare(right.cwd), + )[0]!.cwd; +} + +/** + * THE FOLDER THAT HOLDS AN AGENT, or null when nothing better than the agent's + * own directory exists. + * + * ONE ANSWER, because there are two callers and they must not disagree. The + * rail's derivation asks it to decide which row to draw; `openProject` asks it + * to decide what the picker actually opens when you point it at an agent. + * + * `projects` must be the list `projectRoots` produces. The guard below is only + * as good as the definition of "project" it is handed, and a caller that builds + * its own will decline hops the rail would have made, which restores the silent + * no-op this exists to remove. `openProject` therefore passes + * `projectRoots(...)` verbatim rather than assembling anything. + * + * Null means REFUSE, and refusing is safe: the agent's own folder stays the + * root and renders as a project with that agent inside, which is what opening + * an agent's folder honestly means. + * + * Two reasons to refuse: + * + * 1. **A filesystem root.** `paths.parentOf` answers `/` (and `C:\`) rather + * than null there, deliberately, so that every result stays a listable + * path. Taken literally it turns an agent at `/solo` into a project called + * `/` holding the entire disk, and the swallow guard below cannot catch it + * because at that point there is no other project to swallow yet. + * 2. **It would contain another project.** Without this, the clean demo + * fixture, whose roots are agent folders sitting beside an ordinary project + * under one home directory, promoted them all to `/Users/demo` and produced + * a single project holding every other project, with every agent inside it + * rendered twice. That is the duplicate-agent rendering this rule exists to + * remove, re-created by the repair. + * + * KNOWN LIMIT, stated rather than papered over: a directory holding nothing but + * agent folders and no other project DOES become the project. That is right + * everywhere except a home directory, and a home directory in practice always + * holds another project, which is what makes the guard fire. A depth floor was + * considered and rejected, because every threshold that saves `/Users/demo` + * also breaks a legitimate two-segment root. + */ +/** + * WHAT OPENING A FOLDER ACTUALLY OPENS. + * + * You cannot open a single agent as a project, so pointing the picker at an + * agent's own folder opens the folder that holds it. Without this the press is + * a silent no-op: `projectRoots` declines to draw a row for an agent-rooted + * entry, so the picker says "This is an agent project", the user presses Open, + * and nothing changes. + * + * THE ELIGIBLE PROJECTS ARE `projectRoots`' OWN OUTPUT, not a list assembled + * here to resemble it. That is the whole design of this function, and it is the + * only version of it that has held: the guard inside `holdingProjectFor` asks + * "would this promotion swallow a project", and the answer is only as good as + * the definition of "project" it is handed. Four separate attempts to + * reconstruct that definition locally were each wrong in a different way, and + * every one of them failed in the same direction, by counting something the + * rail does not keep and so refusing a hop the rail would have made, which puts + * the silent no-op back. + * + * `projectRoots` is the one place that decides what a project is: chosen + * folders, folders with a live session, and session-only folders that hold an + * agent no other root already shows, with agent directories excluded and + * promotions guarded. Calling it costs one derivation on a user gesture and + * removes the entire class of drift, because there is no second definition left + * to disagree with. + */ +export function projectToOpen( + requested: string, + sources: ProjectRootSources, +): string { + const isAgentDir = sources.agentPaths.some( + (path) => canonical(path) === canonical(requested), + ); + if (!isAgentDir) return requested; + return ( + holdingProjectFor(requested, { + agentPaths: sources.agentPaths, + projects: projectRoots(sources), + }) ?? requested + ); +} + +export function holdingProjectFor( + agentDir: string, + { + agentPaths, + projects, + }: { agentPaths: readonly string[]; projects: readonly string[] }, +): string | null { + const agentDirs = new Set(agentPaths.map(canonical)); + let parent = parentOf(agentDir); + // An agent nested inside another agent walks up until it clears them all. + while (parent && agentDirs.has(canonical(parent))) parent = parentOf(parent); + if (parent === null || parentOf(parent) === null) return null; + const swallowsAProject = projects.some( + (held) => isUnder(held, parent!) && canonical(held) !== canonical(parent!), + ); + return swallowsAProject ? null : parent; +} + +/** + * THE ORDERED LIST OF PROJECT ROOTS. + * + * One sentence governs this whole function: + * + * 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. + * + * 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 + * that is itself a registered agent is a category error, and it is the single + * cause of both symptoms a real install shows. Its dependency graph has exactly + * 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 + * is replaced by its nearest non-agent ancestor. + * + * This is not a new rule. `project-membership.agentNeedsOwnProject` has + * enforced it on every NEW registration since the accumulation was diagnosed: + * "an agent an open project already contains needs nothing remembered". It was + * simply never applied to the entries already in the list, so the guard stopped + * the bleeding and left the wound. Applying one rule in one direction only is + * why SAP-2927 looked complete while the rail still looked broken. + * + * RULE 2, "that holds agents": a folder known ONLY because a session ran there + * earns a row only if it holds an agent no other project already shows, OR a + * session is LIVE in it. "A session ran here once and exited" and "something is + * running here right now" are different claims, and collapsing them cost a real + * case immediately: a bare scaffold session, a live session in a folder with no + * agent yet, is exactly how you start an agent in an empty folder, and dropping + * its row makes a running session unreachable from the rail. + * `recentDirs` is chosen and capped at 8; session cwds are neither, which is + * why the second list has to earn its rows and the first does not. Two failures + * collapse into that one clause. A visited folder with no agent is not a + * project, while an empty project you OPENED keeps its row, because opening a + * folder in order to build the first agent in it is the whole point of that + * row. And a visited folder INSIDE a project you already opened is not a second + * context: `~/polsia` and `~/polsia/services/workers` are two useful views of + * one agent when you opened both, and the same agent printed twice when the + * inner row is merely where a session happened to start. + * + * NOTHING IS DELETED. Both rules are derivational: `recentDirs` on disk is + * untouched and any folder is one "Add a project" away from coming back. That + * is what makes this safe to apply to an install nobody audited, and why it + * needs no migration, no first-run flow and no undo. The design's original "no + * migration, every entry becomes a project" rule is kept in spirit and dropped + * in letter: nothing a user had disappears, but residue of a fixed bug stops + * being rendered as a choice they made. + */ +export function projectRoots({ + recentDirs, + sessions, + pendingCwds, + pinnedRoots = [], + agentPaths, + sort, +}: ProjectRootSources): string[] { + // Newest activity per directory, for folders `recentDirs` has not heard of. + const newestByCwd = new Map(); + for (const session of sessions) { + const key = canonical(session.cwd); + const prev = newestByCwd.get(key); + if (!prev || session.createdAt > prev) + newestByCwd.set(key, session.createdAt); + } + + // First spelling wins: recentDirs and a session cwd can name one directory + // in two forms (the server `path.resolve`s what it stores, the SPA holds + // what the user typed), and two rows for one folder is unreadable. + const seen = new Set(); + const candidates: string[] = []; + for (const dir of [ + ...pendingCwds, + ...recentDirs, + ...sessions.map((s) => s.cwd), + ]) { + const key = canonical(dir); + if (key === "" || seen.has(key)) continue; + seen.add(key); + candidates.push(dir); + } + + const agentDirs = new Set(agentPaths.map(canonical)); + const pinned = new Set(pinnedRoots.map(canonical)); + const isAgentDir = (dir: string): boolean => agentDirs.has(canonical(dir)); + const isPinned = (dir: string): boolean => pinned.has(canonical(dir)); + // A folder mid-creation is as deliberate an act as opening one, and its agent + // does not exist yet, so it can never be an agent directory either. A folder + // with a LIVE session counts too: you are working in it right now, which is a + // stronger claim than any list of remembered paths. + // `status !== "exited"` is the SAME reading `bareSessionAt` uses, so the row + // the rail keeps and the session it offers cannot disagree. An absent status + // reads as not live: the only callers that omit it name a folder's recency, + // and a missing field must never silently keep a row. + const liveCwds = sessions + .filter((session) => session.status != null && session.status !== "exited") + .map((session) => session.cwd); + const chosen = new Set( + [...pendingCwds, ...recentDirs, ...liveCwds].map(canonical), + ); + const wasChosen = (dir: string): boolean => chosen.has(canonical(dir)); + const agentsUnder = (root: string): string[] => + agentPaths.filter( + (path) => isUnder(path, root) && canonical(path) !== canonical(root), + ); + + /** What each surviving root was DERIVED FROM, so a promoted row inherits the + * recency of the entry that produced it rather than sorting as an unknown. */ + const from = new Map(); + const kept: string[] = []; + const holds = (root: string): boolean => + kept.some((held) => canonical(held) === canonical(root)); + + // The folders the user CHOSE, unconditionally and in order. `recentDirs` is a + // list of deliberate acts; second-guessing it is how a rail starts hiding a + // project somebody opened on purpose. + for (const dir of candidates) { + if ( + // A durable binding preserves the identity of a root that is otherwise + // still live/selected; it is not itself a navigation choice. Keeping an + // exited session cwd solely because an old workspace scope pinned it + // resurrects projects the user explicitly removed. + !wasChosen(dir) || + (isAgentDir(dir) && !isPinned(dir)) + ) + continue; + kept.push(dir); + from.set(canonical(dir), dir); + } + + /* RULE 2, over the session-only folders, SHALLOWEST FIRST. + The order is load-bearing, not tidiness. Taken in candidate order an inner + folder is reached before the outer one that would have explained it, and so + keeps a row it does not need: a captured install kept + `harness-e2e/projects/research-micro-site-` as its own project and + then added `harness-e2e` above it, printing both of its agents twice. + Shallowest first means the outermost folder that explains an agent wins and + every folder below it is measured against a list that already holds it. */ + const sessionOnly = candidates + .filter((dir) => !wasChosen(dir) && !isAgentDir(dir)) + .sort( + (a, b) => + canonical(a).split("/").length - canonical(b).split("/").length || + a.localeCompare(b), + ); + for (const dir of sessionOnly) { + const under = agentsUnder(dir); + if (under.length === 0) continue; + if (under.every((path) => kept.some((root) => isUnder(path, root)))) + continue; + kept.push(dir); + from.set(canonical(dir), dir); + } + + // RULE 1, over the agent-rooted entries, in candidate order so the result is + // deterministic. `kept` grows as promotions land, so a later entry can be + // absorbed by an earlier one's promotion. + for (const dir of candidates) { + if (!isAgentDir(dir) || isPinned(dir)) continue; + if ( + kept.some( + (root) => isUnder(dir, root) && canonical(root) !== canonical(dir), + ) + ) + continue; + const root = holdingProjectFor(dir, { agentPaths, projects: kept }) ?? dir; + if (holds(root)) continue; + kept.push(root); + from.set(canonical(root), dir); + } + + const pendingRank = new Map( + pendingCwds.map((cwd, index) => [canonical(cwd), index]), + ); + const recentRank = new Map( + recentDirs.map((dir, index) => [canonical(dir), index]), + ); + /** Rank and recency are asked of the ENTRY a row came from, so a promoted + * parent sorts where the agent that produced it sorted. */ + const source = (root: string): string => from.get(canonical(root)) ?? root; + + const byRecency = (a: string, b: string): number => { + const ia = recentRank.get(canonical(source(a))) ?? -1; + const ib = recentRank.get(canonical(source(b))) ?? -1; + if (ia >= 0 && ib >= 0) return ia - ib; + if (ia >= 0 || ib >= 0) return ia >= 0 ? -1 : 1; + return (newestByCwd.get(canonical(source(b))) ?? "").localeCompare( + newestByCwd.get(canonical(source(a))) ?? "", + ); + }; + + return kept.sort((a, b) => { + // A folder mid-creation outranks everything, on either sort — the user's + // attention is on it. Among several pending folders, newest first. + const ra = pendingRank.get(canonical(source(a))); + const rb = pendingRank.get(canonical(source(b))); + if (ra !== undefined || rb !== undefined) { + if (ra !== undefined && rb !== undefined) return ra - rb; + return ra !== undefined ? -1 : 1; + } + if (sort === "name") + return basenameOf(a).localeCompare(basenameOf(b)) || a.localeCompare(b); + return byRecency(a, b) || a.localeCompare(b); + }); +} diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 596493871..e4790b10e 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -214,10 +214,15 @@ export interface HarnessSession { * answer the blocking prompt themselves. */ ready: boolean; - /** Trusted Studio-owned role metadata. Generic POST /sessions cannot set it. */ + /** + * @deprecated Legacy planner bootstrap metadata accepted only for rolling + * migration. It is never consulted for prompt, tool, sandbox, or authority. + */ planning?: import("./agent-map.js").PlannerSessionMetadata; + /** Durable lifecycle state for a new project's one automatic map seed. */ + projectBootstrap?: import("./agent-map.js").ProjectBootstrapMetadata; /** Server-authored, path-free identity used only to revalidate MCP scope. */ - agentMapIdentity?: import("./agent-map.js").PlanningSessionIdentity; + agentMapIdentity?: import("./agent-map.js").ProjectAgentSession; } /** @@ -829,18 +834,29 @@ export type AnalyticsEventType = | "agent_map.workspace_read_failed" | "agent_map.mcp_tool" | "agent_map.capability" + | "project_agent.identity_migrated" + | "project_agent.identity_rejected" + | "project_bootstrap.scheduled" + | "project_bootstrap.recovered" + | "project_bootstrap.attempted" + | "project_bootstrap.retried" + | "project_bootstrap.delivered" + | "project_bootstrap.failed" + | "project_bootstrap.preempted" + | "project_bootstrap.skipped" + | "project_bootstrap.input_delivery_uncertain" | "planner_session.created" | "planner_session.resumed" | "planner_session.input_delivery_uncertain" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap events. */ | "planner_greeting.attempted" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap events. */ | "planner_greeting.delivered" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap events. */ | "planner_greeting.failed" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap events. */ | "planner_greeting.skipped" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap events. */ | "planner_greeting.retried"; /** @@ -1089,6 +1105,12 @@ export interface CreateSessionRequest { harness: HarnessKind; /** Profile id; omit for default. */ profile?: string; + /** + * Content-free lifecycle hint: the UI already owns a real first input that + * will be delivered after readiness/attachments. A new-project bootstrap + * yields to that input instead of racing it. This never affects authority. + */ + initialUserInputPending?: boolean; /** * Portable continue: seed this fresh session with a reconstruction of a * prior one instead of asking the vendor to reattach. Accepts either a diff --git a/packages/harness/web/e2e/agent-map-planning.spec.ts b/packages/harness/web/e2e/agent-map-planning.spec.ts index 38b6ce05c..e37aaaf49 100644 --- a/packages/harness/web/e2e/agent-map-planning.spec.ts +++ b/packages/harness/web/e2e/agent-map-planning.spec.ts @@ -1,56 +1,78 @@ import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; -async function openDashboardMap(page: Page): Promise { - const group = page.getByTestId("workspace-group-dashboard-keeper"); - await expect(group.getByTestId("agent-map-row")).toBeVisible(); - await group.getByTestId("agent-map-select").click(); +import { selectMockSessionFromPalette } from "./mock-navigation"; + +async function openProjectMap(page: Page, label: string): Promise { + const group = page.getByTestId(`workspace-group-${label}`); + await group.getByTestId(`project-select-${label}`).click(); + await expect(group.getByTestId(`project-select-${label}`)).toHaveAttribute( + "aria-pressed", + "true", + ); } -async function activeSessionId(page: Page): Promise { - return page.getByTestId("session-context").getAttribute("data-session-id"); +const activeSessionId = (page: Page): Promise => + page.getByTestId("session-context").getAttribute("data-session-id"); + +interface NavigationEvidence { + activeSessionId: string | null; + createSessionCalls: number; + injectInputCalls: number; + openPlannerSessionCalls: number; } -async function openPlannerSessionCallCount(page: Page): Promise { - return page.evaluate( - () => - ( - window as unknown as { - __HARNESS_TEST__?: { openPlannerSessionCalls?: unknown[] }; - } - ).__HARNESS_TEST__?.openPlannerSessionCalls?.length ?? 0, - ); +async function navigationEvidence(page: Page): Promise { + const active = await activeSessionId(page); + return page.evaluate((activeSession) => { + const state = ( + window as unknown as { + __HARNESS_TEST__?: { + createSessionCalls?: unknown[]; + injectInputCalls?: unknown[]; + openPlannerSessionCalls?: unknown[]; + }; + } + ).__HARNESS_TEST__; + return { + activeSessionId: activeSession, + createSessionCalls: state?.createSessionCalls?.length ?? 0, + injectInputCalls: state?.injectInputCalls?.length ?? 0, + openPlannerSessionCalls: state?.openPlannerSessionCalls?.length ?? 0, + }; + }, active); } -test.describe("SAP-3058 Agent Map planning workspace", () => { +test.describe("SAP-3148 project Agent Map navigation", () => { test.beforeEach(async ({ page }) => { await page.goto("/?seed=0&mockFixtures=deep&mockStudioProjects=present"); await expect(page.locator(".rail-workflows")).toBeVisible(); }); - test("first open starts the raw planner CLI beside the honest empty map", async ({ + test("the project name opens the durable map without touching its active conversation", async ({ page, }) => { - await openDashboardMap(page); + const before = await navigationEvidence(page); + expect(before.activeSessionId).toBeTruthy(); - const terminal = page.locator(".harness-terminal"); - await expect(terminal).toBeVisible(); - await expect(terminal.locator(".xterm")).toBeVisible(); + await openProjectMap(page, "acme-app"); + + await expect(page.locator(".harness-terminal")).toBeVisible(); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); await expect(page.getByTestId("agent-map-empty")).toHaveText( "Nothing generated yet", ); + await expect(page.getByTestId("agent-map-row")).toHaveCount(0); + await expect(page.getByTestId("agent-map-select")).toHaveCount(0); + expect(await navigationEvidence(page)).toEqual(before); const [cli, map] = await Promise.all([ - terminal.boundingBox(), + page.locator(".harness-terminal").boundingBox(), page.getByTestId("agent-map-empty").boundingBox(), ]); expect(cli?.width ?? 0).toBeGreaterThan(200); expect(map?.width ?? 0).toBeGreaterThan(200); expect(map?.x ?? 0).toBeGreaterThan((cli?.x ?? 0) + (cli?.width ?? 0) - 2); - await page.screenshot({ - path: "web/e2e/screenshots/agent-map-planning.png", - fullPage: true, - }); await expect .poll(() => @@ -67,31 +89,184 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { .toContain("agent_map.entered"); }); - test("renders the stock-research proposal and a coding-agent follow-up live", async ({ + test("a project map never renders another project's active conversation", async ({ + page, + }) => { + const before = await navigationEvidence(page); + expect(before.activeSessionId).toBe("sess-boot"); + + await openProjectMap(page, "dashboard-keeper"); + + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.locator(".harness-terminal")).toHaveCount(0); + await expect(page.getByTestId("project-session-empty")).toContainText( + "No active session in this project", + ); + // Project-name navigation does not solve containment by selecting a + // different session. The foreign active ID stays untouched but its CLI is + // not mounted beside this project's map. + const after = await navigationEvidence(page); + expect(after).toMatchObject({ + createSessionCalls: before.createSessionCalls, + injectInputCalls: before.injectInputCalls, + openPlannerSessionCalls: before.openPlannerSessionCalls, + }); + await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(0); + expect( + await page.evaluate(() => + ( + ( + window as unknown as { + __HARNESS_TEST__?: { trackEvents?: Array<{ event: string }> }; + } + ).__HARNESS_TEST__?.trackEvents ?? [] + ).filter((event) => event.event === "session.switched"), + ), + ).toHaveLength(0); + }); + + test("the project name remains the map action on the Group axis", async ({ + page, + }) => { + const before = await navigationEvidence(page); + await page.getByTestId("history-trigger").click(); + await page.getByTestId("filing-group-by").selectOption("group"); + await page.keyboard.press("Escape"); + + await openProjectMap(page, "polsia"); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); + await expect(page.getByTestId("agent-map-row")).toHaveCount(0); + const after = await navigationEvidence(page); + expect(after).toMatchObject({ + createSessionCalls: before.createSessionCalls, + injectInputCalls: before.injectInputCalls, + openPlannerSessionCalls: before.openPlannerSessionCalls, + }); + }); + + test("neutral project identity owns restoration instead of a foreign bound Canvas path", async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockRestoreBindingConflict=1", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + + const acme = page + .getByTestId("workspace-group-acme-app") + .getByTestId("project-select-acme-app"); + await expect(acme).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + "sess-boot", + ); + const evidence = await navigationEvidence(page); + expect(evidence).toMatchObject({ + activeSessionId: "sess-boot", + createSessionCalls: 0, + injectInputCalls: 0, + openPlannerSessionCalls: 0, + }); + }); + + test("neutral project identity hands an overlapping nested-project agent to its own session", async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockNoLiveSessions=1&mockStudioProjects=present&mockAgentMapGolden=1", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + + await openProjectMap(page, "polsia"); + const outerProjectId = await page + .getByTestId("agent-map-live") + .getAttribute("data-project-id"); + await openProjectMap(page, "polsia/services/workers"); + const nestedProjectId = await page + .getByTestId("agent-map-live") + .getAttribute("data-project-id"); + expect(outerProjectId).toBeTruthy(); + expect(nestedProjectId).toBeTruthy(); + expect(nestedProjectId).not.toBe(outerProjectId); + + await page.evaluate( + ({ outerId, nestedId }) => { + const publish = ( + window as unknown as { + __HARNESS_TEST__?: { publish?: (message: unknown) => void }; + } + ).__HARNESS_TEST__?.publish; + const base = { + agentSessionId: null, + boundWorkflowPath: null, + harness: "claude-code" as const, + status: "running" as const, + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-01T00:00:00.000Z", + ready: true, + }; + publish?.({ + type: "session.status", + session: { + ...base, + id: "sess-overlap-outer", + cwd: "/Users/demo/polsia", + title: "Outer project session", + agentMapIdentity: { + projectId: outerId, + userId: "user_mock", + sessionId: "sess-overlap-outer", + }, + }, + }); + publish?.({ + type: "session.status", + session: { + ...base, + id: "sess-overlap-nested", + cwd: "/Users/demo/polsia/services/workers", + title: "Nested project session", + agentMapIdentity: { + projectId: nestedId, + userId: "user_mock", + sessionId: "sess-overlap-nested", + }, + }, + }); + }, + { outerId: outerProjectId!, nestedId: nestedProjectId! }, + ); + + await selectMockSessionFromPalette(page, "Outer project session"); + await expect.poll(() => activeSessionId(page)).toBe("sess-overlap-outer"); + + const nestedProject = page.getByTestId( + "workspace-group-polsia/services/workers", + ); + await nestedProject + .getByTestId("workflow-queue") + .locator(".workflow-item-trigger") + .click(); + + await expect.poll(() => activeSessionId(page)).toBe("sess-overlap-nested"); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("right-tab-canvas")).toContainText("Canvas"); + await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); + }); + + test("renders E2 structured state and applies attributed deltas without resetting the viewport", async ({ page, }) => { await page.goto( "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapGolden=1", ); await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); + await openProjectMap(page, "dashboard-keeper"); await expect(page.getByTestId("agent-map-live")).toBeVisible({ timeout: 1_000, }); - await expect - .poll(() => - page.evaluate(() => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { trackEvents?: Array<{ event: string }> }; - } - ).__HARNESS_TEST__?.trackEvents ?? [] - ).map((event) => event.event), - ), - ) - .toContain("agent_map.proposal_visible"); const nodes = page.locator("[data-proposal-state='proposed']"); await expect(nodes).toHaveCount(6); @@ -129,7 +304,7 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { const inspector = page.getByTestId("agent-map-inspector"); await expect(inspector).toContainText("Purpose"); await expect(inspector).toContainText("Contracts"); - await expect(inspector).toContainText("Map planner"); + await expect(inspector).toContainText("Project agent"); await page.getByRole("button", { name: "Close node details" }).click(); await expect(inspector).toHaveCount(0); await expect(researchReport).toBeFocused(); @@ -150,12 +325,12 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { .getAttribute("data-project-id"); expect(projectId).toBeTruthy(); await page.evaluate((activeProjectId) => { - const test = ( + const publish = ( window as unknown as { __HARNESS_TEST__?: { publish?: (message: unknown) => void }; } - ).__HARNESS_TEST__; - test?.publish?.({ + ).__HARNESS_TEST__?.publish; + publish?.({ type: "agent-map.proposal.changed", delta: { schemaVersion: 1, @@ -171,6 +346,8 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { changes: { name: "Campaign Marketing" }, }, ], + // E2's persisted attribution codec remains unchanged in SAP-3148; + // the UI deliberately projects it as one neutral project agent. actor: { userId: "user_mock", sessionId: "builder_mock", @@ -194,59 +371,18 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { await page.getByText("Campaign Marketing", { exact: true }).click(); await expect( page.getByTestId("agent-map-latest-attribution"), - ).toContainText("Agent builder · unplanned"); - await expect(page.locator("[data-proposal-state='proposed']")).toHaveCount( - 6, - ); - - await page.evaluate((activeProjectId) => { - const test = ( - window as unknown as { - __HARNESS_TEST__?: { publish?: (message: unknown) => void }; - } - ).__HARNESS_TEST__; - test?.publish?.({ - type: "agent-map.proposal.changed", - delta: { - schemaVersion: 1, - projectId: activeProjectId, - proposalId: "proposal_00000000-0000-7000-8000-000000000101", - fromVersion: 2, - version: 3, - operationIds: ["operation_00000000-0000-7000-8000-000000000402"], - operations: [ - { - kind: "update-node", - nodeId: "node_00000000-0000-7000-8000-000000000101", - changes: { name: "Equity Research" }, - }, - ], - actor: { - userId: "user_mock", - sessionId: "planner_mock", - role: "map-planner", - assignment: null, - }, - acceptedAt: new Date().toISOString(), - }, - }); - }, projectId); - await expect( - page.getByText("Equity Research", { exact: true }), - ).toBeVisible(); - await expect( - page.getByTestId("agent-map-latest-attribution"), - ).toContainText("Agent builder · unplanned"); + ).toContainText("Project agent"); + await expect(nodes).toHaveCount(6); }); - test("expands the Agent Map in place and unwinds its inspector before full view", async ({ + test("expands the production Agent Map and unwinds its inspector before full view", async ({ page, }) => { await page.goto( "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapGolden=1", ); await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); + await openProjectMap(page, "dashboard-keeper"); const map = page.getByTestId("agent-map-live"); await expect(map).toBeVisible(); @@ -283,312 +419,226 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { await expect(frame).not.toHaveClass(/is-expanded/); await expect(page.getByTestId("resize-handle-rail")).toBeVisible(); await expect(page.getByTestId("resize-handle-canvas")).toBeVisible(); - - await expand.click(); - await page.getByTestId("canvas-expand-exit").click(); - await expect(frame).not.toHaveClass(/is-expanded/); }); - test("a generating greeting still renders the raw planner CLI", async ({ + test("Plan Agents and every sibling are ordinary exact-session tabs", async ({ page, }) => { await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockGreeting=generating", + "/?seed=0&mockStudioProjects=present&mockPlanAgentsSession=1", ); await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - - const terminal = page.locator(".harness-terminal"); - await expect(terminal).toBeVisible(); - await expect(terminal.locator(".xterm")).toBeVisible(); - }); - - test("return resumes the same planner and plus creates a fresh planner tab", async ({ - page, - }) => { - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); - const first = await activeSessionId(page); - expect(first).toBeTruthy(); + await openProjectMap(page, "acme-app"); + + const planAgents = page.getByTestId("session-tab-main-sess-boot"); + const sibling = page.getByTestId("session-tab-main-sess-leasing-2"); + await expect(planAgents).toHaveText(/Plan Agents/); + await expect(page.getByText("Plan Agents", { exact: true })).toHaveCount(1); + await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(1); + await expect(page.getByTestId("session-tab-sess-leasing-2")).toHaveCount(1); + await expect(page.getByTestId("session-tab-sess-bg")).toHaveCount(0); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + + const mapTabIds = await page + .locator(".session-tabs-list > .session-tab") + .evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute("data-testid")).filter(Boolean), + ); + + // A repeated status projection for the same durable session replaces its + // row; it cannot manufacture a second user-visible tab. + await page.evaluate(() => { + const publish = ( + window as unknown as { + __HARNESS_TEST__?: { publish?: (message: unknown) => void }; + } + ).__HARNESS_TEST__?.publish; + publish?.({ + type: "session.status", + session: { + id: "sess-boot", + agentSessionId: null, + boundWorkflowPath: "/Users/demo/acme-app/leasing", + harness: "claude-code", + cwd: "/Users/demo/acme-app", + title: "Plan Agents", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: new Date().toISOString(), + ready: true, + agentMapIdentity: { + projectId: "project_00000000-0000-4000-8000-000000000001", + userId: "user_mock", + sessionId: "sess-boot", + }, + }, + }); + }); + await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(1); - await page - .getByTestId("workflow-dashboard-keeper") - .locator("button") - .click(); - // Moving down from the project map to an agent changes only the right-hand - // subject. The coding-agent CLI stays mounted in the centre and the - // selected agent's canvas/step graph becomes available beside it. - await expect(page.locator(".harness-terminal")).toBeVisible(); + await planAgents.click(); + await expect.poll(() => activeSessionId(page)).toBe("sess-boot"); + await expect + .poll(() => + page.evaluate(() => + ( + ( + window as unknown as { + __HARNESS_TEST__?: { + trackEvents?: Array<{ + event: string; + data?: { navigation_kind?: string }; + harnessSessionId?: string; + }>; + }; + } + ).__HARNESS_TEST__?.trackEvents ?? [] + ).some( + (event) => + event.event === "session.switched" && + event.data?.navigation_kind === "session_tab" && + event.harnessSessionId === "sess-boot", + ), + ), + ) + .toBe(true); + await expect(page.getByTestId("agent-view")).toBeVisible(); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); await expect(page.getByTestId("right-tab-canvas")).toContainText("Canvas"); await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); await expect(page.locator(".canvas-iframe")).toBeVisible(); - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.getByTestId("right-tab-canvas")).toContainText( - "Agent Map", - ); - await expect(page.getByTestId("right-tab-steps")).toBeDisabled(); - expect(await activeSessionId(page)).toBe(first); - - await page.getByTestId("session-tab-new").click(); - await expect.poll(() => activeSessionId(page)).not.toBe(first); - const second = await activeSessionId(page); - await expect( - page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"), - ).toHaveCount(2); - await expect(page.locator(".harness-terminal")).toBeVisible(); - - await page.getByTestId("session-menu").click(); - await page.getByTestId("session-end-btn").click(); - await page.getByTestId("end-session-confirm-btn").click(); - expect(second).not.toBe(first); - await expect.poll(() => activeSessionId(page)).toBe(first); - await expect( - page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"), - ).toHaveCount(1); - await expect(page.locator(".harness-terminal")).toBeVisible(); - }); - - test("an explicitly selected planner tab wins over project resume ordering", async ({ - page, - }) => { - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); - const first = await activeSessionId(page); - expect(first).toBeTruthy(); - - await page.getByTestId("session-menu").click(); - await page.getByTestId("session-rename").click(); - const rename = page.getByTestId("session-rename-input"); - await rename.fill("Planner A"); - await rename.press("Enter"); - - await page.getByTestId("session-tab-new").click(); - await expect.poll(() => activeSessionId(page)).not.toBe(first); - const callsBeforeExplicitSelection = - await openPlannerSessionCallCount(page); - - await page - .getByTestId("workflow-dashboard-keeper") - .locator("button") - .click(); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.getByTestId("right-tab-canvas")).toContainText("Canvas"); - await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); - - await page.getByTestId("palette-trigger").click(); - await page.getByTestId("command-palette-input").fill("Planner A"); - await page - .getByTestId("command-palette-list") - .getByText("Planner A", { exact: true }) - .click(); - - await expect(page.locator(".harness-terminal")).toBeVisible(); - expect(await activeSessionId(page)).toBe(first); - await expect(page.getByTestId("planner-loading")).toHaveCount(0); - expect(await openPlannerSessionCallCount(page)).toBe( - callsBeforeExplicitSelection, - ); - }); - - test("planner session chrome retains rename/end and omits path/editor actions", async ({ - page, - }) => { - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); + expect( + await page + .locator(".session-tabs-list > .session-tab") + .evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute("data-testid")).filter(Boolean), + ), + ).toEqual(mapTabIds); await page.getByTestId("session-menu").click(); const menu = page.getByTestId("session-menu-popover"); + await expect(menu.getByText("Copy path", { exact: true })).toBeVisible(); await expect(menu.getByTestId("session-rename")).toBeVisible(); + await expect(menu.getByTestId("session-open-editor")).toBeVisible(); await expect(menu.getByTestId("session-end-btn")).toBeVisible(); - await expect(menu.getByText("Copy path", { exact: true })).toHaveCount(0); - await expect(menu.getByTestId("session-open-editor")).toHaveCount(0); - }); - - test("ending the planner exposes an immediate fresh-session path", async ({ - page, - }) => { - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); - const ended = await activeSessionId(page); + await page.keyboard.press("Escape"); - await page.getByTestId("session-menu").click(); - await page.getByTestId("session-end-btn").click(); - await expect(page.getByTestId("end-session-confirm")).toContainText( - "stops the planning conversation", - ); - await expect(page.getByTestId("end-session-confirm")).toContainText( - "fresh planning session from Plan Agents", - ); - await expect(page.getByTestId("end-session-confirm")).not.toContainText( - "live terminal", - ); - await page.getByTestId("end-session-confirm-btn").click(); + await sibling.click(); + await expect.poll(() => activeSessionId(page)).toBe("sess-leasing-2"); + await expect(page.getByTestId("agent-view")).toBeVisible(); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); - await expect(page.getByTestId("planner-session-ended")).toBeVisible(); - const startFresh = page.getByTestId("session-tab-new"); - await expect(startFresh).toHaveAttribute( - "aria-label", - "New planning session", - ); - await startFresh.click(); - - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect.poll(() => activeSessionId(page)).not.toBe(ended); + await openProjectMap(page, "acme-app"); + await expect.poll(() => activeSessionId(page)).toBe("sess-leasing-2"); + await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(1); + await expect(page.getByTestId("session-tab-sess-leasing-2")).toHaveCount(1); }); - test("a failed greeting still leaves the raw planner CLI visible", async ({ + test("an exited active session never mounts dead-session chrome over the map", async ({ page, }) => { await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockGreeting=failed", + "/?seed=0&mockStudioProjects=present&mockPlanAgentsSession=1", ); await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); + await openProjectMap(page, "acme-app"); + + await page.evaluate(() => { + const publish = ( + window as unknown as { + __HARNESS_TEST__?: { publish?: (message: unknown) => void }; + } + ).__HARNESS_TEST__?.publish; + publish?.({ + type: "session.status", + session: { + id: "sess-boot", + agentSessionId: null, + boundWorkflowPath: "/Users/demo/acme-app/leasing", + harness: "claude-code", + cwd: "/Users/demo/acme-app", + title: "Plan Agents", + status: "exited", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: new Date().toISOString(), + exitCode: 0, + ready: false, + agentMapIdentity: { + projectId: "project_00000000-0000-4000-8000-000000000001", + userId: "user_mock", + sessionId: "sess-boot", + }, + }, + }); + }); + + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.getByTestId("dead-session-pane")).toHaveCount(0); + await expect(page.getByTestId("project-session-empty")).toBeVisible(); + await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(0); + await expect(page.getByTestId("session-tab-sess-leasing-2")).toHaveCount(1); + await expect(page.getByTestId("session-tab-new")).toHaveCount(1); + await expect(page.getByTestId("planner-session-ended")).toHaveCount(0); + await expect( + page.getByText("New planning session", { exact: true }), + ).toHaveCount(0); - const terminal = page.locator(".harness-terminal"); - await expect(terminal).toBeVisible(); - await expect(terminal.locator(".xterm")).toBeVisible(); + await page.getByTestId("session-tab-main-sess-leasing-2").click(); + await expect.poll(() => activeSessionId(page)).toBe("sess-leasing-2"); + await expect(page.getByTestId("dead-session-pane")).toHaveCount(0); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("agent-view")).toBeVisible(); }); - test("workspace and planner failures stay local, while unauthorized is whole-workspace", async ({ + test("map failures stay in the map pane and never replace the conversation", async ({ page, }) => { await page.goto( "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapWorkspace=error", ); await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); + const before = await navigationEvidence(page); + await openProjectMap(page, "acme-app"); await expect(page.locator(".harness-terminal")).toBeVisible(); await expect(page.getByTestId("agent-map-load-error")).toBeVisible(); - await expect - .poll(() => - page.evaluate(() => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { - trackEvents?: Array<{ - event: string; - data?: Record; - }>; - }; - } - ).__HARNESS_TEST__?.trackEvents ?? [] - ).some( - (event) => - event.event === "agent_map.workspace_load_failed" && - event.data?.pane === "map", - ), - ), - ) - .toBe(true); - - await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockPlanner=error", - ); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - await expect(page.getByTestId("planner-load-error")).toBeVisible(); - await expect(page.getByTestId("agent-map-empty")).toBeVisible(); - await expect - .poll(() => - page.evaluate(() => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { - trackEvents?: Array<{ - event: string; - data?: Record; - }>; - }; - } - ).__HARNESS_TEST__?.trackEvents ?? [] - ).some( - (event) => - event.event === "agent_map.workspace_load_failed" && - event.data?.pane === "planner", - ), - ), - ) - .toBe(true); + expect(await navigationEvidence(page)).toEqual(before); await page.goto( "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapWorkspace=unauthorized", ); await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - await expect(page.getByTestId("agent-map-unavailable")).toBeVisible(); - await expect(page.locator(".right-pane")).toBeHidden(); - await expect(page.getByTestId("resize-handle-canvas")).toHaveCount(0); + await openProjectMap(page, "acme-app"); + await expect(page.locator(".harness-terminal")).toBeVisible(); + await expect(page.getByTestId("agent-map-load-error")).toBeVisible(); }); }); -test.describe("SAP-3058 mobile Agent Map", () => { +test.describe("SAP-3148 mobile Agent Map", () => { test.use({ viewport: { width: 375, height: 812 } }); - test("the raw CLI stays primary and the explicit sheet restores focus on close", async ({ + test("the project name opens the map sheet without replacing the CLI", async ({ page, }) => { await page.goto("/?seed=0&mockFixtures=deep&mockStudioProjects=present"); await expect(page.getByTestId("rail-expand")).toBeVisible(); + const before = await navigationEvidence(page); await page.getByTestId("rail-expand").click(); - await openDashboardMap(page); + // Selecting a mobile rail destination closes the drawer, so its pressed + // state is intentionally no longer mounted after this click. + await page + .getByTestId("workspace-group-acme-app") + .getByTestId("project-select-acme-app") + .click(); await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.locator(".right-pane")).toBeHidden(); - await expect(page.getByTestId("session-menu")).toBeVisible(); - const openMap = page.getByTestId("right-expand"); - await expect(openMap).toHaveText("Agent Map"); - - await openMap.click(); await expect(page.locator(".right-pane")).toBeVisible(); await expect(page.getByTestId("agent-map-empty")).toBeVisible(); await expect(page.getByTestId("right-sheet-scrim")).toBeVisible(); - - await page.keyboard.press("Control+K"); - await expect(page.getByTestId("command-palette-input")).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(page.getByTestId("command-palette-input")).toHaveCount(0); - await expect(page.locator(".right-pane")).toBeVisible(); + expect(await navigationEvidence(page)).toEqual(before); await page.getByTestId("right-collapse").click(); await expect(page.locator(".right-pane")).toBeHidden(); await expect(page.getByTestId("right-expand")).toBeFocused(); - await page.screenshot({ - path: "web/e2e/screenshots/agent-map-planning-mobile.png", - fullPage: true, - }); - }); - - test("a failed preference restore cannot repeatedly close the map sheet", async ({ - page, - }) => { - await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockStudioPreference=error", - ); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.locator(".right-pane")).toBeHidden(); - - // The mock preference read has one 180 ms round trip. Let its one-shot - // failure settle before the user explicitly opens the sheet; the assertion - // below then guards against later session updates closing it again. - await page.waitForTimeout(250); - const first = await activeSessionId(page); - const newSession = page.getByTestId("session-tab-new"); - await expect(newSession).toBeEnabled(); - await newSession.click(); - await expect.poll(() => activeSessionId(page)).not.toBe(first); - - // Open the sheet while the new planner is still launching. Its automatic - // ready/status event arrives later and must not replay the failed preference - // restore or collapse the user-opened Agent Map. - await page.getByTestId("right-expand").click(); - await expect(page.locator(".right-pane")).toBeVisible(); - await expect( - page.locator(".session-dot[data-status='running']"), - ).toBeVisible(); - await expect(page.locator(".right-pane")).toBeVisible(); + await expect(page.getByTestId("right-expand")).toHaveText("Agent Map"); }); }); diff --git a/packages/harness/web/e2e/new-session-composer.spec.ts b/packages/harness/web/e2e/new-session-composer.spec.ts index b04986a1f..1a8857b1b 100644 --- a/packages/harness/web/e2e/new-session-composer.spec.ts +++ b/packages/harness/web/e2e/new-session-composer.spec.ts @@ -107,14 +107,14 @@ test("describing an outcome starts a session and hands the agent that outcome", .toContain("Diff our competitors' pricing pages"); }); -test("Enter keeps a new-agent prompt in its standalone builder until Plan Agents is explicitly selected", async ({ +test("Enter keeps a new-agent prompt in its exact session while the project map is inspected", async ({ page, }) => { await page.goto("/?seed=0&mockNoLiveSessions=1&mockStudioProjects=present"); await expect(page.locator(".rail-workflows")).toBeVisible(); - // The parent project exists, but with no live session it has never restored - // its default Plan Agents workspace. Creating beneath it must not give that - // parent restore a head start over the explicit standalone builder intent. + // The parent project exists, but has no live session or restored workspace. + // Creating beneath it must not give preference restoration a head start over + // the explicit standalone-session intent. await expect(page.getByTestId("workspace-group-acme-app")).toBeVisible(); const before = await sessionEvidence(page); expect(before.activeSessionId).toBeNull(); @@ -124,8 +124,48 @@ test("Enter keeps a new-agent prompt in its standalone builder until Plan Agents await page.getByTestId("rail-create-new").click(); const idea = "Build a sales outreach agent."; await page.getByTestId("composer-input").fill(idea); + await page.evaluate(() => { + // Hold the explicit create open long enough for the project's automatic + // first session to arrive and become active first. + ( + window as unknown as { __MOCK_CREATE_SESSION_DELAY_MS__?: number } + ).__MOCK_CREATE_SESSION_DELAY_MS__ = 2_000; + }); await page.getByTestId("composer-input").press("Enter"); + await page.evaluate(() => { + const publish = ( + window as unknown as { + __HARNESS_TEST__?: { publish?: (message: unknown) => void }; + } + ).__HARNESS_TEST__?.publish; + publish?.({ + type: "session.status", + session: { + id: "sess-competing-plan-agents", + agentSessionId: null, + boundWorkflowPath: null, + harness: "claude-code", + cwd: "/Users/demo/acme-app/projects/build-sales-outreach", + title: "Plan Agents", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-01T00:00:00.000Z", + ready: true, + agentMapIdentity: { + projectId: "project_ffffffff-ffff-4fff-8fff-ffffffffffff", + userId: "user_mock", + sessionId: "sess-competing-plan-agents", + }, + }, + }); + }); + await selectMockSessionFromPalette(page, "Plan Agents"); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + "sess-competing-plan-agents", + ); + await expect(page.getByTestId("new-session-composer")).toHaveCount(0); await expect .poll(async () => (await sessionEvidence(page)).injectedText) @@ -135,38 +175,57 @@ test("Enter keeps a new-agent prompt in its standalone builder until Plan Agents expect(evidence.createSessionCalls).toBe(before.createSessionCalls + 1); expect(evidence.openPlannerSessionCalls).toBe(before.openPlannerSessionCalls); expect(evidence.injectedSessionId).not.toBeNull(); + expect(evidence.injectedSessionId).not.toBe("sess-competing-plan-agents"); expect(evidence.activeSessionId).toBe(evidence.injectedSessionId); expect(evidence.activeSessionId).not.toBe(before.activeSessionId); expect(evidence.injectInputCalls).toBe(before.injectInputCalls + 1); + expect( + await page.evaluate( + () => + ( + window as unknown as { + __HARNESS_TEST__?: { + lastCreateSession?: { + req?: { initialUserInputPending?: boolean }; + }; + }; + } + ).__HARNESS_TEST__?.lastCreateSession?.req?.initialUserInputPending, + ), + ).toBe(true); const project = page.getByTestId( "workspace-group-acme-app/projects/build-sales-outreach", ); - const planAgents = project.getByTestId("agent-map-select"); - await expect(planAgents).toHaveAttribute("aria-pressed", "false"); + const projectMap = project.getByTestId( + "project-select-acme-app/projects/build-sales-outreach", + ); + await expect(projectMap).toHaveAttribute("aria-pressed", "false"); + await expect(project.getByTestId("agent-map-row")).toHaveCount(0); - await planAgents.click(); - await expect - .poll(async () => (await sessionEvidence(page)).openPlannerSessionCalls) - .toBe(before.openPlannerSessionCalls + 1); - await expect(planAgents).toHaveAttribute("aria-pressed", "true"); + const beforeMap = await sessionEvidence(page); + await projectMap.click(); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(projectMap).toHaveAttribute("aria-pressed", "true"); + expect(await sessionEvidence(page)).toEqual(beforeMap); + + await page + .getByTestId(`session-tab-main-${evidence.activeSessionId}`) + .click(); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + expect((await sessionEvidence(page)).activeSessionId).toBe( + evidence.activeSessionId, + ); }); -test("returning to an in-progress standalone builder does not restore Plan Agents", async ({ +test("returning to an in-progress standalone session does not restore the project map", async ({ page, }) => { await page.goto("/?seed=0&mockStudioProjects=present"); await expect(page.locator(".rail-workflows")).toBeVisible(); - await expect - .poll(async () => (await sessionEvidence(page)).openPlannerSessionCalls) - .toBeGreaterThan(0); - await expect - .poll(async () => { - const evidence = await sessionEvidence(page); - return evidence.createSessionCalls - evidence.openPlannerSessionCalls; - }) - .toBe(0); const before = await sessionEvidence(page); + expect(before.createSessionCalls).toBe(0); + expect(before.openPlannerSessionCalls).toBe(0); await page.getByTestId("rail-create-new").click(); const idea = "Build a revisit guard agent."; @@ -192,7 +251,8 @@ test("returning to an in-progress standalone builder does not restore Plan Agent .poll(async () => (await sessionEvidence(page)).injectedText) .toContain(idea); // Let the session we deliberately visited finish its own normal restore; - // only planner work caused by returning to the builder is under test. + // only map restoration caused by returning to the explicit session is under + // test. await page.waitForTimeout(500); const beforeReturn = await sessionEvidence(page); @@ -210,8 +270,9 @@ test("returning to an in-progress standalone builder does not restore Plan Agent await expect( page .getByTestId("workspace-group-acme-app/projects/build-revisit-guard") - .getByTestId("agent-map-select"), + .getByTestId("project-select-acme-app/projects/build-revisit-guard"), ).toHaveAttribute("aria-pressed", "false"); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); }); test("a picked file reaches the first request without naming the project", async ({ @@ -637,7 +698,9 @@ for (const agent of [ // Make the next session never reach ready on its own — the stand-in for a // user still on an agent's login, trust, or onboarding screen. await page.addInitScript(() => { - (window as unknown as { __MOCK_WITHHOLD_READY__?: boolean }).__MOCK_WITHHOLD_READY__ = true; + ( + window as unknown as { __MOCK_WITHHOLD_READY__?: boolean } + ).__MOCK_WITHHOLD_READY__ = true; }); await page.goto("/?seed=0"); await expect(page.locator(".rail-workflows")).toBeVisible(); @@ -646,7 +709,9 @@ for (const agent of [ if (agent.id === "codex") { await page.getByTestId("composer-harness-select").click(); await page.getByTestId("composer-harness-option-codex").click(); - await expect(page.getByTestId("composer-harness-select")).toContainText("Codex"); + await expect(page.getByTestId("composer-harness-select")).toContainText( + "Codex", + ); } const prompt = `Summarise my ${agent.label} inbox every morning.`; await page.getByTestId("composer-input").fill(prompt); @@ -661,7 +726,9 @@ for (const agent of [ () => ( window as unknown as { - __HARNESS_TEST__?: { lastCreateSession?: { req?: { harness?: string } } }; + __HARNESS_TEST__?: { + lastCreateSession?: { req?: { harness?: string } }; + }; } ).__HARNESS_TEST__?.lastCreateSession?.req?.harness, ); @@ -679,7 +746,9 @@ for (const agent of [ // not inject the held intent a second time. await page.evaluate(() => ( - window as unknown as { __HARNESS_TEST__?: { promoteReady?: () => void } } + window as unknown as { + __HARNESS_TEST__?: { promoteReady?: () => void }; + } ).__HARNESS_TEST__?.promoteReady?.(), ); await expect.poll(() => lastInjectText(page)).toContain(prompt); @@ -687,7 +756,9 @@ for (const agent of [ await page.evaluate(() => ( - window as unknown as { __HARNESS_TEST__?: { promoteReady?: () => void } } + window as unknown as { + __HARNESS_TEST__?: { promoteReady?: () => void }; + } ).__HARNESS_TEST__?.promoteReady?.(), ); await page.waitForTimeout(500); diff --git a/packages/harness/web/e2e/open-project.spec.ts b/packages/harness/web/e2e/open-project.spec.ts index 55ed9f16c..37f80d93d 100644 --- a/packages/harness/web/e2e/open-project.spec.ts +++ b/packages/harness/web/e2e/open-project.spec.ts @@ -46,6 +46,8 @@ test.describe("the header + opens a project", () => { test("a folder with NO agent in it becomes a project row, and survives a reload", async ({ page, }) => { + await page.goto("/?mockFixtures=agent-map&mockAutoPlanAgents=1"); + await expect(page.locator(".rail-workflows")).toBeVisible(); await expect(page.getByTestId("project-row-blank-slate")).toHaveCount(0); await page.getByTestId("rail-add-project").click(); @@ -68,15 +70,53 @@ test.describe("the header + opens a project", () => { await expect(page.getByTestId("project-row-blank-slate")).toBeVisible(); const group = page.getByTestId("workspace-group-blank-slate"); - // First visit lands on the pinned Agent Map without a label click. - await expect(group.getByTestId("agent-map-row")).toBeVisible(); - await expect(group.getByTestId("agent-map-select")).toHaveAttribute( - "aria-pressed", - "true", - ); + // The project itself owns its read-only Agent Map destination; there is no + // pinned planning row masquerading as a session. + await expect(group.getByTestId("agent-map-row")).toHaveCount(0); + await expect( + group.getByTestId("project-select-blank-slate"), + ).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("agent-map-empty")).toHaveText( "Nothing generated yet", ); + // The server-owned project-open lifecycle contributes one real ordinary + // session. Plan Agents is only that tab's initial title—never a pinned row + // or a second synthetic navigation element. + const tabs = page.locator(".session-tabs-list > .session-tab"); + await expect(tabs).toHaveCount(1); + await expect(page.getByText("Plan Agents", { exact: true })).toHaveCount(1); + const firstSessionId = ( + await tabs.first().getAttribute("data-testid") + )?.replace("session-tab-", ""); + expect(firstSessionId).toBeTruthy(); + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + __HARNESS_TEST__?: { createSessionCalls?: unknown[] }; + } + ).__HARNESS_TEST__?.createSessionCalls?.length ?? 0, + ), + ) + .toBe(0); + + await page.getByTestId(`session-tab-main-${firstSessionId}`).click(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + firstSessionId!, + ); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("agent-view")).toBeVisible(); + + await group.getByTestId("project-select-blank-slate").click(); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + firstSessionId!, + ); + await expect(tabs).toHaveCount(1); // The row is REMEMBERED, not just rendered: `recentDirs` is the harness's // one workspace list, and the whole rail re-derives from it when the axis // changes. (A cross-RELOAD assertion belongs against a real server — the @@ -88,7 +128,7 @@ test.describe("the header + opens a project", () => { await expect(page.getByTestId("project-row-blank-slate")).toBeVisible(); }); - test("a planning project keeps sessions direct while hiding standalone agent creation", async ({ + test("a Studio project keeps ordinary session and agent creation available", async ({ page, }) => { await page.getByTestId("rail-add-project").click(); @@ -96,37 +136,28 @@ test.describe("the header + opens a project", () => { await page.getByTestId("open-project").click(); const group = page.getByTestId("workspace-group-blank-slate"); - await expect(group.getByTestId("agent-map-row")).toBeVisible(); + await expect(group.getByTestId("agent-map-row")).toHaveCount(0); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); - // Once the initial planner is no longer a live bare session, the old rail - // exposed its standalone-agent shortcut again. Ending it reproduces the - // stable empty-project state from the reported sidebar. - await page.getByTestId("session-menu").click(); - await page.getByTestId("session-end-btn").click(); - await page.getByTestId("end-session-confirm-btn").click(); - await expect(page.getByTestId("planner-session-ended")).toBeVisible(); - - await expect(group.getByTestId("project-empty-blank-slate")).toHaveCount(0); + await expect(group.getByTestId("project-empty-blank-slate")).toBeVisible(); await expect( group.getByTestId("project-start-session-blank-slate"), ).toHaveAttribute("aria-label", "Start a session in blank-slate"); - // The visible empty row and the project menu used to be two doors into the - // same direct-create flow. A planning project exposes neither: generated - // agents enter through an approved map, while project removal remains an - // ordinary project-level action. + // The map is a view, not an authorization gate. Both direct agent creation + // and project removal remain ordinary project-level actions. await openProjectMenu(page, "blank-slate"); await expect( page.getByTestId("project-create-agent-blank-slate"), - ).toHaveCount(0); + ).toBeVisible(); await expect(page.getByTestId("project-remove-blank-slate")).toBeVisible(); await page.keyboard.press("Escape"); - // The same ownership rule covers a bare project with an existing session: - // its former in-session scaffold action cannot bypass planning either. + // A bare project with an existing ordinary session retains its scaffold + // action too. await openProjectMenu(page, "scratch"); - await expect(page.getByTestId("workspace-scaffold-scratch")).toHaveCount(0); + await expect(page.getByTestId("workspace-scaffold-scratch")).toBeVisible(); await expect(page.getByTestId("project-remove-scratch")).toBeVisible(); // NOT the rail-wide empty state leaking down: that one says "No agents yet" diff --git a/packages/harness/web/e2e/polsia-workspace.spec.ts b/packages/harness/web/e2e/polsia-workspace.spec.ts index 54449dafa..d91d2a2f9 100644 --- a/packages/harness/web/e2e/polsia-workspace.spec.ts +++ b/packages/harness/web/e2e/polsia-workspace.spec.ts @@ -116,13 +116,10 @@ test("a Polsia-style Project opens its complete graph without a session and reus await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); await expect(page.locator(".harness-terminal")).toBeVisible(); await expect(page.getByTestId("open-agent-empty")).toHaveCount(0); - // The way back UP is derived from containment, so it exists on an agent - // reached from the map exactly as it does on one reached from the rail. - await expect(page.getByTestId("canvas-altitude-up")).toHaveAttribute( - "aria-label", - "Back to the polsia map", - ); - await page.getByTestId("canvas-altitude-up").click(); + // Returning to the map uses the project name; there is no second right-pane + // navigation route competing with that contract. + await expect(page.getByTestId("canvas-altitude-up")).toHaveCount(0); + await page.getByTestId("project-select-polsia").click(); await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); await expect.poll(() => graphRequestCount(page)).toBe(1); }); diff --git a/packages/harness/web/e2e/project-altitude.spec.ts b/packages/harness/web/e2e/project-altitude.spec.ts index 6bf7a645b..cb23e82dc 100644 --- a/packages/harness/web/e2e/project-altitude.spec.ts +++ b/packages/harness/web/e2e/project-altitude.spec.ts @@ -122,15 +122,23 @@ test("E3.3 — the tab strip is the PROJECT's sessions, including the ones bound // Both, in tab order (oldest first — the order Cmd/Ctrl+1..9 selects). const tabs = page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"); await expect(tabs).toHaveCount(2); - await expect(page.getByTestId("session-tab-sess-polsia-mailer")).toBeVisible(); - await expect(page.getByTestId("session-tab-sess-polsia-rollup")).toBeVisible(); + await expect( + page.getByTestId("session-tab-sess-polsia-mailer"), + ).toBeVisible(); + await expect( + page.getByTestId("session-tab-sess-polsia-rollup"), + ).toBeVisible(); // And they stay put when the selection drops to one agent's board: the tabs // are the PROJECT's, so a sibling selection cannot re-key them (E3.4). - await page.getByTestId("system-graph-node-local:scripts/tools/rollup").click(); + await page + .getByTestId("system-graph-node-local:scripts/tools/rollup") + .click(); await expect(page.getByTestId("workflow-rollup")).toHaveClass(/is-focused/); await expect(tabs).toHaveCount(2); - await expect(page.getByTestId("session-tab-sess-polsia-mailer")).toBeVisible(); + await expect( + page.getByTestId("session-tab-sess-polsia-mailer"), + ).toBeVisible(); }); test("E3.4 — selecting a sibling agent moves the right pane and NOTHING else", async ({ @@ -247,7 +255,7 @@ test("E3.5 — crossing to another project DOES hand the conversation over", asy ); }); -test("E3.7/E3.8 — drilling from a map node cuts down and the way back cuts up, rail following both", async ({ +test("E3.7/E3.8 — drilling from a map node cuts down and only the project name returns to the map", async ({ page, }) => { await page.getByTestId("project-select-acme-app").click(); @@ -268,9 +276,8 @@ test("E3.7/E3.8 — drilling from a map node cuts down and the way back cuts up, ); await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); - const up = page.getByTestId("canvas-altitude-up"); - await expect(up).toHaveAttribute("aria-label", "Back to the acme-app map"); - await up.click(); + await expect(page.getByTestId("canvas-altitude-up")).toHaveCount(0); + await page.getByTestId("project-select-acme-app").click(); // ...and back, with the rail agreeing again. await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); @@ -340,9 +347,17 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a project selected over an EXITED session gave the strip the project's tabs and the handler the exited session's own subject, and Cmd+1 activated a session that was not tab 1. */ - await page.goto("/?seed=0&mockFixtures=deep&mockNoLiveSessions=1"); + await page.goto( + "/?seed=0&mockFixtures=deep&mockNoLiveSessions=1&mockStudioProjects=present&mockAgentMapGolden=1", + ); await expect(page.getByTestId("workspace-group-polsia")).toBeVisible(); - await page.evaluate(() => { + await page.getByTestId("project-select-polsia").click(); + await expect(page.getByTestId("agent-map-live")).toBeVisible(); + const projectId = await page + .getByTestId("agent-map-live") + .getAttribute("data-project-id"); + expect(projectId).toBeTruthy(); + await page.evaluate((selectedProjectId) => { const publish = ( window as unknown as { __HARNESS_TEST__?: { @@ -367,6 +382,11 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a title: "mailer", createdAt: "2026-08-01T10:00:00.000Z", lastActiveAt: "2026-08-01T10:00:00.000Z", + agentMapIdentity: { + projectId: selectedProjectId, + userId: "user_mock", + sessionId: "sess-polsia-1", + }, }, }); publish?.({ @@ -378,12 +398,16 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a title: "rollup", createdAt: "2026-08-01T11:00:00.000Z", lastActiveAt: "2026-08-01T11:00:00.000Z", + agentMapIdentity: { + projectId: selectedProjectId, + userId: "user_mock", + sessionId: "sess-polsia-2", + }, }, }); - }); + }, projectId!); - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); const tabs = page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"); await expect(tabs).toHaveCount(2); @@ -398,15 +422,17 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a "data-session-id", "sess-polsia-1", ); - // ...and the project is still the subject: a number key is a tab jump, not a - // navigation out of the project the tabs belong to. - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); + // A number key is exact conversation navigation, just like clicking the + // corresponding tab. The project map therefore gives way to that ordinary + // session's Canvas/Steps experience while the project-wide tab set remains. + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("agent-view")).toBeVisible(); + await expect(page.getByTestId("right-tab-canvas")).toBeEnabled(); - // The state where the two resolvers actually diverge: the ACTIVE session has - // exited, so it can no longer name the subject, and only the selected project - // can. Both of this project's remaining conversations are BOUND to its - // agents, so the agent rule finds none of them and Cmd+1 reaches nothing. - await page.evaluate(() => { + // Even after the active tab exits, its neutral project identity keeps the + // shortcut resolver on the exact same project-wide set. The remaining live + // tab becomes Cmd/Ctrl+1 rather than disappearing behind an agent-path rule. + await page.evaluate((selectedProjectId) => { ( window as unknown as { __HARNESS_TEST__?: { @@ -428,9 +454,14 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a ready: false, createdAt: "2026-08-01T10:00:00.000Z", lastActiveAt: "2026-08-01T10:00:00.000Z", + agentMapIdentity: { + projectId: selectedProjectId, + userId: "user_mock", + sessionId: "sess-polsia-1", + }, }, }); - }); + }, projectId!); await expect(page.getByTestId("dead-session-pane")).toBeVisible(); await page.keyboard.press("ControlOrMeta+1"); await expect(page.getByTestId("session-context")).toHaveAttribute( @@ -439,12 +470,12 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a ); }); -test("Cmd/Ctrl+1..9 follows a TAB CLICK, the one activation that moves nothing else", async ({ +test("Cmd/Ctrl+1..9 follows an exact ordinary session-tab activation", async ({ page, }) => { /* The key handler closes over its inputs, so it needs every input the strip - has — the active session included. Clicking a tab moves neither the focus - nor the project selection, so with `activeSessionId` missing from the + has — the active session included. Clicking a tab now opens that exact + session's ordinary subject; with `activeSessionId` missing from the effect's deps the listener kept the previous active session and resolved a different list, healing only on the next session event. @@ -503,14 +534,15 @@ test("Cmd/Ctrl+1..9 follows a TAB CLICK, the one activation that moves nothing e const tabs = page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"); await expect(tabs).toHaveCount(2); - // Click the NESTED project's tab. Nothing else moves: the rail selection and - // the focus are untouched, and the map stays open. + // Click the nested project's tab. The exact session becomes active and its + // ordinary conversation/canvas replaces the project map. await page.getByTestId("session-tab-sess-nested").click(); await expect(page.getByTestId("session-context")).toHaveAttribute( "data-session-id", "sess-nested", ); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); + await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); + await expect(page.getByTestId("agent-view")).toBeVisible(); // The strip re-keyed to the nested project, which holds only this session... await expect(tabs).toHaveCount(1); // ...so tab 1 is this session, and Cmd+1 must not reach past it into the @@ -522,13 +554,12 @@ test("Cmd/Ctrl+1..9 follows a TAB CLICK, the one activation that moves nothing e ); }); -test("the tab + and a tab click stay INSIDE the project — the map does not close under them", async ({ +test("the tab + and every tab click open an ordinary project session", async ({ page, }) => { - /* Measured, because it is one line of state away from being wrong: every - session door used to clear the project selection unconditionally, so - starting one of the project's own tabs closed the map it was started - beside — the same mode switch, one click later. */ + /* The map is selected only through the project label. Tabs are real session + handles, so choosing an existing tab or creating a sibling leaves the map + and opens that exact conversation/canvas. */ await page.getByTestId("project-select-acme-app").click(); await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); @@ -537,15 +568,17 @@ test("the tab + and a tab click stay INSIDE the project — the map does not clo "data-session-id", "sess-leasing-2", ); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); + await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); + await expect(page.getByTestId("agent-view")).toBeVisible(); await page.getByTestId("session-tab-new").click(); await expect(page.getByTestId("session-context")).toHaveAttribute( "data-session-id", /^sess-mock-/, ); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect(page.getByTestId("project-row-acme-app")).toHaveClass( + await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); + await expect(page.getByTestId("agent-view")).toBeVisible(); + await expect(page.getByTestId("project-row-acme-app")).not.toHaveClass( /is-selected/, ); }); diff --git a/packages/harness/web/e2e/project-axis.spec.ts b/packages/harness/web/e2e/project-axis.spec.ts index b34df84c6..28f8d2969 100644 --- a/packages/harness/web/e2e/project-axis.spec.ts +++ b/packages/harness/web/e2e/project-axis.spec.ts @@ -164,7 +164,7 @@ test.describe("ordering", () => { }); }); -test.describe("the plan-first project children", () => { +test.describe("durable Studio project navigation", () => { test("the project plus starts a coding session at its root without creating an agent", async ({ page, }) => { @@ -199,14 +199,12 @@ test.describe("the plan-first project children", () => { "project-menu-dashboard-keeper", ]); - // Prove the shortcut also works from map altitude: the new generic session - // becomes the visible workbench, while the planner remains resumable from - // Plan Agents and no scaffold request is made. - await group.getByTestId("agent-map-select").click(); - await expect(group.getByTestId("agent-map-select")).toHaveAttribute( - "aria-pressed", - "true", - ); + // The ordinary project action also works while its read-only map is open. + // A successful create selects the exact new conversation and no scaffold + // operation is smuggled into that session action. + const map = group.getByTestId("project-select-dashboard-keeper"); + await map.click(); + await expect(map).toHaveAttribute("aria-pressed", "true"); await start.click(); await expect .poll(() => @@ -232,10 +230,7 @@ test.describe("the plan-first project children", () => { }, }, }); - await expect(group.getByTestId("agent-map-select")).toHaveAttribute( - "aria-pressed", - "false", - ); + await expect(map).toHaveAttribute("aria-pressed", "false"); await expect(page.getByTestId("session-context-title")).toContainText( "dashboard-keeper", ); @@ -252,78 +247,87 @@ test.describe("the plan-first project children", () => { ).not.toContain("scaffold:/Users/demo/dashboard-keeper"); }); - test("a failed project session keeps Plan Agents selected", async ({ + test("a failed project session keeps the map and active conversation intact", async ({ page, }) => { const group = page.getByTestId("workspace-group-dashboard-keeper"); - const map = group.getByTestId("agent-map-select"); + const map = group.getByTestId("project-select-dashboard-keeper"); + const start = group.getByTestId("project-start-session-dashboard-keeper"); + + // Establish a real conversation in this project first. Cross-project map + // navigation deliberately clears an unrelated active session, so it cannot + // supply the conversation whose preservation this scenario verifies. Open + // the map first so its true -> false transition is also the completion + // signal for the asynchronous successful create. + await map.click(); + await expect(map).toHaveAttribute("aria-pressed", "true"); + await start.click(); + await expect(map).toHaveAttribute("aria-pressed", "false"); + await expect(page.getByTestId("session-context-title")).toContainText( + "dashboard-keeper", + ); + await expect(page.locator(".harness-terminal")).toBeVisible(); await map.click(); await expect(map).toHaveAttribute("aria-pressed", "true"); await expect(page.locator(".harness-terminal")).toBeVisible(); + const activeBefore = await page + .getByTestId("session-context") + .getAttribute("data-session-id"); await page.evaluate(() => { ( window as unknown as { __MOCK_CREATE_SESSION_FAIL_ONCE__?: boolean } ).__MOCK_CREATE_SESSION_FAIL_ONCE__ = true; }); - await group.getByTestId("project-start-session-dashboard-keeper").click(); + await start.click(); await expect(page.getByTestId("toast")).toContainText( "mock: couldn't create session", ); await expect(map).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + activeBefore ?? "", + ); }); - test("a root agent is a separate target below the pinned Agent Map", async ({ + test("the project label and its disclosure are separate from the root agent", async ({ page, }) => { const group = page.getByTestId("workspace-group-dashboard-keeper"); const project = group.getByTestId("project-row-dashboard-keeper"); - const map = group.getByTestId("agent-map-row"); + const map = group.getByTestId("project-select-dashboard-keeper"); const agent = group.getByTestId("workflow-dashboard-keeper"); await expect(project).toBeVisible(); - await expect(map).toBeVisible(); - await expect(map.getByTestId("agent-map-select")).toHaveText("Plan Agents"); - await expect(map.getByTestId("agent-map-select")).toHaveAttribute( - "data-tooltip", - "Open Plan Agents", + await expect(group.getByTestId("agent-map-row")).toHaveCount(0); + await expect(map).toHaveAccessibleName( + "Open Agent Map for dashboard-keeper", ); await expect(agent).toBeVisible(); - await expect(group.locator(":scope > *")).toHaveCount(3); + await expect(group.locator(":scope > *")).toHaveCount(2); - // The project label is disclosure-only; the two children remain distinct. - await page.getByTestId("project-select-dashboard-keeper").click(); - await expect(map).toBeHidden(); - await expect(agent).toBeHidden(); - await page.getByTestId("project-select-dashboard-keeper").click(); - await expect(map).toBeVisible(); - - await group.getByTestId("agent-map-select").click(); - await expect(map).toHaveClass(/is-selected/); - await expect(map.getByTestId("agent-map-select")).toHaveAttribute( - "data-tooltip", - "Plan Agents selected", - ); + // The label selects the map and does not fold the root-agent child. + await map.click(); + await expect(map).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("agent-map-empty")).toBeVisible(); + await expect(agent).toBeVisible(); - // A selected child expands on selection, but an intentional disclosure - // click stays collapsed until the user expands it again. - await page.getByTestId("project-select-dashboard-keeper").click(); - await expect(map).toBeHidden(); - await expect( - page.getByTestId("project-disclosure-dashboard-keeper"), - ).toHaveAttribute("aria-expanded", "false"); - await page.getByTestId("project-select-dashboard-keeper").click(); - await expect(map).toBeVisible(); - await expect(map).toHaveClass(/is-selected/); + // Only the chevron owns collapse; the selected map remains mounted. + const disclosure = page.getByTestId("project-disclosure-dashboard-keeper"); + await disclosure.click(); + await expect(disclosure).toHaveAttribute("aria-expanded", "false"); + await expect(agent).toBeHidden(); + await expect(page.getByTestId("agent-map-empty")).toBeVisible(); + await map.click(); + await expect(agent).toBeHidden(); + await disclosure.click(); + await expect(agent).toBeVisible(); - await agent.locator("button").click(); + await agent.locator(".workflow-item-trigger").click(); await expect(agent).toHaveClass(/is-focused/); - // Every durable project has at least the Agent Map child to disclose. - await expect( - page.getByTestId("project-disclosure-dashboard-keeper"), - ).toHaveCount(1); - await expect(page.getByTestId("project-disclosure-polsia")).toHaveCount(1); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("right-tab-canvas")).toContainText("Canvas"); + await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); }); test("the project row carries no deploy glyph; the agent child does", async ({ @@ -340,7 +344,10 @@ test.describe("the plan-first project children", () => { .getByTestId("workflow-dashboard-keeper") .locator(".workflow-status"), ).toHaveCount(1); - // The rail also offers no per-project `+`. + await expect( + group.getByTestId("project-start-session-dashboard-keeper"), + ).toBeVisible(); + // The removed legacy shortcut is not a second project-level `+`. await expect( page.locator('.rail-list [data-testid^="workspace-new-session-"]'), ).toHaveCount(0); @@ -351,14 +358,14 @@ test.describe("the plan-first project children", () => { }) => { const map = page .getByTestId("workspace-group-dashboard-keeper") - .getByTestId("agent-map-row"); - await map.getByTestId("agent-map-select").click(); - await expect(map).toHaveClass(/is-selected/); + .getByTestId("project-select-dashboard-keeper"); + await map.click(); + await expect(map).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("agent-map-empty")).toBeVisible(); await page.getByTestId("rail-templates").click(); await expect(page.getByTestId("templates-panel")).toBeVisible(); - await expect(map).not.toHaveClass(/is-selected/); + await expect(map).toHaveAttribute("aria-pressed", "false"); }); }); diff --git a/packages/harness/web/e2e/selection-canvas.spec.ts b/packages/harness/web/e2e/selection-canvas.spec.ts index 77091514c..a896a087c 100644 --- a/packages/harness/web/e2e/selection-canvas.spec.ts +++ b/packages/harness/web/e2e/selection-canvas.spec.ts @@ -36,12 +36,10 @@ import type { Page } from "@playwright/test"; * in this fixture are used — `services/workers` is opened as its own project * as well as nested, so its agents have two rows each. */ const select = async (page: Page, name: string): Promise => { - await page.getByTestId(`workflow-${name}`).locator(".workflow-item-trigger").click(); - // A root-agent row belongs to the Project axis first: its label opens the - // Project graph, and the graph card is the separate agent-selection door. - if (await page.getByTestId("workspace-graph-view").isVisible()) { - await page.getByRole("button", { name: `Open ${name}`, exact: true }).click(); - } + await page + .getByTestId(`workflow-${name}`) + .locator(".workflow-item-trigger") + .click(); await expect(page.getByTestId(`workflow-${name}`)).toHaveClass(/is-focused/); }; @@ -69,7 +67,6 @@ async function openRightPane(page: Page): Promise { if ((await expand.count()) > 0) await expand.click(); } - /** The agent the pane says it is about (Steps surface). */ const paneSubject = (page: Page) => page.getByTestId("right-panel-canvas").locator(".workflow-actions-name"); @@ -102,7 +99,9 @@ const boardFrame = (page: Page) => page.locator(".canvas-iframe"); * unavailable), so the accident is gone. */ const boardMounted = async (page: Page): Promise => { - await expect.poll(() => boardFrame(page).count(), { timeout: 10_000 }).toBeGreaterThan(0); + await expect + .poll(() => boardFrame(page).count(), { timeout: 10_000 }) + .toBeGreaterThan(0); }; /** @@ -143,7 +142,7 @@ async function startSessionOn(page: Page, name: string): Promise { } test.beforeEach(async ({ page }) => { - await page.goto("/?mockFixtures=deep"); + await page.goto("/?mockFixtures=deep&mockStudioProjects=present"); await expect(page.locator(".rail-workflows")).toBeVisible(); await expect(page.getByTestId("workspace-group-polsia")).toBeVisible(); }); @@ -161,7 +160,10 @@ test.describe("the board follows the selection; the session does not", () => { // The board is `outreach`'s, served by the workflow-keyed route — which is // reached ONLY when the subject and the session's binding differ, so this // attribute is itself the proof that they have. - await expect(boardFrame(page)).toHaveAttribute("srcdoc", /outreach — mock agent board/); + await expect(boardFrame(page)).toHaveAttribute( + "srcdoc", + /outreach — mock agent board/, + ); // …while the session below it is untouched. Both halves matter: either one // alone is satisfied by the old coupled behaviour. expect(await activeSessionId(page)).toBe(sessionId); @@ -178,11 +180,16 @@ test.describe("the board follows the selection; the session does not", () => { await select(page, "outreach"); await boardMounted(page); // Canvas: the document on screen is outreach's. - await expect(boardFrame(page)).toHaveAttribute("srcdoc", /outreach — mock agent board/); + await expect(boardFrame(page)).toHaveAttribute( + "srcdoc", + /outreach — mock agent board/, + ); // Steps: the same agent, read from the other projection. await openSteps(page); await expect(paneSubject(page)).toHaveText("outreach"); - await expect(page.getByTestId("canvas-steps-count")).toHaveText("2 steps · 1 exit"); + await expect(page.getByTestId("canvas-steps-count")).toHaveText( + "2 steps · 1 exit", + ); }); test("a selection in another project MOVES the active session, and back again", async ({ @@ -219,7 +226,9 @@ test.describe("verb gating", () => { await startSessionOn(page, "mailer"); const prod = page.getByTestId("session-step-prod"); await expect(prod).toBeEnabled(); - await expect(prod).toHaveAccessibleName("Open mailer in the Sapiom dashboard"); + await expect(prod).toHaveAccessibleName( + "Open mailer in the Sapiom dashboard", + ); await page.getByRole("button", { name: "Choose run target" }).click(); await expect(page.getByTestId("session-step-run")).toBeEnabled(); await page.keyboard.press("Escape"); @@ -228,14 +237,20 @@ test.describe("verb gating", () => { // underneath a session that is still bound to the deployed agent. const sessionId = await activeSessionId(page); await select(page, "sender"); - await expect(boardFrame(page)).toHaveAttribute("srcdoc", /sender — mock agent board/); + await expect(boardFrame(page)).toHaveAttribute( + "srcdoc", + /sender — mock agent board/, + ); // The session did not move, so the binding is still the DEPLOYED agent. expect(await activeSessionId(page)).toBe(sessionId); // Prod: disabled, and its reason readable from BOTH channels. await expect(prod).toBeDisabled(); await expect(prod).toHaveAccessibleName("Prod: Not deployed yet"); - await expect(prod).toHaveAttribute("data-tooltip", "Prod: Not deployed yet"); + await expect(prod).toHaveAttribute( + "data-tooltip", + "Prod: Not deployed yet", + ); // Run (the cloud target): same. await page.getByRole("button", { name: "Choose run target" }).click(); @@ -256,18 +271,24 @@ test.describe("verb gating", () => { await expect(page.getByTestId("session-step-prod")).toBeEnabled(); }); - test("the run sheet opens on the SELECTION, not on the bound agent", async ({ page }) => { + test("the run sheet opens on the SELECTION, not on the bound agent", async ({ + page, + }) => { // Gating is only half of it: the action target has to move too, and the // sheet's own title is the honest readout of which agent is about to run. await startSessionOn(page, "mailer"); await select(page, "sender"); await page.getByTestId("session-step-local").click(); - await expect(page.getByRole("dialog", { name: "Run sender" })).toBeVisible(); + await expect( + page.getByRole("dialog", { name: "Run sender" }), + ).toBeVisible(); }); }); test.describe("boards for agents with no session", () => { - test("an agent that has never hosted a session shows a REAL board", async ({ page }) => { + test("an agent that has never hosted a session shows a REAL board", async ({ + page, + }) => { // No fixture session is rooted in polsia, so `gateway` has never had one: // before IA-01's workflow-keyed route this pane could only say "no running // session for gateway". The board is now served from `sapiom.json` alone. @@ -280,7 +301,10 @@ test.describe("boards for agents with no session", () => { await expect( page.frameLocator(".canvas-iframe").getByTestId("mock-workflow-board"), ).toBeVisible(); - await expect(boardFrame(page)).toHaveAttribute("srcdoc", /gateway — mock agent board/); + await expect(boardFrame(page)).toHaveAttribute( + "srcdoc", + /gateway — mock agent board/, + ); // The theme bridge: a `srcdoc` frame has no URL, so the served document's // `?theme=` reader has nothing to read and the app hands it the theme in an // appended script instead. Without it the board uses the light product @@ -292,23 +316,33 @@ test.describe("boards for agents with no session", () => { // the count is read from the posted graph, not from the document's markup. await openSteps(page); await expect(paneSubject(page)).toHaveText("gateway"); - await expect(page.getByTestId("canvas-steps-count")).toHaveText("2 steps · 1 exit"); + await expect(page.getByTestId("canvas-steps-count")).toHaveText( + "2 steps · 1 exit", + ); // The state this replaces is gone, not merely covered up. await expect(page.getByTestId("canvas-empty-no-session")).toHaveCount(0); }); - test("`preparing`, `empty` and `error` are three distinct honest states", async ({ page }) => { + test("`preparing`, `empty` and `error` are three distinct honest states", async ({ + page, + }) => { // Not one generic failure: `preparing` is a fresh scaffold with no deps // installed and must never surface a build error to someone who has just // created an agent; `empty` is a registered agent with no readable // sapiom.json (absent ⇒ empty); `error` is an extraction that ran and // failed. Collapsing them was how the first became the third. - const seed = async (status: string, reason: string | null): Promise => { + const seed = async ( + status: string, + reason: string | null, + ): Promise => { await page.evaluate( ({ status, reason }) => { ( window as unknown as { - __MOCK_WORKFLOW_GRAPH__?: Record; + __MOCK_WORKFLOW_GRAPH__?: Record< + string, + { status: string; reason: string | null } + >; } ).__MOCK_WORKFLOW_GRAPH__ = { "/Users/demo/polsia/services/gateway": { status, reason }, @@ -329,7 +363,10 @@ test.describe("boards for agents with no session", () => { await expect(page.getByTestId("canvas-empty-route-error")).toHaveCount(0); await expect(page.getByTestId("canvas-empty-route-empty")).toHaveCount(0); - await seed("empty", "This agent has no sapiom.json, so there is no graph to render yet."); + await seed( + "empty", + "This agent has no sapiom.json, so there is no graph to render yet.", + ); await select(page, "rollup"); await expect(page.getByTestId("canvas-empty-route-empty")).toContainText( "no sapiom.json", @@ -352,7 +389,9 @@ test.describe("run evidence", () => { // account of what ran, in the surface whose whole job is to say what ran. await startSessionOn(page, "mailer"); await page.getByTestId("session-step-local").click(); - await expect(page.getByRole("dialog", { name: "Run mailer" })).toBeVisible(); + await expect( + page.getByRole("dialog", { name: "Run mailer" }), + ).toBeVisible(); await page.getByTestId("run-sheet-submit").click(); await openSteps(page); await expect(page.getByTestId("run-workspace")).toBeVisible(); diff --git a/packages/harness/web/e2e/smoke.spec.ts b/packages/harness/web/e2e/smoke.spec.ts index cc2eb83e5..5643ebdf2 100644 --- a/packages/harness/web/e2e/smoke.spec.ts +++ b/packages/harness/web/e2e/smoke.spec.ts @@ -643,10 +643,9 @@ test.describe("three-zone IA (rail explorer, tab strip, right pane)", () => { "true", ); - // ...and the way back UP is there, so the map is no longer a one-way door. - const up = page.getByTestId("canvas-altitude-up"); - await expect(up).toHaveAttribute("aria-label", "Back to the acme-app map"); - await up.click(); + // The project name is the sole map-navigation action. + await expect(page.getByTestId("canvas-altitude-up")).toHaveCount(0); + await page.getByTestId("project-select-acme-app").click(); await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); await expect(page.getByTestId("project-row-acme-app")).toHaveClass( /is-selected/, diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 340891cd1..d6545af75 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -49,6 +49,7 @@ import { } from "react"; import type { JSX } from "react"; import type { + AppState, HarnessKind, HarnessSession, MacroDef, @@ -58,7 +59,6 @@ import type { } from "@shared/types"; import type { WorkspaceKey } from "@shared/system-graph"; import type { - PlannerSessionRequest, StudioProjectId, StudioWorkspaceSelection, } from "@shared/agent-map"; @@ -104,6 +104,7 @@ import { conversationSubject, liveSessionsForFocus, liveSessionsForProject, + liveSessionsForStudioProject, mergeSubjectRuns, projectRootForAgent, rootContains, @@ -115,7 +116,6 @@ import { } from "./lib/session-scope"; import { canvasView, - projectAbove, studioCanvasView, stepsDisabledReason, secretsDisabledReason, @@ -152,7 +152,7 @@ import { directActionKind } from "./lib/macro-actions"; import { describeWorkflowPrompt } from "./lib/describe-prompt"; import { sessionDisplayName } from "./lib/session-name"; import type { PaletteAction } from "./lib/palette"; -import { getTheme, toggleTheme } from "./lib/theme"; +import { toggleTheme } from "./lib/theme"; import { loadUiPrefs, saveUiPrefs } from "./lib/ui-prefs"; import { useNavigationHistory, @@ -207,6 +207,32 @@ const knownRootsOf = ( launchDir: string | null | undefined, ): string[] => [...(recentDirs ?? []), ...(launchDir ? [launchDir] : [])]; +/** Resolve the one Studio workspace selection every map consumer observes. */ +const effectiveStudioWorkspaceSelection = ( + selection: StudioWorkspaceSelection | null, + state: Pick | null | undefined, +): StudioWorkspaceSelection | null => { + if (!selection || !state) return null; + if ( + !state.studioProjects?.some( + (project) => project.projectId === selection.projectId, + ) + ) { + return null; + } + if (selection.kind === "agent-map") return selection; + const agentStillExists = state.workflows.some((workflow) => + workflow.studioBindings?.some( + (binding) => + binding.projectId === selection.projectId && + binding.agentId === selection.agentId, + ), + ); + return agentStillExists + ? selection + : { kind: "agent-map", projectId: selection.projectId }; +}; + /** * How long a held initial prompt waits for the coding agent to become ready * (i.e. the user to finish any sign-in, trust, or onboarding step) before we @@ -229,6 +255,8 @@ interface CreateSessionAtOptions { keepComposerOpen?: boolean; /** Keep an explicit new-agent builder active when its root joins Studio. */ standaloneBuilder?: boolean; + /** The caller already owns the session's first real user-authored turn. */ + initialUserInputPending?: boolean; } /** @@ -279,12 +307,13 @@ export const App = (): JSX.Element => { const [siblingSessionPending, setSiblingSessionPending] = useState(false); const siblingSessionPendingRef = useRef(false); // The focused agent (or bare-scaffold folder) path — the rail's single - // selection and the main panel's tab-strip subject. The active tab's - // session is harness.activeSessionId. + // selection and the Canvas/Steps subject. The active conversation is always + // harness.activeSessionId; choosing the project map does not rewrite it. const [focusedAgentPath, setFocusedAgentPath] = useState(null); - // The project the canvas is at MAP altitude for. Not a destination any more - // (SAP-2980): the chat stays in the centre and the map draws beside it, so - // this selects a SUBJECT rather than replacing the workbench. + // The legacy project the canvas is at MAP altitude for. Studio projects use + // `studioSelection` below, but retain the same invariant: the chat stays in + // the centre and the map draws beside it, so this selects a SUBJECT rather + // than replacing the workbench. // // ONE value, not the key and a parallel meta record it used to be: half the // doors cleared only the key and left the meta behind, so every reader had to @@ -305,83 +334,41 @@ export const App = (): JSX.Element => { ); const restoredStudioProjectsRef = useRef(new Set()); const studioRestoreGenerationRef = useRef(0); - const plannerProjectId = - studioSelection?.kind === "agent-map" ? studioSelection.projectId : null; - const handlePlannerReady = useCallback( - ( - response: { session: HarnessSession }, - mode: PlannerSessionRequest["mode"], - ): void => { - const selected = harness.state?.sessions.find( - (session) => session.id === harness.activeSessionId, - ); - // An explicit palette/history selection is more specific than the - // project-level resume ordering. Keep that chosen live tab; fresh mode - // remains an explicit request to select the newly-created planner. - if ( - mode === "resume-or-create" && - selected?.status !== "exited" && - selected?.planning?.identity.role === "map-planner" && - selected.planning.identity.projectId === - response.session.planning?.identity.projectId - ) { - return; - } - harness.setActiveSessionId(response.session.id); - }, - [ - harness.activeSessionId, - harness.setActiveSessionId, - harness.state?.sessions, - ], - ); - const activePlannerForProject = harness.state?.sessions.find( - (session) => - session.id === harness.activeSessionId && - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === plannerProjectId, + const effectiveStudioSelection = effectiveStudioWorkspaceSelection( + studioSelection, + harness.state, ); + const agentMapProjectId = + effectiveStudioSelection?.kind === "agent-map" + ? effectiveStudioSelection.projectId + : null; const agentMapEntry = useAgentMapEntry({ - projectId: plannerProjectId, - selectedPlanner: activePlannerForProject ?? null, + projectId: agentMapProjectId, api: harness.api, - harness: () => - loadUiPrefs().preferredHarness === "codex" ? "codex" : "claude-code", - theme: getTheme, - openPlannerSession: harness.openPlannerSession, - onPlannerReady: handlePlannerReady, subscribeProposalChanges: harness.subscribeAgentMapProposalChanges, subscribeReconnects: harness.subscribeEventReconnects, }); // A project visit restores its server-owned preference before choosing an - // altitude. Once map is chosen, `useAgentMapEntry` owns the independent map - // and planner requests; preference restoration must not couple their fate. + // altitude. Once map is chosen, `useAgentMapEntry` owns only the independent + // map read; preference restoration must never turn that view change into a + // session create, resume, or selection. useEffect(() => { const state = harness.state; const active = state?.sessions.find( (session) => session.id === harness.activeSessionId, ); - // Bind the intent as soon as createSession() selects its session — before - // that call's slower recent-directory/catalog refresh finishes. Once - // bound, the session id makes the intent safe to retain across focus - // changes; only that session disappearing or exiting expires it. + // Once the create response binds an intent to its exact session, retain it + // across focus changes until that session disappears or exits. A pending + // null is deliberately NOT inferred from the active same-cwd session: a + // new project's automatic Plan Agents session can arrive while an explicit + // create is in flight, and cwd cannot correlate those two launches. for (const [ root, sessionId, ] of pendingStandaloneBuilderSessionsRef.current) { - if (sessionId === null) { - if ( - active && - active.status !== "exited" && - samePath(root, active.cwd) - ) { - pendingStandaloneBuilderSessionsRef.current.set(root, active.id); - } - continue; - } + if (sessionId === null) continue; const builder = state?.sessions.find( (session) => session.id === sessionId, ); @@ -391,14 +378,35 @@ export const App = (): JSX.Element => { } if (!state?.studioProjects || !active) return; + const identityProjectId = active.agentMapIdentity?.projectId ?? null; + const identityProject = identityProjectId + ? state.studioProjects.find( + (candidate) => candidate.projectId === identityProjectId, + ) + : null; + // A neutral principal is the exact server-derived authority. Never let a + // bound Canvas subject reclassify that session under a nested project. The + // path-only branch exists solely for rolling compatibility with sessions + // persisted before project principals were recorded. + if (identityProjectId && !identityProject) return; + const eligibleProjects = identityProject + ? [identityProject] + : state.studioProjects; + const eligibleScopes = identityProject + ? (state.workspaceScopes ?? []).filter( + (candidate) => candidate.projectId === identityProject.projectId, + ) + : (state.workspaceScopes ?? []); const scope = mostSpecificStudioScope( - active.boundWorkflowPath ?? active.cwd, - state.workspaceScopes ?? [], - state.studioProjects, - ); - const project = state.studioProjects.find( - (candidate) => candidate.projectId === scope?.projectId, + active.cwd, + eligibleScopes, + eligibleProjects, ); + const project = + identityProject ?? + state.studioProjects.find( + (candidate) => candidate.projectId === scope?.projectId, + ); if (!scope?.projectId || !project) return; const standaloneBuilderRoot = [ ...pendingStandaloneBuilderSessionsRef.current, @@ -532,20 +540,23 @@ export const App = (): JSX.Element => { * this epic removes, one click later. A functional updater so the rule can * be applied from handlers that do not close over the current selection. */ - const leaveProjectUnlessInside = useCallback((cwd: string | null): void => { - setSelectedProject((current) => - current && cwd && rootContains(current.root, cwd) ? current : null, - ); - setStudioSelection((current) => { - if (!current || !cwd) return null; - const ownsTarget = (harness.state?.workspaceScopes ?? []).some( - (scope) => - scope.projectId === current.projectId && - rootContains(scope.cwd, cwd), + const leaveProjectUnlessInside = useCallback( + (cwd: string | null): void => { + setSelectedProject((current) => + current && cwd && rootContains(current.root, cwd) ? current : null, ); - return ownsTarget ? current : null; - }); - }, [harness.state]); + setStudioSelection((current) => { + if (!current || !cwd) return null; + const ownsTarget = (harness.state?.workspaceScopes ?? []).some( + (scope) => + scope.projectId === current.projectId && + rootContains(scope.cwd, cwd), + ); + return ownsTarget ? current : null; + }); + }, + [harness.state], + ); // "Open in Studio" deep links (sapiom://agent/). The applier is a ref // because it needs `state`/`handleFocusAgent`, which exist only past the loading // guard; the effects below reach it through the ref. The cold-start target rides @@ -562,6 +573,7 @@ export const App = (): JSX.Element => { const selectProjectRef = useRef< ((workspaceKey: WorkspaceKey, root: string, label: string) => void) | null >(null); + const openSessionRef = useRef<((sessionId: string) => void) | null>(null); const coldDeepLinkRef = useRef(deepLinkFromSearch()); const coldDeepLinkHandledRef = useRef(false); // A clone kicked off from a remote-only deep link: focus the agent once the @@ -861,8 +873,10 @@ export const App = (): JSX.Element => { // second copy of the rule it would address a different list than the one // the user is counting along. const sessions = harness.state?.sessions ?? []; + const shortcutActive = + sessions.find((s) => s.id === harness.activeSessionId) ?? null; const subject = conversationSubject( - sessions.find((s) => s.id === harness.activeSessionId) ?? null, + shortcutActive, focusedAgentPath, // The SELECTED project, exactly as the strip passes it. Left null // here, the two resolvers agree only while the active session is live @@ -874,29 +888,19 @@ export const App = (): JSX.Element => { selectedProject?.root ?? null, knownRootsOf(harness.settings?.recentDirs, harness.state?.launchDir), ); - const tabs = - studioSelection?.kind === "agent-map" - ? sessions.filter( - (session) => - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === - studioSelection.projectId, - ) - : subject.kind === "project" - ? liveSessionsForProject(sessions, subject.root) - : liveSessionsForFocus(sessions, subject.path); + const studioProjectId = + effectiveStudioSelection?.projectId ?? + shortcutActive?.agentMapIdentity?.projectId ?? + null; + const tabs = studioProjectId + ? liveSessionsForStudioProject(sessions, studioProjectId) + : subject.kind === "project" + ? liveSessionsForProject(sessions, subject.root) + : liveSessionsForFocus(sessions, subject.path); const target = tabs[Number(e.key) - 1]; if (target) { e.preventDefault(); - setComposing(false); - setReviewSummary(null); - leaveProjectUnlessInside(target.cwd); - // A tab jump is a navigation: leave any full-width destination that - // is standing in for the workbench, or it would linger over the tab. - setTemplatesOpen(false); - setOverviewOpen(false); - harness.setActiveSessionId(target.id); + openSessionRef.current?.(target.id); } } }; @@ -917,7 +921,7 @@ export const App = (): JSX.Element => { harness.settings?.recentDirs, focusedAgentPath, selectedProject, - studioSelection, + effectiveStudioSelection, isMobile, rightCollapsed, paletteOpen, @@ -1045,11 +1049,11 @@ export const App = (): JSX.Element => { // Legacy System Graph selection keeps its established auto-open behavior. if ( selectedProject || - (!isMobile && studioSelection?.kind === "agent-map") + (!isMobile && effectiveStudioSelection?.kind === "agent-map") ) { setRightCollapsed(false); } - }, [isMobile, selectedProject, studioSelection]); + }, [effectiveStudioSelection?.kind, isMobile, selectedProject]); // Crossing the breakpoint resets both panes to that mode's default. const prevMobile = useRef(isMobile); @@ -1091,8 +1095,11 @@ export const App = (): JSX.Element => { applyingVisitRef.current = false; return; } - if (studioSelection?.kind === "agent-map") { - recordVisit({ kind: "agent-map", projectId: studioSelection.projectId }); + if (effectiveStudioSelection?.kind === "agent-map") { + recordVisit({ + kind: "agent-map", + projectId: effectiveStudioSelection.projectId, + }); } else if (selectedProject) { recordVisit({ kind: "project", @@ -1121,7 +1128,7 @@ export const App = (): JSX.Element => { }, [ recordVisit, selectedProject, - studioSelection, + effectiveStudioSelection, templatesOpen, reviewSummary, composing, @@ -1324,32 +1331,36 @@ export const App = (): JSX.Element => { const boundWorkflow = state.workflows.find((w) => w.path === boundWorkflowPath) ?? null; const workspaceScopes = state.workspaceScopes ?? []; - const selectedStudioProject = studioSelection + const studioScopeForAgentProject = ( + path: string, + projectId: string | null | undefined, + ) => { + if (!projectId) return null; + const project = state.studioProjects?.find( + (candidate) => candidate.projectId === projectId, + ); + if (!project) return null; + return mostSpecificStudioScope( + path, + workspaceScopes.filter((scope) => scope.projectId === projectId), + [project], + ); + }; + const selectedStudioProject = effectiveStudioSelection ? (state.studioProjects?.find( - (project) => project.projectId === studioSelection.projectId, + (project) => project.projectId === effectiveStudioSelection.projectId, ) ?? null) : null; const selectedStudioWorkflow = - studioSelection?.kind === "agent" + effectiveStudioSelection?.kind === "agent" ? (state.workflows.find((workflow) => workflow.studioBindings?.some( (binding) => - binding.projectId === studioSelection.projectId && - binding.agentId === studioSelection.agentId, + binding.projectId === effectiveStudioSelection.projectId && + binding.agentId === effectiveStudioSelection.agentId, ), ) ?? null) : null; - // Missing client inventory is an in-memory map fallback, never a durable - // repair. The server owns the completeness decision in the effect above. - const effectiveStudioSelection = - studioSelection && selectedStudioProject - ? studioSelection.kind === "agent" && !selectedStudioWorkflow - ? ({ - kind: "agent-map", - projectId: studioSelection.projectId, - } satisfies StudioWorkspaceSelection) - : studioSelection - : null; const selectedStudioScopes = effectiveStudioSelection ? workspaceScopes.filter( (scope) => scope.projectId === effectiveStudioSelection.projectId, @@ -1389,24 +1400,7 @@ export const App = (): JSX.Element => { : null; const view = studioView ?? legacyView; const atMapAltitude = view.altitude === "map"; - const planningWorkspace = studioView?.altitude === "map"; - const agentMapUnavailable = - planningWorkspace && agentMapEntry.state.unavailable !== null; - const plannerSessions = planningWorkspace - ? state.sessions.filter( - (session) => - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === studioView.projectId, - ) - : []; - const activePlannerSession = - planningWorkspace && - activeSession?.status !== "exited" && - activeSession?.planning?.identity.role === "map-planner" && - activeSession.planning.identity.projectId === studioView.projectId - ? activeSession - : null; + const projectMapSelected = studioView?.altitude === "map"; /** * Whose tabs the strip shows: the ACTIVE session's PROJECT (SAP-2980), never @@ -1427,14 +1421,27 @@ export const App = (): JSX.Element => { : (selectedProject?.root ?? null), knownProjectRoots(), ); - const focusTabs = planningWorkspace - ? plannerSessions + const studioConversationProjectId = + planFirstSelection?.projectId ?? + activeSession?.agentMapIdentity?.projectId ?? + null; + const focusTabs = studioConversationProjectId + ? liveSessionsForStudioProject(state.sessions, studioConversationProjectId) : conversation.kind === "project" ? liveSessionsForProject(state.sessions, conversation.root) : liveSessionsForFocus(state.sessions, conversation.path); + // Project-name navigation cannot activate a session. Keep an already-active + // conversation only when it belongs to that exact project; a foreign CLI is + // hidden until the user explicitly selects one of this project's tabs. + const activeProjectTab = projectMapSelected + ? (focusTabs.find((session) => session.id === harness.activeSessionId) ?? + null) + : activeSession; + const conversationSession = projectMapSelected + ? activeProjectTab + : activeSession; const showReview = reviewSummary != null; - const showDead = - !planningWorkspace && !showReview && activeSession?.status === "exited"; + const showDead = !showReview && conversationSession?.status === "exited"; // An agent selected with no session that can WORK on it: honest absence, and // opening one lands on the "start a session" state. // @@ -1455,7 +1462,6 @@ export const App = (): JSX.Element => { const showAgentEmpty = !showReview && !showDead && - !planningWorkspace && !composing && !atMapAltitude && focusedWorkflow != null && @@ -1463,20 +1469,27 @@ export const App = (): JSX.Element => { activeSession, effectiveFocusedAgentPath, knownProjectRoots(), + planFirstSelection?.kind === "agent" + ? planFirstSelection.projectId + : null, + planFirstSelection?.kind === "agent" ? selectedStudioScope?.cwd : null, ); // The workbench: a live active session. const showWorkbench = !showReview && !showDead && - !planningWorkspace && !composing && !showAgentEmpty && - activeSession != null && - activeSession.status !== "exited"; + conversationSession != null && + conversationSession.status !== "exited"; + const showMapConversationEmpty = + !showReview && + !composing && + projectMapSelected && + conversationSession == null; // A project selected with no session yet: its first one is on the way, and // the centre says so rather than flashing the create-new composer. const showProjectStarting = - !planningWorkspace && !showReview && !showDead && !composing && @@ -1488,18 +1501,9 @@ export const App = (): JSX.Element => { const showComposer = !showReview && !showDead && - !planningWorkspace && !showProjectStarting && + !showMapConversationEmpty && (composing || (!showAgentEmpty && !showWorkbench)); - /** The project a board can cut UP to — derived, so the way back is the same - * door whether the agent was reached from the rail or from the map. */ - const upToProject = atMapAltitude - ? null - : projectAbove( - effectiveFocusedAgentPath, - knownProjectRoots(), - workspaceScopes, - ); const stepsDisabled = stepsDisabledReason(view.altitude); const secretsDisabled = secretsDisabledReason(view.altitude); // At map altitude the map IS the canvas panel, so a stored `steps` intent is @@ -1509,13 +1513,12 @@ export const App = (): JSX.Element => { (secretsDisabled && rightTab === "secrets") ? "canvas" : rightTab; - const rightPaneSuppressedByComposer = - (showComposer && !atMapAltitude) || agentMapUnavailable; - const sessionBarSession = planningWorkspace - ? activePlannerSession - : showWorkbench || showDead - ? activeSession - : null; + const rightPaneSuppressedByComposer = showComposer && !atMapAltitude; + const sessionBarSession = + showWorkbench || showDead ? conversationSession : null; + const newTabSource = projectMapSelected + ? (activeProjectTab ?? focusTabs[0] ?? null) + : activeSession; // A live session to return to when the composer was opened over the workbench. const composerCanCancel = composing && activeSession != null && activeSession.status !== "exited"; @@ -1623,21 +1626,7 @@ export const App = (): JSX.Element => { if (isMobile) setRailCollapsed(true); }; - /** - * The rail verb: SELECT a project. - * - * A project is somewhere you WORK, not somewhere you look (SAP-2980), so - * this does two things that used to be one. The canvas goes to map altitude - * — and the conversation becomes the project's, through the SAME decision - * function an agent selection uses. Reusing it is the point: crossing from - * one project to another has to hand the conversation over exactly once, - * with one rule, and a second hand-written copy here is how the two answers - * come to disagree. - * - * A project with no live session gets one at its root. Otherwise a project is - * a row you can select but cannot talk to — an empty workbench beside a map, - * which is the failure this criterion names. - */ + /** Select a project's durable map without touching session state. */ const handleSelectWorkspace = ( workspaceKey: WorkspaceKey, root: string, @@ -1677,11 +1666,10 @@ export const App = (): JSX.Element => { setTemplatesOpen(false); setOverviewOpen(false); closeMobileDrawer(); - // Stable Studio projects talk through their trusted map-planner. The - // selection effect starts resume-or-create; an ordinary project-root PTY - // here would race it and briefly make the wrong conversation authoritative. + // A Studio project-name click is a read-only navigation action. It must not + // choose, create, resume, focus, or prompt any session. if (selectedAgentMap) { - if (isMobile) setRightCollapsed(true); + if (isMobile) setRightCollapsed(false); return; } const decision = sessionForFocus({ @@ -1738,9 +1726,7 @@ export const App = (): JSX.Element => { * the create itself; the rail no longer starts sessions. */ function preferredHarness(): HarnessKind { - return loadUiPrefs().preferredHarness === "codex" - ? "codex" - : "claude-code"; + return loadUiPrefs().preferredHarness === "codex" ? "codex" : "claude-code"; } /** @@ -1768,10 +1754,10 @@ export const App = (): JSX.Element => { ): Promise => { if (!options.keepComposerOpen) setComposing(false); setReviewSummary(null); - // A session started INSIDE the selected project is one of ITS tabs — the - // tab `+`, the palette's "new session in this folder", the project's own - // first session. Closing its map under them would be the mode switch this - // epic removes, one click later. + // Preserve a same-project selection while the create is in flight. The + // caller owns the final destination: a project-row create can deliberately + // stay put until success, while a tab-strip create opens the new ordinary + // conversation/canvas once its exact session exists. leaveProjectUnlessInside(cwd); setOverviewOpen(false); setFocusedAgentPath(cwd); @@ -1789,10 +1775,25 @@ export const App = (): JSX.Element => { studioRestoreGenerationRef.current += 1; } try { - const session = await harness.createSession({ - cwd, - harness: agentHarness, - }); + const session = await harness.createSession( + { + cwd, + harness: agentHarness, + ...((options.initialUserInputPending ?? options.standaloneBuilder) + ? { initialUserInputPending: true } + : {}), + }, + (created) => { + if ( + options.standaloneBuilder && + pendingStandaloneBuilderSessionsRef.current.get(cwd) === null + ) { + // The POST response—not an active same-cwd status projection—is + // the first trustworthy correlation for this explicit create. + pendingStandaloneBuilderSessionsRef.current.set(cwd, created.id); + } + }, + ); if ( options.standaloneBuilder && pendingStandaloneBuilderSessionsRef.current.get(cwd) === null @@ -1859,7 +1860,9 @@ export const App = (): JSX.Element => { agentHarness: HarnessKind, idea?: string, ): Promise => { - const session = await createSessionAt(cwd, agentHarness); + const session = await createSessionAt(cwd, agentHarness, { + initialUserInputPending: true, + }); sendScaffoldPrompt(session, cwd, idea); }; @@ -1947,7 +1950,9 @@ export const App = (): JSX.Element => { : null; const session = existing ?? - (await createSessionAt(request.root, preferredHarness())); + (await createSessionAt(request.root, preferredHarness(), { + initialUserInputPending: input.instruction.trim().length > 0, + })); await harness.bindWorkflow(session.id, created.path); harness.setActiveSessionId(session.id); setFocusedAgentPath(created.path); @@ -1995,6 +2000,9 @@ export const App = (): JSX.Element => { void (async () => { try { const session = await createSessionAt(cwd, source.harness); + studioRestoreGenerationRef.current += 1; + setStudioSelection(null); + setSelectedProject(null); if (!workflowPath) { setFocusedAgentPath(cwd); return; @@ -2111,7 +2119,9 @@ export const App = (): JSX.Element => { // the dialog shows it verbatim. const parent = parentOf(cwd); if (!parent) - throw new Error(`Can't create an agent at ${cwd} — pick a folder inside a project.`); + throw new Error( + `Can't create an agent at ${cwd} — pick a folder inside a project.`, + ); const created = await harness.scaffoldAgent( parent, basenameOf(cwd), @@ -2125,7 +2135,9 @@ export const App = (): JSX.Element => { setFocusedAgentPath(created.path); return; } - const session = await createSessionAt(cwd, "claude-code"); + const session = await createSessionAt(cwd, "claude-code", { + initialUserInputPending: true, + }); trackUse(); sendPromptWhenReady( session.id, @@ -2245,46 +2257,51 @@ export const App = (): JSX.Element => { setTemplatesOpen(false); setOverviewOpen(false); const session = state.sessions.find((s) => s.id === id); - if (session?.planning?.identity.role === "map-planner") { - const selection: StudioWorkspaceSelection = { - kind: "agent-map", - projectId: session.planning.identity.projectId, - }; - restoredStudioProjectsRef.current.add(selection.projectId); - setStudioSelection(selection); - setSelectedProject(null); - setFocusedAgentPath(session.cwd); - closeMobileDrawer(); - if (isMobile) setRightCollapsed(true); - harness.setActiveSessionId(id); - void harness.api.putStudioCurrentWorkspace( - selection.projectId, - selection, + studioRestoreGenerationRef.current += 1; + setSelectedProject(null); + setStudioSelection(null); + closeMobileDrawer(); + if (session) { + if (session.agentMapIdentity?.projectId) { + // An explicit session selection owns its destination. Mark its neutral + // project visited even when the session has no agent binding, so a + // late preference read cannot replace this exact ordinary tab. + restoredStudioProjectsRef.current.add( + session.agentMapIdentity.projectId, + ); + } + const focusPath = boundWorkflowPathOf(session) ?? session.cwd; + setFocusedAgentPath(focusPath); + const workflow = state.workflows.find((candidate) => + samePath(candidate.path, focusPath), ); - return; + const binding = workflow?.studioBindings?.find( + (candidate) => + candidate.projectId === session.agentMapIdentity?.projectId, + ); + if (binding) { + const selection: StudioWorkspaceSelection = { + kind: "agent", + projectId: binding.projectId, + agentId: binding.agentId, + }; + restoredStudioProjectsRef.current.add(binding.projectId); + setStudioSelection(selection); + void harness.api.putStudioCurrentWorkspace( + binding.projectId, + selection, + ); + } } - // Opening one of the selected project's own sessions is not a navigation - // away from it — only a session somewhere else is. - leaveProjectUnlessInside(session?.cwd ?? null); - closeMobileDrawer(); - if (session) - setFocusedAgentPath(boundWorkflowPathOf(session) ?? session.cwd); harness.setActiveSessionId(id); }; + openSessionRef.current = openSession; - // Select a tab in the strip — same as openSession, but the tab always - // belongs to the current focus, so focus never moves. + // Tabs are exact conversation navigation. Selecting one leaves the project + // map and renders that ordinary session's own conversation/canvas. const selectTab = (id: string): void => { - setComposing(false); - setReviewSummary(null); - setTemplatesOpen(false); - setOverviewOpen(false); - // The tabs ARE the project's tabs at map altitude — picking one must not - // close the map they are rendered beside. - leaveProjectUnlessInside( - state.sessions.find((s) => s.id === id)?.cwd ?? null, - ); - harness.setActiveSessionId(id); + openSession(id); + track("session.switched", { navigation_kind: "session_tab" }, id); }; // One entry point for reviewing a past (transcript) session. @@ -2331,7 +2348,7 @@ export const App = (): JSX.Element => { const selectStudioAgent = ( workflow: WorkflowInfo, preferred?: { projectId: string; agentId: string }, - ): void => { + ): { projectId: string; agentId: string } | null => { const bindings = workflow.studioBindings ?? []; const owningScope = mostSpecificStudioScope( workflow.path, @@ -2350,7 +2367,8 @@ export const App = (): JSX.Element => { (candidate) => candidate.projectId === owningScope?.projectId, ) ?? bindings.find( - (candidate) => candidate.projectId === studioSelection?.projectId, + (candidate) => + candidate.projectId === effectiveStudioSelection?.projectId, ) ?? [...bindings].sort( (left, right) => @@ -2359,7 +2377,7 @@ export const App = (): JSX.Element => { )[0]; if (!binding) { setStudioSelection(null); - return; + return null; } const selection: StudioWorkspaceSelection = { kind: "agent", @@ -2379,6 +2397,7 @@ export const App = (): JSX.Element => { setStudioSelection(current.selection); }) .catch(() => {}); + return binding; }; const handleFocusAgent = ( @@ -2395,14 +2414,22 @@ export const App = (): JSX.Element => { const workflow = state.workflows.find((candidate) => samePath(candidate.path, path), ); - if (workflow) selectStudioAgent(workflow, preferredStudioBinding); - else setStudioSelection(null); + const studioBinding = workflow + ? selectStudioAgent(workflow, preferredStudioBinding) + : null; + if (!workflow) setStudioSelection(null); closeMobileDrawer(); + const targetScope = studioScopeForAgentProject( + path, + studioBinding?.projectId, + ); const decision = sessionForFocus({ focusPath: path, active: activeSession, sessions: state.sessions, roots: knownProjectRoots(), + targetProjectId: studioBinding?.projectId, + targetProjectRoot: targetScope?.cwd, }); if ( decision.kind === "switch" && @@ -2464,7 +2491,9 @@ export const App = (): JSX.Element => { pendingCloneFocusRef.current = target.definitionId; setRightCollapsed(true); // terminal-first, like the template flow try { - const session = await createSessionAt(cwd, "claude-code"); + const session = await createSessionAt(cwd, "claude-code", { + initialUserInputPending: true, + }); sendPromptWhenReady( session.id, cloneDefinitionPrompt(target.definitionId, cwd), @@ -2492,9 +2521,38 @@ export const App = (): JSX.Element => { const handleBindWorkflow = async (path: string): Promise => { setSelectedProject(null); closeMobileDrawer(); + const workflow = state.workflows.find((candidate) => + samePath(candidate.path, path), + ); + const selectedBinding = + effectiveStudioSelection?.kind === "agent" + ? workflow?.studioBindings?.find( + (candidate) => + candidate.projectId === effectiveStudioSelection.projectId && + candidate.agentId === effectiveStudioSelection.agentId, + ) + : undefined; + const inferredScope = + selectedBinding || !state.studioProjects + ? null + : mostSpecificStudioScope(path, workspaceScopes, state.studioProjects); + const targetBinding = + selectedBinding ?? + workflow?.studioBindings?.find( + (candidate) => candidate.projectId === inferredScope?.projectId, + ); + const targetProjectId = targetBinding?.projectId ?? null; + const targetScope = studioScopeForAgentProject(path, targetProjectId); const live = state.sessions.filter((s) => s.status !== "exited"); const ownsPath = (s: HarnessSession): boolean => - s.boundWorkflowPath === path || isWithinDir(s.cwd, path); + (samePath(s.boundWorkflowPath ?? "", path) || isWithinDir(s.cwd, path)) && + sessionReachesFocus( + s, + path, + knownProjectRoots(), + targetProjectId, + targetScope?.cwd, + ); // Prefer the ACTIVE tab when it already owns the workflow, so running a // macro against the current agent never yanks the workbench to a sibling // session in the same workspace (e.g. re-visualize on a two-tab agent). @@ -2520,7 +2578,10 @@ export const App = (): JSX.Element => { } else { try { targetId = ( - await createSessionAt(sessionCwdForAgent(path), "claude-code") + await createSessionAt( + targetScope?.cwd ?? sessionCwdForAgent(path), + "claude-code", + ) ).id; } catch (err) { harness.showToast( @@ -2531,10 +2592,7 @@ export const App = (): JSX.Element => { } await harness.bindWorkflow(targetId, path); setFocusedAgentPath(path); - const workflow = state.workflows.find((candidate) => - samePath(candidate.path, path), - ); - if (workflow) selectStudioAgent(workflow); + if (workflow) selectStudioAgent(workflow, targetBinding); return targetId; }; @@ -2713,20 +2771,12 @@ export const App = (): JSX.Element => { sessions={state.sessions} pendingWorkspaces={harness.pendingWorkspaces} activeSessionId={harness.activeSessionId} - focusedAgentPath={ - atMapAltitude ? null : effectiveFocusedAgentPath - } + focusedAgentPath={atMapAltitude ? null : effectiveFocusedAgentPath} workspaceScopes={state.workspaceScopes} studioProjects={state.studioProjects} studioSelection={planFirstSelection} selectedWorkspaceKey={selectedProject?.workspaceKey ?? null} onSelectWorkspace={handleSelectWorkspace} - onSelectAgentMap={(projectId, root, label) => { - const scope = workspaceScopes.find( - (candidate) => candidate.projectId === projectId, - ); - if (scope) handleSelectWorkspace(scope.workspaceKey, root, label); - }} onSelectStudioAgent={(workflow, projectId, agentId) => handleFocusAgent(workflow.path, { projectId, agentId }) } @@ -2768,16 +2818,13 @@ export const App = (): JSX.Element => { closedProjects={harness.closedProjects} unsearchedCheckouts={harness.unsearchedCheckouts} onRemoveProject={async (root) => { - if ( - selectedProject && - samePath(selectedProject.root, root) - ) { + if (selectedProject && samePath(selectedProject.root, root)) { setSelectedProject(null); } const removedProjectId = workspaceScopes.find((scope) => samePath(scope.cwd, root), )?.projectId; - if (studioSelection?.projectId === removedProjectId) { + if (effectiveStudioSelection?.projectId === removedProjectId) { studioRestoreGenerationRef.current += 1; setStudioSelection(null); } @@ -2854,7 +2901,7 @@ export const App = (): JSX.Element => { // A project-row `+` explicitly asks to see a fresh coding // session, even when Plan Agents or a legacy project map is the // current altitude. Change views only after creation succeeds so - // a failed launch leaves the planner in place and resumable. + // a failed launch leaves the map and current conversation intact. const started = await startProjectSession( root, label, @@ -3001,12 +3048,18 @@ export const App = (): JSX.Element => {
0) + } onBack={composerCanCancel ? () => setComposing(false) : null} activeSession={sessionBarSession} sessionName={ @@ -3019,16 +3072,8 @@ export const App = (): JSX.Element => { : null } onRenameSession={renameSession} - boundWorkflowName={ - planningWorkspace ? null : (boundWorkflow?.name ?? null) - } - sessions={ - planningWorkspace - ? plannerSessions - : showWorkbench - ? focusTabs - : [] - } + boundWorkflowName={boundWorkflow?.name ?? null} + sessions={showWorkbench || projectMapSelected ? focusTabs : []} busySessionIds={harness.busySessionIds} onSelectSession={selectTab} labelOf={(session) => @@ -3045,31 +3090,23 @@ export const App = (): JSX.Element => { onExpandRail={ railCollapsed ? () => setRailCollapsed(false) : null } - onExpandRight={ - !agentMapUnavailable && rightCollapsed ? expandRightPane : null - } + onExpandRight={rightCollapsed ? expandRightPane : null} expandRightLabel={ - planningWorkspace ? "Agent Map" : "Expand canvas panel" + projectMapSelected ? "Agent Map" : "Expand canvas panel" } - showExpandRightLabel={isMobile && planningWorkspace} + showExpandRightLabel={isMobile && projectMapSelected} expandRightRef={rightPaneTriggerRef} subjectName={ - planningWorkspace + projectMapSelected ? (selectedStudioProject?.displayName ?? "Agent Map") : (focusedWorkflow?.name ?? (activeSession ? basenameOf(activeSession.cwd) : null)) } - newSessionPending={ - planningWorkspace - ? agentMapEntry.state.planner.status === "loading" - : siblingSessionPending - } + newSessionPending={siblingSessionPending} onNewSession={ - planningWorkspace - ? agentMapEntry.openFreshPlanner - : activeSession - ? () => handleStartSiblingSession(activeSession) - : null + newTabSource + ? () => handleStartSiblingSession(newTabSource) + : null } /* The agent action cluster shares the same row as the tabs. Its subject AND its gating are `rightPaneWorkflow` — the same @@ -3098,7 +3135,7 @@ export const App = (): JSX.Element => { that is the SAP-2931 trap itself — the verbs staying live against the bound agent while the pane showed another. */ actions={ - !planningWorkspace && rightPaneWorkflow ? ( + !atMapAltitude && rightPaneWorkflow ? ( { />
- {agentMapUnavailable ? ( - - Retry - - } - /> - ) : planningWorkspace ? ( - agentMapEntry.state.planner.status === "error" ? ( - - Retry session - - } - /> - ) : agentMapEntry.state.planner.status === "loading" ? ( - - ) : activePlannerSession?.planning ? ( - /* Agent Map planning is still an ordinary coding-agent - session. Keep the exact same raw CLI surface used for - every agent: trust/auth prompts, slash commands, tool - output, and provider chrome must remain visible rather - than being replaced by a transcript/composer facsimile. */ -
-
- -
-
- ) : ( - - New planning session - - } - /> - ) - ) : showReview && reviewSummary ? ( + {showReview && reviewSummary ? ( { }} onClose={() => setReviewSummary(null)} /> - ) : showDead && activeSession ? ( + ) : showDead && conversationSession ? ( void harness.resumeSession(activeSession.id)} + onResume={() => + void harness.resumeSession(conversationSession.id) + } onContinue={() => void harness.rehydrateSession({ - cwd: activeSession.cwd, - harness: activeSession.harness, - from: activeSession.id, + cwd: conversationSession.cwd, + harness: conversationSession.harness, + from: conversationSession.id, }) } - onClose={() => void harness.closeSession(activeSession.id)} + onClose={() => + void harness.closeSession(conversationSession.id) + } /> ) : showAgentEmpty && focusedWorkflow ? ( /* Honest absence: no session that can WORK on this agent — its @@ -3274,6 +3232,14 @@ export const App = (): JSX.Element => { } /> + ) : showMapConversationEmpty ? ( + ) : showProjectStarting && startingProject ? ( /* E3.2: a project you can select but not talk to is the failure. Its first session is on the way — say so, instead @@ -3286,13 +3252,13 @@ export const App = (): JSX.Element => { title={`Starting a session in ${startingProject.label}…`} body="Sessions boot at the project root, so the coding agent comes up with this project's instructions and skills." /> - ) : showWorkbench && harness.activeSessionId ? ( + ) : showWorkbench && conversationSession ? (
@@ -3338,29 +3304,29 @@ export const App = (): JSX.Element => { !isMobile && !rightPaneSuppressedByComposer && !canvasExpanded && ( -
- )} +
+ )} {isMobile && !rightCollapsed && (
{ role="tablist" aria-label="Right pane" > - {/* THE WAY BACK UP. The map was a one-way door: you could drill - into an agent and never return, which is most of why it felt - like a dead end. Derived from containment (not from the click - that got here), so the same door exists whether the agent was - reached from the rail or from a map node. */} - {upToProject && ( - - )} {/* Steps are an AGENT's steps. At map altitude there is no meaningful step list for a whole project, and a tab that @@ -3463,7 +3403,8 @@ export const App = (): JSX.Element => { aria-label={secretsDisabled ?? undefined} data-tooltip={secretsDisabled ?? undefined} className={ - "right-pane-tab" + (shownTab === "secrets" ? " is-active" : "") + "right-pane-tab" + + (shownTab === "secrets" ? " is-active" : "") } onClick={() => setRightTab("secrets")} data-testid="right-tab-secrets" @@ -3501,20 +3442,20 @@ export const App = (): JSX.Element => { )} {/* Full view belongs to the graph surface currently shown: Agent Map at project altitude, Canvas / Focus below it. */} - {(!atMapAltitude || planningWorkspace) && ( + {(!atMapAltitude || projectMapSelected) && (
diff --git a/packages/harness/web/src/components/AgentMapInspector.tsx b/packages/harness/web/src/components/AgentMapInspector.tsx index 2e02a6dd7..07f348a78 100644 --- a/packages/harness/web/src/components/AgentMapInspector.tsx +++ b/packages/harness/web/src/components/AgentMapInspector.tsx @@ -27,7 +27,6 @@ export function AgentMapInspector({ relationship.fromNodeId === node.id || relationship.toNodeId === node.id, ); const latest = latestNodeAttribution(snapshot, node.id); - const assignment = latest?.actor.assignment; // Plan-node names are user-authored across every kind; `agent` is the // USER_NAMED_OBJECTS privacy marker, not a claim about node.kind. return ( @@ -104,14 +103,7 @@ export function AgentMapInspector({

Latest change

- {latest.actor.role === "map-planner" - ? "Map planner" - : "Agent builder"} - {assignment?.kind === "planned" - ? " · planned assignment" - : assignment?.kind === "unplanned" - ? " · unplanned" - : ""} + Project agent {` · ${new Date(latest.acceptedAt).toLocaleString()}`}

diff --git a/packages/harness/web/src/components/ProjectTreeRows.tsx b/packages/harness/web/src/components/ProjectTreeRows.tsx index ef2f957ea..e7d18ad68 100644 --- a/packages/harness/web/src/components/ProjectTreeRows.tsx +++ b/packages/harness/web/src/components/ProjectTreeRows.tsx @@ -119,39 +119,6 @@ function dragSourceProps( export const projectKey = (root: string): string => `project:${root}`; export const dirKey = (path: string): string => `dir:${path}`; -/** The durable project's pinned first child. It is a workspace, not an agent. */ -export function AgentMapRow({ - selected, - onSelect, -}: { - selected: boolean; - onSelect: () => void; -}): JSX.Element { - return ( -
-
- ); -} - /** * The row's left slot: identity at rest, disclosure on hover. * @@ -365,9 +332,9 @@ export function ProjectRow({ disclosable = true, mainTestid, tooltip, + projectViewLabel = "dependency graph", busy = false, drag, - disclosureOnly = false, }: { label: string; root: string; @@ -400,13 +367,13 @@ export function ProjectRow({ disclosable?: boolean; mainTestid?: string; tooltip?: string; + /** The project-level view selected by the label. */ + projectViewLabel?: string; busy?: boolean; /** The project row is a DROP TARGET (the root is a directory like any other), * never a drag source: moving the folder the project IS would move the * project, which is what removing and adding one is for. */ drag?: RailDrag; - /** Plan-first project labels disclose children instead of selecting a child. */ - disclosureOnly?: boolean; }): JSX.Element { const agentPath = rootAgent?.workflow.path ?? null; // The row's identity, and the click that follows from it, are settled at the @@ -475,13 +442,11 @@ export function ProjectRow({ merged row through the trailing control the rail passes, so nothing is lost, and the two subjects stop competing for one click. */ onClick={ - disclosureOnly - ? onToggleCollapsed - : focusTarget - ? () => onFocusAgent(focusTarget) - : workspaceKey - ? () => onSelectProject(workspaceKey, root, label) - : undefined + focusTarget + ? () => onFocusAgent(focusTarget) + : workspaceKey + ? () => onSelectProject(workspaceKey, root, label) + : undefined } /* DOUBLE-CLICK TOGGLES DISCLOSURE — the platform convention for a disclosure row, and its absence read as breakage: the chevron was @@ -513,38 +478,29 @@ export function ProjectRow({ is; the title answers where it lives. */ title={root} aria-pressed={ - disclosureOnly - ? undefined - : focusTarget - ? busy - ? undefined - : isFocused - : workspaceKey - ? selected - : undefined + focusTarget + ? busy + ? undefined + : isFocused + : workspaceKey + ? selected + : undefined } - aria-expanded={disclosureOnly ? !collapsed : undefined} aria-busy={busy ? true : undefined} aria-label={ - disclosureOnly - ? `${collapsed ? "Expand" : "Collapse"} ${label}` - : focusTarget - ? `Focus ${label}` - : workspaceKey - ? `Open dependency graph for ${label}` - : undefined + focusTarget + ? `Focus ${label}` + : workspaceKey + ? `Open ${projectViewLabel} for ${label}` + : undefined } data-tooltip={ tooltip ?? - (disclosureOnly - ? collapsed - ? "Expand" - : "Collapse" - : focusTarget - ? "Focus this agent" - : workspaceKey - ? "Open dependency graph" - : undefined) + (focusTarget + ? "Focus this agent" + : workspaceKey + ? `Open ${projectViewLabel}` + : undefined) } > {label} diff --git a/packages/harness/web/src/components/SessionBar.tsx b/packages/harness/web/src/components/SessionBar.tsx index 9fac5ece6..504ea7cd8 100644 --- a/packages/harness/web/src/components/SessionBar.tsx +++ b/packages/harness/web/src/components/SessionBar.tsx @@ -20,9 +20,6 @@ function workspaceLabelOf(path: string): string { const EMPTY_BUSY_SESSION_IDS: ReadonlySet = new Set(); interface SessionBarProps { - /** Planner sessions keep normal tabs/rename/end but have no meaningful - * filesystem path or editor action in this workspace. */ - planning?: boolean; /** The main panel is showing the Overview/intro, not a session. */ overviewMode?: boolean; /** Set while an agent is open whose workspace has no live session. */ @@ -86,7 +83,6 @@ interface SessionBarProps { * its caret, while agent actions remain right-anchored on the same row. */ export function SessionBar({ - planning = false, overviewMode = false, openedAgentName = null, reviewTitle = null, @@ -219,18 +215,19 @@ export function SessionBar({ ) : null - ) : activeSession && - activeSession.status !== "exited" && - sessions.length > 0 && + ) : sessions.length > 0 && onSelectSession && onNewSession && labelOf ? ( setMenuOpen((open) => !open)} menuTriggerRef={menuTriggerRef} menuTooltip={ - planning - ? `${HARNESS_LABELS[activeSession.harness]} · Agent Map` - : `${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}` + activeSession + ? `${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}` + : undefined } renaming={renaming} renameDraft={renameDraft} @@ -274,11 +271,7 @@ export function SessionBar({ data-testid="session-menu" aria-haspopup="menu" aria-expanded={menuOpen} - data-tooltip={ - planning - ? `${HARNESS_LABELS[activeSession.harness]} · Agent Map` - : `${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}` - } + data-tooltip={`${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}`} onClick={() => setMenuOpen((open) => !open)} {...trackingAttrs({ object: "session" })} > @@ -332,22 +325,20 @@ export function SessionBar({
)} - {!planning && ( - - )} + - {!planning && ( - - )} + {activeSession.status !== "exited" && ( - )} - - {planning && onNewSession && !activeSession && !composing && ( - - )} + {onNewSession && + activeSession?.status === "exited" && + sessions.length === 0 && + !composing && ( + + )} {actions} @@ -455,11 +428,6 @@ export function SessionBar({ {confirmingClose && activeSession && ( setConfirmingClose(false)} onConfirm={() => { setConfirmingClose(false); diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx index b4f2efcf4..26e55a25f 100644 --- a/packages/harness/web/src/components/WorkflowsRail.tsx +++ b/packages/harness/web/src/components/WorkflowsRail.tsx @@ -39,7 +39,6 @@ import { describeUpdateOutcome, getDesktopBridge } from "../lib/desktop"; import { ProjectRow, ProjectTreeRows, - AgentMapRow, dirKey, projectKey, } from "./ProjectTreeRows"; @@ -73,6 +72,7 @@ import { buildProjectTree, projectIsEmpty, projectRoots, + projectSessionRoot, unrootedAgents, } from "../lib/project-tree"; import { @@ -123,7 +123,6 @@ interface WorkflowsRailProps { root: string, label: string, ) => void; - onSelectAgentMap: (projectId: string, root: string, label: string) => void; onSelectStudioAgent: ( workflow: WorkflowInfo, projectId: string, @@ -267,8 +266,8 @@ const SORT_LABELS: Record = { * * The adjacent `+` has one stable meaning: start a coding-agent session at this * project's root. This menu keeps the lower-frequency, explicitly named - * project actions. On legacy servers that includes scaffolding a Sapiom agent; - * on current plan-first projects, agent creation remains owned by Plan Agents. + * project actions, including creating or scaffolding a Sapiom agent. Opening + * the Agent Map never takes ownership of those ordinary build controls. * * Named items say it instead. Each carries the project's own label, so the * subject is read rather than inferred, and the destructive one is last and @@ -284,10 +283,9 @@ function ProjectRowMenu({ onRemove, }: { label: string; - /** The compatibility create action this project currently offers, or null - * when its Agent Map owns creation / while one is mid-creation. A bare - * project (sessions, no agent) scaffolds into its existing session; every - * other project starts a new one rooted at the project. */ + /** The create action this project currently offers, or null while one is + * mid-creation. A bare project (sessions, no agent) scaffolds into its + * existing session; every other project starts a new one at the root. */ create: { kind: "create" | "scaffold"; testid: string; @@ -463,7 +461,6 @@ export function WorkflowsRail({ studioSelection, selectedWorkspaceKey, onSelectWorkspace, - onSelectAgentMap, onSelectStudioAgent, onFocusAgent, onOpenPalette, @@ -698,10 +695,29 @@ export function WorkflowsRail({ const shown = (path: string): boolean => !hiddenByClosedProject(path, closedProjects, openRoots); const visibleWorkflows = workflows.filter((workflow) => shown(workflow.path)); + const durableRootCandidates = (workspaceScopes ?? []).flatMap((scope) => + scope.projectId ? [{ projectId: scope.projectId, cwd: scope.cwd }] : [], + ); + const rootedSessions = sessions.flatMap((session) => { + if (!session.agentMapIdentity) return [session]; + const root = projectSessionRoot( + { + cwd: session.cwd, + projectId: session.agentMapIdentity.projectId, + }, + durableRootCandidates, + ); + // Match the server scope catalog: a neutral session contributes its + // trusted durable root, never a descendant cwd or a stale binding. + return root ? [{ ...session, cwd: root }] : []; + }); const roots = projectRoots({ recentDirs, - sessions, + sessions: rootedSessions, pendingCwds, + pinnedRoots: (workspaceScopes ?? []) + .filter((scope) => scope.projectId != null) + .map((scope) => scope.cwd), // Hidden agents are deliberately NOT passed. A removed project's agents are // not on screen, so they cannot be the reason a folder is filed away. agentPaths: visibleWorkflows.map((workflow) => workflow.path), @@ -738,8 +754,8 @@ export function WorkflowsRail({ * without touching disk is the Group axis, above.) * * Offered ONLY on the project axis. The plan comes from `lib/agent-move.ts`; - * the endpoint guards itself again, so a refusal can still arrive for a plan - * this rail blessed — a planner is not a permission system. + * the endpoint guards itself again, so a refusal can still arrive for a move + * this rail considered valid — client geometry is not server authority. */ const drag: RailDrag | undefined = axis === "project" @@ -1232,12 +1248,10 @@ export function WorkflowsRail({ const studioProject = studioProjects?.find( (candidate) => candidate.projectId === workspaceScope?.projectId, ); - // Current servers issue a durable Studio project for every scope, - // and that project's Agent Map owns creation. The absent case is a - // compatibility payload, not a second creation mode. Keep ownership - // independent of the selected axis so Group cannot restore a bypass. - const mapOwnsCreation = studioProject != null; - const planFirst = axis === "project" && mapOwnsCreation; + // Current servers issue a durable Studio project for every scope. + // Its project label owns Agent Map navigation; creation remains an + // ordinary project action available beside that read-only view. + const planFirst = studioProject != null; const mapSelected = planFirst && studioSelection?.kind === "agent-map" && @@ -1311,18 +1325,20 @@ export function WorkflowsRail({ workspaceScope?.workspaceKey === selectedWorkspaceKey) } onSelectProject={onSelectWorkspace} + projectViewLabel={planFirst ? "Agent Map" : undefined} focusedAgentPath={focusedAgentPath} onFocusAgent={focusProjectAgent} - focusable={creating || bare != null} + focusable={!planFirst && (creating || bare != null)} disclosable={ planFirst - ? true + ? project.rootAgent != null || + project.dirs.length > 0 || + project.agents.length > 0 : axis === "group" ? showGroups || soloAgents.length > 0 : project.dirs.length > 0 || project.agents.length > 0 } busy={creating} - disclosureOnly={planFirst} drag={drag} mainTestid={ workspaceScope @@ -1387,7 +1403,7 @@ export function WorkflowsRail({ before the overflow menu. Its accessible name supplies the noun the glyph cannot: this starts a coding-agent SESSION at the project root. It does not scaffold a - Sapiom agent or bypass Plan Agents. */} + Sapiom agent. */} {!pending && ( -
- )} +
+
{/* THE BOUNDARY'S OWN ANSWER, when there is one. A scan stops at every separate checkout, so a folder that is not itself a repo but holds several clones finds @@ -1578,7 +1577,7 @@ export function WorkflowsRail({ collapsedKeys={collapsedKeys} onToggleCollapsed={toggleCollapsed} focusedAgentPath={focusedAgentPath} - onFocusAgent={onFocusAgent} + onFocusAgent={focusProjectAgent} onCreate={() => { const label = nextGroupLabel(groupNodes); railGroups.edit(project.root, groupAgents, (state) => @@ -1629,7 +1628,7 @@ export function WorkflowsRail({ prefix={node?.prefix ?? ""} prefixFull={node?.prefixFull ?? ""} isFocused={workflow.path === focusedAgentPath} - onFocus={onFocusAgent} + onFocus={focusProjectAgent} /> ); })} diff --git a/packages/harness/web/src/lib/agent-map.test.ts b/packages/harness/web/src/lib/agent-map.test.ts index 9d7625c32..65c8a2fc8 100644 --- a/packages/harness/web/src/lib/agent-map.test.ts +++ b/packages/harness/web/src/lib/agent-map.test.ts @@ -264,6 +264,25 @@ describe("mostSpecificStudioScope", () => { )?.projectId, ).toBe(nestedProjectId); }); + + it("fails closed when two durable projects claim the same nearest root", () => { + const otherProjectId = "project_00000000-0000-4000-8000-000000000002"; + + expect( + mostSpecificStudioScope( + "/work/services/agent", + [ + { workspaceKey: "scope-a", cwd: "/work/services", projectId }, + { + workspaceKey: "scope-b", + cwd: "/work/services", + projectId: otherProjectId, + }, + ], + [validResponseProject(projectId), validResponseProject(otherProjectId)], + ), + ).toBeNull(); + }); }); function validResponseProject(id: string): StudioProjectSummary { diff --git a/packages/harness/web/src/lib/agent-map.ts b/packages/harness/web/src/lib/agent-map.ts index 41aa0f308..cda16d4b3 100644 --- a/packages/harness/web/src/lib/agent-map.ts +++ b/packages/harness/web/src/lib/agent-map.ts @@ -330,23 +330,25 @@ export function mostSpecificStudioScope( projects: readonly StudioProjectSummary[], ): (WorkspaceScopeSummary & { projectId: string }) | null { const projectIds = new Set(projects.map((project) => project.projectId)); - return ( - scopes - .filter((scope): scope is WorkspaceScopeSummary & { projectId: string } => - Boolean( - scope.projectId && - projectIds.has(scope.projectId) && - isWithinDir(scope.cwd, targetPath), - ), - ) - .map((scope) => ({ - scope, - depth: stripTrailingSep(scope.cwd).length, - })) - .sort( - (left, right) => - right.depth - left.depth || - left.scope.projectId.localeCompare(right.scope.projectId), - )[0]?.scope ?? null - ); + const matches = scopes + .filter((scope): scope is WorkspaceScopeSummary & { projectId: string } => + Boolean( + scope.projectId && + projectIds.has(scope.projectId) && + isWithinDir(scope.cwd, targetPath), + ), + ) + .map((scope) => ({ + scope, + depth: stripTrailingSep(scope.cwd).length, + })); + if (matches.length === 0) return null; + const depth = Math.max(...matches.map((match) => match.depth)); + const nearest = matches.filter((match) => match.depth === depth); + if (new Set(nearest.map(({ scope }) => scope.projectId)).size !== 1) { + return null; + } + return nearest.sort((left, right) => + left.scope.workspaceKey.localeCompare(right.scope.workspaceKey), + )[0]!.scope; } diff --git a/packages/harness/web/src/lib/api.test.ts b/packages/harness/web/src/lib/api.test.ts index 7a4eae7f0..1e0a13e5e 100644 --- a/packages/harness/web/src/lib/api.test.ts +++ b/packages/harness/web/src/lib/api.test.ts @@ -505,18 +505,15 @@ describe("RealApi planner mutations", () => { location: { search: "" }, }); const accepted = { - identity: { - projectId: "project-1", - sessionId: "planner-1", - userId: "user-1", - role: "map-planner" as const, - }, - greeting: { status: "skipped" as const, reason: "user-proceeded" }, + projectId: "project-1", + targetSessionId: "planner-1", + userId: "user-1", + bootstrap: { status: "skipped" as const, reason: "user-proceeded" }, queuedInputIds: ["input-1"], }; const retrying = { ...accepted, - greeting: { + bootstrap: { status: "generating" as const, attemptId: "attempt-2", }, diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 207985f29..46225cee5 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -52,6 +52,7 @@ import type { PlannerSessionMetadataResponse, PlannerSessionRequest, PlannerSessionResponse, + ProjectBootstrapMetadata, PutStudioCurrentWorkspaceRequest, StudioCurrentWorkspaceResponse, StudioProjectId, @@ -383,15 +384,13 @@ export interface HarnessApi { projectId: StudioProjectId, request: PlannerSessionRequest, ): Promise; - /** Compatibility surface for coordinator-driven clients. The Studio renders - * the planner's raw CLI and does not project this protocol into a second - * transcript/composer UI. */ + /** @deprecated Rolling alias into ordinary project-session input. */ sendPlannerMessage( projectId: StudioProjectId, sessionId: string, request: PlannerMessageRequest, ): Promise; - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap. */ retryPlannerGreeting( projectId: StudioProjectId, sessionId: string, @@ -2262,6 +2261,60 @@ export class MockApi implements HarnessApi { })); } + /** Mirror the server's neutral project principal in opt-in Studio fixtures. */ + private studioSession( + session: HarnessSession, + projects: readonly StudioProjectSummary[] | undefined, + ): HarnessSession { + if (!projects) return session; + const projectIds = new Set(projects.map((project) => project.projectId)); + const matches = this.workspaceScopes() + .filter( + (scope) => + scope.projectId && + projectIds.has(scope.projectId) && + isWithinDir(scope.cwd, session.cwd), + ) + .sort( + (left, right) => + right.cwd.length - left.cwd.length || + left.cwd.localeCompare(right.cwd), + ); + const nearestDepth = matches[0]?.cwd.length; + const nearestProjectIds = new Set( + matches + .filter((scope) => scope.cwd.length === nearestDepth) + .map((scope) => scope.projectId), + ); + const projectId = + nearestProjectIds.size === 1 ? matches[0]?.projectId : undefined; + if (!projectId || !projectIds.has(projectId)) return session; + const usePlanAgentsFixture = + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get( + "mockPlanAgentsSession", + ) === "1" && + session.id === "sess-boot"; + const useRestoreBindingConflictFixture = + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get( + "mockRestoreBindingConflict", + ) === "1" && + session.id === "sess-boot"; + return { + ...session, + ...(usePlanAgentsFixture ? { title: "Plan Agents" } : {}), + ...(useRestoreBindingConflictFixture + ? { boundWorkflowPath: "/Users/demo/polsia/services/workers" } + : {}), + agentMapIdentity: { + projectId, + userId: "user_mock", + sessionId: session.id, + }, + }; + } + private studioWorkflows(): WorkflowInfo[] { const scopes = this.workspaceScopes(); return this.workflows.map((workflow, index) => { @@ -2353,7 +2406,9 @@ export class MockApi implements HarnessApi { ) === "off" ? false : true, - sessions: this.sessions, + sessions: this.sessions.map((session) => + this.studioSession(session, studioProjects), + ), workflows: this.studioWorkflows(), workspaceScopes: this.workspaceScopes(), ...(studioProjects ? { studioProjects } : {}), @@ -2541,23 +2596,23 @@ export class MockApi implements HarnessApi { if (failure === "error") { throw new ApiError( 503, - "Planner service is unavailable", - "Planner service is unavailable", + "Project session service is unavailable", + "Project session service is unavailable", ); } if (failure === "unauthorized") { throw new ApiError( 403, - "Planner project is not available", - "Planner project is not available", + "Project session is not available", + "Project session is not available", ); } const existing = this.sessions .filter( (session) => session.status !== "exited" && - session.planning?.identity.projectId === projectId && - session.planning.identity.userId === "user_mock", + session.agentMapIdentity?.projectId === projectId && + session.agentMapIdentity.userId === "user_mock", ) .sort((left, right) => right.lastActiveAt.localeCompare(left.lastActiveAt), @@ -2584,14 +2639,16 @@ export class MockApi implements HarnessApi { typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("mockGreeting"); - session.planning = { - identity: { - projectId, - sessionId: session.id, - userId: "user_mock", - role: "map-planner", - }, - greeting: + session.agentMapIdentity = { + projectId, + sessionId: session.id, + userId: "user_mock", + }; + session.projectBootstrap = { + projectId, + targetSessionId: session.id, + userId: "user_mock", + bootstrap: greetingFixture === "generating" ? { status: "generating", attemptId: "attempt_mock" } : greetingFixture === "failed" @@ -2616,7 +2673,7 @@ export class MockApi implements HarnessApi { startedAt: now, endedAt: null, turns: - session.planning.greeting.status === "delivered" + session.projectBootstrap.bootstrap.status === "delivered" ? [ { index: 1, @@ -2624,8 +2681,8 @@ export class MockApi implements HarnessApi { promptAt: null, toolCalls: [], assistantText: - "I’m your project planning agent. We’ll plan the agents, responsibilities, data flow, resources, and connectors together. What kind of agent architecture do you want to build?", - model: "mock-planner", + "I inspected the available project context and kept the shared Agent Map honest. What would you like to build?", + model: "mock-project-agent", usage: null, completedAt: now, incomplete: false, @@ -2633,7 +2690,8 @@ export class MockApi implements HarnessApi { ] : [], turnCount: 0, - eventCount: session.planning.greeting.status === "delivered" ? 2 : 0, + eventCount: + session.projectBootstrap.bootstrap.status === "delivered" ? 2 : 0, reconstructed: true, archivedAt: null, limitations: [], @@ -2649,25 +2707,37 @@ export class MockApi implements HarnessApi { const session = this.sessions.find( (candidate) => candidate.id === sessionId, ); - if (session?.planning?.identity.projectId !== projectId) { + const identity = session?.agentMapIdentity; + if ( + !session || + identity?.projectId !== projectId || + identity.sessionId !== sessionId || + identity.userId !== "user_mock" + ) { throw new ApiError( 403, - "Forbidden planner session", - "Forbidden planner session", + "Forbidden project session", + "Forbidden project session", ); } const inputId = `input_mock_${Date.now()}`; - session.planning = { - ...session.planning, - greeting: - session.planning.greeting.status === "delivered" || - session.planning.greeting.status === "skipped" - ? session.planning.greeting - : { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [...session.planning.queuedInputIds, inputId], - }; + const bootstrap = session.projectBootstrap; + const shouldQueue = Boolean( + bootstrap && + bootstrap.bootstrap.status !== "delivered" && + bootstrap.bootstrap.status !== "skipped", + ); + if (bootstrap && shouldQueue) { + session.projectBootstrap = { + ...bootstrap, + bootstrap: { status: "skipped", reason: "user-proceeded" }, + queuedInputIds: [...bootstrap.queuedInputIds, inputId], + }; + } await this.injectInput(sessionId, { text: request.text }); - const accepted = structuredClone(session.planning); + const accepted = session.projectBootstrap + ? structuredClone(session.projectBootstrap) + : null; const project = this.studioProjects()?.find( (candidate) => candidate.projectId === projectId, ); @@ -2681,7 +2751,7 @@ export class MockApi implements HarnessApi { (candidate) => candidate.id === sessionId, ); const record = this.plannerSessionRecords.get(sessionId); - if (!current?.planning || !record) return; + if (!current || !record) return; const completedAt = new Date().toISOString(); const turns = [ ...record.turns, @@ -2691,8 +2761,8 @@ export class MockApi implements HarnessApi { promptAt: completedAt, toolCalls: [], assistantText: - "Let’s start by clarifying the outcome, the actors involved, and the information they need to exchange.", - model: "mock-planner", + "I’ll keep the shared Agent Map current where this work changes project architecture, and proceed directly where the request is already build-ready.", + model: "mock-project-agent", usage: null, completedAt, incomplete: false, @@ -2704,19 +2774,21 @@ export class MockApi implements HarnessApi { turnCount: record.turnCount + 1, eventCount: record.eventCount + 2, }); - current.planning = { - ...current.planning, - queuedInputIds: current.planning.queuedInputIds.filter( - (candidate) => candidate !== inputId, - ), - }; + if (shouldQueue && current.projectBootstrap) { + current.projectBootstrap = { + ...current.projectBootstrap, + queuedInputIds: current.projectBootstrap.queuedInputIds.filter( + (candidate) => candidate !== inputId, + ), + }; + } void import("./events").then(({ publishMockBusMessage }) => { if (goldenFixtureEnabled && !this.agentMapSnapshots.has(projectId)) { if (!project) return; const fixture = goldenAgentMapFixture( project, new Date().toISOString(), - accepted.identity.userId, + identity.userId, sessionId, ); this.agentMapSnapshots.set(projectId, fixture.snapshot); @@ -2744,11 +2816,17 @@ export class MockApi implements HarnessApi { const session = this.sessions.find( (candidate) => candidate.id === sessionId, ); - if (session?.planning?.identity.projectId !== projectId) { + const identity = session?.agentMapIdentity; + if ( + !session || + identity?.projectId !== projectId || + identity.sessionId !== sessionId || + identity.userId !== "user_mock" + ) { throw new ApiError( 403, - "Forbidden planner session", - "Forbidden planner session", + "Forbidden project session", + "Forbidden project session", ); } const retryFailure = @@ -2762,32 +2840,36 @@ export class MockApi implements HarnessApi { "Greeting retry is temporarily unavailable", ); } + const metadata = session.projectBootstrap; if ( - session.planning.greeting.status !== "failed" || - !session.planning.greeting.retryable || - session.planning.queuedInputIds.length > 0 + !metadata || + metadata.bootstrap.status !== "failed" || + !metadata.bootstrap.retryable || + metadata.queuedInputIds.length > 0 ) { throw new ApiError( 409, - "Greeting retry is not available", - "Greeting retry is not available", + "Project bootstrap retry is not available", + "Project bootstrap retry is not available", ); } - session.planning = { - ...session.planning, - greeting: { status: "generating", attemptId: "attempt_mock_retry" }, + session.projectBootstrap = { + ...metadata, + bootstrap: { status: "generating", attemptId: "attempt_mock_retry" }, }; - const retrying = structuredClone(session.planning); + const retrying: ProjectBootstrapMetadata = structuredClone( + session.projectBootstrap, + ); setTimeout(() => { const current = this.sessions.find( (candidate) => candidate.id === sessionId, ); const record = this.plannerSessionRecords.get(sessionId); - if (!current?.planning || !record) return; + if (!current?.projectBootstrap || !record) return; const completedAt = new Date().toISOString(); - current.planning = { - ...current.planning, - greeting: { + current.projectBootstrap = { + ...current.projectBootstrap, + bootstrap: { status: "delivered", messageId: "message_mock_greeting_retry", }, @@ -2802,8 +2884,8 @@ export class MockApi implements HarnessApi { promptAt: null, toolCalls: [], assistantText: - "I’m your project planning agent. What kind of agent architecture do you want to build?", - model: "mock-planner", + "I inspected the available project context and kept the shared Agent Map honest. What would you like to build?", + model: "mock-project-agent", usage: null, completedAt, incomplete: false, @@ -3020,7 +3102,16 @@ export class MockApi implements HarnessApi { } async createSession(req: CreateSessionRequest): Promise { - await delay(300); + const requestedDelay = + typeof window === "undefined" + ? null + : (window as unknown as { __MOCK_CREATE_SESSION_DELAY_MS__?: number }) + .__MOCK_CREATE_SESSION_DELAY_MS__; + await delay( + typeof requestedDelay === "number" && requestedDelay >= 0 + ? requestedDelay + : 300, + ); if (typeof window !== "undefined") { const win = window as unknown as { __HARNESS_TEST__?: Record; @@ -3040,7 +3131,7 @@ export class MockApi implements HarnessApi { throw new Error("mock: couldn't create session"); } } - const session: HarnessSession = { + let session: HarnessSession = { id: `sess-mock-${this.sessions.length + 1}`, agentSessionId: null, boundWorkflowPath: null, @@ -3060,6 +3151,10 @@ export class MockApi implements HarnessApi { ready: false, }; this.sessions = [...this.sessions, session]; + session = this.studioSession(session, this.studioProjects()); + this.sessions = this.sessions.map((candidate) => + candidate.id === session.id ? session : candidate, + ); // Mirror the real server: create answers "starting", and the event bus // promotes the session to running/ready moments later. Without this, a // mock-created session would stay unready forever and gate the action @@ -3667,8 +3762,51 @@ export class MockApi implements HarnessApi { // reload can (and in the spec does) start before this delay resolves. A // write behind the delay would lose the dismiss to its own fixture. if (patch.helpSeen !== undefined) writeMockHelpSeen(patch.helpSeen); + const previousRecentDirs = new Set(this.settings.recentDirs); await delay(); this.settings = { ...this.settings, ...patch }; + // Opt-in parity fixture for the production project-open lifecycle: a newly + // durable project gets one ordinary first session titled Plan Agents. This + // is intentionally not routed through the mock create-session endpoint; + // the server owns it, so a project-name click still makes zero client + // session requests. + const autoPlanAgents = + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get("mockAutoPlanAgents") === + "1"; + const addedRoots = (patch.recentDirs ?? []).filter( + (root) => !previousRecentDirs.has(root), + ); + if (autoPlanAgents && addedRoots.length > 0) { + const { publishMockBusMessage } = await import("./events"); + for (const root of addedRoots) { + if (this.sessions.some((session) => samePath(session.cwd, root))) { + continue; + } + const projectId = this.studioProjectId(root); + const id = `sess-plan-agents-${this.sessions.length + 1}`; + const now = new Date().toISOString(); + const session: HarnessSession = { + id, + agentSessionId: null, + boundWorkflowPath: null, + harness: "claude-code", + cwd: root, + title: "Plan Agents", + status: "running", + createdAt: now, + lastActiveAt: now, + ready: true, + agentMapIdentity: { + projectId, + userId: "user_mock", + sessionId: id, + }, + }; + this.sessions = [...this.sessions, session]; + publishMockBusMessage({ type: "session.status", session }); + } + } return this.settings; } diff --git a/packages/harness/web/src/lib/canvas-altitude.test.ts b/packages/harness/web/src/lib/canvas-altitude.test.ts index 35b986b70..52d275a35 100644 --- a/packages/harness/web/src/lib/canvas-altitude.test.ts +++ b/packages/harness/web/src/lib/canvas-altitude.test.ts @@ -5,7 +5,6 @@ import type { WorkspaceScopeSummary } from "@shared/system-graph"; import { canvasView, studioCanvasView, - projectAbove, projectRefForRoot, stepsDisabledReason, } from "./canvas-altitude"; @@ -42,33 +41,43 @@ describe("studioCanvasView", () => { const HOME = "/Users/demo"; const POLSIA = `${HOME}/polsia`; const ADS = `${POLSIA}/backend/src/agents/ads`; -const WORKERS = `${POLSIA}/services/workers`; const scopes: WorkspaceScopeSummary[] = [ { workspaceKey: "ws-polsia", cwd: POLSIA }, - { workspaceKey: "ws-workers", cwd: WORKERS }, ]; const polsia = { workspaceKey: "ws-polsia", root: POLSIA, label: "polsia" }; describe("canvasView: the project wins, and that is stated once", () => { it("is map altitude for a selected project", () => { - expect(canvasView(polsia, null)).toEqual({ altitude: "map", project: polsia }); + expect(canvasView(polsia, null)).toEqual({ + altitude: "map", + project: polsia, + }); }); it("is board altitude for a selected agent", () => { - expect(canvasView(null, ADS)).toEqual({ altitude: "board", agentPath: ADS }); + expect(canvasView(null, ADS)).toEqual({ + altitude: "board", + agentPath: ADS, + }); }); it("still resolves to ONE altitude when a door forgets to clear the other half", () => { // The two are mutually exclusive by construction. This is the case where // that construction has a hole: the answer must be a project's map, never // an agent's board drawn under a project's name. - expect(canvasView(polsia, ADS)).toEqual({ altitude: "map", project: polsia }); + expect(canvasView(polsia, ADS)).toEqual({ + altitude: "map", + project: polsia, + }); }); it("is board altitude with nothing selected at all", () => { - expect(canvasView(null, null)).toEqual({ altitude: "board", agentPath: null }); + expect(canvasView(null, null)).toEqual({ + altitude: "board", + agentPath: null, + }); }); }); @@ -78,7 +87,9 @@ describe("projectRefForRoot: the browser joins, it never invents a key", () => { }); it("matches on segment boundaries, so a trailing separator still joins", () => { - expect(projectRefForRoot(`${POLSIA}/`, "polsia", scopes)?.workspaceKey).toBe("ws-polsia"); + expect( + projectRefForRoot(`${POLSIA}/`, "polsia", scopes)?.workspaceKey, + ).toBe("ws-polsia"); }); it("never joins a neighbouring project by string prefix", () => { @@ -97,35 +108,6 @@ describe("projectRefForRoot: the browser joins, it never invents a key", () => { }); }); -describe("projectAbove: the way back is derived, not remembered", () => { - it("is the agent's project, whichever door reached the agent", () => { - expect(projectAbove(ADS, [POLSIA], scopes)).toEqual({ - workspaceKey: "ws-polsia", - root: POLSIA, - label: "polsia", - }); - }); - - it("takes the NEAREST project when roots overlap, the same one the session booted in", () => { - const nested = `${WORKERS}/queue`; - expect(projectAbove(nested, [POLSIA, WORKERS], scopes)?.workspaceKey).toBe("ws-workers"); - }); - - it("offers no way up for an agent outside every project", () => { - // `projectRootForAgent` falls back to the agent's own folder. Cutting up to - // that would open a map of one agent — itself. - expect(projectAbove(`${HOME}/scratch/bot`, [POLSIA], scopes)).toBeNull(); - }); - - it("offers no way up when the server issued no graph key for the project", () => { - expect(projectAbove(ADS, [POLSIA], [])).toBeNull(); - }); - - it("is null with nothing selected", () => { - expect(projectAbove(null, [POLSIA], scopes)).toBeNull(); - }); -}); - describe("stepsDisabledReason: a disabled tab says why", () => { it("gives the sentence at map altitude, not a bare boolean", () => { // A tab that silently shows the last agent's steps under a project's name diff --git a/packages/harness/web/src/lib/canvas-altitude.ts b/packages/harness/web/src/lib/canvas-altitude.ts index 96548bd25..cd54d6d0d 100644 --- a/packages/harness/web/src/lib/canvas-altitude.ts +++ b/packages/harness/web/src/lib/canvas-altitude.ts @@ -23,7 +23,6 @@ import type { WorkspaceKey, WorkspaceScopeSummary } from "@shared/system-graph"; import type { StudioWorkspaceSelection } from "@shared/agent-map"; import { basenameOf, samePath } from "./paths"; -import { projectRootForAgent } from "./session-scope"; /** A project as both surfaces need it: the rail's row and the graph's key. */ export interface ProjectRef { @@ -92,31 +91,11 @@ export function projectRefForRoot( if (!root) return null; const scope = scopes.find((candidate) => samePath(candidate.cwd, root)); if (!scope) return null; - return { workspaceKey: scope.workspaceKey, root, label: label ?? basenameOf(root) }; -} - -/** - * The project an agent's board can cut UP to, or null. - * - * DERIVED from containment on every call, never remembered from the click that - * drilled down. Remembering it would make the way back depend on how you got - * here — an agent reached from the rail would have no way up while the same - * agent reached from the map would — and it would go stale the moment a - * project is removed or an agent is moved. Derived, the way up is the same - * one door however you arrived, and it disappears exactly when the project does. - */ -export function projectAbove( - agentPath: string | null, - roots: readonly string[], - scopes: readonly WorkspaceScopeSummary[], -): ProjectRef | null { - if (!agentPath) return null; - const root = projectRootForAgent(agentPath, roots); - // `projectRootForAgent` falls back to the agent's own folder when no known - // root contains it. That is not a project, and offering "up" to it would - // send the user to a map of one agent — itself. - if (samePath(root, agentPath)) return null; - return projectRefForRoot(root, null, scopes); + return { + workspaceKey: scope.workspaceKey, + root, + label: label ?? basenameOf(root), + }; } /** diff --git a/packages/harness/web/src/lib/paths.ts b/packages/harness/web/src/lib/paths.ts index 89380524c..7d0dc4d30 100644 --- a/packages/harness/web/src/lib/paths.ts +++ b/packages/harness/web/src/lib/paths.ts @@ -1,111 +1,7 @@ /** - * Browser-side path helpers for the ABSOLUTE paths the server hands us. + * Browser compatibility export for the host-neutral path helpers. * - * The server builds them with `path.join`, so they arrive in the host's native - * shape — backslash-separated on Windows. The SPA cannot ask `node:path` which - * host that was; it infers the separator from the string itself, which works - * because a Windows absolute path always contains at least one `\` (`C:\…`) - * and a POSIX one never does. - * - * Joins preserve the input's native separator (what gets POSTed back must - * match what the server sent), but every COMPARISON normalizes both - * separators first: paths that were joined in the browser before this module - * existed shipped in mixed form (`C:\Users\x\projects/newsletter-autopilot`), - * and those still have to compare equal to their native spellings. - */ - -/** The separator `p` itself uses. `\` anywhere marks a Windows path — POSIX - * filenames may legally contain `\`, but never in the absolute paths the - * server supplies. */ -export function sepOf(p: string): "\\" | "/" { - return p.includes("\\") ? "\\" : "/"; -} - -/** `` in the root's native separator, with no doubled - * separator when the root carries a trailing one. */ -export function joinPath(root: string, name: string): string { - const trimmedRoot = root.trim().replace(/[\\/]+$/, ""); - return `${trimmedRoot}${sepOf(root)}${name.trim()}`; -} - -/** Last non-empty segment under either separator, or the input when it has - * none (a relative name is its own basename). */ -export function basenameOf(p: string): string { - return p.split(/[\\/]/).filter(Boolean).pop() ?? p; -} - -/** - * Parent of an absolute path, or null at a filesystem root (`/`, `C:\`, bare - * `C:`) and for separator-free relative strings. Mirrors `path.dirname` - * without pulling node:path into the browser bundle. - * - * Needed because GET /api/fs/list reports one level DOWN: a path can only - * learn whether it is itself an agent project by asking its parent. - */ -export function parentOf(input: string): string | null { - const trimmed = input.replace(/[\\/]+$/, ""); - if (trimmed === "" || /^[A-Za-z]:$/.test(trimmed)) return null; - const lastSep = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - if (lastSep < 0) return null; - const cut = trimmed.slice(0, lastSep); - // First-level paths keep their root spelled out — `/Users` → `/`, - // `C:\Users` → `C:\` — so the result is always itself a listable path. - if (/^[A-Za-z]:$/.test(cut)) return cut + trimmed[lastSep]; - return cut || "/"; -} - -/** `/a/b/` → `/a/b` under either separator, so a user's trailing slash never - * breaks a path comparison. Bare roots (`/`, `C:\`) pass through unchanged — - * stripping them would leave something that isn't a path. */ -export function stripTrailingSep(p: string): string { - const trimmed = p.replace(/[\\/]+$/, ""); - if (trimmed === p) return p; - if (trimmed === "") return p[0]; - if (/^[A-Za-z]:$/.test(trimmed)) return trimmed + p[trimmed.length]; - return trimmed; -} - -/** Whether `child` IS `parent` or sits beneath it — never a mere string - * prefix, so `/a/scratch-2` is not within `/a/scratch`. Separator-insensitive - * on both sides, so a mixed-form path still matches its native spelling. */ -/** - * Whether two paths name the same directory, ignoring separator form and a - * trailing separator. - * - * Needed because the client and the server no longer agree byte-for-byte: the - * server `path.resolve()`s every cwd it stores (server/cwd-normalize.ts) while - * the SPA holds whatever the user typed or a recentDirs entry recorded — so a - * `C:/…`-typed path, or one with a trailing slash, fails a raw `===` against - * the very session it just created (empty tab strip, unhighlighted rail row). + * Existing web imports stay stable while the browser and server consume the + * same implementation from src/shared. */ -export function samePath(a: string, b: string): boolean { - return stripTrailingSep(a.replace(/\\/g, "/")) === stripTrailingSep(b.replace(/\\/g, "/")); -} - -export function isWithinDir(parent: string, child: string): boolean { - const p = stripTrailingSep(parent.replace(/\\/g, "/")); - const c = stripTrailingSep(child.replace(/\\/g, "/")); - if (c === p) return true; - // A filesystem root keeps its trailing separator (stripTrailingSep's - // contract), so appending another would test "C://…" and never match — - // every session under a root-level workspace looked like an orphan. - return p.endsWith("/") ? c.startsWith(p) : c.startsWith(`${p}/`); -} - -/** Whether typed input is trying to be an absolute path (`/…`, `~…`, or a - * Windows drive like `C:\…` / `C:/…`) rather than a search query. */ -export function looksAbsolutePath(input: string): boolean { - return input.startsWith("/") || input.startsWith("~") || /^[A-Za-z]:[\\/]/.test(input); -} - -/** "/Users/…/onboarding-flow" — middle-truncates a long path so a chip row - * never hard-clips a chip mid-glyph; the full path stays in the tooltip. */ -export function middleTruncatePath(path: string): string { - const sep = sepOf(path); - const segments = path.split(/[\\/]/).filter(Boolean); - if (segments.length <= 2) return path; - // POSIX first segments lost their leading `/` to the split; a drive letter - // (`C:`) never had one. - const prefix = sep === "\\" ? "" : sep; - return `${prefix}${segments[0]}${sep}…${sep}${segments[segments.length - 1]}`; -} +export * from "../../../src/shared/paths.js"; diff --git a/packages/harness/web/src/lib/project-tree.ts b/packages/harness/web/src/lib/project-tree.ts index 427bb9c59..640827f9c 100644 --- a/packages/harness/web/src/lib/project-tree.ts +++ b/packages/harness/web/src/lib/project-tree.ts @@ -1,4 +1,5 @@ -import type { SessionStatus, WorkflowInfo } from "@shared/types"; +import type { WorkflowInfo } from "@shared/types"; +import type { RailSort } from "../../../src/shared/project-roots.js"; import { displayAgentName } from "./agent-name"; import { @@ -9,6 +10,24 @@ import { stripTrailingSep, } from "./paths"; +/** + * WHICH FOLDERS ARE PROJECTS now lives in `src/shared/project-roots`, because the + * SERVER has to reach the same answer: it issues one durable Studio project + * per workspace scope, and a scope list built from a second definition left + * every promoted root without a project (see that module's header). Re-exported + * here so this file stays the rail's one import for project shape. + */ +export type { + ProjectRootSources, + RailSort, +} from "../../../src/shared/project-roots.js"; +export { + holdingProjectFor, + projectRoots, + projectSessionRoot, + projectToOpen, +} from "../../../src/shared/project-roots.js"; + /** * The rail's filing axes. * @@ -33,14 +52,10 @@ import { */ export type RailAxis = "project" | "group"; -/** - * Row order within a container. "name" is A–Z; "recent" is - * newest-activity-first, but ONLY for the project rows (they carry session - * recency) — `WorkflowInfo` has no timestamp, so agent ROWS are always - * path-stable regardless of this setting. "recent" therefore changes project - * order, not row order. - */ -export type RailSort = "recent" | "name"; +/* `RailSort` is defined in `src/shared/project-roots` and re-exported above: it + orders the PROJECT rows, which that module produces. `WorkflowInfo` has no + timestamp, so agent rows stay path-stable whatever this is set to — "recent" + changes project order, not row order. */ /** * One agent row. `prefix` is the unbranched directory chain compacted ONTO @@ -511,334 +526,6 @@ export function unrootedAgents( .sort(agentOrder(sort)); } -/** Everything the rail knows about which folders are projects. */ -export interface ProjectRootSources { - /** Upstream's workspace list — most-recently-used project directories, - * newest first, already deduped and pruned of dead paths at every boot. */ - recentDirs: readonly string[]; - /** Session cwds widen the candidate set for folders `recentDirs` has not yet - * recorded, and carry the recency signal for them. `status` separates the - * two very different claims a cwd can make: see rule 2. */ - sessions: readonly { - cwd: string; - createdAt: string; - status?: SessionStatus; - }[]; - /** Folders whose agent is mid-creation: known before any session or agent - * exists under them. */ - pendingCwds: readonly string[]; - /** - * Every registered agent's OWN directory. - * - * Required, not optional. The rule below cannot be stated without it, and a - * caller that forgets it would silently get the old accumulating behaviour - * back with every test still green. - */ - agentPaths: readonly string[]; - sort: RailSort; -} - -/** - * THE FOLDER THAT HOLDS AN AGENT, or null when nothing better than the agent's - * own directory exists. - * - * ONE ANSWER, because there are two callers and they must not disagree. The - * rail's derivation asks it to decide which row to draw; `openProject` asks it - * to decide what the picker actually opens when you point it at an agent. - * - * `projects` must be the list `projectRoots` produces. The guard below is only - * as good as the definition of "project" it is handed, and a caller that builds - * its own will decline hops the rail would have made, which restores the silent - * no-op this exists to remove. `openProject` therefore passes - * `projectRoots(...)` verbatim rather than assembling anything. - * - * Null means REFUSE, and refusing is safe: the agent's own folder stays the - * root and renders as a project with that agent inside, which is what opening - * an agent's folder honestly means. - * - * Two reasons to refuse: - * - * 1. **A filesystem root.** `paths.parentOf` answers `/` (and `C:\`) rather - * than null there, deliberately, so that every result stays a listable - * path. Taken literally it turns an agent at `/solo` into a project called - * `/` holding the entire disk, and the swallow guard below cannot catch it - * because at that point there is no other project to swallow yet. - * 2. **It would contain another project.** Without this, the clean demo - * fixture, whose roots are agent folders sitting beside an ordinary project - * under one home directory, promoted them all to `/Users/demo` and produced - * a single project holding every other project, with every agent inside it - * rendered twice. That is the duplicate-agent rendering this rule exists to - * remove, re-created by the repair. - * - * KNOWN LIMIT, stated rather than papered over: a directory holding nothing but - * agent folders and no other project DOES become the project. That is right - * everywhere except a home directory, and a home directory in practice always - * holds another project, which is what makes the guard fire. A depth floor was - * considered and rejected, because every threshold that saves `/Users/demo` - * also breaks a legitimate two-segment root. - */ -/** - * WHAT OPENING A FOLDER ACTUALLY OPENS. - * - * You cannot open a single agent as a project, so pointing the picker at an - * agent's own folder opens the folder that holds it. Without this the press is - * a silent no-op: `projectRoots` declines to draw a row for an agent-rooted - * entry, so the picker says "This is an agent project", the user presses Open, - * and nothing changes. - * - * THE ELIGIBLE PROJECTS ARE `projectRoots`' OWN OUTPUT, not a list assembled - * here to resemble it. That is the whole design of this function, and it is the - * only version of it that has held: the guard inside `holdingProjectFor` asks - * "would this promotion swallow a project", and the answer is only as good as - * the definition of "project" it is handed. Four separate attempts to - * reconstruct that definition locally were each wrong in a different way, and - * every one of them failed in the same direction, by counting something the - * rail does not keep and so refusing a hop the rail would have made, which puts - * the silent no-op back. - * - * `projectRoots` is the one place that decides what a project is: chosen - * folders, folders with a live session, and session-only folders that hold an - * agent no other root already shows, with agent directories excluded and - * promotions guarded. Calling it costs one derivation on a user gesture and - * removes the entire class of drift, because there is no second definition left - * to disagree with. - */ -export function projectToOpen( - requested: string, - sources: ProjectRootSources, -): string { - const isAgentDir = sources.agentPaths.some( - (path) => canonical(path) === canonical(requested), - ); - if (!isAgentDir) return requested; - return ( - holdingProjectFor(requested, { - agentPaths: sources.agentPaths, - projects: projectRoots(sources), - }) ?? requested - ); -} - -export function holdingProjectFor( - agentDir: string, - { - agentPaths, - projects, - }: { agentPaths: readonly string[]; projects: readonly string[] }, -): string | null { - const agentDirs = new Set(agentPaths.map(canonical)); - let parent = parentOf(agentDir); - // An agent nested inside another agent walks up until it clears them all. - while (parent && agentDirs.has(canonical(parent))) parent = parentOf(parent); - if (parent === null || parentOf(parent) === null) return null; - const swallowsAProject = projects.some( - (held) => isUnder(held, parent!) && canonical(held) !== canonical(parent!), - ); - return swallowsAProject ? null : parent; -} - -/** - * THE ORDERED LIST OF PROJECT ROOTS. - * - * One sentence governs this whole function: - * - * 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. - * - * 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 - * that is itself a registered agent is a category error, and it is the single - * cause of both symptoms a real install shows. Its dependency graph has exactly - * 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 - * is replaced by its nearest non-agent ancestor. - * - * This is not a new rule. `project-membership.agentNeedsOwnProject` has - * enforced it on every NEW registration since the accumulation was diagnosed: - * "an agent an open project already contains needs nothing remembered". It was - * simply never applied to the entries already in the list, so the guard stopped - * the bleeding and left the wound. Applying one rule in one direction only is - * why SAP-2927 looked complete while the rail still looked broken. - * - * RULE 2, "that holds agents": a folder known ONLY because a session ran there - * earns a row only if it holds an agent no other project already shows, OR a - * session is LIVE in it. "A session ran here once and exited" and "something is - * running here right now" are different claims, and collapsing them cost a real - * case immediately: a bare scaffold session, a live session in a folder with no - * agent yet, is exactly how you start an agent in an empty folder, and dropping - * its row makes a running session unreachable from the rail. - * `recentDirs` is chosen and capped at 8; session cwds are neither, which is - * why the second list has to earn its rows and the first does not. Two failures - * collapse into that one clause. A visited folder with no agent is not a - * project, while an empty project you OPENED keeps its row, because opening a - * folder in order to build the first agent in it is the whole point of that - * row. And a visited folder INSIDE a project you already opened is not a second - * context: `~/polsia` and `~/polsia/services/workers` are two useful views of - * one agent when you opened both, and the same agent printed twice when the - * inner row is merely where a session happened to start. - * - * NOTHING IS DELETED. Both rules are derivational: `recentDirs` on disk is - * untouched and any folder is one "Add a project" away from coming back. That - * is what makes this safe to apply to an install nobody audited, and why it - * needs no migration, no first-run flow and no undo. The design's original "no - * migration, every entry becomes a project" rule is kept in spirit and dropped - * in letter: nothing a user had disappears, but residue of a fixed bug stops - * being rendered as a choice they made. - */ -export function projectRoots({ - recentDirs, - sessions, - pendingCwds, - agentPaths, - sort, -}: ProjectRootSources): string[] { - // Newest activity per directory, for folders `recentDirs` has not heard of. - const newestByCwd = new Map(); - for (const session of sessions) { - const key = canonical(session.cwd); - const prev = newestByCwd.get(key); - if (!prev || session.createdAt > prev) - newestByCwd.set(key, session.createdAt); - } - - // First spelling wins: recentDirs and a session cwd can name one directory - // in two forms (the server `path.resolve`s what it stores, the SPA holds - // what the user typed), and two rows for one folder is unreadable. - const seen = new Set(); - const candidates: string[] = []; - for (const dir of [ - ...pendingCwds, - ...recentDirs, - ...sessions.map((s) => s.cwd), - ]) { - const key = canonical(dir); - if (key === "" || seen.has(key)) continue; - seen.add(key); - candidates.push(dir); - } - - const agentDirs = new Set(agentPaths.map(canonical)); - const isAgentDir = (dir: string): boolean => agentDirs.has(canonical(dir)); - // A folder mid-creation is as deliberate an act as opening one, and its agent - // does not exist yet, so it can never be an agent directory either. A folder - // with a LIVE session counts too: you are working in it right now, which is a - // stronger claim than any list of remembered paths. - // `status !== "exited"` is the SAME reading `bareSessionAt` uses, so the row - // the rail keeps and the session it offers cannot disagree. An absent status - // reads as not live: the only callers that omit it name a folder's recency, - // and a missing field must never silently keep a row. - const liveCwds = sessions - .filter((session) => session.status != null && session.status !== "exited") - .map((session) => session.cwd); - const chosen = new Set( - [...pendingCwds, ...recentDirs, ...liveCwds].map(canonical), - ); - const wasChosen = (dir: string): boolean => chosen.has(canonical(dir)); - const agentsUnder = (root: string): string[] => - agentPaths.filter( - (path) => isUnder(path, root) && canonical(path) !== canonical(root), - ); - - /** What each surviving root was DERIVED FROM, so a promoted row inherits the - * recency of the entry that produced it rather than sorting as an unknown. */ - const from = new Map(); - const kept: string[] = []; - const holds = (root: string): boolean => - kept.some((held) => canonical(held) === canonical(root)); - - // The folders the user CHOSE, unconditionally and in order. `recentDirs` is a - // list of deliberate acts; second-guessing it is how a rail starts hiding a - // project somebody opened on purpose. - for (const dir of candidates) { - if (!wasChosen(dir) || isAgentDir(dir)) continue; - kept.push(dir); - from.set(canonical(dir), dir); - } - - /* RULE 2, over the session-only folders, SHALLOWEST FIRST. - The order is load-bearing, not tidiness. Taken in candidate order an inner - folder is reached before the outer one that would have explained it, and so - keeps a row it does not need: a captured install kept - `harness-e2e/projects/research-micro-site-` as its own project and - then added `harness-e2e` above it, printing both of its agents twice. - Shallowest first means the outermost folder that explains an agent wins and - every folder below it is measured against a list that already holds it. */ - const sessionOnly = candidates - .filter((dir) => !wasChosen(dir) && !isAgentDir(dir)) - .sort( - (a, b) => - canonical(a).split("/").length - canonical(b).split("/").length || - a.localeCompare(b), - ); - for (const dir of sessionOnly) { - const under = agentsUnder(dir); - if (under.length === 0) continue; - if (under.every((path) => kept.some((root) => isUnder(path, root)))) - continue; - kept.push(dir); - from.set(canonical(dir), dir); - } - - // RULE 1, over the agent-rooted entries, in candidate order so the result is - // deterministic. `kept` grows as promotions land, so a later entry can be - // absorbed by an earlier one's promotion. - for (const dir of candidates) { - if (!isAgentDir(dir)) continue; - if ( - kept.some( - (root) => isUnder(dir, root) && canonical(root) !== canonical(dir), - ) - ) - continue; - const root = holdingProjectFor(dir, { agentPaths, projects: kept }) ?? dir; - if (holds(root)) continue; - kept.push(root); - from.set(canonical(root), dir); - } - - const pendingRank = new Map( - pendingCwds.map((cwd, index) => [canonical(cwd), index]), - ); - const recentRank = new Map( - recentDirs.map((dir, index) => [canonical(dir), index]), - ); - /** Rank and recency are asked of the ENTRY a row came from, so a promoted - * parent sorts where the agent that produced it sorted. */ - const source = (root: string): string => from.get(canonical(root)) ?? root; - - const byRecency = (a: string, b: string): number => { - const ia = recentRank.get(canonical(source(a))) ?? -1; - const ib = recentRank.get(canonical(source(b))) ?? -1; - if (ia >= 0 && ib >= 0) return ia - ib; - if (ia >= 0 || ib >= 0) return ia >= 0 ? -1 : 1; - return (newestByCwd.get(canonical(source(b))) ?? "").localeCompare( - newestByCwd.get(canonical(source(a))) ?? "", - ); - }; - - return kept.sort((a, b) => { - // A folder mid-creation outranks everything, on either sort — the user's - // attention is on it. Among several pending folders, newest first. - const ra = pendingRank.get(canonical(source(a))); - const rb = pendingRank.get(canonical(source(b))); - if (ra !== undefined || rb !== undefined) { - if (ra !== undefined && rb !== undefined) return ra - rb; - return ra !== undefined ? -1 : 1; - } - if (sort === "name") - return basenameOf(a).localeCompare(basenameOf(b)) || a.localeCompare(b); - return byRecency(a, b) || a.localeCompare(b); - }); -} - /** * Project labels. * diff --git a/packages/harness/web/src/lib/session-name.test.ts b/packages/harness/web/src/lib/session-name.test.ts index bfc84d3c5..29c6b53b0 100644 --- a/packages/harness/web/src/lib/session-name.test.ts +++ b/packages/harness/web/src/lib/session-name.test.ts @@ -32,17 +32,46 @@ describe("sessionDisplayName", () => { }); it("a sibling with a transcript title never pushes the default to 'folder 2'", () => { - const titled = session({ id: "s1", title: "Build the leasing pipeline", createdAt: "2026-07-20T09:00:00.000Z" }); - const untitled = session({ id: "s2", createdAt: "2026-07-20T10:00:00.000Z" }); + const titled = session({ + id: "s1", + title: "Build the leasing pipeline", + createdAt: "2026-07-20T09:00:00.000Z", + }); + const untitled = session({ + id: "s2", + createdAt: "2026-07-20T10:00:00.000Z", + }); const all = [titled, untitled]; - expect(sessionDisplayName(titled, all, {})).toBe("Build the leasing pipeline"); + expect(sessionDisplayName(titled, all, {})).toBe( + "Build the leasing pipeline", + ); expect(sessionDisplayName(untitled, all, {})).toBe("acme-app"); }); it("a user rename beats everything and empties back to the default", () => { const s = session({ title: "Build the leasing pipeline" }); - expect(sessionDisplayName(s, [s], { s1: "Leasing revamp" })).toBe("Leasing revamp"); - expect(sessionDisplayName(s, [s], { s1: " " })).toBe("Build the leasing pipeline"); + expect(sessionDisplayName(s, [s], { s1: "Leasing revamp" })).toBe( + "Leasing revamp", + ); + expect(sessionDisplayName(s, [s], { s1: " " })).toBe( + "Build the leasing pipeline", + ); + }); + + it("treats Plan Agents as an ordinary initial title that can be renamed", () => { + const initial = session({ id: "plan", title: "Plan Agents" }); + const sibling = session({ id: "build", title: "Implement checkout" }); + expect(sessionDisplayName(initial, [initial, sibling], {})).toBe( + "Plan Agents", + ); + expect( + sessionDisplayName(initial, [initial, sibling], { + plan: "Architecture notes", + }), + ).toBe("Architecture notes"); + expect(sessionDisplayName(sibling, [initial, sibling], {})).toBe( + "Implement checkout", + ); }); it("defaults from a Windows cwd's basename too", () => { @@ -51,8 +80,16 @@ describe("sessionDisplayName", () => { }); it("sessions in different folders never collide", () => { - const a = session({ id: "s1", cwd: "/Users/demo/acme-app", title: "acme-app" }); - const b = session({ id: "s2", cwd: "/Users/demo/scratch", title: "scratch" }); + const a = session({ + id: "s1", + cwd: "/Users/demo/acme-app", + title: "acme-app", + }); + const b = session({ + id: "s2", + cwd: "/Users/demo/scratch", + title: "scratch", + }); const all = [a, b]; expect(sessionDisplayName(a, all, {})).toBe("acme-app"); expect(sessionDisplayName(b, all, {})).toBe("scratch"); diff --git a/packages/harness/web/src/lib/session-scope.test.ts b/packages/harness/web/src/lib/session-scope.test.ts index 35e4c10a6..744bf590f 100644 --- a/packages/harness/web/src/lib/session-scope.test.ts +++ b/packages/harness/web/src/lib/session-scope.test.ts @@ -7,6 +7,7 @@ import { conversationSubject, liveSessionsForFocus, liveSessionsForProject, + liveSessionsForStudioProject, mergeSubjectRuns, OBSERVED_RUN_WINDOW, projectRootForAgent, @@ -61,7 +62,9 @@ describe("projectRootForAgent: a session boots at the project root", () => { // Honest degradation: an agent discovered outside every opened project // still opens rather than failing to start. expect(projectRootForAgent(ADS, [])).toBe(ADS); - expect(projectRootForAgent(ADS, [`${HOME}/unrelated`, `${HOME}/other`])).toBe(ADS); + expect( + projectRootForAgent(ADS, [`${HOME}/unrelated`, `${HOME}/other`]), + ).toBe(ADS); }); it("matches on segment boundaries, so a same-prefix sibling root never wins", () => { @@ -99,12 +102,21 @@ describe("projectRootForAgent: a session boots at the project root", () => { // The server hands the SPA native paths and the SPA holds whatever // recentDirs recorded, so the two spellings of one directory must resolve // to the same project (paths.ts's mixed-form contract). - expect(projectRootForAgent("C:\\Users\\demo\\polsia\\agents\\ads", ["C:\\Users\\demo\\polsia"])) - .toBe("C:\\Users\\demo\\polsia"); - expect(projectRootForAgent("C:\\Users\\demo\\polsia\\agents\\ads", ["C:/Users/demo/polsia"])) - .toBe("C:/Users/demo/polsia"); - expect(projectRootForAgent("C:\\Users\\demo\\polsia-old\\ads", ["C:\\Users\\demo\\polsia"])) - .toBe("C:\\Users\\demo\\polsia-old\\ads"); + expect( + projectRootForAgent("C:\\Users\\demo\\polsia\\agents\\ads", [ + "C:\\Users\\demo\\polsia", + ]), + ).toBe("C:\\Users\\demo\\polsia"); + expect( + projectRootForAgent("C:\\Users\\demo\\polsia\\agents\\ads", [ + "C:/Users/demo/polsia", + ]), + ).toBe("C:/Users/demo/polsia"); + expect( + projectRootForAgent("C:\\Users\\demo\\polsia-old\\ads", [ + "C:\\Users\\demo\\polsia", + ]), + ).toBe("C:\\Users\\demo\\polsia-old\\ads"); }); }); @@ -163,41 +175,64 @@ const session = (over: Partial = {}): ScopedSession => ({ describe("liveSessionsForFocus", () => { const bound = session({ id: "b", boundWorkflowPath: ADS, cwd: POLSIA }); const inFolder = session({ id: "f", cwd: ADS }); - const elsewhere = session({ id: "e", boundWorkflowPath: `${POLSIA}/other`, cwd: POLSIA }); + const elsewhere = session({ + id: "e", + boundWorkflowPath: `${POLSIA}/other`, + cwd: POLSIA, + }); const dead = session({ id: "d", boundWorkflowPath: ADS, status: "exited" }); it("claims bound sessions and unbound sessions sitting in the folder", () => { - expect(liveSessionsForFocus([bound, inFolder, elsewhere, dead], ADS).map((s) => s.id)).toEqual([ - "b", - "f", - ]); + expect( + liveSessionsForFocus([bound, inFolder, elsewhere, dead], ADS).map( + (s) => s.id, + ), + ).toEqual(["b", "f"]); }); it("never claims a session bound elsewhere just because the cwd matches", () => { // A session bound to another agent belongs to that agent's strip, whatever // folder it happens to sit in. - const boundElsewhereInFolder = session({ id: "x", boundWorkflowPath: POLSIA, cwd: ADS }); + const boundElsewhereInFolder = session({ + id: "x", + boundWorkflowPath: POLSIA, + cwd: ADS, + }); expect(liveSessionsForFocus([boundElsewhereInFolder], ADS)).toEqual([]); }); it("orders oldest first, the order Cmd/Ctrl+1..9 selects, and stably on a tie", () => { - const older = session({ id: "z", cwd: ADS, createdAt: "2026-08-01T09:00:00.000Z" }); - const newer = session({ id: "a", cwd: ADS, createdAt: "2026-08-01T11:00:00.000Z" }); - expect(liveSessionsForFocus([newer, older], ADS).map((s) => s.id)).toEqual(["z", "a"]); + const older = session({ + id: "z", + cwd: ADS, + createdAt: "2026-08-01T09:00:00.000Z", + }); + const newer = session({ + id: "a", + cwd: ADS, + createdAt: "2026-08-01T11:00:00.000Z", + }); + expect(liveSessionsForFocus([newer, older], ADS).map((s) => s.id)).toEqual([ + "z", + "a", + ]); // Same timestamp, ordered by id, so the strip never reorders between renders. expect( - liveSessionsForFocus([session({ id: "m", cwd: ADS }), session({ id: "k", cwd: ADS })], ADS).map( - (s) => s.id, - ), + liveSessionsForFocus( + [session({ id: "m", cwd: ADS }), session({ id: "k", cwd: ADS })], + ADS, + ).map((s) => s.id), ).toEqual(["k", "m"]); }); it("compares paths, not strings, so a trailing separator still matches", () => { // The server resolve()s the cwd it stores while the selection is whatever // recentDirs kept — a raw === hid the session the user had just created. - expect(liveSessionsForFocus([session({ id: "t", cwd: `${ADS}/` })], ADS).map((s) => s.id)).toEqual( - ["t"], - ); + expect( + liveSessionsForFocus([session({ id: "t", cwd: `${ADS}/` })], ADS).map( + (s) => s.id, + ), + ).toEqual(["t"]); }); it("is empty for no subject", () => { @@ -218,25 +253,42 @@ describe("liveSessionsForProject: a session belongs to its PROJECT", () => { // EMPTY strip under `liveSessionsForFocus` — a project you can select but // cannot see your own conversations in. expect(liveSessionsForFocus([boundToAds], POLSIA)).toEqual([]); - expect(liveSessionsForProject([boundToAds], POLSIA).map((s) => s.id)).toEqual(["b"]); + expect( + liveSessionsForProject([boundToAds], POLSIA).map((s) => s.id), + ).toEqual(["b"]); }); it("claims every live session inside the root and nothing outside it", () => { expect( - liveSessionsForProject([boundToAds, unbound, nested, elsewhere, dead], POLSIA).map((s) => s.id), + liveSessionsForProject( + [boundToAds, unbound, nested, elsewhere, dead], + POLSIA, + ).map((s) => s.id), ).toEqual(["b", "n", "u"]); }); it("is asymmetric, exactly like sessionReachesFocus: the outer project contains the nested one's sessions, never the reverse", () => { const workers = `${POLSIA}/services/workers`; - expect(liveSessionsForProject([nested], POLSIA).map((s) => s.id)).toEqual(["n"]); + expect(liveSessionsForProject([nested], POLSIA).map((s) => s.id)).toEqual([ + "n", + ]); expect(liveSessionsForProject([unbound], workers)).toEqual([]); }); it("orders oldest first and stably, the same order the strip renders", () => { - const older = session({ id: "z", cwd: POLSIA, createdAt: "2026-08-01T09:00:00.000Z" }); - const newer = session({ id: "a", cwd: POLSIA, createdAt: "2026-08-01T11:00:00.000Z" }); - expect(liveSessionsForProject([newer, older], POLSIA).map((s) => s.id)).toEqual(["z", "a"]); + const older = session({ + id: "z", + cwd: POLSIA, + createdAt: "2026-08-01T09:00:00.000Z", + }); + const newer = session({ + id: "a", + cwd: POLSIA, + createdAt: "2026-08-01T11:00:00.000Z", + }); + expect( + liveSessionsForProject([newer, older], POLSIA).map((s) => s.id), + ).toEqual(["z", "a"]); }); it("is empty for no project", () => { @@ -247,6 +299,88 @@ describe("liveSessionsForProject: a session belongs to its PROJECT", () => { }); }); +describe("liveSessionsForStudioProject: project identity owns session tabs", () => { + const projectId = "project_alpha"; + + it("uses exact project identity instead of cwd containment", () => { + const matchingOutsideRoot = session({ + id: "matching", + cwd: SIDEQUEST, + agentMapIdentity: { projectId }, + }); + const foreignInsideRoot = session({ + id: "foreign", + cwd: POLSIA, + agentMapIdentity: { projectId: "project_beta" }, + }); + + expect( + liveSessionsForStudioProject( + [foreignInsideRoot, matchingOutsideRoot], + projectId, + ).map((candidate) => candidate.id), + ).toEqual(["matching"]); + }); + + it("excludes exited, foreign, and missing project identities", () => { + const live = session({ id: "live", agentMapIdentity: { projectId } }); + const exited = session({ + id: "exited", + status: "exited", + agentMapIdentity: { projectId }, + }); + const foreign = session({ + id: "foreign", + agentMapIdentity: { projectId: "project_beta" }, + }); + const missing = session({ id: "missing", agentMapIdentity: null }); + + expect( + liveSessionsForStudioProject( + [exited, foreign, missing, live], + projectId, + ).map((candidate) => candidate.id), + ).toEqual(["live"]); + }); + + it("orders oldest first, uses id as a stable tie-breaker, and deduplicates session IDs", () => { + const older = session({ + id: "z", + createdAt: "2026-08-01T09:00:00.000Z", + agentMapIdentity: { projectId }, + }); + const tiedLaterId = session({ + id: "m", + agentMapIdentity: { projectId }, + }); + const tiedEarlierId = session({ + id: "k", + agentMapIdentity: { projectId }, + }); + const duplicate = session({ + id: "z", + createdAt: "2026-08-01T11:00:00.000Z", + agentMapIdentity: { projectId }, + }); + + expect( + liveSessionsForStudioProject( + [tiedLaterId, duplicate, tiedEarlierId, older], + projectId, + ).map((candidate) => candidate.id), + ).toEqual(["z", "k", "m"]); + }); + + it("is empty when no project is selected", () => { + expect( + liveSessionsForStudioProject( + [session({ agentMapIdentity: { projectId } })], + null, + ), + ).toEqual([]); + }); +}); + describe("conversationSubject: the chat belongs to a project", () => { const roots = [POLSIA, SIDEQUEST]; @@ -254,7 +388,10 @@ describe("conversationSubject: the chat belongs to a project", () => { // E3.4 on screen, not merely in the session pointer: the agent selection is // not an input here, so the strip is the SAME set before and after the click. const active = session({ boundWorkflowPath: ADS, cwd: POLSIA }); - expect(conversationSubject(active, ADS, null, roots)).toEqual({ kind: "project", root: POLSIA }); + expect(conversationSubject(active, ADS, null, roots)).toEqual({ + kind: "project", + root: POLSIA, + }); expect(conversationSubject(active, OUTREACH, null, roots)).toEqual({ kind: "project", root: POLSIA, @@ -273,9 +410,17 @@ describe("conversationSubject: the chat belongs to a project", () => { }); it("names the SELECTED project when nothing is running, so a project you just picked is already the subject", () => { - expect(conversationSubject(null, ADS, POLSIA, roots)).toEqual({ kind: "project", root: POLSIA }); + expect(conversationSubject(null, ADS, POLSIA, roots)).toEqual({ + kind: "project", + root: POLSIA, + }); expect( - conversationSubject(session({ status: "exited", cwd: POLSIA }), ADS, POLSIA, roots), + conversationSubject( + session({ status: "exited", cwd: POLSIA }), + ADS, + POLSIA, + roots, + ), ).toEqual({ kind: "project", root: POLSIA }); }); @@ -291,13 +436,22 @@ describe("conversationSubject: the chat belongs to a project", () => { it("falls back to the agent subject for a session outside every known root", () => { // A scaffold folder not yet in recentDirs has no project. Inventing one // from its cwd would give the strip a root no rail row corresponds to. - const loose = session({ cwd: `${HOME}/scratch`, boundWorkflowPath: `${HOME}/scratch/bot` }); + const loose = session({ + cwd: `${HOME}/scratch`, + boundWorkflowPath: `${HOME}/scratch/bot`, + }); expect(conversationSubject(loose, ADS, null, roots)).toEqual({ kind: "focus", path: `${HOME}/scratch/bot`, }); - expect(conversationSubject(null, ADS, null, roots)).toEqual({ kind: "focus", path: ADS }); - expect(conversationSubject(null, null, null, roots)).toEqual({ kind: "focus", path: null }); + expect(conversationSubject(null, ADS, null, roots)).toEqual({ + kind: "focus", + path: ADS, + }); + expect(conversationSubject(null, null, null, roots)).toEqual({ + kind: "focus", + path: null, + }); }); }); @@ -306,7 +460,9 @@ describe("sessionStripSubject: the strip follows the ACTIVE session", () => { // Selecting F while B's session runs must leave B's tabs on screen. Keyed // to the selection, the strip emptied itself under the session still // running in the pane below it. - expect(sessionStripSubject(session({ boundWorkflowPath: ADS }), OUTREACH)).toBe(ADS); + expect( + sessionStripSubject(session({ boundWorkflowPath: ADS }), OUTREACH), + ).toBe(ADS); }); it("falls back to the session's own folder when it is unbound", () => { @@ -316,7 +472,10 @@ describe("sessionStripSubject: the strip follows the ACTIVE session", () => { it("names the rail selection only when there is no live active session", () => { expect(sessionStripSubject(null, ADS)).toBe(ADS); expect( - sessionStripSubject(session({ status: "exited", boundWorkflowPath: POLSIA }), ADS), + sessionStripSubject( + session({ status: "exited", boundWorkflowPath: POLSIA }), + ADS, + ), ).toBe(ADS); expect(sessionStripSubject(null, null)).toBeNull(); }); @@ -324,15 +483,28 @@ describe("sessionStripSubject: the strip follows the ACTIVE session", () => { describe("sessionForFocus: selection moves the session across projects, never within one", () => { const at = (over: Partial): ScopedSession => session(over); - const polsiaSession = at({ id: "p", cwd: POLSIA, lastActiveAt: "2026-08-02T10:00:00.000Z" }); - const otherSession = at({ id: "o", cwd: SIDEQUEST, lastActiveAt: "2026-08-02T09:00:00.000Z" }); + const polsiaSession = at({ + id: "p", + cwd: POLSIA, + lastActiveAt: "2026-08-02T10:00:00.000Z", + }); + const otherSession = at({ + id: "o", + cwd: SIDEQUEST, + lastActiveAt: "2026-08-02T09:00:00.000Z", + }); const roots = [POLSIA, SIDEQUEST]; it("keeps the session when the selected agent is in its project", () => { // The whole point of the decoupling: read F's board while still talking to // B. One session has context on every agent in its project. expect( - sessionForFocus({ focusPath: ADS, active: polsiaSession, sessions: [polsiaSession], roots }), + sessionForFocus({ + focusPath: ADS, + active: polsiaSession, + sessions: [polsiaSession], + roots, + }), ).toEqual({ kind: "keep" }); expect( sessionForFocus({ @@ -359,7 +531,12 @@ describe("sessionForFocus: selection moves the session across projects, never wi it("switches to none when the selected project has no live session", () => { expect( - sessionForFocus({ focusPath: ADS, active: otherSession, sessions: [otherSession], roots }), + sessionForFocus({ + focusPath: ADS, + active: otherSession, + sessions: [otherSession], + roots, + }), ).toEqual({ kind: "switch", to: null }); }); @@ -389,9 +566,16 @@ describe("sessionForFocus: selection moves the session across projects, never wi // Reachable after closing the last tab: the pane said "no running session // for F" while F's project had one, which was a false absence. expect( - sessionForFocus({ focusPath: ADS, active: null, sessions: [polsiaSession], roots }), + sessionForFocus({ + focusPath: ADS, + active: null, + sessions: [polsiaSession], + roots, + }), ).toEqual({ kind: "switch", to: polsiaSession }); - expect(sessionForFocus({ focusPath: ADS, active: null, sessions: [], roots })).toEqual({ + expect( + sessionForFocus({ focusPath: ADS, active: null, sessions: [], roots }), + ).toEqual({ kind: "switch", to: null, }); @@ -438,8 +622,16 @@ describe("sessionForFocus: selection moves the session across projects, never wi }), ).toEqual({ kind: "switch", to: older }); // With no lastActiveAt anywhere, createdAt is the fallback ordering. - const c1 = at({ id: "c1", cwd: POLSIA, createdAt: "2026-07-01T00:00:00.000Z" }); - const c2 = at({ id: "c2", cwd: POLSIA, createdAt: "2026-08-04T00:00:00.000Z" }); + const c1 = at({ + id: "c1", + cwd: POLSIA, + createdAt: "2026-07-01T00:00:00.000Z", + }); + const c2 = at({ + id: "c2", + cwd: POLSIA, + createdAt: "2026-08-04T00:00:00.000Z", + }); expect( sessionForFocus({ focusPath: ADS, @@ -470,8 +662,16 @@ describe("sessionForFocus: selection moves the session across projects, never wi // Not symmetric, and deliberately: a session rooted at the NESTED project // cannot reach up to an agent outside it, so that one hands over — to the // outer project's own most-recently-worked-in session. - const inner = at({ id: "inner", cwd: nested, lastActiveAt: "2026-08-01T00:00:00.000Z" }); - const outerRecent = at({ id: "outer", cwd: POLSIA, lastActiveAt: "2026-08-09T00:00:00.000Z" }); + const inner = at({ + id: "inner", + cwd: nested, + lastActiveAt: "2026-08-01T00:00:00.000Z", + }); + const outerRecent = at({ + id: "outer", + cwd: POLSIA, + lastActiveAt: "2026-08-09T00:00:00.000Z", + }); expect( sessionForFocus({ focusPath: ADS, @@ -482,12 +682,59 @@ describe("sessionForFocus: selection moves the session across projects, never wi ).toEqual({ kind: "switch", to: outerRecent }); }); + it("uses neutral project identity instead of cwd containment for a Studio handoff", () => { + const nested = `${POLSIA}/services/workers`; + const worker = `${nested}/ads`; + const outer = at({ + id: "outer", + cwd: POLSIA, + agentMapIdentity: { projectId: "project_outer" }, + }); + const nestedSession = at({ + id: "nested", + cwd: nested, + agentMapIdentity: { projectId: "project_nested" }, + }); + expect( + sessionForFocus({ + focusPath: worker, + active: outer, + sessions: [outer, nestedSession], + roots: [POLSIA, nested], + targetProjectId: "project_nested", + targetProjectRoot: nested, + }), + ).toEqual({ kind: "switch", to: nestedSession }); + }); + + it("falls back safely for identity-less legacy sessions without admitting an outer root", () => { + const nested = `${POLSIA}/services/workers`; + const worker = `${nested}/ads`; + const outerLegacy = at({ id: "outer-legacy", cwd: POLSIA }); + const nestedLegacy = at({ id: "nested-legacy", cwd: nested }); + expect( + sessionForFocus({ + focusPath: worker, + active: outerLegacy, + sessions: [outerLegacy, nestedLegacy], + roots: [POLSIA, nested], + targetProjectId: "project_nested", + targetProjectRoot: nested, + }), + ).toEqual({ kind: "switch", to: nestedLegacy }); + }); + it("keeps a session left rooted in an agent's own folder when a sibling is selected", () => { // Older builds rooted sessions at the agent. That session still belongs to // the project around it, so a sibling selection must not read as a jump. const legacy = at({ id: "legacy", cwd: ADS }); expect( - sessionForFocus({ focusPath: OUTREACH, active: legacy, sessions: [legacy], roots }), + sessionForFocus({ + focusPath: OUTREACH, + active: legacy, + sessions: [legacy], + roots, + }), ).toEqual({ kind: "keep" }); }); @@ -496,13 +743,30 @@ describe("sessionForFocus: selection moves the session across projects, never wi // Its own folder is the fallback project, so only sessions inside it can // claim it. expect( - sessionForFocus({ focusPath: orphan, active: polsiaSession, sessions: [polsiaSession], roots }), + sessionForFocus({ + focusPath: orphan, + active: polsiaSession, + sessions: [polsiaSession], + roots, + }), ).toEqual({ kind: "switch", to: null }); const inOrphan = at({ id: "orphan", cwd: orphan }); expect( - sessionForFocus({ focusPath: orphan, active: polsiaSession, sessions: [inOrphan], roots }), + sessionForFocus({ + focusPath: orphan, + active: polsiaSession, + sessions: [inOrphan], + roots, + }), ).toEqual({ kind: "switch", to: inOrphan }); - expect(sessionForFocus({ focusPath: orphan, active: null, sessions: [], roots: [] })).toEqual({ + expect( + sessionForFocus({ + focusPath: orphan, + active: null, + sessions: [], + roots: [], + }), + ).toEqual({ kind: "switch", to: null, }); @@ -510,7 +774,12 @@ describe("sessionForFocus: selection moves the session across projects, never wi it("keeps the session when the selection is the project root itself", () => { expect( - sessionForFocus({ focusPath: POLSIA, active: polsiaSession, sessions: [polsiaSession], roots }), + sessionForFocus({ + focusPath: POLSIA, + active: polsiaSession, + sessions: [polsiaSession], + roots, + }), ).toEqual({ kind: "keep" }); }); }); @@ -543,12 +812,20 @@ describe("canvasSourceFor: which entry point serves the subject's board", () => // That route is the one canvas.reload addresses and the run-state bridge // posts into; reaching for the workflow-keyed one here would trade a live // board for a snapshot. - expect(canvasSourceFor({ subjectPath: ADS, bindingPath: ADS, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ subjectPath: ADS, bindingPath: ADS, sessionId: "s1" }), + ).toEqual({ kind: "session", sessionId: "s1", }); // Separator/trailing-slash spellings are the same place. - expect(canvasSourceFor({ subjectPath: ADS, bindingPath: `${ADS}/`, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ + subjectPath: ADS, + bindingPath: `${ADS}/`, + sessionId: "s1", + }), + ).toEqual({ kind: "session", sessionId: "s1", }); @@ -557,7 +834,13 @@ describe("canvasSourceFor: which entry point serves the subject's board", () => it("uses the workflow-keyed route whenever the session is bound elsewhere", () => { // /canvas/:sessionId/ resolves by the BINDING, so it would serve the wrong // agent's board — this is the mis-draw the route exists to prevent. - expect(canvasSourceFor({ subjectPath: ADS, bindingPath: OUTREACH, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ + subjectPath: ADS, + bindingPath: OUTREACH, + sessionId: "s1", + }), + ).toEqual({ kind: "agent", path: ADS, }); @@ -566,19 +849,25 @@ describe("canvasSourceFor: which entry point serves the subject's board", () => it("uses the workflow-keyed route for an agent with no session at all", () => { // The criterion IA-01 landed for: an agent that has never hosted a session // still has a board. - expect(canvasSourceFor({ subjectPath: ADS, bindingPath: null, sessionId: null })).toEqual({ + expect( + canvasSourceFor({ subjectPath: ADS, bindingPath: null, sessionId: null }), + ).toEqual({ kind: "agent", path: ADS, }); // A live but unbound session is the same case. - expect(canvasSourceFor({ subjectPath: ADS, bindingPath: null, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ subjectPath: ADS, bindingPath: null, sessionId: "s1" }), + ).toEqual({ kind: "agent", path: ADS, }); }); it("has no source without a subject", () => { - expect(canvasSourceFor({ subjectPath: null, bindingPath: ADS, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ subjectPath: null, bindingPath: ADS, sessionId: "s1" }), + ).toEqual({ kind: "none", }); }); @@ -600,47 +889,67 @@ describe("lifecycleVerbGate: the verbs are gated BY the selection, not aimed at // The prototype's bug in one assertion: subject and enabled-state came from // different agents. One call returns both, so they cannot drift apart. for (const verb of ["prod", "test", "run", "deploy"] as const) { - expect(lifecycleVerbGate(verb, { ...signedIn, subject: draft }).subjectPath).toBe(ADS); + expect( + lifecycleVerbGate(verb, { ...signedIn, subject: draft }).subjectPath, + ).toBe(ADS); } }); it("disables Prod and Run for an undeployed agent, with the reason", () => { // The exact mis-target: selecting the undeployed agent left Prod and Run // live against the deployed one the session was bound to. - expect(lifecycleVerbGate("prod", { ...signedIn, subject: draft }).reason).toBe( - "Not deployed yet", - ); - expect(lifecycleVerbGate("run", { ...signedIn, subject: draft }).reason).toBe( - "Not deployed yet", - ); + expect( + lifecycleVerbGate("prod", { ...signedIn, subject: draft }).reason, + ).toBe("Not deployed yet"); + expect( + lifecycleVerbGate("run", { ...signedIn, subject: draft }).reason, + ).toBe("Not deployed yet"); }); it("leaves Test and Deploy available on an undeployed agent", () => { // They are precisely what you CAN do to it; disabling the verbs that fix // the state you are being told about would be honest about nothing. - expect(lifecycleVerbGate("test", { ...signedIn, subject: draft }).reason).toBeNull(); - expect(lifecycleVerbGate("deploy", { ...signedIn, subject: draft }).reason).toBeNull(); + expect( + lifecycleVerbGate("test", { ...signedIn, subject: draft }).reason, + ).toBeNull(); + expect( + lifecycleVerbGate("deploy", { ...signedIn, subject: draft }).reason, + ).toBeNull(); }); it("enables Prod and Run on a deployed agent", () => { - expect(lifecycleVerbGate("prod", { ...signedIn, subject: deployed }).reason).toBeNull(); - expect(lifecycleVerbGate("run", { ...signedIn, subject: deployed }).reason).toBeNull(); + expect( + lifecycleVerbGate("prod", { ...signedIn, subject: deployed }).reason, + ).toBeNull(); + expect( + lifecycleVerbGate("run", { ...signedIn, subject: deployed }).reason, + ).toBeNull(); }); it("puts the auth gate ahead of the deployment gate on the cloud verbs", () => { // Signed out, "Not deployed yet" would send the user to Deploy, which is // also blocked — the reason has to name the thing they can act on. expect( - lifecycleVerbGate("run", { subject: draft, authenticated: false, deployError: null }).reason, + lifecycleVerbGate("run", { + subject: draft, + authenticated: false, + deployError: null, + }).reason, ).toBe("Connect your account first"); expect( - lifecycleVerbGate("deploy", { subject: draft, authenticated: false, deployError: null }) - .reason, + lifecycleVerbGate("deploy", { + subject: draft, + authenticated: false, + deployError: null, + }).reason, ).toBe("Connect your account first"); // Prod is a read, not a cloud call: it needs a definition, not a session. expect( - lifecycleVerbGate("prod", { subject: deployed, authenticated: false, deployError: null }) - .reason, + lifecycleVerbGate("prod", { + subject: deployed, + authenticated: false, + deployError: null, + }).reason, ).toBeNull(); }); @@ -656,8 +965,11 @@ describe("lifecycleVerbGate: the verbs are gated BY the selection, not aimed at ).toBe("Last deploy failed — retry Deploy"); // A ready build outlives a stale failure (workflow-deployment's rule). expect( - lifecycleVerbGate("run", { subject: deployed, authenticated: true, deployError: "boom" }) - .reason, + lifecycleVerbGate("run", { + subject: deployed, + authenticated: true, + deployError: "boom", + }).reason, ).toBeNull(); // Prod only asks whether there is a page to open. expect( @@ -673,11 +985,17 @@ describe("lifecycleVerbGate: the verbs are gated BY the selection, not aimed at expect( lifecycleVerbGate("run", { ...signedIn, - subject: agent({ definitionId: 4821, activeBuildRunStatus: "building" }), + subject: agent({ + definitionId: 4821, + activeBuildRunStatus: "building", + }), }).reason, ).toBe("Build in progress"); expect( - lifecycleVerbGate("run", { ...signedIn, subject: agent({ definitionId: 4821 }) }).reason, + lifecycleVerbGate("run", { + ...signedIn, + subject: agent({ definitionId: 4821 }), + }).reason, ).toBe("No ready deployment yet"); }); @@ -717,7 +1035,9 @@ describe("run evidence follows the subject, not the session", () => { // unattributed runs on EVERY subject — that is how a run no agent produced // appears under one that never ran it. Matches this repo's existing // `observedRunMatchesWorkflow`. - expect(runsForSubject([mine, theirs, unattributed], null)).toEqual([unattributed]); + expect(runsForSubject([mine, theirs, unattributed], null)).toEqual([ + unattributed, + ]); }); it("keeps the shown run only while it belongs to the subject", () => { @@ -768,25 +1088,32 @@ describe("run evidence follows the subject, not the session", () => { // They are its own and some are still polling; evicting them for another // session's finished history would drop the live half of the evidence. const observed = [run("a", ADS), run("b", ADS), run("c", ADS)]; - expect(mergeSubjectRuns(observed, [run("x", ADS), run("y", ADS)], 3)).toEqual(observed); + expect( + mergeSubjectRuns(observed, [run("x", ADS), run("y", ADS)], 3), + ).toEqual(observed); // Room for exactly one extra, and it is the newest of them. expect( - mergeSubjectRuns(observed, [run("x", ADS), run("y", ADS)], 4).map((r) => r.run.executionId), + mergeSubjectRuns(observed, [run("x", ADS), run("y", ADS)], 4).map( + (r) => r.run.executionId, + ), ).toEqual(["a", "b", "c", "y"]); }); it("keeps the newest observed runs when the observed list alone exceeds the window", () => { const observed = [run("old", ADS), run("mid", ADS), run("new", ADS)]; - expect(mergeSubjectRuns(observed, [run("x", ADS)], 2).map((r) => r.run.executionId)).toEqual([ - "mid", - "new", - ]); + expect( + mergeSubjectRuns(observed, [run("x", ADS)], 2).map( + (r) => r.run.executionId, + ), + ).toEqual(["mid", "new"]); expect(mergeSubjectRuns(observed, [], 0)).toEqual([]); }); it("is the observed list when there is nothing to add, and vice versa", () => { expect(mergeSubjectRuns([mine], [])).toEqual([mine]); - expect(mergeSubjectRuns([], [theirs]).map((r) => r.run.executionId)).toEqual(["x2"]); + expect( + mergeSubjectRuns([], [theirs]).map((r) => r.run.executionId), + ).toEqual(["x2"]); expect(mergeSubjectRuns([], [])).toEqual([]); }); @@ -820,7 +1147,9 @@ describe("sessionReachesFocus: one containment answer, two callers", () => { expect(sessionReachesFocus(at({ cwd: POLSIA }), ADS, [POLSIA])).toBe(true); // The session's own PROJECT, not its raw cwd: a session an older build left // rooted in the agent's folder still belongs to the project around it. - expect(sessionReachesFocus(at({ cwd: ADS }), OUTREACH, [POLSIA])).toBe(true); + expect(sessionReachesFocus(at({ cwd: ADS }), OUTREACH, [POLSIA])).toBe( + true, + ); }); it("is false for an agent in another project", () => { @@ -828,13 +1157,48 @@ describe("sessionReachesFocus: one containment answer, two callers", () => { // tab in a project falls back to whatever else is running, and the // workbench then pointed at a project that does not contain the agent on // screen. - expect(sessionReachesFocus(at({ cwd: SIDEQUEST }), ADS, [POLSIA, SIDEQUEST])).toBe(false); + expect( + sessionReachesFocus(at({ cwd: SIDEQUEST }), ADS, [POLSIA, SIDEQUEST]), + ).toBe(false); + }); + + it("treats a neutral Studio principal as the project boundary", () => { + const nested = `${POLSIA}/services/workers`; + const worker = `${nested}/ads`; + expect( + sessionReachesFocus( + at({ + cwd: POLSIA, + agentMapIdentity: { projectId: "project_outer" }, + }), + worker, + [POLSIA, nested], + "project_nested", + nested, + ), + ).toBe(false); + expect( + sessionReachesFocus( + at({ + cwd: POLSIA, + agentMapIdentity: { projectId: "project_nested" }, + }), + worker, + [POLSIA, nested], + "project_nested", + nested, + ), + ).toBe(true); }); it("is false with nothing active, an exited session, or no selection", () => { expect(sessionReachesFocus(null, ADS, [POLSIA])).toBe(false); - expect(sessionReachesFocus(at({ cwd: POLSIA, status: "exited" }), ADS, [POLSIA])).toBe(false); - expect(sessionReachesFocus(at({ cwd: POLSIA }), null, [POLSIA])).toBe(false); + expect( + sessionReachesFocus(at({ cwd: POLSIA, status: "exited" }), ADS, [POLSIA]), + ).toBe(false); + expect(sessionReachesFocus(at({ cwd: POLSIA }), null, [POLSIA])).toBe( + false, + ); }); it("agrees with sessionForFocus, which is the point of having one answer", () => { @@ -862,14 +1226,26 @@ describe("canvasSourceFor: a session bound to nothing still draws its own board" // live session whose board the pane has always drawn and whose empty-state // copy speaks about it. Reading "no subject" as "no source" replaced that // with a fresh-install "No session" message under a running session. - expect(canvasSourceFor({ subjectPath: null, bindingPath: null, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ + subjectPath: null, + bindingPath: null, + sessionId: "s1", + }), + ).toEqual({ kind: "session", sessionId: "s1", }); }); it("is nothing when there is neither a subject nor a session", () => { - expect(canvasSourceFor({ subjectPath: null, bindingPath: null, sessionId: null })).toEqual({ + expect( + canvasSourceFor({ + subjectPath: null, + bindingPath: null, + sessionId: null, + }), + ).toEqual({ kind: "none", }); }); @@ -877,7 +1253,9 @@ describe("canvasSourceFor: a session bound to nothing still draws its own board" it("is nothing when the subject is suppressed under a bound session", () => { // The create-new draft or a review owns the centre: an absence must not // have the bound agent's board sitting behind it. - expect(canvasSourceFor({ subjectPath: null, bindingPath: ADS, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ subjectPath: null, bindingPath: ADS, sessionId: "s1" }), + ).toEqual({ kind: "none", }); }); diff --git a/packages/harness/web/src/lib/session-scope.ts b/packages/harness/web/src/lib/session-scope.ts index 87c1270fd..1282ca056 100644 --- a/packages/harness/web/src/lib/session-scope.ts +++ b/packages/harness/web/src/lib/session-scope.ts @@ -51,7 +51,10 @@ export function rootContains(root: string, agentPath: string): boolean { * the old behaviour, returned verbatim — so an agent discovered outside every * opened project still starts a session rather than failing to start. */ -export function projectRootForAgent(agentPath: string, roots: readonly string[]): string { +export function projectRootForAgent( + agentPath: string, + roots: readonly string[], +): string { return ( roots .filter((root) => rootContains(root, agentPath)) @@ -82,6 +85,9 @@ export interface ScopedSession { cwd: string; status: string; boundWorkflowPath?: string | null; + /** Server-derived project identity. Unlike cwd containment, this remains + * exact when one opened project is nested inside another. */ + agentMapIdentity?: { projectId: string } | null; createdAt: string; /** Last activity, when the adapter reports it. "Most recent session" means * the one most recently WORKED IN, not the one most recently made: after a @@ -129,9 +135,13 @@ export function liveSessionsForFocus( (s) => s.status !== "exited" && (samePath(s.boundWorkflowPath ?? "", focusPath) || - ((s.boundWorkflowPath ?? null) == null && samePath(s.cwd, focusPath))), + ((s.boundWorkflowPath ?? null) == null && + samePath(s.cwd, focusPath))), ) - .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); + .sort( + (a, b) => + a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id), + ); } /** @@ -167,7 +177,42 @@ export function liveSessionsForProject( if (!projectRoot) return []; return sessions .filter((s) => s.status !== "exited" && rootContains(projectRoot, s.cwd)) - .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); + .sort( + (a, b) => + a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id), + ); +} + +/** + * Live sessions belonging to one durable Studio project. + * + * A Studio project is an authority boundary, not a path prefix. Session tabs + * therefore use the server-derived principal and never absorb a nested + * project's sessions merely because their cwd happens to be contained by the + * selected root. The final identity guard also keeps a malformed duplicate + * projection from manufacturing two visible tabs for one session ID. + */ +export function liveSessionsForStudioProject( + sessions: readonly S[], + projectId: string | null, +): S[] { + if (!projectId) return []; + const seen = new Set(); + return sessions + .filter( + (session) => + session.status !== "exited" && + session.agentMapIdentity?.projectId === projectId, + ) + .sort( + (a, b) => + a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id), + ) + .filter((session) => { + if (seen.has(session.id)) return false; + seen.add(session.id); + return true; + }); } /** @@ -230,7 +275,8 @@ export function conversationSubject( const live = active && active.status !== "exited" ? active : null; if (live) { const owner = roots.find((root) => rootContains(root, live.cwd)); - if (owner) return { kind: "project", root: projectRootForAgent(live.cwd, roots) }; + if (owner) + return { kind: "project", root: projectRootForAgent(live.cwd, roots) }; } if (projectRoot) return { kind: "project", root: projectRoot }; return { kind: "focus", path: sessionStripSubject(active, focusPath) }; @@ -256,8 +302,21 @@ export function sessionReachesFocus( active: ScopedSession | null, focusPath: string | null, roots: readonly string[], + targetProjectId: string | null = null, + targetProjectRoot: string | null = null, ): boolean { if (!active || active.status === "exited" || focusPath == null) return false; + if (targetProjectId) { + const activeProjectId = active.agentMapIdentity?.projectId; + if (activeProjectId) return activeProjectId === targetProjectId; + + // Rolling compatibility for sessions persisted before neutral principals: + // match the canonical project root exactly. Plain downward containment is + // unsafe here because an outer project also contains every nested project. + const focusRoot = + targetProjectRoot ?? projectRootForAgent(focusPath, roots); + return samePath(projectRootForAgent(active.cwd, roots), focusRoot); + } return rootContains(projectRootForAgent(active.cwd, roots), focusPath); } @@ -270,6 +329,11 @@ export interface FocusSessionInput { sessions: readonly S[]; /** The project roots the user has opened (`knownProjectRoots()`). */ roots: readonly string[]; + /** Exact server-derived Studio project selected for this agent. Omitted only + * for legacy sessions/projects that predate durable project identity. */ + targetProjectId?: string | null; + /** The selected project's exact root/binding containing `focusPath`. */ + targetProjectRoot?: string | null; } export type FocusSessionDecision = @@ -302,33 +366,49 @@ function byRecency(a: S, b: S): number { * are in different projects. So the session follows the selection into the new * scope, landing on that project's own session or on none. * - * "Can this session reach that agent" is asked ONE way, downward: does the - * active session's project CONTAIN the selected agent? That is what makes - * overlapping roots behave. With `~/polsia` and `~/polsia/services/workers` - * both open, an agent under `workers/` resolves (longest root wins) to the - * nested project, but a session at `~/polsia` still genuinely contains it — so - * selecting that agent KEEPS the session rather than appearing to jump - * projects for a row the session can already work on. The reverse is not - * symmetric and must not be: a session rooted at `~/polsia/services/workers` - * cannot reach up to an agent at `~/polsia`, so that one does hand over. + * For durable Studio projects, the server-derived project ID is the boundary: + * overlapping roots never let an outer project session impersonate a nested + * project's session. Path containment remains only for rolling compatibility + * with sessions and projects that predate neutral project principals. */ export function sessionForFocus({ focusPath, active, sessions, roots, + targetProjectId = null, + targetProjectRoot = null, }: FocusSessionInput): FocusSessionDecision { const live = sessions.filter((session) => session.status !== "exited"); - if (sessionReachesFocus(active, focusPath, roots)) return { kind: "keep" }; + if ( + sessionReachesFocus( + active, + focusPath, + roots, + targetProjectId, + targetProjectRoot, + ) + ) + return { kind: "keep" }; // The agent's own session wins the handover: navigating to F should land on // F's session when it has one. Then the project's most recent session, since // any session in the project can work on the agent. Then none. - const focusRoot = projectRootForAgent(focusPath, roots); - const own = liveSessionsForFocus(live, focusPath).sort(byRecency)[0]; - const inProject = live - .filter((session) => rootContains(focusRoot, session.cwd)) + const focusRoot = targetProjectRoot ?? projectRootForAgent(focusPath, roots); + const eligible = targetProjectId + ? live.filter((session) => { + const sessionProjectId = session.agentMapIdentity?.projectId; + return sessionProjectId + ? sessionProjectId === targetProjectId + : samePath(projectRootForAgent(session.cwd, roots), focusRoot); + }) + : live; + const own = liveSessionsForFocus(eligible, focusPath).sort(byRecency)[0]; + const inProject = eligible + .filter((session) => + targetProjectId ? true : rootContains(focusRoot, session.cwd), + ) .sort(byRecency)[0]; return { kind: "switch", to: own ?? inProject ?? null }; } @@ -580,7 +660,10 @@ export function mergeSubjectRuns( const room = limit - kept.length; if (room <= 0) return kept; const seen = new Set(kept.map((entry) => entry.run.executionId)); - return [...kept, ...extra.filter((entry) => !seen.has(entry.run.executionId)).slice(-room)]; + return [ + ...kept, + ...extra.filter((entry) => !seen.has(entry.run.executionId)).slice(-room), + ]; } /** @@ -616,7 +699,10 @@ export function shownRunForSubject( runs: readonly R[], sessionRun: R | null, ): R | null { - if (sessionRun && runs.some((entry) => entry.run.executionId === sessionRun.run.executionId)) { + if ( + sessionRun && + runs.some((entry) => entry.run.executionId === sessionRun.run.executionId) + ) { return sessionRun; } return runs.length > 0 ? runs[runs.length - 1] : null; diff --git a/packages/harness/web/src/lib/use-agent-map-entry.ts b/packages/harness/web/src/lib/use-agent-map-entry.ts index 7178c7a92..b0e1c194a 100644 --- a/packages/harness/web/src/lib/use-agent-map-entry.ts +++ b/packages/harness/web/src/lib/use-agent-map-entry.ts @@ -1,12 +1,9 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { AgentMapWorkspaceResponse, AcceptedProposalDelta, - PlannerSessionRequest, - PlannerSessionResponse, StudioProjectId, } from "@shared/agent-map"; -import type { HarnessKind, HarnessSession, UiTheme } from "@shared/types"; import { ApiError, errorMessage, type HarnessApi } from "./api"; import { track } from "./track"; @@ -22,39 +19,17 @@ export type AgentMapWorkspacePaneState = } | { status: "error"; message: string }; -export type AgentMapPlannerPaneState = - | { status: "idle" } - | { status: "loading" } - | { status: "ready"; value: PlannerSessionResponse } - | { status: "error"; message: string }; - export interface AgentMapEntryState { projectId: StudioProjectId | null; workspace: AgentMapWorkspacePaneState; - planner: AgentMapPlannerPaneState; - /** Missing/deleted/foreign projects are the only errors that replace both - * panes. Every ordinary read/launch failure stays local to its own pane. */ + /** Missing/deleted/foreign projects are bounded separately from ordinary + * map-read failures so the renderer can present the right recovery copy. */ unavailable: string | null; } interface AgentMapEntryOptions { projectId: StudioProjectId | null; - /** The user's explicit live planner selection, when it already belongs to - * this project. It is more specific than project-level resume ordering. */ - selectedPlanner: HarnessSession | null; api: HarnessApi; - /** Read at launch time so a theme/provider change made while the workspace - * is open is honored by the next explicit fresh-session action. */ - harness: () => HarnessKind; - theme: () => UiTheme; - openPlannerSession: ( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ) => Promise; - onPlannerReady: ( - response: PlannerSessionResponse, - mode: PlannerSessionRequest["mode"], - ) => void; subscribeProposalChanges: ( listener: (delta: AcceptedProposalDelta) => void, ) => () => void; @@ -64,7 +39,6 @@ interface AgentMapEntryOptions { const EMPTY_ENTRY: AgentMapEntryState = { projectId: null, workspace: { status: "idle" }, - planner: { status: "idle" }, unavailable: null, }; @@ -93,18 +67,6 @@ function failureDimensions( }; } -function selectedPlannerResponse( - projectId: StudioProjectId | null, - session: HarnessSession | null, -): PlannerSessionResponse | null { - return projectId !== null && - session?.status !== "exited" && - session?.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === projectId - ? { session, resolution: "live" } - : null; -} - /** The shared gate before an accepted delta may mutate state or telemetry. */ export function shouldCommitAcceptedDelta( currentProjectId: StudioProjectId | null, @@ -123,42 +85,27 @@ export function shouldCommitAcceptedDelta( } /** - * Opens the two halves of the first Agent Map experience concurrently. + * Loads the durable Agent Map for the selected project. * - * Workspace reads and planner launches have separate request generations and - * separate retry verbs. A late response from a project the user already left - * is ignored. The hook intentionally does not own session state: the returned - * planner session is handed to the central harness store through - * `openPlannerSession`, so tabs, status events, and planner metadata share one - * canonical `HarnessSession` projection. + * This hook deliberately owns no session behavior. Selecting a project is a + * read-only navigation action: it may read map state, but it cannot create, + * resume, focus, or prompt a coding session. A late response from a project the + * user already left is ignored. */ export function useAgentMapEntry({ projectId, - selectedPlanner, api, - harness, - theme, - openPlannerSession, - onPlannerReady, subscribeProposalChanges, subscribeReconnects, }: AgentMapEntryOptions): { state: AgentMapEntryState; retryWorkspace: () => void; - retryPlanner: () => void; - retryAll: () => void; - openFreshPlanner: () => void; } { const [state, setState] = useState(EMPTY_ENTRY); const currentProjectRef = useRef(projectId); const startedProjectRef = useRef(null); const workspaceRequestRef = useRef(0); - const plannerRequestRef = useRef(0); const apiRef = useRef(api); - const harnessRef = useRef(harness); - const themeRef = useRef(theme); - const openPlannerRef = useRef(openPlannerSession); - const onPlannerReadyRef = useRef(onPlannerReady); const visibleProposalRef = useRef(new Map()); const visibleDeltaRef = useRef( new Map(), @@ -166,15 +113,6 @@ export function useAgentMapEntry({ currentProjectRef.current = projectId; apiRef.current = api; - harnessRef.current = harness; - themeRef.current = theme; - openPlannerRef.current = openPlannerSession; - onPlannerReadyRef.current = onPlannerReady; - - const selectedResponse = useMemo( - () => selectedPlannerResponse(projectId, selectedPlanner), - [projectId, selectedPlanner], - ); const loadWorkspace = useCallback((target: StudioProjectId): void => { const request = ++workspaceRequestRef.current; @@ -183,7 +121,6 @@ export function useAgentMapEntry({ ? current : { projectId: target, - planner: { status: "idle" } as AgentMapPlannerPaneState, unavailable: null, }), projectId: target, @@ -201,11 +138,7 @@ export function useAgentMapEntry({ visibleProposalRef.current.get(target) !== value.proposal.id ) { visibleProposalRef.current.set(target, value.proposal.id); - const latest = value.proposal.history.at(-1); - track("agent_map.proposal_created", { - author_role: latest?.actor.role ?? "unknown", - assignment_kind: latest?.actor.assignment?.kind ?? "none", - }); + track("agent_map.proposal_created"); } setState((current) => current.projectId === target @@ -300,8 +233,6 @@ export function useAgentMapEntry({ : current, ); track("agent_map.proposal_visible", { - author_role: delta.actor.role, - assignment_kind: delta.actor.assignment?.kind ?? "none", visible_latency_ms: visibleLatency, }); }; @@ -332,95 +263,25 @@ export function useAgentMapEntry({ }; }, [loadWorkspace, projectId, subscribeProposalChanges, subscribeReconnects]); - const loadPlanner = useCallback( - (target: StudioProjectId, mode: PlannerSessionRequest["mode"]): void => { - const request = ++plannerRequestRef.current; - setState((current) => ({ - ...(current.projectId === target - ? current - : { - projectId: target, - workspace: { status: "idle" } as AgentMapWorkspacePaneState, - unavailable: null, - }), - projectId: target, - planner: { status: "loading" }, - })); - void openPlannerRef - .current(target, { - mode, - harness: harnessRef.current(), - theme: themeRef.current(), - }) - .then( - (value) => { - if ( - currentProjectRef.current !== target || - plannerRequestRef.current !== request - ) - return; - onPlannerReadyRef.current(value, mode); - setState((current) => - current.projectId === target - ? { ...current, planner: { status: "ready", value } } - : current, - ); - }, - (error: unknown) => { - if ( - currentProjectRef.current !== target || - plannerRequestRef.current !== request - ) - return; - track("agent_map.workspace_load_failed", { - ...failureDimensions(target, error), - pane: "planner", - }); - const message = errorMessage( - error, - "The planning conversation could not be opened.", - ); - setState((current) => - current.projectId === target - ? { - ...current, - planner: { status: "error", message }, - unavailable: isWholeWorkspaceUnavailable(error) - ? message - : current.unavailable, - } - : current, - ); - }, - ); - }, - [], - ); - useEffect(() => { if (projectId === null) { startedProjectRef.current = null; workspaceRequestRef.current += 1; - plannerRequestRef.current += 1; setState(EMPTY_ENTRY); return; } // The ref survives React StrictMode's setup/cleanup probe, preventing two - // planner launches and duplicate entry telemetry for one visible visit. + // map reads and duplicate entry telemetry for one visible visit. if (startedProjectRef.current === projectId) return; startedProjectRef.current = projectId; setState({ projectId, workspace: { status: "loading" }, - planner: selectedResponse - ? { status: "ready", value: selectedResponse } - : { status: "loading" }, unavailable: null, }); track("agent_map.entered", { project_id: projectId }); loadWorkspace(projectId); - if (!selectedResponse) loadPlanner(projectId, "resume-or-create"); - }, [loadPlanner, loadWorkspace, projectId, selectedResponse]); + }, [loadWorkspace, projectId]); const retryWorkspace = useCallback((): void => { const target = currentProjectRef.current; @@ -429,28 +290,6 @@ export function useAgentMapEntry({ loadWorkspace(target); }, [loadWorkspace]); - const retryPlanner = useCallback((): void => { - const target = currentProjectRef.current; - if (!target) return; - setState((current) => ({ ...current, unavailable: null })); - loadPlanner(target, "resume-or-create"); - }, [loadPlanner]); - - const retryAll = useCallback((): void => { - const target = currentProjectRef.current; - if (!target) return; - setState((current) => ({ ...current, unavailable: null })); - loadWorkspace(target); - loadPlanner(target, "resume-or-create"); - }, [loadPlanner, loadWorkspace]); - - const openFreshPlanner = useCallback((): void => { - const target = currentProjectRef.current; - if (!target) return; - setState((current) => ({ ...current, unavailable: null })); - loadPlanner(target, "fresh"); - }, [loadPlanner]); - return { state: state.projectId === projectId @@ -460,14 +299,8 @@ export function useAgentMapEntry({ : { projectId, workspace: { status: "loading" }, - planner: selectedResponse - ? { status: "ready", value: selectedResponse } - : { status: "loading" }, unavailable: null, }, retryWorkspace, - retryPlanner, - retryAll, - openFreshPlanner, }; } diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index 1c77e2e54..12950cf4a 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -167,7 +167,13 @@ export interface HarnessStateHook { /** Loads past sessions across a set of directories (one global * resume view, not one directory at a time). */ loadHistory: (cwds: string[]) => Promise; - createSession: (req: CreateSessionRequest) => Promise; + createSession: ( + req: CreateSessionRequest, + /** Runs as soon as the POST identifies the exact session, before selecting + * it or refreshing the project catalog. This lets an explicit create own + * its pending UI intent without guessing from same-cwd status events. */ + onCreated?: (session: HarnessSession) => void, + ) => Promise; attachFile: ( sessionId: string, request: AttachFileRequest, @@ -184,8 +190,8 @@ export interface HarnessStateHook { /** A past session's reconstructed transcript (null when nothing was * recorded for it). Stable identity — safe as an effect dependency. */ sessionRecord: (id: string) => Promise; - /** Opens the trusted map-planner for a project and publishes the returned - * session into the same store that backs the normal session strip. */ + /** @deprecated Rolling client alias that opens an ordinary project session + * through the bounded planner-named HTTP route. */ openPlannerSession: ( projectId: StudioProjectId, request: PlannerSessionRequest, @@ -1244,153 +1250,156 @@ export function useHarnessState(): HarnessStateHook { ); useEffect(() => { - return subscribeEvents((message) => { - // SessionRecord invalidations have a targeted listener below. Keeping - // them out of the legacy last-message slot avoids repainting the entire - // Studio for records no mounted transcript is watching. - if (message.type !== "session.record.changed") setLastMessage(message); - setSystemGraphAnnouncements((current) => - systemGraphAnnouncementsAfterMessage(current, message), - ); - if (message.type === "session.status") { - sessionStatusRevisions.current.set( - message.session.id, - (sessionStatusRevisions.current.get(message.session.id) ?? 0) + 1, + return subscribeEvents( + (message) => { + // SessionRecord invalidations have a targeted listener below. Keeping + // them out of the legacy last-message slot avoids repainting the entire + // Studio for records no mounted transcript is watching. + if (message.type !== "session.record.changed") setLastMessage(message); + setSystemGraphAnnouncements((current) => + systemGraphAnnouncementsAfterMessage(current, message), ); - setState((prev) => { - if (!prev) return prev; - const exists = prev.sessions.some( - (session) => session.id === message.session.id, + if (message.type === "session.status") { + sessionStatusRevisions.current.set( + message.session.id, + (sessionStatusRevisions.current.get(message.session.id) ?? 0) + 1, ); - const sessions = exists - ? prev.sessions.map((session) => - session.id === message.session.id ? message.session : session, - ) - : [...prev.sessions, message.session]; - return { ...prev, sessions }; - }); - // An exited session can never produce more output — drop any pending - // busy state/timer for it rather than leaving a stale pulse on a tab - // that's about to move to the history menu. - if (message.session.status === "exited") { - const id = message.session.id; - const timer = busyTimers.current.get(id); - if (timer) { - clearTimeout(timer); - busyTimers.current.delete(id); - } - setBusySessionIds((prev) => { - if (!prev.has(id)) return prev; - const next = new Set(prev); - next.delete(id); - return next; + setState((prev) => { + if (!prev) return prev; + const exists = prev.sessions.some( + (session) => session.id === message.session.id, + ); + const sessions = exists + ? prev.sessions.map((session) => + session.id === message.session.id ? message.session : session, + ) + : [...prev.sessions, message.session]; + return { ...prev, sessions }; }); - } - } else if (message.type === "session.record.changed") { - sessionRecordChangeListeners.current.forEach((listener) => - listener(message.harnessSessionId), - ); - } else if (message.type === "workflows.changed") { - // The workspace watcher saw a sapiom.json appear/change — the one - // client signal for an agent built in-app. Emit agent.created for any - // path we haven't already baselined (load) or imported (scan/connect). - // Capture this at message receipt, not response settlement: boot can - // resolve while this list is in flight. Anything announced before boot - // established its baseline belongs to that baseline regardless of HTTP - // completion order. - const baselineOnly = seenAgentPathsRef.current === null; - void refreshWorkflows() - .then((workflows) => { - const seen = seenAgentPathsRef.current; - if (baselineOnly || seen === null) { - // Lost the race with the initial load — baseline, don't emit. - const baseline = (seenAgentPathsRef.current ??= new Set()); - for (const workflow of workflows) baseline.add(workflow.path); - return; - } - for (const path of newAgentPaths(seen, workflows)) { - seen.add(path); - trackProduct("agent.created", { - workflow_slug: slugFromPath(path), - ...agentProvenance(workflows.find((w) => w.path === path)), - }); + // An exited session can never produce more output — drop any pending + // busy state/timer for it rather than leaving a stale pulse on a tab + // that's about to move to the history menu. + if (message.session.status === "exited") { + const id = message.session.id; + const timer = busyTimers.current.get(id); + if (timer) { + clearTimeout(timer); + busyTimers.current.delete(id); } - }) - // A bus refresh is best-effort. Keep the last successful projection - // and let the next event/auth/manual refresh retry; never create an - // unhandled rejection from the event callback. - .catch(() => undefined); - } else if (message.type === "system-graph.changed") { - // Invalidate even while its workspace destination is closed. The next - // open must never resurrect a pre-edit process-lifetime promise. - systemGraphLoader.invalidate(message.workspaceKey, message.revision); - } else if (message.type === "agent-map.proposal.changed") { - agentMapProposalChangeListeners.current.forEach((listener) => - listener(message.delta), - ); - } else if (message.type === "execution.started") { - startRunPolling( - message.harnessSessionId, - message.executionId, - message.target, - ); - } else if (message.type === "port.detected") { - setPreviewBySession((prev) => - new Map(prev).set(message.harnessSessionId, { - port: message.port, - url: message.url, - }), - ); - } else if (message.type === "task.status") { - // Each frame is a full snapshot of one task — upsert by id. - setTasks((prev) => { - const exists = prev.some((task) => task.id === message.task.id); - return exists - ? prev.map((task) => - task.id === message.task.id ? message.task : task, - ) - : [...prev, message.task]; - }); - } else if (message.type === "session.activity") { - const id = message.harnessSessionId; - setBusySessionIds((prev) => - prev.has(id) ? prev : new Set(prev).add(id), - ); - const existingTimer = busyTimers.current.get(id); - if (existingTimer) clearTimeout(existingTimer); - busyTimers.current.set( - id, - setTimeout(() => { - busyTimers.current.delete(id); setBusySessionIds((prev) => { if (!prev.has(id)) return prev; const next = new Set(prev); next.delete(id); return next; }); - }, BUSY_WINDOW_MS), - ); - } else if (message.type === "auth.changed") { - // Real-time auth state update from the server — update AppState in - // place so SettingsPopover, WorkflowsRail, and deploy gating all - // react without a full reload or polling. - setState((prev) => - prev - ? { - ...prev, - authenticated: message.authenticated, - organizationName: message.organizationName, + } + } else if (message.type === "session.record.changed") { + sessionRecordChangeListeners.current.forEach((listener) => + listener(message.harnessSessionId), + ); + } else if (message.type === "workflows.changed") { + // The workspace watcher saw a sapiom.json appear/change — the one + // client signal for an agent built in-app. Emit agent.created for any + // path we haven't already baselined (load) or imported (scan/connect). + // Capture this at message receipt, not response settlement: boot can + // resolve while this list is in flight. Anything announced before boot + // established its baseline belongs to that baseline regardless of HTTP + // completion order. + const baselineOnly = seenAgentPathsRef.current === null; + void refreshWorkflows() + .then((workflows) => { + const seen = seenAgentPathsRef.current; + if (baselineOnly || seen === null) { + // Lost the race with the initial load — baseline, don't emit. + const baseline = (seenAgentPathsRef.current ??= new Set()); + for (const workflow of workflows) baseline.add(workflow.path); + return; } - : prev, - ); - // Definition build evidence is authenticated enrichment. Re-list on - // both sign-in and sign-out so a post-boot login can enable a ready - // agent and a logout cannot leave tenant metadata pinned in memory. - void refreshWorkflows().catch(() => undefined); - } - }, () => { - eventReconnectListeners.current.forEach((listener) => listener()); - }); + for (const path of newAgentPaths(seen, workflows)) { + seen.add(path); + trackProduct("agent.created", { + workflow_slug: slugFromPath(path), + ...agentProvenance(workflows.find((w) => w.path === path)), + }); + } + }) + // A bus refresh is best-effort. Keep the last successful projection + // and let the next event/auth/manual refresh retry; never create an + // unhandled rejection from the event callback. + .catch(() => undefined); + } else if (message.type === "system-graph.changed") { + // Invalidate even while its workspace destination is closed. The next + // open must never resurrect a pre-edit process-lifetime promise. + systemGraphLoader.invalidate(message.workspaceKey, message.revision); + } else if (message.type === "agent-map.proposal.changed") { + agentMapProposalChangeListeners.current.forEach((listener) => + listener(message.delta), + ); + } else if (message.type === "execution.started") { + startRunPolling( + message.harnessSessionId, + message.executionId, + message.target, + ); + } else if (message.type === "port.detected") { + setPreviewBySession((prev) => + new Map(prev).set(message.harnessSessionId, { + port: message.port, + url: message.url, + }), + ); + } else if (message.type === "task.status") { + // Each frame is a full snapshot of one task — upsert by id. + setTasks((prev) => { + const exists = prev.some((task) => task.id === message.task.id); + return exists + ? prev.map((task) => + task.id === message.task.id ? message.task : task, + ) + : [...prev, message.task]; + }); + } else if (message.type === "session.activity") { + const id = message.harnessSessionId; + setBusySessionIds((prev) => + prev.has(id) ? prev : new Set(prev).add(id), + ); + const existingTimer = busyTimers.current.get(id); + if (existingTimer) clearTimeout(existingTimer); + busyTimers.current.set( + id, + setTimeout(() => { + busyTimers.current.delete(id); + setBusySessionIds((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + }, BUSY_WINDOW_MS), + ); + } else if (message.type === "auth.changed") { + // Real-time auth state update from the server — update AppState in + // place so SettingsPopover, WorkflowsRail, and deploy gating all + // react without a full reload or polling. + setState((prev) => + prev + ? { + ...prev, + authenticated: message.authenticated, + organizationName: message.organizationName, + } + : prev, + ); + // Definition build evidence is authenticated enrichment. Re-list on + // both sign-in and sign-out so a post-boot login can enable a ready + // agent and a logout cannot leave tenant metadata pinned in memory. + void refreshWorkflows().catch(() => undefined); + } + }, + () => { + eventReconnectListeners.current.forEach((listener) => listener()); + }, + ); }, [refreshWorkflows, startRunPolling]); /** @@ -1516,8 +1525,16 @@ export function useHarnessState(): HarnessStateHook { ); const createSession = useCallback( - async (req: CreateSessionRequest): Promise => { + async ( + req: CreateSessionRequest, + onCreated?: (session: HarnessSession) => void, + ): Promise => { const session = await api.createSession(req); + // The response is the first unambiguous client-side correlation between + // this explicit request and its session. Notify its owner before + // selection can trigger project-preference restoration; a status frame + // at the same cwd may belong to the automatic first project session. + onCreated?.(session); // The event bus can deliver this session's first `session.status` before // the POST response resolves (the server broadcasts starting/running // during create) — appending unconditionally then renders a duplicate @@ -1732,19 +1749,14 @@ export function useHarnessState(): HarnessStateHook { setState((prev) => (prev ? { ...prev, sessions: remaining } : prev)); if (activeSessionId === id) { const closed = state?.sessions.find((session) => session.id === id); - const nextPlanner = - closed?.planning?.identity.role === "map-planner" - ? remaining.find( - (session) => - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === - closed.planning?.identity.projectId, - ) - : undefined; + const projectId = closed?.agentMapIdentity?.projectId; const nextRunning = - nextPlanner ?? - remaining.find((session) => session.status !== "exited"); + remaining.find( + (session) => + session.status !== "exited" && + projectId != null && + session.agentMapIdentity?.projectId === projectId, + ) ?? remaining.find((session) => session.status !== "exited"); selectSession(nextRunning ? nextRunning.id : null); } }, From 036a73c68667af86742ffc63726b69dd70144894 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 4 Sep 2026 16:56:03 +0000 Subject: [PATCH 2/3] feat(harness): unify project agents (SAP-3148) --- .changeset/unified-project-agents.md | 20 + .../scripts/smoke-agent-stub.cjs | 60 + packages/harness-desktop/scripts/smoke.sh | 37 +- .../src/main/smoke-agent-stub.test.ts | 60 + packages/harness-desktop/src/main/smoke.ts | 294 +- packages/harness/README.md | 17 + .../src/core/ingest-credentials.test.ts | 32 +- .../harness/src/core/ingest-credentials.ts | 45 +- .../harness/src/core/planner-greeting.test.ts | 2867 ++++++++++++++- packages/harness/src/core/planner-greeting.ts | 3261 ++++++++++++++--- packages/harness/src/core/planning-session.ts | 22 +- .../harness/src/core/session-manager.test.ts | 465 ++- packages/harness/src/core/session-manager.ts | 403 +- .../src/core/studio-project-catalog.test.ts | 114 +- .../src/core/studio-project-catalog.ts | 95 +- packages/harness/src/core/task-manager.ts | 2 +- packages/harness/src/index.ts | 4 + .../src/server/agent-map-mcp-wiring.test.ts | 147 + packages/harness/src/server/agent-map.test.ts | 331 +- packages/harness/src/server/agent-map.ts | 73 +- .../src/server/codex-tailer-wiring.test.ts | 57 + packages/harness/src/server/index.ts | 117 +- packages/harness/src/server/ingest.test.ts | 187 +- packages/harness/src/server/ingest.ts | 72 +- .../server/project-bootstrap-outbox.test.ts | 96 +- packages/harness/src/server/rest.test.ts | 216 +- packages/harness/src/server/rest.ts | 52 +- .../harness/src/server/terminal-ws.test.ts | 109 + packages/harness/src/server/terminal-ws.ts | 46 +- packages/harness/src/shared/agent-map.ts | 19 +- packages/harness/src/shared/paths.ts | 28 +- .../harness/src/shared/project-roots.test.ts | 70 + packages/harness/src/shared/project-roots.ts | 53 +- packages/harness/src/shared/types.ts | 22 +- .../harness/web/src/lib/agent-map.test.ts | 53 + packages/harness/web/src/lib/agent-map.ts | 37 +- packages/harness/web/src/lib/paths.test.ts | 65 +- packages/harness/web/src/lib/project-tree.ts | 11 +- 38 files changed, 8735 insertions(+), 924 deletions(-) create mode 100644 packages/harness-desktop/scripts/smoke-agent-stub.cjs create mode 100644 packages/harness-desktop/src/main/smoke-agent-stub.test.ts create mode 100644 packages/harness/src/server/terminal-ws.test.ts diff --git a/.changeset/unified-project-agents.md b/.changeset/unified-project-agents.md index cbc601f62..9b892e914 100644 --- a/.changeset/unified-project-agents.md +++ b/.changeset/unified-project-agents.md @@ -4,3 +4,23 @@ --- Unify Agent Studio project sessions around one ordinary coding-agent identity, make the project name open the shared Agent Map, and seed new projects through a durable, retry-safe bootstrap in the first `Plan Agents` session. + +**Breaking for embedders** (minor while `@sapiom/harness` is pre-1.0): +`HarnessSession.agentMapIdentity` is now the role-neutral +`ProjectAgentSession { projectId, userId, sessionId }`; `role` and `assignment` +are no longer present. Valid persisted `planning` metadata is migrated into the +optional `projectBootstrap` lifecycle field and then removed. The deprecated +planner-message alias now returns `ProjectBootstrapMetadata | null`, with +`projectId`, `userId`, `targetSessionId`, and `bootstrap` replacing the former +nested `identity` and `greeting` fields. + +**Migration:** stop branching on `agentMapIdentity.role` or `.assignment`, read +optional `projectBootstrap` only for bootstrap status, and handle `metadata: +null` from the compatibility alias—or move to the generic session routes. An +embedder that already owns a new session's first prompt should send +`initialUserInputPending: true` in the same `CreateSessionRequest`, so automatic +bootstrap yields before launch. New telemetry consumers should recognize the +neutral `project_agent.*` and `project_bootstrap.*` events. Valid legacy state +keeps its session/provider IDs, cwd, title, transcript, and Canvas; malformed or +conflicting authority is retained and fails closed. Downgrading does not restore +the former planner coordinator semantics. diff --git a/packages/harness-desktop/scripts/smoke-agent-stub.cjs b/packages/harness-desktop/scripts/smoke-agent-stub.cjs new file mode 100644 index 000000000..9c4facb66 --- /dev/null +++ b/packages/harness-desktop/scripts/smoke-agent-stub.cjs @@ -0,0 +1,60 @@ +const fs = require("node:fs"); + +function environmentCapturePath(base, sessionId) { + return `${base}.${encodeURIComponent(sessionId)}.json`; +} + +function captureAgentEnvironment(env = process.env) { + const base = env.SAPIOM_SMOKE_AGENT_ENV; + const sessionId = env.SAPIOM_HARNESS_SESSION_ID; + if (!base || !sessionId) return null; + + const file = environmentCapturePath(base, sessionId); + const snapshot = { + schemaVersion: 1, + sessionId, + variableCount: Object.keys(env).length, + hasEsbuildBinaryPath: Object.prototype.hasOwnProperty.call( + env, + "ESBUILD_BINARY_PATH", + ), + hasPath: typeof env.PATH === "string" && env.PATH.length > 0, + }; + fs.writeFileSync(file, `${JSON.stringify(snapshot)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + return file; +} + +function runSessionStartHook() { + try { + const settingsIndex = process.argv.indexOf("--settings"); + const settingsPath = + settingsIndex > -1 ? process.argv[settingsIndex + 1] : null; + if (!settingsPath) return; + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + const command = settings.hooks.SessionStart[0].hooks[0].command; + const { execFileSync, execSync } = require("node:child_process"); + if (process.platform === "win32") { + const bash = "C:\\Program Files\\Git\\bin\\bash.exe"; + if (fs.existsSync(bash)) { + execFileSync(bash, ["-c", command], { stdio: "ignore" }); + } else { + execSync(command, { stdio: "ignore" }); + } + } else { + execFileSync("/bin/sh", ["-c", command], { stdio: "ignore" }); + } + } catch { + // A failed hook is exactly what checkSessionCreate's ready poll reports. + } +} + +module.exports = { captureAgentEnvironment, environmentCapturePath }; + +if (require.main === module) { + captureAgentEnvironment(); + runSessionStartHook(); + setTimeout(() => process.exit(0), 3000); +} diff --git a/packages/harness-desktop/scripts/smoke.sh b/packages/harness-desktop/scripts/smoke.sh index 8ce6e15f6..2a5a6c790 100755 --- a/packages/harness-desktop/scripts/smoke.sh +++ b/packages/harness-desktop/scripts/smoke.sh @@ -75,52 +75,31 @@ export SAPIOM_SMOKE_OUT="$(native "$report_file")" # check can assert the WHOLE readiness chain (settings → hook command → node # resolution under the hook shell → POST → ready), the exact seam that broke # silently on Windows and dropped every held first prompt. -cat > "$smoke_home/stub-agent.js" <<'STUBJS' -const fs = require("fs"); -const envFile = process.env.SAPIOM_SMOKE_AGENT_ENV; -if (envFile) fs.writeFileSync(envFile, Object.entries(process.env).map(([k, v]) => k + "=" + v).join("\n") + "\n"); -try { - const i = process.argv.indexOf("--settings"); - const settingsPath = i > -1 ? process.argv[i + 1] : null; - if (settingsPath) { - const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); - const command = settings.hooks.SessionStart[0].hooks[0].command; - const { execFileSync, execSync } = require("child_process"); - if (process.platform === "win32") { - const bash = "C:\\Program Files\\Git\\bin\\bash.exe"; - if (fs.existsSync(bash)) execFileSync(bash, ["-c", command], { stdio: "ignore" }); - else execSync(command, { stdio: "ignore" }); - } else { - execFileSync("/bin/sh", ["-c", command], { stdio: "ignore" }); - } - } -} catch { - // A failed hook is exactly what the ready-poll in checkSessionCreate reports. -} -setTimeout(() => process.exit(0), 3000); -STUBJS +cp "$here/smoke-agent-stub.cjs" "$smoke_home/stub-agent.cjs" if [ "$(uname -s)" != "Linux" ] && [ "$(uname -s)" != "Darwin" ]; then # Shaped like an npm shim on purpose — a `.cmd` that runs `node