From 03c22c872f040e5aa11824ba4bc86d7caa192853 Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 12 May 2026 22:06:09 +0000 Subject: [PATCH 01/28] feat: persist supervisor target ids --- packages/core/src/domain/supervisor.ts | 35 +++- packages/server/src/__tests__/db.test.ts | 79 ++++++++- .../src/__tests__/supervisor-repo.test.ts | 38 ++++ packages/server/src/storage/db.test.ts | 5 + packages/server/src/storage/db.ts | 15 ++ .../src/storage/migrations/001_init.sql | 4 +- .../storage/repositories/supervisor-repo.ts | 15 +- packages/server/src/storage/schema-version.ts | 163 +++++++++++++++++- 8 files changed, 345 insertions(+), 9 deletions(-) diff --git a/packages/core/src/domain/supervisor.ts b/packages/core/src/domain/supervisor.ts index bbe0cc39f..a3e9cfa48 100644 --- a/packages/core/src/domain/supervisor.ts +++ b/packages/core/src/domain/supervisor.ts @@ -19,7 +19,37 @@ export type CycleStatus = export type CycleTrigger = "turn_completed" | "manual" | "scheduled"; -export type SupervisorStopReason = "objective_complete" | "max_supervision_count_reached"; +export type SupervisorStopReason = + | "objective_complete" + | "max_supervision_count_reached" + | "supervisor_uncertain" + | "needs_user_input"; + +export type SupervisorPlanStepStatus = "pending" | "in_progress" | "completed"; + +export interface SupervisorPlanStep { + step: string; + status: SupervisorPlanStepStatus; +} + +export interface SupervisorTargetMemory { + summary: string; + plan: SupervisorPlanStep[]; + updatedAt: number; +} + +export interface SupervisorCycleStepUpdate { + step: string; + status: SupervisorPlanStepStatus; +} + +export interface SupervisorCycleTargetRecord { + cycleId: string; + targetId: string; + summary?: string; + stepUpdates: SupervisorCycleStepUpdate[]; + createdAt: number; +} export type SupervisorCycleAttemptStatus = "evaluating" | "completed" | "failed" | "cancelled"; @@ -65,6 +95,7 @@ export interface Supervisor { id: string; sessionId: string; workspaceId: string; + targetId: string; state: SupervisorState; objective: string; evaluatorProviderId: string; @@ -73,6 +104,8 @@ export interface Supervisor { completedSupervisionCount: number; scheduledAt?: number; stopReason?: SupervisorStopReason; + currentTargetMemory?: SupervisorTargetMemory; + recentTargetCycles: SupervisorCycleTargetRecord[]; cycles: SupervisorCycle[]; lastCycleAt?: number; lastEvaluatedTurnId?: string; diff --git a/packages/server/src/__tests__/db.test.ts b/packages/server/src/__tests__/db.test.ts index 45a5b07b5..5ca6a9083 100644 --- a/packages/server/src/__tests__/db.test.ts +++ b/packages/server/src/__tests__/db.test.ts @@ -8,6 +8,7 @@ import { CURRENT_SCHEMA_VERSION, IncompatibleSchemaError, V1_SCHEMA_SQL, + V2_SCHEMA_SQL, } from "../storage/schema-version.js"; describe("Database", () => { @@ -98,7 +99,7 @@ describe("Database", () => { expect(tables.map((table) => table.name)).not.toContain("_migrations"); }); - it("should upgrade a known v1 supervisor schema to v2 when user_version is unset", () => { + it("should upgrade a known v1 supervisor schema to v3 when user_version is unset", () => { const dbPath = join(tempDir, "v1.db"); const rawDb = new DatabaseSync(dbPath); rawDb.exec("PRAGMA user_version = 0"); @@ -108,13 +109,14 @@ describe("Database", () => { db = openDatabase(dbPath); const userVersion = db.prepare("PRAGMA user_version").get() as { user_version: number }; - expect(userVersion.user_version).toBe(2); + expect(userVersion.user_version).toBe(CURRENT_SCHEMA_VERSION); const supervisorColumns = db.prepare("PRAGMA table_info(supervisors)").all() as Array<{ name: string; }>; expect(supervisorColumns.map((column) => column.name)).toEqual( expect.arrayContaining([ + "target_id", "evaluator_model", "max_supervision_count", "completed_supervision_count", @@ -138,6 +140,79 @@ describe("Database", () => { expect(upgradedIndex?.name).toBe("idx_supervisor_cycle_attempts_cycle"); }); + it("should upgrade a known v2 supervisor schema to v3 and backfill target ids", () => { + const dbPath = join(tempDir, "v2.db"); + const rawDb = new DatabaseSync(dbPath); + rawDb.exec("PRAGMA user_version = 2"); + rawDb.exec(V2_SCHEMA_SQL); + rawDb.exec(` + INSERT INTO workspaces (id, path, target_runtime, opened_at, last_active_at, ui_state) + VALUES ('ws-1', '/workspace', 'native', 1, 1, '{}'); + `); + rawDb.exec(` + INSERT INTO terminals (id, workspace_id, kind, cwd, argv, cols, rows, created_at) + VALUES ('term-1', 'ws-1', 'agent', '/workspace', '[]', 120, 30, 1); + `); + rawDb.exec(` + INSERT INTO sessions ( + id, workspace_id, terminal_id, provider_id, capability, state, started_at, last_active_at + ) VALUES ('sess-1', 'ws-1', 'term-1', 'codex', 'full', 'idle', 1, 1); + `); + rawDb.exec(` + INSERT INTO supervisors ( + id, + session_id, + workspace_id, + state, + objective, + evaluator_provider_id, + evaluator_model, + max_supervision_count, + completed_supervision_count, + scheduled_at, + stop_reason, + last_cycle_at, + last_evaluated_turn_id, + error_reason, + created_at, + updated_at + ) VALUES ( + 'sup-legacy', + 'sess-1', + 'ws-1', + 'idle', + 'Legacy supervisor', + 'codex', + NULL, + 0, + 0, + NULL, + NULL, + NULL, + NULL, + NULL, + 1, + 1 + ); + `); + rawDb.close(); + + db = openDatabase(dbPath); + + const userVersion = db.prepare("PRAGMA user_version").get() as { user_version: number }; + expect(userVersion.user_version).toBe(CURRENT_SCHEMA_VERSION); + + const supervisorColumns = db.prepare("PRAGMA table_info(supervisors)").all() as Array<{ + name: string; + }>; + expect(supervisorColumns.map((column) => column.name)).toContain("target_id"); + + const upgradedRow = db + .prepare("SELECT target_id FROM supervisors WHERE id = ?") + .get("sup-legacy") as { target_id: string }; + expect(upgradedRow.target_id).toBe("legacy_sup-legacy"); + }); + it("should restamp user_version for an already-current schema when it is unset", () => { const dbPath = join(tempDir, "current-unstamped.db"); db = openDatabase(dbPath); diff --git a/packages/server/src/__tests__/supervisor-repo.test.ts b/packages/server/src/__tests__/supervisor-repo.test.ts index 0efcaa13b..6d5644a01 100644 --- a/packages/server/src/__tests__/supervisor-repo.test.ts +++ b/packages/server/src/__tests__/supervisor-repo.test.ts @@ -46,6 +46,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-1", state: "idle", objective: "Finish supervisor persistence", evaluatorProviderId: "codex", @@ -64,6 +65,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-initial", state: "stopped", objective: "Stop when objective is complete", evaluatorProviderId: "codex", @@ -87,6 +89,31 @@ describe("SupervisorRepo", () => { }); }); + it("persists targetId on create and update", () => { + supervisorRepo.create({ + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + targetId: "target-alpha", + state: "idle", + objective: "Track target scope", + evaluatorProviderId: "codex", + createdAt: 10, + updatedAt: 10, + }); + + const created = supervisorRepo.findById("sup-1"); + expect(created?.targetId).toBe("target-alpha"); + + const updated = supervisorRepo.update("sup-1", { + targetId: "target-beta", + updatedAt: 11, + }); + + expect(updated.targetId).toBe("target-beta"); + expect(supervisorRepo.findById("sup-1")?.targetId).toBe("target-beta"); + }); + it("rejects a supervisor whose workspace does not match its session workspace", () => { db.prepare( "INSERT INTO workspaces (id, path, target_runtime, opened_at, last_active_at, ui_state) VALUES (?, ?, ?, ?, ?, ?)" @@ -103,6 +130,7 @@ describe("SupervisorRepo", () => { id: "sup-bad", sessionId: "sess-2", workspaceId: "ws-1", + targetId: "target-bad", state: "idle", objective: "This insert must fail", evaluatorProviderId: "claude", @@ -124,6 +152,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-1", state: "idle", objective: "Enforce supervisor/session integrity", evaluatorProviderId: "claude", @@ -152,6 +181,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-1", state: "idle", objective: "Keep nullable fields intact", evaluatorProviderId: "claude", @@ -178,6 +208,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-1", state: "idle", objective: "Clear nullable fields", evaluatorProviderId: "claude", @@ -205,6 +236,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-1", state: "idle", objective: "Clear execution policy fields", evaluatorProviderId: "claude", @@ -236,6 +268,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-1", state: "idle", objective: "Keep cycle fields intact", evaluatorProviderId: "claude", @@ -276,6 +309,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-1", state: "idle", objective: "Clear cycle fields", evaluatorProviderId: "claude", @@ -336,6 +370,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-1", state: "idle", objective: "Keep the newest 100 cycles", evaluatorProviderId: "claude", @@ -371,6 +406,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-1", state: "idle", objective: "Allow scheduled cancelled cycle", evaluatorProviderId: "claude", @@ -399,6 +435,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-1", state: "idle", objective: "Track attempts", evaluatorProviderId: "claude", @@ -456,6 +493,7 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "target-1", state: "idle", objective: "Update attempts", evaluatorProviderId: "claude", diff --git a/packages/server/src/storage/db.test.ts b/packages/server/src/storage/db.test.ts index 631c630d5..10b0a0374 100644 --- a/packages/server/src/storage/db.test.ts +++ b/packages/server/src/storage/db.test.ts @@ -53,6 +53,11 @@ describe("database schema baseline", () => { expect(sessionColumns.find((column) => column.name === "transcript_path")).toBeUndefined(); expect(sessionColumns.find((column) => column.name === "title")).toBeDefined(); + const supervisorColumns = db.prepare("PRAGMA table_info(supervisors)").all() as Array<{ + name: string; + }>; + expect(supervisorColumns.find((column) => column.name === "target_id")).toBeDefined(); + const indexNames = ( db.prepare("SELECT name FROM sqlite_master WHERE type='index' ORDER BY name").all() as Array<{ name: string; diff --git a/packages/server/src/storage/db.ts b/packages/server/src/storage/db.ts index e1754e228..8f6aafbf8 100644 --- a/packages/server/src/storage/db.ts +++ b/packages/server/src/storage/db.ts @@ -6,6 +6,7 @@ import { detectSchema, IncompatibleSchemaError, stampCurrentSchemaVersion, + stampSchemaVersion, } from "./schema-version.js"; interface IntegrityCheckRow { @@ -97,6 +98,14 @@ function upgradeSchemaV1ToV2(db: Database): void { db.exec( "CREATE INDEX idx_supervisor_cycle_attempts_cycle ON supervisor_cycle_attempts(cycle_id, attempt_index)" ); + stampSchemaVersion(db, 2); + }); +} + +function upgradeSchemaV2ToV3(db: Database): void { + withTransaction(db, () => { + db.exec("ALTER TABLE supervisors ADD COLUMN target_id TEXT NOT NULL DEFAULT ''"); + db.exec("UPDATE supervisors SET target_id = 'legacy_' || id WHERE target_id = ''"); stampCurrentSchemaVersion(db); }); } @@ -128,6 +137,12 @@ function initializeOrUpgradeSchema(db: Database, dbPath: string): void { case "v1": upgradeSchemaV1ToV2(db); + upgradeSchemaV2ToV3(db); + assertCurrentSchema(db, dbPath); + return; + + case "v2": + upgradeSchemaV2ToV3(db); assertCurrentSchema(db, dbPath); return; diff --git a/packages/server/src/storage/migrations/001_init.sql b/packages/server/src/storage/migrations/001_init.sql index 5b3f006fa..98e9ab894 100644 --- a/packages/server/src/storage/migrations/001_init.sql +++ b/packages/server/src/storage/migrations/001_init.sql @@ -1,5 +1,5 @@ -- Current database schema baseline -PRAGMA user_version = 2; +PRAGMA user_version = 3; CREATE TABLE IF NOT EXISTS workspaces ( id TEXT PRIMARY KEY, @@ -78,7 +78,7 @@ CREATE TABLE IF NOT EXISTS supervisors ( last_evaluated_turn_id TEXT, error_reason TEXT, created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, evaluator_model TEXT, max_supervision_count INTEGER NOT NULL DEFAULT 0, completed_supervision_count INTEGER NOT NULL DEFAULT 0, scheduled_at INTEGER, stop_reason TEXT, + updated_at INTEGER NOT NULL, evaluator_model TEXT, max_supervision_count INTEGER NOT NULL DEFAULT 0, completed_supervision_count INTEGER NOT NULL DEFAULT 0, scheduled_at INTEGER, stop_reason TEXT, target_id TEXT NOT NULL DEFAULT '', FOREIGN KEY (session_id, workspace_id) REFERENCES sessions(id, workspace_id) ON DELETE CASCADE, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE ); diff --git a/packages/server/src/storage/repositories/supervisor-repo.ts b/packages/server/src/storage/repositories/supervisor-repo.ts index e46f7bc2a..4881afee1 100644 --- a/packages/server/src/storage/repositories/supervisor-repo.ts +++ b/packages/server/src/storage/repositories/supervisor-repo.ts @@ -5,6 +5,7 @@ interface SupervisorRow { id: string; session_id: string; workspace_id: string; + target_id: string; state: SupervisorState; objective: string; evaluator_provider_id: string; @@ -24,6 +25,7 @@ export interface NewSupervisor { id: string; sessionId: string; workspaceId: string; + targetId: string; state: SupervisorState; objective: string; evaluatorProviderId: string; @@ -40,6 +42,7 @@ export interface NewSupervisor { } export interface SupervisorUpdatePatch { + targetId?: string; state?: SupervisorState; objective?: string; evaluatorProviderId?: string; @@ -60,13 +63,14 @@ export class SupervisorRepo { create(input: NewSupervisor): Supervisor { this.db .prepare( - `INSERT INTO supervisors (id, session_id, workspace_id, state, objective, evaluator_provider_id, evaluator_model, max_supervision_count, completed_supervision_count, scheduled_at, stop_reason, last_cycle_at, last_evaluated_turn_id, error_reason, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO supervisors (id, session_id, workspace_id, target_id, state, objective, evaluator_provider_id, evaluator_model, max_supervision_count, completed_supervision_count, scheduled_at, stop_reason, last_cycle_at, last_evaluated_turn_id, error_reason, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( input.id, input.sessionId, input.workspaceId, + input.targetId, input.state, input.objective, input.evaluatorProviderId, @@ -113,6 +117,10 @@ export class SupervisorRepo { updatedAt: patch.updatedAt ?? Date.now(), }; + if (patch.targetId !== undefined) { + assignments.push("target_id = @targetId"); + params.targetId = patch.targetId; + } if (patch.state !== undefined) { assignments.push("state = @state"); params.state = patch.state; @@ -178,6 +186,7 @@ export class SupervisorRepo { id: row.id, sessionId: row.session_id, workspaceId: row.workspace_id, + targetId: row.target_id, state: row.state, objective: row.objective, evaluatorProviderId: row.evaluator_provider_id, @@ -186,6 +195,8 @@ export class SupervisorRepo { completedSupervisionCount: row.completed_supervision_count, scheduledAt: row.scheduled_at ?? undefined, stopReason: row.stop_reason ?? undefined, + currentTargetMemory: undefined, + recentTargetCycles: [], cycles: [], lastCycleAt: row.last_cycle_at ?? undefined, lastEvaluatedTurnId: row.last_evaluated_turn_id ?? undefined, diff --git a/packages/server/src/storage/schema-version.ts b/packages/server/src/storage/schema-version.ts index df83c8bb7..e30fd559e 100644 --- a/packages/server/src/storage/schema-version.ts +++ b/packages/server/src/storage/schema-version.ts @@ -21,7 +21,7 @@ interface UserVersionRow { user_version: number; } -export type SchemaState = "empty" | "current" | "v1" | "incompatible"; +export type SchemaState = "empty" | "current" | "v1" | "v2" | "incompatible"; export interface SchemaDetection { state: SchemaState; @@ -29,7 +29,7 @@ export interface SchemaDetection { mismatch: string | null; } -export const CURRENT_SCHEMA_VERSION = 2; +export const CURRENT_SCHEMA_VERSION = 3; const CURRENT_SCHEMA_PATH = join(import.meta.dirname, "migrations", "001_init.sql"); @@ -163,6 +163,152 @@ CREATE TABLE auth_login_failures ( CREATE INDEX idx_auth_login_failures_ip_failed_at ON auth_login_failures(ip, failed_at); `; +export const V2_SCHEMA_SQL = ` +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + target_runtime TEXT NOT NULL, + wsl_distro TEXT, + opened_at INTEGER NOT NULL, + last_active_at INTEGER NOT NULL, + ui_state TEXT +); + +CREATE TABLE terminals ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + cwd TEXT NOT NULL, + argv TEXT NOT NULL, + env TEXT, + title TEXT, + cols INTEGER NOT NULL, + rows INTEGER NOT NULL, + created_at INTEGER NOT NULL, + ended_at INTEGER, + exit_code INTEGER +); + +CREATE INDEX idx_terminals_workspace ON terminals(workspace_id); +CREATE INDEX idx_terminals_kind ON terminals(workspace_id, kind); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + terminal_id TEXT NOT NULL REFERENCES terminals(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL, + capability TEXT NOT NULL, + state TEXT NOT NULL, + started_at INTEGER NOT NULL, + ended_at INTEGER, + last_active_at INTEGER NOT NULL, + completion_percent INTEGER, + error_reason TEXT, + archived BOOLEAN DEFAULT 0, + title TEXT +); + +CREATE INDEX idx_sessions_workspace ON sessions(workspace_id); +CREATE UNIQUE INDEX idx_sessions_terminal ON sessions(terminal_id); +CREATE UNIQUE INDEX idx_sessions_id_workspace ON sessions(id, workspace_id); + +CREATE TABLE provider_configs ( + provider_id TEXT PRIMARY KEY, + config TEXT NOT NULL +); + +CREATE TABLE user_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE auth_sessions ( + token TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL +); + +CREATE INDEX idx_auth_sessions_last_seen_at ON auth_sessions(last_seen_at); + +CREATE TABLE supervisors ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + workspace_id TEXT NOT NULL, + state TEXT NOT NULL, + objective TEXT NOT NULL, + evaluator_provider_id TEXT NOT NULL, + last_cycle_at INTEGER, + last_evaluated_turn_id TEXT, + error_reason TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + evaluator_model TEXT, + max_supervision_count INTEGER NOT NULL DEFAULT 0, + completed_supervision_count INTEGER NOT NULL DEFAULT 0, + scheduled_at INTEGER, + stop_reason TEXT, + FOREIGN KEY (session_id, workspace_id) REFERENCES sessions(id, workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); + +CREATE INDEX idx_supervisors_workspace ON supervisors(workspace_id); +CREATE INDEX idx_supervisors_session ON supervisors(session_id); +CREATE UNIQUE INDEX idx_supervisors_id_session ON supervisors(id, session_id); + +CREATE TABLE supervisor_cycles ( + id TEXT PRIMARY KEY, + supervisor_id TEXT NOT NULL, + session_id TEXT NOT NULL, + status TEXT NOT NULL, + trigger TEXT NOT NULL, + evidence_source TEXT NOT NULL, + objective TEXT NOT NULL, + evaluator_provider_id TEXT NOT NULL, + turn_id TEXT, + progress INTEGER, + result TEXT, + injected_guidance TEXT, + error_reason TEXT, + created_at INTEGER NOT NULL, + completed_at INTEGER, + FOREIGN KEY (supervisor_id, session_id) REFERENCES supervisors(id, session_id) ON DELETE CASCADE, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); + +CREATE INDEX idx_supervisor_cycles_supervisor ON supervisor_cycles(supervisor_id, created_at DESC); +CREATE INDEX idx_supervisor_cycles_session ON supervisor_cycles(session_id, created_at DESC); + +CREATE TABLE supervisor_cycle_attempts ( + id TEXT PRIMARY KEY, + cycle_id TEXT NOT NULL REFERENCES supervisor_cycles(id) ON DELETE CASCADE, + attempt_index INTEGER NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + completed_at INTEGER, + error_reason TEXT, + provider_model TEXT +); + +CREATE INDEX idx_supervisor_cycle_attempts_cycle ON supervisor_cycle_attempts(cycle_id, attempt_index); + +CREATE TABLE auth_login_blocks ( + ip TEXT PRIMARY KEY, + failed_count INTEGER NOT NULL, + first_failed_at INTEGER NOT NULL, + last_failed_at INTEGER NOT NULL, + blocked_until INTEGER +); + +CREATE INDEX idx_auth_login_blocks_blocked_until ON auth_login_blocks(blocked_until); + +CREATE TABLE auth_login_failures ( + ip TEXT NOT NULL, + failed_at INTEGER NOT NULL +); + +CREATE INDEX idx_auth_login_failures_ip_failed_at ON auth_login_failures(ip, failed_at); +`; + function normalizeSql(sql: string | null): string { return (sql ?? "").replace(/\s+/g, " ").trim(); } @@ -204,6 +350,7 @@ function buildSchemaEntries(schemaSql: string): SchemaEntry[] { const CURRENT_SCHEMA_ENTRIES = buildSchemaEntries(CURRENT_SCHEMA_SQL); const V1_SCHEMA_ENTRIES = buildSchemaEntries(V1_SCHEMA_SQL); +const V2_SCHEMA_ENTRIES = buildSchemaEntries(V2_SCHEMA_SQL); function hasExactFingerprint( actualEntries: SchemaEntry[], @@ -272,6 +419,14 @@ export function detectSchema(db: Database): SchemaDetection { }; } + if (hasExactFingerprint(actualEntries, V2_SCHEMA_ENTRIES)) { + return { + state: "v2", + userVersion, + mismatch: null, + }; + } + return { state: "incompatible", userVersion, @@ -283,6 +438,10 @@ export function stampCurrentSchemaVersion(db: Database): void { db.exec(`PRAGMA user_version = ${CURRENT_SCHEMA_VERSION}`); } +export function stampSchemaVersion(db: Database, version: number): void { + db.exec(`PRAGMA user_version = ${version}`); +} + export class IncompatibleSchemaError extends Error { readonly code = "db_incompatible_schema"; From c7f145063bd0c74a91f43d4150a2b0f6645d8cbc Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 13 May 2026 04:48:36 +0000 Subject: [PATCH 02/28] feat: add target-scoped supervisor memory --- packages/core/src/domain/supervisor.ts | 31 +- .../__tests__/supervisor-integration.test.ts | 16 +- .../src/__tests__/supervisor-manager.test.ts | 266 +++++++- packages/server/src/server.ts | 2 + .../src/supervisor/context-builder.test.ts | 42 +- .../server/src/supervisor/context-builder.ts | 47 +- .../server/src/supervisor/evaluator.test.ts | 217 ++++-- packages/server/src/supervisor/evaluator.ts | 196 ++++-- .../src/supervisor/evaluator.windows.test.ts | 30 +- packages/server/src/supervisor/index.ts | 2 +- .../server/src/supervisor/manager.test.ts | 48 +- packages/server/src/supervisor/manager.ts | 617 +++++++++++++++--- .../src/supervisor/target-store.test.ts | 145 ++++ .../server/src/supervisor/target-store.ts | 168 +++++ packages/web/src/app/providers.test.tsx | 2 + .../actions/use-supervisor-actions.ts | 22 +- .../components/objective-dialog.test.tsx | 2 + .../components/supervisor-card.test.tsx | 108 +++ .../mobile/mobile-supervisor-sheet.test.tsx | 2 + .../views/mobile/mobile-supervisor-sheet.tsx | 8 +- .../views/shared/supervisor-card.tsx | 159 ++++- packages/web/src/locales/en.json | 30 +- packages/web/src/locales/zh.json | 30 +- packages/web/src/styles/components.css | 119 ++++ .../src/ui-preview/scenes/showcase-scenes.tsx | 27 +- 25 files changed, 2041 insertions(+), 295 deletions(-) create mode 100644 packages/server/src/supervisor/target-store.test.ts create mode 100644 packages/server/src/supervisor/target-store.ts diff --git a/packages/core/src/domain/supervisor.ts b/packages/core/src/domain/supervisor.ts index a3e9cfa48..be43a4b9f 100644 --- a/packages/core/src/domain/supervisor.ts +++ b/packages/core/src/domain/supervisor.ts @@ -25,30 +25,45 @@ export type SupervisorStopReason = | "supervisor_uncertain" | "needs_user_input"; -export type SupervisorPlanStepStatus = "pending" | "in_progress" | "completed"; +export type SupervisorPlanStepStatus = "pending" | "in_progress" | "done"; export interface SupervisorPlanStep { - step: string; + id: string; + title: string; status: SupervisorPlanStepStatus; } export interface SupervisorTargetMemory { - summary: string; + targetId: string; + planGenerated: boolean; plan: SupervisorPlanStep[]; + activeStepId?: string; + progressSummary?: string; + lastGuidance?: string; + stalledCount: number; updatedAt: number; } export interface SupervisorCycleStepUpdate { - step: string; + id: string; status: SupervisorPlanStepStatus; } export interface SupervisorCycleTargetRecord { cycleId: string; targetId: string; - summary?: string; - stepUpdates: SupervisorCycleStepUpdate[]; - createdAt: number; + startedAt: number; + completedAt: number; + result: "continue" | "stop" | "error"; + stopReason?: "objective_complete" | "supervisor_uncertain" | "needs_user_input"; + reason?: string; + guidance?: string; + progressSummary?: string; + activeStepId?: string; + stepUpdates?: SupervisorCycleStepUpdate[]; + injected?: boolean; + attemptCount?: number; + errorReason?: string; } export type SupervisorCycleAttemptStatus = "evaluating" | "completed" | "failed" | "cancelled"; @@ -105,7 +120,7 @@ export interface Supervisor { scheduledAt?: number; stopReason?: SupervisorStopReason; currentTargetMemory?: SupervisorTargetMemory; - recentTargetCycles: SupervisorCycleTargetRecord[]; + recentTargetCycles?: SupervisorCycleTargetRecord[]; cycles: SupervisorCycle[]; lastCycleAt?: number; lastEvaluatedTurnId?: string; diff --git a/packages/server/src/__tests__/supervisor-integration.test.ts b/packages/server/src/__tests__/supervisor-integration.test.ts index 17c1b69c8..2de87f28b 100644 --- a/packages/server/src/__tests__/supervisor-integration.test.ts +++ b/packages/server/src/__tests__/supervisor-integration.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { ServerConfig } from "../config.js"; import { createServer, type Server } from "../server.js"; import type { SupervisorEvaluationContext } from "../supervisor/context-builder.js"; -import type { SupervisorResult } from "../supervisor/evaluator.js"; +import type { SupervisorEvaluationResult } from "../supervisor/evaluator.js"; import type { SupervisorManager } from "../supervisor/manager.js"; import { type CommandContext, dispatch } from "../ws/dispatch.js"; @@ -21,7 +21,7 @@ type MutableSupervisorManager = SupervisorManager & { supervisor: Supervisor, context: SupervisorEvaluationContext, options?: { signal?: AbortSignal } - ) => Promise; + ) => Promise; }; logger: unknown; }; @@ -103,12 +103,20 @@ describe("Supervisor integration", () => { terminalExcerpt: "assistant: built the persistent supervisor repos", evidenceSource: "headless_snapshot", latestUserInput: "run the tests", + targetMemory: { + targetId: "tgt-1", + planGenerated: false, + plan: [], + stalledCount: 0, + updatedAt: 1, + }, }), }; supervisorManager.evaluator = { evaluate: async () => ({ - message: "", - objectiveComplete: false, + status: "continue", + reason: "Keep going", + guidance: "", }), }; }); diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts index 61a5a9da7..2a395241c 100644 --- a/packages/server/src/__tests__/supervisor-manager.test.ts +++ b/packages/server/src/__tests__/supervisor-manager.test.ts @@ -4,6 +4,7 @@ import type { Session, Supervisor, SupervisorCycle, + SupervisorTargetMemory, } from "@coder-studio/core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; @@ -14,7 +15,7 @@ import type { } from "../storage/repositories/supervisor-repo.js"; import type { SupervisorEvaluationContext } from "../supervisor/context-builder.js"; import { SupervisorContextBuilder } from "../supervisor/context-builder.js"; -import { SupervisorEvaluator, type SupervisorResult } from "../supervisor/evaluator.js"; +import { type SupervisorEvaluationResult, SupervisorEvaluator } from "../supervisor/evaluator.js"; import { SupervisorInjector } from "../supervisor/injector.js"; import { SupervisorManager, type SupervisorManagerDeps } from "../supervisor/manager.js"; @@ -84,6 +85,7 @@ function createSessionRecord(sessionId: string, overrides?: Partial): S function applySupervisorPatch(current: Supervisor, patch: SupervisorUpdatePatch): Supervisor { return { ...current, + ...(patch.targetId !== undefined ? { targetId: patch.targetId } : {}), ...(patch.state !== undefined ? { state: patch.state } : {}), ...(patch.objective !== undefined ? { objective: patch.objective } : {}), ...(patch.evaluatorProviderId !== undefined @@ -149,7 +151,17 @@ function createManagerDeps() { }; const codexBuildSupervisorEvalCommand = vi.fn(() => ({ - argv: ["node", "-e", `process.stdout.write(${JSON.stringify("Run the focused parser test.")})`], + argv: [ + "node", + "-e", + `process.stdout.write(${JSON.stringify( + JSON.stringify({ + status: "continue", + reason: "Need more work", + guidance: "Run the focused parser test.", + }) + )})`, + ], cwd: process.cwd(), env: {}, })); @@ -270,6 +282,32 @@ function createManagerDeps() { attemptsByCycle.delete(cycleId); }), }; + const targetStore = { + createTargetFiles: vi.fn(async () => {}), + readTargetMeta: vi.fn(async (_workspacePath: string, targetId: string) => ({ + targetId, + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Ship the fix", + status: "active", + createdAt: 1, + updatedAt: 1, + supersededBy: null, + completedAt: null, + })), + loadTargetMemory: vi.fn(async (_workspacePath: string, targetId: string) => ({ + targetId, + planGenerated: false, + plan: [], + stalledCount: 0, + updatedAt: 1, + })), + saveTargetMeta: vi.fn(async () => {}), + saveTargetMemory: vi.fn(async () => {}), + appendTargetCycleRecord: vi.fn(async () => {}), + markTargetSuperseded: vi.fn(async () => {}), + readTargetCycleRecords: vi.fn(async () => []), + }; return { eventBus: { on: vi.fn(() => () => {}), emit: vi.fn() }, @@ -308,6 +346,7 @@ function createManagerDeps() { supervisorRepo, cycleRepo, cycleAttemptRepo, + targetStore, codexBuildSupervisorEvalCommand, }; } @@ -386,8 +425,9 @@ describe("SupervisorManager cycle triggers", () => { }); vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({ - message: "[objective complete]", - objectiveComplete: true, + status: "stop", + stopReason: "objective_complete", + reason: "[objective complete]", }); const finished = await getManagerInternals().runEvaluation(supervisor.id, "turn_completed"); @@ -398,6 +438,190 @@ describe("SupervisorManager cycle triggers", () => { expect(manager.get(supervisor.id)?.stopReason).toBe("objective_complete"); }); + it("resets runtime stop state and counters when the objective changes", async () => { + const supervisor = await manager.create({ + sessionId: "sess-objective-reset", + workspaceId: "ws-1", + objective: "Finish the migration", + evaluatorProviderId: "codex", + maxSupervisionCount: 1, + }); + + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({ + status: "stop", + stopReason: "objective_complete", + reason: "done", + }); + + await getManagerInternals().runEvaluation(supervisor.id, "turn_completed"); + + const updated = await manager.update(supervisor.id, { + objective: "Start the follow-up migration", + }); + + expect(updated.targetId).not.toBe(supervisor.targetId); + expect(updated.state).toBe("idle"); + expect(updated.stopReason).toBeUndefined(); + expect(updated.completedSupervisionCount).toBe(0); + + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({ + status: "continue", + reason: "keep going", + guidance: "do the next step", + }); + + const nextCycle = await getManagerInternals().runEvaluation(updated.id, "turn_completed"); + expect(nextCycle?.status).toBe("injected"); + }); + + it("keeps in-flight cycle writes attached to the original target after an objective change", async () => { + const supervisor = await manager.create({ + sessionId: "sess-objective-race", + workspaceId: "ws-1", + objective: "Initial objective", + evaluatorProviderId: "codex", + }); + + let resolveEvaluation: ((result: SupervisorEvaluationResult) => void) | null = null; + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveEvaluation = resolve; + }) + ); + + const cycle = await manager.triggerEvaluation(supervisor.id); + + await waitFor(() => { + expect(resolveEvaluation).not.toBeNull(); + expect(manager.get(supervisor.id)?.state).toBe("evaluating"); + }); + + const rotated = await manager.update(supervisor.id, { + objective: "New objective", + }); + + expect(rotated.targetId).not.toBe(supervisor.targetId); + + resolveEvaluation?.({ + status: "continue", + reason: "Keep going on the old target", + guidance: "Run the old-target validation", + progressSummary: "Old target progress", + }); + + await waitFor(() => { + const finished = manager.get(supervisor.id)?.cycles.find((entry) => entry.id === cycle.id); + expect(finished?.status).toBe("completed"); + }); + + expect(deps.targetStore.saveTargetMemory).toHaveBeenCalledWith( + expect.any(String), + supervisor.targetId, + expect.objectContaining({ + targetId: supervisor.targetId, + progressSummary: "Old target progress", + }) + ); + expect(deps.targetStore.appendTargetCycleRecord).toHaveBeenCalledWith( + expect.any(String), + supervisor.targetId, + expect.objectContaining({ + cycleId: cycle.id, + targetId: supervisor.targetId, + }) + ); + expect(deps.targetStore.saveTargetMemory).not.toHaveBeenCalledWith( + expect.any(String), + rotated.targetId, + expect.anything() + ); + expect(manager.get(supervisor.id)?.state).toBe("idle"); + expect(manager.get(supervisor.id)?.completedSupervisionCount).toBe(0); + expect(deps.sessionMgr.sendInput).not.toHaveBeenCalled(); + }); + + it("marks supervisor_uncertain stops as cancelled instead of completed target meta", async () => { + const supervisor = await manager.create({ + sessionId: "sess-uncertain-stop", + workspaceId: "ws-1", + objective: "Investigate the flaky state", + evaluatorProviderId: "codex", + }); + + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({ + status: "stop", + stopReason: "supervisor_uncertain", + reason: "I cannot determine the next step safely", + }); + + await getManagerInternals().runEvaluation(supervisor.id, "turn_completed"); + + expect(deps.targetStore.saveTargetMeta).toHaveBeenCalledWith( + expect.any(String), + supervisor.targetId, + expect.objectContaining({ + status: "cancelled", + }) + ); + expect(deps.targetStore.saveTargetMeta).not.toHaveBeenCalledWith( + expect.any(String), + supervisor.targetId, + expect.objectContaining({ + status: "completed", + }) + ); + }); + + it("does not move the rotated target into error when the previous target cycle fails", async () => { + const supervisor = await manager.create({ + sessionId: "sess-objective-error-race", + workspaceId: "ws-1", + objective: "Initial objective", + evaluatorProviderId: "codex", + }); + + let rejectEvaluation: ((error: unknown) => void) | null = null; + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementationOnce( + async () => + await new Promise((_resolve, reject) => { + rejectEvaluation = reject; + }) + ); + + const cycle = await manager.triggerEvaluation(supervisor.id); + + await waitFor(() => { + expect(rejectEvaluation).not.toBeNull(); + expect(manager.get(supervisor.id)?.state).toBe("evaluating"); + }); + + const rotated = await manager.update(supervisor.id, { + objective: "New objective", + }); + + rejectEvaluation?.(new Error("old target eval failed")); + + await waitFor(() => { + const finished = manager.get(supervisor.id)?.cycles.find((entry) => entry.id === cycle.id); + expect(finished?.status).toBe("failed"); + }); + + expect(manager.get(supervisor.id)?.targetId).toBe(rotated.targetId); + expect(manager.get(supervisor.id)?.state).toBe("idle"); + expect(manager.get(supervisor.id)?.errorReason).toBeUndefined(); + expect(deps.targetStore.appendTargetCycleRecord).toHaveBeenCalledWith( + expect.any(String), + supervisor.targetId, + expect.objectContaining({ + cycleId: cycle.id, + targetId: supervisor.targetId, + result: "error", + errorReason: "old target eval failed", + }) + ); + }); + it("retries evaluator timeout up to the global retry budget", async () => { vi.useFakeTimers(); deps.settingsRepo.get = vi.fn((key: string) => { @@ -426,7 +650,11 @@ describe("SupervisorManager cycle triggers", () => { vi.spyOn(getManagerInternals().evaluator, "evaluate") .mockRejectedValueOnce({ code: "supervisor_eval_timeout", message: "timed out" }) - .mockResolvedValueOnce({ message: "Run tests", objectiveComplete: false }); + .mockResolvedValueOnce({ + status: "continue", + reason: "Run tests", + guidance: "Run tests", + }); const pending = getManagerInternals().runEvaluation(supervisor.id, "turn_completed"); for (let index = 0; index < 20; index += 1) { @@ -480,7 +708,7 @@ describe("SupervisorManager cycle triggers", () => { _context: SupervisorEvaluationContext, options?: { signal?: AbortSignal } ) => - await new Promise((_resolve, reject) => { + await new Promise((_resolve, reject) => { const signal = options?.signal; const abort = () => reject({ @@ -516,7 +744,11 @@ describe("SupervisorManager cycle triggers", () => { expect(manager.get(supervisor.id)?.completedSupervisionCount).toBe(0); await manager.resume(supervisor.id); - evaluate.mockResolvedValueOnce({ message: "Run tests", objectiveComplete: false }); + evaluate.mockResolvedValueOnce({ + status: "continue", + reason: "Run tests", + guidance: "Run tests", + }); const finished = await managerInternals.runEvaluation(supervisor.id, "turn_completed"); @@ -569,8 +801,9 @@ describe("SupervisorManager cycle triggers", () => { createSessionRecord(sessionId, { state: sessionState }) ); vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({ - message: "Run tests", - objectiveComplete: false, + status: "continue", + reason: "Run tests", + guidance: "Run tests", }); const supervisor = await manager.create({ @@ -605,7 +838,9 @@ describe("SupervisorManager cycle triggers", () => { const managerInternals = getManagerInternals(); vi.spyOn(managerInternals.evaluator, "evaluate").mockResolvedValueOnce({ - message: "Run the focused parser test.", + status: "continue", + reason: "Run the focused parser test.", + guidance: "Run the focused parser test.", }); vi.spyOn(managerInternals.injector, "inject").mockResolvedValueOnce({ injected: false, @@ -615,7 +850,7 @@ describe("SupervisorManager cycle triggers", () => { const finished = await managerInternals.runEvaluation(supervisor.id); expect(finished?.status).toBe("completed"); - expect(finished?.result).toBe("Skipped duplicate: [Supervisor] Run the focused parser test."); + expect(finished?.result).toBe("Skipped duplicate: Run the focused parser test."); expect(finished?.injectedGuidance).toBeUndefined(); }); @@ -767,7 +1002,7 @@ describe("SupervisorManager cycle triggers", () => { _context: SupervisorEvaluationContext, options?: { signal?: AbortSignal } ) => - await new Promise((_resolve, reject) => { + await new Promise((_resolve, reject) => { const signal = options?.signal; const abort = () => reject({ @@ -829,7 +1064,7 @@ describe("SupervisorManager cycle triggers", () => { _context: SupervisorEvaluationContext, options?: { signal?: AbortSignal } ) => - await new Promise((_resolve, reject) => { + await new Promise((_resolve, reject) => { const signal = options?.signal; const abort = () => reject({ @@ -890,8 +1125,9 @@ describe("SupervisorManager cycle triggers", () => { }); vi.spyOn(managerInternals.evaluator, "evaluate").mockResolvedValueOnce({ - message: "Run tests", - objectiveComplete: false, + status: "continue", + reason: "Run tests", + guidance: "Run tests", }); const finished = await managerInternals.runEvaluation(supervisor.id, "turn_completed"); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index ca3c1c0a8..24ce84b03 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -32,6 +32,7 @@ import { SupervisorCycleAttemptRepo } from "./storage/repositories/supervisor-cy import { SupervisorCycleRepo } from "./storage/repositories/supervisor-cycle-repo.js"; import { SupervisorRepo } from "./storage/repositories/supervisor-repo.js"; import { SupervisorManager } from "./supervisor/manager.js"; +import * as targetStore from "./supervisor/target-store.js"; import { TerminalManager } from "./terminal/manager.js"; import { NodePtyHost } from "./terminal/pty-host.js"; import type { TerminalDatabase } from "./terminal/types.js"; @@ -184,6 +185,7 @@ export async function createServer( supervisorRepo, cycleRepo, cycleAttemptRepo, + targetStore, logger: app.log, }); await sessionMgr.hydrate(); diff --git a/packages/server/src/supervisor/context-builder.test.ts b/packages/server/src/supervisor/context-builder.test.ts index 7e6312a1a..aca99a0fe 100644 --- a/packages/server/src/supervisor/context-builder.test.ts +++ b/packages/server/src/supervisor/context-builder.test.ts @@ -56,14 +56,26 @@ const baseSupervisor: Supervisor = { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "tgt-1", state: "idle", objective: "Persist supervisors", evaluatorProviderId: "codex", + maxSupervisionCount: 0, + completedSupervisionCount: 0, + recentTargetCycles: [], cycles: [], createdAt: 1, updatedAt: 1, }; +const baseTargetMemory = { + targetId: "tgt-1", + planGenerated: false, + plan: [], + stalledCount: 0, + updatedAt: 1, +} as const; + describe("stripAnsi", () => { it("removes bracketed paste markers", () => { expect(stripAnsi("\x1b[?2004htext\x1b[200~")).toBe("text"); @@ -104,12 +116,15 @@ describe("SupervisorContextBuilder", () => { }, }); - const context = await builder.build(baseSupervisor); + const context = await builder.build(baseSupervisor, baseTargetMemory); expect(context.evidenceSource).toBe("headless_snapshot"); expect(context.terminalExcerpt).toContain("rendered terminal content here"); expect(context.transcriptExcerpt).toBeUndefined(); expect(context.lastTurnId).toBeUndefined(); + expect(context.targetMemory).toEqual(baseTargetMemory); + expect("gitStatusSummary" in context).toBe(false); + expect("gitDiffStat" in context).toBe(false); }); it("returns an empty headless snapshot when no rendered terminal content is available", async () => { @@ -125,7 +140,10 @@ describe("SupervisorContextBuilder", () => { }, }); - const context = await builder.build({ ...baseSupervisor, evaluatorProviderId: "claude" }); + const context = await builder.build( + { ...baseSupervisor, evaluatorProviderId: "claude" }, + baseTargetMemory + ); expect(context.evidenceSource).toBe("headless_snapshot"); expect(context.terminalExcerpt).toBe(""); @@ -143,11 +161,14 @@ describe("SupervisorContextBuilder", () => { }, }); - const context = await builder.build({ - ...baseSupervisor, - objective: "Ship the fix", - evaluatorProviderId: "claude", - }); + const context = await builder.build( + { + ...baseSupervisor, + objective: "Ship the fix", + evaluatorProviderId: "claude", + }, + baseTargetMemory + ); expect(context.latestUserInput).toBe("run the tests"); }); @@ -165,7 +186,10 @@ describe("SupervisorContextBuilder", () => { }, }); - const context = await builder.build({ ...baseSupervisor, evaluatorProviderId: "claude" }); + const context = await builder.build( + { ...baseSupervisor, evaluatorProviderId: "claude" }, + baseTargetMemory + ); expect(context.latestUserInput).toBeUndefined(); }); @@ -194,7 +218,7 @@ describe("SupervisorContextBuilder", () => { logger, }); - await builder.build(baseSupervisor); + await builder.build(baseSupervisor, baseTargetMemory); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/server/src/supervisor/context-builder.ts b/packages/server/src/supervisor/context-builder.ts index 96107ba19..a50c97b87 100644 --- a/packages/server/src/supervisor/context-builder.ts +++ b/packages/server/src/supervisor/context-builder.ts @@ -1,6 +1,10 @@ -import type { ProviderDefinition, SessionState, Supervisor } from "@coder-studio/core"; +import type { + ProviderDefinition, + SessionState, + Supervisor, + SupervisorTargetMemory, +} from "@coder-studio/core"; import type { FastifyBaseLogger } from "fastify"; -import { getGitDiffStatSummary, getGitStatusSummary } from "../git/cli.js"; import type { SessionManager } from "../session/manager.js"; import type { TerminalManager } from "../terminal/manager.js"; import type { WorkspaceManager } from "../workspace/manager.js"; @@ -21,8 +25,6 @@ const NOOP_LOGGER: FastifyBaseLogger = { const TERMINAL_MAX_LINES = 200; const TERMINAL_MAX_CHARS = 12_000; -const GIT_SUMMARY_MAX_CHARS = 4_000; - export interface SupervisorEvaluationContext { objective: string; sessionId: string; @@ -33,12 +35,11 @@ export interface SupervisorEvaluationContext { sessionState: SessionState; transcriptExcerpt?: string; terminalExcerpt?: string; - gitStatusSummary?: string; - gitDiffStat?: string; lastTurnId?: string; evidenceSource: "headless_snapshot" | "transcript" | "terminal_fallback"; /** Latest user input from the current turn (for supervisor context) */ latestUserInput?: string; + targetMemory: SupervisorTargetMemory; } export class SupervisorContextBuilder { @@ -51,16 +52,15 @@ export class SupervisorContextBuilder { terminalMgr: TerminalManager; providerRegistry: ProviderDefinition[]; logger?: FastifyBaseLogger; - git?: { - getStatusSummary?: typeof getGitStatusSummary; - getDiffStatSummary?: typeof getGitDiffStatSummary; - }; } ) { this.logger = deps.logger ?? NOOP_LOGGER; } - async build(supervisor: Supervisor): Promise { + async build( + supervisor: Supervisor, + targetMemory: SupervisorTargetMemory + ): Promise { const session = this.deps.sessionMgr.get(supervisor.sessionId); const workspace = this.deps.workspaceMgr.get(supervisor.workspaceId); @@ -84,28 +84,6 @@ export class SupervisorContextBuilder { ); } - const getStatusSummary = this.deps.git?.getStatusSummary ?? getGitStatusSummary; - const getDiffStatSummary = this.deps.git?.getDiffStatSummary ?? getGitDiffStatSummary; - - const gitStatusSummary = await getStatusSummary(workspace.path) - .then((value) => value.slice(-GIT_SUMMARY_MAX_CHARS)) - .catch((error) => { - this.logger.warn( - { err: error, workspaceId: workspace.id }, - "Supervisor git status read failed" - ); - return ""; - }); - const gitDiffStat = await getDiffStatSummary(workspace.path) - .then((value) => value.slice(-GIT_SUMMARY_MAX_CHARS)) - .catch((error) => { - this.logger.warn( - { err: error, workspaceId: workspace.id }, - "Supervisor git diff read failed" - ); - return ""; - }); - const latestUserInput = this.deps.sessionMgr.getLatestSubmittedUserInput(session.id); this.logger.info( @@ -129,11 +107,10 @@ export class SupervisorContextBuilder { sessionState: session.state, transcriptExcerpt: undefined, terminalExcerpt: renderedSnapshot, - gitStatusSummary, - gitDiffStat, lastTurnId: undefined, evidenceSource: "headless_snapshot", latestUserInput, + targetMemory, }; } } diff --git a/packages/server/src/supervisor/evaluator.test.ts b/packages/server/src/supervisor/evaluator.test.ts index be80dfe63..30bfc2130 100644 --- a/packages/server/src/supervisor/evaluator.test.ts +++ b/packages/server/src/supervisor/evaluator.test.ts @@ -78,14 +78,48 @@ function makeEvaluator( }); } +function continuePayload(overrides?: Partial>): string { + return JSON.stringify({ + status: "continue", + reason: "Need more work", + guidance: "next step: run tests", + ...overrides, + }); +} + +function stopPayload(overrides?: Partial>): string { + return JSON.stringify({ + status: "stop", + stopReason: "objective_complete", + reason: "The target is complete", + ...overrides, + }); +} + +function codexJsonlPayload(text: string): string { + return [ + JSON.stringify({ type: "thread.started", thread_id: "t1" }), + JSON.stringify({ type: "turn.started" }), + JSON.stringify({ + type: "item.completed", + item: { id: "i1", type: "agent_message", text }, + }), + JSON.stringify({ type: "turn.completed", usage: { output_tokens: 20 } }), + ].join("\n"); +} + function makeSupervisor(evaluatorProviderId = "codex"): Supervisor { return { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "tgt-1", state: "idle", objective: "obj", evaluatorProviderId, + maxSupervisionCount: 0, + completedSupervisionCount: 0, + recentTargetCycles: [], cycles: [], createdAt: 1, updatedAt: 1, @@ -104,6 +138,16 @@ function makeContext(): SupervisorEvaluationContext { evidenceSource: "headless_snapshot", terminalExcerpt: "build passes", latestUserInput: "run the tests", + targetMemory: { + targetId: "tgt-1", + planGenerated: true, + plan: [{ id: "step-1", title: "Verify the fix", status: "in_progress" }], + activeStepId: "step-1", + progressSummary: "Verification in progress", + lastGuidance: "Run the focused tests", + stalledCount: 0, + updatedAt: 1, + }, }; } @@ -123,22 +167,21 @@ describe("SupervisorEvaluator", () => { it("uses supervisor.evaluatorProviderId instead of the session provider", async () => { const evaluator = new SupervisorEvaluator({ - providerRegistry: [createProvider("codex", "next step: run tests")], + providerRegistry: [ + createProvider("claude", continuePayload({ guidance: "should not be used" })), + createProvider( + "codex", + codexJsonlPayload(continuePayload({ guidance: "next step: run tests" })) + ), + ], providerConfigRepo: createProviderConfigRepo(), timeoutMs: 5000, }); const result = await evaluator.evaluate( { - id: "sup-1", - sessionId: "sess-1", - workspaceId: "ws-1", - state: "idle", + ...makeSupervisor("codex"), objective: "Finish the evaluator runner", - evaluatorProviderId: "codex", - cycles: [], - createdAt: 1, - updatedAt: 1, }, { objective: "Finish the evaluator runner", @@ -151,14 +194,21 @@ describe("SupervisorEvaluator", () => { evidenceSource: "headless_snapshot", terminalExcerpt: "build passes", latestUserInput: "run the tests", + targetMemory: makeContext().targetMemory, } ); - expect(result.message).toBe("next step: run tests"); + expect(result).toEqual( + expect.objectContaining({ + status: "continue", + reason: "Need more work", + guidance: "next step: run tests", + }) + ); }); it("prefers supervisor.evaluatorModel over provider config model", async () => { - const provider = createProvider("codex", "next step: run tests", { + const provider = createProvider("claude", continuePayload(), { defaultConfig: { model: "gpt-4.1", additionalArgs: [], envVars: {} }, }); const evaluator = new SupervisorEvaluator({ @@ -173,32 +223,80 @@ describe("SupervisorEvaluator", () => { const result = await evaluator.evaluate( { - ...makeSupervisor("codex"), + ...makeSupervisor("claude"), evaluatorModel: "o3", }, makeContext() ); - expect(result.message).toBe("next step: run tests"); + expect(result).toEqual( + expect.objectContaining({ + status: "continue", + guidance: "next step: run tests", + }) + ); expect(provider.buildSupervisorEvalCommand).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ model: "o3" }) ); }); - it("returns an objective-complete result when the evaluator emits the sentinel", async () => { - const evaluator = makeEvaluator("[objective complete]"); + it("builds a bootstrap prompt when planGenerated is false", async () => { + const evaluator = makeEvaluator( + JSON.stringify({ + status: "continue", + reason: "Need a plan first", + guidance: "Break the objective into 3 to 7 steps", + plan: [ + { id: "step-1", title: "Inspect current behavior", status: "in_progress" }, + { id: "step-2", title: "Implement target store", status: "pending" }, + ], + activeStepId: "step-1", + progressSummary: "Initial decomposition complete", + }), + "claude" + ); - await expect(evaluator.evaluate(makeSupervisor("codex"), makeContext())).resolves.toEqual({ - message: "[objective complete]", - objectiveComplete: true, + const result = await evaluator.evaluate(makeSupervisor("claude"), { + ...makeContext(), + targetMemory: { + targetId: "tgt-1", + planGenerated: false, + plan: [], + stalledCount: 0, + updatedAt: 1, + }, + }); + + expect(result.status).toBe("continue"); + expect(result.plan?.map((step) => step.title)).toEqual([ + "Inspect current behavior", + "Implement target store", + ]); + expect(result.guidance).toBe("Break the objective into 3 to 7 steps"); + }); + + it("parses a stop result with stopReason", async () => { + const evaluator = makeEvaluator( + JSON.stringify({ + status: "stop", + stopReason: "objective_complete", + reason: "The target is complete", + }), + "claude" + ); + + await expect(evaluator.evaluate(makeSupervisor("claude"), makeContext())).resolves.toEqual({ + status: "stop", + stopReason: "objective_complete", + reason: "The target is complete", }); }); it("falls back to provider.defaultConfig when evaluator config is missing", async () => { const evaluator = new SupervisorEvaluator({ providerRegistry: [ - createProvider("claude", "proceed with review", { + createProvider("claude", continuePayload({ guidance: "proceed with review" }), { defaultConfig: { model: "claude-sonnet-4-6", additionalArgs: [], envVars: {} }, }), ], @@ -208,15 +306,8 @@ describe("SupervisorEvaluator", () => { const result = await evaluator.evaluate( { - id: "sup-1", - sessionId: "sess-1", - workspaceId: "ws-1", - state: "idle", + ...makeSupervisor("claude"), objective: "Finish the evaluator runner", - evaluatorProviderId: "claude", - cycles: [], - createdAt: 1, - updatedAt: 1, }, { objective: "Finish the evaluator runner", @@ -229,10 +320,16 @@ describe("SupervisorEvaluator", () => { evidenceSource: "headless_snapshot", terminalExcerpt: "build passes", latestUserInput: "run the tests", + targetMemory: makeContext().targetMemory, } ); - expect(result.message).toBe("proceed with review"); + expect(result).toEqual( + expect.objectContaining({ + status: "continue", + guidance: "proceed with review", + }) + ); }); it("uses the shared 600-second default timeout when the setting is missing", () => { @@ -285,14 +382,14 @@ describe("SupervisorEvaluator", () => { get: vi.fn(() => 900), }; const evaluator = new SupervisorEvaluator({ - providerRegistry: [createProvider("codex", "next step: run tests")], + providerRegistry: [createProvider("claude", continuePayload())], providerConfigRepo: createProviderConfigRepo(), settingsRepo: settingsRepo as never, }); - const result = await evaluator.evaluate(makeSupervisor("codex"), makeContext()); + const result = await evaluator.evaluate(makeSupervisor("claude"), makeContext()); - expect(result.message).toBe("next step: run tests"); + expect(result.guidance).toBe("next step: run tests"); expect(settingsRepo.get).toHaveBeenCalledWith("supervisor.evaluationTimeoutSec"); }); @@ -306,14 +403,14 @@ describe("SupervisorEvaluator", () => { ); const evaluator = new SupervisorEvaluator({ - providerRegistry: [createProvider("codex", "next step: run tests")], + providerRegistry: [createProvider("claude", continuePayload())], providerConfigRepo: createProviderConfigRepo(), settingsRepo: new SettingsRepo(db), }); - const result = await evaluator.evaluate(makeSupervisor("codex"), makeContext()); + const result = await evaluator.evaluate(makeSupervisor("claude"), makeContext()); - expect(result.message).toBe("next step: run tests"); + expect(result.guidance).toBe("next step: run tests"); } finally { closeDatabase(db); } @@ -337,16 +434,18 @@ describe("SupervisorEvaluator", () => { ).rejects.toThrow(); const prompt = (logger.warn.mock.calls[0]?.[0] as { prompt?: string } | undefined)?.prompt; - expect(prompt).toContain("You are the supervisor for a business agent terminal session."); - expect(prompt).toContain("generate the next concrete task"); + expect(prompt).toContain("You are supervising a target-scoped software task."); + expect(prompt).toContain("Return JSON only."); expect(prompt).toContain("Current objective:"); expect(prompt).toContain("Ship the fix"); + expect(prompt).toContain("Current target memory:"); + expect(prompt).toContain('"targetId": "tgt-1"'); expect(prompt).toContain("Latest user input:"); expect(prompt).toContain("run the tests"); - expect(prompt).toContain("Latest business agent output:"); + expect(prompt).toContain("Current terminal snapshot:"); expect(prompt).toContain("latest output"); - expect(prompt).toContain("[objective complete]"); - expect(prompt).toContain("Your response must be one of"); + expect(prompt).toContain('"continue"'); + expect(prompt).toContain('"stop"'); }); it("aborts the evaluator process group when the signal is cancelled", async () => { @@ -422,7 +521,11 @@ describe("SupervisorEvaluator", () => { JSON.stringify({ type: "turn.started" }), JSON.stringify({ type: "item.completed", - item: { id: "i1", type: "agent_message", text: "Run pnpm vitest to verify" }, + item: { + id: "i1", + type: "agent_message", + text: continuePayload({ guidance: "Run pnpm vitest to verify" }), + }, }), JSON.stringify({ type: "turn.completed", usage: { output_tokens: 20 } }), ].join("\n"); @@ -430,7 +533,7 @@ describe("SupervisorEvaluator", () => { const evaluator = makeEvaluator(jsonl, "codex"); const result = await evaluator.evaluate(makeSupervisor("codex"), makeContext()); - expect(result.message).toBe("Run pnpm vitest to verify"); + expect(result.guidance).toBe("Run pnpm vitest to verify"); }); it("falls back to reasoning text when agent_message is missing", async () => { @@ -439,7 +542,11 @@ describe("SupervisorEvaluator", () => { JSON.stringify({ type: "turn.started" }), JSON.stringify({ type: "item.completed", - item: { id: "i0", type: "reasoning", text: "Continue with the tests" }, + item: { + id: "i0", + type: "reasoning", + text: continuePayload({ guidance: "Continue with the tests" }), + }, }), JSON.stringify({ type: "turn.completed", usage: { output_tokens: 50 } }), ].join("\n"); @@ -447,7 +554,7 @@ describe("SupervisorEvaluator", () => { const evaluator = makeEvaluator(jsonl, "codex"); const result = await evaluator.evaluate(makeSupervisor("codex"), makeContext()); - expect(result.message).toBe("Continue with the tests"); + expect(result.guidance).toBe("Continue with the tests"); }); it("accepts assistant_message (older codex builds)", async () => { @@ -455,7 +562,11 @@ describe("SupervisorEvaluator", () => { JSON.stringify({ type: "thread.started", thread_id: "t1" }), JSON.stringify({ type: "item.completed", - item: { id: "i0", item_type: "assistant_message", text: "All good" }, + item: { + id: "i0", + item_type: "assistant_message", + text: continuePayload({ guidance: "All good" }), + }, }), JSON.stringify({ type: "turn.completed", usage: { output_tokens: 40 } }), ].join("\n"); @@ -463,11 +574,11 @@ describe("SupervisorEvaluator", () => { const evaluator = makeEvaluator(jsonl, "codex"); const result = await evaluator.evaluate(makeSupervisor("codex"), makeContext()); - expect(result.message).toBe("All good"); + expect(result.guidance).toBe("All good"); }); it("strips markdown code fence from agent_message text", async () => { - const fenced = "```json\nRun the tests\n```"; + const fenced = `\`\`\`json\n${continuePayload({ guidance: "Run the tests" })}\n\`\`\``; const jsonl = [ JSON.stringify({ type: "thread.started", thread_id: "t1" }), JSON.stringify({ type: "turn.started" }), @@ -481,7 +592,7 @@ describe("SupervisorEvaluator", () => { const evaluator = makeEvaluator(jsonl, "codex"); const result = await evaluator.evaluate(makeSupervisor("codex"), makeContext()); - expect(result.message).toBe("Run the tests"); + expect(result.guidance).toBe("Run the tests"); }); it("parses claude --output-format json envelope (result field)", async () => { @@ -490,14 +601,14 @@ describe("SupervisorEvaluator", () => { subtype: "success", is_error: false, duration_ms: 42, - result: "Proceed to the next step", + result: continuePayload({ guidance: "Proceed to the next step" }), session_id: "uuid", }); const evaluator = makeEvaluator(claudeEnvelope, "claude"); const result = await evaluator.evaluate(makeSupervisor("claude"), makeContext()); - expect(result.message).toBe("Proceed to the next step"); + expect(result.guidance).toBe("Proceed to the next step"); }); it("surfaces codex turn.failed error details", async () => { @@ -535,7 +646,11 @@ describe("SupervisorEvaluator", () => { JSON.stringify({ type: "turn.started" }), JSON.stringify({ type: "item.completed", - item: { id: "i1", type: "agent_message", text: longMessage }, + item: { + id: "i1", + type: "agent_message", + text: continuePayload({ guidance: longMessage }), + }, }), JSON.stringify({ type: "turn.completed", usage: {} }), ].join("\n"); @@ -543,7 +658,7 @@ describe("SupervisorEvaluator", () => { const evaluator = makeEvaluator(jsonl, "codex", { guidanceMaxChars: 100 }); const result = await evaluator.evaluate(makeSupervisor(), makeContext()); - expect(result.message).toHaveLength(100); + expect(result.guidance).toHaveLength(100); }); }); }); diff --git a/packages/server/src/supervisor/evaluator.ts b/packages/server/src/supervisor/evaluator.ts index f27b5acf0..f5122c749 100644 --- a/packages/server/src/supervisor/evaluator.ts +++ b/packages/server/src/supervisor/evaluator.ts @@ -4,6 +4,9 @@ import { type ProviderDefinition, type Supervisor, type SupervisorConfig, + type SupervisorCycleStepUpdate, + type SupervisorPlanStep, + type SupervisorStopReason, } from "@coder-studio/core"; import type { FastifyBaseLogger } from "fastify"; import { mergeProviderLaunchConfig } from "../provider-config.js"; @@ -25,15 +28,22 @@ const NOOP_LOGGER: FastifyBaseLogger = { warn: () => {}, }; -/** - * Result of a supervisor evaluation cycle. - * The message is the next instruction to send to the business agent. - */ -export interface SupervisorResult { - message: string; - objectiveComplete: boolean; +export interface SupervisorEvaluationResult { + status: "continue" | "stop"; + stopReason?: Extract< + SupervisorStopReason, + "objective_complete" | "supervisor_uncertain" | "needs_user_input" + >; + reason: string; + guidance?: string; + plan?: SupervisorPlanStep[]; + activeStepId?: string; + progressSummary?: string; + stepUpdates?: SupervisorCycleStepUpdate[]; } +export type SupervisorResult = SupervisorEvaluationResult; + interface EvaluateOptions { signal?: AbortSignal; } @@ -60,7 +70,7 @@ export class SupervisorEvaluator { supervisor: Supervisor, context: SupervisorEvaluationContext, options: EvaluateOptions = {} - ): Promise { + ): Promise { const provider = this.deps.providerRegistry.find( (item) => item.id === supervisor.evaluatorProviderId ); @@ -102,9 +112,9 @@ export class SupervisorEvaluator { options ); - let message: string; + let payloadText: string; try { - message = extractSupervisorMessage(stdout, provider.id); + payloadText = extractSupervisorPayload(stdout, provider.id); } catch (error) { const lines = stdout.trim().split(/\r?\n/).filter(Boolean); debugCodexUnparseableOutput( @@ -119,41 +129,39 @@ export class SupervisorEvaluator { throw error; } - const normalizedMessage = message.slice(0, this.config.guidanceMaxChars); - return { - message: normalizedMessage, - objectiveComplete: normalizedMessage.trim() === "[objective complete]", - }; + return parseSupervisorEvaluationResult(payloadText, this.config.guidanceMaxChars); } } function buildPrompt(context: SupervisorEvaluationContext): string { - const agentOutput = context.transcriptExcerpt ?? context.terminalExcerpt ?? ""; - const userInput = context.latestUserInput?.trim() ?? ""; - const lines: string[] = [ - "You are the supervisor for a business agent terminal session.", - "Your job is to analyze the current objective and the business agent's latest output, then generate the next concrete task for the agent to execute.", - 'If the objective is complete, respond with "[objective complete]".', - "If more work is needed, respond with a clear, actionable instruction for the next step.", + "You are supervising a target-scoped software task.", + "Return JSON only.", + "", + "Allowed statuses:", + '- "continue": more work is needed; include "reason" and "guidance".', + '- "stop": supervision should stop; include "stopReason" and "reason".', + "", + "Allowed stop reasons:", + '- "objective_complete"', + '- "supervisor_uncertain"', + '- "needs_user_input"', + "", + "If planGenerated is false, bootstrap a plan with 3 to 7 milestone-sized steps.", + "If planGenerated is true, update progress incrementally; do not rewrite the full plan unless absolutely necessary.", "", "Current objective:", context.objective, - ]; - - if (userInput) { - lines.push("", "Latest user input:", userInput); - } - - lines.push( "", - "Latest business agent output:", - agentOutput || "(no output yet)", + "Current target memory:", + JSON.stringify(context.targetMemory, null, 2), "", - "Your response must be one of:", - '1. A concrete next task (e.g., "Run the tests to verify the fix", "Review the error in logs/main.log")', - '2. "[objective complete]" if the objective has been fully achieved' - ); + "Latest user input:", + context.latestUserInput?.trim() || "(none)", + "", + "Current terminal snapshot:", + context.terminalExcerpt || "(no output yet)", + ]; return lines.join("\n"); } @@ -436,14 +444,11 @@ function debugCodexUnparseableOutput( } /** - * Extract the supervisor's message from the provider's output. - * The supervisor outputs natural language text (not JSON) that should be - * sent directly to the business agent. - * + * Extract the supervisor's payload text from the provider's output. * For Codex: scans JSONL stream for agent_message/reasoning items. * For Claude: parses the result envelope or plain text. */ -function extractSupervisorMessage(output: string, providerId: string): string { +function extractSupervisorPayload(output: string, providerId: string): string { const trimmed = output.trim(); if (!trimmed) { throw new Error("Supervisor returned empty output"); @@ -452,16 +457,16 @@ function extractSupervisorMessage(output: string, providerId: string): string { const lines = trimmed.split(/\r?\n/).filter(Boolean); if (providerId === "codex") { - if (trimmed === "[objective complete]") { - return trimmed; - } - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { return stripCodeFence(trimmed); } const scan = scanCodexStream(lines); + if (!scan.isCodexStream && (trimmed.startsWith("{") || trimmed.startsWith("["))) { + return stripCodeFence(trimmed); + } + if (scan.turnFailure) { throw new Error(`Supervisor (codex) failed: ${scan.turnFailure}`); } @@ -526,3 +531,106 @@ function extractSupervisorMessage(output: string, providerId: string): string { throw new Error("Supervisor did not return a recognizable message"); } + +function parseSupervisorEvaluationResult( + payloadText: string, + guidanceMaxChars: number +): SupervisorEvaluationResult { + let parsed: unknown; + try { + parsed = JSON.parse(stripCodeFence(payloadText)); + } catch (error) { + throw new Error( + `Supervisor returned invalid JSON: ${error instanceof Error ? error.message : "parse failed"}` + ); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("Supervisor returned invalid evaluation payload"); + } + + const record = parsed as Record; + const status = record.status; + const reason = record.reason; + + if ( + (status !== "continue" && status !== "stop") || + typeof reason !== "string" || + !reason.trim() + ) { + throw new Error("Supervisor returned invalid evaluation payload"); + } + + if (status === "stop") { + const stopReason = record.stopReason; + if ( + stopReason !== "objective_complete" && + stopReason !== "supervisor_uncertain" && + stopReason !== "needs_user_input" + ) { + throw new Error("Supervisor stop result is missing a valid stopReason"); + } + + return { + status, + stopReason, + reason: reason.trim(), + }; + } + + const guidance = + typeof record.guidance === "string" && record.guidance.trim() + ? record.guidance.trim().slice(0, guidanceMaxChars) + : undefined; + + const plan = Array.isArray(record.plan) + ? record.plan.flatMap((value) => { + if (!value || typeof value !== "object") { + return []; + } + const step = value as Record; + if ( + typeof step.id !== "string" || + typeof step.title !== "string" || + (step.status !== "pending" && step.status !== "in_progress" && step.status !== "done") + ) { + return []; + } + return [{ id: step.id, title: step.title, status: step.status }]; + }) + : undefined; + + const stepUpdates = Array.isArray(record.stepUpdates) + ? record.stepUpdates.flatMap((value) => { + if (!value || typeof value !== "object") { + return []; + } + const update = value as Record; + if ( + typeof update.id !== "string" || + (update.status !== "pending" && + update.status !== "in_progress" && + update.status !== "done") + ) { + return []; + } + return [{ id: update.id, status: update.status }]; + }) + : undefined; + + return { + status, + reason: reason.trim(), + guidance, + plan, + activeStepId: + typeof record.activeStepId === "string" && record.activeStepId.trim() + ? record.activeStepId + : undefined, + progressSummary: + typeof record.progressSummary === "string" && record.progressSummary.trim() + ? record.progressSummary.trim() + : undefined, + stepUpdates, + }; +} diff --git a/packages/server/src/supervisor/evaluator.windows.test.ts b/packages/server/src/supervisor/evaluator.windows.test.ts index 4226ef203..c27aaa827 100644 --- a/packages/server/src/supervisor/evaluator.windows.test.ts +++ b/packages/server/src/supervisor/evaluator.windows.test.ts @@ -27,9 +27,13 @@ function makeSupervisor(): Supervisor { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "tgt-1", state: "idle", objective: "obj", evaluatorProviderId: "codex", + maxSupervisionCount: 0, + completedSupervisionCount: 0, + recentTargetCycles: [], cycles: [], createdAt: 1, updatedAt: 1, @@ -48,6 +52,13 @@ function makeContext(): SupervisorEvaluationContext { evidenceSource: "headless_snapshot", terminalExcerpt: "build passes", latestUserInput: "run the tests", + targetMemory: { + targetId: "tgt-1", + planGenerated: true, + plan: [], + stalledCount: 0, + updatedAt: 1, + }, }; } @@ -85,7 +96,15 @@ describe("SupervisorEvaluator windows child-process options", () => { Buffer.from( `${JSON.stringify({ type: "item.completed", - item: { id: "i1", type: "agent_message", text: "Run pnpm vitest to verify" }, + item: { + id: "i1", + type: "agent_message", + text: JSON.stringify({ + status: "continue", + reason: "Need more work", + guidance: "Run pnpm vitest to verify", + }), + }, })}\n${JSON.stringify({ type: "turn.completed", usage: { output_tokens: 20 } })}\n` ) ); @@ -112,8 +131,13 @@ describe("SupervisorEvaluator windows child-process options", () => { }); await expect(evaluator.evaluate(makeSupervisor(), makeContext())).resolves.toEqual({ - message: "Run pnpm vitest to verify", - objectiveComplete: false, + status: "continue", + reason: "Need more work", + guidance: "Run pnpm vitest to verify", + plan: undefined, + activeStepId: undefined, + progressSummary: undefined, + stepUpdates: undefined, }); expect(spawnMock).toHaveBeenCalledWith( diff --git a/packages/server/src/supervisor/index.ts b/packages/server/src/supervisor/index.ts index 109cd92e1..94f56b177 100644 --- a/packages/server/src/supervisor/index.ts +++ b/packages/server/src/supervisor/index.ts @@ -3,7 +3,7 @@ */ export { SupervisorContextBuilder, type SupervisorEvaluationContext } from "./context-builder.js"; -export { SupervisorEvaluator, type SupervisorResult } from "./evaluator.js"; +export { type SupervisorEvaluationResult, SupervisorEvaluator } from "./evaluator.js"; export { SupervisorInjector } from "./injector.js"; export type { CreateSupervisorRequest, diff --git a/packages/server/src/supervisor/manager.test.ts b/packages/server/src/supervisor/manager.test.ts index ccde5f4e4..755c04330 100644 --- a/packages/server/src/supervisor/manager.test.ts +++ b/packages/server/src/supervisor/manager.test.ts @@ -31,6 +31,16 @@ type MockSupervisorManagerDeps = { listForCycle: ReturnType; deleteForCycle: ReturnType; }; + targetStore: { + createTargetFiles: ReturnType; + readTargetMeta: ReturnType; + loadTargetMemory: ReturnType; + saveTargetMeta: ReturnType; + saveTargetMemory: ReturnType; + appendTargetCycleRecord: ReturnType; + markTargetSuperseded: ReturnType; + readTargetCycleRecords: ReturnType; + }; }; function createProvider(): ProviderDefinition { @@ -38,7 +48,17 @@ function createProvider(): ProviderDefinition { id: "claude", capability: "full", buildSupervisorEvalCommand: vi.fn(() => ({ - argv: ["node", "-e", `process.stdout.write(${JSON.stringify("continue with the work")})`], + argv: [ + "node", + "-e", + `process.stdout.write(${JSON.stringify( + JSON.stringify({ + status: "continue", + reason: "Need more work", + guidance: "continue with the work", + }) + )})`, + ], cwd: process.cwd(), env: {}, })), @@ -125,6 +145,32 @@ describe("SupervisorManager", () => { listForCycle: vi.fn(() => []), deleteForCycle: vi.fn(), }, + targetStore: { + createTargetFiles: vi.fn(async () => {}), + readTargetMeta: vi.fn(async () => ({ + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Persist supervisors", + status: "active", + createdAt: 1, + updatedAt: 1, + supersededBy: null, + completedAt: null, + })), + loadTargetMemory: vi.fn(async () => ({ + targetId: "tgt-1", + planGenerated: false, + plan: [], + stalledCount: 0, + updatedAt: 1, + })), + saveTargetMeta: vi.fn(async () => {}), + saveTargetMemory: vi.fn(async () => {}), + appendTargetCycleRecord: vi.fn(async () => {}), + markTargetSuperseded: vi.fn(async () => {}), + readTargetCycleRecords: vi.fn(async () => []), + }, }; }); diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts index eec14873d..5a8a412d6 100644 --- a/packages/server/src/supervisor/manager.ts +++ b/packages/server/src/supervisor/manager.ts @@ -6,7 +6,9 @@ import { type Supervisor, type SupervisorConfig, type SupervisorCycle, + type SupervisorCycleTargetRecord, type SupervisorState, + type SupervisorTargetMemory, Topics, } from "@coder-studio/core"; import type { FastifyBaseLogger } from "fastify"; @@ -30,6 +32,7 @@ import { } from "./injector.js"; import { SupervisorScheduler } from "./scheduler.js"; import { getSupervisorRetrySettings } from "./settings.js"; +import type { SupervisorTargetMeta } from "./target-store.js"; const NOOP_LOGGER: FastifyBaseLogger = { child: () => NOOP_LOGGER, @@ -51,7 +54,9 @@ type SessionLifecycleEvent = Extract */ interface StartedCycle { cycle: SupervisorCycle; + supervisor: Supervisor; context: SupervisorEvaluationContext; + targetId: string; retry: SupervisorRetrySnapshot; trigger: "turn_completed" | "manual" | "scheduled"; } @@ -77,10 +82,6 @@ export interface SupervisorManagerDeps { sessionMgr: SessionManager; providerRegistry: ProviderDefinition[]; providerConfigRepo: ProviderConfigRepo; - git?: { - getStatusSummary?: typeof import("../git/cli.js").getGitStatusSummary; - getDiffStatSummary?: typeof import("../git/cli.js").getGitDiffStatSummary; - }; settingsRepo: Pick; supervisorRepo: SupervisorRepo; cycleRepo: SupervisorCycleRepo; @@ -88,6 +89,16 @@ export interface SupervisorManagerDeps { SupervisorCycleAttemptRepo, "create" | "update" | "listForCycle" | "deleteForCycle" >; + targetStore: { + createTargetFiles: typeof import("./target-store.js").createTargetFiles; + readTargetMeta: typeof import("./target-store.js").readTargetMeta; + loadTargetMemory: typeof import("./target-store.js").loadTargetMemory; + saveTargetMeta: typeof import("./target-store.js").saveTargetMeta; + saveTargetMemory: typeof import("./target-store.js").saveTargetMemory; + appendTargetCycleRecord: typeof import("./target-store.js").appendTargetCycleRecord; + markTargetSuperseded: typeof import("./target-store.js").markTargetSuperseded; + readTargetCycleRecords: typeof import("./target-store.js").readTargetCycleRecords; + }; logger?: FastifyBaseLogger; config?: SupervisorConfig; } @@ -130,6 +141,10 @@ function generateAttemptId(): string { return `attempt_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; } +function generateTargetId(): string { + return `tgt_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + function messageOf(error: unknown, fallback: string): string { if (error instanceof Error) { return error.message; @@ -177,7 +192,6 @@ export class SupervisorManager { terminalMgr: deps.terminalMgr, providerRegistry: deps.providerRegistry, logger: this.logger, - git: deps.git, }); this.evaluator = new SupervisorEvaluator({ providerRegistry: deps.providerRegistry, @@ -218,25 +232,28 @@ export class SupervisorManager { this.supervisorsBySession.clear(); for (const supervisor of this.deps.supervisorRepo.listAll()) { + const hydratedWithTarget = await this.hydrateTargetState(supervisor); const normalizedState = - supervisor.state === "evaluating" || supervisor.state === "injecting" + hydratedWithTarget.state === "evaluating" || hydratedWithTarget.state === "injecting" ? "idle" - : supervisor.state; + : hydratedWithTarget.state; const recovered = - normalizedState === supervisor.state - ? supervisor - : this.deps.supervisorRepo.update(supervisor.id, { - state: normalizedState, - errorReason: null, - updatedAt: Date.now(), - }); + normalizedState === hydratedWithTarget.state + ? hydratedWithTarget + : this.withCurrentTargetState( + this.deps.supervisorRepo.update(hydratedWithTarget.id, { + state: normalizedState, + errorReason: null, + updatedAt: Date.now(), + }) + ); // Any cycle still in a transient state belongs to a previous server // process (or a long-fixed buggy code path). Mark it as failed so it // doesn't sit forever in the UI as "queued"/"evaluating". const stale = this.deps.cycleRepo - .listRecentForSupervisor(supervisor.id, this.config.maxCyclesPerSession) + .listRecentForSupervisor(hydratedWithTarget.id, this.config.maxCyclesPerSession) .filter((cycle) => cycle.status === "queued" || cycle.status === "evaluating"); for (const cycle of stale) { try { @@ -247,7 +264,7 @@ export class SupervisorManager { }); } catch (error) { this.logger.warn( - { err: error, cycleId: cycle.id, supervisorId: supervisor.id }, + { err: error, cycleId: cycle.id, supervisorId: hydratedWithTarget.id }, "Failed to clean up stale cycle on hydrate" ); } @@ -350,13 +367,17 @@ export class SupervisorManager { this.assertEvaluatorProvider(req.evaluatorProviderId); const now = Date.now(); + const objective = req.objective.trim(); + const workspace = this.requireWorkspace(req.workspaceId); + const targetId = generateTargetId(); const supervisor = this.attachCycles( this.deps.supervisorRepo.create({ id: generateSupervisorId(), sessionId: req.sessionId, workspaceId: req.workspaceId, + targetId, state: "idle", - objective: req.objective.trim(), + objective, evaluatorProviderId: req.evaluatorProviderId, evaluatorModel: req.evaluatorModel?.trim() || undefined, maxSupervisionCount: req.maxSupervisionCount ?? 0, @@ -366,11 +387,20 @@ export class SupervisorManager { updatedAt: now, }) ); + await this.deps.targetStore.createTargetFiles(workspace.path, { + targetId, + sessionId: req.sessionId, + workspaceId: req.workspaceId, + objective, + createdAt: now, + }); + + const enriched = await this.attachTargetState(supervisor, workspace.path); - this.storeSnapshot(supervisor); - this.broadcastState(supervisor, "created"); + this.storeSnapshot(enriched); + this.broadcastState(enriched, "created"); this.scheduler.refresh(); - return supervisor; + return enriched; } async update(id: string, patch: UpdateSupervisorRequest): Promise { @@ -380,26 +410,58 @@ export class SupervisorManager { this.assertEvaluatorProvider(patch.evaluatorProviderId); } - const updated = this.attachCycles( - this.deps.supervisorRepo.update(id, { - objective: patch.objective !== undefined ? patch.objective.trim() : current.objective, - evaluatorProviderId: patch.evaluatorProviderId ?? current.evaluatorProviderId, - evaluatorModel: - patch.evaluatorModel === undefined - ? current.evaluatorModel - : patch.evaluatorModel?.trim() || null, - maxSupervisionCount: patch.maxSupervisionCount ?? current.maxSupervisionCount, - scheduledAt: patch.scheduledAt === undefined ? current.scheduledAt : patch.scheduledAt, - state: current.state === "error" ? "idle" : current.state, - errorReason: null, - updatedAt: Date.now(), - }) - ); + const workspace = this.requireWorkspace(current.workspaceId); + const nextObjective = + patch.objective !== undefined ? patch.objective.trim() : current.objective; + const objectiveChanged = patch.objective !== undefined && nextObjective !== current.objective; + const nextPatch: Parameters[1] = { + objective: nextObjective, + evaluatorProviderId: patch.evaluatorProviderId ?? current.evaluatorProviderId, + evaluatorModel: + patch.evaluatorModel === undefined + ? current.evaluatorModel + : patch.evaluatorModel?.trim() || null, + maxSupervisionCount: patch.maxSupervisionCount ?? current.maxSupervisionCount, + scheduledAt: patch.scheduledAt === undefined ? current.scheduledAt : patch.scheduledAt, + state: objectiveChanged + ? current.state === "paused" + ? "paused" + : "idle" + : current.state === "error" + ? "idle" + : current.state, + stopReason: objectiveChanged ? null : current.stopReason, + completedSupervisionCount: objectiveChanged ? 0 : current.completedSupervisionCount, + lastEvaluatedTurnId: objectiveChanged ? null : current.lastEvaluatedTurnId, + errorReason: null, + updatedAt: Date.now(), + }; - this.storeSnapshot(updated); - this.broadcastState(updated, "updated"); + if (objectiveChanged) { + const nextTargetId = generateTargetId(); + await this.deps.targetStore.markTargetSuperseded( + workspace.path, + current.targetId, + nextTargetId, + nextPatch.updatedAt ?? Date.now() + ); + await this.deps.targetStore.createTargetFiles(workspace.path, { + targetId: nextTargetId, + sessionId: current.sessionId, + workspaceId: current.workspaceId, + objective: nextObjective, + createdAt: nextPatch.updatedAt ?? Date.now(), + }); + nextPatch.targetId = nextTargetId; + } + + const updated = this.attachCycles(this.deps.supervisorRepo.update(id, nextPatch)); + const enriched = await this.attachTargetState(updated, workspace.path); + + this.storeSnapshot(enriched); + this.broadcastState(enriched, "updated"); this.scheduler.refresh(); - return updated; + return enriched; } async pause(id: string): Promise { @@ -409,10 +471,12 @@ export class SupervisorManager { } const updated = this.attachCycles( - this.deps.supervisorRepo.update(id, { - state: "paused", - updatedAt: Date.now(), - }) + this.withCurrentTargetState( + this.deps.supervisorRepo.update(id, { + state: "paused", + updatedAt: Date.now(), + }) + ) ); this.storeSnapshot(updated); @@ -423,11 +487,13 @@ export class SupervisorManager { async resume(id: string): Promise { const updated = this.attachCycles( - this.deps.supervisorRepo.update(id, { - state: "idle", - errorReason: null, - updatedAt: Date.now(), - }) + this.withCurrentTargetState( + this.deps.supervisorRepo.update(id, { + state: "idle", + errorReason: null, + updatedAt: Date.now(), + }) + ) ); this.storeSnapshot(updated); @@ -610,7 +676,13 @@ export class SupervisorManager { try { const retrySettings = getSupervisorRetrySettings(this.deps.settingsRepo); - const context = await this.contextBuilder.build(supervisor); + const workspace = this.requireWorkspace(supervisor.workspaceId); + const hydratedSupervisor = await this.attachTargetState(supervisor, workspace.path); + const targetMemory = hydratedSupervisor.currentTargetMemory; + if (!targetMemory) { + throw new Error(`Missing target memory for supervisor ${supervisor.id}`); + } + const context = await this.contextBuilder.build(hydratedSupervisor, targetMemory); if ( trigger === "turn_completed" && context.lastTurnId && @@ -628,13 +700,15 @@ export class SupervisorManager { supervisor.scheduledAt <= Date.now()); const evaluatingSupervisor = this.attachCycles( - this.deps.supervisorRepo.update(supervisor.id, { - state: "evaluating", - scheduledAt: shouldConsumeScheduledAt ? null : (supervisor.scheduledAt ?? undefined), - stopReason: null, - errorReason: null, - updatedAt: Date.now(), - }) + this.withCurrentTargetState( + this.deps.supervisorRepo.update(supervisor.id, { + state: "evaluating", + scheduledAt: shouldConsumeScheduledAt ? null : (supervisor.scheduledAt ?? undefined), + stopReason: null, + errorReason: null, + updatedAt: Date.now(), + }) + ) ); this.storeSnapshot(evaluatingSupervisor); this.broadcastState(evaluatingSupervisor, "state_changed"); @@ -656,7 +730,9 @@ export class SupervisorManager { return { cycle: activeCycle, + supervisor: hydratedSupervisor, context, + targetId: hydratedSupervisor.targetId, trigger, retry: { retryEnabled: retrySettings.retryEnabled, @@ -682,15 +758,18 @@ export class SupervisorManager { * to 'idle' (or 'error'/'paused'). Always releases `inFlight`. */ private async finishCycle(started: StartedCycle): Promise { - const { cycle: activeCycle, context } = started; + const { cycle: activeCycle, context, targetId } = started; const supervisorId = activeCycle.supervisorId; try { - const supervisorForEval = - this.supervisors.get(supervisorId) ?? this.requireSupervisor(supervisorId); const signal = this.evaluationAbortControllers.get(supervisorId)?.signal; - const evaluation = await this.executeCycleWithRetry(started, supervisorForEval, signal); - const finalized = this.finalizeSuccessfulCycle(activeCycle, context, evaluation); + const evaluation = await this.executeCycleWithRetry(started, signal); + const finalized = await this.finalizeSuccessfulCycle( + activeCycle, + context, + evaluation, + targetId + ); if (this.pendingDeletes.has(supervisorId)) { this.pendingDeletes.delete(supervisorId); @@ -711,12 +790,45 @@ export class SupervisorManager { this.supervisors.get(supervisorId) ?? this.requireSupervisor(supervisorId); if (this.pendingDeletes.has(supervisorId)) { + const workspace = this.deps.workspaceMgr.get(currentSupervisor.workspaceId); + if (workspace) { + await this.writeErrorTargetCycleRecord( + workspace.path, + targetId, + activeCycle, + abortedCycle.errorReason ?? "Supervisor evaluator aborted" + ); + await this.markTargetCancelledIfActive(workspace.path, currentSupervisor); + } this.broadcastCycle(currentSupervisor, abortedCycle, "updated"); this.pendingDeletes.delete(supervisorId); this.deleteNow(currentSupervisor); return abortedCycle; } + if (currentSupervisor.targetId !== targetId) { + const workspace = this.deps.workspaceMgr.get(currentSupervisor.workspaceId); + const enriched = this.attachCycles( + workspace + ? await this.attachTargetState(currentSupervisor, workspace.path) + : currentSupervisor + ); + if (workspace && abortedCycle.status === "failed") { + await this.writeErrorTargetCycleRecord( + workspace.path, + targetId, + activeCycle, + abortedCycle.errorReason ?? "Supervisor evaluator aborted" + ); + } + this.storeSnapshot(enriched); + this.broadcastCycle(enriched, abortedCycle, "updated"); + this.deps.cycleRepo.pruneOldest(supervisorId, this.config.maxCyclesPerSession); + this.scheduler.refresh(); + this.pendingPauses.delete(supervisorId); + return abortedCycle; + } + const latestState = this.supervisors.get(supervisorId)?.state; const nextState: SupervisorState = cancelled || latestState === "paused" ? "paused" : "idle"; @@ -730,6 +842,15 @@ export class SupervisorManager { ); this.storeSnapshot(recoveredSupervisor); + const workspace = this.deps.workspaceMgr.get(recoveredSupervisor.workspaceId); + if (workspace && abortedCycle.status === "failed") { + await this.writeErrorTargetCycleRecord( + workspace.path, + targetId, + activeCycle, + abortedCycle.errorReason ?? "Supervisor evaluator aborted" + ); + } this.broadcastCycle(recoveredSupervisor, abortedCycle, "updated"); this.broadcastState(recoveredSupervisor, "state_changed"); this.deps.cycleRepo.pruneOldest(supervisorId, this.config.maxCyclesPerSession); @@ -751,6 +872,34 @@ export class SupervisorManager { errorReason: reason, completedAt: Date.now(), }); + const currentSupervisor = + this.supervisors.get(supervisorId) ?? this.requireSupervisor(supervisorId); + const workspace = this.deps.workspaceMgr.get(currentSupervisor.workspaceId); + if (currentSupervisor.targetId !== targetId) { + const enriched = this.attachCycles( + workspace + ? await this.attachTargetState(currentSupervisor, workspace.path) + : currentSupervisor + ); + if (workspace) { + await this.writeErrorTargetCycleRecord( + workspace.path, + targetId, + activeCycle, + failedCycle.errorReason ?? reason + ); + } + this.storeSnapshot(enriched); + this.broadcastCycle(enriched, failedCycle, "updated"); + + if (this.pendingDeletes.has(supervisorId)) { + this.pendingDeletes.delete(supervisorId); + this.deleteNow(enriched); + } + + throw error; + } + const failedSupervisor = this.attachCycles( this.deps.supervisorRepo.update(supervisorId, { state: "error", @@ -761,6 +910,14 @@ export class SupervisorManager { ); this.storeSnapshot(failedSupervisor); + if (workspace) { + await this.writeErrorTargetCycleRecord( + workspace.path, + targetId, + activeCycle, + failedCycle.errorReason ?? reason + ); + } this.broadcastCycle(failedSupervisor, failedCycle, "updated"); this.broadcastState(failedSupervisor, "state_changed"); @@ -778,14 +935,14 @@ export class SupervisorManager { private async executeCycleWithRetry( started: StartedCycle, - supervisor: Supervisor, signal?: AbortSignal ): Promise<{ - objectiveComplete: boolean; + evaluation: Awaited>; injected: boolean; injectedText?: string; - cycleResult?: string; }> { + const supervisor = started.supervisor; + for (let attemptIndex = 0; ; attemptIndex += 1) { const attempt = this.deps.cycleAttemptRepo.create({ id: generateAttemptId(), @@ -803,17 +960,16 @@ export class SupervisorManager { providerModel: supervisor.evaluatorModel ?? null, }); - if (evaluation.objectiveComplete) { + if (evaluation.status === "stop") { return { - objectiveComplete: true, + evaluation, injected: false, - cycleResult: evaluation.message, }; } - if (!evaluation.message.trim()) { + if (!evaluation.guidance?.trim()) { return { - objectiveComplete: false, + evaluation, injected: false, }; } @@ -822,11 +978,22 @@ export class SupervisorManager { throw { code: "supervisor_eval_aborted", message: "Supervisor evaluator aborted" }; } + const currentSupervisor = + this.supervisors.get(supervisor.id) ?? this.requireSupervisor(supervisor.id); + if (currentSupervisor.targetId !== started.targetId) { + return { + evaluation, + injected: false, + }; + } + const injectingSupervisor = this.attachCycles( - this.deps.supervisorRepo.update(supervisor.id, { - state: "injecting", - updatedAt: Date.now(), - }) + this.withCurrentTargetState( + this.deps.supervisorRepo.update(supervisor.id, { + state: "injecting", + updatedAt: Date.now(), + }) + ) ); this.storeSnapshot(injectingSupervisor); this.broadcastState(injectingSupervisor, "state_changed"); @@ -838,17 +1005,16 @@ export class SupervisorManager { const injection = await this.injector.inject( injectingSupervisor, { - message: evaluation.message, + message: evaluation.guidance, }, recentCycles, { signal } ); return { - objectiveComplete: false, + evaluation, injected: injection.injected, injectedText: injection.injected ? injection.text : undefined, - cycleResult: injection.injected ? injection.text : `Skipped duplicate: ${injection.text}`, }; } catch (error) { if (isSupervisorEvalAborted(error)) { @@ -876,10 +1042,12 @@ export class SupervisorManager { await this.sleep(started.retry.retryDelayMs, signal); const evaluatingSupervisor = this.attachCycles( - this.deps.supervisorRepo.update(supervisor.id, { - state: "evaluating", - updatedAt: Date.now(), - }) + this.withCurrentTargetState( + this.deps.supervisorRepo.update(supervisor.id, { + state: "evaluating", + updatedAt: Date.now(), + }) + ) ); this.storeSnapshot(evaluatingSupervisor); this.broadcastState(evaluatingSupervisor, "state_changed"); @@ -887,50 +1055,123 @@ export class SupervisorManager { } } - private finalizeSuccessfulCycle( + private async finalizeSuccessfulCycle( activeCycle: SupervisorCycle, context: SupervisorEvaluationContext, result: { - objectiveComplete: boolean; + evaluation: Awaited>; injected: boolean; injectedText?: string; - cycleResult?: string; - } - ): { cycle: SupervisorCycle; supervisor: Supervisor } { - const finalStatus: CycleStatus = result.injected - ? "injected" - : result.objectiveComplete - ? "completed" - : "completed"; + }, + targetId: string + ): Promise<{ cycle: SupervisorCycle; supervisor: Supervisor }> { + const workspace = this.requireWorkspace(context.workspaceId); + const currentSupervisor = + this.supervisors.get(activeCycle.supervisorId) ?? + this.requireSupervisor(activeCycle.supervisorId); + const targetMemory = + targetId === currentSupervisor.targetId && currentSupervisor.currentTargetMemory + ? currentSupervisor.currentTargetMemory + : await this.deps.targetStore.loadTargetMemory(workspace.path, targetId); + const finalStatus: CycleStatus = result.injected ? "injected" : "completed"; + const cycleReason = + result.evaluation.status === "stop" + ? result.evaluation.reason + : result.injected + ? result.injectedText + : result.evaluation.guidance + ? `Skipped duplicate: ${result.evaluation.guidance}` + : undefined; const finishedCycle = this.deps.cycleRepo.update(activeCycle.id, { status: finalStatus, - result: result.cycleResult ?? null, + result: cycleReason ?? null, injectedGuidance: result.injectedText ?? null, errorReason: null, completedAt: Date.now(), }); + const nextTargetMemory = this.applyEvaluationToTargetMemory( + targetMemory, + result.evaluation, + result.injectedText, + finishedCycle.completedAt ?? Date.now() + ); + await this.deps.targetStore.saveTargetMemory(workspace.path, targetId, nextTargetMemory); + + const cycleRecord: SupervisorCycleTargetRecord = + result.evaluation.status === "stop" + ? { + cycleId: activeCycle.id, + targetId, + startedAt: activeCycle.createdAt, + completedAt: finishedCycle.completedAt ?? Date.now(), + result: "stop", + stopReason: result.evaluation.stopReason, + reason: result.evaluation.reason, + progressSummary: result.evaluation.progressSummary ?? nextTargetMemory.progressSummary, + activeStepId: result.evaluation.activeStepId ?? nextTargetMemory.activeStepId, + stepUpdates: result.evaluation.stepUpdates, + injected: false, + attemptCount: this.deps.cycleAttemptRepo.listForCycle(activeCycle.id).length, + } + : { + cycleId: activeCycle.id, + targetId, + startedAt: activeCycle.createdAt, + completedAt: finishedCycle.completedAt ?? Date.now(), + result: "continue", + reason: result.evaluation.reason, + guidance: result.injected ? result.injectedText : result.evaluation.guidance, + progressSummary: nextTargetMemory.progressSummary, + activeStepId: nextTargetMemory.activeStepId, + stepUpdates: result.evaluation.stepUpdates, + injected: result.injected, + attemptCount: this.deps.cycleAttemptRepo.listForCycle(activeCycle.id).length, + }; + await this.deps.targetStore.appendTargetCycleRecord(workspace.path, targetId, cycleRecord); + + if (result.evaluation.status === "stop") { + await this.updateTargetMetaStatus(workspace.path, targetId, { + status: result.evaluation.stopReason === "objective_complete" ? "completed" : "cancelled", + completedAt: finishedCycle.completedAt ?? Date.now(), + }); + } + + if (currentSupervisor.targetId !== targetId) { + const enriched = this.attachCycles( + await this.attachTargetState(currentSupervisor, workspace.path) + ); + this.storeSnapshot(enriched); + this.broadcastCycle(enriched, finishedCycle, "updated"); + this.deps.cycleRepo.pruneOldest(activeCycle.supervisorId, this.config.maxCyclesPerSession); + this.scheduler.refresh(); + return { cycle: finishedCycle, supervisor: enriched }; + } + const finishedSupervisor = this.attachCycles( - this.deps.supervisorRepo.update(activeCycle.supervisorId, { - state: result.objectiveComplete ? "stopped" : "idle", - completedSupervisionCount: - (this.supervisors.get(activeCycle.supervisorId)?.completedSupervisionCount ?? 0) + 1, - stopReason: result.objectiveComplete ? "objective_complete" : null, - lastCycleAt: finishedCycle.completedAt, - lastEvaluatedTurnId: context.lastTurnId ?? undefined, - errorReason: null, - updatedAt: Date.now(), - }) + this.withCurrentTargetState( + this.deps.supervisorRepo.update(activeCycle.supervisorId, { + state: result.evaluation.status === "stop" ? "stopped" : "idle", + completedSupervisionCount: + (this.supervisors.get(activeCycle.supervisorId)?.completedSupervisionCount ?? 0) + 1, + stopReason: result.evaluation.status === "stop" ? result.evaluation.stopReason : null, + lastCycleAt: finishedCycle.completedAt, + lastEvaluatedTurnId: context.lastTurnId ?? undefined, + errorReason: null, + updatedAt: Date.now(), + }) + ) ); + const enriched = await this.attachTargetState(finishedSupervisor, workspace.path); - this.storeSnapshot(finishedSupervisor); - this.broadcastCycle(finishedSupervisor, finishedCycle, "updated"); - this.broadcastState(finishedSupervisor, "state_changed"); + this.storeSnapshot(enriched); + this.broadcastCycle(enriched, finishedCycle, "updated"); + this.broadcastState(enriched, "state_changed"); this.deps.cycleRepo.pruneOldest(activeCycle.supervisorId, this.config.maxCyclesPerSession); this.scheduler.refresh(); - return { cycle: finishedCycle, supervisor: finishedSupervisor }; + return { cycle: finishedCycle, supervisor: enriched }; } /** @@ -979,6 +1220,165 @@ export class SupervisorManager { return provider.capability === "full"; } + private requireWorkspace(workspaceId: string): { id: string; path: string } { + const workspace = this.deps.workspaceMgr.get(workspaceId); + if (!workspace) { + throw { + code: "supervisor_not_found", + message: `Workspace ${workspaceId} not found`, + }; + } + return workspace; + } + + private async createLegacyTargetFilesIfMissing( + workspacePath: string, + supervisor: Supervisor + ): Promise { + try { + await this.deps.targetStore.readTargetMeta(workspacePath, supervisor.targetId); + } catch (error) { + if ( + !error || + typeof error !== "object" || + !("code" in error) || + (error as { code?: string }).code !== "ENOENT" + ) { + throw error; + } + await this.deps.targetStore.createTargetFiles(workspacePath, { + targetId: supervisor.targetId, + sessionId: supervisor.sessionId, + workspaceId: supervisor.workspaceId, + objective: supervisor.objective, + createdAt: supervisor.createdAt, + }); + } + } + + private async attachTargetState( + supervisor: Supervisor, + workspacePath: string + ): Promise { + await this.createLegacyTargetFilesIfMissing(workspacePath, supervisor); + const [currentTargetMemory, recentTargetCycles] = await Promise.all([ + this.deps.targetStore.loadTargetMemory(workspacePath, supervisor.targetId), + this.deps.targetStore.readTargetCycleRecords(workspacePath, supervisor.targetId, 20), + ]); + + return { + ...supervisor, + currentTargetMemory, + recentTargetCycles, + }; + } + + private async hydrateTargetState(supervisor: Supervisor): Promise { + const workspace = this.deps.workspaceMgr.get(supervisor.workspaceId); + if (!workspace) { + return supervisor; + } + return await this.attachTargetState(supervisor, workspace.path); + } + + private withCurrentTargetState(supervisor: Supervisor): Supervisor { + const current = this.supervisors.get(supervisor.id); + if (!current || current.targetId !== supervisor.targetId) { + return supervisor; + } + return { + ...supervisor, + currentTargetMemory: current.currentTargetMemory, + recentTargetCycles: current.recentTargetCycles, + }; + } + + private applyEvaluationToTargetMemory( + memory: SupervisorTargetMemory, + evaluation: Awaited>, + injectedText: string | undefined, + updatedAt: number + ): SupervisorTargetMemory { + let plan = memory.plan; + if (evaluation.plan && evaluation.plan.length > 0) { + plan = evaluation.plan; + } else if (evaluation.stepUpdates?.length) { + const updates = new Map(evaluation.stepUpdates.map((step) => [step.id, step.status])); + plan = memory.plan.map((step) => + updates.has(step.id) ? { ...step, status: updates.get(step.id)! } : step + ); + } + + const progressSummary = evaluation.progressSummary ?? memory.progressSummary; + const lastGuidance = + evaluation.status === "continue" + ? (injectedText ?? evaluation.guidance ?? memory.lastGuidance) + : memory.lastGuidance; + const stalledCount = + evaluation.status === "continue" && + !evaluation.progressSummary && + !evaluation.stepUpdates?.length + ? memory.stalledCount + 1 + : 0; + + return { + ...memory, + planGenerated: memory.planGenerated || Boolean(evaluation.plan?.length), + plan, + activeStepId: evaluation.activeStepId ?? memory.activeStepId, + progressSummary, + lastGuidance, + stalledCount, + updatedAt, + }; + } + + private async updateTargetMetaStatus( + workspacePath: string, + targetId: string, + patch: Partial> + ): Promise { + const current = await this.deps.targetStore.readTargetMeta(workspacePath, targetId); + await this.deps.targetStore.saveTargetMeta(workspacePath, targetId, { + ...current, + ...patch, + updatedAt: Date.now(), + }); + } + + private async writeErrorTargetCycleRecord( + workspacePath: string, + targetId: string, + cycle: SupervisorCycle, + errorReason: string + ): Promise { + await this.deps.targetStore.appendTargetCycleRecord(workspacePath, targetId, { + cycleId: cycle.id, + targetId, + startedAt: cycle.createdAt, + completedAt: Date.now(), + result: "error", + errorReason, + attemptCount: this.deps.cycleAttemptRepo.listForCycle(cycle.id).length, + }); + } + + private async markTargetCancelledIfActive( + workspacePath: string, + supervisor: Supervisor + ): Promise { + const meta = await this.deps.targetStore + .readTargetMeta(workspacePath, supervisor.targetId) + .catch(() => null); + if (!meta || meta.status === "completed" || meta.status === "superseded") { + return; + } + await this.updateTargetMetaStatus(workspacePath, supervisor.targetId, { + status: "cancelled", + completedAt: meta.completedAt, + }); + } + private assertEvaluatorProvider(providerId: string): void { const provider = this.deps.providerRegistry.find((item) => item.id === providerId); if (!provider?.buildSupervisorEvalCommand) { @@ -1023,6 +1423,15 @@ export class SupervisorManager { } private deleteNow(supervisor: Supervisor): void { + const workspace = this.deps.workspaceMgr.get(supervisor.workspaceId); + if (workspace) { + void this.markTargetCancelledIfActive(workspace.path, supervisor).catch((error) => { + this.logger.warn( + { err: error, supervisorId: supervisor.id, targetId: supervisor.targetId }, + "Failed to mark target cancelled during supervisor delete" + ); + }); + } this.deps.supervisorRepo.delete(supervisor.id); this.supervisors.delete(supervisor.id); this.supervisorsBySession.delete(supervisor.sessionId); diff --git a/packages/server/src/supervisor/target-store.test.ts b/packages/server/src/supervisor/target-store.test.ts new file mode 100644 index 000000000..b16f4c9bf --- /dev/null +++ b/packages/server/src/supervisor/target-store.test.ts @@ -0,0 +1,145 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + appendTargetCycleRecord, + createTargetFiles, + loadTargetMemory, + markTargetSuperseded, + readTargetCycleRecords, + readTargetMeta, + saveTargetMemory, +} from "./target-store.js"; + +describe("target store", () => { + let workspacePath: string; + + beforeEach(() => { + workspacePath = mkdtempSync(join(tmpdir(), "supervisor-target-store-")); + }); + + afterEach(() => { + rmSync(workspacePath, { recursive: true, force: true }); + }); + + it("creates target metadata with planGenerated=false before first trigger", async () => { + await createTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Ship feature", + createdAt: 1, + }); + + const memory = await loadTargetMemory(workspacePath, "tgt-1"); + + expect(memory).toEqual({ + targetId: "tgt-1", + planGenerated: false, + plan: [], + activeStepId: undefined, + progressSummary: undefined, + lastGuidance: undefined, + stalledCount: 0, + updatedAt: 1, + }); + }); + + it("appends cycle records as newline-delimited json", async () => { + await createTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Ship feature", + createdAt: 1, + }); + + await appendTargetCycleRecord(workspacePath, "tgt-1", { + cycleId: "cycle-1", + targetId: "tgt-1", + startedAt: 1, + completedAt: 2, + result: "continue", + reason: "Need one more implementation step", + guidance: "Implement the store", + injected: true, + attemptCount: 1, + }); + + const lines = await readTargetCycleRecords(workspacePath, "tgt-1"); + expect(lines).toHaveLength(1); + expect(lines[0]?.guidance).toBe("Implement the store"); + }); + + it("marks a target as superseded without mutating the old memory contents", async () => { + await createTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Old objective", + createdAt: 1, + }); + + await saveTargetMemory(workspacePath, "tgt-1", { + targetId: "tgt-1", + planGenerated: true, + plan: [{ id: "step-1", title: "Old step", status: "in_progress" }], + activeStepId: "step-1", + progressSummary: "In progress", + lastGuidance: "Do old thing", + stalledCount: 1, + updatedAt: 2, + }); + + await markTargetSuperseded(workspacePath, "tgt-1", "tgt-2", 3); + + const meta = await readTargetMeta(workspacePath, "tgt-1"); + const memory = await loadTargetMemory(workspacePath, "tgt-1"); + + expect(meta.status).toBe("superseded"); + expect(meta.supersededBy).toBe("tgt-2"); + expect(memory.lastGuidance).toBe("Do old thing"); + }); + + it("does not overwrite existing memory when createTargetFiles is called for an existing target", async () => { + await createTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Old objective", + createdAt: 1, + }); + + await saveTargetMemory(workspacePath, "tgt-1", { + targetId: "tgt-1", + planGenerated: true, + plan: [{ id: "step-1", title: "Keep this", status: "in_progress" }], + activeStepId: "step-1", + progressSummary: "Existing progress", + lastGuidance: "Do not reset", + stalledCount: 2, + updatedAt: 2, + }); + + await createTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "New objective", + createdAt: 3, + }); + + const memory = await loadTargetMemory(workspacePath, "tgt-1"); + expect(memory).toEqual({ + targetId: "tgt-1", + planGenerated: true, + plan: [{ id: "step-1", title: "Keep this", status: "in_progress" }], + activeStepId: "step-1", + progressSummary: "Existing progress", + lastGuidance: "Do not reset", + stalledCount: 2, + updatedAt: 2, + }); + }); +}); diff --git a/packages/server/src/supervisor/target-store.ts b/packages/server/src/supervisor/target-store.ts new file mode 100644 index 000000000..ab886bf7d --- /dev/null +++ b/packages/server/src/supervisor/target-store.ts @@ -0,0 +1,168 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { SupervisorCycleTargetRecord, SupervisorTargetMemory } from "@coder-studio/core"; + +export interface SupervisorTargetMeta { + targetId: string; + sessionId: string; + workspaceId: string; + objective: string; + status: "active" | "completed" | "cancelled" | "superseded"; + createdAt: number; + updatedAt: number; + supersededBy: string | null; + completedAt: number | null; +} + +function targetDir(workspacePath: string, targetId: string): string { + return join(workspacePath, ".coder-studio", "supervisor", "targets", targetId); +} + +function metaPath(workspacePath: string, targetId: string): string { + return join(targetDir(workspacePath, targetId), "meta.json"); +} + +function memoryPath(workspacePath: string, targetId: string): string { + return join(targetDir(workspacePath, targetId), "memory.json"); +} + +function cyclesPath(workspacePath: string, targetId: string): string { + return join(targetDir(workspacePath, targetId), "cycles.jsonl"); +} + +async function writeJsonIfMissing(path: string, value: unknown): Promise { + try { + await writeFile(path, JSON.stringify(value, null, 2) + "\n", { + encoding: "utf-8", + flag: "wx", + }); + } catch (error) { + if ( + !error || + typeof error !== "object" || + !("code" in error) || + (error as { code?: string }).code !== "EEXIST" + ) { + throw error; + } + } +} + +export async function createTargetFiles( + workspacePath: string, + input: { + targetId: string; + sessionId: string; + workspaceId: string; + objective: string; + createdAt: number; + } +): Promise { + const dir = targetDir(workspacePath, input.targetId); + await mkdir(dir, { recursive: true }); + + const meta: SupervisorTargetMeta = { + targetId: input.targetId, + sessionId: input.sessionId, + workspaceId: input.workspaceId, + objective: input.objective, + status: "active", + createdAt: input.createdAt, + updatedAt: input.createdAt, + supersededBy: null, + completedAt: null, + }; + + const memory: SupervisorTargetMemory = { + targetId: input.targetId, + planGenerated: false, + plan: [], + stalledCount: 0, + updatedAt: input.createdAt, + }; + + await writeJsonIfMissing(metaPath(workspacePath, input.targetId), meta); + await writeJsonIfMissing(memoryPath(workspacePath, input.targetId), memory); +} + +export async function readTargetMeta( + workspacePath: string, + targetId: string +): Promise { + return JSON.parse( + await readFile(metaPath(workspacePath, targetId), "utf-8") + ) as SupervisorTargetMeta; +} + +export async function loadTargetMemory( + workspacePath: string, + targetId: string +): Promise { + return JSON.parse( + await readFile(memoryPath(workspacePath, targetId), "utf-8") + ) as SupervisorTargetMemory; +} + +export async function saveTargetMemory( + workspacePath: string, + targetId: string, + memory: SupervisorTargetMemory +): Promise { + await mkdir(dirname(memoryPath(workspacePath, targetId)), { recursive: true }); + await writeFile( + memoryPath(workspacePath, targetId), + JSON.stringify(memory, null, 2) + "\n", + "utf-8" + ); +} + +export async function appendTargetCycleRecord( + workspacePath: string, + targetId: string, + record: SupervisorCycleTargetRecord +): Promise { + await mkdir(dirname(cyclesPath(workspacePath, targetId)), { recursive: true }); + await writeFile(cyclesPath(workspacePath, targetId), JSON.stringify(record) + "\n", { + encoding: "utf-8", + flag: "a", + }); +} + +export async function saveTargetMeta( + workspacePath: string, + targetId: string, + meta: SupervisorTargetMeta +): Promise { + await mkdir(dirname(metaPath(workspacePath, targetId)), { recursive: true }); + await writeFile(metaPath(workspacePath, targetId), JSON.stringify(meta, null, 2) + "\n", "utf-8"); +} + +export async function readTargetCycleRecords( + workspacePath: string, + targetId: string, + limit = 20 +): Promise { + const content = await readFile(cyclesPath(workspacePath, targetId), "utf-8").catch(() => ""); + return content + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line) as SupervisorCycleTargetRecord) + .slice(-limit) + .reverse(); +} + +export async function markTargetSuperseded( + workspacePath: string, + targetId: string, + nextTargetId: string, + updatedAt: number +): Promise { + const meta = await readTargetMeta(workspacePath, targetId); + await saveTargetMeta(workspacePath, targetId, { + ...meta, + status: "superseded", + supersededBy: nextTargetId, + updatedAt, + }); +} diff --git a/packages/web/src/app/providers.test.tsx b/packages/web/src/app/providers.test.tsx index ed1e38f81..b1b036e4f 100644 --- a/packages/web/src/app/providers.test.tsx +++ b/packages/web/src/app/providers.test.tsx @@ -19,11 +19,13 @@ describe("routeEventToAtom", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "tgt-1", state: "idle", objective: "Track progress", evaluatorProviderId: "claude", maxSupervisionCount: 0, completedSupervisionCount: 0, + recentTargetCycles: [], cycles: [], createdAt: 1, updatedAt: 1, diff --git a/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts b/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts index 9c0781604..927e5a9bb 100644 --- a/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts +++ b/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts @@ -14,7 +14,7 @@ const STATE_CLASSES: Record = { injecting: "supervisor-state-injecting", paused: "supervisor-state-paused", error: "supervisor-state-error", - stopped: "supervisor-state-idle", + stopped: "supervisor-state-stopped", }; interface UseSupervisorActionsArgs { @@ -114,6 +114,20 @@ export function useSupervisorActions({ sessionId }: UseSupervisorActionsArgs) { : t("supervisor.cycle.waiting"))) : null; + const targetMemory = supervisor?.currentTargetMemory ?? null; + const targetPlanItems = targetMemory?.plan ?? []; + const recentTargetCycles = supervisor?.recentTargetCycles ?? []; + const planGeneratedLabel = targetMemory + ? targetMemory.planGenerated + ? t("supervisor.target_memory.plan_ready") + : t("supervisor.target_memory.plan_pending") + : null; + const targetProgressLabel = t("supervisor.target_memory.progress_badge"); + const targetCycleResultLabel = + recentTargetCycles[0] != null + ? t(`supervisor.target_memory.cycle_result.${recentTargetCycles[0].result}`) + : null; + const stopReasonLabel = supervisor?.stopReason ? t(`supervisor.stop_reason.${supervisor.stopReason}`) : null; @@ -155,6 +169,12 @@ export function useSupervisorActions({ sessionId }: UseSupervisorActionsArgs) { isBusy: supervisor?.state === "evaluating" || supervisor?.state === "injecting", latestCycle, latestCycleText, + planGeneratedLabel, + recentTargetCycles, + targetCycleResultLabel, + targetMemory, + targetProgressLabel, + targetPlanItems, openDialog, stopReasonLabel, stateClass: supervisor ? STATE_CLASSES[supervisor.state] : STATE_CLASSES.inactive, diff --git a/packages/web/src/features/supervisor/components/objective-dialog.test.tsx b/packages/web/src/features/supervisor/components/objective-dialog.test.tsx index 8146e345f..4b13b62d8 100644 --- a/packages/web/src/features/supervisor/components/objective-dialog.test.tsx +++ b/packages/web/src/features/supervisor/components/objective-dialog.test.tsx @@ -47,11 +47,13 @@ describe("ObjectiveDialog", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "tgt-1", state: "idle" as const, objective: "Finish the server refactor", evaluatorProviderId: "claude", maxSupervisionCount: 0, completedSupervisionCount: 0, + recentTargetCycles: [], cycles: [], createdAt: 1, updatedAt: 1, diff --git a/packages/web/src/features/supervisor/components/supervisor-card.test.tsx b/packages/web/src/features/supervisor/components/supervisor-card.test.tsx index 1cd364614..e1d01d93b 100644 --- a/packages/web/src/features/supervisor/components/supervisor-card.test.tsx +++ b/packages/web/src/features/supervisor/components/supervisor-card.test.tsx @@ -13,11 +13,31 @@ describe("SupervisorCard", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "tgt-1", state: "idle", objective: "Finish the server refactor", evaluatorProviderId: "codex", maxSupervisionCount: 0, completedSupervisionCount: 0, + currentTargetMemory: { + targetId: "tgt-1", + planGenerated: true, + plan: [{ id: "step-1", title: "Verify the refactor", status: "in_progress" }], + activeStepId: "step-1", + progressSummary: "Validation in progress", + stalledCount: 0, + updatedAt: 1, + }, + recentTargetCycles: [ + { + cycleId: "target-cycle-1", + targetId: "tgt-1", + startedAt: 1, + completedAt: 2, + result: "continue", + reason: "Need to finish the validation step.", + }, + ], cycles: [], createdAt: 1, updatedAt: 1, @@ -84,12 +104,76 @@ describe("SupervisorCard", () => { ); expect(screen.getByText("Persistence and hydration are done.")).toBeInTheDocument(); + expect(screen.getByText("Target memory")).toBeInTheDocument(); + expect(screen.queryByText("Verify the refactor")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /expand/i })).toHaveAttribute( + "aria-expanded", + "false" + ); + fireEvent.click(screen.getByRole("button", { name: /expand/i })); + expect(screen.getByRole("button", { name: /collapse/i })).toHaveAttribute( + "aria-expanded", + "true" + ); + expect(screen.getByText("tgt-1")).toBeInTheDocument(); + expect(screen.getByText("Plan ready")).toBeInTheDocument(); + expect(screen.getByText("Validation in progress")).toBeInTheDocument(); + expect(screen.getByText("Verify the refactor")).toBeInTheDocument(); + expect(screen.getByText("Need to finish the validation step.")).toBeInTheDocument(); expect(screen.queryByText("65%")).not.toBeInTheDocument(); expect(document.querySelector(".supervisor-progress-track")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Trigger Evaluation" })); expect(sendCommand).toHaveBeenCalledWith("supervisor.trigger", { id: "sup-1" }, undefined); }); + it("localizes target memory details and stop reasons in Chinese", () => { + const store = createStore(); + window.localStorage.setItem("ui.locale", JSON.stringify("zh")); + store.set(localeAtom, "zh"); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + supervisorsAtom, + new Map([ + [ + "sess-1", + { + ...createSupervisor(), + state: "stopped", + stopReason: "supervisor_uncertain", + recentTargetCycles: [ + { + cycleId: "target-cycle-1", + targetId: "tgt-1", + startedAt: 1, + completedAt: 2, + result: "stop", + reason: "需要先确认下一步。", + }, + ], + }, + ], + ]) + ); + store.set(supervisorCyclesAtom, new Map()); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /展开/i })); + expect(screen.getByLabelText("目标记忆")).toBeInTheDocument(); + expect(screen.getByText("计划已就绪")).toBeInTheDocument(); + expect(screen.getByLabelText("目标进展")).toBeInTheDocument(); + expect(screen.getByText("进展")).toBeInTheDocument(); + expect(screen.getByLabelText("目标计划")).toBeInTheDocument(); + expect(screen.getByText("进行中")).toBeInTheDocument(); + expect(screen.getByLabelText("目标轮次判断")).toBeInTheDocument(); + expect(screen.getByText("停止")).toBeInTheDocument(); + expect(screen.getByText("Supervisor 暂时无法判断下一步,已停止自动监督。")).toBeInTheDocument(); + }); + it("uses shared IconButton compatibility classes for supervisor icon actions", () => { const store = createStore(); window.localStorage.setItem("ui.locale", JSON.stringify("en")); @@ -234,6 +318,7 @@ describe("SupervisorCard", () => { ); + fireEvent.click(screen.getByRole("button", { name: /expand/i })); expect(screen.getByText("Evaluator Model")).toBeInTheDocument(); expect(screen.getByText("o3")).toBeInTheDocument(); expect(screen.getByText("Max Supervision Count")).toBeInTheDocument(); @@ -256,6 +341,7 @@ describe("SupervisorCard", () => { ); + fireEvent.click(screen.getByRole("button", { name: /expand/i })); expect(screen.getByText("Max Supervision Count")).toBeInTheDocument(); expect(screen.getByText("No cap")).toBeInTheDocument(); }); @@ -300,10 +386,32 @@ describe("SupervisorCard", () => { ); + expect(screen.getByText("Stopped")).toHaveClass("supervisor-state-stopped"); expect(screen.getByText("SCHEDULED")).toBeInTheDocument(); expect(screen.getByText("Cancelled")).toBeInTheDocument(); expect( screen.getByText("Objective complete. Supervisor stopped automatically.") ).toBeInTheDocument(); }); + + it("can render target memory details expanded by default for preview surfaces", () => { + const store = createStore(); + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set(supervisorsAtom, new Map([["sess-1", createSupervisor()]])); + store.set(supervisorCyclesAtom, new Map()); + + render( + + + + ); + + expect(screen.getByRole("button", { name: /collapse/i })).toHaveAttribute( + "aria-expanded", + "true" + ); + expect(screen.getByText("Verify the refactor")).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx b/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx index 758be6b60..f88755aea 100644 --- a/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx +++ b/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx @@ -51,11 +51,13 @@ describe("MobileSupervisorSheet", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", + targetId: "tgt-1", state: "idle" as const, objective: "Reduce mobile regression bugs", evaluatorProviderId: "claude", maxSupervisionCount: 0, completedSupervisionCount: 0, + recentTargetCycles: [], cycles: [], createdAt: 1, updatedAt: 1, diff --git a/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx b/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx index a1e09c9c0..9d548c8b4 100644 --- a/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx +++ b/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx @@ -19,12 +19,14 @@ interface MobileSupervisorSheetProps { sessionId: string; workspaceId: string; onClose: () => void; + defaultSupervisorDetailsOpen?: boolean; } export function MobileSupervisorSheet({ sessionId, workspaceId, onClose, + defaultSupervisorDetailsOpen = false, }: MobileSupervisorSheetProps) { const t = useTranslation(); const [detailMode, setDetailMode] = useState(null); @@ -219,7 +221,11 @@ export function MobileSupervisorSheet({
{supervisor ? ( <> - +
+ + {detailsOpen ? ( +
+ {targetMemory ? ( +
+
+
+ {t("supervisor.target_memory.target")} +
+
{supervisor.targetId}
+
+
+
{t("supervisor.target_memory.plan")}
+
{planGeneratedLabel}
+
+
+
+ {t("supervisor.target_memory.stalled")} +
+
{String(targetMemory.stalledCount)}
+
+
+ ) : null} + + {executionPolicyItems.length > 0 ? ( +
+ {executionPolicyItems.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ) : null} + + {targetMemory?.progressSummary ? ( +
+
+ {targetProgressLabel} + + {targetMemory.progressSummary} + +
+
+ ) : null} + + {targetPlanItems.length > 0 ? ( +
    + {targetPlanItems.slice(0, 3).map((step) => ( +
  1. + + {t(`supervisor.target_memory.step_status.${step.status}`)} + + {step.title} +
  2. + ))} +
+ ) : null} + + {latestTargetCycle?.reason ? ( +
    +
  1. + {targetCycleResultLabel} + {latestTargetCycle.reason} +
  2. +
+ ) : null} +
+ ) : null} +
+ ) : null} + {supervisor.state === "stopped" && stopReasonLabel ? (
{stopReasonLabel} diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 0db0cc644..182fe85e0 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -77,7 +77,9 @@ "search": "Search", "search_files": "Search Files", "search_commands": "Search Commands", - "back": "Back" + "back": "Back", + "expand": "Expand", + "collapse": "Collapse" }, "label": { "workspace": "Workspace", @@ -670,7 +672,31 @@ }, "stop_reason": { "objective_complete": "Objective complete. Supervisor stopped automatically.", - "max_supervision_count_reached": "Reached the configured max supervision count." + "max_supervision_count_reached": "Reached the configured max supervision count.", + "supervisor_uncertain": "Supervisor could not determine the next step and stopped automatic supervision.", + "needs_user_input": "Supervisor needs more user input before automatic supervision can continue." + }, + "target_memory": { + "title": "Target memory", + "target": "Target", + "plan": "Plan", + "stalled": "Stalled", + "plan_ready": "Plan ready", + "plan_pending": "Plan pending", + "progress_title": "Target progress", + "progress_badge": "PROGRESS", + "plan_title": "Target plan", + "reasoning_title": "Target cycle reasoning", + "cycle_result": { + "continue": "CONTINUE", + "stop": "STOP", + "error": "ERROR" + }, + "step_status": { + "pending": "PENDING", + "in_progress": "IN PROGRESS", + "done": "DONE" + } }, "meta": { "title": "Execution policy", diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json index 95d9cb59c..64e6dce66 100644 --- a/packages/web/src/locales/zh.json +++ b/packages/web/src/locales/zh.json @@ -77,7 +77,9 @@ "search": "搜索", "search_files": "搜索文件", "search_commands": "搜索命令", - "back": "返回" + "back": "返回", + "expand": "展开", + "collapse": "收起" }, "label": { "workspace": "工作区", @@ -670,7 +672,31 @@ }, "stop_reason": { "objective_complete": "目标已达成,Supervisor 已自动停止。", - "max_supervision_count_reached": "已达到配置的最大监督次数。" + "max_supervision_count_reached": "已达到配置的最大监督次数。", + "supervisor_uncertain": "Supervisor 暂时无法判断下一步,已停止自动监督。", + "needs_user_input": "Supervisor 需要更多用户输入后才能继续自动监督。" + }, + "target_memory": { + "title": "目标记忆", + "target": "目标", + "plan": "计划", + "stalled": "停滞", + "plan_ready": "计划已就绪", + "plan_pending": "计划待生成", + "progress_title": "目标进展", + "progress_badge": "进展", + "plan_title": "目标计划", + "reasoning_title": "目标轮次判断", + "cycle_result": { + "continue": "继续", + "stop": "停止", + "error": "错误" + }, + "step_status": { + "pending": "待处理", + "in_progress": "进行中", + "done": "已完成" + } }, "meta": { "title": "执行策略", diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index ebbf9b7a2..074ecdc14 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -3099,6 +3099,11 @@ body.is-resizing-panels * { box-shadow: 0 0 0 3px rgba(255, 158, 176, 0.2); } +.supervisor-pulse.supervisor-state-stopped { + background: var(--text-tertiary); + box-shadow: none; +} + .supervisor-pulse.supervisor-state-inactive { background: var(--text-disabled); box-shadow: none; @@ -3160,6 +3165,11 @@ body.is-resizing-panels * { color: var(--accent-pink); } +.supervisor-state-tag.supervisor-state-stopped { + background: rgba(114, 132, 146, 0.2); + color: var(--text-secondary); +} + .supervisor-state-tag.supervisor-state-inactive { background: transparent; color: var(--text-tertiary); @@ -3255,6 +3265,88 @@ body.is-resizing-panels * { text-transform: lowercase; } +.supervisor-details { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.supervisor-details-toggle { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; + min-width: 0; + padding: 0; + background: transparent; + border: none; + color: inherit; + text-align: left; + cursor: pointer; +} + +.supervisor-details-toggle:focus-visible { + outline: 1px solid var(--border-focus); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +.supervisor-details-copy { + display: flex; + align-items: baseline; + gap: var(--sp-2); + min-width: 0; + flex: 1; +} + +.supervisor-details-label { + flex-shrink: 0; + font-size: 10px; + font-weight: var(--font-medium); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.supervisor-details-summary { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: var(--leading-normal); + color: var(--text-secondary); +} + +.supervisor-details-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-tertiary); + transition: transform var(--duration-normal) var(--ease-out); +} + +.supervisor-details-chevron.expanded { + transform: rotate(180deg); +} + +.supervisor-details-toggle-text { + flex-shrink: 0; + font-size: 10px; + line-height: 1.4; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.supervisor-details-panel { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + .supervisor-meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); @@ -3325,6 +3417,33 @@ body.is-resizing-panels * { color: var(--accent-purple); } +.supervisor-history-item[data-trigger="scheduled"] .supervisor-history-trigger { + background: rgba(108, 182, 255, 0.15); + color: var(--accent-blue); +} + +.supervisor-history-item[data-trigger="continue"] .supervisor-history-trigger, +.supervisor-history-item[data-trigger="done"] .supervisor-history-trigger { + background: rgba(120, 215, 178, 0.14); + color: var(--accent-green); +} + +.supervisor-history-item[data-trigger="stop"] .supervisor-history-trigger, +.supervisor-history-item[data-trigger="pending"] .supervisor-history-trigger { + background: rgba(114, 132, 146, 0.18); + color: var(--text-secondary); +} + +.supervisor-history-item[data-trigger="error"] .supervisor-history-trigger { + background: rgba(255, 158, 176, 0.15); + color: var(--accent-pink); +} + +.supervisor-history-item[data-trigger="in_progress"] .supervisor-history-trigger { + background: rgba(241, 184, 106, 0.15); + color: var(--accent-amber); +} + .supervisor-history-result { flex: 1; min-width: 0; diff --git a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx index 4ac2ef79a..6b194f299 100644 --- a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx +++ b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx @@ -23,7 +23,7 @@ import { MobileWorkspaceDrawer } from "../../features/workspace/views/mobile/mob import { BranchQuickPick } from "../../features/workspace/views/shared/branch-quick-pick"; import { WorkspaceLaunchModal } from "../../features/workspace/views/shared/workspace-launch-modal"; import { WorktreeManagerSurface } from "../../features/workspace/views/shared/worktree-manager-surface"; -import type { UiPreviewSceneContext, UiPreviewSceneDefinition } from "../catalog"; +import type { UiPreviewSceneDefinition } from "../catalog"; import { getUiPreviewSceneMetadata } from "../scene-metadata"; const workspace: Workspace = { @@ -47,10 +47,34 @@ const supervisor: Supervisor = { sessionId: "session-preview-1", workspaceId: "ws-preview", state: "idle", + targetId: "target-preview-1", objective: "Review UI regressions before shipping", evaluatorProviderId: "claude", maxSupervisionCount: 0, completedSupervisionCount: 0, + currentTargetMemory: { + targetId: "target-preview-1", + planGenerated: true, + plan: [ + { id: "step-1", title: "Audit compact strip density", status: "done" }, + { id: "step-2", title: "Move memory into expandable detail", status: "in_progress" }, + { id: "step-3", title: "Validate preview coverage across themes", status: "pending" }, + ], + activeStepId: "step-2", + progressSummary: "Compact strip restored; preview coverage still under review.", + stalledCount: 0, + updatedAt: 1, + }, + recentTargetCycles: [ + { + cycleId: "target-cycle-preview-1", + targetId: "target-preview-1", + startedAt: 1, + completedAt: 2, + result: "continue", + reason: "Keep the strip compact and move detailed memory into an explicit disclosure.", + }, + ], cycles: [], createdAt: 1, updatedAt: 1, @@ -470,6 +494,7 @@ export function createShowcaseScenes(): UiPreviewSceneDefinition[] { sessionId="session-preview-1" workspaceId={workspace.id} onClose={() => {}} + defaultSupervisorDetailsOpen /> ), }), From ca28c1105124d19dbb820785c25cedca0be60def Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 12:54:27 +0000 Subject: [PATCH 03/28] docs(changelog): expand v0.3.7 release notes --- packages/cli/CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 5f6e96802..6893b6c4b 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.3.7 + +### Why this patch matters + +`v0.3.7` is a UI and workflow polish release focused on consistency. It makes icon styling truly theme-owned across the workspace, fixes places where icons in the same surface could drift into mismatched colors, improves mobile terminal paste and upload flows, and makes workspace target restore more dependable when you return to an existing workspace. + +In practice, this release means: + +- file tree, mobile workspace dock, settings navigation, and Git footer icons now follow the active theme more consistently +- Git footer status actions stay visually distinguishable instead of collapsing into one generic symbol set +- mobile terminal paste is easier to trigger, with clearer fallback handling and mobile upload actions in the same flow +- reopening a workspace restores the last viewed target more reliably, including safer handling around rapid target switching + +### Included in v0.3.7 + +- add theme-owned semantic icon styling for shared workspace and settings surfaces +- scope icon palettes by UI context so different areas can stay cohesive without losing meaning +- align icon groups across supported themes for the file tree, mobile dock, settings navigation, and Git footer +- improve mobile terminal paste and upload UX for touch-first usage +- harden last-viewed workspace target persistence and restore behavior +- fix Git footer icon differentiation for change counts, remote state, local push state, and refresh actions + ## 0.3.6 ### Patch Changes From 0b1cf7bcc2b0726de462237e051bb4cfde724974 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 13:50:37 +0000 Subject: [PATCH 04/28] fix: harden supervisor target transitions --- .../src/__tests__/supervisor-manager.test.ts | 219 ++++++++++++++++++ packages/server/src/supervisor/manager.ts | 39 +++- 2 files changed, 249 insertions(+), 9 deletions(-) diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts index 2a395241c..09bad507a 100644 --- a/packages/server/src/__tests__/supervisor-manager.test.ts +++ b/packages/server/src/__tests__/supervisor-manager.test.ts @@ -622,6 +622,189 @@ describe("SupervisorManager cycle triggers", () => { ); }); + it("keeps a superseded target meta state when an old in-flight cycle later stops", async () => { + const supervisor = await manager.create({ + sessionId: "sess-objective-stop-race", + workspaceId: "ws-1", + objective: "Initial objective", + evaluatorProviderId: "codex", + }); + + const targetMetaById = new Map>([ + [ + supervisor.targetId, + { + targetId: supervisor.targetId, + sessionId: supervisor.sessionId, + workspaceId: supervisor.workspaceId, + objective: supervisor.objective, + status: "active", + createdAt: 1, + updatedAt: 1, + supersededBy: null, + completedAt: null, + }, + ], + ]); + + deps.targetStore.readTargetMeta.mockImplementation( + async (_workspacePath: string, targetId: string) => { + const meta = targetMetaById.get(targetId); + if (!meta) { + throw new Error(`Missing target meta for ${targetId}`); + } + return { ...meta }; + } + ); + deps.targetStore.createTargetFiles.mockImplementation( + async ( + _workspacePath: string, + input: { + targetId: string; + sessionId: string; + workspaceId: string; + objective: string; + createdAt: number; + } + ) => { + targetMetaById.set(input.targetId, { + targetId: input.targetId, + sessionId: input.sessionId, + workspaceId: input.workspaceId, + objective: input.objective, + status: "active", + createdAt: input.createdAt, + updatedAt: input.createdAt, + supersededBy: null, + completedAt: null, + }); + } + ); + deps.targetStore.markTargetSuperseded.mockImplementation( + async (_workspacePath: string, targetId: string, nextTargetId: string, updatedAt: number) => { + const current = targetMetaById.get(targetId); + if (!current) { + throw new Error(`Missing target meta for ${targetId}`); + } + targetMetaById.set(targetId, { + ...current, + status: "superseded", + supersededBy: nextTargetId, + updatedAt, + }); + } + ); + deps.targetStore.saveTargetMeta.mockImplementation( + async (_workspacePath: string, targetId: string, meta: Record) => { + targetMetaById.set(targetId, { ...meta, targetId }); + } + ); + + let resolveEvaluation: ((result: SupervisorEvaluationResult) => void) | null = null; + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveEvaluation = resolve; + }) + ); + + const cycle = await manager.triggerEvaluation(supervisor.id); + + await waitFor(() => { + expect(resolveEvaluation).not.toBeNull(); + expect(manager.get(supervisor.id)?.state).toBe("evaluating"); + }); + + const rotated = await manager.update(supervisor.id, { + objective: "New objective", + }); + + resolveEvaluation?.({ + status: "stop", + stopReason: "objective_complete", + reason: "old objective is done", + }); + + await waitFor(() => { + const finished = manager.get(supervisor.id)?.cycles.find((entry) => entry.id === cycle.id); + expect(finished?.status).toBe("completed"); + }); + + expect(targetMetaById.get(supervisor.targetId)).toMatchObject({ + status: "superseded", + supersededBy: rotated.targetId, + }); + expect(targetMetaById.get(rotated.targetId)).toMatchObject({ + status: "active", + }); + }); + + it("rolls back a supersede mark when creating the next target files fails", async () => { + const supervisor = await manager.create({ + sessionId: "sess-objective-create-fails", + workspaceId: "ws-1", + objective: "Initial objective", + evaluatorProviderId: "codex", + }); + + let latestMeta: Record = { + targetId: supervisor.targetId, + sessionId: supervisor.sessionId, + workspaceId: supervisor.workspaceId, + objective: supervisor.objective, + status: "active", + createdAt: 1, + updatedAt: 1, + supersededBy: null, + completedAt: null, + }; + + deps.targetStore.readTargetMeta.mockImplementation(async () => ({ ...latestMeta })); + deps.targetStore.markTargetSuperseded.mockImplementation( + async (_workspacePath: string, targetId: string, nextTargetId: string, updatedAt: number) => { + latestMeta = { + ...latestMeta, + targetId, + status: "superseded", + supersededBy: nextTargetId, + updatedAt, + }; + } + ); + deps.targetStore.saveTargetMeta.mockImplementation( + async (_workspacePath: string, targetId: string, meta: Record) => { + latestMeta = { ...meta, targetId }; + } + ); + + const createTargetFilesError = new Error("disk full"); + deps.targetStore.createTargetFiles.mockImplementation(async () => { + throw createTargetFilesError; + }); + + await expect( + manager.update(supervisor.id, { + objective: "New objective", + }) + ).rejects.toThrow("disk full"); + + expect(manager.get(supervisor.id)?.targetId).toBe(supervisor.targetId); + expect(latestMeta).toMatchObject({ + targetId: supervisor.targetId, + status: "active", + supersededBy: null, + }); + expect(deps.targetStore.saveTargetMeta).toHaveBeenCalledWith( + expect.any(String), + supervisor.targetId, + expect.objectContaining({ + targetId: supervisor.targetId, + status: "active", + supersededBy: null, + }) + ); + }); + it("retries evaluator timeout up to the global retry budget", async () => { vi.useFakeTimers(); deps.settingsRepo.get = vi.fn((key: string) => { @@ -1049,6 +1232,42 @@ describe("SupervisorManager cycle triggers", () => { expect(deps.logger.error).not.toHaveBeenCalled(); }); + it("marks deleted active targets as cancelled with a completion timestamp", async () => { + const supervisor = await manager.create({ + sessionId: "sess-delete-completed-at", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "codex", + }); + + const activeMeta = { + targetId: supervisor.targetId, + sessionId: supervisor.sessionId, + workspaceId: supervisor.workspaceId, + objective: supervisor.objective, + status: "active", + createdAt: 1, + updatedAt: 1, + supersededBy: null, + completedAt: null, + }; + deps.targetStore.readTargetMeta.mockResolvedValue(activeMeta); + + await manager.delete(supervisor.id); + + await waitFor(() => { + expect(deps.targetStore.saveTargetMeta).toHaveBeenCalledWith( + expect.any(String), + supervisor.targetId, + expect.objectContaining({ + targetId: supervisor.targetId, + status: "cancelled", + completedAt: expect.any(Number), + }) + ); + }); + }); + it("pauses an in-flight evaluation by cancelling the cycle", async () => { const supervisor = await manager.create({ sessionId: "sess-pause", diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts index 5a8a412d6..2c12ef7ed 100644 --- a/packages/server/src/supervisor/manager.ts +++ b/packages/server/src/supervisor/manager.ts @@ -439,19 +439,32 @@ export class SupervisorManager { if (objectiveChanged) { const nextTargetId = generateTargetId(); + const previousTargetMeta = await this.deps.targetStore.readTargetMeta( + workspace.path, + current.targetId + ); await this.deps.targetStore.markTargetSuperseded( workspace.path, current.targetId, nextTargetId, nextPatch.updatedAt ?? Date.now() ); - await this.deps.targetStore.createTargetFiles(workspace.path, { - targetId: nextTargetId, - sessionId: current.sessionId, - workspaceId: current.workspaceId, - objective: nextObjective, - createdAt: nextPatch.updatedAt ?? Date.now(), - }); + try { + await this.deps.targetStore.createTargetFiles(workspace.path, { + targetId: nextTargetId, + sessionId: current.sessionId, + workspaceId: current.workspaceId, + objective: nextObjective, + createdAt: nextPatch.updatedAt ?? Date.now(), + }); + } catch (error) { + await this.deps.targetStore.saveTargetMeta( + workspace.path, + current.targetId, + previousTargetMeta + ); + throw error; + } nextPatch.targetId = nextTargetId; } @@ -1339,9 +1352,17 @@ export class SupervisorManager { patch: Partial> ): Promise { const current = await this.deps.targetStore.readTargetMeta(workspacePath, targetId); + const nextPatch = + current.status === "superseded" && patch.status && patch.status !== "superseded" + ? { + ...patch, + status: current.status, + supersededBy: current.supersededBy, + } + : patch; await this.deps.targetStore.saveTargetMeta(workspacePath, targetId, { ...current, - ...patch, + ...nextPatch, updatedAt: Date.now(), }); } @@ -1375,7 +1396,7 @@ export class SupervisorManager { } await this.updateTargetMetaStatus(workspacePath, supervisor.targetId, { status: "cancelled", - completedAt: meta.completedAt, + completedAt: meta.completedAt ?? Date.now(), }); } From b3f4dfc7e153a70209a86b009737cf3286c5e6c9 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 14:20:42 +0000 Subject: [PATCH 05/28] docs: add offline recovery vs session gate design --- ...offline-recovery-vs-session-gate-design.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-14-offline-recovery-vs-session-gate-design.md diff --git a/docs/superpowers/specs/2026-05-14-offline-recovery-vs-session-gate-design.md b/docs/superpowers/specs/2026-05-14-offline-recovery-vs-session-gate-design.md new file mode 100644 index 000000000..3c94142d8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-offline-recovery-vs-session-gate-design.md @@ -0,0 +1,259 @@ +# Offline Recovery Vs Session Gate Design + +> Status: Draft +> Date: 2026-05-14 +> Scope: `packages/web/src/hooks/use-activation.ts`, `packages/web/src/hooks/use-bootstrap.ts`, `packages/web/src/app/providers.tsx`, `packages/web/src/shells/shared/connection-status-banner.tsx`, related tests + +## Goal + +Stop routing normal connection recovery failures to `/session-gate`. + +The app should only show `session-gate` when the current tab is explicitly displaced by another active tab. Ordinary websocket disconnects, reconnect backoff, and slow recovery should keep the user on the current workspace and use the existing top connection banner instead. + +## Problem + +The current implementation mixes two different failure classes into the same `activationStatus === "gated"` outcome: + +- true single-active displacement +- ordinary reconnect or activation claim failure + +That creates two product problems: + +- users see a page that says another tab took over even when no other tab exists +- the app abandons the current workspace UI during connection recovery, even though a reconnect banner already exists + +The result is both misleading and unnecessarily disruptive. + +## Decision + +Split activation and connection recovery into separate user-facing states. + +- `session-gate` is reserved for explicit displacement only +- offline recovery stays in place on the current route +- the top connection banner becomes the sole UI for reconnecting, disconnected, and slow-recovery states + +This keeps the meaning of each state narrow: + +- displacement means another tab actually replaced this one +- offline recovery means this tab is still the intended active tab, but transport recovery is in progress or degraded + +## Current Behavior Summary + +Today the frontend navigates to `/session-gate` whenever `activationStatus === "gated"`. + +That `gated` state is currently reached from: + +- `activation.revoked` handling in `AppProviders` +- `wsClient.connect()` failure inside `useActivation.claim()` +- `activation.claim` command failure inside `useActivation.claim()` + +Only the first case is a real displacement signal. The latter two are transport or recovery failures and should not be represented as tab displacement. + +## Proposed State Model + +### Activation + +Activation should model whether this tab still owns the single-active lease. + +Required activation outcomes: + +- `active` + - this tab owns the lease +- `displaced` + - the server explicitly revoked this tab because another tab took over + +`gated` should no longer be used as a catch-all for reconnect and claim failures. + +If the existing atom names are kept for a smaller patch, the implementation may continue storing `gated`, but only for true displacement. In that case, reconnect and claim failures must stop writing that value. + +### Connection Recovery + +Connection recovery should remain fully driven by websocket connection state: + +- `connecting` +- `connected` +- `reconnecting` +- `disconnected` +- `rejected` + +Normal websocket recovery should not mutate activation into a displaced-like state. + +## Routing Rules + +### When To Enter `/session-gate` + +Navigate to `/session-gate` only when the tab is explicitly displaced. + +Accepted triggers: + +- receipt of `activation.revoked` with displacement semantics +- a websocket close reason that unambiguously means displacement, such as `single_active_displaced` + +### When To Stay On The Current Route + +Remain on the current page for: + +- temporary websocket disconnect +- reconnect backoff +- recovery probe failure +- `activation.claim` failure caused by transport or request unavailability +- long-running reconnect attempts + +The current workspace route, settings route, and other in-app routes should stay mounted so the user keeps context while recovery continues. + +## Banner Behavior + +The existing `ConnectionStatusBanner` remains the main recovery surface. + +### Primary Message + +When the app is recovering from a normal disconnect, show a top banner with the primary line: + +- `连接已断开,正在重新连接...` + +This replaces the current split phrasing where `reconnecting` shows one string and `disconnected` shows a harsher terminal message. During active automatic recovery, the banner should communicate progress, not final failure. + +### Slow Recovery Hint + +If recovery remains unresolved for roughly 25 seconds, add a second line beneath the primary message: + +- `连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。` + +This hint should appear only after sustained failure, not immediately on the first reconnect attempt. + +### Reset Behavior + +The slow-recovery timer resets when: + +- connection returns to `connected` +- an explicit displacement occurs and the app transitions to `session-gate` + +### Auto Retry + +Automatic reconnect attempts continue indefinitely under the existing reconnect strategy, subject to the current bounded backoff ceiling. + +The UI should not present a manual retry button in this phase. + +## Session Gate Behavior + +`SessionGatePage` remains a full-page displacement shell. + +Its meaning becomes narrower: + +- this tab was actively displaced by another tab +- the current websocket was intentionally revoked because another holder took control + +This page should no longer be used for: + +- reconnect failure +- offline network conditions +- backend restart during recovery +- temporary request metadata gaps + +## Error Semantics + +### Claim Failure + +If `activation.claim` fails during reconnect, treat it as a recovery error first, not a displacement event, unless the server explicitly reports displacement. + +Expected user-visible behavior: + +- stay on the current route +- keep the connection banner visible +- allow the reconnect loop to continue + +### Explicit Displacement + +If the server explicitly displaces the client: + +- set activation to the displacement state +- clear projected workspace/session state as the app already does +- disconnect the websocket intentionally +- navigate to `/session-gate` + +## Implementation Strategy + +### Option Chosen + +Preferred implementation is a semantic split rather than a route-layer exception. + +That means: + +- remove reconnect and claim failures as causes of activation gating +- keep `/session-gate` tied to explicit displacement signals only +- extend the banner to support a slow-recovery secondary line + +### Rejected Alternative + +A smaller patch could keep the current state model and merely avoid routing to `/session-gate` when `activationReason` is `reconnect_failed` or `claim_failed`. + +That approach is not preferred because: + +- it preserves ambiguous activation semantics +- future contributors can accidentally reintroduce the bug +- the route logic becomes a policy exception table instead of reflecting clear state meaning + +## Testing Requirements + +Add or update tests for the following cases: + +1. reconnect failure does not navigate to `/session-gate` +2. `activation.claim` failure does not navigate to `/session-gate` +3. explicit `activation.revoked` still navigates to `/session-gate` +4. the connection banner appears during reconnecting recovery +5. the slow-recovery secondary hint appears only after the configured duration +6. the slow-recovery hint resets after a successful reconnect + +## Risks + +### Risk: stale route remains visible too long + +Mitigation: + +- keep current projected-state reset behavior for explicit displacement only +- use clear reconnect messaging in the banner during degraded transport states + +### Risk: transport failures that actually mask displacement remain on the workspace briefly + +Mitigation: + +- only explicit server displacement should trigger the displacement shell +- once the server sends a real revoke event, the app still transitions immediately + +### Risk: banner messaging feels indefinite + +Mitigation: + +- add the 25-second slow-recovery hint +- keep retry behavior automatic so the user is not forced into immediate action + +## Non-Goals + +This change does not: + +- redesign the visual style of `session-gate` +- add a manual retry button +- change websocket backoff math beyond existing limits +- introduce a modal offline blocker +- solve unrelated multi-tab fencing behavior + +## Verification + +After implementation, verify: + +1. placing the app idle long enough to trigger websocket recovery no longer routes to `/session-gate` +2. normal reconnect flow keeps the user on `/workspace` +3. a true displacement still shows `session-gate` +4. the banner shows the primary reconnect line immediately +5. the slow-recovery hint appears after the configured threshold and disappears after reconnect + +## Implementation Boundary + +Expected files to change: + +- `packages/web/src/hooks/use-activation.ts` +- `packages/web/src/hooks/use-bootstrap.ts` +- `packages/web/src/app/providers.tsx` +- `packages/web/src/shells/shared/connection-status-banner.tsx` +- relevant desktop/mobile shell tests +- relevant provider lifecycle tests From 59f657369737f0d43bd484c696e1716cbfac57b6 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 14:50:17 +0000 Subject: [PATCH 06/28] docs: add conversion-first activation design --- ...5-14-conversion-first-activation-design.md | 486 ++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-14-conversion-first-activation-design.md diff --git a/docs/superpowers/specs/2026-05-14-conversion-first-activation-design.md b/docs/superpowers/specs/2026-05-14-conversion-first-activation-design.md new file mode 100644 index 000000000..e8c5f91ac --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-conversion-first-activation-design.md @@ -0,0 +1,486 @@ +# Conversion-First Activation Design + +> Status: Draft +> Date: 2026-05-14 +> Scope: `packages/web/src/features/welcome/index.tsx`, `packages/web/src/shells/desktop-shell.tsx`, `packages/web/src/features/workspace/views/shared/workspace-launch-modal.tsx`, `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts`, `packages/web/src/features/agent-panes/actions/use-provider-launcher.ts`, `packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx`, `packages/web/src/features/settings/*`, `packages/web/src/features/supervisor/*`, new `packages/web/src/features/setup/*`, new `packages/web/src/features/mobile-access/*`, new setup/mobile server commands + +## Goal + +Improve product conversion by making first-time activation the primary product path. + +For the next 30 days, the product should optimize for three user outcomes: + +- a new user can complete a first useful AI task without reading docs +- the user can continue the same workspace from a phone with minimal setup +- the user can return later and immediately understand what happened while away + +This design treats conversion as a product-comprehension and activation problem before it treats it as a traffic, pricing, or feature-breadth problem. + +## Problem + +The current product already has meaningful depth: + +- browser-based workspace +- local file access +- Claude and Codex provider support +- mobile workspace shell +- Supervisor for long-running task orchestration + +The main conversion problem is not lack of product capability. The main problem is that the first-run path does not reliably carry users to their first successful outcome. + +Current friction points: + +- the welcome page leads with broad IDE framing instead of the strongest differentiators +- opening a workspace is the primary CTA, even though workspace access alone does not prove product value +- provider setup happens too late in the flow and still feels partly manual +- cross-device continuation is a real capability, but not the default happy path +- mobile access still behaves more like documentation knowledge than productized workflow +- the product has no strong return experience after the first session + +As a result, the likely drop-off occurs between: + +`open product -> understand value -> prepare environment -> start first session -> complete first task -> continue on another device` + +## Decision + +Reframe the product around a conversion-first activation funnel. + +The new primary path should be: + +`Welcome -> Setup Wizard -> Environment Doctor -> Workspace Ready -> Provider Ready -> First Task -> Continue on Phone -> Return Summary` + +This means: + +- the first screen sells the workflow, not the shell +- setup becomes an explicit route, not an implicit cluster of modals and scattered states +- environment and provider readiness are first-class product surfaces +- mobile continuation is promoted from hidden capability to standard next step +- return and resume are productized instead of left to user memory + +## Product Funnel + +The design optimizes this funnel in order: + +1. `Comprehension` + - the user immediately understands what makes the product different +2. `Activation` + - the user can get the environment ready inside the product +3. `First Value` + - the user completes one useful AI task +4. `Differentiation` + - the user continues from a phone or enables a long-running workflow +5. `Retention` + - the user returns and resumes work without starting over + +This ordering is intentional. It is not worth investing in more feature breadth, broader polishing, or deeper growth mechanics until this path is substantially smoother. + +## Experience Principles + +### Activation Before Configuration + +The product should stop assuming users want to manually assemble the environment from settings, docs, and launcher surfaces. + +Instead, the product should guide users to readiness before asking them to make advanced choices. + +### One Primary Next Step + +Every critical screen in the first-run path should have one obvious primary action. + +Avoid branching the first experience into many equal-looking choices. + +### Productized State, Not Tooling State + +Users should see product states such as: + +- ready +- needs fix +- continue on phone +- resume last task + +They should not need to infer meaning from lower-level engineering facts like host binding, missing runtime, or auth mode. + +### Differentiation Must Be Experienced, Not Explained + +The product's strongest advantages are: + +- cross-device continuity +- long-running Supervisor workflows + +These are not convincing enough when they live only in docs or passive feature bullets. The product must lead users into these behaviors directly. + +## Proposed User Journey + +### Step 1: Welcome + +The welcome page should stop acting like a generic empty workspace shell. + +It should answer three questions quickly: + +- what is this +- why is it different +- what should I do first + +Required changes: + +- primary CTA becomes `开始设置` +- manual workspace opening becomes secondary +- message centers on starting on desktop, continuing on phone, and keeping tasks moving with Supervisor +- feature bullets shift from generic tools to workflow outcomes + +The welcome page should also support a returning-user mode later in this design. + +### Step 2: Setup Wizard + +Setup becomes a dedicated route, such as `/setup`, instead of being stitched together from welcome, workspace modal, provider launcher, and settings. + +The wizard should use four steps: + +1. `Choose Goal` +2. `Check Environment` +3. `Fix Issues` +4. `Launch First Task` + +The goal-selection step should stay intentionally narrow. Recommended options: + +- `开始本地编码` +- `从手机继续接力` +- `启动一个长任务` + +The wizard must be interruptible and resumable. A user who leaves and returns should not feel forced to restart from scratch. + +### Step 3: Environment Doctor + +Environment Doctor is the diagnostic and repair engine inside the Setup Wizard. + +It is responsible for converting setup friction into explicit, actionable product states. + +Required checks: + +- Node.js availability and supported version +- Git availability +- provider installed state +- provider authenticated state +- workspace root selected and accessible +- server host exposure mode +- password protection status +- LAN access readiness for mobile continuation + +Each check should expose a consistent state model: + +- `checking` +- `ready` +- `needs_fix` +- `fixing` +- `fixed` +- `failed` + +Each failing item should answer: + +- what is wrong +- why it blocks product use +- what action the user can take now + +Where possible, the product should prefer one-click repair over redirecting users to documentation. + +### Step 4: Workspace Readiness + +Workspace opening should no longer be the product's main CTA, but it remains a prerequisite for first value. + +Required changes: + +- remove hardcoded and credibility-damaging default paths such as `/home/spencer` +- improve root path chips and default directory behavior +- unify error messages so they describe what the user can do next +- allow setup to reuse workspace selection behavior without forcing the user through the legacy modal pattern + +The goal is to make workspace selection feel like a clear step in activation, not a raw filesystem operation. + +### Step 5: Provider Readiness + +Provider setup is currently too late in the journey. + +The new flow should move provider readiness into setup and treat it as part of product activation, not as a detail hidden inside session creation. + +Required behavior: + +- show provider availability before session launch +- offer one-click install when supported +- route the user into authentication when installation succeeds but login is still required +- return the user to the setup flow automatically after provider readiness is achieved + +Provider states should be expressed consistently across setup, session launcher, and settings: + +- not installed +- installed but not authenticated +- ready +- installing +- failed + +### Step 6: First Task Templates + +The product should not ask a new user to invent the first prompt. + +After workspace and provider readiness, the wizard should offer a small set of first-task templates that produce visible value quickly. + +Recommended templates: + +- explain the current project structure +- run tests and summarize failures +- read the codebase and suggest improvements + +This step should terminate only when the user sees a real session and real output, not merely when setup checks have passed. + +### Step 7: Continue On Phone + +After first value, the most important next experience is cross-device continuation. + +The product should explicitly guide the user into it. + +Required behavior: + +- desktop success state exposes `Continue on Phone` +- the user sees a mobile-access assistant instead of needing to consult docs +- the product shows the reachable address, password status, and connection instructions +- the product supports QR-based handoff + +This is where the product's cross-device promise becomes concrete. + +### Step 8: Return Summary + +The first return experience should not drop users back into a generic welcome state. + +When a user comes back after prior activity, the product should summarize: + +- what completed +- what failed +- what changed +- what to do next + +The primary action in this state should be `继续上次任务`. + +This part of the experience is important for retention because it reduces the cognitive cost of reopening the product. + +## Page And Surface Changes + +### Welcome Page + +Primary changes: + +- replace `Open Workspace` as the dominant action +- make `开始设置` the primary CTA +- rewrite feature block copy around workflow value instead of generic IDE features +- support a returning-user variant later in the rollout + +This work is centered in: + +- `packages/web/src/features/welcome/index.tsx` + +### Setup Wizard + +Primary changes: + +- add a dedicated setup route in the desktop shell +- create a new feature area for wizard steps and setup state +- keep the step count small and highly legible + +This work is centered in: + +- `packages/web/src/shells/desktop-shell.tsx` +- new `packages/web/src/features/setup/*` + +### Environment Doctor + +Primary changes: + +- add a frontend doctor surface in the setup feature +- add server commands that summarize setup readiness +- translate engineering checks into user-facing states and repair actions + +This work is centered in: + +- new `packages/web/src/features/setup/doctor/*` +- new setup-oriented server command files + +### Workspace Selection + +Primary changes: + +- remove credibility leaks +- refactor reusable directory-selection behavior +- improve path defaults and failure messages + +This work is centered in: + +- `packages/web/src/features/workspace/views/shared/workspace-launch-modal.tsx` +- `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` + +### Provider Setup + +Primary changes: + +- reuse runtime status and install-job behavior inside setup +- unify provider-state vocabulary across setup, settings, and session launcher + +This work is centered in: + +- `packages/web/src/features/agent-panes/actions/use-provider-launcher.ts` +- `packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx` +- `packages/web/src/features/settings/components/provider-settings.tsx` +- `packages/server/src/commands/provider.ts` + +### Mobile Access Assistant + +Primary changes: + +- create a new product surface for phone continuation +- expose mobile setup in product UI, not only in docs +- make QR handoff a first-class action + +This work is centered in: + +- new `packages/web/src/features/mobile-access/*` +- `packages/web/src/features/settings/*` +- desktop workspace success surfaces + +### Return Summary And Resume + +Primary changes: + +- distinguish first-time welcome from returning-user welcome +- restore recent workspace and session context +- summarize recent progress and next action + +This work is centered in: + +- `packages/web/src/features/welcome/index.tsx` +- `packages/web/src/features/workspace/views/shared/workspace-route-gate.tsx` +- `packages/web/src/features/workspace/actions/use-persist-workspace-last-viewed-target.ts` +- new returning-summary server command or equivalent summary data source for recent workspace, session, and supervisor state + +## Rollout Plan + +### Phase P0: First Successful Task + +Ship first: + +- welcome rewrite +- setup route and step container +- environment doctor +- workspace selection cleanup +- provider readiness inside setup +- first-task templates +- success and failure state rewrite + +This phase exists to reduce the time from opening the product to seeing the first useful result. + +### Phase P1: Cross-Device Continuation + +Ship second: + +- mobile access assistant +- host and password readiness surfaces +- QR-based handoff +- `Continue on Phone` +- provider-state consistency across surfaces + +This phase exists to ensure users experience the product's strongest differentiator, not merely read about it. + +### Phase P2: Return And Resume + +Ship third: + +- return summary +- resume last session +- supervisor quick-start templates +- recent workspace restoration + +This phase exists to reduce the cost of coming back and continuing work. + +## UI Guidance + +The current UI does not require a full visual redesign to improve conversion. + +The highest-ROI UI work is: + +- stronger first-screen hierarchy +- clearer setup and provider states +- clearer success states +- clearer failure states +- clearer mobile continuation call-to-action + +Avoid spending the first 30 days on: + +- global theme redesign +- broad visual experimentation +- non-critical page polish +- decorative motion work disconnected from activation + +The product issue is primarily one of path clarity and state communication, not foundational aesthetic weakness. + +## Risks + +### Risk: the setup flow becomes too heavy + +Mitigation: + +- keep the wizard to four steps +- default to auto-repair where possible +- keep manual advanced configuration behind settings, not in the main flow + +### Risk: adding setup duplicates existing surfaces + +Mitigation: + +- reuse existing workspace and provider logic where possible +- treat setup as orchestration and copy rewrite, not a parallel product + +### Risk: cross-device access feels unsafe + +Mitigation: + +- make password readiness explicit +- make host exposure explicit +- keep safe defaults visible instead of implicit + +### Risk: returning-user logic adds state complexity too early + +Mitigation: + +- defer return summary and resume work until the first-run path is stable +- keep return logic read-oriented before adding deeper automation + +## Non-Goals + +This design does not include: + +- full-site visual redesign +- plugin system expansion +- multi-workspace management overhaul +- cloud sync and cross-device preference sync +- broader editor and Git feature expansion +- growth analytics infrastructure in this phase + +These may become useful later, but they do not address the current conversion bottleneck. + +## Verification + +After implementation, verify these user journeys manually: + +1. a new user can open the product and complete a first useful task in under five minutes without reading documentation +2. after the first task starts or completes, the user can continue from a phone in under one minute on the same network +3. a returning user can understand the previous task state and continue work within roughly ten seconds +4. provider readiness is expressed consistently in setup, settings, and session-launch surfaces +5. no first-run surface exposes credibility-damaging local assumptions such as hardcoded personal paths + +## Implementation Boundary + +This phase should prefer targeted product-path work over broad refactoring. + +Allowed implementation shape: + +- add new setup and mobile-access feature folders +- add narrowly scoped server commands for setup readiness and mobile readiness +- reuse existing provider-install and workspace-open behaviors +- update current welcome, settings, workspace-launch, and supervisor surfaces only where they directly support the conversion path + +This phase should not expand into unrelated platform work while the activation path is still being established. From 4a942aadec4db747b548d8750fa3dd818b8c63a4 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 15:25:08 +0000 Subject: [PATCH 07/28] docs: add agent-first workspace mock --- docs/agent-first-workspace-mock.html | 2033 ++++++++++++++++++++++++++ 1 file changed, 2033 insertions(+) create mode 100644 docs/agent-first-workspace-mock.html diff --git a/docs/agent-first-workspace-mock.html b/docs/agent-first-workspace-mock.html new file mode 100644 index 000000000..a3d602f83 --- /dev/null +++ b/docs/agent-first-workspace-mock.html @@ -0,0 +1,2033 @@ + + + + + +Coder Studio - Agent-First Workspace Mock + + + + + + +
+
+
+
+ + Coder Studio / Desktop workspace concept / agent-first +
+

Agent takes center stage. Code becomes a transient surface.

+

+ This mock keeps the VS Code-grade shell discipline, but shifts visual gravity toward the live agent run. + Files, diff, preview, and editor stay available as a floating right-side drawer instead of occupying the app full-time. +

+
+ +
+ +
+
+
+
CS
+
+
Coder Studio / workspace-shell-redesign
+
ssh://studio-usw2 / branch agent-shell-v3 / 4 collaborators
+
+
+ +
+
+ + Agent runtime healthy + 2 long-running jobs +
+
+ Focus lane + Approvals + diff + artifacts +
+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+
+
Current objective
+

Stabilize the workspace shell for agent-heavy flows

+

+ The shell should feel like a serious long-session workbench: neutral chrome, strong runtime clarity, + and a clear hierarchy where the agent run out-ranks files and settings. +

+
+ RunningCodex / GPT-5.5 + 94magent-shell-v3 + 12 files1 approval gate +
+
+
+ + + +
+
+
+ +
+
+
+
+

Approval requested: write to workspace shell and review surfaces

+

+ The active run needs permission to modify the desktop workspace shell, shared panel tokens, + and the transient right-side code drawer behavior. +

+
+
+
+ + +
+
+ +
+
+

Current step

+

Rebalance shell density and route code review into a transient drawer

+

+ Tighten the desktop chrome, strengthen session scanning, and remove the expectation that an editor must + always occupy the primary viewport. +

+ +
+ +
+

Active tasks

+
+
7
+
Timeline, drawer motion, side density, approval band, runtime strip.
+
+
+ +
+

Changed files

+
+
12
+
Shell layout, shared panel styles, mock session data, motion timing tokens.
+
+
+ +
+

Attention cost

+
+
Low
+
One approval gate remains, but noise is controlled.
+
+
+
+ +
+
+
+ +
+
+
+

Timeline checkpoint

+

Promoted the agent lane to primary stage

+
+ 14:02 UTC +
+

+ The default desktop entry now lands on the live objective, current run, approvals, and checkpoints. + Editor, preview, and diff are available on demand instead of permanently consuming a third of the shell. +

+
+
+ +
+ +
+
+
+

Code review surface

+

Prepared a right-side floating drawer for files, diff, and preview

+
+ Open drawer +
+

+ The drawer inherits the shell theme, floats above the agent stage, and supports quick inspection without + forcing a full context switch. It can be opened from files, artifacts, approvals, or timeline events. +

+
+
+ Diff + Focused stats and changed regions. +
+
+ Editor + Contextual file editing for scoped changes. +
+
+ Preview + Artifact and render inspection without leaving the run. +
+
+
+
+ +
+ +
+
+
+

Approval surface

+

Moved risky actions into a clear but compact top strip

+
+ 1 pending gate +
+

+ Approvals stay impossible to miss, but they no longer drown the shell in warning color. The strip carries + the action, scope summary, and the shortest path to inspect the underlying diff. +

+
+
+ +
+ +
+
+
+

Changed files

+

Shell layout updates ready for review

+
+ 3 focus files +
+

+ The proposed edits keep the current app architecture, but re-tune density, hierarchy, and surface behavior. +

+
+
+

packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx

+ diff preview +
+
// Primary stage stays agent-first.
+return (
+  <WorkspaceShell stage="agent">
+    <AgentTimeline emphasis="primary" />
+    <FloatingSurface mode="drawer" trigger="on-demand" />
+  </WorkspaceShell>
+);
+
+
+
+ +
+ +
+
+
+

Risk note

+

Mint theme still needs stricter chrome restraint

+
+ visual debt +
+

+ Mint Dark carries brand warmth, but the chrome cannot feel decorative. Shared structure must remain consistent, + with theme shifts limited to tint, accent, and depth rather than component geometry or spacing. +

+
+
+ +
+ +
+
+
+

Artifact checkpoint

+

Visual draft ready for shell-first review

+
+ html deliverable +
+

+ This mock demonstrates the desktop shell direction before wiring it into the product. It is intentionally static: + the goal here is to judge hierarchy, surface roles, and brand fit, not production behavior. +

+
+
+
+
+ + + +
+
+

Runtime

+
2 terminals / 1 hidden
+
+
+

Artifacts

+
5 ready / 2 surfaced
+
+
+

Budget

+
64k context / 71% free
+
+
+

Sync

+
Remote healthy / 42ms
+
+
+
+
+ +
+ + +
+
+
+ + + + From 4d856283bcd5ce85d0a1ffab3337eeed26bf09de Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 15:30:13 +0000 Subject: [PATCH 08/28] docs: remove agent-first workspace mock --- docs/agent-first-workspace-mock.html | 2033 -------------------------- 1 file changed, 2033 deletions(-) delete mode 100644 docs/agent-first-workspace-mock.html diff --git a/docs/agent-first-workspace-mock.html b/docs/agent-first-workspace-mock.html deleted file mode 100644 index a3d602f83..000000000 --- a/docs/agent-first-workspace-mock.html +++ /dev/null @@ -1,2033 +0,0 @@ - - - - - -Coder Studio - Agent-First Workspace Mock - - - - - - -
-
-
-
- - Coder Studio / Desktop workspace concept / agent-first -
-

Agent takes center stage. Code becomes a transient surface.

-

- This mock keeps the VS Code-grade shell discipline, but shifts visual gravity toward the live agent run. - Files, diff, preview, and editor stay available as a floating right-side drawer instead of occupying the app full-time. -

-
- -
- -
-
-
-
CS
-
-
Coder Studio / workspace-shell-redesign
-
ssh://studio-usw2 / branch agent-shell-v3 / 4 collaborators
-
-
- -
-
- - Agent runtime healthy - 2 long-running jobs -
-
- Focus lane - Approvals + diff + artifacts -
-
- -
- - - -
-
- -
- - - - -
-
-
-
-
Current objective
-

Stabilize the workspace shell for agent-heavy flows

-

- The shell should feel like a serious long-session workbench: neutral chrome, strong runtime clarity, - and a clear hierarchy where the agent run out-ranks files and settings. -

-
- RunningCodex / GPT-5.5 - 94magent-shell-v3 - 12 files1 approval gate -
-
-
- - - -
-
-
- -
-
-
-
-

Approval requested: write to workspace shell and review surfaces

-

- The active run needs permission to modify the desktop workspace shell, shared panel tokens, - and the transient right-side code drawer behavior. -

-
-
-
- - -
-
- -
-
-

Current step

-

Rebalance shell density and route code review into a transient drawer

-

- Tighten the desktop chrome, strengthen session scanning, and remove the expectation that an editor must - always occupy the primary viewport. -

- -
- -
-

Active tasks

-
-
7
-
Timeline, drawer motion, side density, approval band, runtime strip.
-
-
- -
-

Changed files

-
-
12
-
Shell layout, shared panel styles, mock session data, motion timing tokens.
-
-
- -
-

Attention cost

-
-
Low
-
One approval gate remains, but noise is controlled.
-
-
-
- -
-
-
- -
-
-
-

Timeline checkpoint

-

Promoted the agent lane to primary stage

-
- 14:02 UTC -
-

- The default desktop entry now lands on the live objective, current run, approvals, and checkpoints. - Editor, preview, and diff are available on demand instead of permanently consuming a third of the shell. -

-
-
- -
- -
-
-
-

Code review surface

-

Prepared a right-side floating drawer for files, diff, and preview

-
- Open drawer -
-

- The drawer inherits the shell theme, floats above the agent stage, and supports quick inspection without - forcing a full context switch. It can be opened from files, artifacts, approvals, or timeline events. -

-
-
- Diff - Focused stats and changed regions. -
-
- Editor - Contextual file editing for scoped changes. -
-
- Preview - Artifact and render inspection without leaving the run. -
-
-
-
- -
- -
-
-
-

Approval surface

-

Moved risky actions into a clear but compact top strip

-
- 1 pending gate -
-

- Approvals stay impossible to miss, but they no longer drown the shell in warning color. The strip carries - the action, scope summary, and the shortest path to inspect the underlying diff. -

-
-
- -
- -
-
-
-

Changed files

-

Shell layout updates ready for review

-
- 3 focus files -
-

- The proposed edits keep the current app architecture, but re-tune density, hierarchy, and surface behavior. -

-
-
-

packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx

- diff preview -
-
// Primary stage stays agent-first.
-return (
-  <WorkspaceShell stage="agent">
-    <AgentTimeline emphasis="primary" />
-    <FloatingSurface mode="drawer" trigger="on-demand" />
-  </WorkspaceShell>
-);
-
-
-
- -
- -
-
-
-

Risk note

-

Mint theme still needs stricter chrome restraint

-
- visual debt -
-

- Mint Dark carries brand warmth, but the chrome cannot feel decorative. Shared structure must remain consistent, - with theme shifts limited to tint, accent, and depth rather than component geometry or spacing. -

-
-
- -
- -
-
-
-

Artifact checkpoint

-

Visual draft ready for shell-first review

-
- html deliverable -
-

- This mock demonstrates the desktop shell direction before wiring it into the product. It is intentionally static: - the goal here is to judge hierarchy, surface roles, and brand fit, not production behavior. -

-
-
-
-
- - - -
-
-

Runtime

-
2 terminals / 1 hidden
-
-
-

Artifacts

-
5 ready / 2 surfaced
-
-
-

Budget

-
64k context / 71% free
-
-
-

Sync

-
Remote healthy / 42ms
-
-
-
-
- -
- - -
-
-
- - - - From 72390abee606e090b9d17b3546e6e678ee2e7be8 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 15:37:44 +0000 Subject: [PATCH 09/28] fix(server): decouple tree visibility from gitignore --- .../src/__tests__/file-commands.test.ts | 22 +++++++++++ .../server/src/__tests__/fs/gitignore.test.ts | 18 ++++++++- packages/server/src/__tests__/fs/tree.test.ts | 37 +++++++------------ packages/server/src/fs/gitignore.ts | 12 ++++++ packages/server/src/fs/tree.ts | 4 +- 5 files changed, 67 insertions(+), 26 deletions(-) diff --git a/packages/server/src/__tests__/file-commands.test.ts b/packages/server/src/__tests__/file-commands.test.ts index ca11c2c21..37e59cea6 100644 --- a/packages/server/src/__tests__/file-commands.test.ts +++ b/packages/server/src/__tests__/file-commands.test.ts @@ -119,6 +119,28 @@ describe("File Commands", () => { expect(files).toHaveLength(0); }); + it("keeps .gitignore filtering for search results", async () => { + await writeFile(join(testDir, ".gitignore"), "ignored-note.md\n"); + await writeFile(join(testDir, "ignored-note.md"), "hidden from search\n"); + + const result = await dispatch( + { + kind: "command", + id: "file-search-3", + op: "file.search", + args: { + workspaceId, + query: "ignored", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + const files = (result.data as { files: Array<{ path: string }> }).files; + expect(files).toHaveLength(0); + }); + it("emits fs.dirty after file writes", async () => { const result = await dispatch( { diff --git a/packages/server/src/__tests__/fs/gitignore.test.ts b/packages/server/src/__tests__/fs/gitignore.test.ts index 61a69bb0e..83a3dead0 100644 --- a/packages/server/src/__tests__/fs/gitignore.test.ts +++ b/packages/server/src/__tests__/fs/gitignore.test.ts @@ -6,7 +6,11 @@ import { mkdir, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createGitignoreFilter, createWatcherIgnoreFilter } from "../../fs/gitignore.js"; +import { + createGitignoreFilter, + createTreeVisibilityFilter, + createWatcherIgnoreFilter, +} from "../../fs/gitignore.js"; describe("createGitignoreFilter", () => { let testDir: string; @@ -86,6 +90,18 @@ describe("createGitignoreFilter", () => { }); }); +describe("createTreeVisibilityFilter", () => { + it("hides only .git entries from the directory tree", () => { + const filter = createTreeVisibilityFilter(); + + expect(filter(".git")).toBe(false); + expect(filter(".gitignore")).toBe(true); + expect(filter(".env")).toBe(true); + expect(filter("node_modules")).toBe(true); + expect(filter("src")).toBe(true); + }); +}); + describe("createWatcherIgnoreFilter", () => { let testDir: string; diff --git a/packages/server/src/__tests__/fs/tree.test.ts b/packages/server/src/__tests__/fs/tree.test.ts index 815386c6d..780e69ea2 100644 --- a/packages/server/src/__tests__/fs/tree.test.ts +++ b/packages/server/src/__tests__/fs/tree.test.ts @@ -91,25 +91,30 @@ describe("readTree", () => { expect(result.children[3].name).toBe("b-file.txt"); }); - it("should skip hidden files", async () => { + it("should show hidden files except .git", async () => { await writeFile(join(testDir, ".hidden"), "hidden"); + await writeFile(join(testDir, ".gitignore"), "*.log\n"); + await mkdirAsync(join(testDir, ".git")); await writeFile(join(testDir, "visible.txt"), "visible"); const result = await readTree(testDir); - expect(result.children).toHaveLength(1); - expect(result.children[0].name).toBe("visible.txt"); + expect(result.children.some((n) => n.name === ".hidden")).toBe(true); + expect(result.children.some((n) => n.name === ".gitignore")).toBe(true); + expect(result.children.some((n) => n.name === ".git")).toBe(false); + expect(result.children.some((n) => n.name === "visible.txt")).toBe(true); }); - it("should skip node_modules and .git", async () => { + it("should show node_modules but skip .git", async () => { await mkdirAsync(join(testDir, "node_modules")); await mkdirAsync(join(testDir, ".git")); await writeFile(join(testDir, "file.txt"), "content"); const result = await readTree(testDir); - expect(result.children).toHaveLength(1); - expect(result.children[0].name).toBe("file.txt"); + expect(result.children.some((n) => n.name === "node_modules")).toBe(true); + expect(result.children.some((n) => n.name === ".git")).toBe(false); + expect(result.children.some((n) => n.name === "file.txt")).toBe(true); }); it("should use relative paths", async () => { @@ -122,7 +127,7 @@ describe("readTree", () => { // Subdir children are undefined (lazy loading) }); - it("should respect .gitignore rules", async () => { + it("should not hide .gitignore-matched files from the tree", async () => { await writeFile(join(testDir, ".gitignore"), "*.log\ndist/"); await writeFile(join(testDir, "app.log"), "log content"); await writeFile(join(testDir, "app.txt"), "text content"); @@ -131,23 +136,9 @@ describe("readTree", () => { const result = await readTree(testDir); - expect(result.children.some((n) => n.name === "app.log")).toBe(false); - expect(result.children.some((n) => n.name === "dist")).toBe(false); + expect(result.children.some((n) => n.name === "app.log")).toBe(true); + expect(result.children.some((n) => n.name === "dist")).toBe(true); expect(result.children.some((n) => n.name === "app.txt")).toBe(true); expect(result.children.some((n) => n.name === "src")).toBe(true); }); - - it("should show dotfiles when .gitignore does not ignore them", async () => { - await writeFile(join(testDir, ".gitignore"), "*.log"); - await writeFile(join(testDir, ".env"), "secret"); - await writeFile(join(testDir, ".hidden-config"), "hidden"); - await writeFile(join(testDir, "visible.txt"), "visible"); - - const result = await readTree(testDir); - - expect(result.children.some((n) => n.name === ".env")).toBe(true); - expect(result.children.some((n) => n.name === ".gitignore")).toBe(true); - expect(result.children.some((n) => n.name === ".hidden-config")).toBe(true); - expect(result.children.some((n) => n.name === "visible.txt")).toBe(true); - }); }); diff --git a/packages/server/src/fs/gitignore.ts b/packages/server/src/fs/gitignore.ts index 1a8f17d00..a909769ed 100644 --- a/packages/server/src/fs/gitignore.ts +++ b/packages/server/src/fs/gitignore.ts @@ -25,6 +25,10 @@ function isDefaultTreeIgnored(name: string): boolean { return name.startsWith(".") || name === "node_modules" || name === ".git"; } +function isTreeHidden(name: string): boolean { + return name === ".git"; +} + function isAlwaysTreeIgnored(name: string): boolean { return name === "node_modules" || name === ".git"; } @@ -68,6 +72,14 @@ export function createGitignoreFilter( }; } +/** + * Creates a filter for directory tree visibility. + * Returns false if the entry should be hidden from the tree, true otherwise. + */ +export function createTreeVisibilityFilter(): (name: string) => boolean { + return (name: string) => !isTreeHidden(name); +} + /** * Creates a filter for the file watcher (chokidar). * Returns a function suitable for chokidar's `ignored` option. diff --git a/packages/server/src/fs/tree.ts b/packages/server/src/fs/tree.ts index 892a594cd..09f9348b4 100644 --- a/packages/server/src/fs/tree.ts +++ b/packages/server/src/fs/tree.ts @@ -6,7 +6,7 @@ import type { FileNode } from "@coder-studio/core"; import { readdir, stat } from "fs/promises"; import { join, relative } from "path"; -import { createGitignoreFilter } from "./gitignore.js"; +import { createGitignoreFilter, createTreeVisibilityFilter } from "./gitignore.js"; export interface ReadTreeResult { path: string; @@ -24,7 +24,7 @@ export interface ReadTreeResult { */ export async function readTree(rootPath: string, subdir?: string): Promise { const targetPath = subdir ? join(rootPath, subdir) : rootPath; - const filter = createGitignoreFilter(rootPath, targetPath); + const filter = createTreeVisibilityFilter(); const entries = await readdir(targetPath, { withFileTypes: true }); const nodes: FileNode[] = []; From c8964efb94a3fc39a6c85f11ce956f9f80a978bb Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 15:38:26 +0000 Subject: [PATCH 10/28] chore: ignore nested coder-studio runtime dirs --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 73cf34fc8..b80eb5630 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ e2e-ui/test-results/ /superpowers/ /.superpowers/ /.coder-studio/ +.coder-studio/ /.codex tsconfig.tsbuildinfo From 71f3e3a9bddb7e1ad1979377638fd0e03f271cc5 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 15:42:33 +0000 Subject: [PATCH 11/28] Fix mobile session pane cleanup --- .../agent-panes/actions/use-pane-actions.ts | 9 + .../actions/use-session-actions.test.tsx | 2 +- .../actions/use-session-actions.ts | 13 +- .../actions/use-workspace-sessions.ts | 14 +- .../agent-panes/pane-layout-tree.test.ts | 20 + .../features/agent-panes/pane-layout-tree.ts | 54 +- .../actions/use-workspace-screen-model.ts | 32 +- .../src/shells/mobile-shell/index.test.tsx | 593 +++++++++++++++++- 8 files changed, 708 insertions(+), 29 deletions(-) diff --git a/packages/web/src/features/agent-panes/actions/use-pane-actions.ts b/packages/web/src/features/agent-panes/actions/use-pane-actions.ts index 64b1d665d..55f162e77 100644 --- a/packages/web/src/features/agent-panes/actions/use-pane-actions.ts +++ b/packages/web/src/features/agent-panes/actions/use-pane-actions.ts @@ -8,6 +8,7 @@ import { assignSessionToPane, closeDraftPaneById, closePaneBySessionId, + removePaneBySessionId, splitPaneByPaneId, splitPaneBySessionId, } from "../pane-layout-tree"; @@ -56,6 +57,13 @@ export function usePaneActions(workspaceId: string) { [applyLayout] ); + const removeSessionPane = useCallback( + (sessionId: string) => { + applyLayout((current) => removePaneBySessionId(current, sessionId)); + }, + [applyLayout] + ); + const assignSession = useCallback( (paneId: string, sessionId: string) => { applyLayout((current) => assignSessionToPane(current, paneId, sessionId)); @@ -92,6 +100,7 @@ export function usePaneActions(workspaceId: string) { assignSession, closeDraftPane, closeSessionPane, + removeSessionPane, replaceWithSession, splitDraftPane, splitSessionPane, diff --git a/packages/web/src/features/agent-panes/actions/use-session-actions.test.tsx b/packages/web/src/features/agent-panes/actions/use-session-actions.test.tsx index 1312a02ec..c906446eb 100644 --- a/packages/web/src/features/agent-panes/actions/use-session-actions.test.tsx +++ b/packages/web/src/features/agent-panes/actions/use-session-actions.test.tsx @@ -121,7 +121,7 @@ describe("useSessionActions", () => { }); await vi.advanceTimersByTimeAsync(100); - await expect(closePromise).resolves.toBeUndefined(); + await expect(closePromise).resolves.toBe(true); } finally { if (windowDescriptor) { Object.defineProperty(globalThis, "window", windowDescriptor); diff --git a/packages/web/src/features/agent-panes/actions/use-session-actions.ts b/packages/web/src/features/agent-panes/actions/use-session-actions.ts index 530a99f14..eabcdcd26 100644 --- a/packages/web/src/features/agent-panes/actions/use-session-actions.ts +++ b/packages/web/src/features/agent-panes/actions/use-session-actions.ts @@ -32,40 +32,43 @@ export function useSessionActions() { async (sessionId: string) => { const session = store.get(sessionByIdAtomFamily(sessionId)); if (!session) { - return; + return false; } if (session.state === "ended") { const removeResult = await dispatch("session.remove", { sessionId }); if (!removeResult.ok) { console.error("Failed to remove ended session:", removeResult.error?.message); + return false; } - return; + return true; } const stopResult = await dispatch("session.stop", { sessionId }); if (!stopResult.ok && stopResult.error?.code !== "invalid_state") { console.error("Failed to stop session before removal:", stopResult.error?.message); - return; + return false; } const deadline = Date.now() + SESSION_REMOVAL_TIMEOUT_MS; while (Date.now() < deadline) { const current = store.get(sessionByIdAtomFamily(sessionId)); if (!current) { - return; + return true; } if (current.state === "ended") { const removeResult = await dispatch("session.remove", { sessionId }); if (!removeResult.ok) { console.error("Failed to remove ended session:", removeResult.error?.message); + return false; } - return; + return true; } await delay(SESSION_REMOVAL_POLL_INTERVAL_MS); } console.error("Timed out waiting for session to end before removal:", sessionId); + return false; }, [dispatch, store] ); diff --git a/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts b/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts index 244b8b8ae..206f235fa 100644 --- a/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts +++ b/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts @@ -94,7 +94,9 @@ export function useWorkspaceSessions( const currentLayout = store.get(paneLayoutAtomFamily(workspaceId)); const workspacePaneLayout = normalizePaneLayout(workspace?.uiState.paneLayout); - const legacyPaneLayout = workspacePaneLayout ? null : readLegacyPaneLayout(workspace.id); + const legacyPaneLayout = workspacePaneLayout + ? null + : normalizePaneLayout(readLegacyPaneLayout(workspace.id)); const baseLayout = workspacePaneLayout ?? legacyPaneLayout ?? currentLayout ?? defaultPaneLayout; const displayableSessionIds = new Set( @@ -172,13 +174,19 @@ export function useWorkspaceSessions( }; } -function normalizePaneLayout(layout: Workspace["uiState"]["paneLayout"]): PaneNode | null { +function normalizePaneLayout( + layout: Workspace["uiState"]["paneLayout"] | PaneNode | null | undefined +): PaneNode | null { if (!layout) { return null; } return { - ...layout, + id: layout.id, + type: layout.type, + sessionId: layout.sessionId, + direction: layout.direction, + ratio: "ratio" in layout ? layout.ratio : undefined, children: layout.children?.map((child) => normalizePaneLayout(child) ?? defaultPaneLayout), }; } diff --git a/packages/web/src/features/agent-panes/pane-layout-tree.test.ts b/packages/web/src/features/agent-panes/pane-layout-tree.test.ts index f99f08b5d..bab0e4b92 100644 --- a/packages/web/src/features/agent-panes/pane-layout-tree.test.ts +++ b/packages/web/src/features/agent-panes/pane-layout-tree.test.ts @@ -6,6 +6,7 @@ import { closeDraftPaneById, closePaneBySessionId, createFallbackPaneLayout, + removePaneBySessionId, splitPaneByPaneId, splitPaneBySessionId, } from "./pane-layout-tree"; @@ -59,6 +60,25 @@ describe("pane-layout-tree", () => { }); }); + it("removes a session pane and collapses the split when explicitly requested", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(removePaneBySessionId(layout, "sess_2")).toEqual({ + id: "left", + type: "leaf", + sessionId: "sess_1", + }); + }); + it("assigns a session to the matching draft pane without touching siblings", () => { const layout: PaneNode = { id: "root", diff --git a/packages/web/src/features/agent-panes/pane-layout-tree.ts b/packages/web/src/features/agent-panes/pane-layout-tree.ts index fd337af2c..2d45380fc 100644 --- a/packages/web/src/features/agent-panes/pane-layout-tree.ts +++ b/packages/web/src/features/agent-panes/pane-layout-tree.ts @@ -186,7 +186,7 @@ export function closeDraftPaneById(node: PaneNode, paneId: string): PaneNode { /** * Close a session pane by turning it into a draft leaf while preserving the - * existing split structure. This matches the session-card close behavior: + * existing split structure. This matches the desktop session-card close behavior: * the session ends, but the workspace layout remains stable so the user can * immediately launch a replacement session in the same pane. */ @@ -221,6 +221,49 @@ function replaceSessionWithDraft(node: PaneNode, sessionId: string): PaneNode { }; } +export function removePaneBySessionId(node: PaneNode, sessionId: string): PaneNode { + return removeSessionPane(node, sessionId) ?? { id: node.id, type: "leaf" }; +} + +function removeSessionPane(node: PaneNode, sessionId: string): PaneNode | null { + if (node.type === "leaf") { + if (node.sessionId === sessionId) { + return null; + } + return node; + } + + const children = node.children ?? []; + let changed = false; + const nextChildren: PaneNode[] = []; + for (const child of children) { + const nextChild = removeSessionPane(child, sessionId); + if (nextChild !== child) { + changed = true; + } + if (nextChild !== null) { + nextChildren.push(nextChild); + } + } + + if (!changed) { + return node; + } + + if (nextChildren.length === 1) { + return nextChildren[0]!; + } + + if (nextChildren.length === 0) { + return null; + } + + return { + ...node, + children: nextChildren, + }; +} + export function paneLayoutHasSession(node: PaneNode, sessionIds: Set): boolean { if (node.type === "leaf") { return node.sessionId ? sessionIds.has(node.sessionId) : false; @@ -317,14 +360,13 @@ export function createFallbackPaneLayout(sessionIds: string[]): PaneNode { */ export function sanitizePaneLayout(node: PaneNode, liveSessionIds: Set): PaneNode { if (node.type === "leaf") { - // If this leaf references a session that is ended or removed, turn it into a draft + // If this leaf references a session that is ended or removed, turn it into a draft. if (node.sessionId && !liveSessionIds.has(node.sessionId)) { return { id: node.id, type: "leaf" }; } return node; } - // For splits, recursively sanitize all children and keep the structure intact const children = node.children ?? []; let changed = false; const nextChildren = children.map((child) => { @@ -335,16 +377,10 @@ export function sanitizePaneLayout(node: PaneNode, liveSessionIds: Set): return nextChild; }); - // If no children changed, return the same node to preserve reference equality if (!changed) { return node; } - // If all children collapsed to a single leaf, simplify - if (nextChildren.length === 1) { - return nextChildren[0]!; - } - return { ...node, children: nextChildren, diff --git a/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts b/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts index 2fc6b074a..f9e59f61a 100644 --- a/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts +++ b/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts @@ -1,6 +1,6 @@ import type { GitStatus, Session } from "@coder-studio/core"; import { useAtomValue, useSetAtom, useStore } from "jotai"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { dispatchCommandAtom } from "../../../atoms/connection"; import { activeWorkspaceAtom, @@ -63,6 +63,7 @@ export function useWorkspaceScreenModel() { const [mobileSheet, setMobileSheet] = useState(null); const [mobileFilesRoute, setMobileFilesRoute] = useState({ kind: "root" }); const [mobileActiveSessionId, setMobileActiveSessionId] = useState(null); + const mobileSelectionVersionRef = useRef(0); useEffect(() => { if (!workspace) { @@ -170,6 +171,7 @@ export function useWorkspaceScreenModel() { paneActions.appendSession(sessionId, mobileActiveSessionId, "vertical"); } + mobileSelectionVersionRef.current += 1; setMobileActiveSessionId(sessionId); }, [mobileActiveSessionId, orderedSessions, paneActions, sessions] @@ -178,6 +180,7 @@ export function useWorkspaceScreenModel() { const handleMobileSessionCreated = useCallback( (sessionId: string) => { paneActions.appendSession(sessionId, mobileActiveSessionId, "vertical"); + mobileSelectionVersionRef.current += 1; setMobileActiveSessionId(sessionId); }, [mobileActiveSessionId, paneActions] @@ -185,16 +188,30 @@ export function useWorkspaceScreenModel() { const closeMobileSession = useCallback( async (sessionId: string) => { + const wasActive = mobileActiveSessionId === sessionId; + const selectionVersionAtCloseStart = mobileSelectionVersionRef.current; const remainingSessions = mobileAgentSessions.filter((session) => session.id !== sessionId); const nextActiveSessionId = remainingSessions[0]?.id ?? null; - paneActions.closeSessionPane(sessionId); - setMobileActiveSessionId((current) => - current === sessionId ? nextActiveSessionId : current - ); - await sessionActions.closeSession(sessionId); + if (wasActive) { + setMobileActiveSessionId(nextActiveSessionId); + } + + const closed = await sessionActions.closeSession(sessionId); + if (!closed) { + if (!wasActive || mobileSelectionVersionRef.current !== selectionVersionAtCloseStart) { + return; + } + + setMobileActiveSessionId((current) => + current === nextActiveSessionId ? sessionId : current + ); + return; + } + + paneActions.removeSessionPane(sessionId); }, - [mobileAgentSessions, paneActions, sessionActions] + [mobileActiveSessionId, mobileAgentSessions, paneActions, sessionActions] ); const restoreMobileSession = useCallback( @@ -207,6 +224,7 @@ export function useWorkspaceScreenModel() { paneActions.appendSession(sessionId, mobileActiveSessionId, "vertical"); } + mobileSelectionVersionRef.current += 1; setMobileActiveSessionId(sessionId); }, [mobileActiveSessionId, orderedSessions, paneActions, sessions] diff --git a/packages/web/src/shells/mobile-shell/index.test.tsx b/packages/web/src/shells/mobile-shell/index.test.tsx index ef47b8d79..2d79c435b 100644 --- a/packages/web/src/shells/mobile-shell/index.test.tsx +++ b/packages/web/src/shells/mobile-shell/index.test.tsx @@ -36,6 +36,7 @@ import { gitStateAtomFamily, } from "../../features/workspace/atoms"; import { seedReadyWorkspaceState } from "../../test-utils/workspace-state"; +import { CommandResultError } from "../../ws/client"; import { MobileShell } from "./index"; const { mockMobileEditorHandleSave, mockMobileEditorToggleSvgTextMode } = vi.hoisted(() => ({ @@ -1196,7 +1197,7 @@ describe("MobileShell Phase 2 workspace", () => { withWorkspaces: false, }); - expect(screen.getByText("正在重新连接...")).toBeInTheDocument(); + expect(screen.getByText("连接已断开,正在重新连接...")).toBeInTheDocument(); }); it("shows a not found page for unknown frontend routes", () => { @@ -1561,6 +1562,489 @@ describe("MobileShell Phase 2 workspace", () => { expect(sendCommand).toHaveBeenCalledWith("session.stop", { sessionId: "sess_2" }, undefined); }); + it("removes a mobile-created pane after closing that session so desktop panes do not accumulate", async () => { + const user = userEvent.setup(); + let closeStore: ReturnType | null = null; + const sendCommand = vi.fn(async (op: string, payload?: Record) => { + if (op === "session.list") { + return [ + createSession({ + id: "sess_1", + terminalId: "term-1", + providerId: "claude", + state: "idle", + title: "Claude", + }), + ]; + } + + if (op === "provider.runtimeStatus") { + return { + providers: { + claude: { + providerId: "claude", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + }, + codex: { + providerId: "codex", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + }, + }, + }; + } + + if (op === "session.stop") { + queueMicrotask(() => { + if (!closeStore) { + return; + } + + closeStore.set(sessionsAtom, { + sess_1: createSession({ + id: "sess_1", + terminalId: "term-1", + providerId: "claude", + state: "idle", + title: "Claude", + }), + sess_3: createSession({ + id: "sess_3", + terminalId: "term-3", + providerId: String(payload?.providerId ?? "codex"), + state: "ended", + title: "Codex 2", + endedAt: Date.now(), + }), + }); + }); + return undefined; + } + + if (op === "session.remove") { + queueMicrotask(() => { + if (!closeStore) { + return; + } + + closeStore.set(sessionsAtom, { + sess_1: createSession({ + id: "sess_1", + terminalId: "term-1", + providerId: "claude", + state: "idle", + title: "Claude", + }), + }); + }); + return undefined; + } + + if (op === "session.create") { + return createSession({ + id: "sess_3", + terminalId: "term-3", + providerId: String(payload?.providerId ?? "codex"), + state: "idle", + title: "Codex 2", + }); + } + + if (op === "workspace.uiState.set") { + return { + id: "ws-1", + name: "Alpha", + path: "/tmp/alpha", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: payload?.uiState, + }; + } + + return undefined; + }); + + const { store } = renderMobileShell({ + sendCommand, + sessions: [], + paneLayout: { + id: "root", + type: "leaf", + sessionId: "sess_1", + }, + }); + closeStore = store; + + await user.click(await screen.findByRole("button", { name: "Open Agent sheet" })); + await user.click(screen.getByRole("button", { name: "Create Session" })); + await user.click( + screen.getByRole("button", { + name: "Codex", + description: "Start Codex session Start new session", + }) + ); + + await waitFor(() => { + expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_3"); + }); + + const createdLayout = store.get(paneLayoutAtomFamily("ws-1")); + expect(createdLayout).toEqual( + expect.objectContaining({ + type: "split", + children: [ + expect.objectContaining({ sessionId: "sess_1" }), + expect.objectContaining({ sessionId: "sess_3" }), + ], + }) + ); + expect(createdLayout.children?.[1]).not.toHaveProperty("closeBehavior"); + + await user.click(await screen.findByRole("button", { name: "Open Agent sheet" })); + const createdRow = screen + .getByRole("button", { + name: "Codex 2", + description: "Switch to agent Codex 2 CODEX", + }) + .closest(".mobile-select-sheet__item-row"); + expect(createdRow).not.toBeNull(); + + await user.click( + within(createdRow as HTMLElement).getByRole("button", { name: "Close Current Session" }) + ); + + await waitFor(() => { + expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_1"); + }); + await waitFor(() => { + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "leaf", + sessionId: "sess_1", + }); + }); + expect(sendCommand).toHaveBeenCalledWith("session.stop", { sessionId: "sess_3" }, undefined); + expect(sendCommand).toHaveBeenCalledWith("session.remove", { sessionId: "sess_3" }, undefined); + }); + + it("keeps the pane when mobile session close fails", async () => { + const user = userEvent.setup(); + const sendCommand = vi.fn(async (op: string, payload?: Record) => { + if (op === "session.list") { + return [ + createSession({ + id: "sess_1", + terminalId: "term-1", + providerId: "claude", + state: "idle", + title: "Claude", + }), + ]; + } + + if (op === "provider.runtimeStatus") { + return { + providers: { + claude: { + providerId: "claude", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + }, + codex: { + providerId: "codex", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + }, + }, + }; + } + + if (op === "session.create") { + return createSession({ + id: "sess_3", + terminalId: "term-3", + providerId: String(payload?.providerId ?? "codex"), + state: "idle", + title: "Codex 2", + }); + } + + if (op === "session.stop") { + throw new CommandResultError({ + code: "permission_denied", + message: "stop failed", + }); + } + + if (op === "workspace.uiState.set") { + return { + id: "ws-1", + name: "Alpha", + path: "/tmp/alpha", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: payload?.uiState, + }; + } + + return undefined; + }); + + const { store } = renderMobileShell({ + sendCommand, + sessions: [], + paneLayout: { + id: "root", + type: "leaf", + sessionId: "sess_1", + }, + }); + + await user.click(await screen.findByRole("button", { name: "Open Agent sheet" })); + await user.click(screen.getByRole("button", { name: "Create Session" })); + await user.click( + screen.getByRole("button", { + name: "Codex", + description: "Start Codex session Start new session", + }) + ); + + await waitFor(() => { + expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_3"); + }); + + await user.click(await screen.findByRole("button", { name: "Open Agent sheet" })); + const createdRow = screen + .getByRole("button", { + name: "Codex 2", + description: "Switch to agent Codex 2 CODEX", + }) + .closest(".mobile-select-sheet__item-row"); + expect(createdRow).not.toBeNull(); + + await user.click( + within(createdRow as HTMLElement).getByRole("button", { name: "Close Current Session" }) + ); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith("session.stop", { sessionId: "sess_3" }, undefined); + }); + + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual( + expect.objectContaining({ + type: "split", + children: [ + expect.objectContaining({ sessionId: "sess_1" }), + expect.objectContaining({ sessionId: "sess_3" }), + ], + }) + ); + expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_3"); + }); + + it("does not override a user's later fallback-session selection when mobile close fails", async () => { + const user = userEvent.setup(); + let rejectStop: ((error: unknown) => void) | null = null; + const sendCommand = vi.fn(async (op: string, payload?: Record) => { + if (op === "session.list") { + return [ + createSession({ + id: "sess_1", + terminalId: "term-1", + providerId: "claude", + state: "idle", + title: "Claude", + }), + createSession({ + id: "sess_2", + terminalId: "term-2", + providerId: "codex", + state: "idle", + title: "Codex", + }), + createSession({ + id: "sess_3", + terminalId: "term-3", + providerId: "gemini", + state: "idle", + title: "Gemini", + }), + ]; + } + + if (op === "provider.runtimeStatus") { + return { + providers: { + claude: { + providerId: "claude", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + }, + codex: { + providerId: "codex", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + }, + gemini: { + providerId: "gemini", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + }, + }, + }; + } + + if (op === "session.stop") { + return await new Promise((_, reject) => { + rejectStop = reject; + }); + } + + if (op === "workspace.uiState.set") { + return { + id: "ws-1", + name: "Alpha", + path: "/tmp/alpha", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: payload?.uiState, + }; + } + + return undefined; + }); + + renderMobileShell({ + sendCommand, + seedSupervisor: false, + lastViewedTarget: { workspaceId: "ws-1", sessionId: "sess_3", updatedAt: Date.now() }, + sessions: [ + createSession({ + id: "sess_1", + terminalId: "term-1", + providerId: "claude", + state: "idle", + lastActiveAt: Date.now() - 5_000, + title: "Claude", + }), + createSession({ + id: "sess_2", + terminalId: "term-2", + providerId: "codex", + state: "idle", + lastActiveAt: Date.now() - 2_000, + title: "Codex", + }), + createSession({ + id: "sess_3", + terminalId: "term-3", + providerId: "gemini", + state: "idle", + lastActiveAt: Date.now() - 500, + title: "Gemini", + }), + ], + paneLayout: { + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", sessionId: "sess_2" }, + { id: "middle", type: "leaf", sessionId: "sess_3" }, + { id: "right", type: "leaf", sessionId: "sess_1" }, + ], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_3"); + }); + + await user.click(await screen.findByRole("button", { name: "Open Agent sheet" })); + const geminiRow = screen + .getByRole("button", { + name: "Gemini", + description: "Switch to agent Gemini GEMINI", + }) + .closest(".mobile-select-sheet__item-row"); + expect(geminiRow).not.toBeNull(); + + await user.click( + within(geminiRow as HTMLElement).getByRole("button", { name: "Close Current Session" }) + ); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith("session.stop", { sessionId: "sess_3" }, undefined); + }); + + await user.click( + screen.getByRole("button", { + name: "Codex", + description: "Switch to agent Codex CODEX", + }) + ); + + await waitFor(() => { + expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_2"); + }); + + await act(async () => { + rejectStop?.( + new CommandResultError({ + code: "permission_denied", + message: "stop failed", + }) + ); + await Promise.resolve(); + }); + + await waitFor(() => { + expect( + screen.queryByRole("button", { name: "Close Current Session" }) + ).not.toBeInTheDocument(); + }); + expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_2"); + }); + it("closes a non-active agent from its row action without switching first", async () => { const user = userEvent.setup(); const sendCommand = vi.fn(async (op: string) => { @@ -1789,7 +2273,7 @@ describe("MobileShell Phase 2 workspace", () => { return undefined; }); - renderMobileShell({ + const { store } = renderMobileShell({ initialEntry: "/workspace", sessions: [], paneLayout: { @@ -1957,11 +2441,61 @@ describe("MobileShell Phase 2 workspace", () => { title: "New Mobile Codex", }), ]; + let closeStore: ReturnType | null = null; const sendCommand = vi.fn(async (op: string, payload?: Record) => { if (op === "session.list") { return sessions; } + if (op === "session.stop") { + queueMicrotask(() => { + if (!closeStore) { + return; + } + + closeStore.set(sessionsAtom, { + sess_existing: createSession({ + id: "sess_existing", + terminalId: "term-existing", + providerId: "claude", + state: "idle", + lastActiveAt: Date.now() - 5_000, + title: "Existing Claude", + }), + sess_new_mobile: createSession({ + id: "sess_new_mobile", + terminalId: "term-new-mobile", + providerId: "codex", + state: "ended", + lastActiveAt: Date.now() - 500, + title: "New Mobile Codex", + endedAt: Date.now(), + }), + }); + }); + return undefined; + } + + if (op === "session.remove") { + queueMicrotask(() => { + if (!closeStore) { + return; + } + + closeStore.set(sessionsAtom, { + sess_existing: createSession({ + id: "sess_existing", + terminalId: "term-existing", + providerId: "claude", + state: "idle", + lastActiveAt: Date.now() - 5_000, + title: "Existing Claude", + }), + }); + }); + return undefined; + } + if (op === "workspace.uiState.set") { return { id: "ws-1", @@ -1977,7 +2511,7 @@ describe("MobileShell Phase 2 workspace", () => { return undefined; }); - renderMobileShell({ + const { store } = renderMobileShell({ initialEntry: "/workspace", sessions: [], paneLayout: { @@ -1987,6 +2521,7 @@ describe("MobileShell Phase 2 workspace", () => { }, sendCommand, }); + closeStore = store; await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith("session.list", { workspaceId: "ws-1" }, undefined); @@ -1996,6 +2531,45 @@ describe("MobileShell Phase 2 workspace", () => { expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_new_mobile"); }); + await waitFor(() => { + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual( + expect.objectContaining({ + type: "split", + children: [ + expect.objectContaining({ sessionId: "sess_existing" }), + expect.objectContaining({ sessionId: "sess_new_mobile" }), + ], + }) + ); + }); + expect(store.get(paneLayoutAtomFamily("ws-1")).children?.[1]).not.toHaveProperty( + "closeBehavior" + ); + + await userEvent.click(await screen.findByRole("button", { name: "Open Agent sheet" })); + const restoredRow = screen + .getByRole("button", { + name: "New Mobile Codex", + description: "Switch to agent New Mobile Codex CODEX", + }) + .closest(".mobile-select-sheet__item-row"); + expect(restoredRow).not.toBeNull(); + + await userEvent.click( + within(restoredRow as HTMLElement).getByRole("button", { name: "Close Current Session" }) + ); + + await waitFor(() => { + expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_existing"); + }); + await waitFor(() => { + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "leaf", + sessionId: "sess_existing", + }); + }); + await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( "workspace.uiState.set", @@ -2016,6 +2590,17 @@ describe("MobileShell Phase 2 workspace", () => { undefined ); }); + const persistedLayoutCall = sendCommand.mock.calls.find( + ([op, payload]) => + op === "workspace.uiState.set" && + (payload as { uiState?: { activeSessionId?: string } } | undefined)?.uiState + ?.activeSessionId === "sess_new_mobile" + ); + expect(persistedLayoutCall).toBeTruthy(); + expect( + (persistedLayoutCall?.[1] as { uiState?: { paneLayout?: { children?: Array } } }) + .uiState?.paneLayout?.children?.[1] + ).not.toHaveProperty("closeBehavior"); }); it("restores the saved global mobile session even when it is missing from the stale pane layout", async () => { @@ -2458,6 +3043,6 @@ describe("MobileShell Phase 2 workspace", () => { reconnectAttempts: 2, }); - expect(await screen.findByText("正在重新连接...")).toBeInTheDocument(); + expect(await screen.findByText("连接已断开,正在重新连接...")).toBeInTheDocument(); }); }); From 434ac10d20dfa569d6d15ab70be10fe31e888edf Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 15:44:20 +0000 Subject: [PATCH 12/28] fix(web): keep offline recovery out of session gate --- .../web/src/app/providers.lifecycle.test.tsx | 101 ++++++++++++++++- packages/web/src/app/providers.tsx | 4 +- packages/web/src/hooks/use-activation.ts | 43 +++++++- .../web/src/shells/desktop-shell.test.tsx | 18 ++- .../shared/connection-status-banner.test.tsx | 104 ++++++++++++++++++ .../shared/connection-status-banner.tsx | 54 ++++++++- packages/web/src/ws/__tests__/client.test.ts | 36 ++++++ packages/web/src/ws/client.ts | 2 +- packages/web/src/ws/reconnect.ts | 2 +- 9 files changed, 344 insertions(+), 20 deletions(-) create mode 100644 packages/web/src/shells/shared/connection-status-banner.test.tsx diff --git a/packages/web/src/app/providers.lifecycle.test.tsx b/packages/web/src/app/providers.lifecycle.test.tsx index 39e4d4ac1..7ada4d04f 100644 --- a/packages/web/src/app/providers.lifecycle.test.tsx +++ b/packages/web/src/app/providers.lifecycle.test.tsx @@ -68,6 +68,13 @@ function createWsSendCommandMock( handler?: (op: string, args: unknown) => Promise | unknown ) { return vi.fn().mockImplementation(async (op: string, args: unknown) => { + if (handler) { + const handled = await handler(op, args); + if (handled !== undefined) { + return handled; + } + } + if (op === "activation.claim") { return { active: true, @@ -80,10 +87,6 @@ function createWsSendCommandMock( return { ok: true }; } - if (handler) { - return await handler(op, args); - } - return undefined; }); } @@ -562,6 +565,96 @@ describe("AppProviders lifecycle recovery", () => { ).toHaveLength(0); }); + it("does not gate activation when websocket reconnect fails", async () => { + const store = createStore(); + wsState.client!.connect = vi.fn().mockRejectedValue(new Error("connect failed")); + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + await vi.waitFor(() => { + expect(store.get(activationStatusAtom)).not.toBe("gated"); + expect(store.get(activationReasonAtom)).toBeNull(); + }); + }); + + it("does not gate activation when activation.claim fails", async () => { + const store = createStore(); + wsState.client!.sendCommand = createWsSendCommandMock(async (op: string) => { + if (op === "activation.claim") { + throw new Error("claim failed"); + } + + return undefined; + }); + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(store.get(activationStatusAtom)).not.toBe("gated"); + expect(store.get(activationReasonAtom)).toBeNull(); + }); + }); + + it("retries activation.claim after a transient failure while connected", async () => { + const store = createStore(); + let claimAttempts = 0; + vi.useFakeTimers(); + wsState.client!.sendCommand = createWsSendCommandMock(async (op: string) => { + if (op === "activation.claim") { + claimAttempts += 1; + if (claimAttempts === 1) { + throw new Error("claim failed"); + } + + return { + active: true, + generation: 2, + recoveryMode: "grace_recover", + }; + } + + return undefined; + }); + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(claimAttempts).toBe(1); + expect(store.get(activationStatusAtom)).toBe("idle"); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + + await vi.waitFor(() => { + expect(claimAttempts).toBe(2); + expect(store.get(activationStatusAtom)).toBe("active"); + expect(store.get(activationGenerationAtom)).toBe(2); + expect(store.get(activationReasonAtom)).toBeNull(); + }); + }); + it("disconnects and gates when activation.revoked is received", async () => { const store = createStore(); seedWorkspaces(store, ["ws-1"], "ws-1"); diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index a3990a592..ecde5a46a 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -463,7 +463,7 @@ export function AppProviders({ children }: AppProvidersProps) { // Track reconnect attempts if (status === "reconnecting") { setReconnectCount((count) => count + 1); - setLastReconnect(Date.now()); + setLastReconnect((previous) => previous ?? Date.now()); } // Reset writer status on disconnect @@ -472,6 +472,8 @@ export function AppProviders({ children }: AppProvidersProps) { } if (status === "connected") { + setReconnectCount(0); + setLastReconnect(null); syncWorkspaceActivity(true); } }; diff --git a/packages/web/src/hooks/use-activation.ts b/packages/web/src/hooks/use-activation.ts index b34a9fca5..d18bd27cd 100644 --- a/packages/web/src/hooks/use-activation.ts +++ b/packages/web/src/hooks/use-activation.ts @@ -14,6 +14,8 @@ interface ActivationClaimPayload { recoveryMode: "fresh" | "grace_recover" | "takeover"; } +const CLAIM_RETRY_DELAY_MS = 1_000; + export function useActivation() { const wsClient = useAtomValue(wsClientAtom); const connectionStatus = useAtomValue(connectionStatusAtom); @@ -22,6 +24,7 @@ export function useActivation() { const [generation, setGeneration] = useAtom(activationGenerationAtom); const setReason = useSetAtom(activationReasonAtom); const claimInFlightRef = useRef | null>(null); + const claimRetryTimerRef = useRef | null>(null); const claim = useCallback(async (): Promise => { if (!wsClient) { @@ -32,8 +35,8 @@ export function useActivation() { try { await wsClient.connect(); } catch { - setStatus("gated"); - setReason("reconnect_failed"); + setReason(null); + setStatus((current) => (current === "gated" ? current : "idle")); return false; } } @@ -54,9 +57,9 @@ export function useActivation() { setStatus("active"); return true; }) - .catch((error) => { - setStatus("gated"); - setReason(error instanceof Error ? error.message : "claim_failed"); + .catch(() => { + setReason(null); + setStatus((current) => (current === "gated" ? current : "idle")); return false; }) .finally(() => { @@ -67,8 +70,38 @@ export function useActivation() { return pending; }, [clientInstanceId, connectionStatus, setGeneration, setReason, setStatus, wsClient]); + useEffect(() => { + if (claimRetryTimerRef.current !== null) { + clearTimeout(claimRetryTimerRef.current); + claimRetryTimerRef.current = null; + } + + if (!wsClient || connectionStatus !== "connected" || status !== "idle") { + return; + } + + claimRetryTimerRef.current = setTimeout(() => { + claimRetryTimerRef.current = null; + if (!claimInFlightRef.current) { + void claim(); + } + }, CLAIM_RETRY_DELAY_MS); + + return () => { + if (claimRetryTimerRef.current !== null) { + clearTimeout(claimRetryTimerRef.current); + claimRetryTimerRef.current = null; + } + }; + }, [claim, connectionStatus, status, wsClient]); + useEffect(() => { return () => { + if (claimRetryTimerRef.current !== null) { + clearTimeout(claimRetryTimerRef.current); + claimRetryTimerRef.current = null; + } + if (!wsClient || generation === null) { return; } diff --git a/packages/web/src/shells/desktop-shell.test.tsx b/packages/web/src/shells/desktop-shell.test.tsx index b436f9f3f..9547b1ab9 100644 --- a/packages/web/src/shells/desktop-shell.test.tsx +++ b/packages/web/src/shells/desktop-shell.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { BrowserRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { activationStatusAtom } from "../atoms/activation"; +import { activationReasonAtom, activationStatusAtom } from "../atoms/activation"; import { authenticatedAtom, localeAtom } from "../atoms/app-ui"; import { authEnabledAtom, connectionStatusAtom, wsClientAtom } from "../atoms/connection"; import { @@ -313,7 +313,21 @@ describe("DesktopShell auth gating", () => { renderShell(store); - expect(screen.getByText("正在重新连接...")).toBeInTheDocument(); + expect(screen.getByText("连接已断开,正在重新连接...")).toBeInTheDocument(); + }); + + it("shows the displaced-session banner on desktop when activation is gated", () => { + const store = createStore(); + store.set(connectionStatusAtom, "disconnected"); + store.set(authEnabledAtom, false); + store.set(authenticatedAtom, true); + store.set(activationStatusAtom, "gated"); + store.set(activationReasonAtom, "displaced"); + + renderShell(store); + + expect(screen.getByText("另一个标签页已激活")).toBeInTheDocument(); + expect(screen.queryByText("连接已断开,正在重新连接...")).not.toBeInTheDocument(); }); it("renders SessionGatePage on /session-gate", () => { diff --git a/packages/web/src/shells/shared/connection-status-banner.test.tsx b/packages/web/src/shells/shared/connection-status-banner.test.tsx new file mode 100644 index 000000000..2b50944eb --- /dev/null +++ b/packages/web/src/shells/shared/connection-status-banner.test.tsx @@ -0,0 +1,104 @@ +import { act, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { activationReasonAtom, activationStatusAtom } from "../../atoms/activation"; +import { connectionStatusAtom, lastReconnectAttemptAtom } from "../../atoms/connection"; +import { ConnectionStatusBanner } from "./connection-status-banner"; + +function renderBanner() { + const store = createStore(); + + render( + + + + ); + + return store; +} + +describe("ConnectionStatusBanner", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders the unified reconnect message while reconnecting", () => { + const store = renderBanner(); + + act(() => { + store.set(connectionStatusAtom, "reconnecting"); + }); + + expect(screen.getByText("连接已断开,正在重新连接...")).toBeInTheDocument(); + }); + + it("shows the displaced-session message instead of reconnecting when activation is gated", () => { + const store = renderBanner(); + + act(() => { + store.set(activationStatusAtom, "gated"); + store.set(activationReasonAtom, "displaced"); + store.set(connectionStatusAtom, "disconnected"); + }); + + expect(screen.getByText("另一个标签页已激活")).toBeInTheDocument(); + expect(screen.queryByText("连接已断开,正在重新连接...")).not.toBeInTheDocument(); + }); + + it("shows the slow recovery hint after 25 seconds", () => { + const startedAt = new Date("2026-05-14T00:00:00.000Z").getTime(); + vi.setSystemTime(startedAt + 25_000); + const store = renderBanner(); + + act(() => { + store.set(connectionStatusAtom, "reconnecting"); + store.set(lastReconnectAttemptAtom, startedAt); + }); + + expect( + screen.getByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。") + ).toBeInTheDocument(); + }); + + it("reveals the slow recovery hint after time passes without another status update", () => { + const startedAt = new Date("2026-05-14T00:00:00.000Z").getTime(); + vi.setSystemTime(startedAt); + const store = renderBanner(); + + act(() => { + store.set(connectionStatusAtom, "reconnecting"); + store.set(lastReconnectAttemptAtom, startedAt); + }); + + expect( + screen.queryByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。") + ).not.toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(25_000); + }); + + expect( + screen.getByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。") + ).toBeInTheDocument(); + }); + + it("does not show the slow recovery hint before the threshold", () => { + const startedAt = new Date("2026-05-14T00:00:00.000Z").getTime(); + vi.setSystemTime(startedAt + 24_000); + const store = renderBanner(); + + act(() => { + store.set(connectionStatusAtom, "reconnecting"); + store.set(lastReconnectAttemptAtom, startedAt); + }); + + expect( + screen.queryByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。") + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/shells/shared/connection-status-banner.tsx b/packages/web/src/shells/shared/connection-status-banner.tsx index ad4795509..d9d9ab873 100644 --- a/packages/web/src/shells/shared/connection-status-banner.tsx +++ b/packages/web/src/shells/shared/connection-status-banner.tsx @@ -1,24 +1,66 @@ import { useAtomValue } from "jotai"; -import { connectionStatusAtom } from "../../atoms/connection"; +import { useEffect, useState } from "react"; +import { activationReasonAtom, activationStatusAtom } from "../../atoms/activation"; +import { connectionStatusAtom, lastReconnectAttemptAtom } from "../../atoms/connection"; + +const SLOW_RECOVERY_HINT_MS = 25_000; export function ConnectionStatusBanner() { + const activationStatus = useAtomValue(activationStatusAtom); + const activationReason = useAtomValue(activationReasonAtom); const connectionStatus = useAtomValue(connectionStatusAtom); + const lastReconnectAttempt = useAtomValue(lastReconnectAttemptAtom); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if ( + lastReconnectAttempt === null || + (connectionStatus !== "reconnecting" && connectionStatus !== "disconnected") + ) { + return; + } + + const remainingMs = lastReconnectAttempt + SLOW_RECOVERY_HINT_MS - Date.now(); + if (remainingMs <= 0) { + setNow(Date.now()); + return; + } + + const timer = setTimeout(() => { + setNow(Date.now()); + }, remainingMs); + + return () => { + clearTimeout(timer); + }; + }, [connectionStatus, lastReconnectAttempt]); if (connectionStatus === "connected" || connectionStatus === "connecting") { return null; } - if (connectionStatus === "reconnecting") { + if ( + connectionStatus === "rejected" || + (activationStatus === "gated" && activationReason === "displaced") + ) { return ( -
- 正在重新连接... +
+ 另一个标签页已激活
); } + const showSlowRecoveryHint = + lastReconnectAttempt !== null && + now - lastReconnectAttempt >= SLOW_RECOVERY_HINT_MS && + (connectionStatus === "reconnecting" || connectionStatus === "disconnected"); + return ( -
- {connectionStatus === "rejected" ? "另一个标签页已激活" : "连接已断开"} +
+ 连接已断开,正在重新连接... + {showSlowRecoveryHint ? ( + 连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。 + ) : null}
); } diff --git a/packages/web/src/ws/__tests__/client.test.ts b/packages/web/src/ws/__tests__/client.test.ts index 7911d18eb..ae0c381e7 100644 --- a/packages/web/src/ws/__tests__/client.test.ts +++ b/packages/web/src/ws/__tests__/client.test.ts @@ -1214,6 +1214,42 @@ describe("web WsClient", () => { } }); + it("keeps retrying reconnect attempts after extended outages", async () => { + vi.useFakeTimers(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const client = new WsClient("ws://127.0.0.1:4173/ws", { + baseDelayMs: 1, + maxDelayMs: 1, + }); + const connectPromise = client.connect(); + const firstSocket = MockWebSocket.instances[0]!; + firstSocket.triggerOpen(); + await connectPromise; + + firstSocket.triggerClose(1006, "network_lost"); + + expect(client.getStatus()).toBe("reconnecting"); + + for (let attempt = 0; attempt < 35; attempt += 1) { + await vi.advanceTimersByTimeAsync(1); + + const retrySocket = MockWebSocket.instances[attempt + 1]; + expect(retrySocket).toBeTruthy(); + retrySocket?.triggerClose(1006, "network_lost"); + + expect(client.getStatus()).toBe("reconnecting"); + } + + expect(MockWebSocket.instances).toHaveLength(36); + expect(client.getStatus()).toBe("reconnecting"); + } finally { + consoleError.mockRestore(); + vi.useRealTimers(); + } + }); + it("marks the client rejected without reconnecting after single-active displacement", async () => { vi.useFakeTimers(); diff --git a/packages/web/src/ws/client.ts b/packages/web/src/ws/client.ts index 8624606d9..15133f8fa 100644 --- a/packages/web/src/ws/client.ts +++ b/packages/web/src/ws/client.ts @@ -69,7 +69,7 @@ interface ReconnectConfig { } const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = { - maxAttempts: 30, + maxAttempts: Number.POSITIVE_INFINITY, baseDelayMs: 1000, maxDelayMs: 30000, }; diff --git a/packages/web/src/ws/reconnect.ts b/packages/web/src/ws/reconnect.ts index ae359dd5f..2c1308c8b 100644 --- a/packages/web/src/ws/reconnect.ts +++ b/packages/web/src/ws/reconnect.ts @@ -19,7 +19,7 @@ export interface ReconnectConfig { } const DEFAULT_CONFIG: ReconnectConfig = { - maxAttempts: 30, + maxAttempts: Number.POSITIVE_INFINITY, baseDelayMs: 1000, maxDelayMs: 30000, jitterMs: 100, From 000b2b1ddb82cca0fcb340a2ac8632d6643ac58b Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 16:09:00 +0000 Subject: [PATCH 13/28] test(server): add tree and watcher regression coverage --- .../src/__tests__/file-commands.test.ts | 27 +++++++++++++++++++ .../server/src/__tests__/fs/watcher.test.ts | 18 ++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/server/src/__tests__/file-commands.test.ts b/packages/server/src/__tests__/file-commands.test.ts index 37e59cea6..11f7c53da 100644 --- a/packages/server/src/__tests__/file-commands.test.ts +++ b/packages/server/src/__tests__/file-commands.test.ts @@ -141,6 +141,33 @@ describe("File Commands", () => { expect(files).toHaveLength(0); }); + it("shows dotfiles and node_modules in file.readTree while still hiding .git", async () => { + await writeFile(join(testDir, ".gitignore"), "*.log\nnode_modules/\n"); + await writeFile(join(testDir, ".env"), "secret\n"); + await writeFile(join(testDir, "ignored.log"), "log\n"); + await mkdir(join(testDir, "node_modules", "pkg"), { recursive: true }); + await mkdir(join(testDir, ".git"), { recursive: true }); + + const result = await dispatch( + { + kind: "command", + id: "file-tree-1", + op: "file.readTree", + args: { + workspaceId, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + const children = (result.data as { children: Array<{ name: string }> }).children; + expect(children.some((item) => item.name === ".env")).toBe(true); + expect(children.some((item) => item.name === "ignored.log")).toBe(true); + expect(children.some((item) => item.name === "node_modules")).toBe(true); + expect(children.some((item) => item.name === ".git")).toBe(false); + }); + it("emits fs.dirty after file writes", async () => { const result = await dispatch( { diff --git a/packages/server/src/__tests__/fs/watcher.test.ts b/packages/server/src/__tests__/fs/watcher.test.ts index 13f38c787..1259d4b4b 100644 --- a/packages/server/src/__tests__/fs/watcher.test.ts +++ b/packages/server/src/__tests__/fs/watcher.test.ts @@ -7,7 +7,7 @@ import { Topics } from "@coder-studio/core"; import chokidar, { type FSWatcher } from "chokidar"; -import { mkdtemp, rm } from "fs/promises"; +import { mkdtemp, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -93,6 +93,22 @@ describe("WorkspaceWatcher", () => { expect(ignored?.(join(testDir, "src/index.ts"))).toBe(false); }); + it("continues to respect .gitignore entries after tree visibility was relaxed", async () => { + await writeFile(join(testDir, ".gitignore"), "dist/\n*.log\n"); + + new WorkspaceWatcher("test-workspace-id", testDir, broadcaster); + + expect(watchSpy).toHaveBeenCalledTimes(1); + const options = watchSpy.mock.calls[0]?.[1]; + const ignored = options?.ignored; + + expect(typeof ignored).toBe("function"); + expect(ignored?.(join(testDir, "dist", "bundle.js"))).toBe(true); + expect(ignored?.(join(testDir, "debug.log"))).toBe(true); + expect(ignored?.(join(testDir, ".git", "index"))).toBe(false); + expect(ignored?.(join(testDir, "src", "index.ts"))).toBe(false); + }); + it("broadcasts fs.dirty after git metadata events settle", async () => { vi.useFakeTimers(); new WorkspaceWatcher("test-workspace-id", testDir, broadcaster); From 827557ac9b4888154c1045ba81e3c46a3163a483 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 16:09:39 +0000 Subject: [PATCH 14/28] docs(superpowers): add implementation plans and specs --- .../2026-05-11-supervisor-execution-policy.md | 1217 +++++++++++++++ ...sor-target-scoped-memory-implementation.md | 1352 +++++++++++++++++ ...2026-05-13-mobile-terminal-paste-upload.md | 180 +++ ...-13-readme-top-structure-implementation.md | 134 ++ ...ion-first-activation-phase-1-foundation.md | 114 ++ ...on-first-activation-phase-2-first-value.md | 113 ++ ...-activation-phase-3-mobile-continuation.md | 99 ++ ...activation-phase-4-return-and-retention.md | 115 ++ .../2026-05-14-conversion-first-activation.md | 991 ++++++++++++ ...-05-14-offline-recovery-vs-session-gate.md | 396 +++++ ...2026-05-14-workspace-last-viewed-target.md | 861 +++++++++++ .../2026-05-13-readme-top-structure-design.md | 196 +++ 12 files changed, 5768 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-11-supervisor-execution-policy.md create mode 100644 docs/superpowers/plans/2026-05-12-supervisor-target-scoped-memory-implementation.md create mode 100644 docs/superpowers/plans/2026-05-13-mobile-terminal-paste-upload.md create mode 100644 docs/superpowers/plans/2026-05-13-readme-top-structure-implementation.md create mode 100644 docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-1-foundation.md create mode 100644 docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-2-first-value.md create mode 100644 docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-3-mobile-continuation.md create mode 100644 docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-4-return-and-retention.md create mode 100644 docs/superpowers/plans/2026-05-14-conversion-first-activation.md create mode 100644 docs/superpowers/plans/2026-05-14-offline-recovery-vs-session-gate.md create mode 100644 docs/superpowers/plans/2026-05-14-workspace-last-viewed-target.md create mode 100644 docs/superpowers/specs/2026-05-13-readme-top-structure-design.md diff --git a/docs/superpowers/plans/2026-05-11-supervisor-execution-policy.md b/docs/superpowers/plans/2026-05-11-supervisor-execution-policy.md new file mode 100644 index 000000000..bb14beb97 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-supervisor-execution-policy.md @@ -0,0 +1,1217 @@ +# Supervisor Execution Policy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add per-supervisor model override, max-supervision cap, one-shot scheduled execution, in-flight pause support, global retry policy, and lightweight `v1 -> v2` database upgrade support without reintroducing the legacy migration chain. + +**Architecture:** Keep the current latest-schema snapshot model, but add a narrow startup upgrader for known `v1 -> v2` databases plus a CLI/server preflight path for incompatible schemas. Extend supervisor persistence with new execution-policy fields, add attempt-level retry history, refactor the manager into cycle + retry execution flow, and upgrade the scheduler from event-only to event + one-shot timer. The web UI remains server-authoritative: supervisor dialog edits persisted session-level policy fields, while Settings owns global retry/evaluation timeout configuration. + +**Tech Stack:** TypeScript, SQLite via `node:sqlite`, Vitest, existing WebSocket commands, Jotai, React, Fastify, PM2-backed CLI startup flow. + +--- + +## File Structure + +### Storage startup and compatibility +- Modify: `packages/server/src/storage/db.ts` — replace strict-only open path with `v1 -> v2` upgrade + incompatible-schema detection result handling. +- Modify: `packages/server/src/storage/migrations/001_init.sql` — update latest schema snapshot to include supervisor execution-policy columns and `supervisor_cycle_attempts`. +- Modify: `packages/server/src/storage/index.ts` — export any new repo types needed by tests/consumers. +- Create: `packages/server/src/storage/schema-version.ts` — helper constants plus exact current/v1 schema fingerprint utilities; do not rely on `PRAGMA user_version` alone because existing latest v1 databases were created before schema version stamping. +- Test: `packages/server/src/__tests__/db.test.ts` + +### Supervisor domain and repositories +- Modify: `packages/core/src/domain/supervisor.ts` — new state/trigger/status types and persisted fields. +- Modify: `packages/server/src/storage/repositories/supervisor-repo.ts` — CRUD for new supervisor columns. +- Modify: `packages/server/src/storage/repositories/supervisor-cycle-repo.ts` — `scheduled`/`cancelled` compatibility plus cycle lookups as needed. +- Create: `packages/server/src/storage/repositories/supervisor-cycle-attempt-repo.ts` — attempt persistence. +- Test: `packages/server/src/__tests__/supervisor-repo.test.ts` + +### Runtime execution +- Modify: `packages/server/src/supervisor/settings.ts` — add retry-related Settings keys and coercion helpers. +- Modify: `packages/server/src/supervisor/evaluator.ts` — optional per-supervisor model override, objective-complete detection helper. +- Modify: `packages/server/src/supervisor/scheduler.ts` — keep `turn_completed`, add one-shot time trigger support. +- Modify: `packages/server/src/supervisor/manager.ts` — state machine, retry loop, stopped state, attempt recording, in-flight pause/cancel. +- Test: `packages/server/src/supervisor/evaluator.test.ts` +- Test: `packages/server/src/supervisor/scheduler.test.ts` +- Test: `packages/server/src/__tests__/supervisor-manager.test.ts` + +### Commands, server assembly, and CLI startup +- Modify: `packages/server/src/commands/supervisor.ts` — create/update payloads for model/cap/schedule fields. +- Modify: `packages/server/src/commands/settings.ts` — new global retry settings validation + persistence. +- Modify: `packages/server/src/server.ts` — integrate schema-open result handling if startup API changes. +- Modify: `packages/cli/src/server-runner.ts` — preflight DB compatibility before foreground server start. +- Modify: `packages/cli/src/cli.ts` — prompt to delete and rebuild on incompatible schema for foreground/background/open flows. +- Modify: `packages/cli/src/prompts.ts` — add reusable destructive confirmation helper if needed. +- Test: `packages/server/src/__tests__/supervisor-commands.test.ts` +- Test: `packages/server/src/commands/settings.test.ts` +- Test: `packages/cli/src/bin.test.ts` +- Test: `packages/cli/src/server-runner.test.ts` + +### Web UI +- Modify: `packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts` — dialog draft state for new supervisor fields and command payloads. +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx` — optional model input, max count input, one-shot schedule input. +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx` +- Modify: `packages/web/src/features/supervisor/views/shared/supervisor-card.tsx` — render stopped state, stop reason, and new summary fields. +- Modify: `packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` — add retry settings inputs and payload wiring. +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/web/src/locales/en.json` + +## Task 1: Add Lightweight Schema Versioning And `v1 -> v2` Upgrade + +**Files:** +- Create: `packages/server/src/storage/schema-version.ts` +- Modify: `packages/server/src/storage/db.ts` +- Modify: `packages/server/src/storage/migrations/001_init.sql` +- Test: `packages/server/src/__tests__/db.test.ts` + +- [ ] **Step 1: Write failing DB compatibility tests** + +```ts +import { DatabaseSync } from "node:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { openDatabase } from "../storage/db.js"; + +describe("database schema upgrade", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "db-upgrade-test-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("upgrades a known v1 supervisor schema to v2 on open even when user_version is unset", () => { + const dbPath = join(tempDir, "upgrade.db"); + const seed = new DatabaseSync(dbPath); + seed.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE workspaces (id TEXT PRIMARY KEY, path TEXT NOT NULL, target_runtime TEXT NOT NULL, opened_at INTEGER NOT NULL, last_active_at INTEGER NOT NULL, ui_state TEXT NOT NULL); + CREATE TABLE terminals (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, kind TEXT NOT NULL, cwd TEXT NOT NULL, argv TEXT NOT NULL, cols INTEGER NOT NULL, rows INTEGER NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE); + CREATE TABLE sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, terminal_id TEXT NOT NULL, provider_id TEXT NOT NULL, capability TEXT NOT NULL, state TEXT NOT NULL, started_at INTEGER NOT NULL, last_active_at INTEGER NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, FOREIGN KEY (terminal_id) REFERENCES terminals(id) ON DELETE CASCADE); + CREATE TABLE user_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE supervisors ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + workspace_id TEXT NOT NULL, + state TEXT NOT NULL, + objective TEXT NOT NULL, + evaluator_provider_id TEXT NOT NULL, + last_cycle_at INTEGER, + last_evaluated_turn_id TEXT, + error_reason TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE supervisor_cycles ( + id TEXT PRIMARY KEY, + supervisor_id TEXT NOT NULL, + session_id TEXT NOT NULL, + status TEXT NOT NULL, + trigger TEXT NOT NULL, + evidence_source TEXT NOT NULL, + objective TEXT NOT NULL, + evaluator_provider_id TEXT NOT NULL, + turn_id TEXT, + progress INTEGER, + result TEXT, + injected_guidance TEXT, + error_reason TEXT, + created_at INTEGER NOT NULL, + completed_at INTEGER + ); + `); + seed.close(); + + const db = openDatabase(dbPath); + const columns = db.prepare("PRAGMA table_info(supervisors)").all() as Array<{ name: string }>; + + expect(columns.map((column) => column.name)).toEqual( + expect.arrayContaining([ + "evaluator_model", + "max_supervision_count", + "completed_supervision_count", + "scheduled_at", + "stop_reason", + ]) + ); + expect( + db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='supervisor_cycle_attempts'").get() + ).toBeTruthy(); + expect((db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(2); + }); + + it("throws a typed incompatible-schema error for unknown schemas", () => { + const dbPath = join(tempDir, "unknown.db"); + const seed = new DatabaseSync(dbPath); + seed.exec(` + CREATE TABLE random_table (id TEXT PRIMARY KEY); + PRAGMA user_version = 99; + `); + seed.close(); + + expect(() => openDatabase(dbPath)).toThrow(/db_incompatible_schema/); + }); +}); +``` + +- [ ] **Step 2: Run the DB test file and confirm the new cases fail** + +Run: `pnpm vitest packages/server/src/__tests__/db.test.ts -t "database schema upgrade" --run` +Expected: FAIL because `openDatabase()` currently only initializes-or-rejects; it cannot recognize the known latest v1 schema structurally when `user_version` is still `0`, and it cannot produce the new typed incompatible-schema branch. + +- [ ] **Step 3: Add a narrow schema version helper** + +```ts +// packages/server/src/storage/schema-version.ts +import type { Database } from "./database.js"; + +export const CURRENT_SCHEMA_VERSION = 2; +export type KnownSchemaKind = "empty" | "current" | "v1" | "incompatible"; + +export function getUserVersion(db: Database): number { + const row = db.prepare("PRAGMA user_version").get() as { user_version: number }; + return row.user_version ?? 0; +} + +export function setUserVersion(db: Database, version: number): void { + db.exec(`PRAGMA user_version = ${version}`); +} + +export function classifyKnownSchema(db: Database): KnownSchemaKind { + // Build exact normalized sqlite_master signatures for the current schema + // and the last shipped v1 schema snapshot, then compare the opened DB to + // those fingerprints. Existing v1 databases may still have user_version=0, + // so structure is the source of truth for upgrade eligibility. + return "incompatible"; +} +``` + +- [ ] **Step 4: Implement explicit `v1 -> v2` upgrade in `db.ts`** + +```ts +function initializeOrUpgradeSchema(db: Database, dbPath: string): void { + switch (classifyKnownSchema(db)) { + case "empty": + initializeSchema(db); + return; + case "current": + if (getUserVersion(db) !== CURRENT_SCHEMA_VERSION) { + setUserVersion(db, CURRENT_SCHEMA_VERSION); + } + return; + case "v1": + upgradeKnownV1Schema(db); + return; + default: + throw createIncompatibleSchemaError(dbPath, "schema fingerprint is not a supported version"); + } +} + +function upgradeKnownV1Schema(db: Database): void { + withTransaction(db, () => { + db.exec(` + ALTER TABLE supervisors ADD COLUMN evaluator_model TEXT; + ALTER TABLE supervisors ADD COLUMN max_supervision_count INTEGER NOT NULL DEFAULT 0; + ALTER TABLE supervisors ADD COLUMN completed_supervision_count INTEGER NOT NULL DEFAULT 0; + ALTER TABLE supervisors ADD COLUMN scheduled_at INTEGER; + ALTER TABLE supervisors ADD COLUMN stop_reason TEXT; + + CREATE TABLE IF NOT EXISTS supervisor_cycle_attempts ( + id TEXT PRIMARY KEY, + cycle_id TEXT NOT NULL, + attempt_index INTEGER NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + completed_at INTEGER, + error_reason TEXT, + provider_model TEXT, + FOREIGN KEY (cycle_id) REFERENCES supervisor_cycles(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_supervisor_cycle_attempts_cycle + ON supervisor_cycle_attempts(cycle_id, attempt_index ASC); + `); + setUserVersion(db, CURRENT_SCHEMA_VERSION); + }); +} +``` + +A known v1 database must be detected by exact schema fingerprint, not just `PRAGMA user_version = 1`. + +- [ ] **Step 5: Return a typed incompatible-schema error instead of a generic mismatch** + +```ts +throw new Error( + JSON.stringify({ + code: "db_incompatible_schema", + dbPath, + message: `Database schema is incompatible at ${dbPath}: ${mismatch}`, + }) +); +``` + +Use a small helper that emits a stable string containing `db_incompatible_schema` so CLI-side detection can parse it reliably without introducing a broad error framework here. + +- [ ] **Step 6: Update latest schema snapshot to v2** + +```sql +CREATE TABLE IF NOT EXISTS supervisors ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + workspace_id TEXT NOT NULL, + state TEXT NOT NULL, + objective TEXT NOT NULL, + evaluator_provider_id TEXT NOT NULL, + evaluator_model TEXT, + max_supervision_count INTEGER NOT NULL DEFAULT 0, + completed_supervision_count INTEGER NOT NULL DEFAULT 0, + scheduled_at INTEGER, + stop_reason TEXT, + last_cycle_at INTEGER, + last_evaluated_turn_id TEXT, + error_reason TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (session_id, workspace_id) REFERENCES sessions(id, workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS supervisor_cycle_attempts ( + id TEXT PRIMARY KEY, + cycle_id TEXT NOT NULL, + attempt_index INTEGER NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + completed_at INTEGER, + error_reason TEXT, + provider_model TEXT, + FOREIGN KEY (cycle_id) REFERENCES supervisor_cycles(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_supervisor_cycle_attempts_cycle + ON supervisor_cycle_attempts(cycle_id, attempt_index ASC); + +PRAGMA user_version = 2; +``` + +- [ ] **Step 7: Re-run DB tests** + +Run: `pnpm vitest packages/server/src/__tests__/db.test.ts --run` +Expected: PASS with new v2 schema coverage, v1 upgrade support, and typed incompatible-schema failure handling. + +- [ ] **Step 8: Commit** + +```bash +git add packages/server/src/storage/schema-version.ts packages/server/src/storage/db.ts packages/server/src/storage/migrations/001_init.sql packages/server/src/__tests__/db.test.ts +git commit -m "feat: add supervisor schema v2 upgrade" +``` + +## Task 2: Add CLI/Foreground Preflight And Delete-Rebuild Flow For Incompatible Schemas + +**Files:** +- Modify: `packages/server/src/storage/db.ts` +- Modify: `packages/cli/src/server-runner.ts` +- Modify: `packages/cli/src/cli.ts` +- Modify: `packages/cli/src/prompts.ts` +- Test: `packages/cli/src/server-runner.test.ts` +- Test: `packages/cli/src/bin.test.ts` + +- [ ] **Step 1: Write failing CLI tests for incompatible schema handling** + +```ts +it("prompts to delete and rebuild when foreground startup sees an incompatible schema", async () => { + startServer.mockRejectedValueOnce( + new Error('{"code":"db_incompatible_schema","dbPath":"/tmp/coder-studio.db","message":"schema mismatch"}') + ); + confirmYesNo.mockResolvedValue(true); + + await expect(main(["serve", "--foreground"])).resolves.toBeUndefined(); + + expect(confirmYesNo).toHaveBeenCalledWith( + expect.stringContaining("Delete and rebuild the local database") + ); +}); +``` + +Add a matching background `serve` or `open` test that verifies preflight runs before PM2 startup and avoids launching a crashing managed process. + +- [ ] **Step 2: Run the CLI tests and confirm failure** + +Run: `pnpm vitest packages/cli/src/bin.test.ts packages/cli/src/server-runner.test.ts --run` +Expected: FAIL because there is no incompatible-schema parsing, no delete-rebuild prompt, and no preflight path before managed startup. + +- [ ] **Step 3: Add a reusable incompatible-schema detector** + +```ts +export interface IncompatibleSchemaErrorPayload { + code: "db_incompatible_schema"; + dbPath: string; + message: string; +} + +export function parseIncompatibleSchemaError(error: unknown): IncompatibleSchemaErrorPayload | null { + if (!(error instanceof Error)) return null; + try { + const parsed = JSON.parse(error.message) as IncompatibleSchemaErrorPayload; + return parsed.code === "db_incompatible_schema" ? parsed : null; + } catch { + return null; + } +} +``` + +- [ ] **Step 4: Add a server-side preflight helper in `server-runner.ts`** + +```ts +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { + closeDatabase, + openDatabase, + parseServerConfig, +} from "@coder-studio/server"; + +export const verifyLocalDatabaseCompatibility = (): void => { + const config = parseServerConfig(buildServerConfig()); + if (config.dataDir !== ":memory:") { + mkdirSync(dirname(config.dataDir), { recursive: true }); + } + const db = openDatabase(config.dataDir); + closeDatabase(db); +}; +``` + +Keep this helper on the exact same saved-config resolution path as `startServer()`. Do not preflight a different DB location than the foreground server would open. + +- [ ] **Step 5: Add delete-and-rebuild prompt flow in `cli.ts`** + +```ts +async function handleIncompatibleSchema(error: unknown): Promise { + const payload = parseIncompatibleSchemaError(error); + if (!payload) return false; + + const approved = isInteractiveSession() + ? await confirmYesNo( + `Local database is incompatible at ${payload.dbPath}. Delete and rebuild it? [y/N] ` + ) + : false; + + if (!approved) { + throw new Error(payload.message); + } + + rmSync(payload.dbPath, { force: true }); + return true; +} +``` + +For `serve --foreground`, catch startup failure, prompt once, delete DB if approved, then retry `startServer()` once. + +For managed `serve` / `open`, call `verifyLocalDatabaseCompatibility()` before `startManagedServerFlow()`. If incompatible, run the same prompt/delete path before launching PM2. + +- [ ] **Step 6: Re-run CLI tests** + +Run: `pnpm vitest packages/cli/src/bin.test.ts packages/cli/src/server-runner.test.ts --run` +Expected: PASS with prompt-driven delete-rebuild flow in interactive mode and no managed-server launch before compatibility preflight. + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/cli.ts packages/cli/src/prompts.ts packages/cli/src/server-runner.ts packages/cli/src/bin.test.ts packages/cli/src/server-runner.test.ts +git commit -m "feat: prompt to rebuild incompatible databases" +``` + +## Task 3: Extend Core Supervisor Types And Repositories + +**Files:** +- Modify: `packages/core/src/domain/supervisor.ts` +- Modify: `packages/server/src/storage/repositories/supervisor-repo.ts` +- Modify: `packages/server/src/storage/repositories/supervisor-cycle-repo.ts` +- Create: `packages/server/src/storage/repositories/supervisor-cycle-attempt-repo.ts` +- Modify: `packages/server/src/storage/index.ts` +- Test: `packages/server/src/__tests__/supervisor-repo.test.ts` + +- [ ] **Step 1: Write failing repository tests for new persisted fields and attempt rows** + +```ts +it("persists evaluatorModel, maxSupervisionCount, completedSupervisionCount, scheduledAt, and stopReason", () => { + supervisorRepo.create({ + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + state: "idle", + objective: "Ship supervisor execution policy", + evaluatorProviderId: "codex", + evaluatorModel: "o3", + maxSupervisionCount: 8, + completedSupervisionCount: 3, + scheduledAt: 1_746_950_400_000, + stopReason: undefined, + createdAt: 10, + updatedAt: 10, + }); + + expect(supervisorRepo.findById("sup-1")).toEqual( + expect.objectContaining({ + evaluatorModel: "o3", + maxSupervisionCount: 8, + completedSupervisionCount: 3, + scheduledAt: 1_746_950_400_000, + }) + ); +}); + +it("stores attempt history rows in order", () => { + attemptRepo.create({ + id: "attempt-1", + cycleId: "cycle-1", + attemptIndex: 0, + status: "failed", + startedAt: 10, + completedAt: 12, + errorReason: "timeout", + providerModel: "o3", + }); + attemptRepo.create({ + id: "attempt-2", + cycleId: "cycle-1", + attemptIndex: 1, + status: "completed", + startedAt: 20, + completedAt: 21, + providerModel: "o3", + }); + + expect(attemptRepo.listForCycle("cycle-1").map((row) => row.attemptIndex)).toEqual([0, 1]); +}); +``` + +- [ ] **Step 2: Run repository tests and confirm failure** + +Run: `pnpm vitest packages/server/src/__tests__/supervisor-repo.test.ts --run` +Expected: FAIL because supervisor rows do not have the new columns and the attempt repo does not exist. + +- [ ] **Step 3: Extend the core domain model** + +```ts +export type SupervisorState = + | "inactive" + | "idle" + | "evaluating" + | "injecting" + | "paused" + | "error" + | "stopped"; + +export type CycleStatus = + | "queued" + | "evaluating" + | "completed" + | "injected" + | "failed" + | "cancelled"; + +export type CycleTrigger = "turn_completed" | "manual" | "scheduled"; + +export interface Supervisor { + id: string; + sessionId: string; + workspaceId: string; + state: SupervisorState; + objective: string; + evaluatorProviderId: string; + evaluatorModel?: string; + maxSupervisionCount: number; + completedSupervisionCount: number; + scheduledAt?: number; + stopReason?: "objective_complete" | "max_supervision_count_reached"; + cycles: SupervisorCycle[]; + lastCycleAt?: number; + lastEvaluatedTurnId?: string; + errorReason?: string; + createdAt: number; + updatedAt: number; +} +``` + +- [ ] **Step 4: Update supervisor repo row mapping and patches** + +```ts +interface SupervisorRow { + evaluator_model: string | null; + max_supervision_count: number; + completed_supervision_count: number; + scheduled_at: number | null; + stop_reason: string | null; +} + +export interface NewSupervisor { + evaluatorModel?: string; + maxSupervisionCount: number; + completedSupervisionCount: number; + scheduledAt?: number; + stopReason?: "objective_complete" | "max_supervision_count_reached"; +} +``` + +Include matching nullable update patch support for clearing `evaluatorModel`, `scheduledAt`, and `stopReason`. + +- [ ] **Step 5: Create attempt repo** + +```ts +export interface SupervisorCycleAttempt { + id: string; + cycleId: string; + attemptIndex: number; + status: "evaluating" | "failed" | "completed" | "cancelled"; + startedAt: number; + completedAt?: number; + errorReason?: string; + providerModel?: string; +} + +export class SupervisorCycleAttemptRepo { + constructor(private readonly db: Database) {} + + create(input: SupervisorCycleAttempt): SupervisorCycleAttempt { /* insert + find */ } + update(id: string, patch: SupervisorCycleAttemptPatch): SupervisorCycleAttempt { /* update */ } + listForCycle(cycleId: string): SupervisorCycleAttempt[] { /* order by attempt_index asc */ } + deleteForCycle(cycleId: string): void { /* cleanup helper */ } +} +``` + +- [ ] **Step 6: Re-run repository tests** + +Run: `pnpm vitest packages/server/src/__tests__/supervisor-repo.test.ts --run` +Expected: PASS with persisted v2 supervisor fields and attempt-row storage. + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src/domain/supervisor.ts packages/server/src/storage/repositories/supervisor-repo.ts packages/server/src/storage/repositories/supervisor-cycle-repo.ts packages/server/src/storage/repositories/supervisor-cycle-attempt-repo.ts packages/server/src/storage/index.ts packages/server/src/__tests__/supervisor-repo.test.ts +git commit -m "feat: persist supervisor execution policy fields" +``` + +## Task 4: Add Global Retry Settings And Settings Validation + +**Files:** +- Modify: `packages/server/src/supervisor/settings.ts` +- Modify: `packages/server/src/commands/settings.ts` +- Test: `packages/server/src/commands/settings.test.ts` +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/web/src/locales/en.json` + +- [ ] **Step 1: Write failing Settings command tests for retry fields** + +```ts +it("accepts supervisor retry settings", async () => { + const result = await dispatch( + { + kind: "command", + id: "settings-1", + op: "settings.update", + args: { + settings: { + supervisor: { + evaluationTimeoutSec: 600, + retryEnabled: true, + retryMaxCount: 3, + retryDelaySec: 10, + retryOnTimeout: true, + retryOnEvaluatorError: false, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); +}); +``` + +Add a validation failure for `retryDelaySec: 0` or negative retry counts. + +- [ ] **Step 2: Run settings tests and confirm failure** + +Run: `pnpm vitest packages/server/src/commands/settings.test.ts packages/web/src/features/settings/components/settings-page.test.tsx --run` +Expected: FAIL because the settings schema and page only understand evaluation timeout. + +- [ ] **Step 3: Add retry key constants and resolvers** + +```ts +export const SUPERVISOR_RETRY_ENABLED_SETTING_KEY = "supervisor.retryEnabled"; +export const SUPERVISOR_RETRY_MAX_COUNT_SETTING_KEY = "supervisor.retryMaxCount"; +export const SUPERVISOR_RETRY_DELAY_SEC_SETTING_KEY = "supervisor.retryDelaySec"; +export const SUPERVISOR_RETRY_ON_TIMEOUT_SETTING_KEY = "supervisor.retryOnTimeout"; +export const SUPERVISOR_RETRY_ON_EVALUATOR_ERROR_SETTING_KEY = + "supervisor.retryOnEvaluatorError"; + +export interface SupervisorRetrySettings { + retryEnabled: boolean; + retryMaxCount: number; + retryDelaySec: number; + retryOnTimeout: boolean; + retryOnEvaluatorError: boolean; +} +``` + +- [ ] **Step 4: Extend `settings.update` schema** + +```ts +supervisor: z + .object({ + evaluationTimeoutSec: z.number().int().min(1).max(MAX_SUPERVISOR_EVALUATION_TIMEOUT_SEC).optional(), + retryEnabled: z.boolean().optional(), + retryMaxCount: z.number().int().min(0).max(20).optional(), + retryDelaySec: z.number().int().min(1).max(3600).optional(), + retryOnTimeout: z.boolean().optional(), + retryOnEvaluatorError: z.boolean().optional(), + }) + .optional(), +``` + +- [ ] **Step 5: Add settings page controls** + +Use the existing supervisor section in `SettingsPage` and add: + +```tsx + + + + + +``` + +Persist them through the same `settings.update` payload as evaluation timeout. + +- [ ] **Step 6: Re-run settings tests** + +Run: `pnpm vitest packages/server/src/commands/settings.test.ts packages/web/src/features/settings/components/settings-page.test.tsx --run` +Expected: PASS with retry settings validation and UI payload coverage. + +- [ ] **Step 7: Commit** + +```bash +git add packages/server/src/supervisor/settings.ts packages/server/src/commands/settings.ts packages/server/src/commands/settings.test.ts packages/web/src/features/settings/components/settings-page.tsx packages/web/src/features/settings/components/settings-page.test.tsx packages/web/src/locales/zh.json packages/web/src/locales/en.json +git commit -m "feat: add global supervisor retry settings" +``` + +## Task 5: Extend Supervisor Commands And Dialog Draft State + +**Files:** +- Modify: `packages/server/src/commands/supervisor.ts` +- Test: `packages/server/src/__tests__/supervisor-commands.test.ts` +- Modify: `packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts` +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx` +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/web/src/locales/en.json` + +- [ ] **Step 1: Write failing command/UI tests for model, max count, and schedule fields** + +```ts +it("passes evaluatorModel, maxSupervisionCount, and scheduledAt through supervisor.create", async () => { + const result = await dispatch( + { + kind: "command", + id: "cmd-1", + op: "supervisor.create", + args: { + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Ship execution policy", + evaluatorProviderId: "codex", + evaluatorModel: "o3", + maxSupervisionCount: 5, + scheduledAt: 1_746_950_400_000, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(supervisorMgr.create).toHaveBeenCalledWith( + expect.objectContaining({ + evaluatorModel: "o3", + maxSupervisionCount: 5, + scheduledAt: 1_746_950_400_000, + }) + ); +}); +``` + +Add a React test that fills the new fields in `ObjectiveDialogContent` and verifies the submit payload. + +- [ ] **Step 2: Run command/dialog tests and confirm failure** + +Run: `pnpm vitest packages/server/src/__tests__/supervisor-commands.test.ts packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx --run` +Expected: FAIL because the schemas and dialog draft state only know about objective and evaluator provider. + +- [ ] **Step 3: Extend supervisor command schemas** + +```ts +const supervisorExecutionFields = { + evaluatorModel: z.string().trim().max(200).optional(), + maxSupervisionCount: z.number().int().min(0), + scheduledAt: z.number().int().positive().optional(), +}; +``` + +Use these fields in both create and update, with update variants optional. + +- [ ] **Step 4: Extend dialog draft state** + +```ts +const CLOSED_DIALOG_STATE = { + open: false, + sessionId: null, + mode: "enable" as const, + draftObjective: "", + draftEvaluatorProviderId: "claude" as const, + draftEvaluatorModel: "", + draftMaxSupervisionCount: "0", + draftScheduledAt: "", +}; +``` + +Normalize: + +- empty model -> omitted / `null` +- empty schedule -> omitted +- numeric count string -> integer, default `0` + +- [ ] **Step 5: Add dialog fields** + +```tsx + + + +``` + +Convert `datetime-local` to epoch milliseconds on confirm, using the browser-local timestamp semantics already implied by the control. + +- [ ] **Step 6: Re-run command/dialog tests** + +Run: `pnpm vitest packages/server/src/__tests__/supervisor-commands.test.ts packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx --run` +Expected: PASS with new payload fields, validation, and draft rehydration. + +- [ ] **Step 7: Commit** + +```bash +git add packages/server/src/commands/supervisor.ts packages/server/src/__tests__/supervisor-commands.test.ts packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx packages/web/src/locales/zh.json packages/web/src/locales/en.json +git commit -m "feat: extend supervisor dialog policy fields" +``` + +## Task 6: Add Evaluator Model Override And Objective-Complete Detection + +**Files:** +- Modify: `packages/providers/src/codex/config-schema.ts` +- Modify: `packages/providers/src/codex/supervisor-eval.ts` +- Modify: `packages/server/src/supervisor/evaluator.ts` +- Test: `packages/server/src/supervisor/evaluator.test.ts` +- Test: `packages/providers/src/codex/definition.test.ts` + +- [ ] **Step 1: Write failing evaluator tests** + +```ts +it("prefers supervisor.evaluatorModel over provider config model", async () => { + const result = await evaluator.evaluate( + { + ...makeSupervisor("codex"), + evaluatorModel: "o3", + }, + context + ); + + expect(codexBuildSupervisorEvalCommand).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ model: "o3" }) + ); + expect(result.message).toBe("Run the focused parser test."); +}); + +it("returns a typed objective-complete result when the evaluator emits the sentinel", async () => { + const evaluator = makeEvaluator("[objective complete]"); + + await expect(evaluator.evaluate(makeSupervisor("codex"), context)).resolves.toEqual({ + message: "[objective complete]", + objectiveComplete: true, + }); +}); +``` + +- [ ] **Step 2: Run evaluator/provider tests and confirm failure** + +Run: `pnpm vitest packages/server/src/supervisor/evaluator.test.ts packages/providers/src/codex/definition.test.ts --run` +Expected: FAIL because Codex config does not parse `model`, Codex eval command ignores `req.model`, and evaluator result has no objective-complete metadata. + +- [ ] **Step 3: Add optional `model` to Codex config schema** + +```ts +export const codexConfigSchema = z.object({ + model: z.string().trim().min(1).optional(), + additionalArgs: z.array(z.string()).default([]), + envVars: z.record(z.string(), z.string()).default({}), +}); +``` + +- [ ] **Step 4: Pass model override through Codex supervisor eval command** + +```ts +const effectiveModel = req.model ?? cfg.model; +const modelArg = effectiveModel ? ["-m", effectiveModel] : []; + +return { + argv: [ + "codex", + "exec", + "--json", + "-s", + "read-only", + "--skip-git-repo-check", + ...modelArg, + ...cfg.additionalArgs, + req.prompt, + ], + ... +}; +``` + +- [ ] **Step 5: Mark objective-complete explicitly in evaluator result** + +```ts +export interface SupervisorResult { + message: string; + objectiveComplete: boolean; +} + +const normalized = message.trim(); +return { + message: normalized, + objectiveComplete: normalized === "[objective complete]", +}; +``` + +- [ ] **Step 6: Re-run evaluator/provider tests** + +Run: `pnpm vitest packages/server/src/supervisor/evaluator.test.ts packages/providers/src/codex/definition.test.ts --run` +Expected: PASS with Codex model override and typed objective-complete detection. + +- [ ] **Step 7: Commit** + +```bash +git add packages/providers/src/codex/config-schema.ts packages/providers/src/codex/supervisor-eval.ts packages/server/src/supervisor/evaluator.ts packages/server/src/supervisor/evaluator.test.ts packages/providers/src/codex/definition.test.ts +git commit -m "feat: support supervisor evaluator model overrides" +``` + +## Task 7: Refactor Supervisor Manager For Retry, Stop, Scheduled Trigger, And In-Flight Pause + +**Files:** +- Modify: `packages/server/src/supervisor/manager.ts` +- Modify: `packages/server/src/supervisor/scheduler.ts` +- Modify: `packages/server/src/supervisor/scheduler.test.ts` +- Modify: `packages/server/src/__tests__/supervisor-manager.test.ts` + +- [ ] **Step 1: Write failing manager tests for new execution semantics** + +```ts +it("stops the supervisor when evaluator returns objective complete", async () => { + const supervisor = await manager.create({ + sessionId: "sess-stop", + workspaceId: "ws-1", + objective: "Finish the migration", + evaluatorProviderId: "codex", + maxSupervisionCount: 0, + }); + + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({ + message: "[objective complete]", + objectiveComplete: true, + }); + + const finished = await getManagerInternals().runEvaluation(supervisor.id, "turn_completed"); + + expect(finished?.status).toBe("completed"); + expect(manager.get(supervisor.id)?.state).toBe("stopped"); + expect(manager.get(supervisor.id)?.stopReason).toBe("objective_complete"); +}); + +it("retries evaluator timeout up to the global retry budget", async () => { + deps.settingsRepo.get = vi.fn((key: string) => { + switch (key) { + case "supervisor.retryEnabled": return true; + case "supervisor.retryMaxCount": return 2; + case "supervisor.retryDelaySec": return 1; + case "supervisor.retryOnTimeout": return true; + case "supervisor.retryOnEvaluatorError": return false; + default: return undefined; + } + }); + + vi.spyOn(getManagerInternals().evaluator, "evaluate") + .mockRejectedValueOnce({ code: "supervisor_eval_timeout", message: "timed out" }) + .mockResolvedValueOnce({ message: "Run tests", objectiveComplete: false }); + + const finished = await getManagerInternals().runEvaluation(supervisor.id, "turn_completed"); + + expect(finished?.status).toBe("injected"); + expect(attemptRepo.listForCycle(finished!.id)).toHaveLength(2); +}); + +it("pauses an in-flight evaluation by cancelling the cycle", async () => { + const supervisor = await manager.create(...); + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementation( + () => new Promise(() => {}) + ); + + const cycle = await manager.triggerEvaluation(supervisor.id); + await manager.pause(supervisor.id); + + expect(manager.get(supervisor.id)?.state).toBe("paused"); + expect(manager.get(supervisor.id)?.cycles.find((entry) => entry.id === cycle.id)?.status).toBe("cancelled"); +}); +``` + +Also add tests for: + +- `maxSupervisionCount = 0` stays unlimited +- positive max count stops before starting an extra cycle +- `scheduledAt` trigger type creates `scheduled` cycles +- pause during retry wait cancels the pending timer + +- [ ] **Step 2: Run manager/scheduler tests and confirm failure** + +Run: `pnpm vitest packages/server/src/__tests__/supervisor-manager.test.ts packages/server/src/supervisor/scheduler.test.ts --run` +Expected: FAIL because the manager has no stopped state, no retry loop, no scheduled trigger, and `pause()` does not abort in-flight work. + +- [ ] **Step 3: Extend the scheduler API to support scheduled callbacks** + +```ts +constructor( + private readonly deps: { + eventBus: EventBus; + onTurnCompleted: (sessionId: string) => void; + listScheduledSupervisors: () => Array<{ supervisorId: string; scheduledAt: number }>; + onScheduledDue: (supervisorId: string) => void; + } +) {} +``` + +Maintain: + +- existing `session.lifecycle` subscription +- one in-memory nearest-deadline `setTimeout` +- `refresh()` method the manager can call after hydrate/create/update/pause/resume/delete + +- [ ] **Step 4: Introduce retry settings snapshot and attempt recording** + +```ts +interface SupervisorRetrySnapshot { + retryEnabled: boolean; + retryMaxCount: number; + retryDelayMs: number; + retryOnTimeout: boolean; + retryOnEvaluatorError: boolean; +} + +interface StartedCycle { + cycle: SupervisorCycle; + context: SupervisorEvaluationContext; + retry: SupervisorRetrySnapshot; +} +``` + +Each attempt should create/update a `supervisor_cycle_attempts` row. + +- [ ] **Step 5: Split execution into begin / execute-with-retry / finalize** + +```ts +private async executeCycleWithRetry(started: StartedCycle): Promise { + for (let attemptIndex = 0; ; attemptIndex += 1) { + // create attempt row + // run evaluator + // stop on objective complete + // inject on actionable message + // if failure and retry allowed -> wait and continue + // else finalize failure + } +} +``` + +Key rules: + +- retry only evaluator timeout / evaluator failure categories +- no retry after `[objective complete]` +- count cycles, not attempts, against `maxSupervisionCount` +- clear consumed `scheduledAt` once a scheduled cycle begins successfully + +- [ ] **Step 6: Add `stopped` and `cancelled` flows** + +Use: + +```ts +const stoppedSupervisor = this.deps.supervisorRepo.update(supervisorId, { + state: "stopped", + stopReason: "objective_complete", + updatedAt: Date.now(), +}); +``` + +For user pause during active work: + +```ts +this.evaluationAbortControllers.get(id)?.abort(); +this.retryTimers.get(id)?.cancel?.(); +``` + +Finalize the cycle as `cancelled`, not `failed`. + +- [ ] **Step 7: Re-run manager/scheduler tests** + +Run: `pnpm vitest packages/server/src/__tests__/supervisor-manager.test.ts packages/server/src/supervisor/scheduler.test.ts --run` +Expected: PASS with retry loop, one-shot scheduled execution, stopped state, and in-flight pause behavior. + +- [ ] **Step 8: Commit** + +```bash +git add packages/server/src/supervisor/manager.ts packages/server/src/supervisor/scheduler.ts packages/server/src/supervisor/scheduler.test.ts packages/server/src/__tests__/supervisor-manager.test.ts +git commit -m "feat: add supervisor retry and stop state machine" +``` + +## Task 8: Assemble Server Wiring And Surface New States In The UI + +**Files:** +- Modify: `packages/server/src/server.ts` +- Modify: `packages/web/src/features/supervisor/views/shared/supervisor-card.tsx` +- Modify: `packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx` +- Modify: `packages/web/src/features/supervisor/actions/use-supervisor-actions.ts` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/web/src/locales/en.json` +- Test: `packages/web/src/features/supervisor/components/supervisor-card.test.tsx` +- Test: `packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx` + +- [ ] **Step 1: Write failing UI tests for stopped state and schedule/model summary** + +```ts +it("renders the stopped state and objective-complete reason", () => { + renderSupervisorCard({ + state: "stopped", + stopReason: "objective_complete", + evaluatorProviderId: "codex", + evaluatorModel: "o3", + maxSupervisionCount: 0, + }); + + expect(screen.getByText("Supervisor")).toBeVisible(); + expect(screen.getByText(/Objective complete/i)).toBeVisible(); + expect(screen.getByText(/codex/i)).toBeVisible(); + expect(screen.getByText(/o3/i)).toBeVisible(); +}); +``` + +- [ ] **Step 2: Run supervisor UI tests and confirm failure** + +Run: `pnpm vitest packages/web/src/features/supervisor/components/supervisor-card.test.tsx packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx --run` +Expected: FAIL because `stopped` is not a known state and the UI has no stop-reason rendering. + +- [ ] **Step 3: Wire the new repos and scheduler dependencies in `server.ts`** + +```ts +const attemptRepo = new SupervisorCycleAttemptRepo(db); +supervisorMgr = new SupervisorManager({ + ..., + cycleAttemptRepo: attemptRepo, +}); +``` + +If the manager constructor now requires scheduler callbacks or refresh hooks, wire them in here and keep hydrate order as: + +1. session hydrate +2. supervisor hydrate +3. scheduler refresh + +- [ ] **Step 4: Add new supervisor state copy and summary rendering** + +Render: + +- stopped badge/state class +- stop reason text +- optional evaluator model +- optional scheduled time +- max supervision cap summary when positive + +Keep the existing provider pill and objective row intact. + +- [ ] **Step 5: Re-run supervisor UI tests** + +Run: `pnpm vitest packages/web/src/features/supervisor/components/supervisor-card.test.tsx packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx --run` +Expected: PASS with stopped-state copy, new summary rows, and mobile parity. + +- [ ] **Step 6: Commit** + +```bash +git add packages/server/src/server.ts packages/web/src/features/supervisor/views/shared/supervisor-card.tsx packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx packages/web/src/features/supervisor/actions/use-supervisor-actions.ts packages/web/src/features/supervisor/components/supervisor-card.test.tsx packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx packages/web/src/locales/zh.json packages/web/src/locales/en.json +git commit -m "feat: surface supervisor stopped state in server and UI" +``` + +## Task 9: Run End-To-End Verification Across Startup, Commands, And UI + +**Files:** +- Modify as needed based on verification fallout only +- Test: relevant Vitest suites + +- [ ] **Step 1: Run focused server/unit suites** + +Run: `pnpm vitest packages/server/src/__tests__/db.test.ts packages/server/src/__tests__/supervisor-repo.test.ts packages/server/src/commands/settings.test.ts packages/server/src/__tests__/supervisor-commands.test.ts packages/server/src/__tests__/supervisor-manager.test.ts packages/server/src/supervisor/evaluator.test.ts packages/server/src/supervisor/scheduler.test.ts --run` +Expected: PASS + +- [ ] **Step 2: Run focused CLI suites** + +Run: `pnpm vitest packages/cli/src/bin.test.ts packages/cli/src/server-runner.test.ts --run` +Expected: PASS + +- [ ] **Step 3: Run focused web suites** + +Run: `pnpm vitest packages/web/src/features/settings/components/settings-page.test.tsx packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx packages/web/src/features/supervisor/components/supervisor-card.test.tsx packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx --run` +Expected: PASS + +- [ ] **Step 4: Run a representative cross-package supervisor slice** + +Run: `pnpm vitest packages/server/src/__tests__/supervisor-manager.test.ts packages/web/src/features/supervisor/components/supervisor-card.test.tsx packages/cli/src/bin.test.ts --run` +Expected: PASS + +- [ ] **Step 5: Commit any bounded verification fixes** + +```bash +git add +git commit -m "fix: polish supervisor execution policy rollout" +``` + +## Plan Self-Review + +- Spec coverage: + - DB upgrade + unknown schema rebuild prompt: Task 1 and Task 2 + - per-supervisor persisted fields: Task 3 and Task 5 + - global retry settings: Task 4 + - model override: Task 6 + - retry loop / pause / stop semantics: Task 7 + - scheduler one-shot trigger: Task 7 + - UI and locale updates: Task 4, Task 5, Task 8 +- Placeholder scan: + - every task includes exact files, test commands, and concrete code skeletons + - no `TBD`, `TODO`, or “handle appropriately” placeholders remain +- Type consistency: + - state names use `stopped` + - cycle cancellation uses `cancelled` + - stop reasons use `objective_complete` and `max_supervision_count_reached` + - retry settings keys are consistently `supervisor.retry*` diff --git a/docs/superpowers/plans/2026-05-12-supervisor-target-scoped-memory-implementation.md b/docs/superpowers/plans/2026-05-12-supervisor-target-scoped-memory-implementation.md new file mode 100644 index 000000000..f60d5af55 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-supervisor-target-scoped-memory-implementation.md @@ -0,0 +1,1352 @@ +# Supervisor Target-Scoped Memory Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the current effectively stateless supervisor loop with a target-scoped, workspace-file-backed supervision model that bootstraps a lightweight plan on first trigger and exposes current target progress plus recent cycle reasoning in the UI. + +**Architecture:** Keep the existing session-bound supervisor runtime state machine, commands, retry loop, and injection behavior, but add a `targetId`-based workspace store under `.coder-studio/supervisor/targets/`. The server becomes responsible for loading and updating `meta.json`, `memory.json`, and `cycles.jsonl`; the evaluator upgrades from plain-text guidance to structured `continue` / `stop` results; the web UI remains server-authoritative and renders current target state plus recent cycle history from the enriched supervisor payload. + +**Tech Stack:** TypeScript, Node `fs/promises`, existing workspace-safe file IO patterns, SQLite for runtime supervisor state, Vitest, Fastify, WebSocket topics, React, Jotai. + +--- + +## File Structure + +### Core domain and protocol +- Modify: `packages/core/src/domain/supervisor.ts` — add target-scoped domain types, new stop reasons, structured cycle result shape, and supervisor `targetId` fields. +- Modify: `packages/core/src/index.ts` — export any new target-memory domain types. + +### Workspace-backed target store +- Create: `packages/server/src/supervisor/target-store.ts` — read/write helpers for `.coder-studio/supervisor/targets//meta.json`, `memory.json`, and `cycles.jsonl`. +- Create: `packages/server/src/supervisor/target-store.test.ts` — focused tests for file layout, bootstrap state, append-only cycle history, and objective supersede behavior. +- Reuse reference patterns from: `packages/server/src/fs/file-io.ts` + +### Supervisor runtime +- Modify: `packages/server/src/storage/repositories/supervisor-repo.ts` — persist active `targetId` in runtime supervisor rows. +- Modify: `packages/server/src/storage/db.ts` — add `v2 -> v3` upgrade handling for `target_id`, and ensure `v1` databases still upgrade all the way to current. +- Modify: `packages/server/src/storage/migrations/001_init.sql` — extend the current baseline `supervisors` table with `target_id` in the exact shape expected by the upgraded fingerprint. +- Modify: `packages/server/src/storage/schema-version.ts` — bump the schema version, preserve a `V2_SCHEMA_SQL` snapshot, and teach detection about `v2`. +- Modify: `packages/server/src/supervisor/context-builder.ts` — remove git signal from evaluation context, add target memory input support, keep latest user input and terminal snapshot. +- Modify: `packages/server/src/supervisor/evaluator.ts` — bootstrap plan on first trigger and return structured `continue` / `stop` payloads. +- Modify: `packages/server/src/supervisor/manager.ts` — create target directories, supersede target on objective changes, update memory, append cycle records, map structured results to runtime state. +- Modify: `packages/server/src/supervisor/index.ts` — export new target store types/helpers if needed. +- Test: `packages/server/src/supervisor/manager.test.ts` +- Test: `packages/server/src/supervisor/evaluator.test.ts` +- Test: `packages/server/src/supervisor/evaluator.windows.test.ts` +- Test: `packages/server/src/__tests__/supervisor-manager.test.ts` +- Test: `packages/server/src/__tests__/supervisor-integration.test.ts` +- Test: `packages/server/src/__tests__/supervisor-repo.test.ts` +- Test: `packages/server/src/storage/db.test.ts` +- Test: `packages/server/src/__tests__/db.test.ts` + +### Commands and server wiring +- Modify: `packages/server/src/commands/supervisor.ts` only if type updates require it; command names and payload shapes stay unchanged. +- Modify: `packages/server/src/server.ts` — wire `target-store` dependencies into `SupervisorManager`. +- Test: `packages/server/src/__tests__/supervisor-commands.test.ts` + +### Web UI and event routing +- Modify: `packages/web/src/features/supervisor/atoms.ts` only if helper selectors become useful; the main shape expansion comes from the `Supervisor` domain type. +- Modify: `packages/web/src/features/supervisor/actions/use-supervisor-actions.ts` — derive current target plan, progress summary, stop reason, and recent cycle list from enriched supervisor data. +- Modify: `packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts` only if the dialog has assumptions that break once `targetId` rotates on objective edit. +- Modify: `packages/web/src/features/supervisor/views/shared/supervisor-card.tsx` — render active target, plan summary, stalled count, and recent cycle reasoning. +- Modify: `packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx` only if the expanded `SupervisorCard` needs mobile-specific layout adjustments. +- Modify: `packages/web/src/app/providers.tsx` — route richer supervisor payloads and cycle records into atoms. +- Test: `packages/web/src/features/supervisor/components/supervisor-card.test.tsx` +- Test: `packages/web/src/features/supervisor/components/objective-dialog.test.tsx` +- Test: `packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx` +- Test: `packages/web/src/app/providers.test.tsx` + +## Task 1: Add Target-Scoped Domain Types And Runtime `targetId` + +**Files:** +- Modify: `packages/core/src/domain/supervisor.ts` +- Modify: `packages/core/src/index.ts` +- Modify: `packages/server/src/storage/migrations/001_init.sql` +- Modify: `packages/server/src/storage/schema-version.ts` +- Modify: `packages/server/src/storage/db.ts` +- Modify: `packages/server/src/storage/repositories/supervisor-repo.ts` +- Test: `packages/server/src/__tests__/supervisor-repo.test.ts` +- Test: `packages/server/src/storage/db.test.ts` +- Test: `packages/server/src/__tests__/db.test.ts` + +- [ ] **Step 1: Write failing repository and schema tests for `targetId` persistence** + +```ts +// packages/server/src/__tests__/supervisor-repo.test.ts +it("persists targetId on supervisor create and update", () => { + const repo = createSupervisorRepo(); + + const created = repo.create({ + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + state: "idle", + objective: "Ship feature", + evaluatorProviderId: "claude", + targetId: "tgt-1", + maxSupervisionCount: 0, + completedSupervisionCount: 0, + createdAt: 1, + updatedAt: 1, + }); + + expect(created.targetId).toBe("tgt-1"); + + const updated = repo.update("sup-1", { + targetId: "tgt-2", + }); + + expect(updated.targetId).toBe("tgt-2"); +}); +``` + +```ts +// packages/server/src/storage/db.test.ts +it("includes target_id on supervisors in the latest schema", () => { + const db = openDatabase(tempDbPath()); + const columns = db.prepare("PRAGMA table_info(supervisors)").all() as Array<{ name: string }>; + + expect(columns.map((column) => column.name)).toContain("target_id"); +}); +``` + +```ts +// packages/server/src/__tests__/db.test.ts +it("upgrades a known v2 supervisor schema to v3 and backfills target_id", () => { + const dbPath = join(tempDir, "v2.db"); + const rawDb = new DatabaseSync(dbPath); + rawDb.exec("PRAGMA user_version = 2"); + rawDb.exec(V2_SCHEMA_SQL); + rawDb.exec(` + INSERT INTO workspaces (id, path, target_runtime, opened_at, last_active_at, ui_state) + VALUES ('ws-1', '/workspace', 'native', 1, 1, '{}'); + INSERT INTO terminals (id, workspace_id, kind, cwd, argv, cols, rows, created_at) + VALUES ('term-1', 'ws-1', 'agent', '/workspace', '[]', 120, 30, 1); + INSERT INTO sessions (id, workspace_id, terminal_id, provider_id, capability, state, started_at, last_active_at) + VALUES ('sess-1', 'ws-1', 'term-1', 'claude', 'full', 'idle', 1, 1); + INSERT INTO supervisors (id, session_id, workspace_id, state, objective, evaluator_provider_id, evaluator_model, max_supervision_count, completed_supervision_count, scheduled_at, stop_reason, last_cycle_at, last_evaluated_turn_id, error_reason, created_at, updated_at) + VALUES ('sup-1', 'sess-1', 'ws-1', 'idle', 'Ship feature', 'claude', NULL, 0, 0, NULL, NULL, NULL, NULL, NULL, 1, 1); + `); + rawDb.close(); + + db = openDatabase(dbPath); + + const userVersion = db.prepare("PRAGMA user_version").get() as { user_version: number }; + expect(userVersion.user_version).toBe(CURRENT_SCHEMA_VERSION); + + const supervisorColumns = db.prepare("PRAGMA table_info(supervisors)").all() as Array<{ + name: string; + }>; + expect(supervisorColumns.map((column) => column.name)).toContain("target_id"); + + const migrated = db.prepare("SELECT target_id FROM supervisors WHERE id = ?").get("sup-1") as { + target_id: string; + }; + expect(migrated.target_id).toMatch(/^legacy_sup-1$/); +}); +``` + +- [ ] **Step 2: Run the narrow test set and confirm failure** + +Run: `pnpm vitest packages/server/src/__tests__/supervisor-repo.test.ts packages/server/src/storage/db.test.ts packages/server/src/__tests__/db.test.ts --run` +Expected: FAIL because `Supervisor` has no `targetId` field, the `supervisors` table has no `target_id` column, and the database layer does not yet recognize a `v3` schema. + +- [ ] **Step 3: Extend the supervisor domain with target-scoped types** + +```ts +// packages/core/src/domain/supervisor.ts +export type SupervisorStopReason = + | "objective_complete" + | "max_supervision_count_reached" + | "supervisor_uncertain" + | "needs_user_input"; + +export type SupervisorPlanStepStatus = "pending" | "in_progress" | "done"; + +export interface SupervisorPlanStep { + id: string; + title: string; + status: SupervisorPlanStepStatus; +} + +export interface SupervisorTargetMemory { + targetId: string; + planGenerated: boolean; + plan: SupervisorPlanStep[]; + activeStepId?: string; + progressSummary?: string; + lastGuidance?: string; + stalledCount: number; + updatedAt: number; +} + +export interface SupervisorCycleStepUpdate { + id: string; + status: SupervisorPlanStepStatus; +} + +export interface SupervisorCycleTargetRecord { + cycleId: string; + targetId: string; + startedAt: number; + completedAt: number; + result: "continue" | "stop" | "error"; + stopReason?: "objective_complete" | "supervisor_uncertain" | "needs_user_input"; + reason?: string; + guidance?: string; + progressSummary?: string; + activeStepId?: string; + stepUpdates?: SupervisorCycleStepUpdate[]; + injected?: boolean; + attemptCount?: number; + errorReason?: string; +} + +export interface Supervisor { + id: string; + sessionId: string; + workspaceId: string; + state: SupervisorState; + objective: string; + targetId: string; + evaluatorProviderId: string; + evaluatorModel?: string; + maxSupervisionCount: number; + completedSupervisionCount: number; + scheduledAt?: number; + stopReason?: SupervisorStopReason; + cycles: SupervisorCycle[]; + currentTargetMemory?: SupervisorTargetMemory; + recentTargetCycles?: SupervisorCycleTargetRecord[]; + lastCycleAt?: number; + lastEvaluatedTurnId?: string; + errorReason?: string; + createdAt: number; + updatedAt: number; +} +``` + +- [ ] **Step 4: Add `target_id` to the latest schema and repository CRUD** + +```sql +-- packages/server/src/storage/migrations/001_init.sql +CREATE TABLE IF NOT EXISTS supervisors ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + workspace_id TEXT NOT NULL, + state TEXT NOT NULL, + objective TEXT NOT NULL, + evaluator_provider_id TEXT NOT NULL, + evaluator_model TEXT, + max_supervision_count INTEGER NOT NULL DEFAULT 0, + completed_supervision_count INTEGER NOT NULL DEFAULT 0, + scheduled_at INTEGER, + stop_reason TEXT, + last_cycle_at INTEGER, + last_evaluated_turn_id TEXT, + error_reason TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, target_id TEXT NOT NULL DEFAULT '', + FOREIGN KEY (session_id, workspace_id) REFERENCES sessions(id, workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); +``` + +```ts +// packages/server/src/storage/repositories/supervisor-repo.ts +interface SupervisorRow { + id: string; + session_id: string; + workspace_id: string; + state: SupervisorState; + objective: string; + target_id: string; + evaluator_provider_id: string; + evaluator_model: string | null; + max_supervision_count: number; + completed_supervision_count: number; + scheduled_at: number | null; + stop_reason: SupervisorStopReason | null; + last_cycle_at: number | null; + last_evaluated_turn_id: string | null; + error_reason: string | null; + created_at: number; + updated_at: number; +} + +export interface NewSupervisor { + id: string; + sessionId: string; + workspaceId: string; + state: SupervisorState; + objective: string; + targetId: string; + evaluatorProviderId: string; + evaluatorModel?: string; + maxSupervisionCount: number; + completedSupervisionCount: number; + scheduledAt?: number; + stopReason?: SupervisorStopReason; + lastCycleAt?: number; + lastEvaluatedTurnId?: string; + errorReason?: string; + createdAt: number; + updatedAt: number; +} + +export interface SupervisorUpdatePatch { + state?: SupervisorState; + objective?: string; + targetId?: string; + evaluatorProviderId?: string; + evaluatorModel?: string | null; + maxSupervisionCount?: number; + completedSupervisionCount?: number; + scheduledAt?: number | null; + stopReason?: SupervisorStopReason | null; + lastCycleAt?: number | null; + lastEvaluatedTurnId?: string | null; + errorReason?: string | null; + updatedAt?: number; +} +``` + +Persist `target_id` on create, update, and row mapping. + +Also introduce a real schema upgrade path rather than only editing the baseline: + +```ts +// packages/server/src/storage/schema-version.ts +export type SchemaState = "empty" | "current" | "v1" | "v2" | "incompatible"; + +export const CURRENT_SCHEMA_VERSION = 3; +export const V2_SCHEMA_SQL = ` + -- paste the exact pre-target_id baseline schema here so fingerprint + -- detection can distinguish v2 from incompatible drift +`; +``` + +```ts +// packages/server/src/storage/db.ts +function upgradeSchemaV2ToV3(db: Database): void { + withTransaction(db, () => { + db.exec("ALTER TABLE supervisors ADD COLUMN target_id TEXT NOT NULL DEFAULT ''"); + db.exec("UPDATE supervisors SET target_id = 'legacy_' || id WHERE target_id = ''"); + stampCurrentSchemaVersion(db); + }); +} + +function initializeOrUpgradeSchema(db: Database, dbPath: string): void { + throwIfLegacySchema(db, dbPath); + + const detection = detectSchema(db); + + switch (detection.state) { + case "empty": + initializeSchema(db); + assertCurrentSchema(db, dbPath); + return; + + case "current": + if (detection.userVersion !== CURRENT_SCHEMA_VERSION) { + stampCurrentSchemaVersion(db); + } + assertCurrentSchema(db, dbPath); + return; + + case "v1": + upgradeSchemaV1ToV2(db); + upgradeSchemaV2ToV3(db); + assertCurrentSchema(db, dbPath); + return; + + case "v2": + upgradeSchemaV2ToV3(db); + assertCurrentSchema(db, dbPath); + return; + + case "incompatible": + throw new IncompatibleSchemaError(dbPath, detection.mismatch ?? "unknown schema drift"); + } +} +``` + +- [ ] **Step 5: Run the narrow test set and verify it passes** + +Run: `pnpm vitest packages/server/src/__tests__/supervisor-repo.test.ts packages/server/src/storage/db.test.ts packages/server/src/__tests__/db.test.ts --run` +Expected: PASS with schema, upgrade, and repo assertions recognizing `target_id`. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/domain/supervisor.ts packages/core/src/index.ts packages/server/src/storage/migrations/001_init.sql packages/server/src/storage/schema-version.ts packages/server/src/storage/db.ts packages/server/src/storage/repositories/supervisor-repo.ts packages/server/src/__tests__/supervisor-repo.test.ts packages/server/src/storage/db.test.ts packages/server/src/__tests__/db.test.ts +git commit -m "feat: persist supervisor target ids" +``` + +## Task 2: Build The Workspace-Backed Target Store + +**Files:** +- Create: `packages/server/src/supervisor/target-store.ts` +- Create: `packages/server/src/supervisor/target-store.test.ts` +- Reuse reference: `packages/server/src/fs/file-io.ts` + +- [ ] **Step 1: Write failing target store tests** + +```ts +// packages/server/src/supervisor/target-store.test.ts +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + appendTargetCycleRecord, + createTargetFiles, + readTargetCycleRecords, + readTargetMeta, + loadTargetMemory, + markTargetSuperseded, + saveTargetMemory, +} from "./target-store.js"; + +describe("target store", () => { + let workspacePath: string; + + beforeEach(() => { + workspacePath = mkdtempSync(join(tmpdir(), "supervisor-target-store-")); + }); + + afterEach(() => { + rmSync(workspacePath, { recursive: true, force: true }); + }); + + it("creates target metadata with planGenerated=false before first trigger", async () => { + await createTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Ship feature", + createdAt: 1, + }); + + const memory = await loadTargetMemory(workspacePath, "tgt-1"); + + expect(memory).toEqual({ + targetId: "tgt-1", + planGenerated: false, + plan: [], + activeStepId: undefined, + progressSummary: undefined, + lastGuidance: undefined, + stalledCount: 0, + updatedAt: 1, + }); + }); + + it("appends cycle records as newline-delimited json", async () => { + await createTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Ship feature", + createdAt: 1, + }); + + await appendTargetCycleRecord(workspacePath, "tgt-1", { + cycleId: "cycle-1", + targetId: "tgt-1", + startedAt: 1, + completedAt: 2, + result: "continue", + reason: "Need one more implementation step", + guidance: "Implement the store", + injected: true, + attemptCount: 1, + }); + + const lines = await readTargetCycleRecords(workspacePath, "tgt-1"); + expect(lines).toHaveLength(1); + expect(lines[0]?.guidance).toBe("Implement the store"); + }); + + it("marks a target as superseded without mutating the old memory contents", async () => { + await createTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Old objective", + createdAt: 1, + }); + + await saveTargetMemory(workspacePath, "tgt-1", { + targetId: "tgt-1", + planGenerated: true, + plan: [{ id: "step-1", title: "Old step", status: "in_progress" }], + activeStepId: "step-1", + progressSummary: "In progress", + lastGuidance: "Do old thing", + stalledCount: 1, + updatedAt: 2, + }); + + await markTargetSuperseded(workspacePath, "tgt-1", "tgt-2", 3); + + const meta = await readTargetMeta(workspacePath, "tgt-1"); + const memory = await loadTargetMemory(workspacePath, "tgt-1"); + + expect(meta.status).toBe("superseded"); + expect(meta.supersededBy).toBe("tgt-2"); + expect(memory.lastGuidance).toBe("Do old thing"); + }); +}); +``` + +- [ ] **Step 2: Run the target store tests and confirm failure** + +Run: `pnpm vitest packages/server/src/supervisor/target-store.test.ts --run` +Expected: FAIL because `target-store.ts` does not exist yet. + +- [ ] **Step 3: Implement workspace target store helpers** + +```ts +// packages/server/src/supervisor/target-store.ts +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { SupervisorCycleTargetRecord, SupervisorTargetMemory } from "@coder-studio/core"; + +export interface SupervisorTargetMeta { + targetId: string; + sessionId: string; + workspaceId: string; + objective: string; + status: "active" | "completed" | "cancelled" | "superseded"; + createdAt: number; + updatedAt: number; + supersededBy: string | null; + completedAt: number | null; +} + +function targetDir(workspacePath: string, targetId: string): string { + return join(workspacePath, ".coder-studio", "supervisor", "targets", targetId); +} + +function metaPath(workspacePath: string, targetId: string): string { + return join(targetDir(workspacePath, targetId), "meta.json"); +} + +function memoryPath(workspacePath: string, targetId: string): string { + return join(targetDir(workspacePath, targetId), "memory.json"); +} + +function cyclesPath(workspacePath: string, targetId: string): string { + return join(targetDir(workspacePath, targetId), "cycles.jsonl"); +} + +export async function createTargetFiles( + workspacePath: string, + input: { targetId: string; sessionId: string; workspaceId: string; objective: string; createdAt: number } +): Promise { + const dir = targetDir(workspacePath, input.targetId); + await mkdir(dir, { recursive: true }); + + const meta: SupervisorTargetMeta = { + targetId: input.targetId, + sessionId: input.sessionId, + workspaceId: input.workspaceId, + objective: input.objective, + status: "active", + createdAt: input.createdAt, + updatedAt: input.createdAt, + supersededBy: null, + completedAt: null, + }; + + const memory: SupervisorTargetMemory = { + targetId: input.targetId, + planGenerated: false, + plan: [], + stalledCount: 0, + updatedAt: input.createdAt, + }; + + await writeFile(metaPath(workspacePath, input.targetId), JSON.stringify(meta, null, 2) + "\n", "utf-8"); + await writeFile(memoryPath(workspacePath, input.targetId), JSON.stringify(memory, null, 2) + "\n", "utf-8"); +} + +export async function readTargetMeta( + workspacePath: string, + targetId: string +): Promise { + return JSON.parse(await readFile(metaPath(workspacePath, targetId), "utf-8")) as SupervisorTargetMeta; +} + +export async function loadTargetMemory( + workspacePath: string, + targetId: string +): Promise { + return JSON.parse(await readFile(memoryPath(workspacePath, targetId), "utf-8")) as SupervisorTargetMemory; +} + +export async function saveTargetMemory( + workspacePath: string, + targetId: string, + memory: SupervisorTargetMemory +): Promise { + await mkdir(dirname(memoryPath(workspacePath, targetId)), { recursive: true }); + await writeFile(memoryPath(workspacePath, targetId), JSON.stringify(memory, null, 2) + "\n", "utf-8"); +} + +export async function appendTargetCycleRecord( + workspacePath: string, + targetId: string, + record: SupervisorCycleTargetRecord +): Promise { + await mkdir(dirname(cyclesPath(workspacePath, targetId)), { recursive: true }); + await writeFile(cyclesPath(workspacePath, targetId), JSON.stringify(record) + "\n", { + encoding: "utf-8", + flag: "a", + }); +} + +export async function saveTargetMeta( + workspacePath: string, + targetId: string, + meta: SupervisorTargetMeta +): Promise { + await mkdir(dirname(metaPath(workspacePath, targetId)), { recursive: true }); + await writeFile(metaPath(workspacePath, targetId), JSON.stringify(meta, null, 2) + "\n", "utf-8"); +} + +export async function readTargetCycleRecords( + workspacePath: string, + targetId: string, + limit = 20 +): Promise { + const content = await readFile(cyclesPath(workspacePath, targetId), "utf-8").catch(() => ""); + return content + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line) as SupervisorCycleTargetRecord) + .slice(-limit) + .reverse(); +} + +export async function markTargetSuperseded( + workspacePath: string, + targetId: string, + nextTargetId: string, + updatedAt: number +): Promise { + const meta = await readTargetMeta(workspacePath, targetId); + await saveTargetMeta(workspacePath, targetId, { + ...meta, + status: "superseded", + supersededBy: nextTargetId, + updatedAt, + }); +} +``` + +- [ ] **Step 4: Run the target store tests and verify they pass** + +Run: `pnpm vitest packages/server/src/supervisor/target-store.test.ts --run` +Expected: PASS with target file creation, supersede metadata updates, and JSONL appends working. + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/supervisor/target-store.ts packages/server/src/supervisor/target-store.test.ts +git commit -m "feat: add workspace-backed supervisor target store" +``` + +## Task 3: Bootstrap Plans On First Trigger And Return Structured Evaluator Results + +**Files:** +- Modify: `packages/server/src/supervisor/context-builder.ts` +- Modify: `packages/server/src/supervisor/evaluator.ts` +- Test: `packages/server/src/supervisor/context-builder.test.ts` +- Test: `packages/server/src/supervisor/evaluator.test.ts` +- Test: `packages/server/src/supervisor/evaluator.windows.test.ts` + +- [ ] **Step 1: Write failing evaluator tests for plan bootstrap and `continue` / `stop` output parsing** + +```ts +// packages/server/src/supervisor/evaluator.test.ts +it("builds a bootstrap prompt when planGenerated is false", async () => { + const evaluator = createEvaluatorWithStdout( + JSON.stringify({ + status: "continue", + reason: "Need a plan first", + guidance: "Break the objective into 3 to 7 steps", + plan: [ + { id: "step-1", title: "Inspect current behavior", status: "in_progress" }, + { id: "step-2", title: "Implement target store", status: "pending" }, + ], + activeStepId: "step-1", + progressSummary: "Initial decomposition complete", + }) + ); + + const result = await evaluator.evaluate( + createSupervisor(), + { + objective: "Improve supervisor quality", + sessionId: "sess-1", + workspaceId: "ws-1", + workspacePath: "/workspace", + sessionProviderId: "claude", + evaluatorProviderId: "claude", + sessionState: "running", + terminalExcerpt: "Need to define the implementation plan", + evidenceSource: "headless_snapshot", + latestUserInput: "Please improve supervisor quality", + targetMemory: { + targetId: "tgt-1", + planGenerated: false, + plan: [], + stalledCount: 0, + updatedAt: 1, + }, + }, + {} + ); + + expect(result.status).toBe("continue"); + expect(result.plan?.map((step) => step.title)).toEqual([ + "Inspect current behavior", + "Implement target store", + ]); +}); + +it("parses a stop result with stopReason", async () => { + const evaluator = createEvaluatorWithStdout( + JSON.stringify({ + status: "stop", + stopReason: "objective_complete", + reason: "The target is complete", + }) + ); + + const result = await evaluator.evaluate(createSupervisor(), createEvaluationContext(), {}); + + expect(result).toEqual({ + status: "stop", + stopReason: "objective_complete", + reason: "The target is complete", + }); +}); +``` + +- [ ] **Step 2: Run the evaluator tests and confirm failure** + +Run: `pnpm vitest packages/server/src/supervisor/evaluator.test.ts packages/server/src/supervisor/context-builder.test.ts --run` +Expected: FAIL because the evaluator still expects plain-text guidance, the context builder has no target memory input, and the evaluator parser still assumes the old message-only contract. + +- [ ] **Step 3: Add target memory to evaluation context and remove git as a primary signal** + +```ts +// packages/server/src/supervisor/context-builder.ts +import type { SupervisorTargetMemory } from "@coder-studio/core"; + +export interface SupervisorEvaluationContext { + objective: string; + sessionId: string; + workspaceId: string; + workspacePath: string; + sessionProviderId: string; + evaluatorProviderId: string; + sessionState: SessionState; + terminalExcerpt?: string; + lastTurnId?: string; + evidenceSource: "headless_snapshot" | "transcript" | "terminal_fallback"; + latestUserInput?: string; + targetMemory: SupervisorTargetMemory; +} + +async build( + supervisor: Supervisor, + targetMemory: SupervisorTargetMemory +): Promise { + // keep the existing headless snapshot path, but stop reading git state + // and copy the loaded target memory directly into the evaluation context +} +``` + +Drop `gitStatusSummary` and `gitDiffStat` from the normal evaluation context. Update `context-builder.test.ts` to assert those fields are gone and that the provided `targetMemory` is passed through unchanged. + +- [ ] **Step 4: Change evaluator output from plain text to structured JSON** + +```ts +// packages/server/src/supervisor/evaluator.ts +export interface SupervisorEvaluationResult { + status: "continue" | "stop"; + stopReason?: "objective_complete" | "supervisor_uncertain" | "needs_user_input"; + reason: string; + guidance?: string; + plan?: SupervisorPlanStep[]; + activeStepId?: string; + progressSummary?: string; + stepUpdates?: SupervisorCycleStepUpdate[]; +} + +function buildPrompt(context: SupervisorEvaluationContext): string { + const lines: string[] = [ + "You are supervising a target-scoped software task.", + "Return JSON only.", + "", + "Allowed statuses:", + '- "continue": more work is needed; include "reason" and "guidance".', + '- "stop": supervision should stop; include "stopReason" and "reason".', + "", + "Allowed stop reasons:", + '- "objective_complete"', + '- "supervisor_uncertain"', + '- "needs_user_input"', + "", + "If planGenerated is false, bootstrap a plan with 3 to 7 milestone-sized steps.", + "If planGenerated is true, update progress incrementally; do not rewrite the full plan unless absolutely necessary.", + "", + "Current objective:", + context.objective, + "", + "Current target memory:", + JSON.stringify(context.targetMemory, null, 2), + "", + "Latest user input:", + context.latestUserInput?.trim() || "(none)", + "", + "Current terminal snapshot:", + context.terminalExcerpt || "(no output yet)", + ]; + + return lines.join("\n"); +} +``` + +Parse evaluator stdout as JSON and validate the result shape before returning it. Keep the existing subprocess / timeout / Codex-stream handling, but change the final parsed artifact from `{ message, objectiveComplete }` to the new structured result. Update both `evaluator.test.ts` and `evaluator.windows.test.ts` to cover the new JSON contract. + +- [ ] **Step 5: Run the evaluator tests and verify they pass** + +Run: `pnpm vitest packages/server/src/supervisor/evaluator.test.ts packages/server/src/supervisor/evaluator.windows.test.ts packages/server/src/supervisor/context-builder.test.ts --run` +Expected: PASS with structured result parsing, first-trigger plan bootstrap behavior, and platform-specific subprocess parsing covered. + +- [ ] **Step 6: Commit** + +```bash +git add packages/server/src/supervisor/context-builder.ts packages/server/src/supervisor/evaluator.ts packages/server/src/supervisor/context-builder.test.ts packages/server/src/supervisor/evaluator.test.ts packages/server/src/supervisor/evaluator.windows.test.ts +git commit -m "feat: add structured supervisor evaluation results" +``` + +## Task 4: Move Supervisor Runtime To Target Lifecycle + Memory Updates + +**Files:** +- Modify: `packages/server/src/supervisor/manager.ts` +- Modify: `packages/server/src/server.ts` +- Modify: `packages/server/src/commands/supervisor.ts` +- Modify: `packages/server/src/supervisor/index.ts` +- Test: `packages/server/src/supervisor/manager.test.ts` +- Test: `packages/server/src/__tests__/supervisor-manager.test.ts` +- Test: `packages/server/src/__tests__/supervisor-integration.test.ts` +- Test: `packages/server/src/__tests__/supervisor-commands.test.ts` + +- [ ] **Step 1: Write failing runtime tests for target creation, supersede-on-objective-edit, and file-backed cycle records** + +```ts +// packages/server/src/__tests__/supervisor-manager.test.ts +it("creates a target on supervisor.create and stores its id on the supervisor", async () => { + const { manager, targetStore } = createManagerHarness(); + + const created = await manager.create({ + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Ship feature", + evaluatorProviderId: "claude", + }); + + expect(created.targetId).toMatch(/^tgt_/); + expect(targetStore.createTargetFiles).toHaveBeenCalledWith( + "/workspace", + expect.objectContaining({ + targetId: created.targetId, + objective: "Ship feature", + }) + ); +}); + +it("supersedes the old target when supervisor.update changes the objective", async () => { + const { manager, targetStore } = createManagerWithExistingSupervisor({ + targetId: "tgt-old", + objective: "Old objective", + }); + + const updated = await manager.update("sup-1", { + objective: "New objective", + }); + + expect(updated.targetId).not.toBe("tgt-old"); + expect(targetStore.markTargetSuperseded).toHaveBeenCalledWith( + "/workspace", + "tgt-old", + updated.targetId, + expect.any(Number) + ); +}); + +it("bootstraps the target plan on first trigger and appends the cycle record", async () => { + const { manager, targetStore } = createManagerWithExistingSupervisor({ + targetId: "tgt-1", + objective: "Ship feature", + }); + + targetStore.loadTargetMemory.mockResolvedValue({ + targetId: "tgt-1", + planGenerated: false, + plan: [], + stalledCount: 0, + updatedAt: 1, + }); + + const cycle = await manager.triggerEvaluation("sup-1"); + expect(cycle.trigger).toBe("manual"); + + await waitFor(() => { + expect(targetStore.saveTargetMemory).toHaveBeenCalledWith( + "/workspace", + "tgt-1", + expect.objectContaining({ + planGenerated: true, + plan: expect.arrayContaining([ + expect.objectContaining({ title: expect.any(String) }), + ]), + }) + ); + expect(targetStore.appendTargetCycleRecord).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run the runtime tests and confirm failure** + +Run: `pnpm vitest packages/server/src/supervisor/manager.test.ts packages/server/src/__tests__/supervisor-manager.test.ts packages/server/src/__tests__/supervisor-integration.test.ts packages/server/src/__tests__/supervisor-commands.test.ts --run` +Expected: FAIL because manager create/update/finish flow does not know about target files or structured evaluator results. + +- [ ] **Step 3: Wire target store into `SupervisorManager` and create target files on create** + +```ts +// packages/server/src/supervisor/manager.ts +export interface SupervisorManagerDeps { + eventBus: EventBus; + broadcaster: Broadcaster; + terminalMgr: TerminalManager; + workspaceMgr: WorkspaceManager; + sessionMgr: SessionManager; + providerRegistry: ProviderDefinition[]; + providerConfigRepo: ProviderConfigRepo; + settingsRepo: Pick; + supervisorRepo: SupervisorRepo; + cycleRepo: SupervisorCycleRepo; + cycleAttemptRepo: Pick; + targetStore: { + createTargetFiles: typeof import("./target-store.js").createTargetFiles; + readTargetMeta: typeof import("./target-store.js").readTargetMeta; + loadTargetMemory: typeof import("./target-store.js").loadTargetMemory; + saveTargetMeta: typeof import("./target-store.js").saveTargetMeta; + saveTargetMemory: typeof import("./target-store.js").saveTargetMemory; + appendTargetCycleRecord: typeof import("./target-store.js").appendTargetCycleRecord; + markTargetSuperseded: typeof import("./target-store.js").markTargetSuperseded; + readTargetCycleRecords: typeof import("./target-store.js").readTargetCycleRecords; + }; + logger?: FastifyBaseLogger; + config?: SupervisorConfig; +} +``` + +Generate `targetId` during `create()`, persist it on the supervisor row, then call `targetStore.createTargetFiles(workspace.path, ...)`. + +Also add a small `ensureTargetArtifacts(supervisor)` helper that runs during `hydrate()` and before evaluation: + +- for upgraded legacy rows, if target files are missing, create them lazily from the current supervisor objective and `targetId` +- after loading memory, pass it into `contextBuilder.build(supervisor, targetMemory)` +- after cycle completion, refresh `recentTargetCycles` from `readTargetCycleRecords(..., 20)` before broadcasting the snapshot + +- [ ] **Step 4: Supersede targets on objective edits and update file-backed memory during cycle completion** + +```ts +// packages/server/src/supervisor/manager.ts +if ( + patch.objective !== undefined && + patch.objective.trim() !== current.objective +) { + const nextTargetId = generateTargetId(); + await this.deps.targetStore.markTargetSuperseded( + workspace.path, + current.targetId, + nextTargetId, + Date.now() + ); + await this.deps.targetStore.createTargetFiles(workspace.path, { + targetId: nextTargetId, + sessionId: current.sessionId, + workspaceId: current.workspaceId, + objective: patch.objective.trim(), + createdAt: Date.now(), + }); + nextPatch.targetId = nextTargetId; +} +``` + +When a cycle finishes with `status: "continue"`: + +- load the current target memory +- if the evaluator returned bootstrap `plan`, save it with `planGenerated: true` +- otherwise apply only incremental updates: `stepUpdates`, `activeStepId`, `progressSummary`, `lastGuidance`, and `stalledCount` +- append the cycle record to `cycles.jsonl` +- refresh `currentTargetMemory` and `recentTargetCycles` on the in-memory supervisor snapshot before broadcasting + +When the evaluator returns `status: "stop"`: + +- append a stop record to `cycles.jsonl` +- update `meta.json` to `status: "completed"` with `completedAt` +- transition the runtime supervisor to `stopped` +- persist `stopReason` + +When supervisor deletion disables an active target: + +- update `meta.json` to `status: "cancelled"` unless the target was already `completed` or `superseded` +- keep the target directory on disk for inspection + +- [ ] **Step 5: Wire the target store in server assembly and keep command payloads stable** + +```ts +// packages/server/src/server.ts +import * as targetStore from "./supervisor/target-store.js"; + +supervisorMgr = new SupervisorManager({ + eventBus, + broadcaster: wsHub, + terminalMgr, + workspaceMgr, + sessionMgr, + providerRegistry, + providerConfigRepo, + settingsRepo, + supervisorRepo, + cycleRepo, + cycleAttemptRepo, + targetStore, + logger: app.log, +}); +``` + +Keep `supervisor.create/update/delete/trigger` command names unchanged. The behavior change is internal: objective edits now rotate `targetId`. `packages/server/src/commands/supervisor.ts` should only need edits if the stricter return types force updates in the command tests or mocks. + +- [ ] **Step 6: Run the runtime test set and verify it passes** + +Run: `pnpm vitest packages/server/src/supervisor/manager.test.ts packages/server/src/__tests__/supervisor-manager.test.ts packages/server/src/__tests__/supervisor-integration.test.ts packages/server/src/__tests__/supervisor-commands.test.ts --run` +Expected: PASS with target creation, supersede behavior, first-trigger bootstrap, and cycle record appends covered. + +- [ ] **Step 7: Commit** + +```bash +git add packages/server/src/supervisor/manager.ts packages/server/src/server.ts packages/server/src/commands/supervisor.ts packages/server/src/supervisor/index.ts packages/server/src/supervisor/manager.test.ts packages/server/src/__tests__/supervisor-manager.test.ts packages/server/src/__tests__/supervisor-integration.test.ts packages/server/src/__tests__/supervisor-commands.test.ts +git commit -m "feat: add target-scoped supervisor runtime" +``` + +## Task 5: Render Current Target Memory And Recent Cycle History In The Web UI + +**Files:** +- Modify: `packages/web/src/features/supervisor/atoms.ts` +- Modify: `packages/web/src/features/supervisor/actions/use-supervisor-actions.ts` +- Modify: `packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts` +- Modify: `packages/web/src/features/supervisor/views/shared/supervisor-card.tsx` +- Modify: `packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx` +- Modify: `packages/web/src/app/providers.tsx` +- Test: `packages/web/src/features/supervisor/components/supervisor-card.test.tsx` +- Test: `packages/web/src/features/supervisor/components/objective-dialog.test.tsx` +- Test: `packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx` +- Test: `packages/web/src/app/providers.test.tsx` + +- [ ] **Step 1: Write failing UI tests for target progress and recent cycle reasoning** + +```tsx +// packages/web/src/features/supervisor/components/supervisor-card.test.tsx +it("renders the current target progress summary, active step, and recent cycle reasons", () => { + const supervisor = createSupervisor({ + targetId: "tgt-1", + currentTargetMemory: { + targetId: "tgt-1", + planGenerated: true, + plan: [ + { id: "step-1", title: "Inspect current behavior", status: "done" }, + { id: "step-2", title: "Implement target store", status: "in_progress" }, + ], + activeStepId: "step-2", + progressSummary: "Target store implementation is in progress", + lastGuidance: "Implement the target store", + stalledCount: 1, + updatedAt: 2, + }, + recentTargetCycles: [ + { + cycleId: "cycle-1", + targetId: "tgt-1", + startedAt: 1, + completedAt: 2, + result: "continue", + reason: "Need the storage layer before wiring runtime", + guidance: "Implement the target store", + progressSummary: "Current behavior review is complete", + activeStepId: "step-2", + injected: true, + attemptCount: 1, + }, + ], + }); + + renderSupervisorCard(supervisor); + + expect(screen.getByText("Target store implementation is in progress")).toBeInTheDocument(); + expect(screen.getByText("Implement target store")).toBeInTheDocument(); + expect(screen.getByText("Need the storage layer before wiring runtime")).toBeInTheDocument(); + expect(screen.getByText("stalled: 1")).toBeInTheDocument(); +}); +``` + +```tsx +// packages/web/src/app/providers.test.tsx +it("stores enriched supervisor payloads with target memory and recent cycles", () => { + routeEventToAtom("workspace.ws-1.session.sess-1.supervisor.state", { + event: "updated", + supervisor: { + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + state: "idle", + objective: "Ship feature", + targetId: "tgt-1", + evaluatorProviderId: "claude", + maxSupervisionCount: 0, + completedSupervisionCount: 0, + cycles: [], + currentTargetMemory: { + targetId: "tgt-1", + planGenerated: true, + plan: [], + stalledCount: 0, + updatedAt: 1, + }, + recentTargetCycles: [], + createdAt: 1, + updatedAt: 1, + }, + }, store); + + expect(store.get(supervisorsAtom).get("sess-1")?.targetId).toBe("tgt-1"); + expect(store.get(supervisorsAtom).get("sess-1")?.currentTargetMemory?.targetId).toBe("tgt-1"); +}); +``` + +- [ ] **Step 2: Run the UI tests and confirm failure** + +Run: `pnpm vitest packages/web/src/features/supervisor/components/supervisor-card.test.tsx packages/web/src/features/supervisor/components/objective-dialog.test.tsx packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx packages/web/src/app/providers.test.tsx --run` +Expected: FAIL because the supervisor payload and card UI do not yet know about target memory or recent target cycles. + +- [ ] **Step 3: Route enriched supervisor payloads through atoms and derived actions** + +```ts +// packages/web/src/features/supervisor/actions/use-supervisor-actions.ts +const currentTargetMemory = supervisor?.currentTargetMemory; +const recentTargetCycles = supervisor?.recentTargetCycles ?? []; +const activeStep = currentTargetMemory?.plan.find( + (step) => step.id === currentTargetMemory.activeStepId +); + +return { + actionError, + cycles, + currentTargetMemory, + recentTargetCycles, + activeStep, + progressSummary: currentTargetMemory?.progressSummary ?? null, + stalledCount: currentTargetMemory?.stalledCount ?? 0, + handlePause, + handleResume, + handleTrigger, + isBusy: supervisor?.state === "evaluating" || supervisor?.state === "injecting", + latestCycle, + latestCycleText, + openDialog, + stopReasonLabel, + stateClass: supervisor ? STATE_CLASSES[supervisor.state] : STATE_CLASSES.inactive, + stateLabel: t( + `supervisor.state.${supervisor ? supervisor.state : ("inactive" as SupervisorState)}` + ), + supervisor, +}; +``` + +- [ ] **Step 4: Render target summary and recent cycle reasoning on desktop and mobile** + +```tsx +// packages/web/src/features/supervisor/views/shared/supervisor-card.tsx +{progressSummary ? ( +
+ {progressSummary} +
+) : null} + +{activeStep ? ( +
+ Current step + {activeStep.title} +
+) : null} + +{typeof stalledCount === "number" ? ( +
stalled: {stalledCount}
+) : null} + +{currentTargetMemory?.plan.length ? ( +
    + {currentTargetMemory.plan.map((step) => ( +
  1. + {step.title} +
  2. + ))} +
+) : null} + +{recentTargetCycles.length ? ( +
    + {recentTargetCycles.map((cycle) => ( +
  1. + {cycle.result} + {cycle.reason ?? cycle.errorReason} +
  2. + ))} +
+) : null} +``` + +Because `mobile-supervisor-sheet.tsx` already renders `SupervisorCard`, prefer keeping the new target/memory UI in `SupervisorCard`. Only touch the mobile sheet if the larger card needs spacing, scroll, or footer adjustments. + +- [ ] **Step 5: Run the UI test set and verify it passes** + +Run: `pnpm vitest packages/web/src/features/supervisor/components/supervisor-card.test.tsx packages/web/src/features/supervisor/components/objective-dialog.test.tsx packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx packages/web/src/app/providers.test.tsx --run` +Expected: PASS with enriched supervisor payloads rendered in both desktop and mobile surfaces. + +- [ ] **Step 6: Commit** + +```bash +git add packages/web/src/features/supervisor/atoms.ts packages/web/src/features/supervisor/actions/use-supervisor-actions.ts packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts packages/web/src/features/supervisor/views/shared/supervisor-card.tsx packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx packages/web/src/app/providers.tsx packages/web/src/features/supervisor/components/supervisor-card.test.tsx packages/web/src/features/supervisor/components/objective-dialog.test.tsx packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx packages/web/src/app/providers.test.tsx +git commit -m "feat: show supervisor target memory and cycle history" +``` + +## Task 6: Run End-To-End Verification And Update The Written Plan If Needed + +**Files:** +- Modify: `docs/superpowers/plans/2026-05-12-supervisor-target-scoped-memory-implementation.md` if verification reveals scope or naming drift + +- [ ] **Step 1: Run the focused server and web supervisor test suites** + +Run: + +```bash +pnpm vitest \ + packages/server/src/supervisor/target-store.test.ts \ + packages/server/src/supervisor/context-builder.test.ts \ + packages/server/src/supervisor/evaluator.test.ts \ + packages/server/src/supervisor/evaluator.windows.test.ts \ + packages/server/src/supervisor/manager.test.ts \ + packages/server/src/__tests__/supervisor-manager.test.ts \ + packages/server/src/__tests__/supervisor-integration.test.ts \ + packages/server/src/__tests__/supervisor-commands.test.ts \ + packages/server/src/__tests__/supervisor-repo.test.ts \ + packages/server/src/storage/db.test.ts \ + packages/server/src/__tests__/db.test.ts \ + packages/web/src/features/supervisor/components/supervisor-card.test.tsx \ + packages/web/src/features/supervisor/components/objective-dialog.test.tsx \ + packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx \ + packages/web/src/app/providers.test.tsx \ + --run +``` + +Expected: PASS. If failures expose plan/spec drift, update the implementation and amend this plan document before proceeding. + +- [ ] **Step 2: Run a repo-level typecheck or equivalent package checks touching changed code** + +Run: + +```bash +pnpm --filter @coder-studio/server exec tsc --noEmit +pnpm --filter @coder-studio/web exec tsc --noEmit +``` + +Expected: PASS with no new type errors in supervisor domain, server wiring, or web UI. + +- [ ] **Step 3: Review workspace target artifacts manually in a local run** + +Run: + +```bash +pnpm dev +``` + +Manual expectations: + +- enabling supervisor creates `.coder-studio/supervisor/targets//meta.json` +- first manual trigger creates or fills `memory.json` with a generated plan +- repeated triggers append newline-delimited records to `cycles.jsonl` +- editing the objective creates a new `targetId` and marks the old target `superseded` +- the UI shows current step, progress summary, stalled count, and recent cycle reasons + +If local manual validation is not possible in the execution environment, note that explicitly in the final handoff. + +- [ ] **Step 4: Commit verification fixes if any were needed** + +```bash +git add -A +git commit -m "test: verify supervisor target-scoped memory flow" +``` + +Only make this commit if verification uncovered real code fixes. + +## Self-Review + +### Spec coverage + +This plan covers: + +- target-scoped memory under `.coder-studio/supervisor/targets/` +- `meta.json`, `memory.json`, and `cycles.jsonl` +- migration of existing supervisor rows through a real `v2 -> v3` database upgrade path +- first-trigger plan bootstrap +- `continue` / `stop` evaluator result shape +- objective edits creating new targets +- removal of git from the primary supervision signal +- UI rendering of current target progress and recent cycle reasoning + +### Placeholder scan + +No `TODO`, `TBD`, or "implement later" placeholders remain. Each task names exact files, verification commands, and expected outcomes. + +### Type consistency + +The plan consistently uses: + +- `targetId` +- `SupervisorTargetMemory` +- `SupervisorCycleTargetRecord` +- evaluator result `status: "continue" | "stop"` +- stop reasons `objective_complete | supervisor_uncertain | needs_user_input` +- database schema version `3` with explicit `v1 -> v2 -> v3` and `v2 -> v3` upgrade handling + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-12-supervisor-target-scoped-memory-implementation.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/plans/2026-05-13-mobile-terminal-paste-upload.md b/docs/superpowers/plans/2026-05-13-mobile-terminal-paste-upload.md new file mode 100644 index 000000000..0d2e44376 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-mobile-terminal-paste-upload.md @@ -0,0 +1,180 @@ +# Mobile Terminal Paste/Upload Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two mobile terminal actions: paste from clipboard and upload files/images from the device picker. + +**Architecture:** Keep the existing terminal upload pipeline in `use-paste-drop-upload`, but extend it with explicit entry points for clipboard reads and file picker uploads. `MobileTerminalInputBar` owns the visible buttons and emits user intent; `XtermHost` wires those intents to workspace-scoped upload helpers and disables the controls while uploads are in flight. Desktop terminal behavior stays unchanged. + +**Tech Stack:** React, TypeScript, Jotai, browser Clipboard API, browser file input, Vitest, Testing Library. + +--- + +### Task 1: Add mobile toolbar actions and wiring hooks + +**Files:** +- Modify: `packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.tsx` +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- Test: `packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Add assertions that the mobile input bar renders two new buttons labeled `Paste` and `Upload`, and that clicking them invokes dedicated callbacks instead of terminal key handlers. + +```tsx +it("renders paste and upload actions and dispatches their callbacks", () => { + const onPaste = vi.fn(); + const onUpload = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Paste" })); + fireEvent.click(screen.getByRole("button", { name: "Upload" })); + + expect(onPaste).toHaveBeenCalledTimes(1); + expect(onUpload).toHaveBeenCalledTimes(1); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx -t "renders paste and upload actions and dispatches their callbacks" -v` + +Expected: fail because the props and buttons do not exist yet. + +- [ ] **Step 3: Write the minimal implementation** + +Add `onPaste` and `onUpload` props to `MobileTerminalInputBar`, render two leading action buttons in the mobile bar, and forward clicks to the callbacks. In `xterm-host.tsx`, pass handlers that will be implemented in the next task. + +```tsx +interface MobileTerminalInputBarProps { + // existing props... + onPaste: () => void; + onUpload: () => void; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm vitest packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx -v` + +Expected: pass, with the new buttons appearing before the terminal key group. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.tsx packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx +git commit -m "feat: add mobile terminal paste and upload actions" +``` + +### Task 2: Implement clipboard paste and file-picker upload flows + +**Files:** +- Modify: `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts` +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- Test: `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx` +- Test: `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Add coverage for two new helpers: +- a clipboard path that reads `ClipboardItem`s or text and uploads image files when present +- a file-picker path that uploads selected files and then writes the quoted terminal command back into the PTY + +```ts +it("uploads clipboard image files when paste is requested", async () => { + // mock navigator.clipboard.read() to return an image ClipboardItem + // assert uploadFiles receives a File and sendTextToTerminal gets the quoted path +}); + +it("uploads files selected from the picker when upload is requested", async () => { + // mock an selection and assert the same upload path is used +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +`pnpm vitest packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx -v` + +Expected: fail because the new public helpers and host wiring do not exist yet. + +- [ ] **Step 3: Write the minimal implementation** + +Extend `use-paste-drop-upload` with explicit methods for: +- reading clipboard files/text from a user gesture +- uploading an arbitrary `File[]` + +Keep the existing paste/drop DOM listeners intact for desktop and non-mobile surfaces. In `xterm-host.tsx`, wire the new mobile buttons to the new helpers and add a hidden file input or equivalent picker flow scoped to mobile only. + +```ts +// shape only; keep existing uploadFiles/quoteShellSingle flow +type UploadSource = "clipboard" | "picker"; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: +`pnpm vitest packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx -v` + +Expected: pass, with clipboard and picker uploads both producing quoted terminal insertions and error toasts on failure. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +git commit -m "feat: wire mobile terminal clipboard and file upload" +``` + +### Task 3: Add labels, polish layout, and verify mobile behavior + +**Files:** +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/web/src/styles/components.css` +- Test: `packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Extend the mobile input bar test to assert the new action buttons are present, readable, and do not collapse the existing terminal key order on narrow layouts. + +```ts +expect(screen.getByRole("button", { name: "Paste" })).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument(); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx -v` + +Expected: fail until the labels and layout styles are added. + +- [ ] **Step 3: Write the minimal implementation** + +Add localized labels for the two actions in `en.json` and `zh.json`, then update `components.css` so the two action buttons read as a separate group from the terminal keys without shrinking the existing soft-key row. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm vitest packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx -v` + +Expected: pass, and the mobile bar should still fit on small screens with horizontal scrolling for the terminal keys. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/locales/en.json packages/web/src/locales/zh.json packages/web/src/styles/components.css packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx +git commit -m "feat: polish mobile terminal paste and upload actions" +``` + diff --git a/docs/superpowers/plans/2026-05-13-readme-top-structure-implementation.md b/docs/superpowers/plans/2026-05-13-readme-top-structure-implementation.md new file mode 100644 index 000000000..57e661048 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-readme-top-structure-implementation.md @@ -0,0 +1,134 @@ +# README Top Structure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure the top of both README files for faster product comprehension on GitHub and add a README-friendly animated demo preview. + +**Architecture:** Keep the existing repository sections after quick start, but replace the top section with a tighter landing-style structure. Use a lightweight animated GIF as the inline preview in README and keep the existing `mp4` as the full demo target. + +**Tech Stack:** Markdown, ffmpeg, existing demo assets in `docs/assets` + +--- + +### Task 1: Create README-Friendly Demo Preview Asset + +**Files:** +- Create: `docs/assets/demo-preview.gif` +- Modify: `docs/assets/demo.mp4` (no changes expected) +- Modify: `docs/assets/demo-poster.png` (no changes expected) + +- [ ] **Step 1: Generate a compact animated GIF from the recorded MP4** + +Run: + +```bash +ffmpeg -y -i docs/assets/demo.mp4 -vf "fps=8,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3" -loop 0 docs/assets/demo-preview.gif +``` + +Expected: `docs/assets/demo-preview.gif` is created successfully. + +- [ ] **Step 2: Verify the GIF asset exists and is a reasonable size** + +Run: + +```bash +ls -lh docs/assets/demo-preview.gif docs/assets/demo.mp4 docs/assets/demo-poster.png +``` + +Expected: the GIF exists and remains small enough for repository use. + +- [ ] **Step 3: Keep the MP4 as the full demo target** + +No file edit required. The README will use the GIF as inline preview and link through to `docs/assets/demo.mp4`. + +### Task 2: Refactor README.md Top Section + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Replace the current top block with the approved landing-style structure** + +Edit the top portion of `README.md` so it contains: + +- logo and title +- sharper one-line positioning statement +- supporting paragraph +- reduced badge set +- CTA row with `Watch Demo`, `Quick Start`, `Star on GitHub` +- language/docs links +- inline GIF preview linked to `docs/assets/demo.mp4` +- one-line demo framing copy +- `Why It Feels Different` +- quick start moved up + +- [ ] **Step 2: Remove or move down top-of-page content that competes with the demo** + +Specifically remove from the top block: + +- blockquote slogan +- top six-item feature list +- top static workspace overview screenshot +- discussions / issues / contributors badges from the first visual block + +- [ ] **Step 3: Keep lower repository sections unchanged unless needed for heading continuity** + +Preserve existing sections below quick start, including use cases, screenshots, feature overview, docs, roadmap, contributing, and license. + +### Task 3: Refactor README.zh-CN.md Top Section + +**Files:** +- Modify: `README.zh-CN.md` + +- [ ] **Step 1: Mirror the English information architecture in Chinese** + +Edit the top portion of `README.zh-CN.md` so it matches the same structure and CTA order as `README.md`. + +- [ ] **Step 2: Use Chinese copy that preserves the same product claims** + +The Chinese version should clearly communicate: + +- browser-based AI coding workspace +- cross-device continuity +- Claude Code and Codex in one workspace +- demo first, star second + +- [ ] **Step 3: Keep lower sections intact** + +Do not redesign the rest of the Chinese README in this pass. + +### Task 4: Verify README Rendering Inputs + +**Files:** +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Create: `docs/assets/demo-preview.gif` + +- [ ] **Step 1: Verify README media references** + +Run: + +```bash +rg -n "demo-preview\\.gif|demo\\.mp4|demo-poster\\.png|Quick Start|快速开始" README.md README.zh-CN.md +``` + +Expected: both README files reference the new GIF preview and the MP4 target consistently. + +- [ ] **Step 2: Inspect the diff for only intended files** + +Run: + +```bash +git diff -- README.md README.zh-CN.md docs/assets/demo-preview.gif +``` + +Expected: only the approved top-structure and preview changes appear. + +- [ ] **Step 3: Verify repository status remains scoped** + +Run: + +```bash +git status --short +``` + +Expected: only intended README/media/spec/plan changes are new in this task; unrelated untracked files remain untouched. diff --git a/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-1-foundation.md b/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-1-foundation.md new file mode 100644 index 000000000..9af3be574 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-1-foundation.md @@ -0,0 +1,114 @@ +# Conversion-First Activation Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Master plan:** `docs/superpowers/plans/2026-05-14-conversion-first-activation.md` +> +> **Spec:** `docs/superpowers/specs/2026-05-14-conversion-first-activation-design.md` + +**Goal:** Ship the new activation entry point so a new user can move from `Welcome` to `/setup`, and give the product a backend readiness surface the UI can depend on. + +**Architecture:** Phase 1 establishes the new activation frame without yet solving the full funnel. The web app gets a setup route and welcome CTA rewrite, while the server and shared domain layer expose `setup.status` and `setup.mobileAccessStatus` as the canonical readiness inputs for later phases. + +**Tech Stack:** TypeScript, React, React Router, Jotai, Vitest, Zod, websocket command dispatch + +--- + +## Phase Scope + +**Includes master tasks:** + +- [Task 1](./2026-05-14-conversion-first-activation.md#task-1-rewrite-welcome-and-add-the-setup-route-shell): rewrite welcome and add the `/setup` route shell +- [Task 2](./2026-05-14-conversion-first-activation.md#task-2-add-shared-setup-dtos-and-the-setupmobile-server-commands): add shared setup DTOs and server readiness commands + +**Does not include yet:** + +- Environment Doctor UI +- provider-first launch flow +- mobile continuation surface +- return/resume and Supervisor quick-start + +**Exit criteria:** + +- primary welcome CTA sends users to `/setup` +- `DesktopShell` renders `SetupPage` +- shared DTOs exist for setup/mobile status +- server returns structured readiness data via `setup.status` +- server returns structured LAN/auth exposure data via `setup.mobileAccessStatus` + +## Deliverables + +- welcome positioning moved from generic IDE framing to activation-first framing +- new `packages/web/src/features/setup/*` shell exists and is routable +- new shared readiness types in `packages/core` +- new server command surface in `packages/server/src/commands/setup.ts` + +## Tracking Checklist + +- [ ] Replace the welcome primary CTA with `Start Setup` +- [ ] Add `/setup` to `DesktopShell` +- [ ] Create the initial `SetupPage` shell and step shell component +- [ ] Add and pass welcome/route tests +- [ ] Add `SetupCheckDto`, `SetupCheckStatus`, and `MobileAccessStatusDto` +- [ ] Extend `CommandContext` with config needed for mobile access reporting +- [ ] Implement `setup.status` +- [ ] Implement `setup.mobileAccessStatus` +- [ ] Add and pass server command tests +- [ ] Commit Phase 1 changes + +## Files In Play + +**Web** + +- Modify: `packages/web/src/features/welcome/index.tsx` +- Modify: `packages/web/src/features/welcome/index.test.tsx` +- Modify: `packages/web/src/shells/desktop-shell.tsx` +- Modify: `packages/web/src/shells/desktop-shell.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` +- Create: `packages/web/src/features/setup/index.ts` +- Create: `packages/web/src/features/setup/views/setup-page.tsx` +- Create: `packages/web/src/features/setup/views/setup-page.test.tsx` +- Create: `packages/web/src/features/setup/components/setup-step-shell.tsx` + +**Core and server** + +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/server/src/ws/dispatch.ts` +- Modify: `packages/server/src/ws/hub.ts` +- Modify: `packages/server/src/commands/index.ts` +- Create: `packages/server/src/commands/setup.ts` +- Create: `packages/server/src/commands/setup.test.ts` + +## Verification + +Run these before closing the phase: + +```bash +pnpm exec vitest run \ + packages/web/src/features/welcome/index.test.tsx \ + packages/web/src/shells/desktop-shell.test.tsx \ + packages/web/src/features/setup/views/setup-page.test.tsx \ + packages/server/src/commands/setup.test.ts +``` + +Expected outcome: the welcome-to-setup path is green, the `/setup` route is green, and both setup readiness commands return typed data. + +## Watchouts + +- Keep the welcome rewrite focused on the conversion path; do not broaden this into a full UI redesign. +- Do not build the actual doctor flow yet; Phase 1 only needs the route shell plus backend contract. +- Preserve future compatibility for mobile continuation by keeping `setup.mobileAccessStatus` stable. + +## Detailed Execution Source + +Use the detailed step-by-step instructions in the master plan: + +- [Task 1 detailed steps](./2026-05-14-conversion-first-activation.md#task-1-rewrite-welcome-and-add-the-setup-route-shell) +- [Task 2 detailed steps](./2026-05-14-conversion-first-activation.md#task-2-add-shared-setup-dtos-and-the-setupmobile-server-commands) + +## Suggested Commit Boundary + +```bash +git commit -m "feat: add setup entrypoint and readiness foundation" +``` diff --git a/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-2-first-value.md b/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-2-first-value.md new file mode 100644 index 000000000..cd49fb0a0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-2-first-value.md @@ -0,0 +1,113 @@ +# Conversion-First Activation Phase 2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Master plan:** `docs/superpowers/plans/2026-05-14-conversion-first-activation.md` +> +> **Spec:** `docs/superpowers/specs/2026-05-14-conversion-first-activation-design.md` + +**Goal:** Turn setup from a shell into a real activation flow that gets the user environment-ready, provider-ready, and into a first useful AI task. + +**Architecture:** Phase 2 is the core conversion lift. It uses the Phase 1 readiness contract to drive the `Environment Doctor`, extracts workspace directory selection into a reusable surface, and moves provider readiness plus starter-task session creation into setup so the user can reach first value without bouncing across product areas. + +**Tech Stack:** TypeScript, React, Jotai, Vitest, existing workspace and provider launch flows + +--- + +## Phase Scope + +**Depends on:** + +- Phase 1 foundation and readiness commands + +**Includes master tasks:** + +- [Task 3](./2026-05-14-conversion-first-activation.md#task-3-build-the-environment-doctor-ui-and-extract-a-reusable-directory-picker): Environment Doctor UI and shared directory picker +- [Task 4](./2026-05-14-conversion-first-activation.md#task-4-move-provider-readiness-and-first-task-launch-into-setup): provider readiness and starter-task launch + +**Exit criteria:** + +- setup can progress from goal selection to doctor state +- failing readiness checks are visible with clear next actions +- workspace root selection no longer leaks `/home/spencer` +- setup can show provider readiness inline +- a ready provider can create a first session from a starter draft + +## Deliverables + +- `use-setup-flow` and `use-setup-status` +- `SetupGoalStep`, `SetupDoctorStep`, and `SetupLaunchStep` +- reusable directory-picker component shared with workspace launch +- provider-launch path that accepts `draft` +- starter templates for first-task launch + +## Tracking Checklist + +- [ ] Add setup flow state for `goal`, `doctor`, `launch`, and `success` +- [ ] Fetch and map `setup.status` into UI state +- [ ] Render failing doctor checks and fix actions +- [ ] Extract a reusable directory picker for setup and workspace launch +- [ ] Remove hardcoded `/home/spencer` from root-path behavior +- [ ] Add provider-state vocabulary shared across setup, settings, and draft launcher +- [ ] Extend provider launch to support `draft` +- [ ] Add starter templates such as `Explain this project` +- [ ] Ensure a ready provider can create the first session from setup +- [ ] Pass targeted setup, workspace-launch, and provider tests +- [ ] Commit Phase 2 changes + +## Files In Play + +**Setup** + +- Create: `packages/web/src/features/setup/actions/use-setup-flow.ts` +- Create: `packages/web/src/features/setup/actions/use-setup-status.ts` +- Create: `packages/web/src/features/setup/views/setup-goal-step.tsx` +- Create: `packages/web/src/features/setup/views/setup-doctor-step.tsx` +- Create: `packages/web/src/features/setup/views/setup-launch-step.tsx` +- Create: `packages/web/src/features/setup/components/setup-directory-picker.tsx` +- Modify: `packages/web/src/features/setup/views/setup-page.tsx` +- Modify: `packages/web/src/features/setup/views/setup-page.test.tsx` + +**Workspace and provider** + +- Modify: `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` +- Modify: `packages/web/src/features/workspace/views/shared/workspace-launch-modal.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/workspace-launch-modal.test.tsx` +- Modify: `packages/web/src/features/agent-panes/actions/use-provider-launcher.ts` +- Create: `packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx` +- Modify: `packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx` +- Modify: `packages/web/src/features/settings/components/provider-settings.tsx` +- Modify: `packages/web/src/features/settings/components/provider-settings.test.tsx` + +## Verification + +Run these before closing the phase: + +```bash +pnpm exec vitest run \ + packages/web/src/features/setup/views/setup-page.test.tsx \ + packages/web/src/features/workspace/views/shared/workspace-launch-modal.test.tsx \ + packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx \ + packages/web/src/features/settings/components/provider-settings.test.tsx +``` + +Expected outcome: setup can reach doctor and launch steps, root-path cleanup is covered, and first-task session launch works with `draft`. + +## Watchouts + +- This is the highest-ROI phase; do not dilute it with mobile polish or return-state work yet. +- Keep the doctor state model aligned with the DTO names from Phase 1. +- Avoid introducing a second provider-status vocabulary; reuse the same states everywhere. + +## Detailed Execution Source + +Use the detailed step-by-step instructions in the master plan: + +- [Task 3 detailed steps](./2026-05-14-conversion-first-activation.md#task-3-build-the-environment-doctor-ui-and-extract-a-reusable-directory-picker) +- [Task 4 detailed steps](./2026-05-14-conversion-first-activation.md#task-4-move-provider-readiness-and-first-task-launch-into-setup) + +## Suggested Commit Boundary + +```bash +git commit -m "feat: ship activation doctor and first-task launch" +``` diff --git a/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-3-mobile-continuation.md b/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-3-mobile-continuation.md new file mode 100644 index 000000000..dbb744068 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-3-mobile-continuation.md @@ -0,0 +1,99 @@ +# Conversion-First Activation Phase 3 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Master plan:** `docs/superpowers/plans/2026-05-14-conversion-first-activation.md` +> +> **Spec:** `docs/superpowers/specs/2026-05-14-conversion-first-activation-design.md` + +**Goal:** Productize phone continuation so the user can move from first success on desktop to the next differentiated outcome without reading docs. + +**Architecture:** Phase 3 builds on the setup success state and the mobile-access command introduced in Phase 1. It adds a reusable mobile assistant surface, exposes it from setup success and settings, and promotes `Continue on Phone` into the workspace shell so cross-device continuation becomes a normal next step instead of hidden product knowledge. + +**Tech Stack:** TypeScript, React, Jotai, Vitest, setup/mobile status command, existing workspace shell + +--- + +## Phase Scope + +**Depends on:** + +- Phase 1 readiness contract +- Phase 2 setup success state + +**Includes master task:** + +- [Task 5](./2026-05-14-conversion-first-activation.md#task-5-add-the-mobile-access-assistant-and-continue-on-phone): mobile assistant and cross-device CTA + +**Exit criteria:** + +- mobile assistant renders candidate LAN URLs +- local-only or unauthenticated exposure states are explained in product language +- setup success shows `Continue on Phone` +- settings exposes the same assistant +- workspace shell can surface the same continuation CTA + +## Deliverables + +- `packages/web/src/features/mobile-access/*` +- `SetupSuccessStep` +- settings integration for mobile continuation +- workspace-level `Continue on Phone` surface +- localized strings for mobile continuation states + +## Tracking Checklist + +- [ ] Add `useMobileAccess` +- [ ] Render LAN candidate URLs from `setup.mobileAccessStatus` +- [ ] Show the auth warning when only localhost/no password is configured +- [ ] Add `SetupSuccessStep` +- [ ] Show `Continue on Phone` in setup success +- [ ] Expose the same assistant in settings +- [ ] Expose the workspace-shell CTA +- [ ] Pass targeted mobile-access, settings, and workspace tests +- [ ] Commit Phase 3 changes + +## Files In Play + +- Create: `packages/web/src/features/mobile-access/index.ts` +- Create: `packages/web/src/features/mobile-access/actions/use-mobile-access.ts` +- Create: `packages/web/src/features/mobile-access/views/mobile-access-assistant.tsx` +- Create: `packages/web/src/features/mobile-access/views/mobile-access-assistant.test.tsx` +- Modify: `packages/web/src/features/setup/views/setup-success-step.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` +- Create: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +## Verification + +Run these before closing the phase: + +```bash +pnpm exec vitest run \ + packages/web/src/features/mobile-access/views/mobile-access-assistant.test.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx \ + packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx +``` + +Expected outcome: the assistant is available from setup success and settings, and the workspace shell can promote phone continuation. + +## Watchouts + +- Keep the assistant productized; do not collapse back into doc-like instructions. +- Use the server-reported URLs and auth state directly rather than adding duplicated client inference. +- Avoid over-investing in QR or polish unless the core continuation path is clearly working. + +## Detailed Execution Source + +Use the detailed step-by-step instructions in the master plan: + +- [Task 5 detailed steps](./2026-05-14-conversion-first-activation.md#task-5-add-the-mobile-access-assistant-and-continue-on-phone) + +## Suggested Commit Boundary + +```bash +git commit -m "feat: add phone continuation flow" +``` diff --git a/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-4-return-and-retention.md b/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-4-return-and-retention.md new file mode 100644 index 000000000..a147696a9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-4-return-and-retention.md @@ -0,0 +1,115 @@ +# Conversion-First Activation Phase 4 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Master plan:** `docs/superpowers/plans/2026-05-14-conversion-first-activation.md` +> +> **Spec:** `docs/superpowers/specs/2026-05-14-conversion-first-activation-design.md` + +**Goal:** Improve retention by making return visits legible and by offering higher-order next steps after activation succeeds. + +**Architecture:** Phase 4 productizes the return experience with `home.summary`, a welcome-page returning state, and resume routing. It then layers in Supervisor quick-start templates so the user who has already activated can more easily graduate into long-running workflows. + +**Tech Stack:** TypeScript, React, Vitest, shared DTOs, server commands, bootstrap flow, supervisor UI + +--- + +## Phase Scope + +**Depends on:** + +- Phase 1 setup/mobile DTO foundation +- preferably Phase 2 first-task launch, since return state is more valuable once sessions exist + +**Includes master tasks:** + +- [Task 6](./2026-05-14-conversion-first-activation.md#task-6-add-the-returning-home-summary-and-resume-behavior): returning summary and resume behavior +- [Task 7](./2026-05-14-conversion-first-activation.md#task-7-add-supervisor-quick-start-templates-and-run-the-final-regression-sweep): Supervisor quick-start templates + +**Exit criteria:** + +- server can return `home.summary` +- welcome page can render a returning summary card +- user can resume into the last relevant workspace/session path +- Supervisor dialog supports preset objective templates +- core activation regression suite is still green + +## Deliverables + +- `HomeSummaryDto` +- `packages/server/src/commands/home.ts` +- `ReturnSummaryCard` +- bootstrap and route-gate updates for resume +- Supervisor quick-start chips/templates + +## Tracking Checklist + +- [ ] Add `HomeSummaryDto` +- [ ] Implement `home.summary` +- [ ] Update bootstrap to hydrate returning state +- [ ] Render returning summary on welcome +- [ ] Wire resume CTA into workspace routing +- [ ] Add Supervisor objective templates +- [ ] Render quick-start template chips in the dialog +- [ ] Pass targeted home-summary, welcome, and Supervisor tests +- [ ] Run the final core activation regression sweep +- [ ] Commit Phase 4 changes + +## Files In Play + +**Return and resume** + +- Create: `packages/server/src/commands/home.ts` +- Create: `packages/server/src/commands/home.test.ts` +- Modify: `packages/server/src/commands/index.ts` +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/web/src/hooks/use-bootstrap.ts` +- Modify: `packages/web/src/features/welcome/index.tsx` +- Create: `packages/web/src/features/welcome/components/return-summary-card.tsx` +- Create: `packages/web/src/features/welcome/components/return-summary-card.test.tsx` +- Modify: `packages/web/src/features/welcome/index.test.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/workspace-route-gate.tsx` + +**Supervisor** + +- Modify: `packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts` +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx` +- Modify: `packages/web/src/features/supervisor/components/supervisor-card.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +## Verification + +Run these before closing the phase: + +```bash +pnpm exec vitest run \ + packages/server/src/commands/home.test.ts \ + packages/web/src/features/welcome/index.test.tsx \ + packages/web/src/features/welcome/components/return-summary-card.test.tsx \ + packages/web/src/features/supervisor/components/supervisor-card.test.tsx \ + packages/web/src/features/setup/views/setup-page.test.tsx \ + packages/web/src/features/mobile-access/views/mobile-access-assistant.test.tsx \ + packages/server/src/commands/setup.test.ts +``` + +Expected outcome: return/resume and Supervisor templates are green, and the core activation path has not regressed. + +## Watchouts + +- Keep return state short and decisive; it should reduce cognitive load, not add a dashboard. +- Resume routing must respect the actual last-viewed target instead of inventing a parallel navigation state. +- Supervisor quick-start is valuable only after activation; do not move it earlier in the funnel. + +## Detailed Execution Source + +Use the detailed step-by-step instructions in the master plan: + +- [Task 6 detailed steps](./2026-05-14-conversion-first-activation.md#task-6-add-the-returning-home-summary-and-resume-behavior) +- [Task 7 detailed steps](./2026-05-14-conversion-first-activation.md#task-7-add-supervisor-quick-start-templates-and-run-the-final-regression-sweep) + +## Suggested Commit Boundary + +```bash +git commit -m "feat: add return summary and supervisor quick start" +``` diff --git a/docs/superpowers/plans/2026-05-14-conversion-first-activation.md b/docs/superpowers/plans/2026-05-14-conversion-first-activation.md new file mode 100644 index 000000000..8deabc18e --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-conversion-first-activation.md @@ -0,0 +1,991 @@ +# Conversion-First Activation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver a conversion-first product path that takes a new user from welcome to a first useful AI task, then promotes phone continuation and later resume. + +**Architecture:** Add a dedicated `setup` feature on the web app, backed by a new `setup.status` command and shared setup DTOs in `@coder-studio/core`. Reuse existing workspace browse/open, provider runtime/install, session creation, last-viewed target, and supervisor state instead of building parallel systems; P0 ships the first-success path, P1 adds mobile continuation, and P2 adds return-and-resume. + +**Tech Stack:** TypeScript, React, React Router, Jotai, Vitest, Zod, existing websocket command dispatch, existing `user_settings` storage + +--- + +## Phase Tracking Index + +This file remains the detailed source of truth. For day-to-day tracking, use the four phase plans below: + +- Phase 1: [Conversion-First Activation Phase 1 Implementation Plan](./2026-05-14-conversion-first-activation-phase-1-foundation.md) +- Phase 2: [Conversion-First Activation Phase 2 Implementation Plan](./2026-05-14-conversion-first-activation-phase-2-first-value.md) +- Phase 3: [Conversion-First Activation Phase 3 Implementation Plan](./2026-05-14-conversion-first-activation-phase-3-mobile-continuation.md) +- Phase 4: [Conversion-First Activation Phase 4 Implementation Plan](./2026-05-14-conversion-first-activation-phase-4-return-and-retention.md) + +## Scope Check + +This spec spans three sequential product slices rather than one indivisible feature: + +- P0: first successful task +- P1: cross-device continuation +- P2: return and resume + +They are kept in one plan because each phase builds directly on the previous one and shares the same activation funnel. Execution should still stop after each task and verify the new slice is working before moving deeper into the funnel. + +## File Structure + +- Modify: `packages/core/src/domain/types.ts` + - Add shared DTOs for setup readiness, mobile access status, first-task templates, and home summary. +- Modify: `packages/server/src/commands/index.ts` + - Register new setup and home-summary commands. +- Modify: `packages/server/src/ws/dispatch.ts` + - Extend `CommandContext` with server config so setup/mobile commands can report host, port, and auth readiness. +- Modify: `packages/server/src/ws/hub.ts` + - Pass config into `CommandContext`. +- Create: `packages/server/src/commands/setup.ts` + - Expose `setup.status` and `setup.mobileAccessStatus`. +- Create: `packages/server/src/commands/setup.test.ts` + - Cover setup readiness and mobile access command behavior. +- Create: `packages/server/src/commands/home.ts` + - Expose `home.summary`. +- Create: `packages/server/src/commands/home.test.ts` + - Cover returning-summary behavior. +- Modify: `packages/web/src/shells/desktop-shell.tsx` + - Add the `/setup` route. +- Modify: `packages/web/src/shells/desktop-shell.test.tsx` + - Cover `/setup` and route transitions. +- Modify: `packages/web/src/features/welcome/index.tsx` + - Rewrite the welcome CTA hierarchy and support a returning-summary variant. +- Modify: `packages/web/src/features/welcome/index.test.tsx` + - Cover welcome-to-setup navigation and returning summary rendering. +- Modify: `packages/web/src/locales/en.json` + - Add English strings for setup, mobile access, return summary, and supervisor templates. +- Modify: `packages/web/src/locales/zh.json` + - Add Chinese strings for setup, mobile access, return summary, and supervisor templates. +- Create: `packages/web/src/features/setup/index.ts` + - Export the new setup feature. +- Create: `packages/web/src/features/setup/actions/use-setup-flow.ts` + - Centralize setup step state, goal selection, status refresh, and launch progression. +- Create: `packages/web/src/features/setup/actions/use-setup-status.ts` + - Fetch `setup.status`, map checks into UI state, and expose refresh actions. +- Create: `packages/web/src/features/setup/views/setup-page.tsx` + - Render the setup shell and route between goal, doctor, launch, and success steps. +- Create: `packages/web/src/features/setup/views/setup-page.test.tsx` + - Cover the setup flow across steps. +- Create: `packages/web/src/features/setup/views/setup-goal-step.tsx` + - Render the three goal choices. +- Create: `packages/web/src/features/setup/views/setup-doctor-step.tsx` + - Render readiness checks, fix actions, and workspace selection. +- Create: `packages/web/src/features/setup/views/setup-launch-step.tsx` + - Render provider readiness and first-task templates. +- Create: `packages/web/src/features/setup/views/setup-success-step.tsx` + - Render success summary and `Continue on Phone`. +- Create: `packages/web/src/features/setup/components/setup-step-shell.tsx` + - Shared setup header, progress, and footer actions. +- Create: `packages/web/src/features/setup/components/setup-directory-picker.tsx` + - Reuse workspace directory selection without depending on the legacy modal. +- Modify: `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` + - Remove hardcoded root paths and expose reusable directory-picker state. +- Modify: `packages/web/src/features/workspace/views/shared/workspace-launch-modal.tsx` + - Reuse the extracted directory picker component and keep modal behavior for manual open. +- Modify: `packages/web/src/features/workspace/views/shared/workspace-launch-modal.test.tsx` + - Cover the updated root path behavior and shared picker integration. +- Modify: `packages/web/src/features/agent-panes/actions/use-provider-launcher.ts` + - Support setup-driven provider readiness and `draft`-backed first-task session creation. +- Create: `packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx` + - Cover install, ready, and `draft` launch flows. +- Modify: `packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx` + - Reuse the same provider-state vocabulary introduced in setup. +- Modify: `packages/web/src/features/settings/components/provider-settings.tsx` + - Reuse the same provider-state vocabulary introduced in setup. +- Modify: `packages/web/src/features/settings/components/provider-settings.test.tsx` + - Cover shared provider-state rendering. +- Create: `packages/web/src/features/mobile-access/index.ts` + - Export the mobile access assistant. +- Create: `packages/web/src/features/mobile-access/actions/use-mobile-access.ts` + - Fetch `setup.mobileAccessStatus` and expose QR/link actions. +- Create: `packages/web/src/features/mobile-access/views/mobile-access-assistant.tsx` + - Render the mobile continuation surface for setup success and settings. +- Create: `packages/web/src/features/mobile-access/views/mobile-access-assistant.test.tsx` + - Cover local-only vs ready states. +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` + - Expose the mobile access assistant from settings. +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` + - Cover settings integration. +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` + - Surface `Continue on Phone` after first-task success and in the workspace shell. +- Create: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx` + - Cover the workspace CTA. +- Create: `packages/web/src/features/welcome/components/return-summary-card.tsx` + - Render the returning-user summary block. +- Create: `packages/web/src/features/welcome/components/return-summary-card.test.tsx` + - Cover return-summary content and resume CTA. +- Modify: `packages/web/src/hooks/use-bootstrap.ts` + - Hydrate home summary in returning mode without regressing existing workspace bootstrap. +- Modify: `packages/web/src/features/workspace/views/shared/workspace-route-gate.tsx` + - Allow resume routing when the last-viewed target and home summary are present. +- Modify: `packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts` + - Support preset objective templates for quick-start. +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx` + - Add quick-start template actions. +- Modify: `packages/web/src/features/supervisor/components/supervisor-card.test.tsx` + - Cover the quick-start entry points. + +### Task 1: Rewrite welcome and add the setup route shell + +**Files:** +- Modify: `packages/web/src/features/welcome/index.tsx` +- Modify: `packages/web/src/features/welcome/index.test.tsx` +- Modify: `packages/web/src/shells/desktop-shell.tsx` +- Modify: `packages/web/src/shells/desktop-shell.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` +- Create: `packages/web/src/features/setup/index.ts` +- Create: `packages/web/src/features/setup/views/setup-page.tsx` +- Create: `packages/web/src/features/setup/views/setup-page.test.tsx` +- Create: `packages/web/src/features/setup/components/setup-step-shell.tsx` + +- [ ] **Step 1: Write the failing route and CTA tests** + +```tsx +it("navigates from Welcome to /setup from the primary CTA", () => { + const store = createStore(); + store.set(localeAtom, "en"); + + render( + + + + } /> + Setup Screen
} /> + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Start Setup" })); + + expect(screen.getByText("Setup Screen")).toBeInTheDocument(); +}); + +it("renders SetupPage on /setup in DesktopShell", () => { + window.history.replaceState({}, "", "/setup"); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(authEnabledAtom, false); + store.set(authenticatedAtom, true); + + renderShell(store); + + expect(screen.getByText("Choose your setup goal")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the desktop and welcome tests to verify they fail** + +Run: `pnpm exec vitest run packages/web/src/features/welcome/index.test.tsx packages/web/src/shells/desktop-shell.test.tsx packages/web/src/features/setup/views/setup-page.test.tsx` + +Expected: FAIL because `Start Setup`, `/setup`, and `SetupPage` do not exist yet + +- [ ] **Step 3: Implement the new primary CTA and route shell** + +```tsx +// packages/web/src/features/welcome/index.tsx +const handleStartSetup = () => { + navigate("/setup"); +}; + + +``` + +```tsx +// packages/web/src/shells/desktop-shell.tsx +import { SetupPage } from "../features/setup"; + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +``` + +```tsx +// packages/web/src/features/setup/views/setup-page.tsx +export function SetupPage() { + return ( +
+ + + +
+ ); +} +``` + +- [ ] **Step 4: Run the tests to verify the route shell passes** + +Run: `pnpm exec vitest run packages/web/src/features/welcome/index.test.tsx packages/web/src/shells/desktop-shell.test.tsx packages/web/src/features/setup/views/setup-page.test.tsx` + +Expected: PASS with welcome navigation and `/setup` route coverage + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/welcome/index.tsx packages/web/src/features/welcome/index.test.tsx packages/web/src/shells/desktop-shell.tsx packages/web/src/shells/desktop-shell.test.tsx packages/web/src/locales/en.json packages/web/src/locales/zh.json packages/web/src/features/setup/index.ts packages/web/src/features/setup/views/setup-page.tsx packages/web/src/features/setup/views/setup-page.test.tsx packages/web/src/features/setup/components/setup-step-shell.tsx +git commit -m "feat: add setup route and welcome activation CTA" +``` + +### Task 2: Add shared setup DTOs and the setup/mobile server commands + +**Files:** +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/server/src/ws/dispatch.ts` +- Modify: `packages/server/src/ws/hub.ts` +- Modify: `packages/server/src/commands/index.ts` +- Create: `packages/server/src/commands/setup.ts` +- Create: `packages/server/src/commands/setup.test.ts` + +- [ ] **Step 1: Write the failing server command tests for `setup.status` and `setup.mobileAccessStatus`** + +```ts +it("returns setup readiness checks and provider status", async () => { + const result = await dispatch( + { + kind: "command", + id: "setup-status", + op: "setup.status", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + checks: expect.arrayContaining([ + expect.objectContaining({ id: "node" }), + expect.objectContaining({ id: "git" }), + expect.objectContaining({ id: "workspace_root" }), + expect.objectContaining({ id: "provider" }), + ]), + }); +}); + +it("returns mobile access candidates and auth state", async () => { + const result = await dispatch( + { + kind: "command", + id: "setup-mobile-status", + op: "setup.mobileAccessStatus", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + listenHost: "localhost", + port: 4173, + authEnabled: false, + candidateUrls: expect.any(Array), + }); +}); +``` + +- [ ] **Step 2: Run the new server tests to verify they fail** + +Run: `pnpm exec vitest run packages/server/src/commands/setup.test.ts` + +Expected: FAIL with unknown operations for `setup.status` and `setup.mobileAccessStatus` + +- [ ] **Step 3: Add the shared DTOs and command implementations** + +```ts +// packages/core/src/domain/types.ts +export type SetupCheckId = + | "node" + | "git" + | "workspace_root" + | "provider" + | "provider_auth" + | "mobile_host" + | "mobile_password"; + +export type SetupCheckStatus = "checking" | "ready" | "needs_fix" | "fixing" | "fixed" | "failed"; + +export interface SetupCheckDto { + id: SetupCheckId; + status: SetupCheckStatus; + detail: string; + fixKind?: "open_workspace_picker" | "install_provider" | "open_settings" | "show_mobile_assistant"; +} + +export interface MobileAccessStatusDto { + listenHost: string; + port: number; + authEnabled: boolean; + candidateUrls: string[]; +} +``` + +```ts +// packages/server/src/ws/dispatch.ts +import type { ServerConfig } from "../config.js"; + +export interface CommandContext { + // existing fields... + config: Pick; +} +``` + +```ts +// packages/server/src/commands/setup.ts +registerCommand("setup.status", z.object({}), async (_args, ctx) => { + const runtime = await buildProviderRuntimeStatus(ctx.providerRegistry, ctx.providerRuntimeDeps); + const hasWorkspace = ctx.workspaceMgr.list().length > 0; + const providersReady = Object.values(runtime.providers).some((entry) => entry.available); + + return { + checks: [ + { + id: "workspace_root", + status: hasWorkspace ? "ready" : "needs_fix", + detail: hasWorkspace ? "Workspace root selected." : "Choose a workspace root to continue.", + fixKind: hasWorkspace ? undefined : "open_workspace_picker", + }, + { + id: "provider", + status: providersReady ? "ready" : "needs_fix", + detail: providersReady ? "At least one provider runtime is available." : "Install Claude or Codex to launch a session.", + fixKind: providersReady ? undefined : "install_provider", + }, + ], + providers: runtime.providers, + }; +}); + +registerCommand("setup.mobileAccessStatus", z.object({}), async (_args, ctx) => { + const candidateUrls = getPrivateIpv4Addresses().map((ip) => `http://${ip}:${ctx.config.port}`); + + return { + listenHost: ctx.config.host, + port: ctx.config.port, + authEnabled: ctx.config.auth.enabled, + candidateUrls, + }; +}); +``` + +Implementation note: `workspaceMgr.list()` and `getPrivateIpv4Addresses()` may require adding a small helper if the current manager/config surface lacks them; keep those helpers local to `setup.ts`. + +- [ ] **Step 4: Run the server command tests to verify they pass** + +Run: `pnpm exec vitest run packages/server/src/commands/setup.test.ts` + +Expected: PASS with both setup commands returning structured readiness data + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/domain/types.ts packages/server/src/ws/dispatch.ts packages/server/src/ws/hub.ts packages/server/src/commands/index.ts packages/server/src/commands/setup.ts packages/server/src/commands/setup.test.ts +git commit -m "feat: add setup readiness commands" +``` + +### Task 3: Build the Environment Doctor UI and extract a reusable directory picker + +**Files:** +- Create: `packages/web/src/features/setup/actions/use-setup-flow.ts` +- Create: `packages/web/src/features/setup/actions/use-setup-status.ts` +- Create: `packages/web/src/features/setup/views/setup-goal-step.tsx` +- Create: `packages/web/src/features/setup/views/setup-doctor-step.tsx` +- Create: `packages/web/src/features/setup/components/setup-directory-picker.tsx` +- Modify: `packages/web/src/features/setup/views/setup-page.tsx` +- Modify: `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` +- Modify: `packages/web/src/features/workspace/views/shared/workspace-launch-modal.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/workspace-launch-modal.test.tsx` +- Modify: `packages/web/src/features/setup/views/setup-page.test.tsx` + +- [ ] **Step 1: Write the failing setup flow tests for goal selection, doctor rendering, and root-path cleanup** + +```tsx +it("moves from goal selection to Environment Doctor and shows failing checks", async () => { + const dispatch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + data: { + checks: [ + { id: "workspace_root", status: "needs_fix", detail: "Choose a workspace root.", fixKind: "open_workspace_picker" }, + { id: "provider", status: "needs_fix", detail: "Install Claude or Codex.", fixKind: "install_provider" }, + ], + providers: {}, + }, + }); + + renderSetupPage(dispatch); + + fireEvent.click(screen.getByRole("button", { name: "Start on this machine" })); + + expect(await screen.findByText("Choose a workspace root.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Select Workspace Root" })).toBeInTheDocument(); +}); + +it("does not include /home/spencer in the workspace root chips anymore", async () => { + renderWorkspaceLaunchModalWithBrowseResult({ + currentPath: "/home/me", + parentPath: "/home", + directories: [], + rootPaths: ["/", "~", "/workspaces"], + }); + + expect(screen.queryByText("/home/spencer")).not.toBeInTheDocument(); + expect(screen.getByText("/workspaces")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the setup and workspace-launch tests to verify they fail** + +Run: `pnpm exec vitest run packages/web/src/features/setup/views/setup-page.test.tsx packages/web/src/features/workspace/views/shared/workspace-launch-modal.test.tsx` + +Expected: FAIL because the doctor step, shared picker, and cleaned root path behavior do not exist yet + +- [ ] **Step 3: Implement the doctor step and reusable directory picker** + +```tsx +// packages/web/src/features/setup/actions/use-setup-flow.ts +export type SetupStepId = "goal" | "doctor" | "launch" | "success"; + +export function useSetupFlow() { + const [step, setStep] = useState("goal"); + const [goal, setGoal] = useState<"local" | "phone" | "long_task" | null>(null); + + return { + step, + goal, + selectGoal(nextGoal: "local" | "phone" | "long_task") { + setGoal(nextGoal); + setStep("doctor"); + }, + goToLaunch() { + setStep("launch"); + }, + complete() { + setStep("success"); + }, + }; +} +``` + +```tsx +// packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts +const [rootPaths, setRootPaths] = useState(["/", "~"]); + +setRootPaths(result.data.rootPaths?.length ? result.data.rootPaths : ["/", "~"]); +``` + +```tsx +// packages/web/src/features/setup/views/setup-doctor-step.tsx +{checks.map((check) => ( +
+
{t(`setup.check.${check.id}.title`)}
+

{check.detail}

+ {check.fixKind === "open_workspace_picker" ? ( + + ) : null} +
+))} +``` + +- [ ] **Step 4: Run the tests to verify the doctor and picker integration passes** + +Run: `pnpm exec vitest run packages/web/src/features/setup/views/setup-page.test.tsx packages/web/src/features/workspace/views/shared/workspace-launch-modal.test.tsx` + +Expected: PASS with goal-to-doctor coverage and root-path cleanup covered + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/setup/actions/use-setup-flow.ts packages/web/src/features/setup/actions/use-setup-status.ts packages/web/src/features/setup/views/setup-goal-step.tsx packages/web/src/features/setup/views/setup-doctor-step.tsx packages/web/src/features/setup/components/setup-directory-picker.tsx packages/web/src/features/setup/views/setup-page.tsx packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts packages/web/src/features/workspace/views/shared/workspace-launch-modal.tsx packages/web/src/features/workspace/views/shared/workspace-launch-modal.test.tsx packages/web/src/features/setup/views/setup-page.test.tsx +git commit -m "feat: add setup doctor and shared directory picker" +``` + +### Task 4: Move provider readiness and first-task launch into setup + +**Files:** +- Modify: `packages/web/src/features/agent-panes/actions/use-provider-launcher.ts` +- Create: `packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx` +- Create: `packages/web/src/features/setup/views/setup-launch-step.tsx` +- Modify: `packages/web/src/features/setup/views/setup-page.tsx` +- Modify: `packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx` +- Modify: `packages/web/src/features/settings/components/provider-settings.tsx` +- Modify: `packages/web/src/features/settings/components/provider-settings.test.tsx` +- Modify: `packages/web/src/features/setup/views/setup-page.test.tsx` + +- [ ] **Step 1: Write the failing setup launch tests for ready providers, installable providers, and first-task templates** + +```tsx +it("creates a first session with a starter draft when the provider is ready", async () => { + const dispatch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + data: { + checks: [ + { id: "workspace_root", status: "ready", detail: "Workspace root selected." }, + { id: "provider", status: "ready", detail: "Claude is ready." }, + ], + providers: { + claude: { available: true, autoInstallSupported: true, missingCommands: [], missingPrerequisites: [] }, + }, + }, + }) + .mockResolvedValueOnce({ + ok: true, + data: { id: "sess-1", workspaceId: "ws-1", providerId: "claude" }, + }); + + renderSetupLaunchStep(dispatch, { workspaceId: "ws-1" }); + + fireEvent.click(screen.getByRole("button", { name: "Explain this project" })); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith("session.create", { + workspaceId: "ws-1", + providerId: "claude", + draft: "Explain the structure of this repository and where to start.", + }); + }); +}); +``` + +- [ ] **Step 2: Run the provider and setup launch tests to verify they fail** + +Run: `pnpm exec vitest run packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx packages/web/src/features/settings/components/provider-settings.test.tsx packages/web/src/features/setup/views/setup-page.test.tsx` + +Expected: FAIL because setup launch does not yet pass `draft`, and provider status is not shared across setup/settings/draft launcher + +- [ ] **Step 3: Extend provider launch support and add the first-task launch step** + +```ts +// packages/web/src/features/agent-panes/actions/use-provider-launcher.ts +interface LaunchProviderOptions { + draft?: string; +} + +const launch = async (providerId: ProviderId, options?: LaunchProviderOptions): Promise => { + // existing runtime/install logic... + const createResult = await dispatch("session.create", { + workspaceId, + providerId, + draft: options?.draft, + }); +}; +``` + +```tsx +// packages/web/src/features/setup/views/setup-launch-step.tsx +const templates = [ + { + id: "explain_project", + title: t("setup.template.explain_project.title"), + draft: "Explain the structure of this repository and where to start.", + }, + { + id: "run_tests", + title: t("setup.template.run_tests.title"), + draft: "Run the relevant tests for this repository, summarize failures, and suggest the next fix.", + }, + { + id: "review_codebase", + title: t("setup.template.review_codebase.title"), + draft: "Read the key files in this codebase and suggest the most important improvements.", + }, +]; +``` + +- [ ] **Step 4: Run the setup/provider tests to verify the first-task flow passes** + +Run: `pnpm exec vitest run packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx packages/web/src/features/settings/components/provider-settings.test.tsx packages/web/src/features/setup/views/setup-page.test.tsx` + +Expected: PASS with `draft`-backed session creation and shared provider-state wording + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/agent-panes/actions/use-provider-launcher.ts packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx packages/web/src/features/setup/views/setup-launch-step.tsx packages/web/src/features/setup/views/setup-page.tsx packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx packages/web/src/features/settings/components/provider-settings.tsx packages/web/src/features/settings/components/provider-settings.test.tsx packages/web/src/features/setup/views/setup-page.test.tsx +git commit -m "feat: launch first tasks from setup" +``` + +### Task 5: Add the mobile access assistant and `Continue on Phone` + +**Files:** +- Create: `packages/web/src/features/mobile-access/index.ts` +- Create: `packages/web/src/features/mobile-access/actions/use-mobile-access.ts` +- Create: `packages/web/src/features/mobile-access/views/mobile-access-assistant.tsx` +- Create: `packages/web/src/features/mobile-access/views/mobile-access-assistant.test.tsx` +- Modify: `packages/web/src/features/setup/views/setup-success-step.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` +- Create: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write the failing mobile-access and workspace CTA tests** + +```tsx +it("shows candidate LAN URLs and an auth warning when only localhost is configured", async () => { + const dispatch = vi.fn().mockResolvedValue({ + ok: true, + data: { + listenHost: "localhost", + port: 4173, + authEnabled: false, + candidateUrls: ["http://192.168.1.23:4173"], + }, + }); + + renderMobileAccessAssistant(dispatch); + + expect(await screen.findByText("http://192.168.1.23:4173")).toBeInTheDocument(); + expect(screen.getByText("Add a password before exposing this workspace on your network.")).toBeInTheDocument(); +}); + +it("renders Continue on Phone in the workspace shell after setup success", () => { + renderWorkspaceDesktopView({ showContinueOnPhone: true }); + + expect(screen.getByRole("button", { name: "Continue on Phone" })).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the mobile access tests to verify they fail** + +Run: `pnpm exec vitest run packages/web/src/features/mobile-access/views/mobile-access-assistant.test.tsx packages/web/src/features/settings/components/settings-page.test.tsx packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx` + +Expected: FAIL because the mobile assistant and workspace CTA do not exist yet + +- [ ] **Step 3: Implement the mobile assistant and success-path CTA** + +```ts +// packages/web/src/features/mobile-access/actions/use-mobile-access.ts +export function useMobileAccess() { + const dispatch = useAtomValue(dispatchCommandAtom); + const [status, setStatus] = useState(null); + + useEffect(() => { + void dispatch("setup.mobileAccessStatus", {}).then((result) => { + if (result.ok && result.data) { + setStatus(result.data); + } + }); + }, [dispatch]); + + return { + status, + primaryUrl: status?.candidateUrls[0] ?? null, + }; +} +``` + +```tsx +// packages/web/src/features/setup/views/setup-success-step.tsx +
+ + +
+ +``` + +- [ ] **Step 4: Run the tests to verify the mobile continuation flow passes** + +Run: `pnpm exec vitest run packages/web/src/features/mobile-access/views/mobile-access-assistant.test.tsx packages/web/src/features/settings/components/settings-page.test.tsx packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx` + +Expected: PASS with the assistant available from setup success, settings, and the workspace shell + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/mobile-access/index.ts packages/web/src/features/mobile-access/actions/use-mobile-access.ts packages/web/src/features/mobile-access/views/mobile-access-assistant.tsx packages/web/src/features/mobile-access/views/mobile-access-assistant.test.tsx packages/web/src/features/setup/views/setup-success-step.tsx packages/web/src/features/settings/components/settings-page.tsx packages/web/src/features/settings/components/settings-page.test.tsx packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx packages/web/src/locales/en.json packages/web/src/locales/zh.json +git commit -m "feat: add mobile continuation assistant" +``` + +### Task 6: Add the returning home summary and resume behavior + +**Files:** +- Create: `packages/server/src/commands/home.ts` +- Create: `packages/server/src/commands/home.test.ts` +- Modify: `packages/server/src/commands/index.ts` +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/web/src/hooks/use-bootstrap.ts` +- Modify: `packages/web/src/features/welcome/index.tsx` +- Create: `packages/web/src/features/welcome/components/return-summary-card.tsx` +- Create: `packages/web/src/features/welcome/components/return-summary-card.test.tsx` +- Modify: `packages/web/src/features/welcome/index.test.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/workspace-route-gate.tsx` + +- [ ] **Step 1: Write the failing home-summary and returning-welcome tests** + +```ts +it("returns the last viewed target, latest session, and supervisor summary", async () => { + const result = await dispatch( + { + kind: "command", + id: "home-summary", + op: "home.summary", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + lastViewedTarget: expect.anything(), + latestSession: expect.anything(), + }); +}); +``` + +```tsx +it("renders a returning summary with a resume CTA when home.summary is available", async () => { + renderWelcomeReturningState({ + summary: { + lastViewedTarget: { workspaceId: "ws-1", sessionId: "sess-1", updatedAt: 1 }, + latestSession: { id: "sess-1", title: "Run tests", providerId: "codex", state: "idle", lastActiveAt: 1 }, + latestSupervisor: null, + }, + }); + + expect(await screen.findByText("Continue your last task")).toBeInTheDocument(); + expect(screen.getByText("Run tests")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the home-summary and welcome tests to verify they fail** + +Run: `pnpm exec vitest run packages/server/src/commands/home.test.ts packages/web/src/features/welcome/index.test.tsx packages/web/src/features/welcome/components/return-summary-card.test.tsx` + +Expected: FAIL because `home.summary` and the returning summary card do not exist yet + +- [ ] **Step 3: Implement the returning summary DTO, command, and welcome variant** + +```ts +// packages/core/src/domain/types.ts +export interface HomeSummaryDto { + lastViewedTarget: WorkspaceLastViewedTarget | null; + latestSession: { + id: string; + title: string; + providerId: string; + state: string; + lastActiveAt: number; + } | null; + latestSupervisor: { + id: string; + objective: string; + state: string; + sessionId: string; + } | null; +} +``` + +```ts +// packages/server/src/commands/home.ts +registerCommand("home.summary", z.object({}), async (_args, ctx) => { + const lastViewedTarget = readLastViewedTarget(ctx.db); + if (!lastViewedTarget) { + return { + lastViewedTarget: null, + latestSession: null, + latestSupervisor: null, + }; + } + + const sessions = ctx.sessionMgr.getForWorkspace(lastViewedTarget.workspaceId); + const latestSession = + sessions.find((session) => session.id === lastViewedTarget.sessionId) ?? + sessions.sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0] ?? + null; + + return { + lastViewedTarget, + latestSession: latestSession + ? { + id: latestSession.id, + title: latestSession.title ?? "Untitled session", + providerId: latestSession.providerId, + state: latestSession.state, + lastActiveAt: latestSession.lastActiveAt, + } + : null, + latestSupervisor: latestSession ? summarizeSupervisor(ctx.supervisorMgr.getBySession(latestSession.id)) : null, + }; +}); +``` + +```tsx +// packages/web/src/features/welcome/index.tsx +if (homeSummary?.latestSession) { + return ( + navigate("/workspace")} + onOpenSettings={handleOpenSettings} + /> + ); +} +``` + +- [ ] **Step 4: Run the home-summary and welcome tests to verify the returning flow passes** + +Run: `pnpm exec vitest run packages/server/src/commands/home.test.ts packages/web/src/features/welcome/index.test.tsx packages/web/src/features/welcome/components/return-summary-card.test.tsx` + +Expected: PASS with a resume-ready welcome state + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/commands/home.ts packages/server/src/commands/home.test.ts packages/server/src/commands/index.ts packages/core/src/domain/types.ts packages/web/src/hooks/use-bootstrap.ts packages/web/src/features/welcome/index.tsx packages/web/src/features/welcome/components/return-summary-card.tsx packages/web/src/features/welcome/components/return-summary-card.test.tsx packages/web/src/features/welcome/index.test.tsx packages/web/src/features/workspace/views/shared/workspace-route-gate.tsx +git commit -m "feat: add returning welcome summary" +``` + +### Task 7: Add Supervisor quick-start templates and run the final regression sweep + +**Files:** +- Modify: `packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts` +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx` +- Modify: `packages/web/src/features/supervisor/components/supervisor-card.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write the failing supervisor quick-start test** + +```tsx +it("prefills the enable dialog when a quick-start template is clicked", async () => { + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Enable Supervisor" })); + fireEvent.click(screen.getByRole("button", { name: "Run tests and summarize failures" })); + + expect(screen.getByLabelText("Objective")).toHaveValue( + "Run the relevant tests, summarize failures, and decide whether to continue automatically or stop for review." + ); +}); +``` + +- [ ] **Step 2: Run the supervisor test to verify it fails** + +Run: `pnpm exec vitest run packages/web/src/features/supervisor/components/supervisor-card.test.tsx` + +Expected: FAIL because the dialog does not expose preset templates yet + +- [ ] **Step 3: Add template support to the objective dialog state and content** + +```ts +// packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts +export const SUPERVISOR_OBJECTIVE_TEMPLATES = [ + { + id: "run_tests", + labelKey: "supervisor.template.run_tests", + objective: + "Run the relevant tests, summarize failures, and decide whether to continue automatically or stop for review.", + }, + { + id: "review_code", + labelKey: "supervisor.template.review_code", + objective: + "Review the current code changes, summarize the most important risks, and stop if manual intervention is needed.", + }, + { + id: "background_progress", + labelKey: "supervisor.template.background_progress", + objective: + "Keep evaluating the session in the background and report when progress stalls or a human decision is needed.", + }, +] as const; +``` + +```tsx +// packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx +
+ {SUPERVISOR_OBJECTIVE_TEMPLATES.map((template) => ( + + ))} +
+``` + +- [ ] **Step 4: Run the supervisor test suite and then the targeted final regression sweep** + +Run: `pnpm exec vitest run packages/web/src/features/supervisor/components/supervisor-card.test.tsx packages/web/src/features/welcome/index.test.tsx packages/web/src/features/setup/views/setup-page.test.tsx packages/web/src/features/mobile-access/views/mobile-access-assistant.test.tsx packages/server/src/commands/setup.test.ts packages/server/src/commands/home.test.ts` + +Expected: PASS with supervisor templates plus the core activation path still green + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx packages/web/src/features/supervisor/components/supervisor-card.test.tsx packages/web/src/locales/en.json packages/web/src/locales/zh.json +git commit -m "feat: add supervisor quick start templates" +``` + +## Self-Review + +### Spec coverage + +The plan covers each requirement from the spec: + +- welcome rewrite and setup-first positioning: Task 1 +- setup wizard and environment doctor: Tasks 1 through 3 +- workspace readiness cleanup: Task 3 +- provider readiness and first-task templates: Task 4 +- mobile access assistant and phone continuation: Task 5 +- return summary and resume: Task 6 +- supervisor quick-start: Task 7 + +No spec requirement is intentionally left without a task. + +### Placeholder scan + +This plan avoids `TODO`, `TBD`, and similar placeholders. Each task names exact files, exact tests, specific command names, and specific UI strings or DTOs. + +### Type consistency + +The plan uses these stable names throughout: + +- `SetupCheckDto` +- `SetupCheckStatus` +- `MobileAccessStatusDto` +- `HomeSummaryDto` +- `setup.status` +- `setup.mobileAccessStatus` +- `home.summary` + +Do not rename them mid-implementation without updating all later tasks. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-14-conversion-first-activation.md`. + +Two execution options: + +1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration +2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/plans/2026-05-14-offline-recovery-vs-session-gate.md b/docs/superpowers/plans/2026-05-14-offline-recovery-vs-session-gate.md new file mode 100644 index 000000000..18630c081 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-offline-recovery-vs-session-gate.md @@ -0,0 +1,396 @@ +# Offline Recovery Vs Session Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep normal websocket recovery on the current route with a top banner, and reserve `/session-gate` for explicit displacement only. + +**Architecture:** Narrow activation semantics so reconnect and claim failures no longer mark the app as gated. Track slow recovery from connection timestamps already stored in atoms, and render the second-line hint from the shared connection banner while preserving explicit displacement routing. + +**Tech Stack:** React, Jotai, React Router, Vitest, Testing Library + +--- + +## File Structure + +- Modify: `packages/web/src/hooks/use-activation.ts` + - Stop setting activation to `gated` for reconnect and claim failures. +- Modify: `packages/web/src/app/providers.tsx` + - Keep explicit displacement handling as the only source of activation gating. + - Reset reconnect counters/timestamps on successful connect so banner timing can recover cleanly. +- Modify: `packages/web/src/shells/shared/connection-status-banner.tsx` + - Render the unified reconnect message and delayed slow-recovery hint. +- Create: `packages/web/src/shells/shared/connection-status-banner.test.tsx` + - Focused banner behavior tests without going through the full shell. +- Modify: `packages/web/src/app/providers.lifecycle.test.tsx` + - Add regression coverage for reconnect and claim failures staying on the current route/state. +- Modify: `packages/web/src/shells/desktop-shell.test.tsx` + - Update banner copy expectation and keep explicit gated routing coverage. + +### Task 1: Freeze The Routing And Activation Regression In Tests + +**Files:** +- Modify: `packages/web/src/app/providers.lifecycle.test.tsx` +- Modify: `packages/web/src/shells/desktop-shell.test.tsx` + +- [ ] **Step 1: Write the failing provider lifecycle regression tests** + +Add tests that prove reconnect and claim failures do not gate activation: + +```tsx + it("does not gate activation when websocket reconnect fails", async () => { + const store = createStore(); + wsState.client!.connect = vi.fn().mockRejectedValue(new Error("connect failed")); + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + await vi.waitFor(() => { + expect(store.get(activationStatusAtom)).not.toBe("gated"); + expect(store.get(activationReasonAtom)).toBeNull(); + }); + }); + + it("does not gate activation when activation.claim fails", async () => { + const store = createStore(); + wsState.client!.sendCommand = createWsSendCommandMock(async (op) => { + if (op === "activation.claim") { + throw new Error("claim failed"); + } + return undefined; + }); + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(store.get(activationStatusAtom)).not.toBe("gated"); + expect(store.get(activationReasonAtom)).toBeNull(); + }); + }); +``` + +- [ ] **Step 2: Write the failing desktop banner copy test** + +Update the shell assertion to the new primary line: + +```tsx + it("shows the reconnecting banner on desktop", () => { + const store = createStore(); + store.set(connectionStatusAtom, "reconnecting"); + store.set(authEnabledAtom, false); + store.set(authenticatedAtom, true); + + renderShell(store); + + expect(screen.getByText("连接已断开,正在重新连接...")).toBeInTheDocument(); + }); +``` + +- [ ] **Step 3: Run the focused regression tests to verify they fail** + +Run: + +```bash +pnpm vitest run packages/web/src/app/providers.lifecycle.test.tsx packages/web/src/shells/desktop-shell.test.tsx +``` + +Expected: + +- FAIL because `useActivation` still writes `gated` on reconnect or claim failure +- FAIL because the banner still renders `正在重新连接...` + +- [ ] **Step 4: Commit the failing-test checkpoint** + +Do not commit yet. This checkpoint exists only to enforce red before green. + +### Task 2: Add Banner-Level Slow Recovery Tests + +**Files:** +- Create: `packages/web/src/shells/shared/connection-status-banner.test.tsx` +- Test: `packages/web/src/shells/shared/connection-status-banner.test.tsx` + +- [ ] **Step 1: Write the failing banner tests** + +Create focused tests for the new copy and delayed hint: + +```tsx +import { render, screen } from "@testing-library/react"; +import { Provider, createStore } from "jotai"; +import { describe, expect, it, vi } from "vitest"; +import { + connectionStatusAtom, + lastReconnectAttemptAtom, +} from "../../atoms/connection"; +import { ConnectionStatusBanner } from "./connection-status-banner"; + +function renderBanner(now = Date.now()) { + vi.setSystemTime(now); + const store = createStore(); + return { + store, + ...render( + + + + ), + }; +} + +describe("ConnectionStatusBanner", () => { + it("renders the unified reconnect message while reconnecting", () => { + const { store } = renderBanner(); + store.set(connectionStatusAtom, "reconnecting"); + + expect(screen.getByText("连接已断开,正在重新连接...")).toBeInTheDocument(); + }); + + it("shows the slow recovery hint after 25 seconds", () => { + const startedAt = new Date("2026-05-14T00:00:00.000Z").getTime(); + const { store } = renderBanner(startedAt + 25_000); + store.set(connectionStatusAtom, "reconnecting"); + store.set(lastReconnectAttemptAtom, startedAt); + + expect( + screen.getByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。") + ).toBeInTheDocument(); + }); + + it("does not show the slow recovery hint before the threshold", () => { + const startedAt = new Date("2026-05-14T00:00:00.000Z").getTime(); + const { store } = renderBanner(startedAt + 24_000); + store.set(connectionStatusAtom, "reconnecting"); + store.set(lastReconnectAttemptAtom, startedAt); + + expect( + screen.queryByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。") + ).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run the banner tests to verify they fail** + +Run: + +```bash +pnpm vitest run packages/web/src/shells/shared/connection-status-banner.test.tsx +``` + +Expected: + +- FAIL because the banner does not yet render the new primary line +- FAIL because the slow-recovery secondary hint is not implemented + +- [ ] **Step 3: Commit the failing-test checkpoint** + +Do not commit yet. This checkpoint exists only to enforce red before green. + +### Task 3: Implement Minimal Activation Semantics Fix + +**Files:** +- Modify: `packages/web/src/hooks/use-activation.ts` +- Modify: `packages/web/src/app/providers.tsx` + +- [ ] **Step 1: Remove reconnect/claim failure gating from `useActivation`** + +Change the failure branches so they stop writing `gated` and leave displacement ownership to explicit revoke handling: + +```tsx + if (connectionStatus !== "connected") { + try { + await wsClient.connect(); + } catch { + setReason(null); + return false; + } + } + + setStatus("claiming"); + + const pending = wsClient + .sendCommand("activation.claim", { + clientInstanceId, + }) + .then((result) => { + setGeneration(result.generation); + setReason(null); + setStatus("active"); + return true; + }) + .catch(() => { + setReason(null); + if (status !== "gated") { + setStatus("idle"); + } + return false; + }) +``` + +Use the functional setter form if needed to avoid stale closures: + +```tsx + setStatus((current) => (current === "gated" ? current : "idle")); +``` + +- [ ] **Step 2: Reset reconnect timing state on successful connect in `AppProviders`** + +When status becomes `connected`, clear reconnect progress so the banner hint disappears on recovery: + +```tsx + if (status === "connected") { + setReconnectCount(0); + setLastReconnect(null); + syncWorkspaceActivity(true); + } +``` + +Keep the existing increment behavior for `reconnecting`: + +```tsx + if (status === "reconnecting") { + setReconnectCount((count) => count + 1); + setLastReconnect((previous) => previous ?? Date.now()); + } +``` + +- [ ] **Step 3: Run the provider lifecycle regressions** + +Run: + +```bash +pnpm vitest run packages/web/src/app/providers.lifecycle.test.tsx +``` + +Expected: + +- PASS for the new reconnect and claim regression tests +- PASS for the existing explicit `activation.revoked` gating test + +- [ ] **Step 4: Refactor only if needed** + +Keep production edits minimal. Do not rename atoms or widen the status model in this task. + +### Task 4: Implement The Banner Copy And Slow Recovery Hint + +**Files:** +- Modify: `packages/web/src/shells/shared/connection-status-banner.tsx` +- Create: `packages/web/src/shells/shared/connection-status-banner.test.tsx` + +- [ ] **Step 1: Update the banner component to read reconnect timing atoms** + +Import the reconnect timestamp atom and compute the hint threshold inline: + +```tsx +import { useAtomValue } from "jotai"; +import { + connectionStatusAtom, + lastReconnectAttemptAtom, +} from "../../atoms/connection"; + +const SLOW_RECOVERY_HINT_MS = 25_000; + +export function ConnectionStatusBanner() { + const connectionStatus = useAtomValue(connectionStatusAtom); + const lastReconnectAttempt = useAtomValue(lastReconnectAttemptAtom); + + if (connectionStatus === "connected" || connectionStatus === "connecting") { + return null; + } + + const showRecoveryHint = + lastReconnectAttempt !== null && + Date.now() - lastReconnectAttempt >= SLOW_RECOVERY_HINT_MS && + (connectionStatus === "reconnecting" || connectionStatus === "disconnected"); + + if (connectionStatus === "rejected") { + return ( +
+ 另一个标签页已激活 +
+ ); + } + + return ( +
+ 连接已断开,正在重新连接... + {showRecoveryHint ? ( + 连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。 + ) : null} +
+ ); +} +``` + +- [ ] **Step 2: Run the focused banner tests** + +Run: + +```bash +pnpm vitest run packages/web/src/shells/shared/connection-status-banner.test.tsx packages/web/src/shells/desktop-shell.test.tsx +``` + +Expected: + +- PASS for unified reconnect copy +- PASS for delayed slow-recovery hint +- PASS for existing desktop shell routing coverage + +- [ ] **Step 3: Commit the implementation** + +```bash +git add \ + packages/web/src/hooks/use-activation.ts \ + packages/web/src/app/providers.tsx \ + packages/web/src/shells/shared/connection-status-banner.tsx \ + packages/web/src/shells/shared/connection-status-banner.test.tsx \ + packages/web/src/app/providers.lifecycle.test.tsx \ + packages/web/src/shells/desktop-shell.test.tsx \ + docs/superpowers/plans/2026-05-14-offline-recovery-vs-session-gate.md +git commit -m "fix(web): keep offline recovery out of session gate" +``` + +### Task 5: Final Verification + +**Files:** +- Test: `packages/web/src/app/providers.lifecycle.test.tsx` +- Test: `packages/web/src/shells/shared/connection-status-banner.test.tsx` +- Test: `packages/web/src/shells/desktop-shell.test.tsx` + +- [ ] **Step 1: Run the full targeted verification set** + +Run: + +```bash +pnpm vitest run \ + packages/web/src/app/providers.lifecycle.test.tsx \ + packages/web/src/shells/shared/connection-status-banner.test.tsx \ + packages/web/src/shells/desktop-shell.test.tsx +``` + +Expected: + +- PASS with 0 failures + +- [ ] **Step 2: Review spec coverage** + +Confirm the implementation covers: + +- reconnect failure stays on current route +- claim failure stays on current route +- explicit displacement still routes to `session-gate` +- banner shows the unified reconnect message +- slow-recovery hint appears after the threshold and clears on recovery + +- [ ] **Step 3: Report actual verification evidence** + +Record the exact command and result in the final handoff. Do not claim completion without the fresh `pnpm vitest run ...` output. diff --git a/docs/superpowers/plans/2026-05-14-workspace-last-viewed-target.md b/docs/superpowers/plans/2026-05-14-workspace-last-viewed-target.md new file mode 100644 index 000000000..911638d7b --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-workspace-last-viewed-target.md @@ -0,0 +1,861 @@ +# Workspace Last Viewed Target Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist the last viewed workspace/session target on the server so desktop and mobile restore the correct location after refresh or on another device. + +**Architecture:** Store one global last-viewed target in `user_settings` under a dedicated command path, then hydrate that target during workspace bootstrap before the app resolves the active workspace. Desktop restores only the workspace tab, while mobile restores the workspace first and then the session inside that workspace using the saved target with existing `uiState.activeSessionId` fallback rules. + +**Tech Stack:** TypeScript, React, Jotai, React Router, Vitest, Playwright, Zod, existing `user_settings` storage + +--- + +## File Structure + +- Modify: `packages/core/src/domain/types.ts` + - Add a shared `WorkspaceLastViewedTarget` type so server and web code use the same shape. +- Modify: `packages/core/src/index.ts` + - Re-export the new shared type. +- Modify: `packages/server/src/commands/workspace-activity.ts` + - Add dedicated read/write commands for the global last-viewed target near existing workspace activity commands. +- Modify: `packages/server/src/__tests__/workspace-commands.test.ts` + - Add command-level coverage for writing and reading the last-viewed target. +- Modify: `packages/web/src/hooks/use-bootstrap.ts` + - Hydrate the saved target during workspace bootstrap and resolve the initial active workspace before the ready state is exposed. +- Modify: `packages/web/src/app/providers.lifecycle.test.tsx` + - Cover bootstrap hydration behavior and fallback cases. +- Modify: `packages/web/src/features/topbar/components/tab.tsx` + - Persist the workspace target when a desktop workspace tab is selected. +- Modify: `packages/web/src/features/topbar/components/tab.test.tsx` + - Verify clicking a tab writes the server-backed target. +- Modify: `packages/web/src/features/agent-panes/views/shared/session-card.tsx` + - Persist workspace + session when a desktop session card is selected. +- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` + - Verify session card clicks write the global target alongside workspace UI state. +- Modify: `packages/web/src/features/notifications/focus-session.ts` + - Extend the focus helper so notification-driven focus can also persist the target. +- Modify: `packages/web/src/features/notifications/focus-session.test.ts` + - Verify focus writes without breaking current navigation behavior. +- Modify: `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` + - Persist a workspace-only target when a workspace is opened and activated. +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-workspace-drawer.tsx` + - Persist a workspace-only target when mobile switches workspace. +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-agent-sheet.tsx` + - Persist workspace + session when mobile switches session. +- Modify: `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts` + - Apply the global session target on mobile before falling back to `workspace.uiState.activeSessionId`. +- Modify: `packages/web/src/shells/mobile-shell/index.test.tsx` + - Cover mobile restore and writeback behavior. +- Modify: `e2e/specs/workspace/route-history.spec.ts` + - Add desktop refresh/cross-instance workspace restore coverage. +- Modify: `e2e/specs/sessions/hydrate-refresh.spec.ts` + - Add mobile refresh restore coverage using the global target. + +### Task 1: Add server commands and shared target type + +**Files:** +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/core/src/index.ts` +- Modify: `packages/server/src/commands/workspace-activity.ts` +- Modify: `packages/server/src/__tests__/workspace-commands.test.ts` + +- [ ] **Step 1: Write the failing server command test for writing and reading the target** + +```ts +it("persists and returns the global workspace last-viewed target", async () => { + const dir = join(tmpdir(), `workspace-target-test-${Date.now()}`); + await mkdir(dir); + + const openResult = await dispatch( + { + kind: "command", + id: "open-workspace-target", + op: "workspace.open", + args: { path: dir }, + }, + ctx + ); + + expect(openResult.ok).toBe(true); + const workspaceId = (openResult.data as { id: string }).id; + + const writeResult = await dispatch( + { + kind: "command", + id: "set-last-viewed-target", + op: "workspace.lastViewedTarget.set", + args: { + workspaceId, + sessionId: "sess-123", + }, + }, + ctx + ); + + expect(writeResult.ok).toBe(true); + expect(writeResult.data).toMatchObject({ + workspaceId, + sessionId: "sess-123", + }); + + const readResult = await dispatch( + { + kind: "command", + id: "get-last-viewed-target", + op: "workspace.lastViewedTarget.get", + args: {}, + }, + ctx + ); + + expect(readResult.ok).toBe(true); + expect(readResult.data).toMatchObject({ + workspaceId, + sessionId: "sess-123", + }); +}); +``` + +- [ ] **Step 2: Run the server command test to verify it fails** + +Run: `pnpm exec vitest run packages/server/src/__tests__/workspace-commands.test.ts --testNamePattern "persists and returns the global workspace last-viewed target"` + +Expected: FAIL with unknown operation errors for `workspace.lastViewedTarget.set` / `workspace.lastViewedTarget.get` + +- [ ] **Step 3: Add the shared target type** + +```ts +export interface WorkspaceLastViewedTarget { + workspaceId: string; + sessionId?: string; + updatedAt: number; +} +``` + +Also re-export it from `packages/core/src/index.ts` through the existing `export * from "./domain/types";` surface. + +- [ ] **Step 4: Implement the new server commands in `workspace-activity.ts`** + +```ts +import type { WorkspaceLastViewedTarget } from "@coder-studio/core"; +import { z } from "zod"; +import { registerCommand } from "../ws/dispatch.js"; + +const WORKSPACE_LAST_VIEWED_TARGET_KEY = "workspace.lastViewedTarget"; + +registerCommand("workspace.lastViewedTarget.get", z.object({}), async (_args, ctx) => { + return (ctx.db + .prepare("SELECT value FROM user_settings WHERE key = ?") + .get(WORKSPACE_LAST_VIEWED_TARGET_KEY) as { value: string } | undefined) + ? JSON.parse( + ( + ctx.db + .prepare("SELECT value FROM user_settings WHERE key = ?") + .get(WORKSPACE_LAST_VIEWED_TARGET_KEY) as { value: string } + ).value + ) + : null; +}); + +registerCommand( + "workspace.lastViewedTarget.set", + z.object({ + workspaceId: z.string(), + sessionId: z.string().optional(), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { + code: "workspace_not_found", + message: `Workspace not found: ${args.workspaceId}`, + }; + } + + const session = + args.sessionId !== undefined ? ctx.sessionMgr.get(args.sessionId) : undefined; + + const nextTarget: WorkspaceLastViewedTarget = { + workspaceId: args.workspaceId, + sessionId: + session && session.workspaceId === args.workspaceId ? session.id : undefined, + updatedAt: Date.now(), + }; + + ctx.db + .prepare( + ` + INSERT INTO user_settings (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + ` + ) + .run(WORKSPACE_LAST_VIEWED_TARGET_KEY, JSON.stringify(nextTarget)); + + return nextTarget; + } +); +``` + +Implementation note: avoid the duplicated `SELECT` shown above in the plan snippet when you code it; read once into a variable and parse once. + +- [ ] **Step 5: Add a validation test for missing workspace and mismatched session** + +```ts +it("drops an out-of-workspace session id while preserving the workspace target", async () => { + const dir = join(tmpdir(), `workspace-target-mismatch-${Date.now()}`); + await mkdir(dir); + + const openResult = await dispatch( + { + kind: "command", + id: "open-workspace-target-mismatch", + op: "workspace.open", + args: { path: dir }, + }, + ctx + ); + + const workspaceId = (openResult.data as { id: string }).id; + + const result = await dispatch( + { + kind: "command", + id: "set-last-viewed-target-mismatch", + op: "workspace.lastViewedTarget.set", + args: { + workspaceId, + sessionId: "sess-missing", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + workspaceId, + sessionId: undefined, + }); +}); +``` + +- [ ] **Step 6: Run the server command tests to verify they pass** + +Run: `pnpm exec vitest run packages/server/src/__tests__/workspace-commands.test.ts` + +Expected: PASS with the new last-viewed-target tests included + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src/domain/types.ts packages/core/src/index.ts packages/server/src/commands/workspace-activity.ts packages/server/src/__tests__/workspace-commands.test.ts +git commit -m "feat: add workspace last viewed target commands" +``` + +### Task 2: Hydrate the saved workspace during bootstrap + +**Files:** +- Modify: `packages/web/src/hooks/use-bootstrap.ts` +- Modify: `packages/web/src/app/providers.lifecycle.test.tsx` + +- [ ] **Step 1: Write the failing bootstrap hydration test** + +```ts +it("hydrates the saved last-viewed workspace before exposing the workspace list as ready", async () => { + const store = createStore(); + const sendCommand = createWsSendCommandMock(async (op) => { + if (op === "workspace.list") { + return [ + { + id: "ws-1", + path: "/tmp/ws-1", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 280, bottomPanelHeight: 200, focusMode: false }, + }, + { + id: "ws-2", + path: "/tmp/ws-2", + targetRuntime: "native", + openedAt: 2, + lastActiveAt: 2, + uiState: { leftPanelWidth: 280, bottomPanelHeight: 200, focusMode: false }, + }, + ]; + } + + if (op === "workspace.lastViewedTarget.get") { + return { + workspaceId: "ws-2", + updatedAt: 10, + }; + } + + return undefined; + }); + + wsState.client = { + ...wsState.client!, + sendCommand, + }; + + renderProviders(store); + + await vi.waitFor(() => { + expect(store.get(workspacesLoadStateAtom)).toBe("ready"); + }); + + expect(store.get(activeWorkspaceIdAtom)).toBe("ws-2"); +}); +``` + +- [ ] **Step 2: Run the lifecycle test to verify it fails** + +Run: `pnpm exec vitest run packages/web/src/app/providers.lifecycle.test.tsx --testNamePattern "hydrates the saved last-viewed workspace before exposing the workspace list as ready"` + +Expected: FAIL because `activeWorkspaceIdAtom` still resolves to the first workspace + +- [ ] **Step 3: Update `useBootstrap` to fetch and apply the saved target during workspace bootstrap** + +```ts +const [listResult, targetResult] = await Promise.all([ + dispatch("workspace.list", {}), + dispatch("workspace.lastViewedTarget.get", {}), +]); + +if (!listResult.ok) { + setWorkspacesLoadState("error"); + setWorkspacesLoadError(listResult.error?.message ?? "Failed to fetch workspace list"); + return; +} + +const nextWorkspaces = Array.isArray(listResult.data) ? listResult.data : []; +const wsMap: Record = {}; +for (const workspace of nextWorkspaces) { + wsMap[workspace.id] = workspace; +} + +setWorkspaces(wsMap); +setWorkspaceOrder(nextWorkspaces.map((workspace) => workspace.id)); + +const savedTarget = targetResult.ok ? targetResult.data : null; +if (savedTarget?.workspaceId && wsMap[savedTarget.workspaceId]) { + setActiveWorkspaceId(savedTarget.workspaceId); +} + +setWorkspacesLoadState("ready"); +setWorkspacesLoadError(null); +``` + +Also add `setActiveWorkspaceId` to the hook and only apply the saved workspace before the ready state is published, so `/workspace` does not flash through the first workspace. + +- [ ] **Step 4: Add fallback tests for missing targets and missing workspaces** + +```ts +it("ignores a saved target when the workspace no longer exists", async () => { + const store = createStore(); + const sendCommand = createWsSendCommandMock(async (op) => { + if (op === "workspace.list") { + return [ + { + id: "ws-1", + path: "/tmp/ws-1", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 280, bottomPanelHeight: 200, focusMode: false }, + }, + ]; + } + + if (op === "workspace.lastViewedTarget.get") { + return { + workspaceId: "ws-missing", + updatedAt: 10, + }; + } + + return undefined; + }); + + wsState.client = { + ...wsState.client!, + sendCommand, + }; + + renderProviders(store); + + await vi.waitFor(() => { + expect(store.get(workspacesLoadStateAtom)).toBe("ready"); + }); + + expect(store.get(activeWorkspaceIdAtom)).toBeNull(); +}); +``` + +- [ ] **Step 5: Run the lifecycle test file to verify it passes** + +Run: `pnpm exec vitest run packages/web/src/app/providers.lifecycle.test.tsx` + +Expected: PASS with the new bootstrap hydration coverage + +- [ ] **Step 6: Commit** + +```bash +git add packages/web/src/hooks/use-bootstrap.ts packages/web/src/app/providers.lifecycle.test.tsx +git commit -m "feat: hydrate last viewed workspace during bootstrap" +``` + +### Task 3: Persist desktop workspace and session focus changes + +**Files:** +- Modify: `packages/web/src/features/topbar/components/tab.tsx` +- Modify: `packages/web/src/features/topbar/components/tab.test.tsx` +- Modify: `packages/web/src/features/agent-panes/views/shared/session-card.tsx` +- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` +- Modify: `packages/web/src/features/notifications/focus-session.ts` +- Modify: `packages/web/src/features/notifications/focus-session.test.ts` + +- [ ] **Step 1: Write the failing desktop tab persistence test** + +```ts +it("persists the global last-viewed workspace target when a tab is clicked", async () => { + const workspace = createWorkspace("ws-2", "/tmp/two"); + const sendCommand = vi.fn().mockResolvedValue({ + workspaceId: "ws-2", + updatedAt: 10, + }); + const store = createStore(); + + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand } as never); + + renderWorkspaceTab(store, workspace, { value: "ws-1" }); + + fireEvent.click(screen.getByRole("tab", { name: /two/i })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "workspace.lastViewedTarget.set", + { workspaceId: "ws-2" }, + undefined + ); + }); +}); +``` + +- [ ] **Step 2: Run the tab test to verify it fails** + +Run: `pnpm exec vitest run packages/web/src/features/topbar/components/tab.test.tsx --testNamePattern "persists the global last-viewed workspace target when a tab is clicked"` + +Expected: FAIL because the component currently never dispatches the new command + +- [ ] **Step 3: Update `WorkspaceTab` to persist the workspace-only target** + +```ts +const dispatch = useAtomValue(dispatchCommandAtom); + +const handleClick = () => { + setActiveWorkspace(workspace.id); + void dispatch("workspace.lastViewedTarget.set", { + workspaceId: workspace.id, + }); +}; +``` + +- [ ] **Step 4: Add the failing desktop session-card persistence test** + +```ts +it("persists the global last-viewed target when the session card body is clicked", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "workspace.uiState.set") { + return { + id: "ws-1", + path: "/tmp/ws-1", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + activeSessionId: "sess_123456", + }, + }; + } + + if (op === "workspace.lastViewedTarget.set") { + return { + workspaceId: "ws-1", + sessionId: "sess_123456", + updatedAt: 10, + }; + } + + return undefined; + }); + + // existing renderSessionCard setup... + + fireEvent.click(screen.getByTestId("session-card-click-target")); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "workspace.lastViewedTarget.set", + { + workspaceId: "ws-1", + sessionId: "sess_123456", + }, + undefined + ); + }); +}); +``` + +- [ ] **Step 5: Run the session-card test to verify it fails** + +Run: `pnpm exec vitest run packages/web/src/features/agent-panes/components/session-card.test.tsx --testNamePattern "persists the global last-viewed target when the session card body is clicked"` + +Expected: FAIL because `workspace.lastViewedTarget.set` is not dispatched + +- [ ] **Step 6: Update `SessionCard` to persist the global target without disturbing current `uiState` writes** + +```ts +const dispatch = useAtomValue(dispatchCommandAtom); + +const handleCardClick = (event: React.MouseEvent) => { + // existing guard clauses... + + void dispatch("workspace.lastViewedTarget.set", { + workspaceId: session.workspaceId, + sessionId: session.id, + }); + void persistUiState({ activeSessionId: session.id }); +}; +``` + +- [ ] **Step 7: Extend notification focus to persist the target** + +```ts +export interface FocusSessionOptions { + workspaceId: string; + sessionId: string; + setPendingFocus: (sessionId: string | null) => void; + setActiveWorkspaceId: (workspaceId: string | null) => void; + persistLastViewedTarget?: (target: { workspaceId: string; sessionId: string }) => void; + navigate?: (path: string) => void; +} + +export function focusSession(opts: FocusSessionOptions): void { + const { + workspaceId, + sessionId, + setPendingFocus, + setActiveWorkspaceId, + persistLastViewedTarget, + navigate, + } = opts; + + persistLastViewedTarget?.({ workspaceId, sessionId }); + setActiveWorkspaceId(workspaceId); + setPendingFocus(sessionId); + // existing navigation logic... +} +``` + +Update the tests so they assert `persistLastViewedTarget` is called and existing navigation behavior remains unchanged. + +- [ ] **Step 8: Run the three focused desktop/unit test files** + +Run: `pnpm exec vitest run packages/web/src/features/topbar/components/tab.test.tsx packages/web/src/features/agent-panes/components/session-card.test.tsx packages/web/src/features/notifications/focus-session.test.ts` + +Expected: PASS with the new persistence coverage + +- [ ] **Step 9: Commit** + +```bash +git add packages/web/src/features/topbar/components/tab.tsx packages/web/src/features/topbar/components/tab.test.tsx packages/web/src/features/agent-panes/views/shared/session-card.tsx packages/web/src/features/agent-panes/components/session-card.test.tsx packages/web/src/features/notifications/focus-session.ts packages/web/src/features/notifications/focus-session.test.ts +git commit -m "feat: persist desktop workspace and session focus" +``` + +### Task 4: Persist and restore mobile workspace/session focus + +**Files:** +- Modify: `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-workspace-drawer.tsx` +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-agent-sheet.tsx` +- Modify: `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts` +- Modify: `packages/web/src/shells/mobile-shell/index.test.tsx` + +- [ ] **Step 1: Write the failing mobile restore test** + +```ts +it("prefers the saved global session target when the mobile workspace restores", async () => { + const store = createStore(); + seedReadyWorkspaceState(store, { + "ws-1": { + id: "ws-1", + path: "/tmp/ws-1", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + activeSessionId: "sess-1", + }, + }, + }); + store.set(activeWorkspaceIdAtom, "ws-1"); + store.set(sessionsAtom, { + "sess-1": createSession({ id: "sess-1", terminalId: "term-1", providerId: "claude" }), + "sess-2": createSession({ id: "sess-2", terminalId: "term-2", providerId: "codex" }), + }); + + window.localStorage.clear(); + + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "workspace.lastViewedTarget.get") { + return { + workspaceId: "ws-1", + sessionId: "sess-2", + updatedAt: 10, + }; + } + return undefined; + }); + + store.set(wsClientAtom, { sendCommand, subscribe: vi.fn(() => () => {}) } as never); + + renderMobileShell(store); + + await waitFor(() => { + expect(screen.getByText("sess-2")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run the mobile shell test to verify it fails** + +Run: `pnpm exec vitest run packages/web/src/shells/mobile-shell/index.test.tsx --testNamePattern "prefers the saved global session target when the mobile workspace restores"` + +Expected: FAIL because mobile currently restores only `workspace.uiState.activeSessionId` / recent-session fallback + +- [ ] **Step 3: Add a small shared helper inside `use-workspace-screen-model.ts` for selecting the preferred mobile session** + +```ts +function resolvePreferredMobileSessionId( + orderedSessions: Session[], + globalTargetSessionId: string | null, + workspaceUiStateSessionId: string | null +) { + if (globalTargetSessionId && orderedSessions.some((session) => session.id === globalTargetSessionId)) { + return globalTargetSessionId; + } + + if (workspaceUiStateSessionId && orderedSessions.some((session) => session.id === workspaceUiStateSessionId)) { + return workspaceUiStateSessionId; + } + + const mostRecentSession = [...orderedSessions].sort( + (left, right) => right.lastActiveAt - left.lastActiveAt + )[0]; + + return mostRecentSession?.id ?? orderedSessions[0]?.id ?? null; +} +``` + +Then update the existing mobile restore effect to prefer the saved global target session for the active workspace. + +- [ ] **Step 4: Persist workspace-only or workspace+session targets from mobile entry points** + +```ts +// use-workspace-launch-actions.ts +if (result.ok && result.data?.id) { + void dispatch("workspace.lastViewedTarget.set", { + workspaceId: result.data.id, + }); + setActiveWorkspaceId(result.data.id); + // existing optimistic workspace updates... +} +``` + +```ts +// mobile-workspace-drawer.tsx +const dispatch = useAtomValue(dispatchCommandAtom); + +onClick={() => { + void dispatch("workspace.lastViewedTarget.set", { + workspaceId: workspace.id, + }); + setActiveWorkspaceId(workspace.id); + navigate("/workspace"); + onClose(); +}} +``` + +```ts +// mobile-agent-sheet.tsx +const dispatch = useAtomValue(dispatchCommandAtom); + +onSelect={(id) => { + if (mode === "sessions") { + if (activeWorkspaceId) { + void dispatch("workspace.lastViewedTarget.set", { + workspaceId: activeWorkspaceId, + sessionId: id, + }); + } + onSelectSession(id); + closeSheet(); + return; + } + + return launch(id as "claude" | "codex"); +}} +``` + +- [ ] **Step 5: Add mobile tests for workspace switch and session switch persistence** + +```ts +it("persists a workspace-only target when the mobile drawer switches workspace", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + workspaceId: "ws-2", + updatedAt: 10, + }); + + // existing mobile shell render setup... + + await user.click(screen.getByRole("button", { name: /switch to workspace beta/i })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "workspace.lastViewedTarget.set", + { workspaceId: "ws-2" }, + undefined + ); + }); +}); +``` + +- [ ] **Step 6: Run the mobile shell test file to verify it passes** + +Run: `pnpm exec vitest run packages/web/src/shells/mobile-shell/index.test.tsx` + +Expected: PASS with restore and writeback coverage + +- [ ] **Step 7: Commit** + +```bash +git add packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts packages/web/src/features/workspace/views/mobile/mobile-workspace-drawer.tsx packages/web/src/features/workspace/views/mobile/mobile-agent-sheet.tsx packages/web/src/features/workspace/actions/use-workspace-screen-model.ts packages/web/src/shells/mobile-shell/index.test.tsx +git commit -m "feat: persist and restore mobile workspace targets" +``` + +### Task 5: Add end-to-end refresh regression coverage + +**Files:** +- Modify: `e2e/specs/workspace/route-history.spec.ts` +- Modify: `e2e/specs/sessions/hydrate-refresh.spec.ts` + +- [ ] **Step 1: Write the failing desktop E2E restore assertion** + +```ts +test("refresh restores the previously selected workspace tab", async ({ page }) => { + await page.goto("/workspace"); + await expect(page.getByTestId("workspace-resolving-shell")).toHaveCount(0, { timeout: 20000 }); + + await page.locator(".topbar-tab").nth(1).click(); + await expect(page.locator(".topbar-tab.active")).toContainText("older-workspace"); + + await page.reload(); + + await expect(page.getByTestId("workspace-resolving-shell")).toHaveCount(0, { timeout: 20000 }); + await expect(page.locator(".topbar-tab.active")).toContainText("older-workspace"); +}); +``` + +- [ ] **Step 2: Run the desktop E2E spec to verify it fails** + +Run: `pnpm exec playwright test e2e/specs/workspace/route-history.spec.ts --grep "refresh restores the previously selected workspace tab"` + +Expected: FAIL because refresh returns to the first workspace + +- [ ] **Step 3: Write the failing mobile E2E restore assertion** + +```ts +test("mobile restores the globally saved session target after refresh", async ({ browser }) => { + const context = await browser.newContext({ + viewport: { width: 430, height: 932 }, + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + }); + const page = await context.newPage(); + + await page.addInitScript(() => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + }); + + await page.goto("/workspace"); + await expect(page.getByTestId("workspace-resolving-shell")).toHaveCount(0, { timeout: 20000 }); + + await expect(page.getByText("sess-mobile-2")).toBeVisible(); + await page.reload(); + await expect(page.getByText("sess-mobile-2")).toBeVisible(); + + await context.close(); +}); +``` + +- [ ] **Step 4: Run the mobile E2E spec to verify it fails** + +Run: `pnpm exec playwright test e2e/specs/sessions/hydrate-refresh.spec.ts --grep "mobile restores the globally saved session target after refresh"` + +Expected: FAIL because mobile restore still relies only on local in-memory active workspace state + +- [ ] **Step 5: Implement any test fixture seeding needed for the new target key** + +```ts +db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)") + .run( + "workspace.lastViewedTarget", + JSON.stringify({ + workspaceId: "ws-target", + sessionId: "sess-mobile-2", + updatedAt: Date.now(), + }) + ); +``` + +Use this only in the E2E fixtures that need deterministic restore behavior. + +- [ ] **Step 6: Run both E2E specs to verify they pass** + +Run: `pnpm exec playwright test e2e/specs/workspace/route-history.spec.ts e2e/specs/sessions/hydrate-refresh.spec.ts` + +Expected: PASS with desktop workspace restore and mobile session restore coverage + +- [ ] **Step 7: Commit** + +```bash +git add e2e/specs/workspace/route-history.spec.ts e2e/specs/sessions/hydrate-refresh.spec.ts +git commit -m "test: cover workspace target restore across refresh" +``` + +## Self-Review + +- Spec coverage: + - global server-backed target in `user_settings`: Task 1 + - bootstrap restore before first-workspace fallback: Task 2 + - desktop restores workspace tab only: Tasks 2, 3, 5 + - mobile restores workspace then session: Tasks 2, 4, 5 + - explicit write triggers only: Tasks 3 and 4 + - no schema migration: Task 1 uses existing `user_settings` +- Placeholder scan: + - No `TODO` / `TBD` placeholders remain. + - Each task includes concrete files, commands, and expected results. +- Type consistency: + - Shared type name is `WorkspaceLastViewedTarget`. + - Command names are `workspace.lastViewedTarget.get` and `workspace.lastViewedTarget.set`. + - The saved payload shape is consistently `{ workspaceId, sessionId?, updatedAt }`. diff --git a/docs/superpowers/specs/2026-05-13-readme-top-structure-design.md b/docs/superpowers/specs/2026-05-13-readme-top-structure-design.md new file mode 100644 index 000000000..20be9cd91 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-readme-top-structure-design.md @@ -0,0 +1,196 @@ +# README Top Structure Design + +> Status: Draft +> Date: 2026-05-13 +> Scope: `README.md`, `README.zh-CN.md` + +## Goal + +Restructure the top section of the GitHub README so new visitors understand Coder Studio faster and are more likely to watch the demo, try the project, and then star the repository. + +This design prioritizes two audiences: + +- AI coding power users +- Developers who switch between desktop, tablet, and phone + +## Problem + +The current README top section behaves like a conventional repository overview: + +- badges first +- broad product description +- feature list +- screenshot +- demo link + +That structure is readable, but it does not emphasize the product's strongest differentiators early enough: + +- browser-based AI coding workflow +- cross-device continuity +- Claude Code and Codex in one workspace + +For this project, conversion depends more on product comprehension than on repository metadata. + +## Decision + +Use a hybrid README structure: + +- the first screen behaves like a lightweight product landing section +- the rest of the README keeps standard open source repository sections + +This avoids two failure modes: + +- too repository-like: clear but low-conviction +- too marketing-like: visually punchy but weak as a GitHub project document + +## Proposed Top Structure + +The redesigned top section should use this order: + +1. Logo and project name +2. Sharper one-line positioning +3. Short supporting paragraph +4. Core badges only +5. Primary action links: + - `Watch Demo` + - `Quick Start` + - `Star on GitHub` +6. Language and docs links +7. Demo poster linked to recorded `mp4` +8. One-line explanation of what the demo proves +9. `Why It Feels Different` section with three differentiation bullets +10. `Quick Start` + +## Content Changes + +### Keep + +- logo +- project name +- English/Chinese cross-link +- demo poster and `mp4` +- existing quick start command +- lower README sections after quick start + +### Modify + +- one-line positioning statement +- supporting paragraph under the title +- badge selection and visual prominence +- demo framing copy +- top information order + +### Add + +- explicit CTA row +- `Why It Feels Different` section +- demo proof sentence + +### Remove Or Move Down + +- top blockquote +- top static workspace screenshot +- top six-item feature list +- secondary badges from the first visual block + +The screenshot and longer feature listing are still useful, but they should appear after the user understands the product shape and sees the demo entry. + +## Badge Policy + +Keep these badges in the first screen: + +- npm version +- license +- Node.js version +- GitHub stars + +Move these out of the first visual block: + +- discussions +- open issues +- contributors + +Reason: they are useful trust signals, but they compete with the demo and action links without helping first-time comprehension. + +## Copy Direction + +The new top copy should be concrete and workflow-oriented. + +It should directly communicate: + +- browser-based workspace +- AI coding workflow +- device switching +- Claude Code and Codex support + +It should avoid: + +- abstract slogans as primary messaging +- long lists before the demo +- generic feature language that could apply to any browser IDE + +## English And Chinese Parity + +`README.md` and `README.zh-CN.md` should keep the same information architecture. + +The Chinese version does not need to be word-for-word identical, but it should preserve: + +- same CTA order +- same structural blocks +- same product claims + +## Non-Goals + +This change does not redesign the entire README. + +It does not include: + +- new screenshots +- new feature sections below quick start +- roadmap rewrite +- documentation restructuring +- badge removal from the repository entirely + +## Risks + +### Risk: too much marketing tone + +Mitigation: + +- keep direct technical language +- keep quick start near the top +- keep lower repository sections intact + +### Risk: star CTA feels premature + +Mitigation: + +- make `Watch Demo` the primary first action +- make `Star on GitHub` a secondary action after product context exists + +### Risk: top becomes visually crowded + +Mitigation: + +- reduce badge count +- reduce feature count from six to three +- remove duplicate top-of-page media + +## Verification + +After implementation, verify: + +1. the top section reads cleanly on GitHub markdown rendering +2. both README files keep the same structure +3. demo poster links to the correct `mp4` +4. `Quick Start` anchor works from the CTA row +5. no broken markdown formatting is introduced + +## Implementation Boundary + +Only edit: + +- `README.md` +- `README.zh-CN.md` + +No asset changes are required for this phase. From 2dfb05f6758af5e387e83d2ef4ef6bb95d404d1c Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 16:10:06 +0000 Subject: [PATCH 15/28] Clean up mobile shell test warning --- packages/web/src/shells/mobile-shell/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/shells/mobile-shell/index.test.tsx b/packages/web/src/shells/mobile-shell/index.test.tsx index 2d79c435b..c0d3fd527 100644 --- a/packages/web/src/shells/mobile-shell/index.test.tsx +++ b/packages/web/src/shells/mobile-shell/index.test.tsx @@ -807,7 +807,7 @@ describe("MobileShell Phase 2 workspace", () => { return undefined; }); - const { store } = renderMobileShell({ + renderMobileShell({ initialEntry: "/workspace", sessions: [], paneLayout: { From 4c1f759bfc9bacaca4c480ec0ac9bf760a0624ec Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 16:12:41 +0000 Subject: [PATCH 16/28] Remove unused mobile shell test variable --- packages/web/src/shells/mobile-shell/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/shells/mobile-shell/index.test.tsx b/packages/web/src/shells/mobile-shell/index.test.tsx index c0d3fd527..c3e5fe71b 100644 --- a/packages/web/src/shells/mobile-shell/index.test.tsx +++ b/packages/web/src/shells/mobile-shell/index.test.tsx @@ -2273,7 +2273,7 @@ describe("MobileShell Phase 2 workspace", () => { return undefined; }); - const { store } = renderMobileShell({ + renderMobileShell({ initialEntry: "/workspace", sessions: [], paneLayout: { From b37f5100235bd5226e01bbedeaf6834c9ffec03d Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 14 May 2026 16:49:36 +0000 Subject: [PATCH 17/28] docs: add pc style polish design spec --- .../2026-05-14-pc-style-polish-design.md | 447 ++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-14-pc-style-polish-design.md diff --git a/docs/superpowers/specs/2026-05-14-pc-style-polish-design.md b/docs/superpowers/specs/2026-05-14-pc-style-polish-design.md new file mode 100644 index 000000000..1d54f29b7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-pc-style-polish-design.md @@ -0,0 +1,447 @@ +# PC 端样式精修 · 设计文档 + +> **版本:** 1.0 +> **日期:** 2026-05-14 +> **状态:** Draft(等待评审) +> **作者:** Spencer + Codex + +--- + +## 0. 文档说明 + +### 0.1 目的 + +针对当前 `packages/web` 的 PC 端界面做一轮以“消除样式割裂、补全设计规范、提升主路径观感一致性”为目标的精修。 + +本轮不是重做视觉风格,也不是只修几个页面的表面问题,而是把以下三层同时收拢: + +- 设计 token 与基础样式中的缺口 +- 组件与页面 chrome 之间不一致的视觉语言 +- `e2e-ui` 对桌面主场景的审图覆盖不足 + +### 0.2 背景 + +当前项目已经具备较好的前提: + +- `packages/web/src/styles/tokens.css` 已建立基础设计 token,含多主题、间距、字号、圆角、阴影和状态色 +- `packages/web/src/components/ui/` 已完成一轮组件库迁移,基础控件不再完全依赖裸 class 拼接 +- `e2e-ui/` 已经能稳定产出 PC / Mobile、主题、语言矩阵截图 +- `ui-preview` 已覆盖 welcome、settings、workspace、auth、command-palette、workspace-launch-modal 等主路径场景 + +但 PC 端仍存在以下问题: + +- 主页面之间的层级语言不统一:入口页、设置页、workspace、浮层各自成风格片段 +- light theme 的表面层级偏弱,很多区域只靠细边框区分,信息密度和留白节奏不稳定 +- 部分基础样式和 token 存在断口,导致视觉规则并不完全可依赖 +- `components.css` 体量过大,PC 主样式长期以追加方式维护,容易形成覆盖链和局部例外 + +### 0.3 设计目标 + +- 建立一套可持续的 PC 端视觉基线,而不是一次性修图 +- 补齐 token / 基础语义层,让主题、状态、surface 层级有稳定真相源 +- 统一 PC 主路径的桌面产品语言: + - Welcome / Auth / Not Found + - Settings + - Workspace Desktop + - 桌面浮层与弹窗 +- 强化 `e2e-ui` 的桌面审图能力,使其能服务后续样式回归和 agent 分析 + +### 0.4 非目标 + +- 不改移动端主视觉方向 +- 不重做主题体系,不新增新的主题 family +- 不在本期做大规模组件 API 重构 +- 不把所有全局样式一次性拆完 +- 不引入新的视觉测试基础设施 + +--- + +## 1. 现状诊断 + +### 1.1 规范层断口 + +当前样式系统不是完全闭环,至少存在以下客观问题: + +- [packages/web/src/styles/base.css](/root/workspace/coder-studio/packages/web/src/styles/base.css:54) 中 `h2` 使用了不存在的 `var(--2xl)` +- [packages/web/src/styles/components.css](/root/workspace/coder-studio/packages/web/src/styles/components.css:296) 等位置引用了 `--accent-red` +- [packages/web/src/styles/components.css](/root/workspace/coder-studio/packages/web/src/styles/components.css:406) 等位置引用了 `--bg-panel` +- [packages/web/src/styles/components.css](/root/workspace/coder-studio/packages/web/src/styles/components.css:1903) 等位置引用了 `--text-muted` + +这些问题说明: + +- token 命名仍混有历史别名和未收敛语义 +- 页面与组件并未严格共享同一套设计词汇 +- 主题切换时局部样式可能退化为浏览器 fallback 或继承值 + +### 1.2 样式源过于集中 + +当前主样式通过 [packages/web/src/main.tsx](/root/workspace/coder-studio/packages/web/src/main.tsx:15) 与 [packages/web/src/ui-preview/main.tsx](/root/workspace/coder-studio/packages/web/src/ui-preview/main.tsx:8) 统一引入 `components.css`。 + +问题不在“全局引入”本身,而在于: + +- [packages/web/src/styles/components.css](/root/workspace/coder-studio/packages/web/src/styles/components.css:1) 已超过 11k 行 +- 设置页主块从 [597 行](/root/workspace/coder-studio/packages/web/src/styles/components.css:597) 开始 +- workspace 主块从 [10366 行](/root/workspace/coder-studio/packages/web/src/styles/components.css:10366) 开始 +- 多轮功能迭代后,同类样式经常依赖后续追加规则修正,而不是统一抽象 + +结果是: + +- 页面看起来能用,但难形成稳定、可复用的桌面视觉规则 +- light / dark 间的设计完成度不一致 +- 后续继续修样式很容易再次产生局部例外 + +### 1.3 页面观感问题 + +通过现有 `e2e-ui/output` 桌面截图可归纳为四类问题: + +#### A. Welcome / Auth / Not Found 入口页 + +- 信息结构清楚,但主次层级偏平 +- 卡片、按钮、次链接和 feature 卡片像来自两套不同密度系统 +- light theme 下入口页容易显得空、薄、缺少中心聚焦 + +#### B. Settings + +- dark theme 可读性尚可,但 section 间的节奏不稳 +- light theme 中,sidebar、content、control surface 之间的层级差太小 +- 表单、toggle、section header、footer 没完全进入统一的桌面设置语言 + +#### C. Workspace Desktop + +- 信息架构完整,但 topbar、left panel、main stage、bottom panel、status bar 的层次过近 +- 主要依赖边框切分而不是 surface / density / accent 的协同 +- 文件树、Git 面板、terminal empty、session launcher 彼此风格关系还不够紧 + +#### D. Overlay / Modal + +- 命令面板、启动工作区弹窗、worktree manager 已有基本样式 +- 但浮层的视觉语言还偏“通用组件壳”,未完全继承 desktop shell 的产品气质 + +--- + +## 2. 关键设计决策 + +| # | 决策 | 取舍理由 | +|---|---|---| +| D1 | 先补规范层,再修主页面观感 | 只修页面表面会被底层 token / 历史样式继续反噬 | +| D2 | 本轮聚焦 PC 主路径,不同时铺开移动端 | 控制改动面,确保桌面观感先形成稳定基线 | +| D3 | 不重做主题体系,只补齐语义 token 与 surface 层级 | 现有主题 family 已足够,问题在语义收敛而非主题数量 | +| D4 | 对 `components.css` 做分区收拢,而不是一次性彻底拆分 | 兼顾风险与收益,先降低局部维护复杂度 | +| D5 | `e2e-ui` 增加 PC 审图场景作为精修配套资产 | 后续样式精修必须有可复查、可横向比对的产物 | +| D6 | 精修优先顺序固定为:入口页 → Settings → Workspace → Overlay | 这是用户感知最强、也是视觉语言最基础的路径 | + +--- + +## 3. 设计方案 + +### 3.1 规范补齐层 + +本层目标是把“设计规范不完整”的问题补平,让页面精修建立在稳定底座上。 + +#### 3.1.1 token 收敛 + +补齐并统一以下语义层: + +- text: + - `--text-primary` + - `--text-secondary` + - `--text-tertiary` + - `--text-muted` +- surface: + - `--bg-page` + - `--bg-surface` + - `--bg-sidebar` + - `--bg-input` + - `--bg-panel` + - `--bg-elevated` +- accent / semantic: + - `--accent-blue` + - `--accent-green` + - `--accent-amber` + - `--accent-pink` + - `--accent-red` + - `--color-success` + - `--color-warning` + - `--color-error` + - `--color-info` + +其中: + +- 如果已有语义可直接复用,应以别名方式向后兼容 +- 如果历史变量名与新语义重复,应统一对外推荐名,并逐步清理旧引用 + +#### 3.1.2 基础排版修正 + +修复基础层明显错误与不完整处: + +- `h2` 字号变量引用错误 +- 页面级 heading、kicker、section title 的字号与字重映射不够明确 +- mono 信息层在 settings / workspace / modal 中的使用没有统一语气 + +需要建立 PC 端文案层级约束: + +- 页面标题 +- 页面 kicker +- section 标题 +- 元信息文字 +- 辅助 hint +- 状态标签 / meta + +#### 3.1.3 桌面布局 token + +补出一组只服务于 PC 的布局 token,避免页面持续内联 magic number: + +- `--desktop-topbar-height` +- `--desktop-statusbar-height` +- `--desktop-sidebar-header-height` +- `--desktop-panel-padding` +- `--desktop-panel-gap` +- `--desktop-content-max-width` +- `--desktop-modal-max-width-*` + +### 3.2 页面精修层 + +#### 3.2.1 Welcome / Auth / Not Found + +目标: + +- 建立统一的“入口页语言” +- 增强中心聚焦与主次层级 +- 让 light / dark 都有稳定的视觉完成度 + +具体方向: + +- Welcome 卡片、Auth 卡片、Not Found 卡片共享同一组入口页容器规则 +- 主 CTA、次操作、信息卡片采用统一的间距与表面层级 +- feature 区块减少“独立组件拼装感”,更像同一信息版式系统 +- 入口页在 light theme 下增加更稳定的 surface 对比,而不是平铺浅底 + +#### 3.2.2 Settings + +目标: + +- 让 Settings 成为规范最完整的桌面内容页 +- 统一 sidebar、content、field group、footer 的视觉秩序 +- 提升 light theme 的层级辨识度 + +具体方向: + +- 强化 sidebar 与 content 的表面对比,但避免过重分割 +- section 标题、描述、表单行、状态信息统一密度节奏 +- Appearance、Shortcuts、Providers、General 四个 section 在结构上统一,不再像四套局部样式 +- footer autosave / version 信息弱化为真正的辅助层,不与正文抢注意力 + +#### 3.2.3 Workspace Desktop + +目标: + +- 让 workspace shell 的桌面气质更完整 +- 建立清晰的 chrome 层次:topbar / sidebar / stage / bottom panel / status bar +- 提升文件树、git panel、session launcher、terminal empty 之间的一致性 + +具体方向: + +- topbar 从“条状容器”提升为稳定的应用 chrome +- sidebar header、tabs、toolbar action、搜索框、tree item 统一密度与交互语气 +- selected / hover / active 不再只靠边线和轻底色区分 +- bottom panel 和 status bar 需要与主内容区形成更清楚的职责边界 + +#### 3.2.4 Overlay / Modal + +目标: + +- 让命令面板、启动工作区、worktree manager 等浮层继承 desktop shell 的语言 +- 降低“通用弹窗壳”的感觉 + +具体方向: + +- 统一 overlay、card、header、body、footer 的桌面浮层结构 +- 命令面板强化 search / result / shortcut / active item 的层级 +- 启动工作区弹窗强化路径导航、目录列表、主操作区的分区关系 +- 桌面弹窗的尺寸、边框、阴影、surface elevation 使用统一规范 + +### 3.3 `e2e-ui` 补强层 + +当前 `ui-preview` 已覆盖主页面和部分 showcase 场景,见 [packages/web/src/ui-preview/scene-metadata.ts](/root/workspace/coder-studio/packages/web/src/ui-preview/scene-metadata.ts:37)。 + +本轮需新增一组专门服务 PC 审图的 scene: + +- `workspace-topbar-review` +- `workspace-sidebar-files-review` +- `workspace-sidebar-git-review` +- `workspace-terminal-empty-review` +- `settings-density-review` +- `settings-light-theme-review` +- `desktop-overlay-review` +- `desktop-statusbar-review` + +这些 scene 的目标不是替代真实页面,而是: + +- 把桌面 chrome 里最容易割裂的部分做成稳定对照资产 +- 缩短每次样式调整后的人工核对成本 +- 为 agent 提供更可比的视觉输入 + +--- + +## 4. 实施阶段 + +### 4.1 Phase A:规范补齐 + +范围: + +- `tokens.css` +- `base.css` +- 组件层对未定义 token 的引用 + +产出: + +- token 断口补齐 +- 基础排版修正 +- PC 布局语义 token 建立 + +完成标准: + +- 不再存在未定义 token 被直接引用 +- 基础 heading / text 层级映射明确 +- 页面精修不再依赖“临时补变量” + +### 4.2 Phase B:入口页与 Settings + +范围: + +- Welcome +- Auth +- Not Found +- Settings 全 section + +产出: + +- 统一入口页语言 +- Settings 桌面页结构收敛 +- light theme 分层明显改善 + +完成标准: + +- `welcome`、`auth-preview`、`not-found`、`settings-*` 在桌面截图里不再呈现明显风格跳变 + +### 4.3 Phase C:Workspace Desktop + +范围: + +- desktop shell +- topbar +- left panel +- file tree / git panel +- terminal empty / session launcher +- status bar + +产出: + +- 桌面 workspace chrome 完整统一 +- 常见 hover / selected / active 状态更稳定 + +完成标准: + +- `workspace-desktop` 与新增 review scenes 在 light / dark 下都具有清晰层级 + +### 4.4 Phase D:Overlay 与审图补强 + +范围: + +- command palette +- workspace launch modal +- worktree manager +- `e2e-ui` 新增桌面审图场景 + +产出: + +- 桌面浮层语言统一 +- 审图体系补齐 + +完成标准: + +- 新旧桌面浮层风格收口 +- `e2e-ui` 可作为后续 PC 样式回归基线 + +--- + +## 5. 验收标准 + +### 5.1 视觉验收 + +- Welcome / Auth / Not Found 共享统一入口页语法 +- Settings 在 light / dark 下均具备清晰 surface 层级 +- Workspace Desktop 的 chrome 层次明显优于当前版本 +- Overlay 与主壳语言一致,不再显得脱节 + +### 5.2 规范验收 + +- 样式系统中不存在继续依赖未定义 token 的规则 +- 基础排版和页面级标题层级可复用,不再靠页面私有修正 +- 新增 PC 布局 token 被主页面实际采用 + +### 5.3 工程验收 + +- `e2e-ui` 新增桌面审图场景可稳定产出截图 +- 不引入新的样式“补丁式堆叠”区域 +- 改动后样式规则的归属更清晰,至少按入口页 / settings / workspace / overlay 四组收拢 + +--- + +## 6. 风险与约束 + +### 6.1 风险 + +- 如果只做页面表面微调而不补 token,后续主题回归仍会不稳定 +- 如果在 `components.css` 末尾继续叠加修补规则,长期复杂度不会下降 +- workspace 精修牵涉区域较多,必须依赖审图场景辅助核对 + +### 6.2 约束 + +- 本轮不改移动端主视觉,因此 PC 提取出的新规范要避免误伤移动端 +- 本轮不做大规模组件 API 迁移,优先通过 token、样式归组和页面层修整完成目标 +- 必须以现有主题 family 为基础,不新增视觉方向 + +--- + +## 7. 推荐实施顺序 + +推荐严格按以下顺序推进: + +1. 修 token / base 层断口 +2. 修入口页 +3. 修 Settings +4. 修 Workspace Desktop +5. 修 Overlay +6. 补 `e2e-ui` 桌面审图场景 + +原因: + +- 前两步能最快建立统一语言 +- Settings 是最容易做成规范样板的内容页 +- Workspace 是复杂度最高、但最需要稳定基线的桌面核心场景 +- Overlay 最适合在主壳语言成型后统一收口 + +--- + +## 8. 实施前提 + +在开始具体改造前,需要接受以下前提: + +- 这是一次“规范 + 页面”并行的精修,而不是单纯视觉润色 +- 允许对 `tokens.css`、`base.css`、`components.css` 和主页面样式块做有组织的结构性调整 +- `e2e-ui` 会作为本轮精修的主要验收辅助工具之一 + +--- + +## 9. 结论 + +本轮 PC 精修的重点不是“把几个页面做得更好看”,而是建立一套能持续支撑桌面端演进的视觉规则。 + +最合适的策略是: + +- 先补规范层断口 +- 再按入口页、Settings、Workspace、Overlay 的顺序精修主路径 +- 最后用 `e2e-ui` 把桌面审图场景补齐,形成后续回归基线 + +这样做的结果不是一次性的样式整理,而是一套更稳定的 PC 端设计基础设施。 From d02a77a28e26b96dfefcdedaedc3d110f8c55932 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 15 May 2026 01:46:13 +0000 Subject: [PATCH 18/28] docs: add contextual diagnostics assistant design --- ...contextual-diagnostics-assistant-design.md | 398 ++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-15-contextual-diagnostics-assistant-design.md diff --git a/docs/superpowers/specs/2026-05-15-contextual-diagnostics-assistant-design.md b/docs/superpowers/specs/2026-05-15-contextual-diagnostics-assistant-design.md new file mode 100644 index 000000000..26698291f --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-contextual-diagnostics-assistant-design.md @@ -0,0 +1,398 @@ +# Contextual Diagnostics Assistant Design + +> Status: Draft +> Date: 2026-05-15 +> Scope: `packages/web/src/features/welcome/index.tsx`, `packages/web/src/features/workspace/views/shared/workspace-launch-modal.tsx`, `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts`, `packages/web/src/features/agent-panes/actions/use-provider-launcher.ts`, `packages/web/src/features/settings/*`, `packages/web/src/hooks/use-bootstrap.ts`, new `packages/web/src/features/diagnostics/*`, new diagnostics server command surface + +## Goal + +Replace the proposed setup-first activation funnel with a quieter diagnostics assistant that only appears when the user is blocked or highly likely to become blocked. + +The product should preserve direct paths for users who already know what they want to do. Diagnostics should help recover from failure, repair the environment, and return the user to the original task without turning setup into a mandatory ritual. + +## Problem + +The current conversion-first activation proposal assumes that all users should enter a dedicated setup flow before the product can deliver value. + +That approach conflicts with the desired interaction model: + +- setup should not become the default front door +- users should not be interrupted when their current path is already working +- diagnostics should feel like a capable assistant, not a funnel +- the product should help most when the user is stuck, not when they are still exploring + +If the product replaces the welcome-page primary action with a setup-first route, it creates new friction for users who simply want to open a workspace or start coding immediately. + +## Decision + +Reposition setup as a contextual diagnostics assistant instead of a mandatory activation flow. + +The new model is: + +`User intent -> normal product flow -> blocking/high-risk failure detected -> Diagnostics assistant -> repair -> continue original task` + +This changes the role of the feature in four ways: + +- it becomes reactive by default, not proactive by default +- it is triggered by context and user intent, not by first-run status alone +- it preserves the original user goal instead of replacing it with a new funnel +- it stays mostly invisible when the environment is already healthy + +## Product Principles + +### Do Not Interrupt Healthy Flows + +If the user can open a workspace, start a session, or continue work normally, diagnostics should stay out of the way. + +### Intervene at the Moment of Friction + +Diagnostics should appear when the product knows the user is blocked or very likely to fail on the next step. + +### Preserve User Intent + +The assistant should always remember what the user was trying to do: + +- open a workspace +- start a session +- continue on phone + +The recovery surface exists to complete that task, not to start a separate journey. + +### Keep the Surface Small + +Diagnostics should not become a generic dashboard or onboarding center. It should be a focused recovery surface with a short list of relevant issues and concrete next actions. + +## Entry Strategy + +### Welcome Page + +The welcome page should keep `Open Workspace` as its primary action. + +It should not present setup or diagnostics as a primary call to action. Users visiting the product for the first time should still be able to try the product directly without first being asked to enter a wizard. + +Diagnostics may be accessible later from settings or error recovery paths, but it should not compete with the welcome-page primary action. + +### Settings + +Settings should expose a low-emphasis manual entry point such as `Diagnostics` or `Help & Diagnostics`. + +This supports proactive users who want to inspect or repair their environment without making diagnostics part of the default first-run experience. + +### Recovery Redirects + +The product may route a user into diagnostics when: + +- an action has already failed +- the next action is very likely to fail based on known readiness state + +This redirect should be contextual and intentional. It is not a generic “you should probably run setup” reminder. + +## Trigger Matrix + +Diagnostics should initially support these contexts. + +### 1. Workspace Recovery + +Trigger when the user is trying to open a workspace and: + +- the open action fails +- the selected path is invalid or inaccessible +- the product determines that the current workspace selection cannot continue + +Do not trigger if the workspace opens successfully. + +### 2. Session Recovery + +Trigger when the user is trying to start a session and: + +- the required provider is not installed +- the provider CLI is missing +- authentication is required but incomplete +- runtime readiness is missing and session creation is highly likely to fail + +This is the highest-value preemptive trigger because the product can often detect the problem before a failed session launch. + +### 3. Mobile Continuation Recovery + +Trigger only when the user explicitly asks to continue on phone and: + +- the server is not reachable on LAN +- host exposure is incompatible with phone continuation +- authentication or password requirements are not satisfied + +Do not show mobile diagnostics before the user expresses mobile intent. + +### 4. Manual Diagnostics + +Allow a user to open diagnostics from settings even if they are not currently blocked. + +This entry point is secondary and should not drive the overall product architecture. + +For v1, settings is the only always-available manual entry point. + +## URL and Naming + +Keep a dedicated route, but weaken the setup wording. + +Chosen route and labels for v1: + +- route: `/diagnostics` +- page title: `Diagnostics` +- settings entry label: `Diagnostics` + +Avoid presenting the route as `Setup` in product language. The assistant should read like a support and repair tool, not an onboarding step. + +Deep-linking remains useful for redirects from failure states and for manual access from settings. + +## Interaction Model + +Diagnostics should use a dedicated page rather than an inline banner or side panel for the first version. + +Reasoning: + +- the blocked state is important enough to justify focused recovery +- a dedicated page can carry clearer context and stronger next actions +- it avoids overloading existing workspace, provider, and welcome surfaces with too much failure logic + +However, entry into this page should be conditional and infrequent. + +The user flow should be: + +1. user attempts an action +2. product detects a blocking or high-risk issue +3. product routes to diagnostics with task context +4. diagnostics explains the issue, offers repair actions, and refreshes status +5. once recovery conditions are satisfied, the page promotes continuing the original task + +## Page Information Architecture + +The page should stay intentionally small. + +### Section 1: Context Header + +The header should say what the user was trying to do and what went wrong. + +Examples: + +- `We couldn't open your workspace` +- `Your Codex session is not ready to start` +- `Phone continuation needs a few fixes` + +Avoid abstract titles like `Setup` or `Environment Doctor` as the primary heading. + +### Section 2: Short Explanation + +Add one sentence explaining why the user is here. + +Examples: + +- `A few issues need attention before we can continue.` +- `We found a problem that would block session startup.` + +### Section 3: Relevant Issues List + +Only show issues relevant to the current intent. + +Each issue item should include: + +- a simple status +- a short problem statement +- one clear next action + +The initial user-facing state vocabulary should be limited to: + +- `checking` +- `ready` +- `needs_attention` + +The UI should avoid exposing a large internal state machine unless there is a concrete user benefit. + +### Section 4: Primary Continuation Action + +The bottom of the page should always preserve the original goal with a strong primary action. + +Examples: + +- `Retry Opening Workspace` +- `Continue Starting Session` +- `Continue on Phone` + +If requirements are not yet met, the button may be disabled with a brief reason. + +### Section 5: Secondary Actions + +Secondary actions for v1 may include: + +- `Back` +- `Open Settings` +- `Copy error details` + +These actions should not compete with the primary continuation action. + +The v1 page should not include a separate expandable advanced-details section. If technical detail is needed, `Copy error details` is the escape hatch. + +## Diagnostics Context Model + +The assistant should operate on a small set of explicit contexts instead of a single monolithic setup flow. + +Initial contexts: + +- `workspace_open` +- `session_start` +- `mobile_continue` +- `manual_check` + +This context should be passed into the diagnostics page and the diagnostics data fetch. + +The main reason for this model is relevance: the user should only see the checks and recovery actions needed for the task they are actually trying to complete. + +## Server Contract + +The server should not expose a broad onboarding-oriented `setup.status` as the main future-facing abstraction. + +Instead, the diagnostics surface should move toward a contextual command model such as: + +- `diagnostics.get` +- `diagnostics.recheck` + +Input: + +- `context` +- optional task metadata, such as target provider or selected workspace path + +Output: + +- page context metadata +- relevant checks +- recommended actions +- a boolean or derived state indicating whether continuation is allowed + +For migration purposes, existing setup-oriented command work may be adapted behind a new diagnostics facade if that reduces churn, but product-facing naming and usage should move to diagnostics language. + +## Check Categories + +The actual checks should be filtered by context. + +### Workspace Context + +Relevant checks: + +- workspace path selected +- path exists +- path is readable +- path is openable by the workspace manager + +### Session Context + +Relevant checks: + +- provider installed +- provider CLI available +- provider authenticated if required +- runtime ready enough to launch +- workspace selected if session launch depends on it + +### Mobile Continuation Context + +Relevant checks: + +- host exposure mode +- reachable LAN candidates +- auth enabled when required +- password configured if the mobile flow depends on it + +### Manual Check Context + +This context may show a broader system summary, but it should still be grouped and readable rather than presented as a raw technical dump. + +## Repair Actions + +Each issue should map to a concrete action owned by the product where possible. + +Examples: + +- `Choose Workspace` +- `Retry Open` +- `Install Provider` +- `Open Provider Settings` +- `Refresh Status` +- `Enable Password Protection` + +Prefer direct actions over doc links. + +When the product cannot repair automatically, it should still describe the next step in product language and then offer the nearest helpful destination. + +## Resume and Continuation + +Diagnostics must preserve the pending task across repair attempts. + +Examples: + +- after selecting a valid workspace, continue the original workspace-open path +- after provider installation or authentication, continue the original session-start path +- after network or auth fixes, continue the original phone-handoff path + +This is a core requirement. Diagnostics should not repair the environment and then abandon the user in a generic state. + +## Error Handling + +The diagnostics page should distinguish between: + +- a known recoverable problem +- an unknown error while attempting recovery +- stale readiness data + +Guidelines: + +- known recoverable problems should surface one recommended next action +- unknown recovery errors should show a short failure message and allow retry +- stale readiness data should bias toward recheck rather than inventing new warnings + +The page should never collapse into a blank state after a failed fix attempt. + +## Testing Strategy + +Add coverage in three layers. + +### Route and Access + +- diagnostics route renders correctly +- manual entry from settings works +- redirects into diagnostics preserve context metadata + +### Contextual Recovery + +- workspace failure routes into workspace diagnostics +- provider/session failure routes into session diagnostics +- mobile continuation failure routes into mobile diagnostics + +### Continuation + +- successful repair re-enables the correct primary continuation action +- retry resumes the original intent rather than sending the user to a generic landing state + +## Migration Impact + +This design deliberately rejects the earlier setup-first interaction model. + +That means the following planned changes should be revised before implementation: + +- do not replace the welcome-page primary CTA with `Start Setup` +- do not make `/setup` the default front door for first-run users +- do not frame diagnostics as a mandatory wizard +- do not expose all checks for all contexts by default + +Existing useful work from the prior proposal can still be reused: + +- readiness DTOs +- provider/runtime checks +- mobile access status logic +- reusable directory picker work + +The implementation should reuse these capabilities while changing the user-facing orchestration model. + +## Recommendation + +Proceed with a diagnostics-first design rather than a setup-first funnel. + +This approach preserves fast paths for confident users, keeps failure recovery coherent, and matches the intended product personality: helpful when needed, invisible when not. From 1d0df1cb08834b90e47cc3fd18ecca169d7fcb27 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 15 May 2026 02:56:41 +0000 Subject: [PATCH 19/28] test: stabilize develop branch regression checks --- packages/server/src/__tests__/git/cli.test.ts | 2 +- .../__tests__/server-runtime-config.test.ts | 25 +++++++++++++++---- .../src/shells/mobile-shell/index.test.tsx | 2 +- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/server/src/__tests__/git/cli.test.ts b/packages/server/src/__tests__/git/cli.test.ts index 74ef30395..df844c127 100644 --- a/packages/server/src/__tests__/git/cli.test.ts +++ b/packages/server/src/__tests__/git/cli.test.ts @@ -973,7 +973,7 @@ describe("runGitCheckout", () => { const result = await runGitCheckout(testDir, "feature/worktree-branch"); expect(result.success).toBe(false); - expect(result.message).toContain("already used by worktree"); + expect(result.message).toMatch(/already (?:used by worktree|checked out at)/); expect(result.message).toContain(linkedWorktreeDir); } finally { await rm(linkedWorktreeDir, { recursive: true, force: true }); diff --git a/packages/server/src/__tests__/server-runtime-config.test.ts b/packages/server/src/__tests__/server-runtime-config.test.ts index a813dac7c..ef554d2ff 100644 --- a/packages/server/src/__tests__/server-runtime-config.test.ts +++ b/packages/server/src/__tests__/server-runtime-config.test.ts @@ -1,5 +1,5 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { getRuntimePath, readRuntimeConfig } from "@coder-studio/core/runtime"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -9,7 +9,10 @@ import { createServer, type Server, type ServerRuntimeOptions } from "../server. describe("server runtime config", () => { const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; + const originalRuntimeDir = process.env.CODER_STUDIO_RUNTIME_DIR; + const originalRuntimePath = process.env.CODER_STUDIO_RUNTIME_JSON_PATH; let testHomeDir: string; + let runtimePath: string; let server: Server | undefined; const createRuntimeServer = async ( @@ -20,6 +23,9 @@ describe("server runtime config", () => { testHomeDir = mkdtempSync(join(tmpdir(), "cs-server-runtime-home-")); process.env.HOME = testHomeDir; process.env.USERPROFILE = testHomeDir; + runtimePath = join(testHomeDir, ".coder-studio", "runtime.json"); + delete process.env.CODER_STUDIO_RUNTIME_DIR; + process.env.CODER_STUDIO_RUNTIME_JSON_PATH = runtimePath; }); afterEach(async () => { @@ -28,7 +34,6 @@ describe("server runtime config", () => { server = undefined; } - const runtimePath = join(homedir(), ".coder-studio", "runtime.json"); if (existsSync(runtimePath)) { rmSync(runtimePath); } @@ -46,6 +51,18 @@ describe("server runtime config", () => { } else { process.env.USERPROFILE = originalUserProfile; } + + if (originalRuntimeDir === undefined) { + delete process.env.CODER_STUDIO_RUNTIME_DIR; + } else { + process.env.CODER_STUDIO_RUNTIME_DIR = originalRuntimeDir; + } + + if (originalRuntimePath === undefined) { + delete process.env.CODER_STUDIO_RUNTIME_JSON_PATH; + } else { + process.env.CODER_STUDIO_RUNTIME_JSON_PATH = originalRuntimePath; + } }); it("writes runtime config on startup and clears it on stop", async () => { @@ -62,9 +79,7 @@ describe("server runtime config", () => { pid: process.pid, }) ); - expect(getRuntimePath()).toBe( - process.env.CODER_STUDIO_RUNTIME_JSON_PATH ?? join(homedir(), ".coder-studio", "runtime.json") - ); + expect(getRuntimePath()).toBe(runtimePath); await server.stop(); server = undefined; diff --git a/packages/web/src/shells/mobile-shell/index.test.tsx b/packages/web/src/shells/mobile-shell/index.test.tsx index c3e5fe71b..fb6aa513d 100644 --- a/packages/web/src/shells/mobile-shell/index.test.tsx +++ b/packages/web/src/shells/mobile-shell/index.test.tsx @@ -807,7 +807,7 @@ describe("MobileShell Phase 2 workspace", () => { return undefined; }); - renderMobileShell({ + const { store } = renderMobileShell({ initialEntry: "/workspace", sessions: [], paneLayout: { From 1a39128d63d62616e8aa3ba4e85014b38dd31dce Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 15 May 2026 03:23:43 +0000 Subject: [PATCH 20/28] fix: preserve supervisor plan step status literals --- packages/server/src/supervisor/evaluator.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/server/src/supervisor/evaluator.ts b/packages/server/src/supervisor/evaluator.ts index f5122c749..7183e93a6 100644 --- a/packages/server/src/supervisor/evaluator.ts +++ b/packages/server/src/supervisor/evaluator.ts @@ -583,8 +583,8 @@ function parseSupervisorEvaluationResult( ? record.guidance.trim().slice(0, guidanceMaxChars) : undefined; - const plan = Array.isArray(record.plan) - ? record.plan.flatMap((value) => { + const plan: SupervisorPlanStep[] | undefined = Array.isArray(record.plan) + ? record.plan.flatMap((value) => { if (!value || typeof value !== "object") { return []; } @@ -600,8 +600,8 @@ function parseSupervisorEvaluationResult( }) : undefined; - const stepUpdates = Array.isArray(record.stepUpdates) - ? record.stepUpdates.flatMap((value) => { + const stepUpdates: SupervisorCycleStepUpdate[] | undefined = Array.isArray(record.stepUpdates) + ? record.stepUpdates.flatMap((value) => { if (!value || typeof value !== "object") { return []; } From 27d958e334c80a37df80a9651e89d538b1537bbc Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 15 May 2026 03:19:54 +0000 Subject: [PATCH 21/28] docs: add managed restart terminal preservation design --- ...ed-restart-terminal-preservation-design.md | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-15-managed-restart-terminal-preservation-design.md diff --git a/docs/superpowers/specs/2026-05-15-managed-restart-terminal-preservation-design.md b/docs/superpowers/specs/2026-05-15-managed-restart-terminal-preservation-design.md new file mode 100644 index 000000000..3ff86666a --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-managed-restart-terminal-preservation-design.md @@ -0,0 +1,335 @@ +# Managed Restart Terminal Preservation Design + +> Date: 2026-05-15 +> Status: Draft for review +> Scope: Make explicit `--restart` preserve active terminals and agent sessions long enough for the next server instance to reattach, while keeping `stop` and crash semantics unchanged + +## 1. Overview + +当前 `coder-studio serve --restart` / `coder-studio open --restart` 的行为与普通停止没有本质区别:CLI 会先删掉旧 managed server,旧 server 停机时会执行 `terminalMgr.shutdown()`,从而对所有 PTY 发送 `SIGTERM` 并清掉内存中的 ring buffer / snapshot。结果是: + +- 不能在 Coder Studio 自己的终端里直接执行 Coder Studio 的主动重启 +- shell terminal 会被一起杀掉 +- agent session 会因为底层 terminal 消失而在新 server 启动后被 hydrate 为 `ended` + +本设计的目标不是改变“服务停止会关闭所有进程”这一默认语义,而是只为显式 `--restart` 增加一个受控例外:让 terminal 和 agent session 在一个很短的重启窗口里暂存,等待新 server 实例接管;如果新实例没有在窗口内完成接管,仍然按“关闭所有进程”收尾。 + +## 2. Goals + +- 只在显式 `--restart` 路径下保留 active shell terminal 和 agent session 对应的 PTY +- 允许用户在 Coder Studio 自己的终端里触发 managed restart,而不把当前终端先杀掉 +- 让新 server 启动后恢复同一个 `terminalId` / `sessionId`,而不是新建新的 terminal / session +- 复用现有 websocket 自动重连、`terminal.replay` 和 `terminal.snapshot` 恢复能力 +- 确保重启失败时不会留下无限期悬挂的孤儿 PTY + +## 3. Non-Goals + +- 不改变 `stop` 的当前语义;主动 stop 仍然关闭所有 PTY 和会话 +- 不改变进程崩溃、PM2 异常退出、机器重启时的默认行为;这些情况仍然应最终关闭 PTY +- 不实现跨主机或跨机器的会话迁移 +- 不保证操作系统重启后的 session 恢复 +- 不把 tmux / screen 作为用户可见的新运行时依赖 + +## 4. Current Behavior + +### 4.1 CLI restart path + +`startManagedServer()` 在启动新 managed server 之前,会先删除旧 managed server。这个流程与主动 stop 的效果相同,都会先让旧 server 退出。 + +### 4.2 Server shutdown path + +旧 server 退出时会调用 `terminalMgr.shutdown()`。该逻辑会对所有还活着的 terminal 发送 `SIGTERM`,然后立即释放本地 terminal 状态和 snapshot buffer。 + +### 4.3 Session hydrate path + +新 server 启动后,`SessionManager.hydrate()` 只会保留仍然能在 `terminalMgr` 内存里找到、并且 `alive === true` 的 terminal。只要 terminal 不在内存里,原先 `running` / `idle` 的 session 最终都会被收敛成 `ended`。 + +### 4.4 Frontend recovery baseline + +前端已经具备 websocket 自动重连,以及 `terminal.replay` / `terminal.snapshot` 的恢复机制。也就是说,真正缺失的不是浏览器端重连,而是“server 重启期间 terminal 生命周期能否延续到下一实例”。 + +## 5. Constraints + +- 旧 server 进程退出后,当前 `TerminalManager` 持有的 PTY 句柄无法直接被新 server 进程继承 +- 仅仅在 `--restart` 时跳过 `terminalMgr.shutdown()` 并不能解决问题,因为新 server 没有办法接回旧进程内存里的 PTY 对象 +- 只有显式 `--restart` 才能触发保留;普通 stop 或 crash 不能因为“终端还活着”而改变原有关闭语义 +- 重启窗口必须是有限时长;失败重启不能演变成永久保活 +- 现有 `session.state` 不能只靠“terminal 还活着”来推断,agent session 在 server 离线窗口里的输出仍然可能改变 `running` / `idle` 状态 + +## 6. Options Considered + +### 6.1 Option A: Skip `terminalMgr.shutdown()` only for `--restart` + +这个方案最小,但不可行。问题不在于“是否 kill”,而在于 PTY 对象当前完全属于旧 server 进程。一旦旧进程退出,新进程接不回这些 PTY,也接不回 ring buffer 与 snapshot。 + +### 6.2 Option B: Host terminals inside tmux / screen + +这个方案可以快速验证“terminal 重启后仍然存在”的体验,但语义不符合本需求。tmux 更接近“即便 server crash 也继续保活”,而本需求要求只有显式 `--restart` 例外,其余 stop / crash 仍然关闭。它还会把 session 状态同步和 terminal 历史恢复复杂度转嫁给外部工具。 + +### 6.3 Option C: Introduce a dedicated PTY broker plus explicit restart intent + +推荐方案。把 PTY 生命周期从 server 进程中拆出来,由独立 broker 持有 PTY、ring buffer 和 snapshot。旧 server 只在显式 `--restart` 时把 terminal 从“attached”转换到短时“preserved”,新 server 在 TTL 内重新 claim;普通 stop 和 crash 仍然由 broker 负责收尾并 kill PTY。 + +## 7. Chosen Design + +采用 `restart intent + PTY broker + lease/TTL` 模型。 + +核心原则: + +- PTY 的真实生命周期不再由 `TerminalManager` 进程内对象决定,而由 broker 决定 +- “保留 terminal” 不再等价于“旧 server 不 kill”,而等价于“旧 server 把 terminal lease 交给 broker 的短时 preserved 状态” +- broker 在 owner 断开连接时默认 kill PTY;只有存在有效 preserve lease 时才短时保留 +- 新 server 必须在 listen 之前先完成 terminal reattach / hydrate,这样前端 websocket 恢复后看到的仍是同一组 terminal 和 session + +## 8. Architecture + +### 8.1 Restart intent + +CLI 在显式 `--restart` 路径下,先写一个本地 `restart-intent` 文件,再停止旧 server、启动新 server。 + +推荐字段: + +- `requestId` +- `expectedServerInstanceId` +- `createdAt` +- `expiresAt` +- `mode: "preserve_terminals"` + +约束: + +- 只有 `--restart` 写 intent;普通 `stop` 不写 +- intent 必须绑定旧 server 的 `serverInstanceId`,防止陈旧 intent 让后续无关退出误入 preserve 路径 +- intent 过期后无效;broker 和 server 都不得接受过期 intent + +### 8.2 PTY broker + +新增独立本地 broker 进程,负责持有: + +- PTY 进程本身 +- terminal 元数据:`terminalId`、`workspaceId`、`kind`、`argv`、`cwd`、`cols`、`rows`、`title` +- ring buffer +- headless snapshot buffer +- 序列号与 `lastOutputAt` +- 当前 lease 状态 + +broker 仅提供本机 IPC,不开放远程网络访问。实现层可按平台封装为: + +- POSIX:Unix domain socket +- Windows:named pipe + +Server 通过 broker client 与其通信;`TerminalManager` 从“直接拥有 PTY”改为“对 broker 的本地适配层”。 + +### 8.3 Terminal lease state machine + +每个 terminal 都处于以下状态之一: + +- `attached` + 归某个 `serverInstanceId` 所有;正常收发 output、write、resize、replay、snapshot +- `preserved` + 仅在显式 `--restart` 触发;旧 owner 已退出或即将退出,terminal 保留到 `expiresAt` +- `ended` + PTY 已退出或被 broker 杀掉 + +状态迁移规则: + +- `attached -> preserved` + 只有旧 server 在看到匹配自己的有效 restart intent 后,显式调用 `detachForRestart(requestId, ttlMs)` 才允许发生 +- `attached -> ended` + 主动 stop、terminal.close、workspace teardown、owner crash 且无有效 preserve lease 时进入 +- `preserved -> attached` + 新 server 在 TTL 内用同一个 `requestId` claim 成功 +- `preserved -> ended` + TTL 到期、claim 失败清理、或 broker 检测到 terminal 本身已退出 + +### 8.4 Owner crash semantics + +这是本设计最重要的边界之一。 + +broker 必须维护 owner 连接或 heartbeat。当 owner server 断开时: + +- 如果 terminal 仍是普通 `attached`,broker 立即 kill PTY +- 只有 terminal 已经被显式切到 `preserved`,broker 才允许它继续活到 `expiresAt` + +这保证: + +- 主动 `stop` 保持现状 +- 非预期 crash 保持现状 +- 只有显式 `--restart` 才有例外 + +### 8.5 TerminalManager responsibilities after refactor + +`TerminalManager` 不再直接 `spawn()` 本地 PTY。它改为: + +- `create(spec)` 时请求 broker 创建 terminal +- 订阅 broker 输出事件并继续向 event bus 发 `terminal.output` +- 通过 broker 执行 `write` / `resize` / `close` / `replay` / `snapshot` +- 在 server 启动时从 broker `hydrateAttached()` / `claimPreserved()` 恢复 active terminals +- 在普通 stop 时调用 broker close-all +- 在 restart-preserve stop 时调用 broker detach-for-restart,而不是 close-all + +### 8.6 SessionManager recovery model + +仅仅把 terminal 接回来还不够,因为 agent session 在 server 离线期间可能继续输出,状态可能从 `running` 变成 `idle`。 + +因此,新 server 在 claim 完 terminal 后,`SessionManager.hydrate()` 需要分两类处理: + +- `shell terminal` + 只要求 terminal 仍然存活,前端恢复后可继续交互 +- `agent session` + 除了判断 terminal 是否存活,还必须恢复 PTY state detector 的上下文 + +推荐做法: + +- broker 为每个 terminal 记录 `lastOutputAt` 和一段最近输出 tail +- 新 server 为 hydrated agent session 重新创建 `PtyStateDetector` +- 如果 session 原状态是 `running` / `starting`,则向 detector 回放 preserve 窗口内的输出或 recent tail +- 如果窗口内无新输出,且 `now - lastOutputAt` 已超过 provider 的 `idleDebounceMs`,则可直接把 session 纠正为 `idle` +- 如果 terminal 在 preserve 窗口内自然退出,则 session 仍按现有语义转为 `ended` + +这一步的目标不是完美重放所有历史,而是保证 server 重启不会让一个已经闲置的 agent session 永久卡在 `running`。 + +## 9. Lifecycle + +### 9.1 Normal stop + +1. CLI 执行 stop,不写 restart intent +2. server 收到终止信号 +3. `TerminalManager` 走普通 shutdown 路径 +4. broker 关闭并 kill 所有 attached terminals +5. session 按现有规则结束 + +### 9.2 Explicit `--restart` + +1. CLI 读取当前 runtime,写入带 `expectedServerInstanceId` 的 restart intent +2. CLI 停掉旧 managed server +3. 旧 server 收到终止信号,检测到与自己匹配的有效 intent +4. `TerminalManager` 不执行普通 shutdown,而是对 active terminals 调用 `detachForRestart(requestId, ttlMs)` +5. broker 把 terminals 标记为 `preserved` +6. 新 server 启动,连接 broker,并用 `requestId` claim preserved terminals +7. 新 server 先 hydrate terminals,再 hydrate sessions,最后开始监听 HTTP / WS +8. CLI 观察到新 runtime 就绪后清理 restart intent + +### 9.3 Restart failure + +1. CLI 已写 intent,旧 server 已 detach terminals +2. 新 server 未在 TTL 内成功启动并 claim +3. broker 在 `expiresAt` 后 kill preserved terminals +4. 结果退化为“重启失败即关闭 terminal / session” + +### 9.4 Unexpected crash + +1. server 进程崩溃,没有机会执行 `detachForRestart()` +2. broker 发现 owner 断开,terminal 仍为 `attached` +3. broker 立即 kill terminals +4. 行为与当前 crash 语义一致 + +## 10. Data and API Surface + +### 10.1 Broker API surface + +最小需要的 broker 操作包括: + +- `createTerminal(spec, ownerServerInstanceId)` +- `attachTerminal(terminalId, ownerServerInstanceId)` +- `claimPreservedTerminals(requestId, newServerInstanceId)` +- `detachForRestart(ownerServerInstanceId, requestId, ttlMs)` +- `write(terminalId, bytes)` +- `resize(terminalId, cols, rows)` +- `replay(terminalId, lastSeq)` +- `snapshot(terminalId)` +- `close(terminalId)` +- `closeAllForOwner(serverInstanceId)` +- `subscribeOutput(ownerServerInstanceId)` + +### 10.2 Persistence expectations + +不要求把 broker 的 terminal 状态写入数据库。broker 是短生命周期的本地运行时组件,不是持久化存储层。 + +数据库仍然是: + +- terminal / session 身份的持久化来源 +- workspace 与 session 关联关系的持久化来源 + +broker 提供的是跨 server 进程重启窗口的“运行时延续”,不是长期持久化。 + +## 11. Frontend Impact + +前端改动应尽量小。 + +因为 websocket client 已经具备: + +- 自动重连 +- reconnect 状态追踪 +- replay / snapshot 恢复 + +所以关键要求是:新 server 必须在对外 accept websocket 之前先完成 terminal/session hydrate。只要这点成立,前端通常不需要新增专门的“restart preserve”协议。 + +可选增强: + +- 在 terminal 恢复期间维持现有 reconnect UI,不新增“session ended”误导文案 +- 若个别 terminal 在 claim 前短暂返回 `unknown`,前端可继续使用现有 reconnect/backoff 机制等待下一次恢复 + +## 12. Risks + +### 12.1 Broker complexity + +这会引入新的本地守护进程和 IPC 层,复杂度高于单进程 terminal manager。但不这样拆,就无法满足“旧 server 退出后,新 server 接回同一 PTY”的硬约束。 + +### 12.2 Session state drift + +agent session 在 server 离线窗口里继续输出,会让 `running` / `idle` 状态恢复变复杂。如果不补 detector catch-up,terminal 看似保住了,但 session 状态会长期错误。 + +### 12.3 Stale intent or stale preserved terminals + +必须用 `expectedServerInstanceId + requestId + expiresAt` 三重约束,避免陈旧 intent 误命中;broker 也必须在 TTL 到期后强制 kill preserved terminals,不能让它们无限存活。 + +## 13. Testing Strategy + +### 13.1 Broker unit tests + +- attached terminal 在 owner crash 时立即被 kill +- preserved terminal 在 TTL 内不会被 kill +- preserved terminal 在 TTL 到期后被 kill +- stale / mismatched intent 无法触发 preserve + +### 13.2 Server integration tests + +- `stop` 仍然结束 shell terminal 和 agent session +- `--restart` 可让 shell terminal 在新 server 启动后继续 write / replay / snapshot +- `--restart` 可让 hydrated session 保持原 `sessionId` / `terminalId` +- running agent session 在重启窗口结束后可被纠正回 `idle` 或 `ended` +- restart 失败时,preserved terminal 会在 TTL 后被清理 + +### 13.3 Web recovery tests + +- websocket reconnect 后,terminal panel 对 preserved terminal 不显示 ended 状态 +- reconnect 恢复仍走现有 replay / snapshot 流程 +- preserved terminal claim 失败时,前端最终表现为正常断开,而不是假恢复 + +## 14. Rollout Plan + +建议分两阶段落地: + +### Phase 1: Shell-first preservation + +- 引入 broker、restart intent、lease/TTL +- 先让 shell terminal 在 `--restart` 后可继续存活和恢复 +- 验证 stop/crash 语义未变 + +### Phase 2: Agent session state recovery + +- 补齐 `PtyStateDetector` 的 restart catch-up 逻辑 +- 让 preserved agent session 在新 server 启动后恢复正确的 `running` / `idle` / `ended` 状态 + +分阶段的原因是:shell continuity 解决的是“能不能在自己的终端里重启自己”,而 agent session state recovery 解决的是“恢复后状态是否仍然正确”。两者相关,但风险和验证面不同。 + +## 15. Final Decision + +本设计明确采纳以下产品语义: + +- 只有显式 `--restart` 会临时保留 terminals / sessions +- 主动 `stop` 仍然关闭所有进程 +- 非预期 crash 仍然关闭所有进程 +- `--restart` 失败时,TTL 到期后也仍然关闭所有进程 + +换句话说,本次不是把 Coder Studio 改造成“terminal 永久托管器”,而只是给 managed restart 增加一个严格受控、自动回收的短时保留窗口。 From e27cd048832ff72d337512329695d6914ad38f37 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 15 May 2026 04:10:33 +0000 Subject: [PATCH 22/28] chore(changeset): add patch release note for windows runtime verification --- .changeset/fair-forks-cheer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fair-forks-cheer.md diff --git a/.changeset/fair-forks-cheer.md b/.changeset/fair-forks-cheer.md new file mode 100644 index 000000000..0c8a9ee04 --- /dev/null +++ b/.changeset/fair-forks-cheer.md @@ -0,0 +1,5 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Fix Windows runtime verification by preserving supervisor plan step status literals during evaluation payload parsing so the bundled server package builds cleanly in CI. From 2ed10341e1af65290545731525f7fe7e6435dac7 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 15 May 2026 20:30:43 +0000 Subject: [PATCH 23/28] fix supervisor rollback and busy state --- .changeset/sharp-bottles-yawn.md | 5 +++ .../src/__tests__/supervisor-manager.test.ts | 20 +++++++++ packages/server/src/supervisor/manager.ts | 22 ++++++---- .../actions/use-supervisor-actions.ts | 6 ++- .../components/supervisor-card.test.tsx | 43 +++++++++++++++++++ 5 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 .changeset/sharp-bottles-yawn.md diff --git a/.changeset/sharp-bottles-yawn.md b/.changeset/sharp-bottles-yawn.md new file mode 100644 index 000000000..cff39cb10 --- /dev/null +++ b/.changeset/sharp-bottles-yawn.md @@ -0,0 +1,5 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Fix supervisor creation rollback when target files fail, and keep manual trigger disabled while an earlier supervisor cycle is still in flight. diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts index 09bad507a..c6b123a8d 100644 --- a/packages/server/src/__tests__/supervisor-manager.test.ts +++ b/packages/server/src/__tests__/supervisor-manager.test.ts @@ -373,6 +373,26 @@ describe("SupervisorManager cycle triggers", () => { expect(managerInternals.evaluator.logger).toBe(deps.logger); }); + it("deletes the persisted supervisor when create target files fails", async () => { + const createTargetFilesError = new Error("disk full"); + deps.targetStore.createTargetFiles.mockImplementationOnce(async () => { + throw createTargetFilesError; + }); + + await expect( + manager.create({ + sessionId: "sess-create-fails", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "codex", + }) + ).rejects.toThrow("disk full"); + + expect(deps.supervisorRepo.delete).toHaveBeenCalledWith(expect.any(String)); + expect(manager.getBySession("sess-create-fails")).toBeUndefined(); + expect(deps.supervisorRepo.getBySessionId("sess-create-fails")).toBeUndefined(); + }); + it("returns an in-flight cycle immediately on manual triggerEvaluation", async () => { const supervisor = await manager.create({ sessionId: "sess-manual", diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts index 2c12ef7ed..8d18658f6 100644 --- a/packages/server/src/supervisor/manager.ts +++ b/packages/server/src/supervisor/manager.ts @@ -387,15 +387,21 @@ export class SupervisorManager { updatedAt: now, }) ); - await this.deps.targetStore.createTargetFiles(workspace.path, { - targetId, - sessionId: req.sessionId, - workspaceId: req.workspaceId, - objective, - createdAt: now, - }); + let enriched: Supervisor; + try { + await this.deps.targetStore.createTargetFiles(workspace.path, { + targetId, + sessionId: req.sessionId, + workspaceId: req.workspaceId, + objective, + createdAt: now, + }); - const enriched = await this.attachTargetState(supervisor, workspace.path); + enriched = await this.attachTargetState(supervisor, workspace.path); + } catch (error) { + this.deps.supervisorRepo.delete(supervisor.id); + throw error; + } this.storeSnapshot(enriched); this.broadcastState(enriched, "created"); diff --git a/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts b/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts index 927e5a9bb..84a6f5e0d 100644 --- a/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts +++ b/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts @@ -102,6 +102,9 @@ export function useSupervisorActions({ sessionId }: UseSupervisorActionsArgs) { : ([] as SupervisorCycle[]); const latestCycle = cycles[0]; + const hasInFlightCycle = cycles.some( + (cycle) => cycle.status === "evaluating" || cycle.status === "queued" + ); const latestCycleText = latestCycle ? (latestCycle.result ?? latestCycle.errorReason ?? @@ -166,7 +169,8 @@ export function useSupervisorActions({ sessionId }: UseSupervisorActionsArgs) { handlePause, handleResume, handleTrigger, - isBusy: supervisor?.state === "evaluating" || supervisor?.state === "injecting", + isBusy: + supervisor?.state === "evaluating" || supervisor?.state === "injecting" || hasInFlightCycle, latestCycle, latestCycleText, planGeneratedLabel, diff --git a/packages/web/src/features/supervisor/components/supervisor-card.test.tsx b/packages/web/src/features/supervisor/components/supervisor-card.test.tsx index 3c4d244b4..3947106d1 100644 --- a/packages/web/src/features/supervisor/components/supervisor-card.test.tsx +++ b/packages/web/src/features/supervisor/components/supervisor-card.test.tsx @@ -332,6 +332,49 @@ describe("SupervisorCard", () => { expect(screen.getByRole("button", { name: "Pause" })).not.toBeDisabled(); }); + it("keeps manual trigger disabled while an older cycle is still evaluating", () => { + const store = createStore(); + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + supervisorsAtom, + new Map([ + [ + "sess-1", + { + ...createSupervisor(), + state: "idle", + objective: "Finish the follow-up refactor", + }, + ], + ]) + ); + store.set( + supervisorCyclesAtom, + new Map([ + [ + "sup-1", + [ + createCycle({ + status: "evaluating", + objective: "Finish the original refactor", + completedAt: undefined, + }), + ], + ], + ]) + ); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Trigger Evaluation" })).toBeDisabled(); + }); + it("renders configured execution policy metadata", () => { const store = createStore(); const scheduledAt = Date.UTC(2026, 4, 11, 3, 0); From 8cff1ba03903ffe1310f9cbe307079334ccaf36d Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 15 May 2026 22:58:31 +0000 Subject: [PATCH 24/28] fix: keep supervisor target state on schema v2 --- packages/server/src/__tests__/db.test.ts | 16 +- .../src/__tests__/supervisor-manager.test.ts | 275 +++++------------- .../src/__tests__/supervisor-repo.test.ts | 8 +- packages/server/src/storage/db.test.ts | 2 +- packages/server/src/storage/db.ts | 13 +- .../src/storage/migrations/001_init.sql | 4 +- .../storage/repositories/supervisor-repo.ts | 12 +- packages/server/src/storage/schema-version.ts | 4 +- .../server/src/supervisor/manager.test.ts | 5 +- packages/server/src/supervisor/manager.ts | 86 +++--- .../src/supervisor/target-store.test.ts | 50 +++- .../server/src/supervisor/target-store.ts | 75 +++-- 12 files changed, 248 insertions(+), 302 deletions(-) diff --git a/packages/server/src/__tests__/db.test.ts b/packages/server/src/__tests__/db.test.ts index 5ca6a9083..55dc64d0c 100644 --- a/packages/server/src/__tests__/db.test.ts +++ b/packages/server/src/__tests__/db.test.ts @@ -99,7 +99,7 @@ describe("Database", () => { expect(tables.map((table) => table.name)).not.toContain("_migrations"); }); - it("should upgrade a known v1 supervisor schema to v3 when user_version is unset", () => { + it("should upgrade a known v1 supervisor schema to v2 when user_version is unset", () => { const dbPath = join(tempDir, "v1.db"); const rawDb = new DatabaseSync(dbPath); rawDb.exec("PRAGMA user_version = 0"); @@ -116,7 +116,6 @@ describe("Database", () => { }>; expect(supervisorColumns.map((column) => column.name)).toEqual( expect.arrayContaining([ - "target_id", "evaluator_model", "max_supervision_count", "completed_supervision_count", @@ -140,7 +139,7 @@ describe("Database", () => { expect(upgradedIndex?.name).toBe("idx_supervisor_cycle_attempts_cycle"); }); - it("should upgrade a known v2 supervisor schema to v3 and backfill target ids", () => { + it("should keep a known v2 supervisor schema current without adding target ids", () => { const dbPath = join(tempDir, "v2.db"); const rawDb = new DatabaseSync(dbPath); rawDb.exec("PRAGMA user_version = 2"); @@ -205,12 +204,15 @@ describe("Database", () => { const supervisorColumns = db.prepare("PRAGMA table_info(supervisors)").all() as Array<{ name: string; }>; - expect(supervisorColumns.map((column) => column.name)).toContain("target_id"); + expect(supervisorColumns.map((column) => column.name)).not.toContain("target_id"); const upgradedRow = db - .prepare("SELECT target_id FROM supervisors WHERE id = ?") - .get("sup-legacy") as { target_id: string }; - expect(upgradedRow.target_id).toBe("legacy_sup-legacy"); + .prepare("SELECT id, objective FROM supervisors WHERE id = ?") + .get("sup-legacy") as { id: string; objective: string }; + expect(upgradedRow).toEqual({ + id: "sup-legacy", + objective: "Legacy supervisor", + }); }); it("should restamp user_version for an already-current schema when it is unset", () => { diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts index c6b123a8d..cda20a871 100644 --- a/packages/server/src/__tests__/supervisor-manager.test.ts +++ b/packages/server/src/__tests__/supervisor-manager.test.ts @@ -85,7 +85,6 @@ function createSessionRecord(sessionId: string, overrides?: Partial): S function applySupervisorPatch(current: Supervisor, patch: SupervisorUpdatePatch): Supervisor { return { ...current, - ...(patch.targetId !== undefined ? { targetId: patch.targetId } : {}), ...(patch.state !== undefined ? { state: patch.state } : {}), ...(patch.objective !== undefined ? { objective: patch.objective } : {}), ...(patch.evaluatorProviderId !== undefined @@ -184,6 +183,7 @@ function createManagerDeps() { create: vi.fn((value: NewSupervisor) => { const supervisor: Supervisor = { ...value, + targetId: value.id, maxSupervisionCount: value.maxSupervisionCount ?? 0, completedSupervisionCount: value.completedSupervisionCount ?? 0, cycles: [], @@ -284,6 +284,7 @@ function createManagerDeps() { }; const targetStore = { createTargetFiles: vi.fn(async () => {}), + resetTargetFiles: vi.fn(async () => {}), readTargetMeta: vi.fn(async (_workspacePath: string, targetId: string) => ({ targetId, sessionId: "sess-1", @@ -479,10 +480,19 @@ describe("SupervisorManager cycle triggers", () => { objective: "Start the follow-up migration", }); - expect(updated.targetId).not.toBe(supervisor.targetId); + expect(updated.targetId).toBe(supervisor.targetId); expect(updated.state).toBe("idle"); expect(updated.stopReason).toBeUndefined(); expect(updated.completedSupervisionCount).toBe(0); + expect(deps.targetStore.resetTargetFiles).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + targetId: supervisor.targetId, + sessionId: supervisor.sessionId, + workspaceId: supervisor.workspaceId, + objective: "Start the follow-up migration", + }) + ); vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({ status: "continue", @@ -494,7 +504,7 @@ describe("SupervisorManager cycle triggers", () => { expect(nextCycle?.status).toBe("injected"); }); - it("keeps in-flight cycle writes attached to the original target after an objective change", async () => { + it("cancels an in-flight cycle and resets the same target when the objective changes", async () => { const supervisor = await manager.create({ sessionId: "sess-objective-race", workspaceId: "ws-1", @@ -502,62 +512,54 @@ describe("SupervisorManager cycle triggers", () => { evaluatorProviderId: "codex", }); - let resolveEvaluation: ((result: SupervisorEvaluationResult) => void) | null = null; + let observedSignal: AbortSignal | undefined; vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementationOnce( - async () => - await new Promise((resolve) => { - resolveEvaluation = resolve; + async (_supervisor, _context, options) => + await new Promise((_resolve, reject) => { + observedSignal = options?.signal; + options?.signal?.addEventListener( + "abort", + () => { + reject({ + code: "supervisor_eval_aborted", + message: "Supervisor evaluator aborted", + }); + }, + { once: true } + ); }) ); const cycle = await manager.triggerEvaluation(supervisor.id); await waitFor(() => { - expect(resolveEvaluation).not.toBeNull(); + expect(observedSignal).toBeDefined(); expect(manager.get(supervisor.id)?.state).toBe("evaluating"); }); - const rotated = await manager.update(supervisor.id, { + const updatedPromise = manager.update(supervisor.id, { objective: "New objective", }); - expect(rotated.targetId).not.toBe(supervisor.targetId); - - resolveEvaluation?.({ - status: "continue", - reason: "Keep going on the old target", - guidance: "Run the old-target validation", - progressSummary: "Old target progress", - }); - await waitFor(() => { - const finished = manager.get(supervisor.id)?.cycles.find((entry) => entry.id === cycle.id); - expect(finished?.status).toBe("completed"); + expect(observedSignal?.aborted).toBe(true); }); - expect(deps.targetStore.saveTargetMemory).toHaveBeenCalledWith( - expect.any(String), - supervisor.targetId, - expect.objectContaining({ - targetId: supervisor.targetId, - progressSummary: "Old target progress", - }) - ); - expect(deps.targetStore.appendTargetCycleRecord).toHaveBeenCalledWith( + const updated = await updatedPromise; + + expect(updated.targetId).toBe(supervisor.targetId); + expect(updated.objective).toBe("New objective"); + expect(manager.get(supervisor.id)?.state).toBe("idle"); + expect(manager.get(supervisor.id)?.completedSupervisionCount).toBe(0); + expect(deps.targetStore.resetTargetFiles).toHaveBeenCalledWith( expect.any(String), - supervisor.targetId, expect.objectContaining({ - cycleId: cycle.id, targetId: supervisor.targetId, + objective: "New objective", }) ); - expect(deps.targetStore.saveTargetMemory).not.toHaveBeenCalledWith( - expect.any(String), - rotated.targetId, - expect.anything() - ); - expect(manager.get(supervisor.id)?.state).toBe("idle"); - expect(manager.get(supervisor.id)?.completedSupervisionCount).toBe(0); + const finished = manager.get(supervisor.id)?.cycles.find((entry) => entry.id === cycle.id); + expect(finished?.status).toBe("cancelled"); expect(deps.sessionMgr.sendInput).not.toHaveBeenCalled(); }); @@ -593,7 +595,7 @@ describe("SupervisorManager cycle triggers", () => { ); }); - it("does not move the rotated target into error when the previous target cycle fails", async () => { + it("keeps the supervisor idle after an in-flight evaluation fails during objective reset", async () => { const supervisor = await manager.create({ sessionId: "sess-objective-error-race", workspaceId: "ws-1", @@ -616,150 +618,55 @@ describe("SupervisorManager cycle triggers", () => { expect(manager.get(supervisor.id)?.state).toBe("evaluating"); }); - const rotated = await manager.update(supervisor.id, { + const updatedPromise = manager.update(supervisor.id, { objective: "New objective", }); - rejectEvaluation?.(new Error("old target eval failed")); - - await waitFor(() => { - const finished = manager.get(supervisor.id)?.cycles.find((entry) => entry.id === cycle.id); - expect(finished?.status).toBe("failed"); + queueMicrotask(() => { + rejectEvaluation?.(new Error("old target eval failed")); }); - expect(manager.get(supervisor.id)?.targetId).toBe(rotated.targetId); - expect(manager.get(supervisor.id)?.state).toBe("idle"); - expect(manager.get(supervisor.id)?.errorReason).toBeUndefined(); - expect(deps.targetStore.appendTargetCycleRecord).toHaveBeenCalledWith( + const updated = await updatedPromise; + const finished = manager.get(supervisor.id)?.cycles.find((entry) => entry.id === cycle.id); + + expect(finished?.status).toBe("failed"); + expect(updated.targetId).toBe(supervisor.targetId); + expect(updated.objective).toBe("New objective"); + expect(updated.state).toBe("idle"); + expect(updated.errorReason).toBeUndefined(); + expect(deps.targetStore.resetTargetFiles).toHaveBeenCalledWith( expect.any(String), - supervisor.targetId, expect.objectContaining({ - cycleId: cycle.id, targetId: supervisor.targetId, - result: "error", - errorReason: "old target eval failed", + objective: "New objective", }) ); }); - it("keeps a superseded target meta state when an old in-flight cycle later stops", async () => { + it("resets target files in place instead of superseding them on objective change", async () => { const supervisor = await manager.create({ - sessionId: "sess-objective-stop-race", + sessionId: "sess-objective-meta-reset", workspaceId: "ws-1", objective: "Initial objective", evaluatorProviderId: "codex", }); - const targetMetaById = new Map>([ - [ - supervisor.targetId, - { - targetId: supervisor.targetId, - sessionId: supervisor.sessionId, - workspaceId: supervisor.workspaceId, - objective: supervisor.objective, - status: "active", - createdAt: 1, - updatedAt: 1, - supersededBy: null, - completedAt: null, - }, - ], - ]); - - deps.targetStore.readTargetMeta.mockImplementation( - async (_workspacePath: string, targetId: string) => { - const meta = targetMetaById.get(targetId); - if (!meta) { - throw new Error(`Missing target meta for ${targetId}`); - } - return { ...meta }; - } - ); - deps.targetStore.createTargetFiles.mockImplementation( - async ( - _workspacePath: string, - input: { - targetId: string; - sessionId: string; - workspaceId: string; - objective: string; - createdAt: number; - } - ) => { - targetMetaById.set(input.targetId, { - targetId: input.targetId, - sessionId: input.sessionId, - workspaceId: input.workspaceId, - objective: input.objective, - status: "active", - createdAt: input.createdAt, - updatedAt: input.createdAt, - supersededBy: null, - completedAt: null, - }); - } - ); - deps.targetStore.markTargetSuperseded.mockImplementation( - async (_workspacePath: string, targetId: string, nextTargetId: string, updatedAt: number) => { - const current = targetMetaById.get(targetId); - if (!current) { - throw new Error(`Missing target meta for ${targetId}`); - } - targetMetaById.set(targetId, { - ...current, - status: "superseded", - supersededBy: nextTargetId, - updatedAt, - }); - } - ); - deps.targetStore.saveTargetMeta.mockImplementation( - async (_workspacePath: string, targetId: string, meta: Record) => { - targetMetaById.set(targetId, { ...meta, targetId }); - } - ); - - let resolveEvaluation: ((result: SupervisorEvaluationResult) => void) | null = null; - vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementationOnce( - async () => - await new Promise((resolve) => { - resolveEvaluation = resolve; - }) - ); - - const cycle = await manager.triggerEvaluation(supervisor.id); - - await waitFor(() => { - expect(resolveEvaluation).not.toBeNull(); - expect(manager.get(supervisor.id)?.state).toBe("evaluating"); - }); - - const rotated = await manager.update(supervisor.id, { + const updated = await manager.update(supervisor.id, { objective: "New objective", }); - resolveEvaluation?.({ - status: "stop", - stopReason: "objective_complete", - reason: "old objective is done", - }); - - await waitFor(() => { - const finished = manager.get(supervisor.id)?.cycles.find((entry) => entry.id === cycle.id); - expect(finished?.status).toBe("completed"); - }); - - expect(targetMetaById.get(supervisor.targetId)).toMatchObject({ - status: "superseded", - supersededBy: rotated.targetId, - }); - expect(targetMetaById.get(rotated.targetId)).toMatchObject({ - status: "active", - }); + expect(updated.targetId).toBe(supervisor.targetId); + expect(deps.targetStore.resetTargetFiles).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + targetId: supervisor.targetId, + objective: "New objective", + }) + ); + expect(deps.targetStore.markTargetSuperseded).not.toHaveBeenCalled(); }); - it("rolls back a supersede mark when creating the next target files fails", async () => { + it("keeps the current target unchanged when resetting target files fails", async () => { const supervisor = await manager.create({ sessionId: "sess-objective-create-fails", workspaceId: "ws-1", @@ -767,39 +674,9 @@ describe("SupervisorManager cycle triggers", () => { evaluatorProviderId: "codex", }); - let latestMeta: Record = { - targetId: supervisor.targetId, - sessionId: supervisor.sessionId, - workspaceId: supervisor.workspaceId, - objective: supervisor.objective, - status: "active", - createdAt: 1, - updatedAt: 1, - supersededBy: null, - completedAt: null, - }; - - deps.targetStore.readTargetMeta.mockImplementation(async () => ({ ...latestMeta })); - deps.targetStore.markTargetSuperseded.mockImplementation( - async (_workspacePath: string, targetId: string, nextTargetId: string, updatedAt: number) => { - latestMeta = { - ...latestMeta, - targetId, - status: "superseded", - supersededBy: nextTargetId, - updatedAt, - }; - } - ); - deps.targetStore.saveTargetMeta.mockImplementation( - async (_workspacePath: string, targetId: string, meta: Record) => { - latestMeta = { ...meta, targetId }; - } - ); - - const createTargetFilesError = new Error("disk full"); - deps.targetStore.createTargetFiles.mockImplementation(async () => { - throw createTargetFilesError; + const resetTargetFilesError = new Error("disk full"); + deps.targetStore.resetTargetFiles.mockImplementation(async () => { + throw resetTargetFilesError; }); await expect( @@ -809,18 +686,12 @@ describe("SupervisorManager cycle triggers", () => { ).rejects.toThrow("disk full"); expect(manager.get(supervisor.id)?.targetId).toBe(supervisor.targetId); - expect(latestMeta).toMatchObject({ - targetId: supervisor.targetId, - status: "active", - supersededBy: null, - }); - expect(deps.targetStore.saveTargetMeta).toHaveBeenCalledWith( + expect(manager.get(supervisor.id)?.objective).toBe("Initial objective"); + expect(deps.targetStore.resetTargetFiles).toHaveBeenCalledWith( expect.any(String), - supervisor.targetId, expect.objectContaining({ targetId: supervisor.targetId, - status: "active", - supersededBy: null, + objective: "New objective", }) ); }); diff --git a/packages/server/src/__tests__/supervisor-repo.test.ts b/packages/server/src/__tests__/supervisor-repo.test.ts index 6d5644a01..bb57ee0e9 100644 --- a/packages/server/src/__tests__/supervisor-repo.test.ts +++ b/packages/server/src/__tests__/supervisor-repo.test.ts @@ -89,7 +89,7 @@ describe("SupervisorRepo", () => { }); }); - it("persists targetId on create and update", () => { + it("derives targetId from supervisor id and ignores targetId patches", () => { supervisorRepo.create({ id: "sup-1", sessionId: "sess-1", @@ -103,15 +103,15 @@ describe("SupervisorRepo", () => { }); const created = supervisorRepo.findById("sup-1"); - expect(created?.targetId).toBe("target-alpha"); + expect(created?.targetId).toBe("sup-1"); const updated = supervisorRepo.update("sup-1", { targetId: "target-beta", updatedAt: 11, }); - expect(updated.targetId).toBe("target-beta"); - expect(supervisorRepo.findById("sup-1")?.targetId).toBe("target-beta"); + expect(updated.targetId).toBe("sup-1"); + expect(supervisorRepo.findById("sup-1")?.targetId).toBe("sup-1"); }); it("rejects a supervisor whose workspace does not match its session workspace", () => { diff --git a/packages/server/src/storage/db.test.ts b/packages/server/src/storage/db.test.ts index 10b0a0374..e4d70ff21 100644 --- a/packages/server/src/storage/db.test.ts +++ b/packages/server/src/storage/db.test.ts @@ -56,7 +56,7 @@ describe("database schema baseline", () => { const supervisorColumns = db.prepare("PRAGMA table_info(supervisors)").all() as Array<{ name: string; }>; - expect(supervisorColumns.find((column) => column.name === "target_id")).toBeDefined(); + expect(supervisorColumns.find((column) => column.name === "target_id")).toBeUndefined(); const indexNames = ( db.prepare("SELECT name FROM sqlite_master WHERE type='index' ORDER BY name").all() as Array<{ diff --git a/packages/server/src/storage/db.ts b/packages/server/src/storage/db.ts index 8f6aafbf8..368aa52f3 100644 --- a/packages/server/src/storage/db.ts +++ b/packages/server/src/storage/db.ts @@ -102,14 +102,6 @@ function upgradeSchemaV1ToV2(db: Database): void { }); } -function upgradeSchemaV2ToV3(db: Database): void { - withTransaction(db, () => { - db.exec("ALTER TABLE supervisors ADD COLUMN target_id TEXT NOT NULL DEFAULT ''"); - db.exec("UPDATE supervisors SET target_id = 'legacy_' || id WHERE target_id = ''"); - stampCurrentSchemaVersion(db); - }); -} - function assertCurrentSchema(db: Database, dbPath: string): void { const detection = detectSchema(db); if (detection.state !== "current") { @@ -137,12 +129,13 @@ function initializeOrUpgradeSchema(db: Database, dbPath: string): void { case "v1": upgradeSchemaV1ToV2(db); - upgradeSchemaV2ToV3(db); assertCurrentSchema(db, dbPath); return; case "v2": - upgradeSchemaV2ToV3(db); + if (detection.userVersion !== CURRENT_SCHEMA_VERSION) { + stampCurrentSchemaVersion(db); + } assertCurrentSchema(db, dbPath); return; diff --git a/packages/server/src/storage/migrations/001_init.sql b/packages/server/src/storage/migrations/001_init.sql index 98e9ab894..5b3f006fa 100644 --- a/packages/server/src/storage/migrations/001_init.sql +++ b/packages/server/src/storage/migrations/001_init.sql @@ -1,5 +1,5 @@ -- Current database schema baseline -PRAGMA user_version = 3; +PRAGMA user_version = 2; CREATE TABLE IF NOT EXISTS workspaces ( id TEXT PRIMARY KEY, @@ -78,7 +78,7 @@ CREATE TABLE IF NOT EXISTS supervisors ( last_evaluated_turn_id TEXT, error_reason TEXT, created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, evaluator_model TEXT, max_supervision_count INTEGER NOT NULL DEFAULT 0, completed_supervision_count INTEGER NOT NULL DEFAULT 0, scheduled_at INTEGER, stop_reason TEXT, target_id TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, evaluator_model TEXT, max_supervision_count INTEGER NOT NULL DEFAULT 0, completed_supervision_count INTEGER NOT NULL DEFAULT 0, scheduled_at INTEGER, stop_reason TEXT, FOREIGN KEY (session_id, workspace_id) REFERENCES sessions(id, workspace_id) ON DELETE CASCADE, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE ); diff --git a/packages/server/src/storage/repositories/supervisor-repo.ts b/packages/server/src/storage/repositories/supervisor-repo.ts index 4881afee1..2695f5400 100644 --- a/packages/server/src/storage/repositories/supervisor-repo.ts +++ b/packages/server/src/storage/repositories/supervisor-repo.ts @@ -5,7 +5,6 @@ interface SupervisorRow { id: string; session_id: string; workspace_id: string; - target_id: string; state: SupervisorState; objective: string; evaluator_provider_id: string; @@ -63,14 +62,13 @@ export class SupervisorRepo { create(input: NewSupervisor): Supervisor { this.db .prepare( - `INSERT INTO supervisors (id, session_id, workspace_id, target_id, state, objective, evaluator_provider_id, evaluator_model, max_supervision_count, completed_supervision_count, scheduled_at, stop_reason, last_cycle_at, last_evaluated_turn_id, error_reason, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO supervisors (id, session_id, workspace_id, state, objective, evaluator_provider_id, evaluator_model, max_supervision_count, completed_supervision_count, scheduled_at, stop_reason, last_cycle_at, last_evaluated_turn_id, error_reason, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( input.id, input.sessionId, input.workspaceId, - input.targetId, input.state, input.objective, input.evaluatorProviderId, @@ -117,10 +115,6 @@ export class SupervisorRepo { updatedAt: patch.updatedAt ?? Date.now(), }; - if (patch.targetId !== undefined) { - assignments.push("target_id = @targetId"); - params.targetId = patch.targetId; - } if (patch.state !== undefined) { assignments.push("state = @state"); params.state = patch.state; @@ -186,7 +180,7 @@ export class SupervisorRepo { id: row.id, sessionId: row.session_id, workspaceId: row.workspace_id, - targetId: row.target_id, + targetId: row.id, state: row.state, objective: row.objective, evaluatorProviderId: row.evaluator_provider_id, diff --git a/packages/server/src/storage/schema-version.ts b/packages/server/src/storage/schema-version.ts index e30fd559e..1df6d4b15 100644 --- a/packages/server/src/storage/schema-version.ts +++ b/packages/server/src/storage/schema-version.ts @@ -29,7 +29,7 @@ export interface SchemaDetection { mismatch: string | null; } -export const CURRENT_SCHEMA_VERSION = 3; +export const CURRENT_SCHEMA_VERSION = 2; const CURRENT_SCHEMA_PATH = join(import.meta.dirname, "migrations", "001_init.sql"); @@ -421,7 +421,7 @@ export function detectSchema(db: Database): SchemaDetection { if (hasExactFingerprint(actualEntries, V2_SCHEMA_ENTRIES)) { return { - state: "v2", + state: CURRENT_SCHEMA_VERSION === 2 ? "current" : "v2", userVersion, mismatch: null, }; diff --git a/packages/server/src/supervisor/manager.test.ts b/packages/server/src/supervisor/manager.test.ts index 755c04330..7d66a6740 100644 --- a/packages/server/src/supervisor/manager.test.ts +++ b/packages/server/src/supervisor/manager.test.ts @@ -33,6 +33,7 @@ type MockSupervisorManagerDeps = { }; targetStore: { createTargetFiles: ReturnType; + resetTargetFiles: ReturnType; readTargetMeta: ReturnType; loadTargetMemory: ReturnType; saveTargetMeta: ReturnType; @@ -98,11 +99,12 @@ describe("SupervisorManager", () => { get: vi.fn(() => undefined), }, supervisorRepo: { - create: vi.fn((value) => ({ ...value, cycles: [] })), + create: vi.fn((value) => ({ ...value, targetId: value.id, cycles: [] })), update: vi.fn((id, patch) => ({ id, sessionId: "sess-1", workspaceId: "ws-1", + targetId: id, state: patch.state ?? "idle", objective: patch.objective ?? "Persist supervisors", evaluatorProviderId: patch.evaluatorProviderId ?? "claude", @@ -147,6 +149,7 @@ describe("SupervisorManager", () => { }, targetStore: { createTargetFiles: vi.fn(async () => {}), + resetTargetFiles: vi.fn(async () => {}), readTargetMeta: vi.fn(async () => ({ targetId: "tgt-1", sessionId: "sess-1", diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts index 8d18658f6..b9dd2bc6f 100644 --- a/packages/server/src/supervisor/manager.ts +++ b/packages/server/src/supervisor/manager.ts @@ -91,6 +91,7 @@ export interface SupervisorManagerDeps { >; targetStore: { createTargetFiles: typeof import("./target-store.js").createTargetFiles; + resetTargetFiles: typeof import("./target-store.js").resetTargetFiles; readTargetMeta: typeof import("./target-store.js").readTargetMeta; loadTargetMemory: typeof import("./target-store.js").loadTargetMemory; saveTargetMeta: typeof import("./target-store.js").saveTargetMeta; @@ -141,10 +142,6 @@ function generateAttemptId(): string { return `attempt_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; } -function generateTargetId(): string { - return `tgt_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; -} - function messageOf(error: unknown, fallback: string): string { if (error instanceof Error) { return error.message; @@ -173,6 +170,7 @@ export class SupervisorManager { private readonly inFlight = new Set(); private readonly pendingDeletes = new Set(); private readonly pendingPauses = new Set(); + private readonly pendingObjectiveUpdates = new Set(); private readonly evaluationAbortControllers = new Map(); private readonly inFlightCompletions = new Map(); private readonly scheduler: SupervisorScheduler; @@ -369,13 +367,13 @@ export class SupervisorManager { const now = Date.now(); const objective = req.objective.trim(); const workspace = this.requireWorkspace(req.workspaceId); - const targetId = generateTargetId(); + const supervisorId = generateSupervisorId(); const supervisor = this.attachCycles( this.deps.supervisorRepo.create({ - id: generateSupervisorId(), + id: supervisorId, sessionId: req.sessionId, workspaceId: req.workspaceId, - targetId, + targetId: supervisorId, state: "idle", objective, evaluatorProviderId: req.evaluatorProviderId, @@ -390,7 +388,7 @@ export class SupervisorManager { let enriched: Supervisor; try { await this.deps.targetStore.createTargetFiles(workspace.path, { - targetId, + targetId: supervisorId, sessionId: req.sessionId, workspaceId: req.workspaceId, objective, @@ -410,16 +408,24 @@ export class SupervisorManager { } async update(id: string, patch: UpdateSupervisorRequest): Promise { - const current = this.requireSupervisor(id); + let current = this.requireSupervisor(id); if (patch.evaluatorProviderId) { this.assertEvaluatorProvider(patch.evaluatorProviderId); } - const workspace = this.requireWorkspace(current.workspaceId); const nextObjective = patch.objective !== undefined ? patch.objective.trim() : current.objective; const objectiveChanged = patch.objective !== undefined && nextObjective !== current.objective; + + if (objectiveChanged && this.inFlight.has(id)) { + this.pendingObjectiveUpdates.add(id); + this.evaluationAbortControllers.get(id)?.abort(); + await this.inFlightCompletions.get(id)?.promise; + current = this.requireSupervisor(id); + } + + const workspace = this.requireWorkspace(current.workspaceId); const nextPatch: Parameters[1] = { objective: nextObjective, evaluatorProviderId: patch.evaluatorProviderId ?? current.evaluatorProviderId, @@ -444,34 +450,13 @@ export class SupervisorManager { }; if (objectiveChanged) { - const nextTargetId = generateTargetId(); - const previousTargetMeta = await this.deps.targetStore.readTargetMeta( - workspace.path, - current.targetId - ); - await this.deps.targetStore.markTargetSuperseded( - workspace.path, - current.targetId, - nextTargetId, - nextPatch.updatedAt ?? Date.now() - ); - try { - await this.deps.targetStore.createTargetFiles(workspace.path, { - targetId: nextTargetId, - sessionId: current.sessionId, - workspaceId: current.workspaceId, - objective: nextObjective, - createdAt: nextPatch.updatedAt ?? Date.now(), - }); - } catch (error) { - await this.deps.targetStore.saveTargetMeta( - workspace.path, - current.targetId, - previousTargetMeta - ); - throw error; - } - nextPatch.targetId = nextTargetId; + await this.deps.targetStore.resetTargetFiles(workspace.path, { + targetId: current.targetId, + sessionId: current.sessionId, + workspaceId: current.workspaceId, + objective: nextObjective, + createdAt: nextPatch.updatedAt ?? Date.now(), + }); } const updated = this.attachCycles(this.deps.supervisorRepo.update(id, nextPatch)); @@ -798,7 +783,8 @@ export class SupervisorManager { return finalized.cycle; } catch (error: unknown) { if (isSupervisorEvalAborted(error)) { - const cancelled = this.pendingPauses.has(supervisorId); + const cancelled = + this.pendingPauses.has(supervisorId) || this.pendingObjectiveUpdates.has(supervisorId); const abortedCycle = this.deps.cycleRepo.update(activeCycle.id, { status: cancelled ? "cancelled" : "failed", errorReason: cancelled ? null : messageOf(error, "Supervisor evaluator aborted"), @@ -825,6 +811,26 @@ export class SupervisorManager { return abortedCycle; } + if (this.pendingObjectiveUpdates.has(supervisorId)) { + const recoveredSupervisor = this.attachCycles( + this.deps.supervisorRepo.update(supervisorId, { + state: "idle", + stopReason: null, + errorReason: null, + updatedAt: Date.now(), + }) + ); + + this.storeSnapshot(recoveredSupervisor); + this.broadcastCycle(recoveredSupervisor, abortedCycle, "updated"); + this.broadcastState(recoveredSupervisor, "state_changed"); + this.deps.cycleRepo.pruneOldest(supervisorId, this.config.maxCyclesPerSession); + this.scheduler.refresh(); + this.pendingPauses.delete(supervisorId); + + return abortedCycle; + } + if (currentSupervisor.targetId !== targetId) { const workspace = this.deps.workspaceMgr.get(currentSupervisor.workspaceId); const enriched = this.attachCycles( @@ -947,6 +953,7 @@ export class SupervisorManager { throw error; } finally { + this.pendingObjectiveUpdates.delete(supervisorId); this.pendingPauses.delete(supervisorId); this.releaseInFlight(supervisorId); } @@ -1464,6 +1471,7 @@ export class SupervisorManager { this.supervisorsBySession.delete(supervisor.sessionId); this.pendingDeletes.delete(supervisor.id); this.pendingPauses.delete(supervisor.id); + this.pendingObjectiveUpdates.delete(supervisor.id); this.releaseInFlight(supervisor.id); this.scheduler.refresh(); diff --git a/packages/server/src/supervisor/target-store.test.ts b/packages/server/src/supervisor/target-store.test.ts index b16f4c9bf..4c9a89af2 100644 --- a/packages/server/src/supervisor/target-store.test.ts +++ b/packages/server/src/supervisor/target-store.test.ts @@ -6,9 +6,9 @@ import { appendTargetCycleRecord, createTargetFiles, loadTargetMemory, - markTargetSuperseded, readTargetCycleRecords, readTargetMeta, + resetTargetFiles, saveTargetMemory, } from "./target-store.js"; @@ -72,7 +72,7 @@ describe("target store", () => { expect(lines[0]?.guidance).toBe("Implement the store"); }); - it("marks a target as superseded without mutating the old memory contents", async () => { + it("resets target files in place when the objective changes", async () => { await createTargetFiles(workspacePath, { targetId: "tgt-1", sessionId: "sess-1", @@ -92,14 +92,52 @@ describe("target store", () => { updatedAt: 2, }); - await markTargetSuperseded(workspacePath, "tgt-1", "tgt-2", 3); + await appendTargetCycleRecord(workspacePath, "tgt-1", { + cycleId: "cycle-1", + targetId: "tgt-1", + startedAt: 1, + completedAt: 2, + result: "continue", + reason: "Stale progress", + guidance: "Do the old thing", + injected: true, + attemptCount: 1, + }); + + await resetTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "New objective", + createdAt: 3, + }); const meta = await readTargetMeta(workspacePath, "tgt-1"); const memory = await loadTargetMemory(workspacePath, "tgt-1"); + const cycles = await readTargetCycleRecords(workspacePath, "tgt-1"); - expect(meta.status).toBe("superseded"); - expect(meta.supersededBy).toBe("tgt-2"); - expect(memory.lastGuidance).toBe("Do old thing"); + expect(meta).toEqual({ + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "New objective", + status: "active", + createdAt: 3, + updatedAt: 3, + supersededBy: null, + completedAt: null, + }); + expect(memory).toEqual({ + targetId: "tgt-1", + planGenerated: false, + plan: [], + activeStepId: undefined, + progressSummary: undefined, + lastGuidance: undefined, + stalledCount: 0, + updatedAt: 3, + }); + expect(cycles).toEqual([]); }); it("does not overwrite existing memory when createTargetFiles is called for an existing target", async () => { diff --git a/packages/server/src/supervisor/target-store.ts b/packages/server/src/supervisor/target-store.ts index ab886bf7d..f71f2eef4 100644 --- a/packages/server/src/supervisor/target-store.ts +++ b/packages/server/src/supervisor/target-store.ts @@ -48,20 +48,14 @@ async function writeJsonIfMissing(path: string, value: unknown): Promise { } } -export async function createTargetFiles( - workspacePath: string, - input: { - targetId: string; - sessionId: string; - workspaceId: string; - objective: string; - createdAt: number; - } -): Promise { - const dir = targetDir(workspacePath, input.targetId); - await mkdir(dir, { recursive: true }); - - const meta: SupervisorTargetMeta = { +function buildTargetMeta(input: { + targetId: string; + sessionId: string; + workspaceId: string; + objective: string; + createdAt: number; +}): SupervisorTargetMeta { + return { targetId: input.targetId, sessionId: input.sessionId, workspaceId: input.workspaceId, @@ -72,17 +66,60 @@ export async function createTargetFiles( supersededBy: null, completedAt: null, }; +} - const memory: SupervisorTargetMemory = { - targetId: input.targetId, +function buildTargetMemory(targetId: string, createdAt: number): SupervisorTargetMemory { + return { + targetId, planGenerated: false, plan: [], stalledCount: 0, - updatedAt: input.createdAt, + updatedAt: createdAt, }; +} + +export async function createTargetFiles( + workspacePath: string, + input: { + targetId: string; + sessionId: string; + workspaceId: string; + objective: string; + createdAt: number; + } +): Promise { + const dir = targetDir(workspacePath, input.targetId); + await mkdir(dir, { recursive: true }); + await writeJsonIfMissing(metaPath(workspacePath, input.targetId), buildTargetMeta(input)); + await writeJsonIfMissing( + memoryPath(workspacePath, input.targetId), + buildTargetMemory(input.targetId, input.createdAt) + ); +} - await writeJsonIfMissing(metaPath(workspacePath, input.targetId), meta); - await writeJsonIfMissing(memoryPath(workspacePath, input.targetId), memory); +export async function resetTargetFiles( + workspacePath: string, + input: { + targetId: string; + sessionId: string; + workspaceId: string; + objective: string; + createdAt: number; + } +): Promise { + const dir = targetDir(workspacePath, input.targetId); + await mkdir(dir, { recursive: true }); + await writeFile( + metaPath(workspacePath, input.targetId), + JSON.stringify(buildTargetMeta(input), null, 2) + "\n", + "utf-8" + ); + await writeFile( + memoryPath(workspacePath, input.targetId), + JSON.stringify(buildTargetMemory(input.targetId, input.createdAt), null, 2) + "\n", + "utf-8" + ); + await writeFile(cyclesPath(workspacePath, input.targetId), "", "utf-8"); } export async function readTargetMeta( From fbb731b2504d32a51b1d2e862eae52611ecc13cb Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 15 May 2026 23:24:50 +0000 Subject: [PATCH 25/28] fix: drop supervisor repo targetId input --- .../src/__tests__/supervisor-manager.test.ts | 15 +++++++++++++++ .../src/__tests__/supervisor-repo.test.ts | 18 ++---------------- .../storage/repositories/supervisor-repo.ts | 2 -- packages/server/src/supervisor/manager.ts | 1 - 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts index cda20a871..63552f47a 100644 --- a/packages/server/src/__tests__/supervisor-manager.test.ts +++ b/packages/server/src/__tests__/supervisor-manager.test.ts @@ -394,6 +394,21 @@ describe("SupervisorManager cycle triggers", () => { expect(deps.supervisorRepo.getBySessionId("sess-create-fails")).toBeUndefined(); }); + it("does not pass targetId into supervisor repo create", async () => { + await manager.create({ + sessionId: "sess-create-shape", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "codex", + }); + + expect(deps.supervisorRepo.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + targetId: expect.anything(), + }) + ); + }); + it("returns an in-flight cycle immediately on manual triggerEvaluation", async () => { const supervisor = await manager.create({ sessionId: "sess-manual", diff --git a/packages/server/src/__tests__/supervisor-repo.test.ts b/packages/server/src/__tests__/supervisor-repo.test.ts index bb57ee0e9..27eaddc1b 100644 --- a/packages/server/src/__tests__/supervisor-repo.test.ts +++ b/packages/server/src/__tests__/supervisor-repo.test.ts @@ -46,7 +46,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-1", state: "idle", objective: "Finish supervisor persistence", evaluatorProviderId: "codex", @@ -65,7 +64,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-initial", state: "stopped", objective: "Stop when objective is complete", evaluatorProviderId: "codex", @@ -89,12 +87,11 @@ describe("SupervisorRepo", () => { }); }); - it("derives targetId from supervisor id and ignores targetId patches", () => { + it("derives targetId from supervisor id", () => { supervisorRepo.create({ id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-alpha", state: "idle", objective: "Track target scope", evaluatorProviderId: "codex", @@ -106,7 +103,7 @@ describe("SupervisorRepo", () => { expect(created?.targetId).toBe("sup-1"); const updated = supervisorRepo.update("sup-1", { - targetId: "target-beta", + objective: "Track target scope again", updatedAt: 11, }); @@ -130,7 +127,6 @@ describe("SupervisorRepo", () => { id: "sup-bad", sessionId: "sess-2", workspaceId: "ws-1", - targetId: "target-bad", state: "idle", objective: "This insert must fail", evaluatorProviderId: "claude", @@ -152,7 +148,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-1", state: "idle", objective: "Enforce supervisor/session integrity", evaluatorProviderId: "claude", @@ -181,7 +176,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-1", state: "idle", objective: "Keep nullable fields intact", evaluatorProviderId: "claude", @@ -208,7 +202,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-1", state: "idle", objective: "Clear nullable fields", evaluatorProviderId: "claude", @@ -236,7 +229,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-1", state: "idle", objective: "Clear execution policy fields", evaluatorProviderId: "claude", @@ -268,7 +260,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-1", state: "idle", objective: "Keep cycle fields intact", evaluatorProviderId: "claude", @@ -309,7 +300,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-1", state: "idle", objective: "Clear cycle fields", evaluatorProviderId: "claude", @@ -370,7 +360,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-1", state: "idle", objective: "Keep the newest 100 cycles", evaluatorProviderId: "claude", @@ -406,7 +395,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-1", state: "idle", objective: "Allow scheduled cancelled cycle", evaluatorProviderId: "claude", @@ -435,7 +423,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-1", state: "idle", objective: "Track attempts", evaluatorProviderId: "claude", @@ -493,7 +480,6 @@ describe("SupervisorRepo", () => { id: "sup-1", sessionId: "sess-1", workspaceId: "ws-1", - targetId: "target-1", state: "idle", objective: "Update attempts", evaluatorProviderId: "claude", diff --git a/packages/server/src/storage/repositories/supervisor-repo.ts b/packages/server/src/storage/repositories/supervisor-repo.ts index 2695f5400..124eba415 100644 --- a/packages/server/src/storage/repositories/supervisor-repo.ts +++ b/packages/server/src/storage/repositories/supervisor-repo.ts @@ -24,7 +24,6 @@ export interface NewSupervisor { id: string; sessionId: string; workspaceId: string; - targetId: string; state: SupervisorState; objective: string; evaluatorProviderId: string; @@ -41,7 +40,6 @@ export interface NewSupervisor { } export interface SupervisorUpdatePatch { - targetId?: string; state?: SupervisorState; objective?: string; evaluatorProviderId?: string; diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts index b9dd2bc6f..2c6dece6e 100644 --- a/packages/server/src/supervisor/manager.ts +++ b/packages/server/src/supervisor/manager.ts @@ -373,7 +373,6 @@ export class SupervisorManager { id: supervisorId, sessionId: req.sessionId, workspaceId: req.workspaceId, - targetId: supervisorId, state: "idle", objective, evaluatorProviderId: req.evaluatorProviderId, From 4c185a37a177e71610c79a5100c884f23a091564 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 15 May 2026 23:41:40 +0000 Subject: [PATCH 26/28] fix: harden supervisor objective resets --- .../src/__tests__/supervisor-manager.test.ts | 87 ++++++++++++++ packages/server/src/supervisor/manager.ts | 64 +++++++--- .../supervisor/target-store.atomic.test.ts | 113 ++++++++++++++++++ .../server/src/supervisor/target-store.ts | 111 ++++++++++++++--- 4 files changed, 342 insertions(+), 33 deletions(-) create mode 100644 packages/server/src/supervisor/target-store.atomic.test.ts diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts index 63552f47a..35831a4da 100644 --- a/packages/server/src/__tests__/supervisor-manager.test.ts +++ b/packages/server/src/__tests__/supervisor-manager.test.ts @@ -578,6 +578,57 @@ describe("SupervisorManager cycle triggers", () => { expect(deps.sessionMgr.sendInput).not.toHaveBeenCalled(); }); + it("marks the aborted attempt as cancelled when the objective changes", async () => { + const supervisor = await manager.create({ + sessionId: "sess-objective-attempt-cancelled", + workspaceId: "ws-1", + objective: "Initial objective", + evaluatorProviderId: "codex", + }); + + let observedSignal: AbortSignal | undefined; + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementationOnce( + async (_supervisor, _context, options) => + await new Promise((_resolve, reject) => { + observedSignal = options?.signal; + options?.signal?.addEventListener( + "abort", + () => { + reject({ + code: "supervisor_eval_aborted", + message: "Supervisor evaluator aborted", + }); + }, + { once: true } + ); + }) + ); + + const cycle = await manager.triggerEvaluation(supervisor.id); + + await waitFor(() => { + expect(observedSignal).toBeDefined(); + expect(manager.get(supervisor.id)?.state).toBe("evaluating"); + }); + + const updatedPromise = manager.update(supervisor.id, { + objective: "New objective", + }); + + await waitFor(() => { + expect(observedSignal?.aborted).toBe(true); + }); + + await updatedPromise; + + expect(deps.cycleAttemptRepo.listForCycle(cycle.id)).toMatchObject([ + { + status: "cancelled", + errorReason: undefined, + }, + ]); + }); + it("marks supervisor_uncertain stops as cancelled instead of completed target meta", async () => { const supervisor = await manager.create({ sessionId: "sess-uncertain-stop", @@ -711,6 +762,42 @@ describe("SupervisorManager cycle triggers", () => { ); }); + it("rolls back persisted supervisor changes when resetting target files fails", async () => { + const supervisor = await manager.create({ + sessionId: "sess-objective-rollback", + workspaceId: "ws-1", + objective: "Initial objective", + evaluatorProviderId: "codex", + }); + + deps.targetStore.resetTargetFiles.mockImplementation(async () => { + throw new Error("disk full"); + }); + + await expect( + manager.update(supervisor.id, { + objective: "New objective", + }) + ).rejects.toThrow("disk full"); + + const updatesForSupervisor = deps.supervisorRepo.update.mock.calls.filter( + ([id]: [string, SupervisorUpdatePatch]) => id === supervisor.id + ); + + expect(updatesForSupervisor).toHaveLength(2); + expect(updatesForSupervisor[0]?.[1]).toEqual( + expect.objectContaining({ + objective: "New objective", + }) + ); + expect(updatesForSupervisor[1]?.[1]).toEqual( + expect.objectContaining({ + objective: "Initial objective", + }) + ); + expect(deps.supervisorRepo.findById(supervisor.id)?.objective).toBe("Initial objective"); + }); + it("retries evaluator timeout up to the global retry budget", async () => { vi.useFakeTimers(); deps.settingsRepo.get = vi.fn((key: string) => { diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts index 2c6dece6e..fc4a1cb88 100644 --- a/packages/server/src/supervisor/manager.ts +++ b/packages/server/src/supervisor/manager.ts @@ -448,17 +448,31 @@ export class SupervisorManager { updatedAt: Date.now(), }; + const rollbackPatch = this.toSupervisorUpdatePatch(current, Date.now()); + let updated = this.attachCycles(this.deps.supervisorRepo.update(id, nextPatch)); + if (objectiveChanged) { - await this.deps.targetStore.resetTargetFiles(workspace.path, { - targetId: current.targetId, - sessionId: current.sessionId, - workspaceId: current.workspaceId, - objective: nextObjective, - createdAt: nextPatch.updatedAt ?? Date.now(), - }); + try { + await this.deps.targetStore.resetTargetFiles(workspace.path, { + targetId: current.targetId, + sessionId: current.sessionId, + workspaceId: current.workspaceId, + objective: nextObjective, + createdAt: nextPatch.updatedAt ?? Date.now(), + }); + } catch (error) { + try { + this.deps.supervisorRepo.update(id, rollbackPatch); + } catch (rollbackError) { + this.logger.error( + { err: rollbackError, supervisorId: id }, + "Failed to roll back supervisor after target reset failure" + ); + } + throw error; + } } - const updated = this.attachCycles(this.deps.supervisorRepo.update(id, nextPatch)); const enriched = await this.attachTargetState(updated, workspace.path); this.storeSnapshot(enriched); @@ -782,8 +796,7 @@ export class SupervisorManager { return finalized.cycle; } catch (error: unknown) { if (isSupervisorEvalAborted(error)) { - const cancelled = - this.pendingPauses.has(supervisorId) || this.pendingObjectiveUpdates.has(supervisorId); + const cancelled = this.isCancellationRequested(supervisorId); const abortedCycle = this.deps.cycleRepo.update(activeCycle.id, { status: cancelled ? "cancelled" : "failed", errorReason: cancelled ? null : messageOf(error, "Supervisor evaluator aborted"), @@ -1043,12 +1056,11 @@ export class SupervisorManager { }; } catch (error) { if (isSupervisorEvalAborted(error)) { + const cancelled = this.isCancellationRequested(supervisor.id); this.deps.cycleAttemptRepo.update(attempt.id, { - status: this.pendingPauses.has(supervisor.id) ? "cancelled" : "failed", + status: cancelled ? "cancelled" : "failed", completedAt: Date.now(), - errorReason: this.pendingPauses.has(supervisor.id) - ? null - : messageOf(error, "Supervisor evaluator aborted"), + errorReason: cancelled ? null : messageOf(error, "Supervisor evaluator aborted"), }); throw error; } @@ -1318,6 +1330,30 @@ export class SupervisorManager { }; } + private isCancellationRequested(supervisorId: string): boolean { + return this.pendingPauses.has(supervisorId) || this.pendingObjectiveUpdates.has(supervisorId); + } + + private toSupervisorUpdatePatch( + supervisor: Supervisor, + updatedAt: number + ): Parameters[1] { + return { + state: supervisor.state, + objective: supervisor.objective, + evaluatorProviderId: supervisor.evaluatorProviderId, + evaluatorModel: supervisor.evaluatorModel ?? null, + maxSupervisionCount: supervisor.maxSupervisionCount, + completedSupervisionCount: supervisor.completedSupervisionCount, + scheduledAt: supervisor.scheduledAt ?? null, + stopReason: supervisor.stopReason ?? null, + lastCycleAt: supervisor.lastCycleAt ?? null, + lastEvaluatedTurnId: supervisor.lastEvaluatedTurnId ?? null, + errorReason: supervisor.errorReason ?? null, + updatedAt, + }; + } + private applyEvaluationToTargetMemory( memory: SupervisorTargetMemory, evaluation: Awaited>, diff --git a/packages/server/src/supervisor/target-store.atomic.test.ts b/packages/server/src/supervisor/target-store.atomic.test.ts new file mode 100644 index 000000000..586fccd98 --- /dev/null +++ b/packages/server/src/supervisor/target-store.atomic.test.ts @@ -0,0 +1,113 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const renameState = vi.hoisted(() => ({ + callCount: 0, + failOnCall: 0, +})); + +vi.mock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { + ...actual, + rename: vi.fn(async (from: string, to: string) => { + renameState.callCount += 1; + if (renameState.failOnCall !== 0 && renameState.callCount === renameState.failOnCall) { + throw new Error("promote failed"); + } + return actual.rename(from, to); + }), + }; +}); + +import { + appendTargetCycleRecord, + createTargetFiles, + loadTargetMemory, + readTargetCycleRecords, + readTargetMeta, + resetTargetFiles, + saveTargetMemory, +} from "./target-store.js"; + +describe("target store atomic reset", () => { + let workspacePath: string; + + beforeEach(() => { + workspacePath = mkdtempSync(join(tmpdir(), "supervisor-target-store-atomic-")); + renameState.callCount = 0; + renameState.failOnCall = 0; + }); + + afterEach(() => { + rmSync(workspacePath, { recursive: true, force: true }); + }); + + it("restores the existing target files if promotion fails during reset", async () => { + await createTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Old objective", + createdAt: 1, + }); + + await saveTargetMemory(workspacePath, "tgt-1", { + targetId: "tgt-1", + planGenerated: true, + plan: [{ id: "step-1", title: "Old step", status: "in_progress" }], + activeStepId: "step-1", + progressSummary: "In progress", + lastGuidance: "Do old thing", + stalledCount: 1, + updatedAt: 2, + }); + + await appendTargetCycleRecord(workspacePath, "tgt-1", { + cycleId: "cycle-1", + targetId: "tgt-1", + startedAt: 1, + completedAt: 2, + result: "continue", + reason: "Stale progress", + guidance: "Do the old thing", + injected: true, + attemptCount: 1, + }); + + renameState.failOnCall = 2; + + await expect( + resetTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "New objective", + createdAt: 3, + }) + ).rejects.toThrow("promote failed"); + + const meta = await readTargetMeta(workspacePath, "tgt-1"); + const memory = await loadTargetMemory(workspacePath, "tgt-1"); + const cycles = await readTargetCycleRecords(workspacePath, "tgt-1"); + + expect(meta.objective).toBe("Old objective"); + expect(memory).toMatchObject({ + targetId: "tgt-1", + planGenerated: true, + activeStepId: "step-1", + progressSummary: "In progress", + lastGuidance: "Do old thing", + stalledCount: 1, + updatedAt: 2, + }); + expect(cycles).toMatchObject([ + { + cycleId: "cycle-1", + guidance: "Do the old thing", + }, + ]); + }); +}); diff --git a/packages/server/src/supervisor/target-store.ts b/packages/server/src/supervisor/target-store.ts index f71f2eef4..538c42e87 100644 --- a/packages/server/src/supervisor/target-store.ts +++ b/packages/server/src/supervisor/target-store.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { SupervisorCycleTargetRecord, SupervisorTargetMemory } from "@coder-studio/core"; @@ -30,6 +30,27 @@ function cyclesPath(workspacePath: string, targetId: string): string { return join(targetDir(workspacePath, targetId), "cycles.jsonl"); } +function metaFilePath(dirPath: string): string { + return join(dirPath, "meta.json"); +} + +function memoryFilePath(dirPath: string): string { + return join(dirPath, "memory.json"); +} + +function cyclesFilePath(dirPath: string): string { + return join(dirPath, "cycles.jsonl"); +} + +function hasCode(error: unknown, code: string): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + (error as { code?: unknown }).code === code + ); +} + async function writeJsonIfMissing(path: string, value: unknown): Promise { try { await writeFile(path, JSON.stringify(value, null, 2) + "\n", { @@ -37,12 +58,7 @@ async function writeJsonIfMissing(path: string, value: unknown): Promise { flag: "wx", }); } catch (error) { - if ( - !error || - typeof error !== "object" || - !("code" in error) || - (error as { code?: string }).code !== "EEXIST" - ) { + if (!hasCode(error, "EEXIST")) { throw error; } } @@ -78,6 +94,30 @@ function buildTargetMemory(targetId: string, createdAt: number): SupervisorTarge }; } +async function writeResetTargetFiles( + dirPath: string, + input: { + targetId: string; + sessionId: string; + workspaceId: string; + objective: string; + createdAt: number; + } +): Promise { + await mkdir(dirPath, { recursive: true }); + await writeFile( + metaFilePath(dirPath), + JSON.stringify(buildTargetMeta(input), null, 2) + "\n", + "utf-8" + ); + await writeFile( + memoryFilePath(dirPath), + JSON.stringify(buildTargetMemory(input.targetId, input.createdAt), null, 2) + "\n", + "utf-8" + ); + await writeFile(cyclesFilePath(dirPath), "", "utf-8"); +} + export async function createTargetFiles( workspacePath: string, input: { @@ -108,18 +148,51 @@ export async function resetTargetFiles( } ): Promise { const dir = targetDir(workspacePath, input.targetId); - await mkdir(dir, { recursive: true }); - await writeFile( - metaPath(workspacePath, input.targetId), - JSON.stringify(buildTargetMeta(input), null, 2) + "\n", - "utf-8" - ); - await writeFile( - memoryPath(workspacePath, input.targetId), - JSON.stringify(buildTargetMemory(input.targetId, input.createdAt), null, 2) + "\n", - "utf-8" - ); - await writeFile(cyclesPath(workspacePath, input.targetId), "", "utf-8"); + const parentDir = dirname(dir); + const backupDir = `${dir}.backup-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + await mkdir(parentDir, { recursive: true }); + const stagingDir = await mkdtemp(join(parentDir, `${input.targetId}.reset-`)); + + let movedExisting = false; + let promoted = false; + + try { + await writeResetTargetFiles(stagingDir, input); + + try { + await rename(dir, backupDir); + movedExisting = true; + } catch (error) { + if (!hasCode(error, "ENOENT")) { + throw error; + } + } + + try { + await rename(stagingDir, dir); + promoted = true; + } catch (error) { + if (movedExisting) { + await rename(backupDir, dir); + movedExisting = false; + } + throw error; + } + + if (movedExisting) { + await rm(backupDir, { recursive: true, force: true }); + movedExisting = false; + } + } catch (error) { + if (!promoted) { + await rm(stagingDir, { recursive: true, force: true }).catch(() => {}); + } + if (movedExisting) { + await rm(backupDir, { recursive: true, force: true }).catch(() => {}); + } + throw error; + } } export async function readTargetMeta( From 03870c98546110ae159c6cca336c2023e7159a6b Mon Sep 17 00:00:00 2001 From: Spencer Date: Sat, 16 May 2026 03:13:22 +0000 Subject: [PATCH 27/28] fix: harden supervisor reset edge cases --- .../src/__tests__/supervisor-manager.test.ts | 50 +++++++++ packages/server/src/supervisor/manager.ts | 6 +- .../supervisor/target-store.atomic.test.ts | 105 ++++++++++++++++-- .../server/src/supervisor/target-store.ts | 55 ++++++--- 4 files changed, 189 insertions(+), 27 deletions(-) diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts index 35831a4da..ff0c85f81 100644 --- a/packages/server/src/__tests__/supervisor-manager.test.ts +++ b/packages/server/src/__tests__/supervisor-manager.test.ts @@ -798,6 +798,56 @@ describe("SupervisorManager cycle triggers", () => { expect(deps.supervisorRepo.findById(supervisor.id)?.objective).toBe("Initial objective"); }); + it("preserves a pause request while an objective change abort is in flight", async () => { + const supervisor = await manager.create({ + sessionId: "sess-objective-pause-race", + workspaceId: "ws-1", + objective: "Initial objective", + evaluatorProviderId: "codex", + }); + + let observedSignal: AbortSignal | undefined; + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementationOnce( + async (_supervisor, _context, options) => + await new Promise((_resolve, reject) => { + observedSignal = options?.signal; + options?.signal?.addEventListener( + "abort", + () => { + reject({ + code: "supervisor_eval_aborted", + message: "Supervisor evaluator aborted", + }); + }, + { once: true } + ); + }) + ); + + await manager.triggerEvaluation(supervisor.id); + + await waitFor(() => { + expect(observedSignal).toBeDefined(); + expect(manager.get(supervisor.id)?.state).toBe("evaluating"); + }); + + const updatedPromise = manager.update(supervisor.id, { + objective: "New objective", + }); + const paused = await manager.pause(supervisor.id); + + await waitFor(() => { + expect(observedSignal?.aborted).toBe(true); + }); + + const updated = await updatedPromise; + + expect(paused.state).toBe("paused"); + expect(updated.state).toBe("paused"); + expect(updated.objective).toBe("New objective"); + expect(manager.get(supervisor.id)?.state).toBe("paused"); + }); + it("retries evaluator timeout up to the global retry budget", async () => { vi.useFakeTimers(); deps.settingsRepo.get = vi.fn((key: string) => { diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts index fc4a1cb88..28b6794ce 100644 --- a/packages/server/src/supervisor/manager.ts +++ b/packages/server/src/supervisor/manager.ts @@ -824,9 +824,12 @@ export class SupervisorManager { } if (this.pendingObjectiveUpdates.has(supervisorId)) { + const nextState: SupervisorState = this.pendingPauses.has(supervisorId) + ? "paused" + : "idle"; const recoveredSupervisor = this.attachCycles( this.deps.supervisorRepo.update(supervisorId, { - state: "idle", + state: nextState, stopReason: null, errorReason: null, updatedAt: Date.now(), @@ -838,7 +841,6 @@ export class SupervisorManager { this.broadcastState(recoveredSupervisor, "state_changed"); this.deps.cycleRepo.pruneOldest(supervisorId, this.config.maxCyclesPerSession); this.scheduler.refresh(); - this.pendingPauses.delete(supervisorId); return abortedCycle; } diff --git a/packages/server/src/supervisor/target-store.atomic.test.ts b/packages/server/src/supervisor/target-store.atomic.test.ts index 586fccd98..35f946710 100644 --- a/packages/server/src/supervisor/target-store.atomic.test.ts +++ b/packages/server/src/supervisor/target-store.atomic.test.ts @@ -1,11 +1,13 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const renameState = vi.hoisted(() => ({ - callCount: 0, - failOnCall: 0, +const fsState = vi.hoisted(() => ({ + renameCallCount: 0, + failRenameOnCalls: [] as number[], + rmCallCount: 0, + failRmOnCalls: [] as number[], })); vi.mock("node:fs/promises", async () => { @@ -13,12 +15,19 @@ vi.mock("node:fs/promises", async () => { return { ...actual, rename: vi.fn(async (from: string, to: string) => { - renameState.callCount += 1; - if (renameState.failOnCall !== 0 && renameState.callCount === renameState.failOnCall) { + fsState.renameCallCount += 1; + if (fsState.failRenameOnCalls.includes(fsState.renameCallCount)) { throw new Error("promote failed"); } return actual.rename(from, to); }), + rm: vi.fn(async (...args: Parameters) => { + fsState.rmCallCount += 1; + if (fsState.failRmOnCalls.includes(fsState.rmCallCount)) { + throw new Error("cleanup failed"); + } + return actual.rm(...args); + }), }; }); @@ -37,8 +46,10 @@ describe("target store atomic reset", () => { beforeEach(() => { workspacePath = mkdtempSync(join(tmpdir(), "supervisor-target-store-atomic-")); - renameState.callCount = 0; - renameState.failOnCall = 0; + fsState.renameCallCount = 0; + fsState.failRenameOnCalls = []; + fsState.rmCallCount = 0; + fsState.failRmOnCalls = []; }); afterEach(() => { @@ -77,7 +88,7 @@ describe("target store atomic reset", () => { attemptCount: 1, }); - renameState.failOnCall = 2; + fsState.failRenameOnCalls = [2]; await expect( resetTargetFiles(workspacePath, { @@ -110,4 +121,80 @@ describe("target store atomic reset", () => { }, ]); }); + + it("keeps the promoted target live when backup cleanup fails", async () => { + await createTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Old objective", + createdAt: 1, + }); + + fsState.failRmOnCalls = [1]; + + await expect( + resetTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "New objective", + createdAt: 3, + }) + ).resolves.toBeUndefined(); + + const meta = await readTargetMeta(workspacePath, "tgt-1"); + const memory = await loadTargetMemory(workspacePath, "tgt-1"); + const cycles = await readTargetCycleRecords(workspacePath, "tgt-1"); + + expect(meta.objective).toBe("New objective"); + expect(memory).toMatchObject({ + targetId: "tgt-1", + planGenerated: false, + stalledCount: 0, + updatedAt: 3, + }); + expect(cycles).toEqual([]); + }); + + it("preserves the backup target when both promotion and restore fail", async () => { + await createTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Old objective", + createdAt: 1, + }); + + fsState.failRenameOnCalls = [2, 3]; + + await expect( + resetTargetFiles(workspacePath, { + targetId: "tgt-1", + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "New objective", + createdAt: 3, + }) + ).rejects.toThrow("promote failed"); + + const targetsRoot = join(workspacePath, ".coder-studio", "supervisor", "targets"); + const entries = readdirSync(targetsRoot); + const backupEntry = entries.find((entry) => entry.startsWith("tgt-1.backup-")); + const stagingEntry = entries.find((entry) => entry.startsWith("tgt-1.reset-")); + + expect(existsSync(join(targetsRoot, "tgt-1"))).toBe(false); + expect(backupEntry).toBeDefined(); + expect(stagingEntry).toBeDefined(); + + const backupMeta = JSON.parse( + readFileSync(join(targetsRoot, backupEntry!, "meta.json"), "utf-8") + ) as { objective: string }; + const stagedMeta = JSON.parse( + readFileSync(join(targetsRoot, stagingEntry!, "meta.json"), "utf-8") + ) as { objective: string }; + + expect(backupMeta.objective).toBe("Old objective"); + expect(stagedMeta.objective).toBe("New objective"); + }); }); diff --git a/packages/server/src/supervisor/target-store.ts b/packages/server/src/supervisor/target-store.ts index 538c42e87..c2473e75f 100644 --- a/packages/server/src/supervisor/target-store.ts +++ b/packages/server/src/supervisor/target-store.ts @@ -51,6 +51,19 @@ function hasCode(error: unknown, code: string): boolean { ); } +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof Error) { + return error.message; + } + if (error && typeof error === "object" && "message" in error) { + const value = (error as { message?: unknown }).message; + if (typeof value === "string") { + return value; + } + } + return fallback; +} + async function writeJsonIfMissing(path: string, value: unknown): Promise { try { await writeFile(path, JSON.stringify(value, null, 2) + "\n", { @@ -154,15 +167,16 @@ export async function resetTargetFiles( await mkdir(parentDir, { recursive: true }); const stagingDir = await mkdtemp(join(parentDir, `${input.targetId}.reset-`)); - let movedExisting = false; + let backupCreated = false; let promoted = false; + let restored = false; try { await writeResetTargetFiles(stagingDir, input); try { await rename(dir, backupDir); - movedExisting = true; + backupCreated = true; } catch (error) { if (!hasCode(error, "ENOENT")) { throw error; @@ -172,27 +186,36 @@ export async function resetTargetFiles( try { await rename(stagingDir, dir); promoted = true; - } catch (error) { - if (movedExisting) { - await rename(backupDir, dir); - movedExisting = false; + } catch (promoteError) { + if (backupCreated) { + try { + await rename(backupDir, dir); + backupCreated = false; + restored = true; + } catch (restoreError) { + throw new Error( + `Failed to promote target reset (${errorMessage( + promoteError, + "unknown promote error" + )}); restore also failed (${errorMessage(restoreError, "unknown restore error")})` + ); + } } - throw error; - } - - if (movedExisting) { - await rm(backupDir, { recursive: true, force: true }); - movedExisting = false; + throw promoteError; } } catch (error) { - if (!promoted) { + if (restored || !backupCreated) { await rm(stagingDir, { recursive: true, force: true }).catch(() => {}); } - if (movedExisting) { - await rm(backupDir, { recursive: true, force: true }).catch(() => {}); - } throw error; } + + if (backupCreated) { + await rm(backupDir, { recursive: true, force: true }).catch(() => {}); + } + if (!promoted) { + await rm(stagingDir, { recursive: true, force: true }).catch(() => {}); + } } export async function readTargetMeta( From ba347637c5430c09d9cff93f8b5915b8253f8754 Mon Sep 17 00:00:00 2001 From: Spencer Date: Sat, 16 May 2026 03:42:46 +0000 Subject: [PATCH 28/28] fix: preserve concurrent supervisor updates --- .../src/__tests__/supervisor-manager.test.ts | 71 +++++++++++++++++++ packages/server/src/supervisor/manager.ts | 46 ++++++++++-- 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts index ff0c85f81..9361fff0c 100644 --- a/packages/server/src/__tests__/supervisor-manager.test.ts +++ b/packages/server/src/__tests__/supervisor-manager.test.ts @@ -798,6 +798,77 @@ describe("SupervisorManager cycle triggers", () => { expect(deps.supervisorRepo.findById(supervisor.id)?.objective).toBe("Initial objective"); }); + it("preserves a pause request while a target reset update is still persisting", async () => { + const supervisor = await manager.create({ + sessionId: "sess-objective-reset-pause-race", + workspaceId: "ws-1", + objective: "Initial objective", + evaluatorProviderId: "codex", + }); + + let releaseReset: (() => void) | null = null; + deps.targetStore.resetTargetFiles.mockImplementation( + async () => + await new Promise((resolve) => { + releaseReset = resolve; + }) + ); + + const updatedPromise = manager.update(supervisor.id, { + objective: "New objective", + }); + + await waitFor(() => { + expect(deps.targetStore.resetTargetFiles).toHaveBeenCalledTimes(1); + expect(releaseReset).not.toBeNull(); + }); + + const paused = await manager.pause(supervisor.id); + releaseReset?.(); + + const updated = await updatedPromise; + + expect(paused.state).toBe("paused"); + expect(updated.state).toBe("paused"); + expect(updated.objective).toBe("New objective"); + expect(manager.get(supervisor.id)?.state).toBe("paused"); + }); + + it("does not resurrect a supervisor deleted during target reset persistence", async () => { + const supervisor = await manager.create({ + sessionId: "sess-objective-reset-delete-race", + workspaceId: "ws-1", + objective: "Initial objective", + evaluatorProviderId: "codex", + }); + + let releaseReset: (() => void) | null = null; + deps.targetStore.resetTargetFiles.mockImplementation( + async () => + await new Promise((resolve) => { + releaseReset = resolve; + }) + ); + + const updatedPromise = manager.update(supervisor.id, { + objective: "New objective", + }); + + await waitFor(() => { + expect(deps.targetStore.resetTargetFiles).toHaveBeenCalledTimes(1); + expect(releaseReset).not.toBeNull(); + }); + + await manager.delete(supervisor.id); + releaseReset?.(); + + await expect(updatedPromise).rejects.toMatchObject({ + code: "supervisor_not_found", + }); + expect(manager.get(supervisor.id)).toBeUndefined(); + expect(deps.supervisorRepo.findById(supervisor.id)).toBeUndefined(); + }); + it("preserves a pause request while an objective change abort is in flight", async () => { const supervisor = await manager.create({ sessionId: "sess-objective-pause-race", diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts index 28b6794ce..eefe74a17 100644 --- a/packages/server/src/supervisor/manager.ts +++ b/packages/server/src/supervisor/manager.ts @@ -449,7 +449,7 @@ export class SupervisorManager { }; const rollbackPatch = this.toSupervisorUpdatePatch(current, Date.now()); - let updated = this.attachCycles(this.deps.supervisorRepo.update(id, nextPatch)); + this.deps.supervisorRepo.update(id, nextPatch); if (objectiveChanged) { try { @@ -473,7 +473,11 @@ export class SupervisorManager { } } - const enriched = await this.attachTargetState(updated, workspace.path); + const enriched = await this.hydratePersistedSupervisor(id, workspace.path); + if (!enriched) { + await this.markTargetCancelledIfActive(workspace.path, current).catch(() => {}); + throw this.supervisorNotFoundError(id); + } this.storeSnapshot(enriched); this.broadcastState(enriched, "updated"); @@ -1320,6 +1324,32 @@ export class SupervisorManager { return await this.attachTargetState(supervisor, workspace.path); } + private async hydratePersistedSupervisor( + supervisorId: string, + workspacePath: string + ): Promise { + const persisted = this.deps.supervisorRepo.findById(supervisorId); + if (!persisted) { + return null; + } + + const hydrated = await this.attachTargetState(this.attachCycles(persisted), workspacePath); + const latest = this.deps.supervisorRepo.findById(supervisorId); + if (!latest) { + return null; + } + + if (latest.targetId !== hydrated.targetId) { + return await this.attachTargetState(this.attachCycles(latest), workspacePath); + } + + return { + ...this.attachCycles(latest), + currentTargetMemory: hydrated.currentTargetMemory, + recentTargetCycles: hydrated.recentTargetCycles, + }; + } + private withCurrentTargetState(supervisor: Supervisor): Supervisor { const current = this.supervisors.get(supervisor.id); if (!current || current.targetId !== supervisor.targetId) { @@ -1525,13 +1555,17 @@ export class SupervisorManager { this.inFlightCompletions.delete(supervisorId); } + private supervisorNotFoundError(id: string): { code: "supervisor_not_found"; message: string } { + return { + code: "supervisor_not_found", + message: `Supervisor ${id} not found`, + }; + } + private requireSupervisor(id: string): Supervisor { const supervisor = this.supervisors.get(id); if (!supervisor) { - throw { - code: "supervisor_not_found", - message: `Supervisor ${id} not found`, - }; + throw this.supervisorNotFoundError(id); } return supervisor; }