diff --git a/docs/ai/design/2026-08-07-feature-agent-print-mode.md b/docs/ai/design/2026-08-07-feature-agent-print-mode.md index 062459a7..b7b6eb6d 100644 --- a/docs/ai/design/2026-08-07-feature-agent-print-mode.md +++ b/docs/ai/design/2026-08-07-feature-agent-print-mode.md @@ -1,14 +1,14 @@ --- phase: design title: Claude Print-Mode Agent Design -description: Minimal durable print-agent identity, execution, locking, and CLI integration +description: Minimal durable durable-agent identity, execution, locking, and CLI integration --- # Claude Print-Mode Agent Design ## Architecture Overview -Print agents are an additive control path beside the existing process adapters. Existing `AgentManager`, terminal discovery, tmux start, interactive send/wait, groups, channels, and TUI continue to operate on live `AgentInfo` objects. A small print-agent service in `agent-manager` owns durable records and Claude print execution; CLI command orchestration combines the two target kinds only for start, list, detail, and direct send. +Durable agents are an additive control path beside the existing process adapters. Existing `AgentManager`, terminal discovery, tmux start, interactive send/wait, groups, channels, and TUI continue to operate on live `AgentInfo` objects. A small durable-agent service in `agent-manager` owns durable records and Claude print execution; CLI command orchestration combines the two target kinds only for start, list, detail, and direct send. ```mermaid flowchart LR @@ -26,8 +26,8 @@ flowchart LR ### Design boundaries -- `AgentInfo` remains the live-process type with a required PID. Print agents do not fabricate one. -- `PrintAgent` is a separate durable type. +- `AgentInfo` remains the live-process type with a required PID. Durable agents do not fabricate one. +- `DurableAgent` is a separate durable type. - `AgentManager.listAgents()` remains live-only so existing TUI, channels, groups, kill, open, rename, and terminal flows do not accidentally acquire print semantics. - CLI list/detail/direct-send use a small combined resolver. Other commands remain unchanged. - Only Claude is implemented. The runner is injectable for tests but no generic multi-provider framework is introduced. @@ -36,36 +36,36 @@ flowchart LR ### Store file -Default path: `~/.ai-devkit/print-agents.json`. +Default path: `~/.ai-devkit/durable-agents.json`. ```ts -interface PrintAgentStoreFile { +interface DurableAgentRepositoryFile { version: 1; - agents: PrintAgent[]; + agents: DurableAgent[]; } -type PrintAgentState = 'ready' | 'running' | 'degraded'; -type PrintSessionHealth = 'uninitialized' | 'healthy' | 'unknown' | 'mismatch'; -type PrintRunStatus = 'succeeded' | 'failed' | 'interrupted'; +type DurableAgentState = 'ready' | 'running' | 'degraded'; +type DurableSessionHealth = 'uninitialized' | 'healthy' | 'unknown' | 'mismatch'; +type DurableRunStatus = 'succeeded' | 'failed' | 'interrupted'; -interface PrintAgent { +interface DurableAgent { id: string; // immutable AI DevKit UUID - name: string; // unique among print agents + name: string; // unique among durable agents provider: 'claude'; - mode: 'print'; + mode: 'durable'; cwd: string; // canonical real path providerSessionId: string; // immutable caller-assigned Claude UUID - state: PrintAgentState; - sessionHealth: PrintSessionHealth; + state: DurableAgentState; + sessionHealth: DurableSessionHealth; createdAt: string; updatedAt: string; lastActiveAt: string | null; - lastResult: PrintLastResult | null; - activeRun: PrintActiveRun | null; + lastResult: DurableLastResult | null; + activeRun: DurableActiveRun | null; } -interface PrintLastResult { - status: PrintRunStatus; +interface DurableLastResult { + status: DurableRunStatus; completedAt: string; exitCode: number | null; summary: string; // sanitized and bounded @@ -76,7 +76,7 @@ interface ProcessIdentity { startedAt: string; // OS-observed process start identity } -interface PrintActiveRun { +interface DurableActiveRun { token: string; // random ownership token owner: ProcessIdentity; provider: ProcessIdentity | null; @@ -88,8 +88,8 @@ No prompts, transcripts, event history, tool inputs, full provider output, queue ### Lock files -- Store mutation lock: sibling directory `print-agents.json.lock`. -- Per-agent execution lock: `~/.ai-devkit/print-agent-locks/.lock/owner.json`. +- Store mutation lock: sibling directory `durable-agents.json.lock`. +- Per-agent execution lock: `~/.ai-devkit/durable-agent-locks/.lock/owner.json`. - Directory creation with `mkdir` is the cross-process atomic primitive. - Lock owner metadata uses the same token and process identities as `activeRun`. @@ -100,22 +100,22 @@ The per-agent lock is authoritative for exclusion. Persisted `activeRun` makes s ### Store ```ts -interface PrintAgentStoreOptions { +interface DurableAgentRepositoryOptions { filePath?: string; lockTimeoutMs?: number; now?: () => Date; processInspector?: ProcessInspector; } -class PrintAgentStore { - create(input: CreatePrintAgentInput): Promise; - list(): Promise; - getById(id: string): Promise; - resolve(ref: string): Promise; - acquireRun(id: string): Promise<{ agent: PrintAgent; token: string }>; +class DurableAgentRepository { + create(input: CreateDurableAgentInput): Promise; + list(): Promise; + getById(id: string): Promise; + resolve(ref: string): Promise; + acquireRun(id: string): Promise<{ agent: DurableAgent; token: string }>; recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise; - completeRun(id: string, token: string, result: PrintRunCompletion): Promise; - failRun(id: string, token: string, result: PrintRunFailure): Promise; + completeRun(id: string, token: string, result: DurableRunCompletion): Promise; + failRun(id: string, token: string, result: DurableRunFailure): Promise; reconcile(id?: string): Promise; } ``` @@ -139,7 +139,7 @@ Validation runs only `claude --version` and `claude --help`. It requires help te ```ts interface ClaudePrintRunRequest { - agent: PrintAgent; + agent: DurableAgent; prompt: string; executable?: string; firstRun: boolean; @@ -201,12 +201,12 @@ Full result text may be returned to the invoking terminal/JSON response, but onl ```ts type DirectAgentTarget = | { kind: 'interactive'; agent: AgentInfo } - | { kind: 'print'; agent: PrintAgent }; + | { kind: 'print'; agent: DurableAgent }; ``` Resolution order: -1. Exact print-agent stable ID. +1. Exact durable-agent stable ID. 2. Gather exact case-insensitive name matches across print and live agents. 3. If exactly one, use it; if multiple, report ambiguity with mode/type. 4. Apply existing live-agent partial matching only when no print name matches. @@ -218,7 +218,7 @@ Direct `agent send --id` uses this resolver. Group sends remain live-only. - Print sends are always synchronous. - `--wait` is accepted as a no-op semantic confirmation, preserving scripts that add it. -- `--timeout` is rejected for print agents with a clear error. Enforcing it would require process cancellation semantics that are explicitly outside the MVP; silently ignoring it would be unsafe. Interactive timeout behavior is unchanged. +- `--timeout` is rejected for durable agents with a clear error. Enforcing it would require process cancellation semantics that are explicitly outside the MVP; silently ignoring it would be unsafe. Interactive timeout behavior is unchanged. - `--json` emits a print-specific result object without echoing the prompt. - Interactive send behavior and JSON shape remain unchanged. @@ -226,8 +226,8 @@ Direct `agent send --id` uses this resolver. Group sends remain live-only. ### `agent-manager` -- `print/PrintAgent.ts`: durable types and typed errors. -- `print/PrintAgentStore.ts`: atomic JSON persistence, name/ID resolution, locking, ownership, reconciliation, and path safety. +- `print/DurableAgent.ts`: durable types and typed errors. +- `print/DurableAgentRepository.ts`: atomic JSON persistence, name/ID resolution, locking, ownership, reconciliation, and path safety. - `print/ProcessInspector.ts`: exact PID/start-time liveness checks, injectable in tests. - `print/ClaudeCliProbe.ts`: non-billable local capability validation. - `print/ClaudePrintRunner.ts`: safe process launch, stdin delivery, bounded stream parsing, session verification. diff --git a/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md b/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md new file mode 100644 index 00000000..0435d4a7 --- /dev/null +++ b/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md @@ -0,0 +1,67 @@ +--- +phase: design +title: Durable Agents SQLite Design +description: SQLite schema and transactional ownership design +--- + +# Durable Agents SQLite Design + +## Architecture Overview + +```mermaid +flowchart LR + CLI[CLI / runner] --> Service[ClaudePrintAgentService] + Service --> Repository[DurableAgentRepository adapter] + Store --> DB[(agents.db)] + Inspector[LocalProcessInspector] --> Store +``` + +`DurableAgentRepository` remains the public adapter and owns row mapping, validation, and transactional state changes. `DatabaseConnection` owns SQLite configuration and schema migration. Process inspection and cwd canonicalization remain outside transactions; transactions reread state and apply conditional mutations. + +## Data Model + +`durable_agents` is one flattened row per durable agent: + +- Identity: `id` primary key; case-insensitive unique `name`; unconstrained `provider`; `mode` defaulting to `durable`; canonical `cwd`; unique `provider_session_id`. +- Lifecycle: constrained `state`, constrained `session_health`, created/updated timestamps, nullable last-active timestamp. +- Latest result: nullable constrained status, completion timestamp, exit code, and summary. +- Active run: unique token plus owner/provider PID and start-time identity, and run start timestamp. +- Integrity: running state requires every active field; non-running requires all active fields to be null. +- Indexes: state lookup and updated-desc/name-case-insensitive listing. + +## API Design + +- Existing `DurableAgentRepository` methods and `RepositoryLike` structural consumers stay unchanged. +- Options add `dbPath`; tests inject explicit database paths. +- `lockTimeoutMs`, `incompleteLockGraceMs`, and `mutationLockStaleMs` remain type-compatible but have no runtime effect and are deprecated. +- Domain errors continue to represent conflicts, busy ownership, lost tokens, invalid input, and storage failures. + +## Data Flows + +### Ownership + +- Acquire canonicalizes cwd and inspects candidate processes outside the transaction, then uses `BEGIN IMMEDIATE`, rereads the row, and conditionally updates exactly one eligible row with an atomic token and owner identity. +- Record-provider and complete use `UPDATE ... WHERE id = ? AND active_run_token = ?`; zero changes means ownership was lost. +- Reconciliation queries running rows, inspects processes, then CAS-updates using the observed token and start-time identity. A live owner or provider keeps the run busy. + +### Readonly + +Readonly construction requires an existing migrated database and skips directory creation, schema initialization, and write pragmas. Readonly `list()` maps rows only and never reconciles. + +## Design Decisions + +- A separate table isolates durable rows from the registry's dead-process pruning. +- Flattening matches the existing latest-result contract and avoids premature run-history scope. +- SQLite uniqueness and transactions replace lock directories and temp-file replacement. +- Application-layer provider validation avoids migrations when new providers arrive. +- Durable agents persist directly in SQLite; `durable-agents.json` was never released and requires no compatibility path. + +Rejected alternatives are merging into `agents`, storing a whole JSON document in one row, adding `durable_runs`, introducing a repository abstraction, and retaining filesystem lock machinery. + +## Non-Functional Requirements + +- Transactions remain short; filesystem checks and process inspection occur outside them. +- WAL plus a 5-second busy timeout handle contention; acquisition contention maps to `DurableAgentBusyError`. +- Symlink-safe cwd binding prevents path substitution. +- Schema checks reject inconsistent active-run rows and invalid lifecycle/result values. +- State transitions are atomic and recover cleanly on reopen. diff --git a/docs/ai/implementation/2026-08-07-feature-agent-print-mode.md b/docs/ai/implementation/2026-08-07-feature-agent-print-mode.md index 41b43800..7abbd1d3 100644 --- a/docs/ai/implementation/2026-08-07-feature-agent-print-mode.md +++ b/docs/ai/implementation/2026-08-07-feature-agent-print-mode.md @@ -16,15 +16,15 @@ description: Implementation record, decisions, validation, and deviations ### Task 1.1 -- Added `packages/agent-manager/src/print/PrintAgent.ts` with the durable record, state, session health, last-result, active-run, and process-identity contracts. -- Added classified print-agent/store/Claude errors that do not carry prompt content. +- Added `packages/agent-manager/src/durable/DurableAgent.ts` with the durable record, state, session health, last-result, active-run, and process-identity contracts. +- Added classified durable-agent/repository/Claude errors that do not carry prompt content. - Exported the public contracts from `@ai-devkit/agent-manager`. ## TDD Evidence -- Red: `npx vitest run src/__tests__/print/PrintAgent.test.ts` failed because `PrintAgentBusyError` was absent. +- Red: `npx vitest run src/__tests__/print/DurableAgent.test.ts` failed because `DurableAgentBusyError` was absent. - Green/refactor: the same focused test passed (1/1), followed by `npm run typecheck` exit 0. -- Task 1.2 red: three focused store tests failed because `PrintAgentStore` was absent. +- Task 1.2 red: three focused store tests failed because `DurableAgentRepository` was absent. - Task 1.2 green/refactor: all three store tests passed and `npm run typecheck` exited 0. - Task 1.3 red: two run-ownership tests failed because acquisition/completion methods were absent. - Task 1.3 green/refactor: all five store tests passed and `npm run typecheck` exited 0. @@ -38,7 +38,7 @@ description: Implementation record, decisions, validation, and deviations ### Task 1.2 -- Added a separate versioned `~/.ai-devkit/print-agents.json` store. +- Added a separate versioned `~/.ai-devkit/durable-agents.json` repository. - Added canonical cwd validation, distinct UUID generation, exact ID/name resolution, duplicate-name rejection, atomic exclusive temp-file replacement, owner-only mode, bounded mutation locking, and symlink rejection. ### Task 1.3 @@ -58,7 +58,7 @@ description: Implementation record, decisions, validation, and deviations - Added create/send orchestration with fail-fast ownership and no retries. - Added the narrow CLI integrations for print start, merged list/detail, and synchronous direct send while leaving interactive defaults and excluded commands unchanged. -- Added a user-facing list mode boundary: live process agents render as `interactive`, while internal print-mode records render as `durable` in both table and JSON output. Internal storage and harness contracts remain `mode: 'print'`. +- Added a user-facing list mode boundary: live process agents render as `interactive`, while internal print-mode records render as `durable` in both table and JSON output. Internal storage and harness contracts remain `mode: 'durable'`. - Added deterministic unit/integration fixtures that never invoke a real model. - Added crash recovery for old mutation and incomplete run locks and exact cwd/session binding checks. - Documented inherited Claude permissions, hooks, MCP/tool side effects, and explicit print-mode timeout rejection. @@ -66,7 +66,7 @@ description: Implementation record, decisions, validation, and deviations ## Design Alignment - `AgentInfo` remains unchanged and process-specific. -- Print-agent identity is a separate durable type. +- Durable-agent identity is a separate durable type. - No channel, task, receipt, daemon, queue, cancellation, deletion, transcript, or non-Claude provider behavior was added. ## Deviations and Follow-ups @@ -75,7 +75,7 @@ description: Implementation record, decisions, validation, and deviations ## Formal Security Review -- Scope: new print-agent domain/store/probe/runner/service, direct CLI integrations, fixtures, and documentation. Trust boundaries are CLI caller → local state → ephemeral Claude process → untrusted stream/output; the local OS account is the authorization boundary. +- Scope: new durable-agent domain/store/probe/runner/service, direct CLI integrations, fixtures, and documentation. Trust boundaries are CLI caller → local state → ephemeral Claude process → untrusted stream/output; the local OS account is the authorization boundary. - Remediated `SEC-PRINT-001` (medium, data exposure): provider stderr could contain an echoed prompt or tool secret. The runner now drains stderr but never reflects or persists it; a regression test uses a secret-bearing failure. - Remediated `SEC-PRINT-002` (medium, availability/business logic): a crash could strand the global mutation lock. Old empty mutation locks are atomically quarantined and removed after a bounded age; live short operations remain protected. - Remediated `SEC-PRINT-003` (medium, workflow correctness): print `--timeout` was accepted but unenforced. It is now explicitly rejected, because adding termination/cancellation is outside scope. diff --git a/docs/ai/implementation/2026-08-14-feature-agent-registry-write-optimization.md b/docs/ai/implementation/2026-08-14-feature-agent-registry-write-optimization.md index f2e772c1..d15a9884 100644 --- a/docs/ai/implementation/2026-08-14-feature-agent-registry-write-optimization.md +++ b/docs/ai/implementation/2026-08-14-feature-agent-registry-write-optimization.md @@ -46,7 +46,7 @@ The implementation matches the design without schema or migration changes. The o - Initial focused red: 7 failures, including redundant BEGIN/INSERT/COMMIT plus empty prune BEGIN/COMMIT on an unchanged refresh. - Timestamp regression red: the changed-field test failed when `updated_at` used wall-clock time instead of the injected clock. - Restored green: `AgentRegistry.test.ts` and `AgentManager.test.ts` passed 67/67, including atomic rollback coverage. -- Full agent-manager suite passed 509/509 with OS process visibility enabled for the existing print-agent integration. +- Full agent-manager suite passed 509/509 with OS process visibility enabled for the existing durable-agent integration. - Full CLI suite passed 959/959 after the required workspace build. - Full six-project build and lint completed successfully; lint reported six unrelated pre-existing warnings and zero errors. diff --git a/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md b/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md new file mode 100644 index 00000000..b884a9fa --- /dev/null +++ b/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md @@ -0,0 +1,46 @@ +--- +phase: implementation +title: Durable Agents SQLite Implementation +description: Implementation record for the durable-agent persistence backend +--- + +# Durable Agents SQLite Implementation + +## Development Setup + +- Worktree: `feature-durable-agents-sqlite` +- Bootstrap: `npm ci` +- Initial workspace build: `npm run build` +- Task tracing: unavailable (`ai-devkit task` is not a supported command). + +## Code Structure + +- `packages/agent-manager/src/database/`: connection behavior, schema runner, and migration SQL. +- `packages/agent-manager/src/durable/DurableAgentRepository.ts`: unchanged public adapter backed by SQLite. +- `packages/agent-manager/src/__tests__/`: schema, store, concurrency, and integration coverage. + +## Implementation Notes + +- Added `003_durable_agents.sql` with the flattened durable-agent schema, lifecycle/result constraints, active-run consistency checks, and list/state indexes. +- Updated `DatabaseConnection` so readonly construction neither creates parent directories nor runs migrations or write pragmas, and requires schema version 3 or newer. +- Replaced JSON CRUD, global mutation locks, per-agent lock directories, owner files, quarantine, and temp-file replacement inside `DurableAgentRepository` with SQLite row mapping and writes. +- Added `dbPath` and readonly store options. Legacy timing options remain accepted but unused with TypeScript and README deprecations. +- Implemented acquisition with process inspection outside `BEGIN IMMEDIATE`, transaction reread, and conditional claim. Provider recording and completion require `(id, token)`; recovery/reconciliation also compare the observed owner/run start identity. +- Kept writable `list()` reconciliation behavior while readonly `list()` performs only a query. +- Standardized the unreleased domain API on `DurableAgent*`, including files, store options, run/result types, error classes/codes, CLI references, tests, and documentation. Claude-specific print-provider class names and the `--mode print` mechanism remain unchanged. + +## Integration Points + +`ClaudePrintAgentService`, runners, CLI call sites, `LocalProcessInspector`, cwd canonicalization, and exported durable-agent types remain API-compatible. The store shares the agent-manager `DatabaseConnection` and migration sequence. + +## Error Handling + +SQLite name uniqueness maps to `DurableAgentNameConflictError`; lock contention maps to `DurableAgentBusyError`; open, corruption, validation, and other storage failures map to `DurableAgentRepositoryError`. Conditional updates changing zero rows represent lost ownership. + +## Performance and Security + +Writes use short immediate transactions and indexed lookups. Process/filesystem inspection occurs outside write transactions. Canonical cwd binding, symlink checks, token ownership, and PID start-time validation are preserved. + +## Design Alignment + +The implementation follows the approved separate-table, flattened-latest-result, unchanged-adapter, and SQLite-CAS design. Durable agents persist directly in `agents.db`; the unreleased JSON import compatibility path was removed by product-owner direction. No service, runner, CLI, or print-domain rename was introduced. diff --git a/docs/ai/planning/2026-08-07-feature-agent-print-mode.md b/docs/ai/planning/2026-08-07-feature-agent-print-mode.md index f89c65c0..07cf746d 100644 --- a/docs/ai/planning/2026-08-07-feature-agent-print-mode.md +++ b/docs/ai/planning/2026-08-07-feature-agent-print-mode.md @@ -1,7 +1,7 @@ --- phase: planning title: Claude Print-Mode Agent Implementation Plan -description: Ordered TDD tasks for durable Claude print agents +description: Ordered TDD tasks for durable Claude durable agents --- # Claude Print-Mode Agent Implementation Plan @@ -19,14 +19,14 @@ Every production behavior follows strict red → green → refactor. After each ### Phase 1: Durable foundation -- [x] Task 1.1: Add print-agent domain types and typed errors. +- [x] Task 1.1: Add durable-agent domain types and typed errors. - Outcome: stable record/state/result/process-identity contracts exported from `agent-manager`. - Dependencies: approved requirements/design. - Validation: type-level/unit tests for valid public shapes and error classification. - Scenarios: store, locking, list/detail contract foundations. - [x] Task 1.2: Implement atomic JSON persistence and safe create/list/resolve. - - Outcome: separate versioned `print-agents.json`, canonical cwd, UUID creation, case-insensitive unique names, atomic replacement, and symlink rejection. + - Outcome: separate versioned `durable-agents.json`, canonical cwd, UUID creation, case-insensitive unique names, atomic replacement, and symlink rejection. - Dependencies: Task 1.1. - Validation: focused store tests including malformed storage, permissions, contention, and unsafe paths. - Scenarios: print store/resolution unit tests and create/list integration. @@ -51,7 +51,7 @@ Every production behavior follows strict red → green → refactor. After each - Validation: fake spawn/executable tests for all normal and malformed stream cases. - Scenarios: all runner/parser tests and provider identity mismatch integration. -- [x] Task 2.3: Implement print-agent create/send orchestration. +- [x] Task 2.3: Implement durable-agent create/send orchestration. - Outcome: start validates then persists without spawn; send acquires, runs once, completes ready or records degraded, and never retries. - Dependencies: Tasks 1.2, 1.3, 2.1, and 2.2. - Validation: service-level first-send, resume, busy, failure, and recovery tests. diff --git a/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md b/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md new file mode 100644 index 00000000..6d211e97 --- /dev/null +++ b/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md @@ -0,0 +1,50 @@ +--- +phase: planning +title: Durable Agents SQLite Plan +description: Ordered implementation and validation tasks +--- + +# Durable Agents SQLite Plan + +## Milestones + +- [x] Foundation: schema migration and connection behavior. +- [x] Store backend: transactional CRUD/ownership behavior. +- [x] Validation: parity, concurrency, recovery, full gates, and review. + +## Task Breakdown + +### Phase 1: Foundation + +- [x] Add failing schema tests for constraints, case-insensitive uniqueness, indexes, and migration version; implement `003_durable_agents.sql`. Evidence: focused database tests. +- [x] Add failing readonly-connection tests; make readonly open require an existing migrated database without writes. Evidence: file metadata/schema behavior tests. +- [x] Use explicit injected `dbPath` values throughout store and integration tests. + +### Phase 2: Store Backend + +- [x] Retarget identity, name conflict, canonical cwd, listing, result, and session-resume tests to SQLite; replace JSON CRUD with row mapping and transactions. Evidence: `DurableAgentRepository` and Claude integration suites. +- [x] Retarget busy ownership, token rejection, provider liveness, and interrupted reconciliation tests; implement immediate transactions and token/observed-identity CAS. Evidence: focused ownership tests. +- [x] Add two-connection race and corrupt-database mapping tests. Evidence: concurrency/recovery tests. +- [x] Remove global/per-agent lock machinery and obsolete file-mode assertions; document accepted-but-unused options. Evidence: source search and type tests. + +### Phase 3: Integration & Polish + +- [x] Update implementation/testing docs after each completed group and reconcile this checklist. +- [x] Run implementation alignment check and close discovered gaps. +- [x] Run targeted coverage plus full workspace test, lint, typecheck, and build gates. +- [x] Conduct holistic review, commit conventionally, sync/rebase, and push. PR creation is the immediate publication step. + +## Dependencies and Sequencing + +Schema and readonly connection behavior precede the store rewrite. Row mapping precedes CAS operations. Focused tests precede full gates. `npm ci` and `npm run build` must run before any full gate or commit; both completed during workspace setup. + +## Risks & Mitigation + +- Competing migration number: inspect latest `origin/main` during final rebase and renumber if needed. +- Provider PR overlap: preserve provider as unconstrained text and reconcile `DurableAgentRepository` conflicts minimally if either PR lands. +- PID reuse: include process start time in stale-observation CAS predicates. +- Long write locks: keep process inspection and filesystem validation outside immediate transactions. + +## Progress Summary + +All implementation, validation, and review tasks are complete. No scope changes, blocking findings, or design deviations were found. The branch is synchronized with `origin/main`; only PR creation remains. diff --git a/docs/ai/requirements/2026-08-07-feature-agent-print-mode.md b/docs/ai/requirements/2026-08-07-feature-agent-print-mode.md index 5b0d011a..e9b6ee0e 100644 --- a/docs/ai/requirements/2026-08-07-feature-agent-print-mode.md +++ b/docs/ai/requirements/2026-08-07-feature-agent-print-mode.md @@ -28,12 +28,12 @@ The logical agent exists while no provider process is running. One logical agent - Preserve the existing `ai-devkit agent start` and `ai-devkit agent send --id` user journey. - Add `ai-devkit agent start --type claude --mode print --name NAME --cwd PATH`. - Keep interactive mode as the default and leave its behavior unchanged. -- At print-agent start: +- At durable-agent start: - validate the name, cwd, Claude executable, installed Claude version, and required print-mode capabilities without invoking a model; - generate a stable AI DevKit agent ID and a valid caller-assigned Claude session UUID; - persist a minimal durable local mapping and initial `ready` state; - do not launch Claude and do not create or discover a transcript. -- Resolve print agents on send by exact stable agent ID or unique name. +- Resolve durable agents on send by exact stable agent ID or unique name. - Atomically acquire a per-agent busy state before launching Claude; a concurrent send must fail clearly instead of waiting or queueing. - Send the prompt through child-process stdin, never through command-line arguments. - On the first send, synchronously invoke Claude with the equivalent of: @@ -44,7 +44,7 @@ The logical agent exists while no provider process is running. One logical agent - On later sends, invoke the same mode with exact `--resume `; never use `--continue`. - Parse Claude stream JSON, verify the emitted provider session ID equals the stored caller-assigned UUID, capture the final result, and return the logical agent to `ready`. -- Keep print agents visible in list and detail output with stable identity, provider, mode, cwd, `ready`/`running`/`degraded` state, session health, last activity, and last result. +- Keep durable agents visible in list and detail output with stable identity, provider, mode, cwd, `ready`/`running`/`degraded` state, session health, last activity, and last result. - Detect interrupted or abandoned busy state safely and expose/recover it without permitting concurrent use of the same Claude session. - Validate all behavior without a real or billable Claude prompt. @@ -53,7 +53,7 @@ The logical agent exists while no provider process is running. One logical agent - Keep persistence and new types small and repository-consistent. - Isolate provider execution enough for deterministic fake-provider tests. - Preserve a clean future seam for other run-based providers without implementing them now. -- Provide JSON output that identifies print agents without inventing a fake PID or terminal. +- Provide JSON output that identifies durable agents without inventing a fake PID or terminal. ### Non-goals @@ -67,11 +67,11 @@ The logical agent exists while no provider process is running. One logical agent - Interactive permission prompting or forwarding approvals from print-mode runs. - Automatic retry of a failed run. - Cross-host, shared, or multi-user agent storage. -- Changing existing interactive agent start, list, detail, send, wait, open, rename, kill, or channel semantics beyond the minimum additive resolution needed for print agents. +- Changing existing interactive agent start, list, detail, send, wait, open, rename, kill, or channel semantics beyond the minimum additive resolution needed for durable agents. ## User Stories & Use Cases -### Create a print agent +### Create a durable agent As an AI DevKit user, I can run: @@ -89,7 +89,7 @@ As a user, I can run: ai-devkit agent send --id reviewer "Review the authentication design" ``` -AI DevKit resolves the unique print-agent name, acquires its busy state, starts Claude synchronously, sends the prompt via stdin, streams/parses provider events, verifies the stored session UUID, records the outcome, and exits when the run is terminal. +AI DevKit resolves the unique durable-agent name, acquires its busy state, starts Claude synchronously, sends the prompt via stdin, streams/parses provider events, verifies the stored session UUID, records the outcome, and exits when the run is terminal. ### Resume the same context @@ -97,7 +97,7 @@ As a user, I can send a later message to the stable agent ID or its unique name. ### Observe an idle durable agent -As a user, I can list or inspect a print agent even when no Claude process or transcript exists. The output distinguishes the durable logical agent from an interactive process and reports its session health and last run outcome. +As a user, I can list or inspect a durable agent even when no Claude process or transcript exists. The output distinguishes the durable logical agent from an interactive process and reports its session health and last run outcome. ### Reject concurrent sends @@ -115,8 +115,8 @@ As a user, if the AI DevKit process dies after marking the agent busy, a later o - `--mode print` is accepted only with `--type claude` and rejects unsupported combinations before persistence. - Existing interactive command tests remain unchanged or are augmented only for additive mode parsing. - Existing interactive agents continue to use tmux/process detection and terminal input. -- `agent send --id` resolves both existing interactive agents and durable print agents without ambiguous silent preference. Exact stable print-agent ID wins; duplicate or ambiguous names produce an actionable error. -- Print-agent sends are synchronous. Existing `--wait` behavior for interactive agents remains intact; print sends already wait for completion and must not introduce a second execution path. +- `agent send --id` resolves both existing interactive agents and durable durable agents without ambiguous silent preference. Exact stable durable-agent ID wins; duplicate or ambiguous names produce an actionable error. +- Durable-agent sends are synchronous. Existing `--wait` behavior for interactive agents remains intact; print sends already wait for completion and must not introduce a second execution path. ### Identity and persistence @@ -125,7 +125,7 @@ As a user, if the AI DevKit process dies after marking the agent busy, a later o - Durable persistence uses an atomic, crash-safe local update convention consistent with the repository. - Name uniqueness rules are explicit and deterministic for durable agents. - A stored cwd is canonicalized and remains bound to the provider session; later sends cannot silently resume it from another cwd. -- Print agents survive CLI process exit and remain listable without a provider PID. +- Durable agents survive CLI process exit and remain listable without a provider PID. ### Provider execution @@ -150,10 +150,10 @@ As a user, if the AI DevKit process dies after marking the agent busy, a later o ### List and detail -- Human and JSON list/detail output include stable ID, name, provider `claude`, mode `print`, canonical cwd, state, session health, last activity, and last result. +- Human and JSON list/detail output include stable ID, name, provider `claude`, mode `durable`, canonical cwd, state, session health, last activity, and last result. - No fake PID, tmux session, terminal, or transcript path is fabricated. - Before first send, session health communicates that the caller-assigned identity is initialized but no provider transcript/run has yet been observed. -- A running print agent is visible as `running`; a provider/session/protocol failure is visible as `degraded`; a successful or safely recovered agent is `ready`. +- A running durable agent is visible as `running`; a provider/session/protocol failure is visible as `degraded`; a successful or safely recovered agent is `ready`. ### Validation @@ -169,7 +169,7 @@ As a user, if the AI DevKit process dies after marking the agent busy, a later o - Synchronous execution is intentional. The process running `agent send` owns the provider child until completion. - Concurrent sends fail immediately; there is no queue or implicit retry. - Claude owns its native transcript and retention behavior. AI DevKit owns only the logical identity, binding, minimal state, and last result metadata. -- Print agents have no terminal, so terminal-specific operations remain interactive-only and retain their existing semantics. +- Durable agents have no terminal, so terminal-specific operations remain interactive-only and retain their existing semantics. ### Technical constraints @@ -217,6 +217,6 @@ No blocking product questions remain. The following are design decisions constra - Select the smallest persistence mechanism that provides atomic busy acquisition and safe stale-owner recovery. - Define the exact bounded last-result and session-health representation. -- Define deterministic resolution behavior when an interactive and print agent share a name. +- Define deterministic resolution behavior when an interactive and durable agent share a name. - Define the supported Claude capability/version probe using `--version` and `--help` without invoking a model. - Define how `agent send --wait`, `--timeout`, and `--json` render for an already-synchronous print send while preserving interactive behavior. diff --git a/docs/ai/requirements/2026-08-13-feature-agent-registry-sqlite.md b/docs/ai/requirements/2026-08-13-feature-agent-registry-sqlite.md index dfcd1aec..0c2b02c3 100644 --- a/docs/ai/requirements/2026-08-13-feature-agent-registry-sqlite.md +++ b/docs/ai/requirements/2026-08-13-feature-agent-registry-sqlite.md @@ -28,7 +28,7 @@ Non-goals: - Do not redesign provider detection or session parsing. - Do not move historical provider session indexes into this registry. -- Do not change print-mode agent storage in `print-agents.json`. +- Do not change print-mode agent storage in `durable-agents.json`. - Do not add a daemon or long-running registry service. ## User Stories & Use Cases diff --git a/docs/ai/requirements/2026-08-18-feature-durable-agents-sqlite.md b/docs/ai/requirements/2026-08-18-feature-durable-agents-sqlite.md new file mode 100644 index 00000000..cccf5c3b --- /dev/null +++ b/docs/ai/requirements/2026-08-18-feature-durable-agents-sqlite.md @@ -0,0 +1,56 @@ +--- +phase: requirements +title: Durable Agents SQLite Requirements +description: Persist durable durable agents in the shared agents.db database +--- + +# Durable Agents SQLite Requirements + +## Problem Statement + +Durable durable-agent persistence is a new, unreleased capability. It should launch directly on the existing SQLite agent database so durable sessions survive process exits and concurrent CLI access without introducing a separate JSON store or filesystem lock machinery. + +## Goals & Objectives + +- Store durable agents in a separate `durable_agents` table in `~/.ai-devkit/agents.db`. +- Preserve the exported `DurableAgentRepository` API and all service, runner, and CLI call sites. +- Replace all filesystem locking and whole-file writes with short SQLite transactions and token-based compare-and-swap updates. +- Make readonly database connections genuinely write-free and keep readonly listing pure. +- Preserve current identity, cwd safety, ownership, recovery, reconciliation, and session-resume behavior. + +### Non-goals + +- Renaming print-domain types or APIs to durable-agent names. +- Merging durable agents into the process registry `agents` table. +- Adding run history or a `durable_runs` table. +- Supporting legacy JSON import or dual-write; `durable-agents.json` was never released to users. +- Resolving cross-provider/cross-mode name ambiguity beyond existing CLI behavior. + +## User Stories & Use Cases + +- As a CLI user, I can create, list, acquire, resume, and complete a durable agent with unchanged commands. +- As a concurrent caller, only one process can acquire a durable agent and stale observations cannot steal ownership. +- As a readonly caller, I can list an already-migrated database without creating directories, changing pragmas, migrating, or reconciling runs. +- As an operator, I receive domain errors for name conflicts, busy agents, invalid ownership, and corrupt databases. + +## Success Criteria + +- Migration `003_durable_agents.sql` creates the specified flattened table, constraints, and indexes and advances `user_version`. +- `DurableAgentRepository` accepts `dbPath` and defaults directly to `~/.ai-devkit/agents.db`. +- Deprecated lock timing options remain accepted but unused and are documented. +- Create, acquire, provider recording, completion, and reconciliation use SQLite writes; ownership-changing writes use `(id, token)` or observed stale identity CAS predicates. +- `list()` on readonly connections never reconciles. +- The full behavioral and new validation matrix passes, followed by workspace test, lint, typecheck, and build gates. + +## Constraints & Assumptions + +- Existing WAL and `busy_timeout=5000` settings remain authoritative for writable connections. +- Provider remains application-validated with no schema `CHECK`, allowing provider additions without migration. +- Running rows have all active fields populated; non-running rows have none. +- A live owner or live provider keeps a run busy; stale detection includes PID start time to prevent PID-reuse errors. +- Result summaries remain capped at 4,096 characters. +- Open print-provider PRs are coordination risks only; migration numbering is reconciled during final rebase if necessary. + +## Questions & Open Items + +None. Product, schema, concurrency, rollout, and validation decisions reflect the approved unreleased-feature scope. diff --git a/docs/ai/testing/2026-08-07-feature-agent-print-mode.md b/docs/ai/testing/2026-08-07-feature-agent-print-mode.md index b7bb0c06..d9f461f7 100644 --- a/docs/ai/testing/2026-08-07-feature-agent-print-mode.md +++ b/docs/ai/testing/2026-08-07-feature-agent-print-mode.md @@ -8,7 +8,7 @@ description: Offline TDD, security, integration, and compatibility validation ## Test Coverage Goals -- Target 100% branch/function coverage for new print-agent store, probe, parser, runner, and orchestration modules. +- Target 100% branch/function coverage for new durable-agent repository, probe, parser, runner, and orchestration modules. - Cover every requirements success criterion and design state transition. - Keep all provider tests offline and non-billable. - Re-run existing agent-manager and CLI suites to prove interactive compatibility. @@ -16,11 +16,11 @@ description: Offline TDD, security, integration, and compatibility validation ## Unit Tests -### Print agent store and resolution +### Durable agent repository and resolution -- [ ] Creates a durable print agent with distinct valid AI DevKit and Claude UUIDs. +- [ ] Creates a durable durable agent with distinct valid AI DevKit and Claude UUIDs. - [ ] Canonicalizes an existing cwd and rejects missing/non-directory paths. -- [ ] Rejects duplicate print-agent names case-insensitively. +- [ ] Rejects duplicate durable-agent names case-insensitively. - [ ] Resolves an exact stable ID and unique exact name without partial print-name matching. - [ ] Treats a missing store as empty and rejects malformed or unsupported-version storage. - [ ] Persists with atomic replacement and owner-only file permissions. diff --git a/docs/ai/testing/2026-08-14-feature-agent-registry-write-optimization.md b/docs/ai/testing/2026-08-14-feature-agent-registry-write-optimization.md index cdae27a7..f92dff20 100644 --- a/docs/ai/testing/2026-08-14-feature-agent-registry-write-optimization.md +++ b/docs/ai/testing/2026-08-14-feature-agent-registry-write-optimization.md @@ -36,7 +36,7 @@ description: Deterministic SQL operation-count and fake-clock coverage - Green run: focused suite passed 67 tests in 2 files. - `npm run typecheck --workspace @ai-devkit/agent-manager`: exit 0. - `npm run lint --workspace @ai-devkit/agent-manager`: exit 0. -- `npm test --workspace @ai-devkit/agent-manager`: 24 files, 509 tests passed (rerun with OS process visibility for the existing print-agent integration). +- `npm test --workspace @ai-devkit/agent-manager`: 24 files, 509 tests passed (rerun with OS process visibility for the existing durable-agent integration). - `npm test --workspace ai-devkit`: 79 files, 959 tests passed after workspace packages were built. - `npm run build`: all 6 projects built successfully. - `npm run lint`: all 6 projects linted successfully; 0 errors and 6 unrelated pre-existing warnings. diff --git a/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md b/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md new file mode 100644 index 00000000..e0e77033 --- /dev/null +++ b/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md @@ -0,0 +1,62 @@ +--- +phase: testing +title: Durable Agents SQLite Testing Strategy +description: Behavioral parity, concurrency, and recovery validation +--- + +# Durable Agents SQLite Testing Strategy + +## Test Coverage Goals + +Cover all changed persistence and connection branches with focused unit/integration tests, retain critical end-to-end service flows, and finish with the complete workspace gates. Every new behavior begins with a failing test. + +## Schema and Connection + +- [x] Migration 003 creates the flattened table, constraints, indexes, and expected `user_version`. +- [x] Names are unique case-insensitively; provider values remain extensible. +- [x] State, health, result, and active-field consistency constraints reject invalid rows. +- [x] Readonly open of an already-migrated DB performs no initialization/write and lists successfully. +- [x] Readonly open without a usable schema throws a clear error. + +## Store Behavioral Parity + +- [x] Identity creation and provider session identity persist across reopen. +- [x] Case-insensitive name conflicts map to `DurableAgentNameConflictError`. +- [x] Cwd is canonical, safe, and protected against symlink rebinding. +- [x] Busy ownership, stale-token rejection, provider-liveness recovery, and interrupted-run reconciliation match current behavior. +- [x] Session resume remains covered by `ClaudePrintAgent.integration.test.ts`. +- [x] Latest result behavior and the 4,096-character summary cap remain intact. + +## Storage Compatibility + +- [x] Store and integration tests use explicit isolated `dbPath` values. +- [x] No `durable-agents.json` import, marker, backup, or compatibility option exists because JSON persistence was never released. +- [x] Deprecated lock options remain accepted but do not create lock artifacts. + +## Concurrency and Recovery + +- [x] Two connections racing acquisition yield exactly one owner and one busy result. +- [x] Record-provider and completion reject a stale/lost token. +- [x] Reconcile CAS cannot overwrite ownership changed after process inspection. +- [x] Corrupt database errors map to a clear store error. +- [x] Readonly `list()` does not reconcile or mutate running rows. + +## Full Validation + +- [x] Focused agent-manager test suite passes (26 files, 552 tests). +- [x] Coverage is reviewed: `DurableAgentRepository.ts` reports 90.5% lines and 97.36% functions; remaining branches are defensive platform/storage failures. +- [x] Full workspace test suite passes (1,019 tests). +- [x] Workspace lint passes (existing warnings only, zero errors). +- [x] Workspace typecheck passes for all five typed projects. +- [x] Workspace build passes for all six projects. +- [x] `npx ai-devkit@latest lint --feature durable-agents-sqlite` passes. + +Fresh evidence was collected on 2026-08-18 with `npm run test:coverage --workspace @ai-devkit/agent-manager`, `npm test`, `npm run lint`, `npx nx run-many -t typecheck`, `npm run build`, and the feature lint command; every command exited 0. + +## Test Data and Fixtures + +Tests use isolated temporary directories, real SQLite databases, controlled process-inspector doubles, a deliberately corrupt database, and independent store/connection instances for races. No user home state is read or modified. + +## Manual Testing + +No UI changes exist. Automated integration coverage exercises the user-visible durable-agent lifecycle directly against SQLite. diff --git a/packages/agent-manager/README.md b/packages/agent-manager/README.md index df31ee50..a80fd2d6 100644 --- a/packages/agent-manager/README.md +++ b/packages/agent-manager/README.md @@ -36,7 +36,15 @@ ai-devkit agent send "review the current diff" --id reviewer Print mode inherits Claude Code's settings, permissions, hooks, MCP servers, and tool side effects for that working directory. AI DevKit adds no permission bypass or automatic retry, and prompts are delivered over stdin rather than command-line -arguments. `--timeout` is not supported for print agents in this first release. +arguments. `--timeout` is not supported for durable agents in this first release. + +Durable durable-agent state is stored in `~/.ai-devkit/agents.db`. This feature was +not released with JSON persistence, so there is no legacy import or dual-write. + +For direct `DurableAgentRepository` consumers, `dbPath` selects the SQLite database. The +`lockTimeoutMs`, `incompleteLockGraceMs`, and `mutationLockStaleMs` options are +deprecated, accepted, and ignored because SQLite transactions replace the +filesystem lock machinery. Use this package directly only when building custom tooling around AI DevKit's agent detection and control surface. diff --git a/packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts b/packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts new file mode 100644 index 00000000..e8e1617a --- /dev/null +++ b/packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts @@ -0,0 +1,77 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; +import { DatabaseConnection } from '../../database/connection.js'; +import { getSchemaVersion } from '../../database/schema.js'; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function dbPath(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'durable-agent-db-')); + roots.push(root); + return path.join(root, 'state', 'agents.db'); +} + +describe('durable agents schema', () => { + it('migrates to version 3 with durable constraints and indexes', () => { + const connection = new DatabaseConnection({ dbPath: dbPath() }); + expect(getSchemaVersion(connection)).toBe(3); + const table = connection.queryOne<{ sql: string }>( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'durable_agents'", + ); + expect(table?.sql).toContain("DEFAULT 'durable'"); + expect(table?.sql).toContain("state IN ('ready','running','degraded')"); + expect(connection.query<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'durable_agents'", + ).map(({ name }) => name)).toEqual(expect.arrayContaining([ + 'idx_durable_agents_state', 'idx_durable_agents_list', + ])); + connection.close(); + }); + + it('enforces case-insensitive names and active-run consistency but permits new providers', () => { + const connection = new DatabaseConnection({ dbPath: dbPath() }); + const insert = (name: string, provider: string, state = 'ready') => connection.execute(` + INSERT INTO durable_agents ( + id, name, provider, mode, cwd, provider_session_id, state, session_health, + created_at, updated_at + ) VALUES (?, ?, ?, 'durable', '/tmp', ?, ?, 'uninitialized', ?, ?) + `, [crypto.randomUUID(), name, provider, crypto.randomUUID(), state, new Date().toISOString(), new Date().toISOString()]); + expect(() => insert('Alpha', 'future-provider')).not.toThrow(); + expect(() => insert('alpha', 'claude')).toThrow(/UNIQUE/i); + expect(() => insert('Broken', 'claude', 'running')).toThrow(/CHECK/i); + connection.close(); + }); +}); + +describe('readonly DatabaseConnection', () => { + it('opens an already migrated database without changing it', () => { + const file = dbPath(); + const writable = new DatabaseConnection({ dbPath: file }); + writable.close(); + const before = fs.statSync(file).mtimeMs; + const readonly = new DatabaseConnection({ dbPath: file, readonly: true }); + expect(readonly.queryOne<{ user_version: number }>('PRAGMA user_version')?.user_version).toBe(3); + readonly.close(); + expect(fs.statSync(file).mtimeMs).toBe(before); + }); + + it('does not create a missing database or migrate an old one', () => { + const missing = dbPath(); + expect(() => new DatabaseConnection({ dbPath: missing, readonly: true })).toThrow(/readonly.*exist/i); + expect(fs.existsSync(missing)).toBe(false); + + fs.mkdirSync(path.dirname(missing), { recursive: true }); + const raw = new Database(missing); + raw.pragma('user_version = 2'); + raw.close(); + expect(() => new DatabaseConnection({ dbPath: missing, readonly: true })).toThrow(/schema version 3/i); + expect(new Database(missing, { readonly: true }).pragma('user_version', { simple: true })).toBe(2); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts index 8c4e2920..14c7536c 100644 --- a/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts @@ -7,7 +7,7 @@ import { ClaudeCliProbe, ClaudePrintAgentService, ClaudePrintRunner, - PrintAgentStore, + DurableAgentRepository, } from '../../index.js'; const roots: string[] = []; @@ -19,18 +19,18 @@ afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); -describe('Claude print-agent fake-provider journey', () => { +describe('Claude durable-agent fake-provider journey', () => { it('creates without invocation, then starts and resumes the same session through stdin', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'print-agent-integration-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'durable-agent-integration-')); roots.push(root); const cwd = path.join(root, 'project'); fs.mkdirSync(cwd); const capture = path.join(root, 'capture.jsonl'); process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE = capture; const executable = fileURLToPath(new URL('../fixtures/fake-claude.cjs', import.meta.url)); - const store = new PrintAgentStore({ filePath: path.join(root, 'state', 'print-agents.json') }); + const repository = new DurableAgentRepository({ dbPath: path.join(root, 'state', 'agents.db') }); const service = new ClaudePrintAgentService({ - store, + repository, probe: new ClaudeCliProbe({ executable }), runner: new ClaudePrintRunner(), executable, @@ -50,7 +50,7 @@ describe('Claude print-agent fake-provider journey', () => { expect(invocations[1].args).toContain('--resume'); expect(invocations[1].args[invocations[1].args.indexOf('--resume') + 1]).toBe(created.providerSessionId); - const persisted = await store.getById(created.id); + const persisted = await repository.getById(created.id); expect(persisted).toMatchObject({ state: 'ready', sessionHealth: 'healthy' }); }); }); diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts index f6d4e529..459bd4f3 100644 --- a/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts @@ -5,21 +5,21 @@ describe('ClaudePrintAgentService', () => { const api = await import('../../index.js') as Record; expect(api).toHaveProperty('ClaudePrintAgentService'); const probe = { validate: vi.fn().mockResolvedValue({ executable: 'claude', version: '2.1.220' }) }; - const store = { create: vi.fn().mockResolvedValue({ id: 'agent-id', name: 'reviewer' }) }; + const repository = { create: vi.fn().mockResolvedValue({ id: 'agent-id', name: 'reviewer' }) }; const runner = { run: vi.fn() }; const Service = api.ClaudePrintAgentService as new (options: unknown) => any; - await expect(new Service({ store, probe, runner }).create({ name: 'reviewer', cwd: '/project' })) + await expect(new Service({ repository, probe, runner }).create({ name: 'reviewer', cwd: '/project' })) .resolves.toMatchObject({ id: 'agent-id' }); expect(probe.validate).toHaveBeenCalledOnce(); - expect(store.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: '/project' }); + expect(repository.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: '/project' }); expect(runner.run).not.toHaveBeenCalled(); }); it('runs first and resumed sends and records provider identity/results', async () => { const api = await import('../../index.js') as Record; const base = { id: 'id', name: 'reviewer', providerSessionId: 'session', sessionHealth: 'uninitialized' }; - const store = { + const repository = { resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn() .mockResolvedValueOnce({ agent: base, token: 'one' }) @@ -31,15 +31,15 @@ describe('ClaudePrintAgentService', () => { return { sessionId: 'session', result: 'answer', exitCode: 0 }; }) }; const Service = api.ClaudePrintAgentService as new (options: unknown) => any; - const service = new Service({ store, probe: { validate: vi.fn() }, runner, executable: 'fake-claude' }); + const service = new Service({ repository, probe: { validate: vi.fn() }, runner, executable: 'fake-claude' }); await service.send('reviewer', 'first'); await service.send('id', 'later'); expect(runner.run.mock.calls[0][0]).toMatchObject({ prompt: 'first', firstRun: true, executable: 'fake-claude' }); expect(runner.run.mock.calls[1][0]).toMatchObject({ prompt: 'later', firstRun: false, executable: 'fake-claude' }); - expect(store.recordProviderProcess).toHaveBeenCalledWith('id', 'one', { pid: 42, startedAt: 'start' }); - expect(store.completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ + expect(repository.recordProviderProcess).toHaveBeenCalledWith('id', 'one', { pid: 42, startedAt: 'start' }); + expect(repository.completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ status: 'succeeded', exitCode: 0, sessionHealth: 'healthy', })); }); diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts index fb8519e4..5ad793de 100644 --- a/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts @@ -1,11 +1,11 @@ import { EventEmitter } from 'node:events'; import { PassThrough, Writable } from 'node:stream'; import { describe, expect, it, vi } from 'vitest'; -import type { PrintAgent } from '../../index.js'; +import type { DurableAgent } from '../../index.js'; -function agent(): PrintAgent { +function agent(): DurableAgent { return { - id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'durable', cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'running', sessionHealth: 'uninitialized', createdAt: '', updatedAt: '', lastActiveAt: null, lastResult: null, activeRun: null, diff --git a/packages/agent-manager/src/__tests__/print/PrintAgent.test.ts b/packages/agent-manager/src/__tests__/print/DurableAgent.test.ts similarity index 55% rename from packages/agent-manager/src/__tests__/print/PrintAgent.test.ts rename to packages/agent-manager/src/__tests__/print/DurableAgent.test.ts index a7639a37..e90cd5ae 100644 --- a/packages/agent-manager/src/__tests__/print/PrintAgent.test.ts +++ b/packages/agent-manager/src/__tests__/print/DurableAgent.test.ts @@ -1,21 +1,21 @@ import { describe, expect, it } from 'vitest'; -describe('print-agent public domain', () => { +describe('durable-agent public domain', () => { it('exports a classified busy error without exposing prompt data', async () => { const api = await import('../../index.js') as Record; - expect(api).toHaveProperty('PrintAgentBusyError'); - const ErrorType = api.PrintAgentBusyError as new (agentId: string, name: string) => Error & { + expect(api).toHaveProperty('DurableAgentBusyError'); + const ErrorType = api.DurableAgentBusyError as new (agentId: string, name: string) => Error & { code: string; agentId: string; }; const error = new ErrorType('agent-id', 'reviewer'); expect(error).toMatchObject({ - name: 'PrintAgentBusyError', - code: 'PRINT_AGENT_BUSY', + name: 'DurableAgentBusyError', + code: 'DURABLE_AGENT_BUSY', agentId: 'agent-id', - message: 'Print agent "reviewer" is busy.', + message: 'Durable agent "reviewer" is busy.', }); }); }); diff --git a/packages/agent-manager/src/__tests__/print/DurableAgentRepository.sqlite.test.ts b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.sqlite.test.ts new file mode 100644 index 00000000..b6556b2e --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.sqlite.test.ts @@ -0,0 +1,105 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; +import { DurableAgentRepository } from '../../durable/DurableAgentRepository.js'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'durable-agent-sqlite-')); + roots.push(root); + const cwd = path.join(root, 'project'); + fs.mkdirSync(cwd); + return { root, cwd, dbPath: path.join(root, 'state', 'agents.db') }; +} + +describe('DurableAgentRepository SQLite concurrency', () => { + it('allows exactly one acquisition across two connections', async () => { + const { cwd, dbPath } = fixture(); + const identity = { pid: process.pid, startedAt: 'owner-start' }; + const processInspector = { getIdentity: (pid: number) => pid === process.pid ? identity : null }; + const first = new DurableAgentRepository({ dbPath, processInspector }); + const second = new DurableAgentRepository({ dbPath, processInspector }); + const agent = await first.create({ name: 'race', cwd }); + const results = await Promise.allSettled([first.acquireRun(agent.id), second.acquireRun(agent.id)]); + expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1); + const rejected = results.find(({ status }) => status === 'rejected'); + expect(rejected).toMatchObject({ reason: { code: 'DURABLE_AGENT_BUSY' } }); + }); + + it('accepts deprecated lock options without creating lock artifacts', async () => { + const { root, cwd, dbPath } = fixture(); + const repository = new DurableAgentRepository({ + dbPath, lockTimeoutMs: 1, incompleteLockGraceMs: 1, mutationLockStaleMs: 1, + }); + await repository.create({ name: 'lockless', cwd }); + expect(fs.existsSync(`${dbPath}.lock`)).toBe(false); + expect(fs.existsSync(path.join(root, 'state', 'durable-agent-locks'))).toBe(false); + }); + + it('keeps readonly listing pure', async () => { + const { cwd, dbPath } = fixture(); + const live = new Map([[process.pid, 'owner-start']]); + const processInspector = { getIdentity: (pid: number) => { + const startedAt = live.get(pid); + return startedAt ? { pid, startedAt } : null; + } }; + const writable = new DurableAgentRepository({ dbPath, processInspector }); + const agent = await writable.create({ name: 'readonly', cwd }); + await writable.acquireRun(agent.id); + live.clear(); + const readonly = new DurableAgentRepository({ dbPath, readonly: true, processInspector }); + expect((await readonly.list())[0]?.state).toBe('running'); + }); + + it('rejects stale tokens and caps the persisted completion summary', async () => { + const { cwd, dbPath } = fixture(); + const processInspector = { getIdentity: (pid: number) => ({ pid, startedAt: 'owner-start' }) }; + const repository = new DurableAgentRepository({ dbPath, processInspector }); + const agent = await repository.create({ name: 'token', cwd }); + const run = await repository.acquireRun(agent.id); + await expect(repository.recordProviderProcess(agent.id, 'stale', { pid: 42, startedAt: 'provider' })) + .rejects.toMatchObject({ code: 'DURABLE_AGENT_REPOSITORY' }); + const completed = await repository.completeRun(agent.id, run.token, { + status: 'succeeded', exitCode: 0, summary: 'x'.repeat(5000), sessionHealth: 'healthy', + }); + expect(completed.lastResult?.summary).toHaveLength(4096); + }); + + it('does not reconcile over ownership changed after process inspection', async () => { + const { cwd, dbPath } = fixture(); + let inspect: (() => void) | undefined; + const processInspector = { getIdentity: (pid: number) => { + if (inspect) inspect(); + return inspect ? null : { pid, startedAt: 'owner-start' }; + } }; + const repository = new DurableAgentRepository({ dbPath, processInspector }); + const agent = await repository.create({ name: 'cas', cwd }); + await repository.acquireRun(agent.id); + const other = new Database(dbPath); + inspect = () => { + inspect = undefined; + other.prepare(`UPDATE durable_agents SET + active_run_token = 'replacement-token', active_owner_started_at = 'replacement-owner' + WHERE id = ?`).run(agent.id); + }; + + await repository.reconcile(); + + expect(other.prepare('SELECT state, active_run_token FROM durable_agents WHERE id = ?').get(agent.id)) + .toEqual({ state: 'running', active_run_token: 'replacement-token' }); + other.close(); + }); + + it('maps a corrupt database to a store error', () => { + const { dbPath } = fixture(); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.writeFileSync(dbPath, 'not sqlite'); + expect(() => new DurableAgentRepository({ dbPath })).toThrow(/Cannot open durable-agent database/); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/DurableAgentRepository.test.ts b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.test.ts new file mode 100644 index 00000000..f98aa274 --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.test.ts @@ -0,0 +1,163 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +async function loadStore(): Promise { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('DurableAgentRepository'); + return api.DurableAgentRepository; +} + +function fixture(): { root: string; cwd: string; dbPath: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'durable-agent-store-')); + tempDirs.push(root); + const cwd = path.join(root, 'project'); + fs.mkdirSync(cwd); + return { root, cwd, dbPath: path.join(root, 'state', 'agents.db') }; +} + +describe('DurableAgentRepository create/list/resolve', () => { + it('creates distinct durable identities with a canonical cwd and lists them', async () => { + const DurableAgentRepository = await loadStore(); + const { cwd, dbPath } = fixture(); + const repository = new DurableAgentRepository({ dbPath, now: () => new Date('2026-08-07T09:00:00Z') }); + + const agent = await repository.create({ name: 'reviewer', cwd }); + + expect(agent).toMatchObject({ + name: 'reviewer', + provider: 'claude', + mode: 'durable', + cwd: fs.realpathSync(cwd), + state: 'ready', + sessionHealth: 'uninitialized', + activeRun: null, + }); + expect(agent.id).toMatch(/^[0-9a-f-]{36}$/); + expect(agent.providerSessionId).toMatch(/^[0-9a-f-]{36}$/); + expect(agent.id).not.toBe(agent.providerSessionId); + expect(await repository.list()).toEqual([agent]); + expect(fs.existsSync(dbPath)).toBe(true); + }); + + it('resolves exact ids and names and rejects duplicate names', async () => { + const DurableAgentRepository = await loadStore(); + const { cwd, dbPath } = fixture(); + const repository = new DurableAgentRepository({ dbPath }); + const agent = await repository.create({ name: 'Reviewer', cwd }); + + expect(await repository.resolve(agent.id)).toMatchObject({ id: agent.id }); + expect(await repository.resolve('reviewer')).toMatchObject({ id: agent.id }); + expect(await repository.resolve('view')).toBeNull(); + await expect(repository.create({ name: 'reviewer', cwd })).rejects.toMatchObject({ + code: 'DURABLE_AGENT_NAME_CONFLICT', + }); + }); + + it('rejects a missing cwd', async () => { + const DurableAgentRepository = await loadStore(); + const { root, dbPath } = fixture(); + const repository = new DurableAgentRepository({ dbPath }); + + await expect(repository.create({ name: 'missing', cwd: path.join(root, 'missing') })) + .rejects.toMatchObject({ code: 'DURABLE_AGENT_REPOSITORY' }); + }); +}); + +describe('DurableAgentRepository run ownership', () => { + it('fails fast when another exact owner is live and completes only for its token', async () => { + const DurableAgentRepository = await loadStore(); + const { cwd, dbPath } = fixture(); + const live = new Map([[process.pid, 'owner-start']]); + const processInspector = { getIdentity: (pid: number) => { + const startedAt = live.get(pid); + return startedAt ? { pid, startedAt } : null; + } }; + const repository = new DurableAgentRepository({ dbPath, processInspector }); + const agent = await repository.create({ name: 'runner', cwd }); + + const acquired = await repository.acquireRun(agent.id); + await expect(repository.acquireRun(agent.id)).rejects.toMatchObject({ code: 'DURABLE_AGENT_BUSY' }); + await expect(repository.completeRun(agent.id, 'wrong-token', { + status: 'succeeded', exitCode: 0, summary: 'done', sessionHealth: 'healthy', + })).rejects.toMatchObject({ code: 'DURABLE_AGENT_REPOSITORY' }); + + const completed = await repository.completeRun(agent.id, acquired.token, { + status: 'succeeded', exitCode: 0, summary: 'done', sessionHealth: 'healthy', + }); + expect(completed).toMatchObject({ state: 'ready', sessionHealth: 'healthy', activeRun: null }); + }); + + it('retains busy for a live provider then recovers a dead run without signaling it', async () => { + const DurableAgentRepository = await loadStore(); + const { cwd, dbPath } = fixture(); + const live = new Map([[process.pid, 'owner-start'], [4242, 'provider-start']]); + const processInspector = { getIdentity: (pid: number) => { + const startedAt = live.get(pid); + return startedAt ? { pid, startedAt } : null; + } }; + const first = new DurableAgentRepository({ dbPath, processInspector }); + const agent = await first.create({ name: 'recoverable', cwd }); + const run = await first.acquireRun(agent.id); + await first.recordProviderProcess(agent.id, run.token, { pid: 4242, startedAt: 'provider-start' }); + + live.delete(process.pid); + await expect(first.acquireRun(agent.id)).rejects.toMatchObject({ code: 'DURABLE_AGENT_BUSY' }); + + live.delete(4242); + live.set(process.pid, 'replacement-owner-start'); + const recovered = await first.acquireRun(agent.id); + expect(recovered.agent).toMatchObject({ + state: 'running', + lastResult: { status: 'interrupted' }, + }); + await first.completeRun(agent.id, recovered.token, { + status: 'failed', exitCode: 1, summary: 'failed', sessionHealth: 'unknown', + }); + }); + + it('reconciles an interrupted run to degraded during list', async () => { + const DurableAgentRepository = await loadStore(); + const { cwd, dbPath } = fixture(); + const live = new Map([[process.pid, 'owner-start']]); + const repository = new DurableAgentRepository({ dbPath, incompleteLockGraceMs: 10, processInspector: { + getIdentity: (pid: number) => { + const startedAt = live.get(pid); + return startedAt ? { pid, startedAt } : null; + }, + } }); + const agent = await repository.create({ name: 'crashed', cwd }); + await repository.acquireRun(agent.id); + live.clear(); + + const listed = await repository.list(); + + expect(listed[0]).toMatchObject({ + state: 'degraded', + sessionHealth: 'unknown', + activeRun: null, + lastResult: { status: 'interrupted' }, + }); + }); + + it('rejects send acquisition when the bound cwd is replaced by a symlink', async () => { + const DurableAgentRepository = await loadStore(); + const { root, cwd, dbPath } = fixture(); + const repository = new DurableAgentRepository({ dbPath }); + const agent = await repository.create({ name: 'bound', cwd }); + const moved = path.join(root, 'moved-project'); + const other = path.join(root, 'other-project'); + fs.renameSync(cwd, moved); + fs.mkdirSync(other); + fs.symlinkSync(other, cwd); + + await expect(repository.acquireRun(agent.id)).rejects.toMatchObject({ code: 'DURABLE_AGENT_REPOSITORY' }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts b/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts deleted file mode 100644 index 85b4c8e6..00000000 --- a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; - -const tempDirs: string[] = []; - -afterEach(() => { - for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); -}); - -async function loadStore(): Promise { - const api = await import('../../index.js') as Record; - expect(api).toHaveProperty('PrintAgentStore'); - return api.PrintAgentStore; -} - -function fixture(): { root: string; cwd: string; filePath: string } { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'print-agent-store-')); - tempDirs.push(root); - const cwd = path.join(root, 'project'); - fs.mkdirSync(cwd); - return { root, cwd, filePath: path.join(root, 'state', 'print-agents.json') }; -} - -describe('PrintAgentStore create/list/resolve', () => { - it('creates distinct durable identities with a canonical cwd and lists them', async () => { - const PrintAgentStore = await loadStore(); - const { cwd, filePath } = fixture(); - const store = new PrintAgentStore({ filePath, now: () => new Date('2026-08-07T09:00:00Z') }); - - const agent = await store.create({ name: 'reviewer', cwd }); - - expect(agent).toMatchObject({ - name: 'reviewer', - provider: 'claude', - mode: 'print', - cwd: fs.realpathSync(cwd), - state: 'ready', - sessionHealth: 'uninitialized', - activeRun: null, - }); - expect(agent.id).toMatch(/^[0-9a-f-]{36}$/); - expect(agent.providerSessionId).toMatch(/^[0-9a-f-]{36}$/); - expect(agent.id).not.toBe(agent.providerSessionId); - expect(await store.list()).toEqual([agent]); - expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); - }); - - it('resolves exact ids and names and rejects duplicate names', async () => { - const PrintAgentStore = await loadStore(); - const { cwd, filePath } = fixture(); - const store = new PrintAgentStore({ filePath }); - const agent = await store.create({ name: 'Reviewer', cwd }); - - expect(await store.resolve(agent.id)).toMatchObject({ id: agent.id }); - expect(await store.resolve('reviewer')).toMatchObject({ id: agent.id }); - expect(await store.resolve('view')).toBeNull(); - await expect(store.create({ name: 'reviewer', cwd })).rejects.toMatchObject({ - code: 'PRINT_AGENT_NAME_CONFLICT', - }); - }); - - it('rejects missing cwd, malformed storage, and symlinked store targets', async () => { - const PrintAgentStore = await loadStore(); - const { root, cwd, filePath } = fixture(); - const store = new PrintAgentStore({ filePath }); - - await expect(store.create({ name: 'missing', cwd: path.join(root, 'missing') })) - .rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); - - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, '{bad json', { mode: 0o600 }); - await expect(store.list()).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); - - fs.rmSync(filePath); - const target = path.join(root, 'target.json'); - fs.writeFileSync(target, JSON.stringify({ version: 1, agents: [] })); - fs.symlinkSync(target, filePath); - await expect(store.create({ name: 'unsafe', cwd })).rejects.toMatchObject({ - code: 'PRINT_AGENT_STORE', - }); - }); - - it('recovers an abandoned old mutation lock after a crash', async () => { - const PrintAgentStore = await loadStore(); - const { cwd, filePath } = fixture(); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const lockPath = `${filePath}.lock`; - fs.mkdirSync(lockPath); - const old = new Date(Date.now() - 60_000); - fs.utimesSync(lockPath, old, old); - const store = new PrintAgentStore({ filePath, mutationLockStaleMs: 10 }); - - await expect(store.create({ name: 'recovered', cwd })).resolves.toMatchObject({ name: 'recovered' }); - }); -}); - -describe('PrintAgentStore run ownership', () => { - it('fails fast when another exact owner is live and completes only for its token', async () => { - const PrintAgentStore = await loadStore(); - const { cwd, filePath } = fixture(); - const live = new Map([[process.pid, 'owner-start']]); - const processInspector = { getIdentity: (pid: number) => { - const startedAt = live.get(pid); - return startedAt ? { pid, startedAt } : null; - } }; - const store = new PrintAgentStore({ filePath, processInspector }); - const agent = await store.create({ name: 'runner', cwd }); - - const acquired = await store.acquireRun(agent.id); - await expect(store.acquireRun(agent.id)).rejects.toMatchObject({ code: 'PRINT_AGENT_BUSY' }); - await expect(store.completeRun(agent.id, 'wrong-token', { - status: 'succeeded', exitCode: 0, summary: 'done', sessionHealth: 'healthy', - })).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); - - const completed = await store.completeRun(agent.id, acquired.token, { - status: 'succeeded', exitCode: 0, summary: 'done', sessionHealth: 'healthy', - }); - expect(completed).toMatchObject({ state: 'ready', sessionHealth: 'healthy', activeRun: null }); - }); - - it('retains busy for a live provider then recovers a dead run without signaling it', async () => { - const PrintAgentStore = await loadStore(); - const { cwd, filePath } = fixture(); - const live = new Map([[process.pid, 'owner-start'], [4242, 'provider-start']]); - const processInspector = { getIdentity: (pid: number) => { - const startedAt = live.get(pid); - return startedAt ? { pid, startedAt } : null; - } }; - const first = new PrintAgentStore({ filePath, processInspector }); - const agent = await first.create({ name: 'recoverable', cwd }); - const run = await first.acquireRun(agent.id); - await first.recordProviderProcess(agent.id, run.token, { pid: 4242, startedAt: 'provider-start' }); - - live.delete(process.pid); - await expect(first.acquireRun(agent.id)).rejects.toMatchObject({ code: 'PRINT_AGENT_BUSY' }); - - live.delete(4242); - live.set(process.pid, 'replacement-owner-start'); - const recovered = await first.acquireRun(agent.id); - expect(recovered.agent).toMatchObject({ - state: 'running', - lastResult: { status: 'interrupted' }, - }); - await first.completeRun(agent.id, recovered.token, { - status: 'failed', exitCode: 1, summary: 'failed', sessionHealth: 'unknown', - }); - }); - - it('reconciles an old incomplete lock to degraded during list', async () => { - const PrintAgentStore = await loadStore(); - const { root, cwd, filePath } = fixture(); - const live = new Map([[process.pid, 'owner-start']]); - const store = new PrintAgentStore({ filePath, incompleteLockGraceMs: 10, processInspector: { - getIdentity: (pid: number) => { - const startedAt = live.get(pid); - return startedAt ? { pid, startedAt } : null; - }, - } }); - const agent = await store.create({ name: 'crashed', cwd }); - await store.acquireRun(agent.id); - const lockPath = path.join(root, 'state', 'print-agent-locks', `${agent.id}.lock`); - fs.unlinkSync(path.join(lockPath, 'owner.json')); - const old = new Date(Date.now() - 1000); - fs.utimesSync(lockPath, old, old); - live.clear(); - - const listed = await store.list(); - - expect(listed[0]).toMatchObject({ - state: 'degraded', - sessionHealth: 'unknown', - activeRun: null, - lastResult: { status: 'interrupted' }, - }); - }); - - it('rejects send acquisition when the bound cwd is replaced by a symlink', async () => { - const PrintAgentStore = await loadStore(); - const { root, cwd, filePath } = fixture(); - const store = new PrintAgentStore({ filePath }); - const agent = await store.create({ name: 'bound', cwd }); - const moved = path.join(root, 'moved-project'); - const other = path.join(root, 'other-project'); - fs.renameSync(cwd, moved); - fs.mkdirSync(other); - fs.symlinkSync(other, cwd); - - await expect(store.acquireRun(agent.id)).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); - }); -}); diff --git a/packages/agent-manager/src/database/connection.ts b/packages/agent-manager/src/database/connection.ts index 41c1b7c7..2e3b57ff 100644 --- a/packages/agent-manager/src/database/connection.ts +++ b/packages/agent-manager/src/database/connection.ts @@ -1,5 +1,5 @@ import Database from 'better-sqlite3'; -import { mkdirSync } from 'fs'; +import { existsSync, mkdirSync } from 'fs'; import { dirname, join } from 'path'; import { homedir } from 'os'; import { initializeSchema } from './schema.js'; @@ -25,7 +25,10 @@ export class DatabaseConnection { constructor(options: DatabaseOptions = {}) { this.dbPath = options.dbPath ?? DEFAULT_AGENT_REGISTRY_DB_PATH; this.readonly = options.readonly ?? false; - mkdirSync(dirname(this.dbPath), { recursive: true }); + if (this.readonly && !existsSync(this.dbPath)) { + throw new Error(`Cannot open readonly agent database because it does not exist: ${this.dbPath}`); + } + if (!this.readonly) mkdirSync(dirname(this.dbPath), { recursive: true }); this.db = new Database(this.dbPath, { readonly: this.readonly, @@ -34,16 +37,19 @@ export class DatabaseConnection { : options.verbose ? console.log : undefined, }); - this.configure(); - if (!this.readonly) initializeSchema(this); + if (this.readonly) { + const version = this.db.pragma('user_version', { simple: true }) as number; + if (version < 3) { + this.db.close(); + throw new Error(`Readonly agent database requires schema version 3 (found ${version}).`); + } + } else { + this.configure(); + initializeSchema(this); + } } private configure(): void { - if (this.readonly) { - this.db.pragma('foreign_keys = ON'); - this.db.pragma('busy_timeout = 5000'); - return; - } this.db.pragma('journal_mode = WAL'); this.db.pragma('foreign_keys = ON'); this.db.pragma('synchronous = NORMAL'); diff --git a/packages/agent-manager/src/database/migrations/003_durable_agents.sql b/packages/agent-manager/src/database/migrations/003_durable_agents.sql new file mode 100644 index 00000000..202dd8c6 --- /dev/null +++ b/packages/agent-manager/src/database/migrations/003_durable_agents.sql @@ -0,0 +1,43 @@ +CREATE TABLE durable_agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + provider TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'durable', + cwd TEXT NOT NULL, + provider_session_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('ready','running','degraded')), + session_health TEXT NOT NULL CHECK (session_health IN ('uninitialized','healthy','unknown','mismatch')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_active_at TEXT NULL, + last_result_status TEXT NULL CHECK (last_result_status IS NULL OR last_result_status IN ('succeeded','failed','interrupted')), + last_result_completed_at TEXT NULL, + last_result_exit_code INTEGER NULL, + last_result_summary TEXT NULL, + active_run_token TEXT UNIQUE, + active_owner_pid INTEGER NULL, + active_owner_started_at TEXT NULL, + active_provider_pid INTEGER NULL, + active_provider_started_at TEXT NULL, + active_run_started_at TEXT NULL, + CHECK ( + (state = 'running' + AND active_run_token IS NOT NULL + AND active_owner_pid IS NOT NULL + AND active_owner_started_at IS NOT NULL + AND active_run_started_at IS NOT NULL) + OR + (state <> 'running' + AND active_run_token IS NULL + AND active_owner_pid IS NULL + AND active_owner_started_at IS NULL + AND active_provider_pid IS NULL + AND active_provider_started_at IS NULL + AND active_run_started_at IS NULL) + ), + CHECK ((active_provider_pid IS NULL) = (active_provider_started_at IS NULL)), + CHECK ((last_result_status IS NULL) = (last_result_completed_at IS NULL)) +); + +CREATE INDEX idx_durable_agents_state ON durable_agents(state); +CREATE INDEX idx_durable_agents_list ON durable_agents(updated_at DESC, name COLLATE NOCASE); diff --git a/packages/agent-manager/src/print/ClaudeCliProbe.ts b/packages/agent-manager/src/durable/ClaudeCliProbe.ts similarity index 97% rename from packages/agent-manager/src/print/ClaudeCliProbe.ts rename to packages/agent-manager/src/durable/ClaudeCliProbe.ts index d14cfafb..faf2435a 100644 --- a/packages/agent-manager/src/print/ClaudeCliProbe.ts +++ b/packages/agent-manager/src/durable/ClaudeCliProbe.ts @@ -1,6 +1,6 @@ import { execFile } from 'child_process'; import { promisify } from 'util'; -import { ClaudePrintError } from './PrintAgent.js'; +import { ClaudePrintError } from './DurableAgent.js'; type ExecResult = { stdout: string; stderr: string }; type Exec = (file: string, args: string[]) => Promise; diff --git a/packages/agent-manager/src/print/ClaudePrintAgentService.ts b/packages/agent-manager/src/durable/ClaudePrintAgentService.ts similarity index 63% rename from packages/agent-manager/src/print/ClaudePrintAgentService.ts rename to packages/agent-manager/src/durable/ClaudePrintAgentService.ts index 26195b26..fa875ac7 100644 --- a/packages/agent-manager/src/print/ClaudePrintAgentService.ts +++ b/packages/agent-manager/src/durable/ClaudePrintAgentService.ts @@ -1,23 +1,23 @@ -import type { PrintAgent, ProcessIdentity } from './PrintAgent.js'; -import { ClaudePrintError, PrintAgentNotFoundError } from './PrintAgent.js'; +import type { DurableAgent, ProcessIdentity } from './DurableAgent.js'; +import { ClaudePrintError, DurableAgentNotFoundError } from './DurableAgent.js'; import { ClaudeCliProbe } from './ClaudeCliProbe.js'; import { ClaudePrintRunner, type ClaudePrintRunResult } from './ClaudePrintRunner.js'; -import { PrintAgentStore, type CreatePrintAgentInput, type PrintRunCompletion } from './PrintAgentStore.js'; +import { DurableAgentRepository, type CreateDurableAgentInput, type DurableRunCompletion } from './DurableAgentRepository.js'; -interface StoreLike { - create(input: CreatePrintAgentInput): Promise; - list(): Promise; - resolve(reference: string): Promise; - acquireRun(id: string): Promise<{ agent: PrintAgent; token: string }>; +interface RepositoryLike { + create(input: CreateDurableAgentInput): Promise; + list(): Promise; + resolve(reference: string): Promise; + acquireRun(id: string): Promise<{ agent: DurableAgent; token: string }>; recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise; - completeRun(id: string, token: string, result: PrintRunCompletion): Promise; + completeRun(id: string, token: string, result: DurableRunCompletion): Promise; } interface ProbeLike { validate(): Promise<{ executable: string; version: string }> } interface RunnerLike { run(request: Parameters[0]): Promise } export interface ClaudePrintAgentServiceOptions { - store?: StoreLike; + repository?: RepositoryLike; probe?: ProbeLike; runner?: RunnerLike; executable?: string; @@ -29,39 +29,39 @@ export interface ClaudePrintSendResult extends ClaudePrintRunResult { } export class ClaudePrintAgentService { - readonly store: StoreLike; + readonly repository: RepositoryLike; private readonly probe: ProbeLike; private readonly runner: RunnerLike; private readonly executable?: string; constructor(options: ClaudePrintAgentServiceOptions = {}) { - this.store = options.store ?? new PrintAgentStore(); + this.repository = options.repository ?? new DurableAgentRepository(); this.probe = options.probe ?? new ClaudeCliProbe(); this.runner = options.runner ?? new ClaudePrintRunner(); this.executable = options.executable; } - async create(input: CreatePrintAgentInput): Promise { + async create(input: CreateDurableAgentInput): Promise { await this.probe.validate(); - return this.store.create(input); + return this.repository.create(input); } async send(reference: string, prompt: string): Promise { - const resolved = await this.store.resolve(reference); - if (!resolved) throw new PrintAgentNotFoundError(reference); + const resolved = await this.repository.resolve(reference); + if (!resolved) throw new DurableAgentNotFoundError(reference); if (Array.isArray(resolved)) { - throw new ClaudePrintError(`Multiple print agents match "${reference}".`, 'PRINT_AGENT_AMBIGUOUS'); + throw new ClaudePrintError(`Multiple durable agents match "${reference}".`, 'DURABLE_AGENT_AMBIGUOUS'); } - const acquired = await this.store.acquireRun(resolved.id); + const acquired = await this.repository.acquireRun(resolved.id); try { const result = await this.runner.run({ agent: acquired.agent, prompt, executable: this.executable, firstRun: acquired.agent.sessionHealth === 'uninitialized', - onSpawn: (identity) => this.store.recordProviderProcess(resolved.id, acquired.token, identity), + onSpawn: (identity) => this.repository.recordProviderProcess(resolved.id, acquired.token, identity), }); - await this.store.completeRun(resolved.id, acquired.token, { + await this.repository.completeRun(resolved.id, acquired.token, { status: 'succeeded', exitCode: result.exitCode, summary: sanitize(result.result, 4096), @@ -73,7 +73,7 @@ export class ClaudePrintAgentService { const sessionHealth = error instanceof ClaudePrintError && error.code === 'CLAUDE_SESSION_MISMATCH' ? 'mismatch' as const : 'unknown' as const; - await this.store.completeRun(resolved.id, acquired.token, { + await this.repository.completeRun(resolved.id, acquired.token, { status: 'failed', exitCode: null, summary: sanitize(failure.message, 4096), diff --git a/packages/agent-manager/src/print/ClaudePrintRunner.ts b/packages/agent-manager/src/durable/ClaudePrintRunner.ts similarity index 97% rename from packages/agent-manager/src/print/ClaudePrintRunner.ts rename to packages/agent-manager/src/durable/ClaudePrintRunner.ts index 588effd8..5e248372 100644 --- a/packages/agent-manager/src/print/ClaudePrintRunner.ts +++ b/packages/agent-manager/src/durable/ClaudePrintRunner.ts @@ -1,7 +1,7 @@ import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'child_process'; -import type { PrintAgent, ProcessIdentity } from './PrintAgent.js'; -import { ClaudePrintError } from './PrintAgent.js'; -import { LocalProcessInspector, type ProcessInspector } from './PrintAgentStore.js'; +import type { DurableAgent, ProcessIdentity } from './DurableAgent.js'; +import { ClaudePrintError } from './DurableAgent.js'; +import { LocalProcessInspector, type ProcessInspector } from './DurableAgentRepository.js'; type Spawn = ( command: string, @@ -10,7 +10,7 @@ type Spawn = ( ) => ChildProcessWithoutNullStreams; export interface ClaudePrintRunRequest { - agent: PrintAgent; + agent: DurableAgent; prompt: string; executable?: string; firstRun: boolean; diff --git a/packages/agent-manager/src/durable/DurableAgent.ts b/packages/agent-manager/src/durable/DurableAgent.ts new file mode 100644 index 00000000..4db7b67a --- /dev/null +++ b/packages/agent-manager/src/durable/DurableAgent.ts @@ -0,0 +1,91 @@ +export type DurableAgentState = 'ready' | 'running' | 'degraded'; +export type DurableSessionHealth = 'uninitialized' | 'healthy' | 'unknown' | 'mismatch'; +export type DurableRunStatus = 'succeeded' | 'failed' | 'interrupted'; + +export const AGENT_MODES = { + INTERACTIVE: 'interactive', + DURABLE: 'durable', +} as const; + +export interface ProcessIdentity { + pid: number; + startedAt: string; +} + +export interface DurableActiveRun { + token: string; + owner: ProcessIdentity; + provider: ProcessIdentity | null; + startedAt: string; +} + +export interface DurableLastResult { + status: DurableRunStatus; + completedAt: string; + exitCode: number | null; + summary: string; +} + +export interface DurableAgent { + id: string; + name: string; + provider: 'claude'; + mode: typeof AGENT_MODES.DURABLE; + cwd: string; + providerSessionId: string; + state: DurableAgentState; + sessionHealth: DurableSessionHealth; + createdAt: string; + updatedAt: string; + lastActiveAt: string | null; + lastResult: DurableLastResult | null; + activeRun: DurableActiveRun | null; +} + +export class DurableAgentError extends Error { + constructor( + message: string, + public readonly code: string, + ) { + super(message); + this.name = 'DurableAgentError'; + } +} + +export class DurableAgentBusyError extends DurableAgentError { + constructor( + public readonly agentId: string, + agentName: string, + ) { + super(`Durable agent "${agentName}" is busy.`, 'DURABLE_AGENT_BUSY'); + this.name = 'DurableAgentBusyError'; + } +} + +export class DurableAgentNotFoundError extends DurableAgentError { + constructor(public readonly reference: string) { + super(`Durable agent "${reference}" was not found.`, 'DURABLE_AGENT_NOT_FOUND'); + this.name = 'DurableAgentNotFoundError'; + } +} + +export class DurableAgentRepositoryError extends DurableAgentError { + constructor(message: string) { + super(message, 'DURABLE_AGENT_REPOSITORY'); + this.name = 'DurableAgentRepositoryError'; + } +} + +export class DurableAgentNameConflictError extends DurableAgentError { + constructor(public readonly agentName: string) { + super(`Durable agent name "${agentName}" is already in use.`, 'DURABLE_AGENT_NAME_CONFLICT'); + this.name = 'DurableAgentNameConflictError'; + } +} + +export class ClaudePrintError extends DurableAgentError { + constructor(message: string, code = 'CLAUDE_PRINT_FAILED') { + super(message, code); + this.name = 'ClaudePrintError'; + } +} diff --git a/packages/agent-manager/src/durable/DurableAgentRepository.ts b/packages/agent-manager/src/durable/DurableAgentRepository.ts new file mode 100644 index 00000000..a0122d09 --- /dev/null +++ b/packages/agent-manager/src/durable/DurableAgentRepository.ts @@ -0,0 +1,307 @@ +import fs from 'fs'; +import { randomUUID } from 'crypto'; +import { execFileSync } from 'child_process'; +import { DatabaseConnection, DEFAULT_AGENT_REGISTRY_DB_PATH } from '../database/index.js'; +import { AGENT_MODES, type DurableActiveRun, type DurableAgent, type ProcessIdentity, type DurableRunStatus, type DurableSessionHealth } from './DurableAgent.js'; +import { + DurableAgentBusyError, + DurableAgentNameConflictError, + DurableAgentNotFoundError, + DurableAgentRepositoryError, +} from './DurableAgent.js'; + +interface DurableAgentRow { + id: string; name: string; provider: 'claude'; mode: typeof AGENT_MODES.DURABLE; cwd: string; provider_session_id: string; + state: DurableAgent['state']; session_health: DurableSessionHealth; created_at: string; updated_at: string; + last_active_at: string | null; last_result_status: DurableRunStatus | null; + last_result_completed_at: string | null; last_result_exit_code: number | null; last_result_summary: string | null; + active_run_token: string | null; active_owner_pid: number | null; active_owner_started_at: string | null; + active_provider_pid: number | null; active_provider_started_at: string | null; active_run_started_at: string | null; +} + +export interface CreateDurableAgentInput { name: string; cwd: string } + +export interface DurableAgentRepositoryOptions { + dbPath?: string; + readonly?: boolean; + /** @deprecated SQLite busy_timeout replaces filesystem lock polling. */ + lockTimeoutMs?: number; + now?: () => Date; + processInspector?: ProcessInspector; + /** @deprecated Active ownership is committed atomically. */ + incompleteLockGraceMs?: number; + /** @deprecated SQLite transactions replace mutation lock directories. */ + mutationLockStaleMs?: number; +} + +export interface ProcessInspector { getIdentity(pid: number): ProcessIdentity | null } +export interface DurableRunCompletion { + status: DurableRunStatus; exitCode: number | null; summary: string; sessionHealth: DurableSessionHealth; +} + +export class DurableAgentRepository { + readonly dbPath: string; + private readonly now: () => Date; + private readonly processInspector: ProcessInspector; + private readonly readonly: boolean; + private readonly db: DatabaseConnection; + + constructor(options: DurableAgentRepositoryOptions = {}) { + this.dbPath = options.dbPath ?? DEFAULT_AGENT_REGISTRY_DB_PATH; + this.now = options.now ?? (() => new Date()); + this.processInspector = options.processInspector ?? new LocalProcessInspector(); + this.readonly = options.readonly ?? false; + try { + this.db = new DatabaseConnection({ dbPath: this.dbPath, readonly: this.readonly }); + } catch (error) { + if (error instanceof DurableAgentRepositoryError) throw error; + throw new DurableAgentRepositoryError(`Cannot open durable-agent database: ${(error as Error).message}`); + } + } + + async create(input: CreateDurableAgentInput): Promise { + this.assertWritable(); + const cwd = this.canonicalDirectory(input.cwd); + const timestamp = this.now().toISOString(); + const id = randomUUID(); + let providerSessionId = randomUUID(); + while (providerSessionId === id) providerSessionId = randomUUID(); + try { + this.db.execute(`INSERT INTO durable_agents ( + id, name, provider, mode, cwd, provider_session_id, state, session_health, created_at, updated_at + ) VALUES (?, ?, 'claude', ?, ?, ?, 'ready', 'uninitialized', ?, ?)`, + [id, input.name, AGENT_MODES.DURABLE, cwd, providerSessionId, timestamp, timestamp]); + } catch (error) { + if (/UNIQUE constraint failed: durable_agents\.name/i.test((error as Error).message)) { + throw new DurableAgentNameConflictError(input.name); + } + throw this.storageError('Failed to create durable agent', error); + } + return this.requireById(id); + } + + async list(): Promise { + if (!this.readonly) await this.reconcile(); + return this.listRaw(); + } + + async getById(id: string): Promise { + if (!this.readonly) await this.reconcile(); + return this.findById(id); + } + + async resolve(reference: string): Promise { + const agents = await this.list(); + const byId = agents.find((agent) => agent.id === reference); + if (byId) return byId; + const matches = agents.filter((agent) => agent.name.toLowerCase() === reference.toLowerCase()); + return matches.length === 0 ? null : matches.length === 1 ? matches[0]! : matches; + } + + async acquireRun(id: string): Promise<{ agent: DurableAgent; token: string }> { + this.assertWritable(); + const snapshot = this.findById(id); + if (!snapshot) throw new DurableAgentNotFoundError(id); + this.validateBoundCwd(snapshot.cwd); + const observed = snapshot.activeRun; + const observedLive = observed ? this.isActive(observed) : false; + if (observedLive) throw new DurableAgentBusyError(id, snapshot.name); + const owner = this.processInspector.getIdentity(process.pid); + if (!owner) throw new DurableAgentRepositoryError('Cannot determine the current process identity.'); + const token = randomUUID(); + const startedAt = this.now().toISOString(); + let recovered = false; + try { + this.immediate(() => { + const current = this.findById(id); + if (!current) throw new DurableAgentNotFoundError(id); + if (current.state === 'running') { + if (!observed || current.activeRun?.token !== observed.token || observedLive) { + throw new DurableAgentBusyError(id, current.name); + } + recovered = true; + } + const changed = this.db.execute(`UPDATE durable_agents SET + state = 'running', active_run_token = ?, active_owner_pid = ?, active_owner_started_at = ?, + active_provider_pid = NULL, active_provider_started_at = NULL, active_run_started_at = ?, updated_at = ?, + session_health = CASE WHEN state = 'running' THEN 'unknown' ELSE session_health END, + last_result_status = CASE WHEN state = 'running' THEN 'interrupted' ELSE last_result_status END, + last_result_completed_at = CASE WHEN state = 'running' THEN ? ELSE last_result_completed_at END, + last_result_exit_code = CASE WHEN state = 'running' THEN NULL ELSE last_result_exit_code END, + last_result_summary = CASE WHEN state = 'running' THEN 'Previous print run was interrupted.' ELSE last_result_summary END + WHERE id = ? AND (state <> 'running' OR ( + active_run_token = ? AND active_owner_started_at = ? AND active_run_started_at = ? + )) + `, [token, owner.pid, owner.startedAt, startedAt, startedAt, startedAt, id, + observed?.token ?? null, observed?.owner.startedAt ?? null, observed?.startedAt ?? null]); + if (changed.changes !== 1) throw new DurableAgentBusyError(id, current.name); + }); + } catch (error) { + if (error instanceof DurableAgentBusyError || error instanceof DurableAgentNotFoundError) throw error; + if (/busy|locked/i.test((error as Error).message)) throw new DurableAgentBusyError(id, snapshot.name); + throw this.storageError('Failed to acquire durable-agent run', error); + } + const agent = this.requireById(id); + if (recovered && agent.lastResult?.status !== 'interrupted') { + throw new DurableAgentRepositoryError('Failed to record interrupted print run.'); + } + return { agent, token }; + } + + async recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise { + this.assertWritable(); + const changed = this.db.execute(`UPDATE durable_agents SET + active_provider_pid = ?, active_provider_started_at = ?, updated_at = ? + WHERE id = ? AND state = 'running' AND active_run_token = ?`, + [identity.pid, identity.startedAt, this.now().toISOString(), id, token]); + if (changed.changes !== 1) throw new DurableAgentRepositoryError('Print run ownership changed.'); + } + + async completeRun(id: string, token: string, result: DurableRunCompletion): Promise { + this.assertWritable(); + const completedAt = this.now().toISOString(); + const changed = this.db.execute(`UPDATE durable_agents SET + state = ?, session_health = ?, active_run_token = NULL, active_owner_pid = NULL, + active_owner_started_at = NULL, active_provider_pid = NULL, active_provider_started_at = NULL, + active_run_started_at = NULL, last_active_at = ?, updated_at = ?, last_result_status = ?, + last_result_completed_at = ?, last_result_exit_code = ?, last_result_summary = ? + WHERE id = ? AND state = 'running' AND active_run_token = ?`, [ + result.status === 'succeeded' ? 'ready' : 'degraded', result.sessionHealth, completedAt, completedAt, + result.status, completedAt, result.exitCode, result.summary.slice(0, 4096), id, token, + ]); + if (changed.changes !== 1) throw new DurableAgentRepositoryError('Print run ownership changed.'); + return this.requireById(id); + } + + async reconcile(): Promise { + this.assertWritable(); + const running = this.listRaw().filter((agent) => agent.state === 'running' && agent.activeRun); + for (const snapshot of running) { + if (this.isActive(snapshot.activeRun!)) continue; + const completedAt = this.now().toISOString(); + this.db.execute(`UPDATE durable_agents SET + state = 'degraded', session_health = 'unknown', active_run_token = NULL, + active_owner_pid = NULL, active_owner_started_at = NULL, active_provider_pid = NULL, + active_provider_started_at = NULL, active_run_started_at = NULL, updated_at = ?, last_active_at = ?, + last_result_status = 'interrupted', last_result_completed_at = ?, last_result_exit_code = NULL, + last_result_summary = 'Previous print run was interrupted.' + WHERE id = ? AND state = 'running' AND active_run_token = ? + AND active_owner_started_at = ? AND active_run_started_at = ?`, [ + completedAt, completedAt, completedAt, snapshot.id, snapshot.activeRun!.token, + snapshot.activeRun!.owner.startedAt, snapshot.activeRun!.startedAt, + ]); + } + } + + private immediate(operation: () => T): T { + this.db.instance.exec('BEGIN IMMEDIATE'); + try { + const result = operation(); + this.db.instance.exec('COMMIT'); + return result; + } catch (error) { + try { this.db.instance.exec('ROLLBACK'); } catch { /* retain original failure */ } + throw error; + } + } + + private listRaw(): DurableAgent[] { + try { + return this.db.query( + 'SELECT * FROM durable_agents ORDER BY updated_at DESC, name COLLATE NOCASE', + ).map((row) => this.fromRow(row)); + } catch (error) { + throw this.storageError('Failed to read durable-agent database', error); + } + } + + private findById(id: string): DurableAgent | null { + const row = this.db.queryOne('SELECT * FROM durable_agents WHERE id = ?', [id]); + return row ? this.fromRow(row) : null; + } + + private requireById(id: string): DurableAgent { + const agent = this.findById(id); + if (!agent) throw new DurableAgentNotFoundError(id); + return agent; + } + + private fromRow(row: DurableAgentRow): DurableAgent { + const activeRun: DurableActiveRun | null = row.active_run_token === null ? null : { + token: row.active_run_token, + owner: { pid: row.active_owner_pid!, startedAt: row.active_owner_started_at! }, + provider: row.active_provider_pid === null ? null : { + pid: row.active_provider_pid, startedAt: row.active_provider_started_at!, + }, + startedAt: row.active_run_started_at!, + }; + return { + id: row.id, name: row.name, provider: row.provider, mode: row.mode, cwd: row.cwd, + providerSessionId: row.provider_session_id, state: row.state, sessionHealth: row.session_health, + createdAt: row.created_at, updatedAt: row.updated_at, lastActiveAt: row.last_active_at, + lastResult: row.last_result_status === null ? null : { + status: row.last_result_status, completedAt: row.last_result_completed_at!, + exitCode: row.last_result_exit_code, summary: row.last_result_summary ?? '', + }, + activeRun, + }; + } + + private canonicalDirectory(input: string): string { + try { + const resolved = fs.realpathSync(input); + if (!fs.statSync(resolved).isDirectory()) throw new Error('not a directory'); + return resolved; + } catch { + throw new DurableAgentRepositoryError(`Durable agent cwd is not an existing directory: ${input}`); + } + } + + private validateBoundCwd(bound: string): void { + try { + const stat = fs.lstatSync(bound); + if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(bound) !== bound) throw new Error('binding changed'); + } catch { + throw new DurableAgentRepositoryError(`Durable agent cwd binding is no longer safe: ${bound}`); + } + } + + private isActive(metadata: DurableActiveRun): boolean { + return this.sameProcess(metadata.owner) || (metadata.provider !== null && this.sameProcess(metadata.provider)); + } + + private sameProcess(expected: ProcessIdentity): boolean { + const actual = this.processInspector.getIdentity(expected.pid); + return actual !== null && actual.startedAt === expected.startedAt; + } + + private assertWritable(): void { + if (this.readonly) throw new DurableAgentRepositoryError('Durable-agent repository is readonly.'); + } + + private storageError(prefix: string, error: unknown): DurableAgentRepositoryError { + return error instanceof DurableAgentRepositoryError ? error + : new DurableAgentRepositoryError(`${prefix}: ${(error as Error).message}`); + } +} + +export class LocalProcessInspector implements ProcessInspector { + getIdentity(pid: number): ProcessIdentity | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + try { + if (process.platform === 'linux') { + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + const close = stat.lastIndexOf(')'); + const fields = stat.slice(close + 2).split(' '); + const startTicks = fields[19]; + return startTicks ? { pid, startedAt: `linux:${startTicks}` } : null; + } + const startedAt = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return startedAt ? { pid, startedAt } : null; + } catch { + return null; + } + } +} diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index 26264e61..eef400cb 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -38,40 +38,41 @@ export type { AgentRequest } from './utils/agent-requests.js'; export { getAgentRequestPath, readLatestAgentRequest, writeAgentRequest } from './utils/agent-requests.js'; export { - PrintAgentError, - PrintAgentBusyError, - PrintAgentNotFoundError, - PrintAgentStoreError, - PrintAgentNameConflictError, + AGENT_MODES, + DurableAgentError, + DurableAgentBusyError, + DurableAgentNotFoundError, + DurableAgentRepositoryError, + DurableAgentNameConflictError, ClaudePrintError, -} from './print/PrintAgent.js'; +} from './durable/DurableAgent.js'; export type { - PrintAgent, - PrintAgentState, - PrintSessionHealth, - PrintRunStatus, - PrintActiveRun, - PrintLastResult, + DurableAgent, + DurableAgentState, + DurableSessionHealth, + DurableRunStatus, + DurableActiveRun, + DurableLastResult, ProcessIdentity, -} from './print/PrintAgent.js'; -export { PrintAgentStore } from './print/PrintAgentStore.js'; -export { LocalProcessInspector } from './print/PrintAgentStore.js'; +} from './durable/DurableAgent.js'; +export { DurableAgentRepository } from './durable/DurableAgentRepository.js'; +export { LocalProcessInspector } from './durable/DurableAgentRepository.js'; export type { - CreatePrintAgentInput, - PrintAgentStoreOptions, + CreateDurableAgentInput, + DurableAgentRepositoryOptions, ProcessInspector, - PrintRunCompletion, -} from './print/PrintAgentStore.js'; -export { ClaudeCliProbe } from './print/ClaudeCliProbe.js'; -export type { ClaudeCliProbeOptions } from './print/ClaudeCliProbe.js'; -export { ClaudePrintRunner } from './print/ClaudePrintRunner.js'; + DurableRunCompletion, +} from './durable/DurableAgentRepository.js'; +export { ClaudeCliProbe } from './durable/ClaudeCliProbe.js'; +export type { ClaudeCliProbeOptions } from './durable/ClaudeCliProbe.js'; +export { ClaudePrintRunner } from './durable/ClaudePrintRunner.js'; export type { ClaudePrintRunnerOptions, ClaudePrintRunRequest, ClaudePrintRunResult, -} from './print/ClaudePrintRunner.js'; -export { ClaudePrintAgentService } from './print/ClaudePrintAgentService.js'; +} from './durable/ClaudePrintRunner.js'; +export { ClaudePrintAgentService } from './durable/ClaudePrintAgentService.js'; export type { ClaudePrintAgentServiceOptions, ClaudePrintSendResult, -} from './print/ClaudePrintAgentService.js'; +} from './durable/ClaudePrintAgentService.js'; diff --git a/packages/agent-manager/src/print/PrintAgent.ts b/packages/agent-manager/src/print/PrintAgent.ts deleted file mode 100644 index ca812e34..00000000 --- a/packages/agent-manager/src/print/PrintAgent.ts +++ /dev/null @@ -1,86 +0,0 @@ -export type PrintAgentState = 'ready' | 'running' | 'degraded'; -export type PrintSessionHealth = 'uninitialized' | 'healthy' | 'unknown' | 'mismatch'; -export type PrintRunStatus = 'succeeded' | 'failed' | 'interrupted'; - -export interface ProcessIdentity { - pid: number; - startedAt: string; -} - -export interface PrintActiveRun { - token: string; - owner: ProcessIdentity; - provider: ProcessIdentity | null; - startedAt: string; -} - -export interface PrintLastResult { - status: PrintRunStatus; - completedAt: string; - exitCode: number | null; - summary: string; -} - -export interface PrintAgent { - id: string; - name: string; - provider: 'claude'; - mode: 'print'; - cwd: string; - providerSessionId: string; - state: PrintAgentState; - sessionHealth: PrintSessionHealth; - createdAt: string; - updatedAt: string; - lastActiveAt: string | null; - lastResult: PrintLastResult | null; - activeRun: PrintActiveRun | null; -} - -export class PrintAgentError extends Error { - constructor( - message: string, - public readonly code: string, - ) { - super(message); - this.name = 'PrintAgentError'; - } -} - -export class PrintAgentBusyError extends PrintAgentError { - constructor( - public readonly agentId: string, - agentName: string, - ) { - super(`Print agent "${agentName}" is busy.`, 'PRINT_AGENT_BUSY'); - this.name = 'PrintAgentBusyError'; - } -} - -export class PrintAgentNotFoundError extends PrintAgentError { - constructor(public readonly reference: string) { - super(`Print agent "${reference}" was not found.`, 'PRINT_AGENT_NOT_FOUND'); - this.name = 'PrintAgentNotFoundError'; - } -} - -export class PrintAgentStoreError extends PrintAgentError { - constructor(message: string) { - super(message, 'PRINT_AGENT_STORE'); - this.name = 'PrintAgentStoreError'; - } -} - -export class PrintAgentNameConflictError extends PrintAgentError { - constructor(public readonly agentName: string) { - super(`Print agent name "${agentName}" is already in use.`, 'PRINT_AGENT_NAME_CONFLICT'); - this.name = 'PrintAgentNameConflictError'; - } -} - -export class ClaudePrintError extends PrintAgentError { - constructor(message: string, code = 'CLAUDE_PRINT_FAILED') { - super(message, code); - this.name = 'ClaudePrintError'; - } -} diff --git a/packages/agent-manager/src/print/PrintAgentStore.ts b/packages/agent-manager/src/print/PrintAgentStore.ts deleted file mode 100644 index db560aa0..00000000 --- a/packages/agent-manager/src/print/PrintAgentStore.ts +++ /dev/null @@ -1,503 +0,0 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { randomUUID } from 'crypto'; -import { execFileSync } from 'child_process'; -import type { PrintAgent, ProcessIdentity, PrintRunStatus, PrintSessionHealth } from './PrintAgent.js'; -import { - PrintAgentBusyError, - PrintAgentNameConflictError, - PrintAgentNotFoundError, - PrintAgentStoreError, -} from './PrintAgent.js'; - -interface PrintAgentStoreFile { - version: 1; - agents: PrintAgent[]; -} - -export interface CreatePrintAgentInput { - name: string; - cwd: string; -} - -export interface PrintAgentStoreOptions { - filePath?: string; - lockTimeoutMs?: number; - now?: () => Date; - processInspector?: ProcessInspector; - incompleteLockGraceMs?: number; - mutationLockStaleMs?: number; -} - -export interface ProcessInspector { - getIdentity(pid: number): ProcessIdentity | null; -} - -export interface PrintRunCompletion { - status: PrintRunStatus; - exitCode: number | null; - summary: string; - sessionHealth: PrintSessionHealth; -} - -const DEFAULT_FILE = path.join(os.homedir(), '.ai-devkit', 'print-agents.json'); - -export class PrintAgentStore { - readonly filePath: string; - private readonly lockPath: string; - private readonly lockTimeoutMs: number; - private readonly now: () => Date; - private readonly processInspector: ProcessInspector; - private readonly runLocksRoot: string; - private readonly incompleteLockGraceMs: number; - private readonly mutationLockStaleMs: number; - - constructor(options: PrintAgentStoreOptions = {}) { - this.filePath = options.filePath ?? DEFAULT_FILE; - this.lockPath = `${this.filePath}.lock`; - this.lockTimeoutMs = options.lockTimeoutMs ?? 2000; - this.now = options.now ?? (() => new Date()); - this.processInspector = options.processInspector ?? new LocalProcessInspector(); - this.runLocksRoot = path.join(path.dirname(this.filePath), 'print-agent-locks'); - this.incompleteLockGraceMs = options.incompleteLockGraceMs ?? 30_000; - this.mutationLockStaleMs = options.mutationLockStaleMs ?? 30_000; - } - - async create(input: CreatePrintAgentInput): Promise { - const cwd = this.canonicalDirectory(input.cwd); - return this.withMutationLock(async () => { - const data = this.readFile(); - if (data.agents.some((agent) => agent.name.toLowerCase() === input.name.toLowerCase())) { - throw new PrintAgentNameConflictError(input.name); - } - const timestamp = this.now().toISOString(); - let id = randomUUID(); - let providerSessionId = randomUUID(); - while (providerSessionId === id) providerSessionId = randomUUID(); - while (data.agents.some((agent) => agent.id === id)) id = randomUUID(); - const agent: PrintAgent = { - id, - name: input.name, - provider: 'claude', - mode: 'print', - cwd, - providerSessionId, - state: 'ready', - sessionHealth: 'uninitialized', - createdAt: timestamp, - updatedAt: timestamp, - lastActiveAt: null, - lastResult: null, - activeRun: null, - }; - data.agents.push(agent); - this.writeFile(data); - return structuredClone(agent); - }); - } - - async list(): Promise { - await this.reconcile(); - return this.listRaw(); - } - - async getById(id: string): Promise { - return (await this.list()).find((agent) => agent.id === id) ?? null; - } - - async reconcile(): Promise { - const running = this.listRaw().filter((agent) => agent.state === 'running' && agent.activeRun); - for (const snapshot of running) { - const lockPath = this.runLockPath(snapshot.id); - const metadata = this.readRunLock(snapshot.id); - if (metadata && this.isActive(metadata)) continue; - if (!metadata && this.isYoungLock(lockPath)) continue; - - if (fs.existsSync(lockPath)) { - const quarantine = `${lockPath}.stale-${randomUUID()}`; - try { - fs.renameSync(lockPath, quarantine); - this.removeLockDirectory(quarantine); - } catch { - continue; - } - } - const completedAt = this.now().toISOString(); - await this.updateAgent(snapshot.id, (current) => { - if (current.state !== 'running' || current.activeRun?.token !== snapshot.activeRun?.token) return current; - return { - ...current, - state: 'degraded', - sessionHealth: 'unknown', - activeRun: null, - updatedAt: completedAt, - lastActiveAt: completedAt, - lastResult: { - status: 'interrupted', - completedAt, - exitCode: null, - summary: 'Previous print run was interrupted.', - }, - }; - }); - } - } - - async resolve(reference: string): Promise { - const agents = await this.list(); - const byId = agents.find((agent) => agent.id === reference); - if (byId) return byId; - const matches = agents.filter((agent) => agent.name.toLowerCase() === reference.toLowerCase()); - if (matches.length === 0) return null; - return matches.length === 1 ? matches[0]! : matches; - } - - async acquireRun(id: string): Promise<{ agent: PrintAgent; token: string }> { - const existing = await this.getById(id); - if (!existing) throw new PrintAgentNotFoundError(id); - this.validateBoundCwd(existing.cwd); - const runLock = this.runLockPath(id); - let recoveredStale = false; - - for (;;) { - this.ensureRunLocksRoot(); - this.assertNotSymlink(runLock); - try { - fs.mkdirSync(runLock, { mode: 0o700 }); - break; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { - throw new PrintAgentStoreError(`Cannot acquire print-agent run lock: ${(error as Error).message}`); - } - const metadata = this.readRunLock(id); - if (!metadata || this.isActive(metadata)) { - throw new PrintAgentBusyError(id, existing.name); - } - const quarantine = `${runLock}.stale-${randomUUID()}`; - try { - fs.renameSync(runLock, quarantine); - this.removeLockDirectory(quarantine); - recoveredStale = true; - } catch { - // Another contender changed the lock. Retry and inspect the winner. - } - } - } - - const owner = this.processInspector.getIdentity(process.pid); - if (!owner) { - this.removeLockDirectory(runLock); - throw new PrintAgentStoreError('Cannot determine the current process identity.'); - } - const token = randomUUID(); - const startedAt = this.now().toISOString(); - const activeRun = { token, owner, provider: null, startedAt }; - this.writeRunLock(id, activeRun); - - try { - const agent = await this.updateAgent(id, (current) => ({ - ...current, - state: 'running', - activeRun, - updatedAt: startedAt, - ...(recoveredStale ? { - sessionHealth: 'unknown' as const, - lastResult: { - status: 'interrupted' as const, - completedAt: startedAt, - exitCode: null, - summary: 'Previous print run was interrupted.', - }, - } : {}), - })); - return { agent, token }; - } catch (error) { - this.removeOwnedRunLock(id, token); - throw error; - } - } - - async recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise { - const metadata = this.requireOwnedRun(id, token); - const next = { ...metadata, provider: identity }; - this.writeRunLock(id, next); - await this.updateAgent(id, (agent) => { - if (agent.activeRun?.token !== token) throw new PrintAgentStoreError('Print run ownership changed.'); - return { ...agent, activeRun: next, updatedAt: this.now().toISOString() }; - }); - } - - async completeRun(id: string, token: string, result: PrintRunCompletion): Promise { - this.requireOwnedRun(id, token); - const completedAt = this.now().toISOString(); - const agent = await this.updateAgent(id, (current) => { - if (current.activeRun?.token !== token) throw new PrintAgentStoreError('Print run ownership changed.'); - return { - ...current, - state: result.status === 'succeeded' ? 'ready' : 'degraded', - sessionHealth: result.sessionHealth, - activeRun: null, - lastActiveAt: completedAt, - updatedAt: completedAt, - lastResult: { - status: result.status, - completedAt, - exitCode: result.exitCode, - summary: result.summary.slice(0, 4096), - }, - }; - }); - this.removeOwnedRunLock(id, token); - return agent; - } - - private canonicalDirectory(input: string): string { - try { - const resolved = fs.realpathSync(input); - if (!fs.statSync(resolved).isDirectory()) throw new Error('not a directory'); - return resolved; - } catch { - throw new PrintAgentStoreError(`Print agent cwd is not an existing directory: ${input}`); - } - } - - private validateBoundCwd(bound: string): void { - try { - const stat = fs.lstatSync(bound); - if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(bound) !== bound) { - throw new Error('binding changed'); - } - } catch { - throw new PrintAgentStoreError(`Print agent cwd binding is no longer safe: ${bound}`); - } - } - - private ensureSafeParent(): string { - const parent = path.dirname(this.filePath); - fs.mkdirSync(parent, { recursive: true, mode: 0o700 }); - const stat = fs.lstatSync(parent); - if (!stat.isDirectory() || stat.isSymbolicLink()) { - throw new PrintAgentStoreError(`Unsafe print-agent store directory: ${parent}`); - } - return parent; - } - - private ensureRunLocksRoot(): void { - this.ensureSafeParent(); - fs.mkdirSync(this.runLocksRoot, { recursive: true, mode: 0o700 }); - const stat = fs.lstatSync(this.runLocksRoot); - if (!stat.isDirectory() || stat.isSymbolicLink()) { - throw new PrintAgentStoreError(`Unsafe print-agent lock directory: ${this.runLocksRoot}`); - } - } - - private assertNotSymlink(target: string): void { - try { - if (fs.lstatSync(target).isSymbolicLink()) { - throw new PrintAgentStoreError(`Unsafe symbolic link in print-agent storage: ${target}`); - } - } catch (error) { - if (error instanceof PrintAgentStoreError) throw error; - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw new PrintAgentStoreError(`Cannot inspect print-agent storage: ${target}`); - } - } - } - - private readFile(): PrintAgentStoreFile { - this.ensureSafeParent(); - this.assertNotSymlink(this.filePath); - if (!fs.existsSync(this.filePath)) return { version: 1, agents: [] }; - try { - const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as unknown; - if (!this.isStoreFile(parsed)) throw new Error('invalid schema'); - return parsed; - } catch { - throw new PrintAgentStoreError(`Invalid print-agent store: ${this.filePath}`); - } - } - - private listRaw(): PrintAgent[] { - return this.readFile().agents.map((agent) => structuredClone(agent)); - } - - private isStoreFile(value: unknown): value is PrintAgentStoreFile { - if (!value || typeof value !== 'object') return false; - const record = value as Record; - return record.version === 1 && Array.isArray(record.agents); - } - - private writeFile(data: PrintAgentStoreFile): void { - const parent = this.ensureSafeParent(); - this.assertNotSymlink(this.filePath); - const temp = path.join(parent, `.print-agents-${process.pid}-${randomUUID()}.tmp`); - this.assertNotSymlink(temp); - let fd: number | undefined; - try { - fd = fs.openSync(temp, 'wx', 0o600); - fs.writeFileSync(fd, JSON.stringify(data, null, 2), 'utf8'); - fs.fsyncSync(fd); - fs.closeSync(fd); - fd = undefined; - fs.renameSync(temp, this.filePath); - fs.chmodSync(this.filePath, 0o600); - } catch (error) { - if (fd !== undefined) fs.closeSync(fd); - try { fs.unlinkSync(temp); } catch { /* best effort */ } - if (error instanceof PrintAgentStoreError) throw error; - throw new PrintAgentStoreError(`Failed to update print-agent store: ${(error as Error).message}`); - } - } - - private async withMutationLock(operation: () => Promise): Promise { - this.ensureSafeParent(); - const started = Date.now(); - for (;;) { - this.assertNotSymlink(this.lockPath); - try { - fs.mkdirSync(this.lockPath, { mode: 0o700 }); - break; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { - throw new PrintAgentStoreError(`Cannot acquire print-agent store lock: ${(error as Error).message}`); - } - if (!this.isYoungMutationLock()) { - const quarantine = `${this.lockPath}.stale-${randomUUID()}`; - try { - fs.renameSync(this.lockPath, quarantine); - fs.rmdirSync(quarantine); - continue; - } catch { - // Another contender changed the lock. Retry until the bounded timeout. - } - } - if (Date.now() - started >= this.lockTimeoutMs) { - throw new PrintAgentStoreError('Timed out acquiring print-agent store lock.'); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - try { - return await operation(); - } finally { - try { fs.rmdirSync(this.lockPath); } catch { /* surfaced by later contention */ } - } - } - - private isYoungMutationLock(): boolean { - try { - this.assertNotSymlink(this.lockPath); - const stat = fs.statSync(this.lockPath); - return stat.isDirectory() && Date.now() - stat.mtimeMs < this.mutationLockStaleMs; - } catch { - return false; - } - } - - private async updateAgent(id: string, update: (agent: PrintAgent) => PrintAgent): Promise { - return this.withMutationLock(async () => { - const data = this.readFile(); - const index = data.agents.findIndex((agent) => agent.id === id); - if (index < 0) throw new PrintAgentNotFoundError(id); - const next = update(data.agents[index]!); - data.agents[index] = next; - this.writeFile(data); - return structuredClone(next); - }); - } - - private runLockPath(id: string): string { - if (!/^[0-9a-f-]{36}$/i.test(id)) throw new PrintAgentStoreError('Invalid print-agent id.'); - return path.join(this.runLocksRoot, `${id}.lock`); - } - - private readRunLock(id: string): import('./PrintAgent.js').PrintActiveRun | null { - const ownerPath = path.join(this.runLockPath(id), 'owner.json'); - try { - this.assertNotSymlink(ownerPath); - const value = JSON.parse(fs.readFileSync(ownerPath, 'utf8')) as import('./PrintAgent.js').PrintActiveRun; - if (!value || typeof value.token !== 'string' || !value.owner || typeof value.owner.pid !== 'number') return null; - return value; - } catch { - return null; - } - } - - private writeRunLock(id: string, metadata: import('./PrintAgent.js').PrintActiveRun): void { - const lockPath = this.runLockPath(id); - const ownerPath = path.join(lockPath, 'owner.json'); - const tempPath = path.join(lockPath, `.owner-${randomUUID()}.tmp`); - this.assertNotSymlink(lockPath); - this.assertNotSymlink(ownerPath); - fs.writeFileSync(tempPath, JSON.stringify(metadata), { encoding: 'utf8', mode: 0o600, flag: 'wx' }); - fs.renameSync(tempPath, ownerPath); - } - - private requireOwnedRun(id: string, token: string): import('./PrintAgent.js').PrintActiveRun { - const metadata = this.readRunLock(id); - if (!metadata || metadata.token !== token) throw new PrintAgentStoreError('Print run ownership changed.'); - return metadata; - } - - private isActive(metadata: import('./PrintAgent.js').PrintActiveRun): boolean { - return this.sameProcess(metadata.owner) || (metadata.provider !== null && this.sameProcess(metadata.provider)); - } - - private sameProcess(expected: ProcessIdentity): boolean { - const actual = this.processInspector.getIdentity(expected.pid); - return actual !== null && actual.startedAt === expected.startedAt; - } - - private isYoungLock(lockPath: string): boolean { - try { - this.assertNotSymlink(lockPath); - return Date.now() - fs.statSync(lockPath).mtimeMs < this.incompleteLockGraceMs; - } catch { - return false; - } - } - - private removeOwnedRunLock(id: string, token: string): void { - const metadata = this.readRunLock(id); - if (!metadata || metadata.token !== token) return; - this.removeLockDirectory(this.runLockPath(id)); - } - - private removeLockDirectory(lockPath: string): void { - this.assertNotSymlink(lockPath); - try { - for (const name of fs.readdirSync(lockPath)) { - const entry = path.join(lockPath, name); - this.assertNotSymlink(entry); - if (!fs.lstatSync(entry).isFile()) throw new PrintAgentStoreError(`Unsafe entry in print-agent lock: ${entry}`); - fs.unlinkSync(entry); - } - fs.rmdirSync(lockPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - } -} - -export class LocalProcessInspector implements ProcessInspector { - getIdentity(pid: number): ProcessIdentity | null { - if (!Number.isInteger(pid) || pid <= 0) return null; - try { - if (process.platform === 'linux') { - const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); - const close = stat.lastIndexOf(')'); - const fields = stat.slice(close + 2).split(' '); - const startTicks = fields[19]; - if (!startTicks) return null; - return { pid, startedAt: `linux:${startTicks}` }; - } - const startedAt = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { - encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - return startedAt ? { pid, startedAt } : null; - } catch { - return null; - } - } -} diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index eb18840f..7ebd70bf 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -13,13 +13,13 @@ const mockManager: any = { getAdapter: vi.fn(), }; -const mockPrintStore: any = { +const mockDurableRepository: any = { list: vi.fn().mockResolvedValue([]), resolve: vi.fn().mockResolvedValue(null), }; -const mockPrintService: any = { - store: mockPrintStore, +const mockDurableService: any = { + repository: mockDurableRepository, create: vi.fn(), send: vi.fn(), }; @@ -89,6 +89,7 @@ const { RenameNotFoundError, RenameConflictError } = vi.hoisted(() => { }); vi.mock('@ai-devkit/agent-manager', () => ({ + AGENT_MODES: { INTERACTIVE: 'interactive', DURABLE: 'durable' }, AgentManager: vi.fn(function () { return mockManager; }), ClaudeCodeAdapter: vi.fn(), CodexAdapter: vi.fn(), @@ -97,8 +98,8 @@ vi.mock('@ai-devkit/agent-manager', () => ({ GrokCliAdapter: vi.fn(), OpenCodeAdapter: vi.fn(), PiAdapter: vi.fn(), - PrintAgentStore: vi.fn(function () { return mockPrintStore; }), - ClaudePrintAgentService: vi.fn(function () { return mockPrintService; }), + DurableAgentRepository: vi.fn(function () { return mockDurableRepository; }), + ClaudePrintAgentService: vi.fn(function () { return mockDurableService; }), TerminalFocusManager: vi.fn(function () { return mockFocusManager; }), TtyWriter: { send: (location: any, message: string) => mockTtyWriterSend(location, message) }, AgentStatus: { @@ -228,10 +229,10 @@ describe('agent command', () => { mockManager.resolveAgent.mockReset(); mockManager.getAdapter.mockReset(); mockAgentAdapter.getConversation.mockReset(); - mockPrintStore.list.mockReset().mockResolvedValue([]); - mockPrintStore.resolve.mockReset().mockResolvedValue(null); - mockPrintService.create.mockReset(); - mockPrintService.send.mockReset(); + mockDurableRepository.list.mockReset().mockResolvedValue([]); + mockDurableRepository.resolve.mockReset().mockResolvedValue(null); + mockDurableService.create.mockReset(); + mockDurableService.send.mockReset(); mockFocusManager.findTerminal.mockReset(); mockFocusManager.focusTerminal.mockReset(); mockTtyWriterSend.mockReset().mockResolvedValue(undefined); @@ -293,10 +294,10 @@ describe('agent command', () => { ], null, 2)); }); - it('labels print agents as durable in list JSON without a fake pid', async () => { + it('labels durable agents as durable in list JSON without a fake pid', async () => { mockManager.listAgents.mockResolvedValue([]); - mockPrintStore.list.mockResolvedValue([{ - id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', + mockDurableRepository.list.mockResolvedValue([{ + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'durable', cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, }]); @@ -310,21 +311,21 @@ describe('agent command', () => { expect(output[0]).not.toHaveProperty('pid'); }); - it('shows durable print-agent detail without requiring a transcript', async () => { - const printAgent = { - id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', + it('shows durable durable-agent detail without requiring a transcript', async () => { + const durableAgent = { + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'durable', cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, }; - mockPrintStore.resolve.mockResolvedValue(printAgent); + mockDurableRepository.resolve.mockResolvedValue(durableAgent); mockManager.listAgents.mockResolvedValue([]); const program = new Command(); registerAgentCommand(program); - await program.parseAsync(['node', 'test', 'agent', 'detail', '--id', printAgent.id, '--json']); + await program.parseAsync(['node', 'test', 'agent', 'detail', '--id', durableAgent.id, '--json']); const output = JSON.parse(logSpy.mock.calls[0][0] as string); - expect(output).toMatchObject({ id: printAgent.id, provider: 'claude', mode: 'print', state: 'ready' }); + expect(output).toMatchObject({ id: durableAgent.id, provider: 'claude', mode: 'durable', state: 'ready' }); expect(output).not.toHaveProperty('conversation'); }); @@ -387,10 +388,10 @@ describe('agent command', () => { expect(ui.warning).toHaveBeenCalledWith('1 agent(s) waiting for input.'); }); - it('renders print agents as durable without leaking the internal print mode', async () => { + it('renders durable agents as durable without leaking the internal print mode', async () => { mockManager.listAgents.mockResolvedValue([]); - mockPrintStore.list.mockResolvedValue([{ - id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', + mockDurableRepository.list.mockResolvedValue([{ + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'durable', cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, }]); @@ -727,10 +728,10 @@ Waiting on user input`, expect(ui.success).toHaveBeenCalledWith('Sent message to repo-a.'); }); - it('starts a durable Claude print agent without tmux', async () => { - mockPrintService.create.mockResolvedValue({ + it('starts a durable Claude durable agent without tmux', async () => { + mockDurableService.create.mockResolvedValue({ id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', - mode: 'print', cwd: process.cwd(), state: 'ready', + mode: 'durable', cwd: process.cwd(), state: 'ready', }); const program = new Command(); @@ -740,44 +741,44 @@ Waiting on user input`, '--name', 'reviewer', '--cwd', process.cwd(), ]); - expect(mockPrintService.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: process.cwd() }); + expect(mockDurableService.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: process.cwd() }); expect(ui.success).toHaveBeenCalledWith(expect.stringContaining('11111111-1111-4111-8111-111111111111')); }); - it('sends synchronously to an exact print-agent id without terminal injection', async () => { - const printAgent = { + it('sends synchronously to an exact durable-agent id without terminal injection', async () => { + const durableAgent = { id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', - mode: 'print', cwd: '/project', state: 'ready', + mode: 'durable', cwd: '/project', state: 'ready', }; - mockPrintStore.resolve.mockResolvedValue(printAgent); - mockPrintService.send.mockResolvedValue({ ...printAgent, result: '\x1b]0;unsafe\x07review complete', exitCode: 0 }); + mockDurableRepository.resolve.mockResolvedValue(durableAgent); + mockDurableService.send.mockResolvedValue({ ...durableAgent, result: '\x1b]0;unsafe\x07review complete', exitCode: 0 }); const program = new Command(); registerAgentCommand(program); await program.parseAsync([ - 'node', 'test', 'agent', 'send', 'review this', '--id', printAgent.id, + 'node', 'test', 'agent', 'send', 'review this', '--id', durableAgent.id, ]); - expect(mockPrintService.send).toHaveBeenCalledWith(printAgent.id, 'review this'); + expect(mockDurableService.send).toHaveBeenCalledWith(durableAgent.id, 'review this'); expect(mockFocusManager.findTerminal).not.toHaveBeenCalled(); expect(ui.text).toHaveBeenCalledWith('review complete'); }); it('rejects timeout for a synchronous print send instead of silently ignoring it', async () => { - const printAgent = { + const durableAgent = { id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', - mode: 'print', cwd: '/project', state: 'ready', + mode: 'durable', cwd: '/project', state: 'ready', }; - mockPrintStore.resolve.mockResolvedValue(printAgent); + mockDurableRepository.resolve.mockResolvedValue(durableAgent); const program = new Command(); registerAgentCommand(program); await program.parseAsync([ - 'node', 'test', 'agent', 'send', 'review this', '--id', printAgent.id, + 'node', 'test', 'agent', 'send', 'review this', '--id', durableAgent.id, '--wait', '--timeout', '1000', ]); - expect(mockPrintService.send).not.toHaveBeenCalled(); + expect(mockDurableService.send).not.toHaveBeenCalled(); expect(ui.error).toHaveBeenCalledWith(expect.stringContaining('--timeout is not supported')); expect(process.exit).toHaveBeenCalledWith(1); }); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index e1caf5a3..96d2c5df 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -15,7 +15,7 @@ import { OpenCodeAdapter, PiAdapter, ClaudePrintAgentService, - PrintAgentStore, + DurableAgentRepository, AgentStatus, TerminalFocusManager, AgentRegistry, @@ -23,6 +23,7 @@ import { RenameConflictError, TmuxManager, AGENTS, + AGENT_MODES, type StartableAgentType, type AgentInfo, type AgentType, @@ -108,11 +109,6 @@ const TYPE_LABELS: Record = { other: 'Other', }; -const AGENT_MODES = { - INTERACTIVE: 'interactive', - DURABLE: 'durable', -} as const; - function formatType(type: AgentType): string { return TYPE_LABELS[type] ?? type; } @@ -196,8 +192,8 @@ function createAgentManager(): AgentManager { return manager; } -function createPrintAgentService(): ClaudePrintAgentService { - return new ClaudePrintAgentService({ store: new PrintAgentStore() }); +function createDurableAgentService(): ClaudePrintAgentService { + return new ClaudePrintAgentService({ repository: new DurableAgentRepository() }); } const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; @@ -288,7 +284,8 @@ export function registerAgentCommand(program: Command): void { if (!['interactive', 'print'].includes(mode)) { throw new Error(`Unsupported agent mode "${mode}". Supported: interactive, print.`); } - if (mode === 'print' && agentType !== 'claude') { + const internalMode = mode === 'print' ? AGENT_MODES.DURABLE : AGENT_MODES.INTERACTIVE; + if (internalMode === AGENT_MODES.DURABLE && agentType !== 'claude') { throw new Error('Print mode currently supports only --type claude.'); } if (!NAME_REGEX.test(agentName)) { @@ -304,9 +301,9 @@ export function registerAgentCommand(program: Command): void { } try { - if (mode === 'print') { - const entry = await createPrintAgentService().create({ name: agentName, cwd }); - ui.success(`Print agent "${entry.name}" started (${entry.provider}, ID ${entry.id})`); + if (internalMode === AGENT_MODES.DURABLE) { + const entry = await createDurableAgentService().create({ name: agentName, cwd }); + ui.success(`Durable agent "${entry.name}" started (${entry.provider}, ID ${entry.id})`); ui.text(`Working directory: ${formatCwd(entry.cwd)}`); ui.text('State: ready (Claude session not started)'); return; @@ -346,18 +343,18 @@ export function registerAgentCommand(program: Command): void { .action(withErrorHandler('list agents', async (options) => { const manager = createAgentManager(); const agents = await manager.listAgents(); - const printAgents = await createPrintAgentService().store.list(); + const durableAgents = await createDurableAgentService().repository.list(); if (options.json) { const output = [ ...agents.map(agent => ({ ...agent, mode: AGENT_MODES.INTERACTIVE })), - ...printAgents.map(agent => ({ ...agent, mode: AGENT_MODES.DURABLE })), + ...durableAgents.map(agent => ({ ...agent, mode: AGENT_MODES.DURABLE })), ]; console.log(JSON.stringify(output, null, 2)); return; } - if (agents.length === 0 && printAgents.length === 0) { + if (agents.length === 0 && durableAgents.length === 0) { ui.info('No running agents detected.'); return; } @@ -372,7 +369,7 @@ export function registerAgentCommand(program: Command): void { formatStatus(agent.status), formatWorkOn(agent.summary), formatRelativeTime(agent.lastActive), - ]), ...printAgents.map(agent => [ + ]), ...durableAgents.map(agent => [ agent.name, path.basename(agent.cwd), formatType(agent.provider), @@ -627,26 +624,26 @@ export function registerAgentCommand(program: Command): void { return; } - const printService = createPrintAgentService(); - const printResolved = await printService.store.resolve(options.id); - if (Array.isArray(printResolved)) { - throw new Error(`Multiple print agents match "${options.id}".`); + const durableService = createDurableAgentService(); + const durableResolved = await durableService.repository.resolve(options.id); + if (Array.isArray(durableResolved)) { + throw new Error(`Multiple durable agents match "${options.id}".`); } - if (printResolved) { + if (durableResolved) { if (options.timeout !== undefined) { - throw new Error('--timeout is not supported for synchronous print agents.'); + throw new Error('--timeout is not supported for synchronous durable agents.'); } - if (options.id !== printResolved.id) { + if (options.id !== durableResolved.id) { const liveAgents = await manager.listAgents(); const liveExact = liveAgents.filter((agent) => agent.name.toLowerCase() === String(options.id).toLowerCase()); if (liveExact.length > 0) { - throw new Error(`Agent name "${options.id}" is ambiguous across interactive and print modes. Use the print agent ID.`); + throw new Error(`Agent name "${options.id}" is ambiguous across interactive and print modes. Use the durable agent ID.`); } } - const result = await printService.send(options.id, prompt); + const result = await durableService.send(options.id, prompt); if (options.json) { console.log(JSON.stringify({ - target: { id: result.agentId, name: result.agentName, provider: 'claude', mode: 'print' }, + target: { id: result.agentId, name: result.agentName, provider: 'claude', mode: AGENT_MODES.DURABLE }, response: result.result, exitCode: result.exitCode, sessionId: result.sessionId, @@ -717,31 +714,31 @@ export function registerAgentCommand(program: Command): void { .action(withErrorHandler('get agent detail', async (options) => { const manager = createAgentManager(); const agents = await manager.listAgents(); - const printResolved = await createPrintAgentService().store.resolve(options.id); - if (Array.isArray(printResolved)) { - throw new Error(`Multiple print agents match "${options.id}".`); + const durableResolved = await createDurableAgentService().repository.resolve(options.id); + if (Array.isArray(durableResolved)) { + throw new Error(`Multiple durable agents match "${options.id}".`); } - if (printResolved) { + if (durableResolved) { const liveExact = agents.filter((agent) => agent.name.toLowerCase() === String(options.id).toLowerCase()); - if (options.id !== printResolved.id && liveExact.length > 0) { - throw new Error(`Agent name "${options.id}" is ambiguous across interactive and print modes. Use the print agent ID.`); + if (options.id !== durableResolved.id && liveExact.length > 0) { + throw new Error(`Agent name "${options.id}" is ambiguous across interactive and print modes. Use the durable agent ID.`); } if (options.json) { - console.log(JSON.stringify(printResolved, null, 2)); + console.log(JSON.stringify(durableResolved, null, 2)); return; } - ui.text('Print Agent Detail', { breakline: true }); + ui.text('Durable Agent Detail', { breakline: true }); ui.text(chalk.dim('─'.repeat(40))); - ui.text(` ${chalk.bold('Agent ID:')} ${printResolved.id}`); - ui.text(` ${chalk.bold('Session ID:')} ${printResolved.providerSessionId}`); - ui.text(` ${chalk.bold('Name:')} ${printResolved.name}`); + ui.text(` ${chalk.bold('Agent ID:')} ${durableResolved.id}`); + ui.text(` ${chalk.bold('Session ID:')} ${durableResolved.providerSessionId}`); + ui.text(` ${chalk.bold('Name:')} ${durableResolved.name}`); ui.text(` ${chalk.bold('Provider:')} Claude Code`); ui.text(` ${chalk.bold('Mode:')} print`); - ui.text(` ${chalk.bold('CWD:')} ${formatCwd(printResolved.cwd)}`); - ui.text(` ${chalk.bold('State:')} ${printResolved.state}`); - ui.text(` ${chalk.bold('Session:')} ${printResolved.sessionHealth}`); - ui.text(` ${chalk.bold('Last Active:')} ${printResolved.lastActiveAt ? formatRelativeTime(new Date(printResolved.lastActiveAt)) : 'never'}`); - if (printResolved.lastResult) ui.text(` ${chalk.bold('Last Result:')} ${printResolved.lastResult.summary}`); + ui.text(` ${chalk.bold('CWD:')} ${formatCwd(durableResolved.cwd)}`); + ui.text(` ${chalk.bold('State:')} ${durableResolved.state}`); + ui.text(` ${chalk.bold('Session:')} ${durableResolved.sessionHealth}`); + ui.text(` ${chalk.bold('Last Active:')} ${durableResolved.lastActiveAt ? formatRelativeTime(new Date(durableResolved.lastActiveAt)) : 'never'}`); + if (durableResolved.lastResult) ui.text(` ${chalk.bold('Last Result:')} ${durableResolved.lastResult.summary}`); return; }