From b73b61fe29d99ff0860fdc281b0b9e24517bbc61 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 18 Aug 2026 14:40:23 +0000 Subject: [PATCH 01/14] docs(agent): define durable SQLite lifecycle --- ...026-08-18-feature-durable-agents-sqlite.md | 79 +++++++++++++++++++ ...026-08-18-feature-durable-agents-sqlite.md | 36 +++++++++ ...026-08-18-feature-durable-agents-sqlite.md | 52 ++++++++++++ ...026-08-18-feature-durable-agents-sqlite.md | 60 ++++++++++++++ ...026-08-18-feature-durable-agents-sqlite.md | 63 +++++++++++++++ 5 files changed, 290 insertions(+) create mode 100644 docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md create mode 100644 docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md create mode 100644 docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md create mode 100644 docs/ai/requirements/2026-08-18-feature-durable-agents-sqlite.md create mode 100644 docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md 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..ee7908f8 --- /dev/null +++ b/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md @@ -0,0 +1,79 @@ +--- +phase: design +title: Durable Agents SQLite Design +description: SQLite schema, migration, and transactional ownership design +--- + +# Durable Agents SQLite Design + +## Architecture Overview + +```mermaid +flowchart LR + CLI[CLI / runner] --> Service[ClaudePrintAgentService] + Service --> Store[PrintAgentStore adapter] + Store --> DB[(agents.db)] + JSON[print-agents.json] -. first writable open .-> Store + Inspector[LocalProcessInspector] --> Store + Store --> Backup[print-agents.json.migrated-v1.bak] +``` + +`PrintAgentStore` remains the public adapter and owns row mapping, validation, migration import, 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 `print`; 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. + +Migration metadata contains a durable-agent legacy-import marker. It is written in the same `BEGIN IMMEDIATE` transaction as imported rows so import eligibility and imported data cannot diverge. + +## API Design + +- Existing `PrintAgentStore` methods and `StoreLike` structural consumers stay unchanged. +- Options add `dbPath` and retain `filePath` for legacy import and injected-test path compatibility. +- `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 + +### First writable open + +1. Open and migrate `agents.db` through migration 003. +2. Start `BEGIN IMMEDIATE` and check the import marker. +3. If unmarked legacy JSON exists, reject symlinks, parse version 1, validate every agent, and insert every row. +4. Write the marker and commit. On any error, roll back and leave JSON untouched. +5. After commit, rename JSON to `.migrated-v1.bak`. + +### 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. +- No dual-write prevents split-brain state. The retained backup enables explicit export-based rollback. + +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 `PrintAgentBusyError`. +- Symlink-safe cwd binding and legacy-file checks prevent path substitution. +- Schema checks reject inconsistent active-run rows and invalid lifecycle/result values. +- Import and state transitions are atomic and recover cleanly on reopen. 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..c35e69fb --- /dev/null +++ b/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md @@ -0,0 +1,36 @@ +--- +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/print/PrintAgentStore.ts`: unchanged public adapter backed by SQLite. +- `packages/agent-manager/src/__tests__/`: schema, store, migration, concurrency, and integration coverage. + +## Implementation Notes + +Implementation is pending. This document will be updated in lockstep with completed task groups, including files changed, transaction boundaries, error mappings, edge cases, and any design deviations. + +## Integration Points + +`ClaudePrintAgentService`, runners, CLI call sites, `LocalProcessInspector`, cwd canonicalization, and exported print-agent types remain API-compatible. The store shares the agent-manager `DatabaseConnection` and migration sequence. + +## Error Handling + +SQLite constraint/locking/corruption failures will be mapped to existing print-agent domain errors where applicable. Legacy validation failures abort import without a marker, rows, or backup rename. 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. 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..207b1766 --- /dev/null +++ b/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md @@ -0,0 +1,52 @@ +--- +phase: planning +title: Durable Agents SQLite Plan +description: Ordered implementation and validation tasks +--- + +# Durable Agents SQLite Plan + +## Milestones + +- [ ] Foundation: schema migration, connection behavior, and path mapping. +- [ ] Store backend: migration import and transactional CRUD/ownership behavior. +- [ ] Validation: parity, concurrency, recovery, full gates, and review. + +## Task Breakdown + +### Phase 1: Foundation + +- [ ] Add failing schema tests for constraints, case-insensitive uniqueness, indexes, and migration version; implement `003_durable_agents.sql`. Evidence: focused database tests. +- [ ] Add failing readonly-connection tests; make readonly open require an existing migrated database without writes. Evidence: file metadata/schema behavior tests. +- [ ] Add failing JSON-to-database injected-path tests; implement `dbPath` precedence and registry-compatible mapping. Evidence: focused store constructor tests. + +### Phase 2: Store Backend + +- [ ] Retarget identity, name conflict, canonical cwd, listing, result, and session-resume tests to SQLite; replace JSON CRUD with row mapping and transactions. Evidence: `PrintAgentStore` and Claude integration suites. +- [ ] Add migration success, failure, idempotence, symlink rejection, backup, and rollback tests; implement one-time import and marker. Evidence: migration-focused tests and intact source on failure. +- [ ] Retarget busy ownership, token rejection, provider liveness, and interrupted reconciliation tests; implement immediate transactions and token/observed-identity CAS. Evidence: focused ownership tests. +- [ ] Add two-connection race, transaction interruption/reopen, and corrupt-database mapping tests. Evidence: concurrency/recovery tests. +- [ ] 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 + +- [ ] Update implementation/testing docs after each completed group and reconcile this checklist. +- [ ] Run implementation alignment check and close discovered gaps. +- [ ] Run targeted coverage plus full workspace test, lint, typecheck, and build gates. +- [ ] Conduct holistic review, commit conventionally, sync/rebase, push, and open the requested PR. + +## Dependencies and Sequencing + +Schema and readonly connection behavior precede the store rewrite. Row mapping precedes migration import and 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 `PrintAgentStore` conflicts minimally if either PR lands. +- One-way migration: keep the post-commit backup and document export-based rollback. +- 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 + +Requirements, architecture, rollout, and validation scope are fixed by the approved brief. Workspace bootstrap is complete. Implementation begins with failing foundation tests, proceeds through the SQLite adapter and import, then closes with concurrency/recovery validation and full gates. 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..db2d5faf --- /dev/null +++ b/docs/ai/requirements/2026-08-18-feature-durable-agents-sqlite.md @@ -0,0 +1,60 @@ +--- +phase: requirements +title: Durable Agents SQLite Requirements +description: Persist durable print agents in the shared agents.db database +--- + +# Durable Agents SQLite Requirements + +## Problem Statement + +Durable print-agent state currently lives in `~/.ai-devkit/print-agents.json` and relies on hand-rolled filesystem locks, atomic file replacement, and per-agent lock directories. This storage is harder to make transactional and concurrent than the existing SQLite agent registry. Users need durable sessions to survive process exits and concurrent CLI access without being exposed to partial writes or stale lock artifacts. + +## Goals & Objectives + +- Store durable agents in a separate `durable_agents` table in `~/.ai-devkit/agents.db`. +- Preserve the exported `PrintAgentStore` API and all service, runner, and CLI call sites. +- Import a valid legacy version-1 JSON file exactly once on the first writable open. +- 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. +- Dual-writing JSON and SQLite, or supporting automatic rollback to JSON. +- 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 an upgrading user, my valid legacy agents are imported atomically and the JSON file is retained as a clearly named backup. +- 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, malformed migration input, and corrupt databases. + +## Success Criteria + +- Migration `003_durable_agents.sql` creates the specified flattened table, constraints, and indexes and advances `user_version`. +- `PrintAgentStore` accepts `dbPath`; `filePath` remains accepted for one compatibility release and maps test JSON paths to the corresponding `agents.db` path. +- Deprecated lock timing options remain accepted but unused and are documented. +- Import is atomic, marked in SQLite, idempotent, rejects unsafe or invalid JSON without partial data, and renames successful input to `.migrated-v1.bak` only after commit. +- 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. +- Migration is one-way. Rollback requires export; older binaries must not write JSON after migration. +- Open print-provider PRs are coordination risks only; migration numbering is reconciled during final rebase if necessary. + +## Questions & Open Items + +None. Product, schema, migration, concurrency, rollout, compatibility, and validation decisions are binding in the approved feature brief. 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..09ac5742 --- /dev/null +++ b/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md @@ -0,0 +1,63 @@ +--- +phase: testing +title: Durable Agents SQLite Testing Strategy +description: Behavioral parity, migration, 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 + +- [ ] Migration 003 creates the flattened table, constraints, indexes, and expected `user_version`. +- [ ] Names are unique case-insensitively; provider values remain extensible. +- [ ] State, health, result, and active-field consistency constraints reject invalid rows. +- [ ] Readonly open of an already-migrated DB performs no initialization/write and lists successfully. +- [ ] Readonly open without a usable schema throws a clear error. + +## Store Behavioral Parity + +- [ ] Identity creation and provider session identity persist across reopen. +- [ ] Case-insensitive name conflicts map to `PrintAgentNameConflictError`. +- [ ] Cwd is canonical, safe, and protected against symlink rebinding. +- [ ] Busy ownership, stale-token rejection, provider-liveness recovery, and interrupted-run reconciliation match current behavior. +- [ ] Session resume remains covered by `ClaudePrintAgent.integration.test.ts`. +- [ ] Latest result behavior and the 4,096-character summary cap remain intact. + +## Migration and Compatibility + +- [ ] Valid version-1 JSON imports once and is renamed after commit. +- [ ] Malformed, wrong-version, symlinked, or invalid-agent JSON aborts with no partial rows/marker and leaves the source intact. +- [ ] Marker plus absent JSON makes later opens a no-op. +- [ ] Injected JSON `filePath` maps to its test `agents.db`; explicit `dbPath` takes precedence. +- [ ] Deprecated lock options remain accepted but do not create lock artifacts. + +## Concurrency and Recovery + +- [ ] Two connections racing acquisition yield exactly one owner and one busy result. +- [ ] Record-provider and completion reject a stale/lost token. +- [ ] Reconcile CAS cannot overwrite ownership changed after process inspection. +- [ ] Transaction rollback leaves the database reopenable after interruption. +- [ ] Corrupt database errors map to a clear store error. +- [ ] Readonly `list()` does not reconcile or mutate running rows. + +## Full Validation + +- [ ] Focused agent-manager test suite passes. +- [ ] Coverage is reviewed for changed files and gaps are closed or documented. +- [ ] Full workspace test suite passes. +- [ ] Workspace lint passes. +- [ ] Workspace typecheck passes. +- [ ] Workspace build passes. +- [ ] `npx ai-devkit@latest lint --feature durable-agents-sqlite` passes. + +## Test Data and Fixtures + +Tests use isolated temporary directories, real SQLite databases, controlled process-inspector doubles, version-1 JSON fixtures, deliberate malformed/corrupt files, 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 and migration path. From 099de0cca2776285f7943350e120e81e03e0d720 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 18 Aug 2026 14:42:42 +0000 Subject: [PATCH 02/14] feat(agent): add durable agents schema --- .../database/DurableAgentsDatabase.test.ts | 76 +++++++++++++++++++ .../agent-manager/src/database/connection.ts | 24 +++--- .../migrations/003_durable_agents.sql | 48 ++++++++++++ 3 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts create mode 100644 packages/agent-manager/src/database/migrations/003_durable_agents.sql 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..03e4ea0d --- /dev/null +++ b/packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts @@ -0,0 +1,76 @@ +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("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 (?, ?, ?, 'print', '/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/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..4873b96c --- /dev/null +++ b/packages/agent-manager/src/database/migrations/003_durable_agents.sql @@ -0,0 +1,48 @@ +CREATE TABLE durable_agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + provider TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'print', + 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); + +CREATE TABLE durable_agent_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); From 23e5480b0ae261af7fc737a7350935ecde05a58d Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 18 Aug 2026 14:43:23 +0000 Subject: [PATCH 03/14] feat(agent): persist durable agents in SQLite --- .../print/PrintAgentStore.sqlite.test.ts | 172 +++++ .../__tests__/print/PrintAgentStore.test.ts | 39 +- .../src/print/PrintAgentStore.ts | 622 ++++++++---------- 3 files changed, 450 insertions(+), 383 deletions(-) create mode 100644 packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts diff --git a/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts b/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts new file mode 100644 index 00000000..d7d1803b --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts @@ -0,0 +1,172 @@ +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 { PrintAgentStore } from '../../print/PrintAgentStore.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(), 'print-agent-sqlite-')); + roots.push(root); + const cwd = path.join(root, 'project'); + fs.mkdirSync(cwd); + return { + root, + cwd, + filePath: path.join(root, 'state', 'print-agents.json'), + dbPath: path.join(root, 'state', 'agents.db'), + }; +} + +describe('PrintAgentStore SQLite migration', () => { + it('maps an injected JSON path to a sibling db path while explicit dbPath wins', async () => { + const { cwd, filePath, dbPath } = fixture(); + const mapped = new PrintAgentStore({ filePath }); + await mapped.create({ name: 'mapped', cwd }); + expect(fs.existsSync(filePath.replace(/\.json$/, '.db'))).toBe(true); + + const explicit = new PrintAgentStore({ filePath, dbPath }); + await explicit.create({ name: 'explicit', cwd }); + expect(fs.existsSync(dbPath)).toBe(true); + }); + + it('imports legacy JSON once, backs it up after commit, and reopens idempotently', async () => { + const { cwd, filePath } = fixture(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const agent = { + id: crypto.randomUUID(), name: 'legacy', provider: 'claude', mode: 'print', cwd, + providerSessionId: crypto.randomUUID(), state: 'ready', sessionHealth: 'healthy', + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + lastActiveAt: null, lastResult: null, activeRun: null, + }; + fs.writeFileSync(filePath, JSON.stringify({ version: 1, agents: [agent] })); + + const first = new PrintAgentStore({ filePath }); + expect(await first.list()).toEqual([agent]); + expect(fs.existsSync(filePath)).toBe(false); + expect(fs.existsSync(`${filePath}.migrated-v1.bak`)).toBe(true); + const second = new PrintAgentStore({ filePath }); + expect(await second.list()).toEqual([agent]); + }); + + it('rolls back invalid legacy input and leaves the source intact', () => { + const { filePath } = fixture(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: 1, agents: [{ id: 'bad' }] })); + expect(() => new PrintAgentStore({ filePath })).toThrow(/Invalid print-agent store/); + expect(fs.existsSync(filePath)).toBe(true); + }); + + it('rejects a symlinked legacy file without changing its target', () => { + const { root, filePath } = fixture(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const target = path.join(root, 'legacy-target.json'); + fs.writeFileSync(target, JSON.stringify({ version: 1, agents: [] })); + fs.symlinkSync(target, filePath); + expect(() => new PrintAgentStore({ filePath })).toThrow(/symbolic link/i); + expect(fs.existsSync(target)).toBe(true); + }); + + it('rolls back a partially attempted import and can reopen after the source is repaired', async () => { + const { cwd, filePath } = fixture(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const base = { + id: crypto.randomUUID(), provider: 'claude', mode: 'print', cwd, + providerSessionId: crypto.randomUUID(), state: 'ready', sessionHealth: 'healthy', + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + lastActiveAt: null, lastResult: null, activeRun: null, + }; + fs.writeFileSync(filePath, JSON.stringify({ version: 1, agents: [ + { ...base, name: 'duplicate' }, + { ...base, id: crypto.randomUUID(), providerSessionId: crypto.randomUUID(), name: 'DUPLICATE' }, + ] })); + expect(() => new PrintAgentStore({ filePath })).toThrow(/Invalid print-agent store/); + const raw = new Database(filePath.replace(/\.json$/, '.db')); + expect(raw.prepare('SELECT count(*) AS count FROM durable_agents').get()).toEqual({ count: 0 }); + expect(raw.prepare('SELECT count(*) AS count FROM durable_agent_metadata').get()).toEqual({ count: 0 }); + raw.close(); + fs.writeFileSync(filePath, JSON.stringify({ version: 1, agents: [{ ...base, name: 'repaired' }] })); + expect(await new PrintAgentStore({ filePath }).list()).toHaveLength(1); + }); + + 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 PrintAgentStore({ dbPath })).toThrow(/Cannot open print-agent database/); + }); +}); + +describe('PrintAgentStore 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 PrintAgentStore({ dbPath, processInspector }); + const second = new PrintAgentStore({ 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: 'PRINT_AGENT_BUSY' } }); + }); + + 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 PrintAgentStore({ dbPath, processInspector }); + const agent = await writable.create({ name: 'readonly', cwd }); + await writable.acquireRun(agent.id); + live.clear(); + const readonly = new PrintAgentStore({ 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 store = new PrintAgentStore({ dbPath, processInspector }); + const agent = await store.create({ name: 'token', cwd }); + const run = await store.acquireRun(agent.id); + await expect(store.recordProviderProcess(agent.id, 'stale', { pid: 42, startedAt: 'provider' })) + .rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + const completed = await store.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 store = new PrintAgentStore({ dbPath, processInspector }); + const agent = await store.create({ name: 'cas', cwd }); + await store.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 store.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(); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts b/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts index 85b4c8e6..121a353d 100644 --- a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts +++ b/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts @@ -44,7 +44,7 @@ describe('PrintAgentStore create/list/resolve', () => { 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); + expect(fs.existsSync(filePath.replace(/\.json$/, '.db'))).toBe(true); }); it('resolves exact ids and names and rejects duplicate names', async () => { @@ -61,38 +61,13 @@ describe('PrintAgentStore create/list/resolve', () => { }); }); - it('rejects missing cwd, malformed storage, and symlinked store targets', async () => { + it('rejects a missing cwd', async () => { const PrintAgentStore = await loadStore(); - const { root, cwd, filePath } = fixture(); + const { root, 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' }); }); }); @@ -148,9 +123,9 @@ describe('PrintAgentStore run ownership', () => { }); }); - it('reconciles an old incomplete lock to degraded during list', async () => { + it('reconciles an interrupted run to degraded during list', async () => { const PrintAgentStore = await loadStore(); - const { root, cwd, filePath } = fixture(); + const { cwd, filePath } = fixture(); const live = new Map([[process.pid, 'owner-start']]); const store = new PrintAgentStore({ filePath, incompleteLockGraceMs: 10, processInspector: { getIdentity: (pid: number) => { @@ -160,10 +135,6 @@ describe('PrintAgentStore run ownership', () => { } }); 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(); diff --git a/packages/agent-manager/src/print/PrintAgentStore.ts b/packages/agent-manager/src/print/PrintAgentStore.ts index db560aa0..cc71cf04 100644 --- a/packages/agent-manager/src/print/PrintAgentStore.ts +++ b/packages/agent-manager/src/print/PrintAgentStore.ts @@ -3,7 +3,8 @@ 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 { DatabaseConnection, resolveAgentRegistryDbPath } from '../database/index.js'; +import type { PrintActiveRun, PrintAgent, ProcessIdentity, PrintRunStatus, PrintSessionHealth } from './PrintAgent.js'; import { PrintAgentBusyError, PrintAgentNameConflictError, @@ -11,137 +12,98 @@ import { PrintAgentStoreError, } from './PrintAgent.js'; -interface PrintAgentStoreFile { - version: 1; - agents: PrintAgent[]; -} +interface PrintAgentStoreFile { version: 1; agents: PrintAgent[] } -export interface CreatePrintAgentInput { - name: string; - cwd: string; +interface DurableAgentRow { + id: string; name: string; provider: 'claude'; mode: 'print'; cwd: string; provider_session_id: string; + state: PrintAgent['state']; session_health: PrintSessionHealth; created_at: string; updated_at: string; + last_active_at: string | null; last_result_status: PrintRunStatus | 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 CreatePrintAgentInput { name: string; cwd: string } + export interface PrintAgentStoreOptions { + /** Legacy JSON path retained for one compatibility release and one-time import. */ filePath?: string; + 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 ProcessInspector { getIdentity(pid: number): ProcessIdentity | null } export interface PrintRunCompletion { - status: PrintRunStatus; - exitCode: number | null; - summary: string; - sessionHealth: PrintSessionHealth; + status: PrintRunStatus; exitCode: number | null; summary: string; sessionHealth: PrintSessionHealth; } const DEFAULT_FILE = path.join(os.homedir(), '.ai-devkit', 'print-agents.json'); +const IMPORT_MARKER = 'legacy_print_agents_json_v1_imported'; export class PrintAgentStore { readonly filePath: string; - private readonly lockPath: string; - private readonly lockTimeoutMs: number; + readonly dbPath: string; private readonly now: () => Date; private readonly processInspector: ProcessInspector; - private readonly runLocksRoot: string; - private readonly incompleteLockGraceMs: number; - private readonly mutationLockStaleMs: number; + private readonly readonly: boolean; + private readonly db: DatabaseConnection; constructor(options: PrintAgentStoreOptions = {}) { - this.filePath = options.filePath ?? DEFAULT_FILE; - this.lockPath = `${this.filePath}.lock`; - this.lockTimeoutMs = options.lockTimeoutMs ?? 2000; + this.filePath = options.filePath ?? (options.dbPath + ? path.join(path.dirname(options.dbPath), 'print-agents.json') + : DEFAULT_FILE); + this.dbPath = options.dbPath ?? resolveAgentRegistryDbPath( + options.filePath ?? path.join(os.homedir(), '.ai-devkit', 'agents.json'), + ); 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; + this.readonly = options.readonly ?? false; + try { + this.db = new DatabaseConnection({ dbPath: this.dbPath, readonly: this.readonly }); + if (!this.readonly) this.importLegacyJson(); + } catch (error) { + if (error instanceof PrintAgentStoreError) throw error; + throw new PrintAgentStoreError(`Cannot open print-agent database: ${(error as Error).message}`); + } } async create(input: CreatePrintAgentInput): Promise { + this.assertWritable(); 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())) { + 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', 'print', ?, ?, 'ready', 'uninitialized', ?, ?)`, + [id, input.name, cwd, providerSessionId, timestamp, timestamp]); + } catch (error) { + if (/UNIQUE constraint failed: durable_agents\.name/i.test((error as Error).message)) { 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); - }); + throw this.storageError('Failed to create print agent', error); + } + return this.requireById(id); } async list(): Promise { - await this.reconcile(); + if (!this.readonly) 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.', - }, - }; - }); - } + if (!this.readonly) await this.reconcile(); + return this.findById(id); } async resolve(reference: string): Promise { @@ -149,177 +111,202 @@ export class PrintAgentStore { 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; + return matches.length === 0 ? null : 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. - } - } - } - + this.assertWritable(); + const snapshot = this.findById(id); + if (!snapshot) throw new PrintAgentNotFoundError(id); + this.validateBoundCwd(snapshot.cwd); + const observed = snapshot.activeRun; + const observedLive = observed ? this.isActive(observed) : false; + if (observedLive) throw new PrintAgentBusyError(id, snapshot.name); const owner = this.processInspector.getIdentity(process.pid); - if (!owner) { - this.removeLockDirectory(runLock); - throw new PrintAgentStoreError('Cannot determine the current process identity.'); - } + if (!owner) 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); - + let recovered = false; 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 }; + this.immediate(() => { + const current = this.findById(id); + if (!current) throw new PrintAgentNotFoundError(id); + if (current.state === 'running') { + if (!observed || current.activeRun?.token !== observed.token || observedLive) { + throw new PrintAgentBusyError(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 PrintAgentBusyError(id, current.name); + }); } catch (error) { - this.removeOwnedRunLock(id, token); - throw error; + if (error instanceof PrintAgentBusyError || error instanceof PrintAgentNotFoundError) throw error; + if (/busy|locked/i.test((error as Error).message)) throw new PrintAgentBusyError(id, snapshot.name); + throw this.storageError('Failed to acquire print-agent run', error); } + const agent = this.requireById(id); + if (recovered && agent.lastResult?.status !== 'interrupted') { + throw new PrintAgentStoreError('Failed to record interrupted print run.'); + } + return { agent, token }; } 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() }; - }); + 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 PrintAgentStoreError('Print run ownership changed.'); } async completeRun(id: string, token: string, result: PrintRunCompletion): Promise { - this.requireOwnedRun(id, token); + this.assertWritable(); 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; + 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 PrintAgentStoreError('Print run ownership changed.'); + return this.requireById(id); } - 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}`); + 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 validateBoundCwd(bound: string): void { + private importLegacyJson(): void { + if (!fs.existsSync(this.filePath)) return; + this.assertNotSymlink(this.filePath); + const marked = this.db.queryOne('SELECT value FROM durable_agent_metadata WHERE key = ?', [IMPORT_MARKER]); + if (marked) return; + let data: PrintAgentStoreFile; try { - const stat = fs.lstatSync(bound); - if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(bound) !== bound) { - throw new Error('binding changed'); - } + const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as unknown; + if (!this.isStoreFile(parsed) || !parsed.agents.every((agent) => this.isAgent(agent))) throw new Error('invalid schema'); + data = parsed; } catch { - throw new PrintAgentStoreError(`Print agent cwd binding is no longer safe: ${bound}`); + throw new PrintAgentStoreError(`Invalid print-agent store: ${this.filePath}`); } - } - - 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}`); + try { + this.immediate(() => { + for (const agent of data.agents) this.insertAgent(agent); + this.db.execute('INSERT INTO durable_agent_metadata (key, value) VALUES (?, ?)', + [IMPORT_MARKER, this.now().toISOString()]); + }); + } catch (error) { + throw this.storageError(`Invalid print-agent store: ${this.filePath}`, error); + } + try { + fs.renameSync(this.filePath, `${this.filePath}.migrated-v1.bak`); + } catch (error) { + throw this.storageError('Imported print agents but could not preserve the legacy backup', error); } - 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 insertAgent(agent: PrintAgent): void { + this.db.execute(`INSERT INTO durable_agents ( + id, name, provider, mode, cwd, provider_session_id, state, session_health, created_at, updated_at, + last_active_at, last_result_status, last_result_completed_at, last_result_exit_code, last_result_summary, + active_run_token, active_owner_pid, active_owner_started_at, active_provider_pid, + active_provider_started_at, active_run_started_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ + agent.id, agent.name, agent.provider, agent.mode, agent.cwd, agent.providerSessionId, + agent.state, agent.sessionHealth, agent.createdAt, agent.updatedAt, agent.lastActiveAt, + agent.lastResult?.status ?? null, agent.lastResult?.completedAt ?? null, + agent.lastResult?.exitCode ?? null, agent.lastResult?.summary.slice(0, 4096) ?? null, + agent.activeRun?.token ?? null, agent.activeRun?.owner.pid ?? null, + agent.activeRun?.owner.startedAt ?? null, agent.activeRun?.provider?.pid ?? null, + agent.activeRun?.provider?.startedAt ?? null, agent.activeRun?.startedAt ?? null, + ]); } - private assertNotSymlink(target: string): void { + private immediate(operation: () => T): T { + this.db.instance.exec('BEGIN IMMEDIATE'); try { - if (fs.lstatSync(target).isSymbolicLink()) { - throw new PrintAgentStoreError(`Unsafe symbolic link in print-agent storage: ${target}`); - } + const result = operation(); + this.db.instance.exec('COMMIT'); + return result; } catch (error) { - if (error instanceof PrintAgentStoreError) throw error; - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw new PrintAgentStoreError(`Cannot inspect print-agent storage: ${target}`); - } + try { this.db.instance.exec('ROLLBACK'); } catch { /* retain original failure */ } + throw error; } } - private readFile(): PrintAgentStoreFile { - this.ensureSafeParent(); - this.assertNotSymlink(this.filePath); - if (!fs.existsSync(this.filePath)) return { version: 1, agents: [] }; + private listRaw(): PrintAgent[] { 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}`); + 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 print-agent database', error); } } - private listRaw(): PrintAgent[] { - return this.readFile().agents.map((agent) => structuredClone(agent)); + private findById(id: string): PrintAgent | null { + const row = this.db.queryOne('SELECT * FROM durable_agents WHERE id = ?', [id]); + return row ? this.fromRow(row) : null; + } + + private requireById(id: string): PrintAgent { + const agent = this.findById(id); + if (!agent) throw new PrintAgentNotFoundError(id); + return agent; + } + + private fromRow(row: DurableAgentRow): PrintAgent { + const activeRun: PrintActiveRun | 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 isStoreFile(value: unknown): value is PrintAgentStoreFile { @@ -328,119 +315,78 @@ export class PrintAgentStore { 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 isAgent(value: unknown): value is PrintAgent { + if (!value || typeof value !== 'object') return false; + const agent = value as Partial; + const states = ['ready', 'running', 'degraded']; + const health = ['uninitialized', 'healthy', 'unknown', 'mismatch']; + return typeof agent.id === 'string' && typeof agent.name === 'string' && agent.provider === 'claude' + && agent.mode === 'print' && typeof agent.cwd === 'string' && typeof agent.providerSessionId === 'string' + && states.includes(agent.state ?? '') && health.includes(agent.sessionHealth ?? '') + && typeof agent.createdAt === 'string' && typeof agent.updatedAt === 'string' + && (agent.lastActiveAt === null || typeof agent.lastActiveAt === 'string') + && this.isCanonicalDirectory(agent.cwd) + && (agent.state === 'running') === (agent.activeRun !== null && agent.activeRun !== undefined) + && this.validResult(agent.lastResult) && this.validActiveRun(agent.activeRun); } - 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 validResult(value: PrintAgent['lastResult'] | undefined): boolean { + return value === null || (!!value && ['succeeded', 'failed', 'interrupted'].includes(value.status) + && typeof value.completedAt === 'string' && (value.exitCode === null || Number.isInteger(value.exitCode)) + && typeof value.summary === 'string'); } - 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 validActiveRun(value: PrintAgent['activeRun'] | undefined): boolean { + return value === null || (!!value && typeof value.token === 'string' && typeof value.startedAt === 'string' + && this.validIdentity(value.owner) && (value.provider === null || this.validIdentity(value.provider))); } - 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 validIdentity(value: ProcessIdentity | undefined): boolean { + return !!value && Number.isInteger(value.pid) && value.pid > 0 && typeof value.startedAt === 'string'; } - 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 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 readRunLock(id: string): import('./PrintAgent.js').PrintActiveRun | null { - const ownerPath = path.join(this.runLockPath(id), 'owner.json'); + private isCanonicalDirectory(input: string): boolean { 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; + const stat = fs.lstatSync(input); + return stat.isDirectory() && !stat.isSymbolicLink() && fs.realpathSync(input) === input; } catch { - return null; + return false; } } - 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 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 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 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 isActive(metadata: import('./PrintAgent.js').PrintActiveRun): boolean { + private isActive(metadata: PrintActiveRun): boolean { return this.sameProcess(metadata.owner) || (metadata.provider !== null && this.sameProcess(metadata.provider)); } @@ -449,34 +395,13 @@ export class PrintAgentStore { 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 assertWritable(): void { + if (this.readonly) throw new PrintAgentStoreError('Print-agent store is readonly.'); } - 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; - } + private storageError(prefix: string, error: unknown): PrintAgentStoreError { + return error instanceof PrintAgentStoreError ? error + : new PrintAgentStoreError(`${prefix}: ${(error as Error).message}`); } } @@ -489,8 +414,7 @@ export class LocalProcessInspector implements ProcessInspector { const close = stat.lastIndexOf(')'); const fields = stat.slice(close + 2).split(' '); const startTicks = fields[19]; - if (!startTicks) return null; - return { pid, startedAt: `linux:${startTicks}` }; + return startTicks ? { pid, startedAt: `linux:${startTicks}` } : null; } const startedAt = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], From 28922f03e6eb02c60c9ff2703bcd8ec371cf2e97 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 18 Aug 2026 14:44:02 +0000 Subject: [PATCH 04/14] docs(agent): explain durable agent migration --- packages/agent-manager/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/agent-manager/README.md b/packages/agent-manager/README.md index df31ee50..86943598 100644 --- a/packages/agent-manager/README.md +++ b/packages/agent-manager/README.md @@ -38,6 +38,19 @@ tool side effects for that working directory. AI DevKit adds no permission bypas 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. +Durable print-agent state is stored in `~/.ai-devkit/agents.db`. On the first +writable open after upgrading, a valid `~/.ai-devkit/print-agents.json` is +imported once and renamed to `print-agents.json.migrated-v1.bak`. There is no +dual-write: rollback to an older binary requires exporting the SQLite state +before that binary is allowed to write its JSON store again. + +For direct `PrintAgentStore` consumers, `dbPath` selects the SQLite database. +The legacy `filePath` option remains available for one compatibility release as +the JSON import path (and maps injected `.json` test paths to `.db`). 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. ## Documentation From 333ce6ac7bbfde56b7694928dbaae5215ab19c7f Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 18 Aug 2026 14:44:41 +0000 Subject: [PATCH 05/14] docs(agent): record SQLite implementation --- ...026-08-18-feature-durable-agents-sqlite.md | 14 +++++- ...026-08-18-feature-durable-agents-sqlite.md | 26 +++++------ ...026-08-18-feature-durable-agents-sqlite.md | 44 +++++++++---------- 3 files changed, 47 insertions(+), 37 deletions(-) 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 index c35e69fb..e62caba9 100644 --- a/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md @@ -21,7 +21,13 @@ description: Implementation record for the durable-agent persistence backend ## Implementation Notes -Implementation is pending. This document will be updated in lockstep with completed task groups, including files changed, transaction boundaries, error mappings, edge cases, and any design deviations. +- Added `003_durable_agents.sql` with the flattened durable-agent schema, lifecycle/result constraints, active-run consistency checks, import metadata, 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 `PrintAgentStore` with SQLite row mapping and writes. +- Added `dbPath` and readonly store options. `filePath` is retained for one compatibility release and maps injected JSON test paths through the registry path resolver. Legacy timing options remain accepted but unused with TypeScript and README deprecations. +- Implemented one-time version-1 JSON import in `BEGIN IMMEDIATE`; every agent is validated before insertion, marker/data roll back together, and the source is renamed only after commit. +- 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. ## Integration Points @@ -29,8 +35,12 @@ Implementation is pending. This document will be updated in lockstep with comple ## Error Handling -SQLite constraint/locking/corruption failures will be mapped to existing print-agent domain errors where applicable. Legacy validation failures abort import without a marker, rows, or backup rename. Conditional updates changing zero rows represent lost ownership. +SQLite name uniqueness maps to `PrintAgentNameConflictError`; lock contention maps to `PrintAgentBusyError`; open, corruption, validation, and other storage failures map to `PrintAgentStoreError`. Legacy validation failures abort import without a marker, rows, or backup rename. 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, one-way-import, and SQLite-CAS design. No service, runner, CLI, or print-domain rename was introduced. No design deviations are recorded. 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 index 207b1766..a89f7775 100644 --- a/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md @@ -8,30 +8,30 @@ description: Ordered implementation and validation tasks ## Milestones -- [ ] Foundation: schema migration, connection behavior, and path mapping. -- [ ] Store backend: migration import and transactional CRUD/ownership behavior. +- [x] Foundation: schema migration, connection behavior, and path mapping. +- [x] Store backend: migration import and transactional CRUD/ownership behavior. - [ ] Validation: parity, concurrency, recovery, full gates, and review. ## Task Breakdown ### Phase 1: Foundation -- [ ] Add failing schema tests for constraints, case-insensitive uniqueness, indexes, and migration version; implement `003_durable_agents.sql`. Evidence: focused database tests. -- [ ] Add failing readonly-connection tests; make readonly open require an existing migrated database without writes. Evidence: file metadata/schema behavior tests. -- [ ] Add failing JSON-to-database injected-path tests; implement `dbPath` precedence and registry-compatible mapping. Evidence: focused store constructor tests. +- [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] Add failing JSON-to-database injected-path tests; implement `dbPath` precedence and registry-compatible mapping. Evidence: focused store constructor tests. ### Phase 2: Store Backend -- [ ] Retarget identity, name conflict, canonical cwd, listing, result, and session-resume tests to SQLite; replace JSON CRUD with row mapping and transactions. Evidence: `PrintAgentStore` and Claude integration suites. -- [ ] Add migration success, failure, idempotence, symlink rejection, backup, and rollback tests; implement one-time import and marker. Evidence: migration-focused tests and intact source on failure. -- [ ] Retarget busy ownership, token rejection, provider liveness, and interrupted reconciliation tests; implement immediate transactions and token/observed-identity CAS. Evidence: focused ownership tests. -- [ ] Add two-connection race, transaction interruption/reopen, and corrupt-database mapping tests. Evidence: concurrency/recovery tests. -- [ ] Remove global/per-agent lock machinery and obsolete file-mode assertions; document accepted-but-unused options. Evidence: source search and type tests. +- [x] Retarget identity, name conflict, canonical cwd, listing, result, and session-resume tests to SQLite; replace JSON CRUD with row mapping and transactions. Evidence: `PrintAgentStore` and Claude integration suites. +- [x] Add migration success, failure, idempotence, symlink rejection, backup, and rollback tests; implement one-time import and marker. Evidence: migration-focused tests and intact source on failure. +- [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, transaction interruption/reopen, 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 -- [ ] Update implementation/testing docs after each completed group and reconcile this checklist. -- [ ] Run implementation alignment check and close discovered gaps. +- [x] Update implementation/testing docs after each completed group and reconcile this checklist. +- [x] Run implementation alignment check and close discovered gaps. - [ ] Run targeted coverage plus full workspace test, lint, typecheck, and build gates. - [ ] Conduct holistic review, commit conventionally, sync/rebase, push, and open the requested PR. @@ -49,4 +49,4 @@ Schema and readonly connection behavior precede the store rewrite. Row mapping p ## Progress Summary -Requirements, architecture, rollout, and validation scope are fixed by the approved brief. Workspace bootstrap is complete. Implementation begins with failing foundation tests, proceeds through the SQLite adapter and import, then closes with concurrency/recovery validation and full gates. +Foundation and store-backend tasks are complete with focused tests. No scope changes or design deviations were required. Remaining work is the final coverage/full-gate pass, lifecycle review, publication sync, and PR creation. 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 index 09ac5742..9b4f6da6 100644 --- a/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md @@ -12,37 +12,37 @@ Cover all changed persistence and connection branches with focused unit/integrat ## Schema and Connection -- [ ] Migration 003 creates the flattened table, constraints, indexes, and expected `user_version`. -- [ ] Names are unique case-insensitively; provider values remain extensible. -- [ ] State, health, result, and active-field consistency constraints reject invalid rows. -- [ ] Readonly open of an already-migrated DB performs no initialization/write and lists successfully. -- [ ] Readonly open without a usable schema throws a clear error. +- [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 -- [ ] Identity creation and provider session identity persist across reopen. -- [ ] Case-insensitive name conflicts map to `PrintAgentNameConflictError`. -- [ ] Cwd is canonical, safe, and protected against symlink rebinding. -- [ ] Busy ownership, stale-token rejection, provider-liveness recovery, and interrupted-run reconciliation match current behavior. -- [ ] Session resume remains covered by `ClaudePrintAgent.integration.test.ts`. -- [ ] Latest result behavior and the 4,096-character summary cap remain intact. +- [x] Identity creation and provider session identity persist across reopen. +- [x] Case-insensitive name conflicts map to `PrintAgentNameConflictError`. +- [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. ## Migration and Compatibility -- [ ] Valid version-1 JSON imports once and is renamed after commit. -- [ ] Malformed, wrong-version, symlinked, or invalid-agent JSON aborts with no partial rows/marker and leaves the source intact. -- [ ] Marker plus absent JSON makes later opens a no-op. -- [ ] Injected JSON `filePath` maps to its test `agents.db`; explicit `dbPath` takes precedence. -- [ ] Deprecated lock options remain accepted but do not create lock artifacts. +- [x] Valid version-1 JSON imports once and is renamed after commit. +- [x] Malformed, wrong-version, symlinked, or invalid-agent JSON aborts with no partial rows/marker and leaves the source intact. +- [x] Marker plus absent JSON makes later opens a no-op. +- [x] Injected JSON `filePath` maps to its test `agents.db`; explicit `dbPath` takes precedence. +- [x] Deprecated lock options remain accepted but do not create lock artifacts. ## Concurrency and Recovery -- [ ] Two connections racing acquisition yield exactly one owner and one busy result. -- [ ] Record-provider and completion reject a stale/lost token. -- [ ] Reconcile CAS cannot overwrite ownership changed after process inspection. -- [ ] Transaction rollback leaves the database reopenable after interruption. -- [ ] Corrupt database errors map to a clear store error. -- [ ] Readonly `list()` does not reconcile or mutate running rows. +- [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] Transaction rollback leaves the database reopenable after interruption. +- [x] Corrupt database errors map to a clear store error. +- [x] Readonly `list()` does not reconcile or mutate running rows. ## Full Validation From 1c896dc7b4e0f478aa22d61cbd45a879901096e9 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 18 Aug 2026 14:46:42 +0000 Subject: [PATCH 06/14] test(agent): record durable agent validation --- .../2026-08-18-feature-durable-agents-sqlite.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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 index 9b4f6da6..032e4f71 100644 --- a/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md @@ -46,13 +46,15 @@ Cover all changed persistence and connection branches with focused unit/integrat ## Full Validation -- [ ] Focused agent-manager test suite passes. -- [ ] Coverage is reviewed for changed files and gaps are closed or documented. -- [ ] Full workspace test suite passes. -- [ ] Workspace lint passes. -- [ ] Workspace typecheck passes. -- [ ] Workspace build passes. -- [ ] `npx ai-devkit@latest lint --feature durable-agents-sqlite` passes. +- [x] Focused agent-manager test suite passes (26 files, 552 tests). +- [x] Coverage is reviewed: `PrintAgentStore.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 From 15094852800dd1a4a9ed544bb5e2f4d35c9d3619 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 18 Aug 2026 14:48:47 +0000 Subject: [PATCH 07/14] test(agent): cover legacy import failures --- .../print/PrintAgentStore.sqlite.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts b/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts index d7d1803b..92fd9a9a 100644 --- a/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts +++ b/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts @@ -62,6 +62,17 @@ describe('PrintAgentStore SQLite migration', () => { expect(fs.existsSync(filePath)).toBe(true); }); + it.each([ + ['malformed JSON', '{bad json'], + ['an unsupported version', JSON.stringify({ version: 2, agents: [] })], + ])('rejects %s before writing an import marker', (_label, contents) => { + const { filePath } = fixture(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); + expect(() => new PrintAgentStore({ filePath })).toThrow(/Invalid print-agent store/); + expect(fs.existsSync(filePath)).toBe(true); + }); + it('rejects a symlinked legacy file without changing its target', () => { const { root, filePath } = fixture(); fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -116,6 +127,16 @@ describe('PrintAgentStore SQLite concurrency', () => { expect(rejected).toMatchObject({ reason: { code: 'PRINT_AGENT_BUSY' } }); }); + it('accepts deprecated lock options without creating lock artifacts', async () => { + const { root, cwd, filePath } = fixture(); + const store = new PrintAgentStore({ + filePath, lockTimeoutMs: 1, incompleteLockGraceMs: 1, mutationLockStaleMs: 1, + }); + await store.create({ name: 'lockless', cwd }); + expect(fs.existsSync(`${filePath}.lock`)).toBe(false); + expect(fs.existsSync(path.join(root, 'state', 'print-agent-locks'))).toBe(false); + }); + it('keeps readonly listing pure', async () => { const { cwd, dbPath } = fixture(); const live = new Map([[process.pid, 'owner-start']]); From 9b09e652e013db8ed7891529243d8b88f45a591d Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 18 Aug 2026 14:49:58 +0000 Subject: [PATCH 08/14] docs(agent): complete durable agent review --- .../planning/2026-08-18-feature-durable-agents-sqlite.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index a89f7775..f299bb82 100644 --- a/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md @@ -10,7 +10,7 @@ description: Ordered implementation and validation tasks - [x] Foundation: schema migration, connection behavior, and path mapping. - [x] Store backend: migration import and transactional CRUD/ownership behavior. -- [ ] Validation: parity, concurrency, recovery, full gates, and review. +- [x] Validation: parity, concurrency, recovery, full gates, and review. ## Task Breakdown @@ -32,8 +32,8 @@ description: Ordered implementation and validation tasks - [x] Update implementation/testing docs after each completed group and reconcile this checklist. - [x] Run implementation alignment check and close discovered gaps. -- [ ] Run targeted coverage plus full workspace test, lint, typecheck, and build gates. -- [ ] Conduct holistic review, commit conventionally, sync/rebase, push, and open the requested PR. +- [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 @@ -49,4 +49,4 @@ Schema and readonly connection behavior precede the store rewrite. Row mapping p ## Progress Summary -Foundation and store-backend tasks are complete with focused tests. No scope changes or design deviations were required. Remaining work is the final coverage/full-gate pass, lifecycle review, publication sync, and PR creation. +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. From 5bc0ead288d0c106c84c1a5e4ad7188c86d70d26 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 18 Aug 2026 15:15:29 +0000 Subject: [PATCH 09/14] refactor(agent): drop unused legacy import for durable agents --- .../ClaudePrintAgent.integration.test.ts | 2 +- .../print/PrintAgentStore.sqlite.test.ts | 110 ++-------------- .../__tests__/print/PrintAgentStore.test.ts | 34 ++--- .../migrations/003_durable_agents.sql | 5 - .../src/print/PrintAgentStore.ts | 124 +----------------- 5 files changed, 31 insertions(+), 244 deletions(-) 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..5855f474 100644 --- a/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts @@ -28,7 +28,7 @@ describe('Claude print-agent fake-provider journey', () => { 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 store = new PrintAgentStore({ dbPath: path.join(root, 'state', 'agents.db') }); const service = new ClaudePrintAgentService({ store, probe: new ClaudeCliProbe({ executable }), diff --git a/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts b/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts index 92fd9a9a..2e1d94b9 100644 --- a/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts +++ b/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts @@ -15,104 +15,9 @@ function fixture() { roots.push(root); const cwd = path.join(root, 'project'); fs.mkdirSync(cwd); - return { - root, - cwd, - filePath: path.join(root, 'state', 'print-agents.json'), - dbPath: path.join(root, 'state', 'agents.db'), - }; + return { root, cwd, dbPath: path.join(root, 'state', 'agents.db') }; } -describe('PrintAgentStore SQLite migration', () => { - it('maps an injected JSON path to a sibling db path while explicit dbPath wins', async () => { - const { cwd, filePath, dbPath } = fixture(); - const mapped = new PrintAgentStore({ filePath }); - await mapped.create({ name: 'mapped', cwd }); - expect(fs.existsSync(filePath.replace(/\.json$/, '.db'))).toBe(true); - - const explicit = new PrintAgentStore({ filePath, dbPath }); - await explicit.create({ name: 'explicit', cwd }); - expect(fs.existsSync(dbPath)).toBe(true); - }); - - it('imports legacy JSON once, backs it up after commit, and reopens idempotently', async () => { - const { cwd, filePath } = fixture(); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const agent = { - id: crypto.randomUUID(), name: 'legacy', provider: 'claude', mode: 'print', cwd, - providerSessionId: crypto.randomUUID(), state: 'ready', sessionHealth: 'healthy', - createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', - lastActiveAt: null, lastResult: null, activeRun: null, - }; - fs.writeFileSync(filePath, JSON.stringify({ version: 1, agents: [agent] })); - - const first = new PrintAgentStore({ filePath }); - expect(await first.list()).toEqual([agent]); - expect(fs.existsSync(filePath)).toBe(false); - expect(fs.existsSync(`${filePath}.migrated-v1.bak`)).toBe(true); - const second = new PrintAgentStore({ filePath }); - expect(await second.list()).toEqual([agent]); - }); - - it('rolls back invalid legacy input and leaves the source intact', () => { - const { filePath } = fixture(); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify({ version: 1, agents: [{ id: 'bad' }] })); - expect(() => new PrintAgentStore({ filePath })).toThrow(/Invalid print-agent store/); - expect(fs.existsSync(filePath)).toBe(true); - }); - - it.each([ - ['malformed JSON', '{bad json'], - ['an unsupported version', JSON.stringify({ version: 2, agents: [] })], - ])('rejects %s before writing an import marker', (_label, contents) => { - const { filePath } = fixture(); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, contents); - expect(() => new PrintAgentStore({ filePath })).toThrow(/Invalid print-agent store/); - expect(fs.existsSync(filePath)).toBe(true); - }); - - it('rejects a symlinked legacy file without changing its target', () => { - const { root, filePath } = fixture(); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const target = path.join(root, 'legacy-target.json'); - fs.writeFileSync(target, JSON.stringify({ version: 1, agents: [] })); - fs.symlinkSync(target, filePath); - expect(() => new PrintAgentStore({ filePath })).toThrow(/symbolic link/i); - expect(fs.existsSync(target)).toBe(true); - }); - - it('rolls back a partially attempted import and can reopen after the source is repaired', async () => { - const { cwd, filePath } = fixture(); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const base = { - id: crypto.randomUUID(), provider: 'claude', mode: 'print', cwd, - providerSessionId: crypto.randomUUID(), state: 'ready', sessionHealth: 'healthy', - createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', - lastActiveAt: null, lastResult: null, activeRun: null, - }; - fs.writeFileSync(filePath, JSON.stringify({ version: 1, agents: [ - { ...base, name: 'duplicate' }, - { ...base, id: crypto.randomUUID(), providerSessionId: crypto.randomUUID(), name: 'DUPLICATE' }, - ] })); - expect(() => new PrintAgentStore({ filePath })).toThrow(/Invalid print-agent store/); - const raw = new Database(filePath.replace(/\.json$/, '.db')); - expect(raw.prepare('SELECT count(*) AS count FROM durable_agents').get()).toEqual({ count: 0 }); - expect(raw.prepare('SELECT count(*) AS count FROM durable_agent_metadata').get()).toEqual({ count: 0 }); - raw.close(); - fs.writeFileSync(filePath, JSON.stringify({ version: 1, agents: [{ ...base, name: 'repaired' }] })); - expect(await new PrintAgentStore({ filePath }).list()).toHaveLength(1); - }); - - 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 PrintAgentStore({ dbPath })).toThrow(/Cannot open print-agent database/); - }); -}); - describe('PrintAgentStore SQLite concurrency', () => { it('allows exactly one acquisition across two connections', async () => { const { cwd, dbPath } = fixture(); @@ -128,12 +33,12 @@ describe('PrintAgentStore SQLite concurrency', () => { }); it('accepts deprecated lock options without creating lock artifacts', async () => { - const { root, cwd, filePath } = fixture(); + const { root, cwd, dbPath } = fixture(); const store = new PrintAgentStore({ - filePath, lockTimeoutMs: 1, incompleteLockGraceMs: 1, mutationLockStaleMs: 1, + dbPath, lockTimeoutMs: 1, incompleteLockGraceMs: 1, mutationLockStaleMs: 1, }); await store.create({ name: 'lockless', cwd }); - expect(fs.existsSync(`${filePath}.lock`)).toBe(false); + expect(fs.existsSync(`${dbPath}.lock`)).toBe(false); expect(fs.existsSync(path.join(root, 'state', 'print-agent-locks'))).toBe(false); }); @@ -190,4 +95,11 @@ describe('PrintAgentStore SQLite concurrency', () => { .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 PrintAgentStore({ dbPath })).toThrow(/Cannot open print-agent database/); + }); }); diff --git a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts b/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts index 121a353d..ea05787d 100644 --- a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts +++ b/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts @@ -15,19 +15,19 @@ async function loadStore(): Promise { return api.PrintAgentStore; } -function fixture(): { root: string; cwd: string; filePath: string } { +function fixture(): { root: string; cwd: string; dbPath: 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') }; + return { root, cwd, dbPath: path.join(root, 'state', 'agents.db') }; } 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 { cwd, dbPath } = fixture(); + const store = new PrintAgentStore({ dbPath, now: () => new Date('2026-08-07T09:00:00Z') }); const agent = await store.create({ name: 'reviewer', cwd }); @@ -44,13 +44,13 @@ describe('PrintAgentStore create/list/resolve', () => { expect(agent.providerSessionId).toMatch(/^[0-9a-f-]{36}$/); expect(agent.id).not.toBe(agent.providerSessionId); expect(await store.list()).toEqual([agent]); - expect(fs.existsSync(filePath.replace(/\.json$/, '.db'))).toBe(true); + expect(fs.existsSync(dbPath)).toBe(true); }); 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 { cwd, dbPath } = fixture(); + const store = new PrintAgentStore({ dbPath }); const agent = await store.create({ name: 'Reviewer', cwd }); expect(await store.resolve(agent.id)).toMatchObject({ id: agent.id }); @@ -63,8 +63,8 @@ describe('PrintAgentStore create/list/resolve', () => { it('rejects a missing cwd', async () => { const PrintAgentStore = await loadStore(); - const { root, filePath } = fixture(); - const store = new PrintAgentStore({ filePath }); + const { root, dbPath } = fixture(); + const store = new PrintAgentStore({ dbPath }); await expect(store.create({ name: 'missing', cwd: path.join(root, 'missing') })) .rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); @@ -74,13 +74,13 @@ describe('PrintAgentStore create/list/resolve', () => { 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 { 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 store = new PrintAgentStore({ filePath, processInspector }); + const store = new PrintAgentStore({ dbPath, processInspector }); const agent = await store.create({ name: 'runner', cwd }); const acquired = await store.acquireRun(agent.id); @@ -97,13 +97,13 @@ describe('PrintAgentStore run ownership', () => { 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 { 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 PrintAgentStore({ filePath, processInspector }); + const first = new PrintAgentStore({ 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' }); @@ -125,9 +125,9 @@ describe('PrintAgentStore run ownership', () => { it('reconciles an interrupted run to degraded during list', async () => { const PrintAgentStore = await loadStore(); - const { cwd, filePath } = fixture(); + const { cwd, dbPath } = fixture(); const live = new Map([[process.pid, 'owner-start']]); - const store = new PrintAgentStore({ filePath, incompleteLockGraceMs: 10, processInspector: { + const store = new PrintAgentStore({ dbPath, incompleteLockGraceMs: 10, processInspector: { getIdentity: (pid: number) => { const startedAt = live.get(pid); return startedAt ? { pid, startedAt } : null; @@ -149,8 +149,8 @@ describe('PrintAgentStore run ownership', () => { 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 { root, cwd, dbPath } = fixture(); + const store = new PrintAgentStore({ dbPath }); const agent = await store.create({ name: 'bound', cwd }); const moved = path.join(root, 'moved-project'); const other = path.join(root, 'other-project'); diff --git a/packages/agent-manager/src/database/migrations/003_durable_agents.sql b/packages/agent-manager/src/database/migrations/003_durable_agents.sql index 4873b96c..44960509 100644 --- a/packages/agent-manager/src/database/migrations/003_durable_agents.sql +++ b/packages/agent-manager/src/database/migrations/003_durable_agents.sql @@ -41,8 +41,3 @@ CREATE TABLE durable_agents ( 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); - -CREATE TABLE durable_agent_metadata ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); diff --git a/packages/agent-manager/src/print/PrintAgentStore.ts b/packages/agent-manager/src/print/PrintAgentStore.ts index cc71cf04..146b76fb 100644 --- a/packages/agent-manager/src/print/PrintAgentStore.ts +++ b/packages/agent-manager/src/print/PrintAgentStore.ts @@ -1,9 +1,7 @@ import fs from 'fs'; -import os from 'os'; -import path from 'path'; import { randomUUID } from 'crypto'; import { execFileSync } from 'child_process'; -import { DatabaseConnection, resolveAgentRegistryDbPath } from '../database/index.js'; +import { DatabaseConnection, DEFAULT_AGENT_REGISTRY_DB_PATH } from '../database/index.js'; import type { PrintActiveRun, PrintAgent, ProcessIdentity, PrintRunStatus, PrintSessionHealth } from './PrintAgent.js'; import { PrintAgentBusyError, @@ -12,8 +10,6 @@ import { PrintAgentStoreError, } from './PrintAgent.js'; -interface PrintAgentStoreFile { version: 1; agents: PrintAgent[] } - interface DurableAgentRow { id: string; name: string; provider: 'claude'; mode: 'print'; cwd: string; provider_session_id: string; state: PrintAgent['state']; session_health: PrintSessionHealth; created_at: string; updated_at: string; @@ -26,8 +22,6 @@ interface DurableAgentRow { export interface CreatePrintAgentInput { name: string; cwd: string } export interface PrintAgentStoreOptions { - /** Legacy JSON path retained for one compatibility release and one-time import. */ - filePath?: string; dbPath?: string; readonly?: boolean; /** @deprecated SQLite busy_timeout replaces filesystem lock polling. */ @@ -45,11 +39,7 @@ export interface PrintRunCompletion { status: PrintRunStatus; exitCode: number | null; summary: string; sessionHealth: PrintSessionHealth; } -const DEFAULT_FILE = path.join(os.homedir(), '.ai-devkit', 'print-agents.json'); -const IMPORT_MARKER = 'legacy_print_agents_json_v1_imported'; - export class PrintAgentStore { - readonly filePath: string; readonly dbPath: string; private readonly now: () => Date; private readonly processInspector: ProcessInspector; @@ -57,18 +47,12 @@ export class PrintAgentStore { private readonly db: DatabaseConnection; constructor(options: PrintAgentStoreOptions = {}) { - this.filePath = options.filePath ?? (options.dbPath - ? path.join(path.dirname(options.dbPath), 'print-agents.json') - : DEFAULT_FILE); - this.dbPath = options.dbPath ?? resolveAgentRegistryDbPath( - options.filePath ?? path.join(os.homedir(), '.ai-devkit', 'agents.json'), - ); + 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 }); - if (!this.readonly) this.importLegacyJson(); } catch (error) { if (error instanceof PrintAgentStoreError) throw error; throw new PrintAgentStoreError(`Cannot open print-agent database: ${(error as Error).message}`); @@ -209,52 +193,6 @@ export class PrintAgentStore { } } - private importLegacyJson(): void { - if (!fs.existsSync(this.filePath)) return; - this.assertNotSymlink(this.filePath); - const marked = this.db.queryOne('SELECT value FROM durable_agent_metadata WHERE key = ?', [IMPORT_MARKER]); - if (marked) return; - let data: PrintAgentStoreFile; - try { - const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as unknown; - if (!this.isStoreFile(parsed) || !parsed.agents.every((agent) => this.isAgent(agent))) throw new Error('invalid schema'); - data = parsed; - } catch { - throw new PrintAgentStoreError(`Invalid print-agent store: ${this.filePath}`); - } - try { - this.immediate(() => { - for (const agent of data.agents) this.insertAgent(agent); - this.db.execute('INSERT INTO durable_agent_metadata (key, value) VALUES (?, ?)', - [IMPORT_MARKER, this.now().toISOString()]); - }); - } catch (error) { - throw this.storageError(`Invalid print-agent store: ${this.filePath}`, error); - } - try { - fs.renameSync(this.filePath, `${this.filePath}.migrated-v1.bak`); - } catch (error) { - throw this.storageError('Imported print agents but could not preserve the legacy backup', error); - } - } - - private insertAgent(agent: PrintAgent): void { - this.db.execute(`INSERT INTO durable_agents ( - id, name, provider, mode, cwd, provider_session_id, state, session_health, created_at, updated_at, - last_active_at, last_result_status, last_result_completed_at, last_result_exit_code, last_result_summary, - active_run_token, active_owner_pid, active_owner_started_at, active_provider_pid, - active_provider_started_at, active_run_started_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ - agent.id, agent.name, agent.provider, agent.mode, agent.cwd, agent.providerSessionId, - agent.state, agent.sessionHealth, agent.createdAt, agent.updatedAt, agent.lastActiveAt, - agent.lastResult?.status ?? null, agent.lastResult?.completedAt ?? null, - agent.lastResult?.exitCode ?? null, agent.lastResult?.summary.slice(0, 4096) ?? null, - agent.activeRun?.token ?? null, agent.activeRun?.owner.pid ?? null, - agent.activeRun?.owner.startedAt ?? null, agent.activeRun?.provider?.pid ?? null, - agent.activeRun?.provider?.startedAt ?? null, agent.activeRun?.startedAt ?? null, - ]); - } - private immediate(operation: () => T): T { this.db.instance.exec('BEGIN IMMEDIATE'); try { @@ -309,42 +247,6 @@ export class PrintAgentStore { }; } - 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 isAgent(value: unknown): value is PrintAgent { - if (!value || typeof value !== 'object') return false; - const agent = value as Partial; - const states = ['ready', 'running', 'degraded']; - const health = ['uninitialized', 'healthy', 'unknown', 'mismatch']; - return typeof agent.id === 'string' && typeof agent.name === 'string' && agent.provider === 'claude' - && agent.mode === 'print' && typeof agent.cwd === 'string' && typeof agent.providerSessionId === 'string' - && states.includes(agent.state ?? '') && health.includes(agent.sessionHealth ?? '') - && typeof agent.createdAt === 'string' && typeof agent.updatedAt === 'string' - && (agent.lastActiveAt === null || typeof agent.lastActiveAt === 'string') - && this.isCanonicalDirectory(agent.cwd) - && (agent.state === 'running') === (agent.activeRun !== null && agent.activeRun !== undefined) - && this.validResult(agent.lastResult) && this.validActiveRun(agent.activeRun); - } - - private validResult(value: PrintAgent['lastResult'] | undefined): boolean { - return value === null || (!!value && ['succeeded', 'failed', 'interrupted'].includes(value.status) - && typeof value.completedAt === 'string' && (value.exitCode === null || Number.isInteger(value.exitCode)) - && typeof value.summary === 'string'); - } - - private validActiveRun(value: PrintAgent['activeRun'] | undefined): boolean { - return value === null || (!!value && typeof value.token === 'string' && typeof value.startedAt === 'string' - && this.validIdentity(value.owner) && (value.provider === null || this.validIdentity(value.provider))); - } - - private validIdentity(value: ProcessIdentity | undefined): boolean { - return !!value && Number.isInteger(value.pid) && value.pid > 0 && typeof value.startedAt === 'string'; - } - private canonicalDirectory(input: string): string { try { const resolved = fs.realpathSync(input); @@ -355,15 +257,6 @@ export class PrintAgentStore { } } - private isCanonicalDirectory(input: string): boolean { - try { - const stat = fs.lstatSync(input); - return stat.isDirectory() && !stat.isSymbolicLink() && fs.realpathSync(input) === input; - } catch { - return false; - } - } - private validateBoundCwd(bound: string): void { try { const stat = fs.lstatSync(bound); @@ -373,19 +266,6 @@ export class PrintAgentStore { } } - 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 isActive(metadata: PrintActiveRun): boolean { return this.sameProcess(metadata.owner) || (metadata.provider !== null && this.sameProcess(metadata.provider)); } From e58910705ea52ee0ef26ee81879af89bc4bec026 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 18 Aug 2026 15:16:42 +0000 Subject: [PATCH 10/14] docs(agent): remove unreleased migration guidance --- ...026-08-18-feature-durable-agents-sqlite.md | 24 +++++-------------- ...026-08-18-feature-durable-agents-sqlite.md | 11 ++++----- ...026-08-18-feature-durable-agents-sqlite.md | 12 ++++------ ...026-08-18-feature-durable-agents-sqlite.md | 14 ++++------- ...026-08-18-feature-durable-agents-sqlite.md | 15 +++++------- packages/agent-manager/README.md | 13 ++++------ 6 files changed, 31 insertions(+), 58 deletions(-) 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 index ee7908f8..f08a7738 100644 --- a/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md @@ -1,7 +1,7 @@ --- phase: design title: Durable Agents SQLite Design -description: SQLite schema, migration, and transactional ownership design +description: SQLite schema and transactional ownership design --- # Durable Agents SQLite Design @@ -13,12 +13,10 @@ flowchart LR CLI[CLI / runner] --> Service[ClaudePrintAgentService] Service --> Store[PrintAgentStore adapter] Store --> DB[(agents.db)] - JSON[print-agents.json] -. first writable open .-> Store Inspector[LocalProcessInspector] --> Store - Store --> Backup[print-agents.json.migrated-v1.bak] ``` -`PrintAgentStore` remains the public adapter and owns row mapping, validation, migration import, 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. +`PrintAgentStore` 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 @@ -31,25 +29,15 @@ flowchart LR - 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. -Migration metadata contains a durable-agent legacy-import marker. It is written in the same `BEGIN IMMEDIATE` transaction as imported rows so import eligibility and imported data cannot diverge. - ## API Design - Existing `PrintAgentStore` methods and `StoreLike` structural consumers stay unchanged. -- Options add `dbPath` and retain `filePath` for legacy import and injected-test path compatibility. +- 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 -### First writable open - -1. Open and migrate `agents.db` through migration 003. -2. Start `BEGIN IMMEDIATE` and check the import marker. -3. If unmarked legacy JSON exists, reject symlinks, parse version 1, validate every agent, and insert every row. -4. Write the marker and commit. On any error, roll back and leave JSON untouched. -5. After commit, rename JSON to `.migrated-v1.bak`. - ### 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. @@ -66,7 +54,7 @@ Readonly construction requires an existing migrated database and skips directory - 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. -- No dual-write prevents split-brain state. The retained backup enables explicit export-based rollback. +- Durable agents persist directly in SQLite; `print-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. @@ -74,6 +62,6 @@ Rejected alternatives are merging into `agents`, storing a whole JSON document i - Transactions remain short; filesystem checks and process inspection occur outside them. - WAL plus a 5-second busy timeout handle contention; acquisition contention maps to `PrintAgentBusyError`. -- Symlink-safe cwd binding and legacy-file checks prevent path substitution. +- Symlink-safe cwd binding prevents path substitution. - Schema checks reject inconsistent active-run rows and invalid lifecycle/result values. -- Import and state transitions are atomic and recover cleanly on reopen. +- State transitions are atomic and recover cleanly on reopen. 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 index e62caba9..79309c7c 100644 --- a/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md @@ -17,15 +17,14 @@ description: Implementation record for the durable-agent persistence backend - `packages/agent-manager/src/database/`: connection behavior, schema runner, and migration SQL. - `packages/agent-manager/src/print/PrintAgentStore.ts`: unchanged public adapter backed by SQLite. -- `packages/agent-manager/src/__tests__/`: schema, store, migration, concurrency, and integration coverage. +- `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, import metadata, and list/state indexes. +- 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 `PrintAgentStore` with SQLite row mapping and writes. -- Added `dbPath` and readonly store options. `filePath` is retained for one compatibility release and maps injected JSON test paths through the registry path resolver. Legacy timing options remain accepted but unused with TypeScript and README deprecations. -- Implemented one-time version-1 JSON import in `BEGIN IMMEDIATE`; every agent is validated before insertion, marker/data roll back together, and the source is renamed only after commit. +- 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. @@ -35,7 +34,7 @@ description: Implementation record for the durable-agent persistence backend ## Error Handling -SQLite name uniqueness maps to `PrintAgentNameConflictError`; lock contention maps to `PrintAgentBusyError`; open, corruption, validation, and other storage failures map to `PrintAgentStoreError`. Legacy validation failures abort import without a marker, rows, or backup rename. Conditional updates changing zero rows represent lost ownership. +SQLite name uniqueness maps to `PrintAgentNameConflictError`; lock contention maps to `PrintAgentBusyError`; open, corruption, validation, and other storage failures map to `PrintAgentStoreError`. Conditional updates changing zero rows represent lost ownership. ## Performance and Security @@ -43,4 +42,4 @@ Writes use short immediate transactions and indexed lookups. Process/filesystem ## Design Alignment -The implementation follows the approved separate-table, flattened-latest-result, unchanged-adapter, one-way-import, and SQLite-CAS design. No service, runner, CLI, or print-domain rename was introduced. No design deviations are recorded. +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-18-feature-durable-agents-sqlite.md b/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md index f299bb82..45174e9e 100644 --- a/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md @@ -8,8 +8,8 @@ description: Ordered implementation and validation tasks ## Milestones -- [x] Foundation: schema migration, connection behavior, and path mapping. -- [x] Store backend: migration import and transactional CRUD/ownership behavior. +- [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 @@ -18,14 +18,13 @@ description: Ordered implementation and validation tasks - [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] Add failing JSON-to-database injected-path tests; implement `dbPath` precedence and registry-compatible mapping. Evidence: focused store constructor 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: `PrintAgentStore` and Claude integration suites. -- [x] Add migration success, failure, idempotence, symlink rejection, backup, and rollback tests; implement one-time import and marker. Evidence: migration-focused tests and intact source on failure. - [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, transaction interruption/reopen, and corrupt-database mapping tests. Evidence: concurrency/recovery 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 @@ -37,13 +36,12 @@ description: Ordered implementation and validation tasks ## Dependencies and Sequencing -Schema and readonly connection behavior precede the store rewrite. Row mapping precedes migration import and 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. +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 `PrintAgentStore` conflicts minimally if either PR lands. -- One-way migration: keep the post-commit backup and document export-based rollback. - PID reuse: include process start time in stale-observation CAS predicates. - Long write locks: keep process inspection and filesystem validation outside immediate transactions. 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 index db2d5faf..40b3aa6b 100644 --- a/docs/ai/requirements/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/requirements/2026-08-18-feature-durable-agents-sqlite.md @@ -8,13 +8,12 @@ description: Persist durable print agents in the shared agents.db database ## Problem Statement -Durable print-agent state currently lives in `~/.ai-devkit/print-agents.json` and relies on hand-rolled filesystem locks, atomic file replacement, and per-agent lock directories. This storage is harder to make transactional and concurrent than the existing SQLite agent registry. Users need durable sessions to survive process exits and concurrent CLI access without being exposed to partial writes or stale lock artifacts. +Durable print-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 `PrintAgentStore` API and all service, runner, and CLI call sites. -- Import a valid legacy version-1 JSON file exactly once on the first writable open. - 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. @@ -24,23 +23,21 @@ Durable print-agent state currently lives in `~/.ai-devkit/print-agents.json` an - 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. -- Dual-writing JSON and SQLite, or supporting automatic rollback to JSON. +- Supporting legacy JSON import or dual-write; `print-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 an upgrading user, my valid legacy agents are imported atomically and the JSON file is retained as a clearly named backup. - 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, malformed migration input, and corrupt databases. +- 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`. -- `PrintAgentStore` accepts `dbPath`; `filePath` remains accepted for one compatibility release and maps test JSON paths to the corresponding `agents.db` path. +- `PrintAgentStore` accepts `dbPath` and defaults directly to `~/.ai-devkit/agents.db`. - Deprecated lock timing options remain accepted but unused and are documented. -- Import is atomic, marked in SQLite, idempotent, rejects unsafe or invalid JSON without partial data, and renames successful input to `.migrated-v1.bak` only after commit. - 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. @@ -52,9 +49,8 @@ Durable print-agent state currently lives in `~/.ai-devkit/print-agents.json` an - 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. -- Migration is one-way. Rollback requires export; older binaries must not write JSON after migration. - Open print-provider PRs are coordination risks only; migration numbering is reconciled during final rebase if necessary. ## Questions & Open Items -None. Product, schema, migration, concurrency, rollout, compatibility, and validation decisions are binding in the approved feature brief. +None. Product, schema, concurrency, rollout, and validation decisions reflect the approved unreleased-feature scope. 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 index 032e4f71..b9c4bb99 100644 --- a/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md @@ -1,7 +1,7 @@ --- phase: testing title: Durable Agents SQLite Testing Strategy -description: Behavioral parity, migration, concurrency, and recovery validation +description: Behavioral parity, concurrency, and recovery validation --- # Durable Agents SQLite Testing Strategy @@ -27,12 +27,10 @@ Cover all changed persistence and connection branches with focused unit/integrat - [x] Session resume remains covered by `ClaudePrintAgent.integration.test.ts`. - [x] Latest result behavior and the 4,096-character summary cap remain intact. -## Migration and Compatibility +## Storage Compatibility -- [x] Valid version-1 JSON imports once and is renamed after commit. -- [x] Malformed, wrong-version, symlinked, or invalid-agent JSON aborts with no partial rows/marker and leaves the source intact. -- [x] Marker plus absent JSON makes later opens a no-op. -- [x] Injected JSON `filePath` maps to its test `agents.db`; explicit `dbPath` takes precedence. +- [x] Store and integration tests use explicit isolated `dbPath` values. +- [x] No `print-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 @@ -40,7 +38,6 @@ Cover all changed persistence and connection branches with focused unit/integrat - [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] Transaction rollback leaves the database reopenable after interruption. - [x] Corrupt database errors map to a clear store error. - [x] Readonly `list()` does not reconcile or mutate running rows. @@ -58,8 +55,8 @@ Fresh evidence was collected on 2026-08-18 with `npm run test:coverage --workspa ## Test Data and Fixtures -Tests use isolated temporary directories, real SQLite databases, controlled process-inspector doubles, version-1 JSON fixtures, deliberate malformed/corrupt files, and independent store/connection instances for races. No user home state is read or modified. +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 and migration path. +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 86943598..487d68c2 100644 --- a/packages/agent-manager/README.md +++ b/packages/agent-manager/README.md @@ -38,15 +38,10 @@ tool side effects for that working directory. AI DevKit adds no permission bypas 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. -Durable print-agent state is stored in `~/.ai-devkit/agents.db`. On the first -writable open after upgrading, a valid `~/.ai-devkit/print-agents.json` is -imported once and renamed to `print-agents.json.migrated-v1.bak`. There is no -dual-write: rollback to an older binary requires exporting the SQLite state -before that binary is allowed to write its JSON store again. - -For direct `PrintAgentStore` consumers, `dbPath` selects the SQLite database. -The legacy `filePath` option remains available for one compatibility release as -the JSON import path (and maps injected `.json` test paths to `.db`). The +Durable print-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 `PrintAgentStore` 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. From 2e3f6d204a108f9a7279c41bc3319e8eae8f364f Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 19 Aug 2026 10:24:27 +0000 Subject: [PATCH 11/14] refactor(agent): rename print agent domain to durable agents --- .../2026-08-07-feature-agent-print-mode.md | 72 ++++++------- ...026-08-18-feature-durable-agents-sqlite.md | 10 +- .../2026-08-07-feature-agent-print-mode.md | 14 +-- ...ature-agent-registry-write-optimization.md | 2 +- ...026-08-18-feature-durable-agents-sqlite.md | 9 +- .../2026-08-07-feature-agent-print-mode.md | 8 +- ...026-08-18-feature-durable-agents-sqlite.md | 4 +- .../2026-08-07-feature-agent-print-mode.md | 28 ++--- ...026-08-13-feature-agent-registry-sqlite.md | 2 +- ...026-08-18-feature-durable-agents-sqlite.md | 10 +- .../2026-08-07-feature-agent-print-mode.md | 8 +- ...ature-agent-registry-write-optimization.md | 2 +- ...026-08-18-feature-durable-agents-sqlite.md | 6 +- packages/agent-manager/README.md | 6 +- .../ClaudePrintAgent.integration.test.ts | 8 +- .../__tests__/print/ClaudePrintRunner.test.ts | 4 +- ...rintAgent.test.ts => DurableAgent.test.ts} | 12 +-- ...st.ts => DurableAgentStore.sqlite.test.ts} | 28 ++--- ...tore.test.ts => DurableAgentStore.test.ts} | 50 ++++----- packages/agent-manager/src/index.ts | 38 +++---- .../agent-manager/src/print/ClaudeCliProbe.ts | 2 +- .../src/print/ClaudePrintAgentService.ts | 24 ++--- .../src/print/ClaudePrintRunner.ts | 8 +- .../agent-manager/src/print/DurableAgent.ts | 86 +++++++++++++++ ...rintAgentStore.ts => DurableAgentStore.ts} | 100 +++++++++--------- .../agent-manager/src/print/PrintAgent.ts | 86 --------------- .../cli/src/__tests__/commands/agent.test.ts | 62 +++++------ packages/cli/src/commands/agent.ts | 68 ++++++------ 28 files changed, 379 insertions(+), 378 deletions(-) rename packages/agent-manager/src/__tests__/print/{PrintAgent.test.ts => DurableAgent.test.ts} (55%) rename packages/agent-manager/src/__tests__/print/{PrintAgentStore.sqlite.test.ts => DurableAgentStore.sqlite.test.ts} (79%) rename packages/agent-manager/src/__tests__/print/{PrintAgentStore.test.ts => DurableAgentStore.test.ts} (79%) create mode 100644 packages/agent-manager/src/print/DurableAgent.ts rename packages/agent-manager/src/print/{PrintAgentStore.ts => DurableAgentStore.ts} (76%) delete mode 100644 packages/agent-manager/src/print/PrintAgent.ts 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..057cfde1 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 DurableAgentStoreFile { 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'; 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 DurableAgentStoreOptions { 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 DurableAgentStore { + 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/DurableAgentStore.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 index f08a7738..6838dcba 100644 --- a/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md @@ -11,12 +11,12 @@ description: SQLite schema and transactional ownership design ```mermaid flowchart LR CLI[CLI / runner] --> Service[ClaudePrintAgentService] - Service --> Store[PrintAgentStore adapter] + Service --> Store[DurableAgentStore adapter] Store --> DB[(agents.db)] Inspector[LocalProcessInspector] --> Store ``` -`PrintAgentStore` 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. +`DurableAgentStore` 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 @@ -31,7 +31,7 @@ flowchart LR ## API Design -- Existing `PrintAgentStore` methods and `StoreLike` structural consumers stay unchanged. +- Existing `DurableAgentStore` methods and `StoreLike` 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. @@ -54,14 +54,14 @@ Readonly construction requires an existing migrated database and skips directory - 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; `print-agents.json` was never released and requires no compatibility path. +- 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 `PrintAgentBusyError`. +- 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..6c880fb3 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/print/DurableAgent.ts` with the durable record, state, session health, last-result, active-run, and process-identity contracts. +- Added classified durable-agent/store/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 `DurableAgentStore` 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` store. - 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 @@ -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 index 79309c7c..8d91a874 100644 --- a/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md @@ -16,25 +16,26 @@ description: Implementation record for the durable-agent persistence backend ## Code Structure - `packages/agent-manager/src/database/`: connection behavior, schema runner, and migration SQL. -- `packages/agent-manager/src/print/PrintAgentStore.ts`: unchanged public adapter backed by SQLite. +- `packages/agent-manager/src/print/DurableAgentStore.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 `PrintAgentStore` with SQLite row mapping and writes. +- Replaced JSON CRUD, global mutation locks, per-agent lock directories, owner files, quarantine, and temp-file replacement inside `DurableAgentStore` 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 print-agent types remain API-compatible. The store shares the agent-manager `DatabaseConnection` and migration sequence. +`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 `PrintAgentNameConflictError`; lock contention maps to `PrintAgentBusyError`; open, corruption, validation, and other storage failures map to `PrintAgentStoreError`. Conditional updates changing zero rows represent lost ownership. +SQLite name uniqueness maps to `DurableAgentNameConflictError`; lock contention maps to `DurableAgentBusyError`; open, corruption, validation, and other storage failures map to `DurableAgentStoreError`. Conditional updates changing zero rows represent lost ownership. ## Performance and Security 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 index 45174e9e..ae0843b2 100644 --- a/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md @@ -22,7 +22,7 @@ description: Ordered implementation and validation tasks ### 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: `PrintAgentStore` and Claude integration suites. +- [x] Retarget identity, name conflict, canonical cwd, listing, result, and session-resume tests to SQLite; replace JSON CRUD with row mapping and transactions. Evidence: `DurableAgentStore` 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. @@ -41,7 +41,7 @@ Schema and readonly connection behavior precede the store rewrite. Row mapping p ## 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 `PrintAgentStore` conflicts minimally if either PR lands. +- Provider PR overlap: preserve provider as unconstrained text and reconcile `DurableAgentStore` 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. 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..c49da26e 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 @@ -153,7 +153,7 @@ As a user, if the AI DevKit process dies after marking the agent busy, a later o - Human and JSON list/detail output include stable ID, name, provider `claude`, mode `print`, 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 index 40b3aa6b..fb6bf430 100644 --- a/docs/ai/requirements/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/requirements/2026-08-18-feature-durable-agents-sqlite.md @@ -1,19 +1,19 @@ --- phase: requirements title: Durable Agents SQLite Requirements -description: Persist durable print agents in the shared agents.db database +description: Persist durable durable agents in the shared agents.db database --- # Durable Agents SQLite Requirements ## Problem Statement -Durable print-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. +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 `PrintAgentStore` API and all service, runner, and CLI call sites. +- Preserve the exported `DurableAgentStore` 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. @@ -23,7 +23,7 @@ Durable print-agent persistence is a new, unreleased capability. It should launc - 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; `print-agents.json` was never released to users. +- 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 @@ -36,7 +36,7 @@ Durable print-agent persistence is a new, unreleased capability. It should launc ## Success Criteria - Migration `003_durable_agents.sql` creates the specified flattened table, constraints, and indexes and advances `user_version`. -- `PrintAgentStore` accepts `dbPath` and defaults directly to `~/.ai-devkit/agents.db`. +- `DurableAgentStore` 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. 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..868c535e 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 store, 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 store 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 index b9c4bb99..f33e8051 100644 --- a/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md @@ -21,7 +21,7 @@ Cover all changed persistence and connection branches with focused unit/integrat ## Store Behavioral Parity - [x] Identity creation and provider session identity persist across reopen. -- [x] Case-insensitive name conflicts map to `PrintAgentNameConflictError`. +- [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`. @@ -30,7 +30,7 @@ Cover all changed persistence and connection branches with focused unit/integrat ## Storage Compatibility - [x] Store and integration tests use explicit isolated `dbPath` values. -- [x] No `print-agents.json` import, marker, backup, or compatibility option exists because JSON persistence was never released. +- [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 @@ -44,7 +44,7 @@ Cover all changed persistence and connection branches with focused unit/integrat ## Full Validation - [x] Focused agent-manager test suite passes (26 files, 552 tests). -- [x] Coverage is reviewed: `PrintAgentStore.ts` reports 90.5% lines and 97.36% functions; remaining branches are defensive platform/storage failures. +- [x] Coverage is reviewed: `DurableAgentStore.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. diff --git a/packages/agent-manager/README.md b/packages/agent-manager/README.md index 487d68c2..20b63fa1 100644 --- a/packages/agent-manager/README.md +++ b/packages/agent-manager/README.md @@ -36,12 +36,12 @@ 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 print-agent state is stored in `~/.ai-devkit/agents.db`. This feature was +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 `PrintAgentStore` consumers, `dbPath` selects the SQLite database. The +For direct `DurableAgentStore` 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. 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 5855f474..4ca98a6e 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, + DurableAgentStore, } from '../../index.js'; const roots: string[] = []; @@ -19,16 +19,16 @@ 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({ dbPath: path.join(root, 'state', 'agents.db') }); + const store = new DurableAgentStore({ dbPath: path.join(root, 'state', 'agents.db') }); const service = new ClaudePrintAgentService({ store, probe: new ClaudeCliProbe({ executable }), diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts index fb8519e4..7fd57da7 100644 --- a/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts @@ -1,9 +1,9 @@ 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', cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'running', 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/PrintAgentStore.sqlite.test.ts b/packages/agent-manager/src/__tests__/print/DurableAgentStore.sqlite.test.ts similarity index 79% rename from packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts rename to packages/agent-manager/src/__tests__/print/DurableAgentStore.sqlite.test.ts index 2e1d94b9..9ca3f0d1 100644 --- a/packages/agent-manager/src/__tests__/print/PrintAgentStore.sqlite.test.ts +++ b/packages/agent-manager/src/__tests__/print/DurableAgentStore.sqlite.test.ts @@ -3,7 +3,7 @@ import os from 'os'; import path from 'path'; import Database from 'better-sqlite3'; import { afterEach, describe, expect, it } from 'vitest'; -import { PrintAgentStore } from '../../print/PrintAgentStore.js'; +import { DurableAgentStore } from '../../print/DurableAgentStore.js'; const roots: string[] = []; afterEach(() => { @@ -11,35 +11,35 @@ afterEach(() => { }); function fixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'print-agent-sqlite-')); + 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('PrintAgentStore SQLite concurrency', () => { +describe('DurableAgentStore 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 PrintAgentStore({ dbPath, processInspector }); - const second = new PrintAgentStore({ dbPath, processInspector }); + const first = new DurableAgentStore({ dbPath, processInspector }); + const second = new DurableAgentStore({ 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: 'PRINT_AGENT_BUSY' } }); + expect(rejected).toMatchObject({ reason: { code: 'DURABLE_AGENT_BUSY' } }); }); it('accepts deprecated lock options without creating lock artifacts', async () => { const { root, cwd, dbPath } = fixture(); - const store = new PrintAgentStore({ + const store = new DurableAgentStore({ dbPath, lockTimeoutMs: 1, incompleteLockGraceMs: 1, mutationLockStaleMs: 1, }); await store.create({ name: 'lockless', cwd }); expect(fs.existsSync(`${dbPath}.lock`)).toBe(false); - expect(fs.existsSync(path.join(root, 'state', 'print-agent-locks'))).toBe(false); + expect(fs.existsSync(path.join(root, 'state', 'durable-agent-locks'))).toBe(false); }); it('keeps readonly listing pure', async () => { @@ -49,22 +49,22 @@ describe('PrintAgentStore SQLite concurrency', () => { const startedAt = live.get(pid); return startedAt ? { pid, startedAt } : null; } }; - const writable = new PrintAgentStore({ dbPath, processInspector }); + const writable = new DurableAgentStore({ dbPath, processInspector }); const agent = await writable.create({ name: 'readonly', cwd }); await writable.acquireRun(agent.id); live.clear(); - const readonly = new PrintAgentStore({ dbPath, readonly: true, processInspector }); + const readonly = new DurableAgentStore({ 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 store = new PrintAgentStore({ dbPath, processInspector }); + const store = new DurableAgentStore({ dbPath, processInspector }); const agent = await store.create({ name: 'token', cwd }); const run = await store.acquireRun(agent.id); await expect(store.recordProviderProcess(agent.id, 'stale', { pid: 42, startedAt: 'provider' })) - .rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + .rejects.toMatchObject({ code: 'DURABLE_AGENT_STORE' }); const completed = await store.completeRun(agent.id, run.token, { status: 'succeeded', exitCode: 0, summary: 'x'.repeat(5000), sessionHealth: 'healthy', }); @@ -78,7 +78,7 @@ describe('PrintAgentStore SQLite concurrency', () => { if (inspect) inspect(); return inspect ? null : { pid, startedAt: 'owner-start' }; } }; - const store = new PrintAgentStore({ dbPath, processInspector }); + const store = new DurableAgentStore({ dbPath, processInspector }); const agent = await store.create({ name: 'cas', cwd }); await store.acquireRun(agent.id); const other = new Database(dbPath); @@ -100,6 +100,6 @@ describe('PrintAgentStore SQLite concurrency', () => { const { dbPath } = fixture(); fs.mkdirSync(path.dirname(dbPath), { recursive: true }); fs.writeFileSync(dbPath, 'not sqlite'); - expect(() => new PrintAgentStore({ dbPath })).toThrow(/Cannot open print-agent database/); + expect(() => new DurableAgentStore({ dbPath })).toThrow(/Cannot open durable-agent database/); }); }); diff --git a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts b/packages/agent-manager/src/__tests__/print/DurableAgentStore.test.ts similarity index 79% rename from packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts rename to packages/agent-manager/src/__tests__/print/DurableAgentStore.test.ts index ea05787d..e11163d7 100644 --- a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts +++ b/packages/agent-manager/src/__tests__/print/DurableAgentStore.test.ts @@ -11,23 +11,23 @@ afterEach(() => { async function loadStore(): Promise { const api = await import('../../index.js') as Record; - expect(api).toHaveProperty('PrintAgentStore'); - return api.PrintAgentStore; + expect(api).toHaveProperty('DurableAgentStore'); + return api.DurableAgentStore; } function fixture(): { root: string; cwd: string; dbPath: string } { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'print-agent-store-')); + 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('PrintAgentStore create/list/resolve', () => { +describe('DurableAgentStore create/list/resolve', () => { it('creates distinct durable identities with a canonical cwd and lists them', async () => { - const PrintAgentStore = await loadStore(); + const DurableAgentStore = await loadStore(); const { cwd, dbPath } = fixture(); - const store = new PrintAgentStore({ dbPath, now: () => new Date('2026-08-07T09:00:00Z') }); + const store = new DurableAgentStore({ dbPath, now: () => new Date('2026-08-07T09:00:00Z') }); const agent = await store.create({ name: 'reviewer', cwd }); @@ -48,46 +48,46 @@ describe('PrintAgentStore create/list/resolve', () => { }); it('resolves exact ids and names and rejects duplicate names', async () => { - const PrintAgentStore = await loadStore(); + const DurableAgentStore = await loadStore(); const { cwd, dbPath } = fixture(); - const store = new PrintAgentStore({ dbPath }); + const store = new DurableAgentStore({ dbPath }); 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', + code: 'DURABLE_AGENT_NAME_CONFLICT', }); }); it('rejects a missing cwd', async () => { - const PrintAgentStore = await loadStore(); + const DurableAgentStore = await loadStore(); const { root, dbPath } = fixture(); - const store = new PrintAgentStore({ dbPath }); + const store = new DurableAgentStore({ dbPath }); await expect(store.create({ name: 'missing', cwd: path.join(root, 'missing') })) - .rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + .rejects.toMatchObject({ code: 'DURABLE_AGENT_STORE' }); }); }); -describe('PrintAgentStore run ownership', () => { +describe('DurableAgentStore run ownership', () => { it('fails fast when another exact owner is live and completes only for its token', async () => { - const PrintAgentStore = await loadStore(); + const DurableAgentStore = 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 store = new PrintAgentStore({ dbPath, processInspector }); + const store = new DurableAgentStore({ dbPath, 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.acquireRun(agent.id)).rejects.toMatchObject({ code: 'DURABLE_AGENT_BUSY' }); await expect(store.completeRun(agent.id, 'wrong-token', { status: 'succeeded', exitCode: 0, summary: 'done', sessionHealth: 'healthy', - })).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + })).rejects.toMatchObject({ code: 'DURABLE_AGENT_STORE' }); const completed = await store.completeRun(agent.id, acquired.token, { status: 'succeeded', exitCode: 0, summary: 'done', sessionHealth: 'healthy', @@ -96,20 +96,20 @@ describe('PrintAgentStore run ownership', () => { }); it('retains busy for a live provider then recovers a dead run without signaling it', async () => { - const PrintAgentStore = await loadStore(); + const DurableAgentStore = 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 PrintAgentStore({ dbPath, processInspector }); + const first = new DurableAgentStore({ 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: 'PRINT_AGENT_BUSY' }); + await expect(first.acquireRun(agent.id)).rejects.toMatchObject({ code: 'DURABLE_AGENT_BUSY' }); live.delete(4242); live.set(process.pid, 'replacement-owner-start'); @@ -124,10 +124,10 @@ describe('PrintAgentStore run ownership', () => { }); it('reconciles an interrupted run to degraded during list', async () => { - const PrintAgentStore = await loadStore(); + const DurableAgentStore = await loadStore(); const { cwd, dbPath } = fixture(); const live = new Map([[process.pid, 'owner-start']]); - const store = new PrintAgentStore({ dbPath, incompleteLockGraceMs: 10, processInspector: { + const store = new DurableAgentStore({ dbPath, incompleteLockGraceMs: 10, processInspector: { getIdentity: (pid: number) => { const startedAt = live.get(pid); return startedAt ? { pid, startedAt } : null; @@ -148,9 +148,9 @@ describe('PrintAgentStore run ownership', () => { }); it('rejects send acquisition when the bound cwd is replaced by a symlink', async () => { - const PrintAgentStore = await loadStore(); + const DurableAgentStore = await loadStore(); const { root, cwd, dbPath } = fixture(); - const store = new PrintAgentStore({ dbPath }); + const store = new DurableAgentStore({ dbPath }); const agent = await store.create({ name: 'bound', cwd }); const moved = path.join(root, 'moved-project'); const other = path.join(root, 'other-project'); @@ -158,6 +158,6 @@ describe('PrintAgentStore run ownership', () => { fs.mkdirSync(other); fs.symlinkSync(other, cwd); - await expect(store.acquireRun(agent.id)).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + await expect(store.acquireRun(agent.id)).rejects.toMatchObject({ code: 'DURABLE_AGENT_STORE' }); }); }); diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index 26264e61..c527e4b6 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -38,30 +38,30 @@ export type { AgentRequest } from './utils/agent-requests.js'; export { getAgentRequestPath, readLatestAgentRequest, writeAgentRequest } from './utils/agent-requests.js'; export { - PrintAgentError, - PrintAgentBusyError, - PrintAgentNotFoundError, - PrintAgentStoreError, - PrintAgentNameConflictError, + DurableAgentError, + DurableAgentBusyError, + DurableAgentNotFoundError, + DurableAgentStoreError, + DurableAgentNameConflictError, ClaudePrintError, -} from './print/PrintAgent.js'; +} from './print/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 './print/DurableAgent.js'; +export { DurableAgentStore } from './print/DurableAgentStore.js'; +export { LocalProcessInspector } from './print/DurableAgentStore.js'; export type { - CreatePrintAgentInput, - PrintAgentStoreOptions, + CreateDurableAgentInput, + DurableAgentStoreOptions, ProcessInspector, - PrintRunCompletion, -} from './print/PrintAgentStore.js'; + DurableRunCompletion, +} from './print/DurableAgentStore.js'; export { ClaudeCliProbe } from './print/ClaudeCliProbe.js'; export type { ClaudeCliProbeOptions } from './print/ClaudeCliProbe.js'; export { ClaudePrintRunner } from './print/ClaudePrintRunner.js'; diff --git a/packages/agent-manager/src/print/ClaudeCliProbe.ts b/packages/agent-manager/src/print/ClaudeCliProbe.ts index d14cfafb..faf2435a 100644 --- a/packages/agent-manager/src/print/ClaudeCliProbe.ts +++ b/packages/agent-manager/src/print/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/print/ClaudePrintAgentService.ts index 26195b26..e8e63e41 100644 --- a/packages/agent-manager/src/print/ClaudePrintAgentService.ts +++ b/packages/agent-manager/src/print/ClaudePrintAgentService.ts @@ -1,16 +1,16 @@ -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 { DurableAgentStore, type CreateDurableAgentInput, type DurableRunCompletion } from './DurableAgentStore.js'; interface StoreLike { - create(input: CreatePrintAgentInput): Promise; - list(): Promise; - resolve(reference: string): Promise; - acquireRun(id: string): Promise<{ agent: PrintAgent; token: string }>; + 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 }> } @@ -35,22 +35,22 @@ export class ClaudePrintAgentService { private readonly executable?: string; constructor(options: ClaudePrintAgentServiceOptions = {}) { - this.store = options.store ?? new PrintAgentStore(); + this.store = options.store ?? new DurableAgentStore(); 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); } async send(reference: string, prompt: string): Promise { const resolved = await this.store.resolve(reference); - if (!resolved) throw new PrintAgentNotFoundError(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); try { diff --git a/packages/agent-manager/src/print/ClaudePrintRunner.ts b/packages/agent-manager/src/print/ClaudePrintRunner.ts index 588effd8..c9d03bf1 100644 --- a/packages/agent-manager/src/print/ClaudePrintRunner.ts +++ b/packages/agent-manager/src/print/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 './DurableAgentStore.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/print/DurableAgent.ts b/packages/agent-manager/src/print/DurableAgent.ts new file mode 100644 index 00000000..d4b4db47 --- /dev/null +++ b/packages/agent-manager/src/print/DurableAgent.ts @@ -0,0 +1,86 @@ +export type DurableAgentState = 'ready' | 'running' | 'degraded'; +export type DurableSessionHealth = 'uninitialized' | 'healthy' | 'unknown' | 'mismatch'; +export type DurableRunStatus = 'succeeded' | 'failed' | 'interrupted'; + +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: 'print'; + 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 DurableAgentStoreError extends DurableAgentError { + constructor(message: string) { + super(message, 'DURABLE_AGENT_STORE'); + this.name = 'DurableAgentStoreError'; + } +} + +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/print/PrintAgentStore.ts b/packages/agent-manager/src/print/DurableAgentStore.ts similarity index 76% rename from packages/agent-manager/src/print/PrintAgentStore.ts rename to packages/agent-manager/src/print/DurableAgentStore.ts index 146b76fb..1333f318 100644 --- a/packages/agent-manager/src/print/PrintAgentStore.ts +++ b/packages/agent-manager/src/print/DurableAgentStore.ts @@ -2,26 +2,26 @@ 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 type { PrintActiveRun, PrintAgent, ProcessIdentity, PrintRunStatus, PrintSessionHealth } from './PrintAgent.js'; +import type { DurableActiveRun, DurableAgent, ProcessIdentity, DurableRunStatus, DurableSessionHealth } from './DurableAgent.js'; import { - PrintAgentBusyError, - PrintAgentNameConflictError, - PrintAgentNotFoundError, - PrintAgentStoreError, -} from './PrintAgent.js'; + DurableAgentBusyError, + DurableAgentNameConflictError, + DurableAgentNotFoundError, + DurableAgentStoreError, +} from './DurableAgent.js'; interface DurableAgentRow { id: string; name: string; provider: 'claude'; mode: 'print'; cwd: string; provider_session_id: string; - state: PrintAgent['state']; session_health: PrintSessionHealth; created_at: string; updated_at: string; - last_active_at: string | null; last_result_status: PrintRunStatus | null; + 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 CreatePrintAgentInput { name: string; cwd: string } +export interface CreateDurableAgentInput { name: string; cwd: string } -export interface PrintAgentStoreOptions { +export interface DurableAgentStoreOptions { dbPath?: string; readonly?: boolean; /** @deprecated SQLite busy_timeout replaces filesystem lock polling. */ @@ -35,18 +35,18 @@ export interface PrintAgentStoreOptions { } export interface ProcessInspector { getIdentity(pid: number): ProcessIdentity | null } -export interface PrintRunCompletion { - status: PrintRunStatus; exitCode: number | null; summary: string; sessionHealth: PrintSessionHealth; +export interface DurableRunCompletion { + status: DurableRunStatus; exitCode: number | null; summary: string; sessionHealth: DurableSessionHealth; } -export class PrintAgentStore { +export class DurableAgentStore { readonly dbPath: string; private readonly now: () => Date; private readonly processInspector: ProcessInspector; private readonly readonly: boolean; private readonly db: DatabaseConnection; - constructor(options: PrintAgentStoreOptions = {}) { + constructor(options: DurableAgentStoreOptions = {}) { this.dbPath = options.dbPath ?? DEFAULT_AGENT_REGISTRY_DB_PATH; this.now = options.now ?? (() => new Date()); this.processInspector = options.processInspector ?? new LocalProcessInspector(); @@ -54,12 +54,12 @@ export class PrintAgentStore { try { this.db = new DatabaseConnection({ dbPath: this.dbPath, readonly: this.readonly }); } catch (error) { - if (error instanceof PrintAgentStoreError) throw error; - throw new PrintAgentStoreError(`Cannot open print-agent database: ${(error as Error).message}`); + if (error instanceof DurableAgentStoreError) throw error; + throw new DurableAgentStoreError(`Cannot open durable-agent database: ${(error as Error).message}`); } } - async create(input: CreatePrintAgentInput): Promise { + async create(input: CreateDurableAgentInput): Promise { this.assertWritable(); const cwd = this.canonicalDirectory(input.cwd); const timestamp = this.now().toISOString(); @@ -73,24 +73,24 @@ export class PrintAgentStore { [id, input.name, cwd, providerSessionId, timestamp, timestamp]); } catch (error) { if (/UNIQUE constraint failed: durable_agents\.name/i.test((error as Error).message)) { - throw new PrintAgentNameConflictError(input.name); + throw new DurableAgentNameConflictError(input.name); } - throw this.storageError('Failed to create print agent', error); + throw this.storageError('Failed to create durable agent', error); } return this.requireById(id); } - async list(): Promise { + async list(): Promise { if (!this.readonly) await this.reconcile(); return this.listRaw(); } - async getById(id: string): Promise { + async getById(id: string): Promise { if (!this.readonly) await this.reconcile(); return this.findById(id); } - async resolve(reference: string): Promise { + async resolve(reference: string): Promise { const agents = await this.list(); const byId = agents.find((agent) => agent.id === reference); if (byId) return byId; @@ -98,26 +98,26 @@ export class PrintAgentStore { return matches.length === 0 ? null : matches.length === 1 ? matches[0]! : matches; } - async acquireRun(id: string): Promise<{ agent: PrintAgent; token: string }> { + async acquireRun(id: string): Promise<{ agent: DurableAgent; token: string }> { this.assertWritable(); const snapshot = this.findById(id); - if (!snapshot) throw new PrintAgentNotFoundError(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 PrintAgentBusyError(id, snapshot.name); + if (observedLive) throw new DurableAgentBusyError(id, snapshot.name); const owner = this.processInspector.getIdentity(process.pid); - if (!owner) throw new PrintAgentStoreError('Cannot determine the current process identity.'); + if (!owner) throw new DurableAgentStoreError('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 PrintAgentNotFoundError(id); + if (!current) throw new DurableAgentNotFoundError(id); if (current.state === 'running') { if (!observed || current.activeRun?.token !== observed.token || observedLive) { - throw new PrintAgentBusyError(id, current.name); + throw new DurableAgentBusyError(id, current.name); } recovered = true; } @@ -134,16 +134,16 @@ export class PrintAgentStore { )) `, [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 PrintAgentBusyError(id, current.name); + if (changed.changes !== 1) throw new DurableAgentBusyError(id, current.name); }); } catch (error) { - if (error instanceof PrintAgentBusyError || error instanceof PrintAgentNotFoundError) throw error; - if (/busy|locked/i.test((error as Error).message)) throw new PrintAgentBusyError(id, snapshot.name); - throw this.storageError('Failed to acquire print-agent run', 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 PrintAgentStoreError('Failed to record interrupted print run.'); + throw new DurableAgentStoreError('Failed to record interrupted print run.'); } return { agent, token }; } @@ -154,10 +154,10 @@ export class PrintAgentStore { 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 PrintAgentStoreError('Print run ownership changed.'); + if (changed.changes !== 1) throw new DurableAgentStoreError('Print run ownership changed.'); } - async completeRun(id: string, token: string, result: PrintRunCompletion): Promise { + 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 @@ -169,7 +169,7 @@ export class PrintAgentStore { 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 PrintAgentStoreError('Print run ownership changed.'); + if (changed.changes !== 1) throw new DurableAgentStoreError('Print run ownership changed.'); return this.requireById(id); } @@ -205,29 +205,29 @@ export class PrintAgentStore { } } - private listRaw(): PrintAgent[] { + 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 print-agent database', error); + throw this.storageError('Failed to read durable-agent database', error); } } - private findById(id: string): PrintAgent | null { + 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): PrintAgent { + private requireById(id: string): DurableAgent { const agent = this.findById(id); - if (!agent) throw new PrintAgentNotFoundError(id); + if (!agent) throw new DurableAgentNotFoundError(id); return agent; } - private fromRow(row: DurableAgentRow): PrintAgent { - const activeRun: PrintActiveRun | null = row.active_run_token === null ? null : { + 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 : { @@ -253,7 +253,7 @@ export class PrintAgentStore { 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}`); + throw new DurableAgentStoreError(`Durable agent cwd is not an existing directory: ${input}`); } } @@ -262,11 +262,11 @@ export class PrintAgentStore { 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}`); + throw new DurableAgentStoreError(`Durable agent cwd binding is no longer safe: ${bound}`); } } - private isActive(metadata: PrintActiveRun): boolean { + private isActive(metadata: DurableActiveRun): boolean { return this.sameProcess(metadata.owner) || (metadata.provider !== null && this.sameProcess(metadata.provider)); } @@ -276,12 +276,12 @@ export class PrintAgentStore { } private assertWritable(): void { - if (this.readonly) throw new PrintAgentStoreError('Print-agent store is readonly.'); + if (this.readonly) throw new DurableAgentStoreError('Durable-agent store is readonly.'); } - private storageError(prefix: string, error: unknown): PrintAgentStoreError { - return error instanceof PrintAgentStoreError ? error - : new PrintAgentStoreError(`${prefix}: ${(error as Error).message}`); + private storageError(prefix: string, error: unknown): DurableAgentStoreError { + return error instanceof DurableAgentStoreError ? error + : new DurableAgentStoreError(`${prefix}: ${(error as Error).message}`); } } 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/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index eb18840f..7698e6ff 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 mockDurableStore: any = { list: vi.fn().mockResolvedValue([]), resolve: vi.fn().mockResolvedValue(null), }; -const mockPrintService: any = { - store: mockPrintStore, +const mockDurableService: any = { + store: mockDurableStore, create: vi.fn(), send: vi.fn(), }; @@ -97,8 +97,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; }), + DurableAgentStore: vi.fn(function () { return mockDurableStore; }), + ClaudePrintAgentService: vi.fn(function () { return mockDurableService; }), TerminalFocusManager: vi.fn(function () { return mockFocusManager; }), TtyWriter: { send: (location: any, message: string) => mockTtyWriterSend(location, message) }, AgentStatus: { @@ -228,10 +228,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(); + mockDurableStore.list.mockReset().mockResolvedValue([]); + mockDurableStore.resolve.mockReset().mockResolvedValue(null); + mockDurableService.create.mockReset(); + mockDurableService.send.mockReset(); mockFocusManager.findTerminal.mockReset(); mockFocusManager.focusTerminal.mockReset(); mockTtyWriterSend.mockReset().mockResolvedValue(undefined); @@ -293,9 +293,9 @@ 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([{ + mockDurableStore.list.mockResolvedValue([{ id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, @@ -310,21 +310,21 @@ describe('agent command', () => { expect(output[0]).not.toHaveProperty('pid'); }); - it('shows durable print-agent detail without requiring a transcript', async () => { - const printAgent = { + it('shows durable durable-agent detail without requiring a transcript', async () => { + const durableAgent = { id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, }; - mockPrintStore.resolve.mockResolvedValue(printAgent); + mockDurableStore.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: 'print', state: 'ready' }); expect(output).not.toHaveProperty('conversation'); }); @@ -387,9 +387,9 @@ 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([{ + mockDurableStore.list.mockResolvedValue([{ id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, @@ -727,8 +727,8 @@ 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', }); @@ -740,44 +740,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', }; - mockPrintStore.resolve.mockResolvedValue(printAgent); - mockPrintService.send.mockResolvedValue({ ...printAgent, result: '\x1b]0;unsafe\x07review complete', exitCode: 0 }); + mockDurableStore.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', }; - mockPrintStore.resolve.mockResolvedValue(printAgent); + mockDurableStore.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..3f84e4fa 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -15,7 +15,7 @@ import { OpenCodeAdapter, PiAdapter, ClaudePrintAgentService, - PrintAgentStore, + DurableAgentStore, AgentStatus, TerminalFocusManager, AgentRegistry, @@ -196,8 +196,8 @@ function createAgentManager(): AgentManager { return manager; } -function createPrintAgentService(): ClaudePrintAgentService { - return new ClaudePrintAgentService({ store: new PrintAgentStore() }); +function createDurableAgentService(): ClaudePrintAgentService { + return new ClaudePrintAgentService({ store: new DurableAgentStore() }); } const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; @@ -305,8 +305,8 @@ 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})`); + 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 +346,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().store.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 +372,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,23 +627,23 @@ 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.store.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' }, @@ -717,31 +717,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().store.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; } From 4ddde0b0a30df4ab8ec3767f50f58f8ff76526d5 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 19 Aug 2026 10:34:16 +0000 Subject: [PATCH 12/14] refactor(agent): rename durable agent store to repository --- .../2026-08-07-feature-agent-print-mode.md | 8 +- ...026-08-18-feature-durable-agents-sqlite.md | 6 +- .../2026-08-07-feature-agent-print-mode.md | 6 +- ...026-08-18-feature-durable-agents-sqlite.md | 6 +- ...026-08-18-feature-durable-agents-sqlite.md | 4 +- ...026-08-18-feature-durable-agents-sqlite.md | 4 +- .../2026-08-07-feature-agent-print-mode.md | 4 +- ...026-08-18-feature-durable-agents-sqlite.md | 2 +- packages/agent-manager/README.md | 2 +- .../ClaudePrintAgent.integration.test.ts | 8 +- .../print/ClaudePrintAgentService.test.ts | 14 ++-- ... => DurableAgentRepository.sqlite.test.ts} | 38 +++++----- ...test.ts => DurableAgentRepository.test.ts} | 76 +++++++++---------- packages/agent-manager/src/index.ts | 10 +-- .../src/print/ClaudePrintAgentService.ts | 22 +++--- .../src/print/ClaudePrintRunner.ts | 2 +- .../agent-manager/src/print/DurableAgent.ts | 6 +- ...gentStore.ts => DurableAgentRepository.ts} | 32 ++++---- .../cli/src/__tests__/commands/agent.test.ts | 20 ++--- packages/cli/src/commands/agent.ts | 10 +-- 20 files changed, 140 insertions(+), 140 deletions(-) rename packages/agent-manager/src/__tests__/print/{DurableAgentStore.sqlite.test.ts => DurableAgentRepository.sqlite.test.ts} (72%) rename packages/agent-manager/src/__tests__/print/{DurableAgentStore.test.ts => DurableAgentRepository.test.ts} (62%) rename packages/agent-manager/src/print/{DurableAgentStore.ts => DurableAgentRepository.ts} (91%) 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 057cfde1..1ceea09e 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 @@ -39,7 +39,7 @@ flowchart LR Default path: `~/.ai-devkit/durable-agents.json`. ```ts -interface DurableAgentStoreFile { +interface DurableAgentRepositoryFile { version: 1; agents: DurableAgent[]; } @@ -100,14 +100,14 @@ The per-agent lock is authoritative for exclusion. Persisted `activeRun` makes s ### Store ```ts -interface DurableAgentStoreOptions { +interface DurableAgentRepositoryOptions { filePath?: string; lockTimeoutMs?: number; now?: () => Date; processInspector?: ProcessInspector; } -class DurableAgentStore { +class DurableAgentRepository { create(input: CreateDurableAgentInput): Promise; list(): Promise; getById(id: string): Promise; @@ -227,7 +227,7 @@ Direct `agent send --id` uses this resolver. Group sends remain live-only. ### `agent-manager` - `print/DurableAgent.ts`: durable types and typed errors. -- `print/DurableAgentStore.ts`: atomic JSON persistence, name/ID resolution, locking, ownership, reconciliation, and path safety. +- `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 index 6838dcba..07cfd6a1 100644 --- a/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md @@ -11,12 +11,12 @@ description: SQLite schema and transactional ownership design ```mermaid flowchart LR CLI[CLI / runner] --> Service[ClaudePrintAgentService] - Service --> Store[DurableAgentStore adapter] + Service --> Repository[DurableAgentRepository adapter] Store --> DB[(agents.db)] Inspector[LocalProcessInspector] --> Store ``` -`DurableAgentStore` 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. +`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 @@ -31,7 +31,7 @@ flowchart LR ## API Design -- Existing `DurableAgentStore` methods and `StoreLike` structural consumers stay unchanged. +- 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. 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 6c880fb3..b2fe73cc 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 @@ -17,14 +17,14 @@ description: Implementation record, decisions, validation, and deviations ### Task 1.1 - Added `packages/agent-manager/src/print/DurableAgent.ts` with the durable record, state, session health, last-result, active-run, and process-identity contracts. -- Added classified durable-agent/store/Claude errors that do not carry prompt content. +- 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/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 `DurableAgentStore` 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/durable-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 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 index 8d91a874..d9ec3d2a 100644 --- a/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md @@ -16,14 +16,14 @@ description: Implementation record for the durable-agent persistence backend ## Code Structure - `packages/agent-manager/src/database/`: connection behavior, schema runner, and migration SQL. -- `packages/agent-manager/src/print/DurableAgentStore.ts`: unchanged public adapter backed by SQLite. +- `packages/agent-manager/src/print/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 `DurableAgentStore` with SQLite row mapping and writes. +- 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. @@ -35,7 +35,7 @@ description: Implementation record for the durable-agent persistence backend ## Error Handling -SQLite name uniqueness maps to `DurableAgentNameConflictError`; lock contention maps to `DurableAgentBusyError`; open, corruption, validation, and other storage failures map to `DurableAgentStoreError`. Conditional updates changing zero rows represent lost ownership. +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 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 index ae0843b2..6d211e97 100644 --- a/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/planning/2026-08-18-feature-durable-agents-sqlite.md @@ -22,7 +22,7 @@ description: Ordered implementation and validation tasks ### 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: `DurableAgentStore` and Claude integration suites. +- [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. @@ -41,7 +41,7 @@ Schema and readonly connection behavior precede the store rewrite. Row mapping p ## 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 `DurableAgentStore` conflicts minimally if either PR lands. +- 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. 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 index fb6bf430..cccf5c3b 100644 --- a/docs/ai/requirements/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/requirements/2026-08-18-feature-durable-agents-sqlite.md @@ -13,7 +13,7 @@ Durable durable-agent persistence is a new, unreleased capability. It should lau ## Goals & Objectives - Store durable agents in a separate `durable_agents` table in `~/.ai-devkit/agents.db`. -- Preserve the exported `DurableAgentStore` API and all service, runner, and CLI call sites. +- 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. @@ -36,7 +36,7 @@ Durable durable-agent persistence is a new, unreleased capability. It should lau ## Success Criteria - Migration `003_durable_agents.sql` creates the specified flattened table, constraints, and indexes and advances `user_version`. -- `DurableAgentStore` accepts `dbPath` and defaults directly to `~/.ai-devkit/agents.db`. +- `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. 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 868c535e..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 durable-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,7 +16,7 @@ description: Offline TDD, security, integration, and compatibility validation ## Unit Tests -### Durable agent store and resolution +### Durable agent repository and resolution - [ ] Creates a durable durable agent with distinct valid AI DevKit and Claude UUIDs. - [ ] Canonicalizes an existing cwd and rejects missing/non-directory paths. 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 index f33e8051..e0e77033 100644 --- a/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/testing/2026-08-18-feature-durable-agents-sqlite.md @@ -44,7 +44,7 @@ Cover all changed persistence and connection branches with focused unit/integrat ## Full Validation - [x] Focused agent-manager test suite passes (26 files, 552 tests). -- [x] Coverage is reviewed: `DurableAgentStore.ts` reports 90.5% lines and 97.36% functions; remaining branches are defensive platform/storage failures. +- [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. diff --git a/packages/agent-manager/README.md b/packages/agent-manager/README.md index 20b63fa1..a80fd2d6 100644 --- a/packages/agent-manager/README.md +++ b/packages/agent-manager/README.md @@ -41,7 +41,7 @@ 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 `DurableAgentStore` consumers, `dbPath` selects the SQLite database. The +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. 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 4ca98a6e..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, - DurableAgentStore, + DurableAgentRepository, } from '../../index.js'; const roots: string[] = []; @@ -28,9 +28,9 @@ describe('Claude durable-agent fake-provider journey', () => { 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 DurableAgentStore({ dbPath: path.join(root, 'state', 'agents.db') }); + 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 durable-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/DurableAgentStore.sqlite.test.ts b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.sqlite.test.ts similarity index 72% rename from packages/agent-manager/src/__tests__/print/DurableAgentStore.sqlite.test.ts rename to packages/agent-manager/src/__tests__/print/DurableAgentRepository.sqlite.test.ts index 9ca3f0d1..696a8426 100644 --- a/packages/agent-manager/src/__tests__/print/DurableAgentStore.sqlite.test.ts +++ b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.sqlite.test.ts @@ -3,7 +3,7 @@ import os from 'os'; import path from 'path'; import Database from 'better-sqlite3'; import { afterEach, describe, expect, it } from 'vitest'; -import { DurableAgentStore } from '../../print/DurableAgentStore.js'; +import { DurableAgentRepository } from '../../print/DurableAgentRepository.js'; const roots: string[] = []; afterEach(() => { @@ -18,13 +18,13 @@ function fixture() { return { root, cwd, dbPath: path.join(root, 'state', 'agents.db') }; } -describe('DurableAgentStore SQLite concurrency', () => { +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 DurableAgentStore({ dbPath, processInspector }); - const second = new DurableAgentStore({ dbPath, processInspector }); + 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); @@ -34,10 +34,10 @@ describe('DurableAgentStore SQLite concurrency', () => { it('accepts deprecated lock options without creating lock artifacts', async () => { const { root, cwd, dbPath } = fixture(); - const store = new DurableAgentStore({ + const repository = new DurableAgentRepository({ dbPath, lockTimeoutMs: 1, incompleteLockGraceMs: 1, mutationLockStaleMs: 1, }); - await store.create({ name: 'lockless', cwd }); + 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); }); @@ -49,23 +49,23 @@ describe('DurableAgentStore SQLite concurrency', () => { const startedAt = live.get(pid); return startedAt ? { pid, startedAt } : null; } }; - const writable = new DurableAgentStore({ dbPath, processInspector }); + const writable = new DurableAgentRepository({ dbPath, processInspector }); const agent = await writable.create({ name: 'readonly', cwd }); await writable.acquireRun(agent.id); live.clear(); - const readonly = new DurableAgentStore({ dbPath, readonly: true, processInspector }); + 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 store = new DurableAgentStore({ dbPath, processInspector }); - const agent = await store.create({ name: 'token', cwd }); - const run = await store.acquireRun(agent.id); - await expect(store.recordProviderProcess(agent.id, 'stale', { pid: 42, startedAt: 'provider' })) - .rejects.toMatchObject({ code: 'DURABLE_AGENT_STORE' }); - const completed = await store.completeRun(agent.id, run.token, { + 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); @@ -78,9 +78,9 @@ describe('DurableAgentStore SQLite concurrency', () => { if (inspect) inspect(); return inspect ? null : { pid, startedAt: 'owner-start' }; } }; - const store = new DurableAgentStore({ dbPath, processInspector }); - const agent = await store.create({ name: 'cas', cwd }); - await store.acquireRun(agent.id); + 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; @@ -89,7 +89,7 @@ describe('DurableAgentStore SQLite concurrency', () => { WHERE id = ?`).run(agent.id); }; - await store.reconcile(); + 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' }); @@ -100,6 +100,6 @@ describe('DurableAgentStore SQLite concurrency', () => { const { dbPath } = fixture(); fs.mkdirSync(path.dirname(dbPath), { recursive: true }); fs.writeFileSync(dbPath, 'not sqlite'); - expect(() => new DurableAgentStore({ dbPath })).toThrow(/Cannot open durable-agent database/); + expect(() => new DurableAgentRepository({ dbPath })).toThrow(/Cannot open durable-agent database/); }); }); diff --git a/packages/agent-manager/src/__tests__/print/DurableAgentStore.test.ts b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.test.ts similarity index 62% rename from packages/agent-manager/src/__tests__/print/DurableAgentStore.test.ts rename to packages/agent-manager/src/__tests__/print/DurableAgentRepository.test.ts index e11163d7..9e1fd08b 100644 --- a/packages/agent-manager/src/__tests__/print/DurableAgentStore.test.ts +++ b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.test.ts @@ -11,8 +11,8 @@ afterEach(() => { async function loadStore(): Promise { const api = await import('../../index.js') as Record; - expect(api).toHaveProperty('DurableAgentStore'); - return api.DurableAgentStore; + expect(api).toHaveProperty('DurableAgentRepository'); + return api.DurableAgentRepository; } function fixture(): { root: string; cwd: string; dbPath: string } { @@ -23,13 +23,13 @@ function fixture(): { root: string; cwd: string; dbPath: string } { return { root, cwd, dbPath: path.join(root, 'state', 'agents.db') }; } -describe('DurableAgentStore create/list/resolve', () => { +describe('DurableAgentRepository create/list/resolve', () => { it('creates distinct durable identities with a canonical cwd and lists them', async () => { - const DurableAgentStore = await loadStore(); + const DurableAgentRepository = await loadStore(); const { cwd, dbPath } = fixture(); - const store = new DurableAgentStore({ dbPath, now: () => new Date('2026-08-07T09:00:00Z') }); + const repository = new DurableAgentRepository({ dbPath, now: () => new Date('2026-08-07T09:00:00Z') }); - const agent = await store.create({ name: 'reviewer', cwd }); + const agent = await repository.create({ name: 'reviewer', cwd }); expect(agent).toMatchObject({ name: 'reviewer', @@ -43,67 +43,67 @@ describe('DurableAgentStore create/list/resolve', () => { 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(await repository.list()).toEqual([agent]); expect(fs.existsSync(dbPath)).toBe(true); }); it('resolves exact ids and names and rejects duplicate names', async () => { - const DurableAgentStore = await loadStore(); + const DurableAgentRepository = await loadStore(); const { cwd, dbPath } = fixture(); - const store = new DurableAgentStore({ dbPath }); - const agent = await store.create({ name: 'Reviewer', cwd }); + const repository = new DurableAgentRepository({ dbPath }); + const agent = await repository.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({ + 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 DurableAgentStore = await loadStore(); + const DurableAgentRepository = await loadStore(); const { root, dbPath } = fixture(); - const store = new DurableAgentStore({ dbPath }); + const repository = new DurableAgentRepository({ dbPath }); - await expect(store.create({ name: 'missing', cwd: path.join(root, 'missing') })) - .rejects.toMatchObject({ code: 'DURABLE_AGENT_STORE' }); + await expect(repository.create({ name: 'missing', cwd: path.join(root, 'missing') })) + .rejects.toMatchObject({ code: 'DURABLE_AGENT_REPOSITORY' }); }); }); -describe('DurableAgentStore run ownership', () => { +describe('DurableAgentRepository run ownership', () => { it('fails fast when another exact owner is live and completes only for its token', async () => { - const DurableAgentStore = await loadStore(); + 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 store = new DurableAgentStore({ dbPath, processInspector }); - const agent = await store.create({ name: 'runner', cwd }); + const repository = new DurableAgentRepository({ dbPath, processInspector }); + const agent = await repository.create({ name: 'runner', cwd }); - const acquired = await store.acquireRun(agent.id); - await expect(store.acquireRun(agent.id)).rejects.toMatchObject({ code: 'DURABLE_AGENT_BUSY' }); - await expect(store.completeRun(agent.id, 'wrong-token', { + 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_STORE' }); + })).rejects.toMatchObject({ code: 'DURABLE_AGENT_REPOSITORY' }); - const completed = await store.completeRun(agent.id, acquired.token, { + 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 DurableAgentStore = await loadStore(); + 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 DurableAgentStore({ dbPath, processInspector }); + 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' }); @@ -124,20 +124,20 @@ describe('DurableAgentStore run ownership', () => { }); it('reconciles an interrupted run to degraded during list', async () => { - const DurableAgentStore = await loadStore(); + const DurableAgentRepository = await loadStore(); const { cwd, dbPath } = fixture(); const live = new Map([[process.pid, 'owner-start']]); - const store = new DurableAgentStore({ dbPath, incompleteLockGraceMs: 10, processInspector: { + const repository = new DurableAgentRepository({ dbPath, 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 agent = await repository.create({ name: 'crashed', cwd }); + await repository.acquireRun(agent.id); live.clear(); - const listed = await store.list(); + const listed = await repository.list(); expect(listed[0]).toMatchObject({ state: 'degraded', @@ -148,16 +148,16 @@ describe('DurableAgentStore run ownership', () => { }); it('rejects send acquisition when the bound cwd is replaced by a symlink', async () => { - const DurableAgentStore = await loadStore(); + const DurableAgentRepository = await loadStore(); const { root, cwd, dbPath } = fixture(); - const store = new DurableAgentStore({ dbPath }); - const agent = await store.create({ name: 'bound', cwd }); + 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(store.acquireRun(agent.id)).rejects.toMatchObject({ code: 'DURABLE_AGENT_STORE' }); + await expect(repository.acquireRun(agent.id)).rejects.toMatchObject({ code: 'DURABLE_AGENT_REPOSITORY' }); }); }); diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index c527e4b6..e4617385 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -41,7 +41,7 @@ export { DurableAgentError, DurableAgentBusyError, DurableAgentNotFoundError, - DurableAgentStoreError, + DurableAgentRepositoryError, DurableAgentNameConflictError, ClaudePrintError, } from './print/DurableAgent.js'; @@ -54,14 +54,14 @@ export type { DurableLastResult, ProcessIdentity, } from './print/DurableAgent.js'; -export { DurableAgentStore } from './print/DurableAgentStore.js'; -export { LocalProcessInspector } from './print/DurableAgentStore.js'; +export { DurableAgentRepository } from './print/DurableAgentRepository.js'; +export { LocalProcessInspector } from './print/DurableAgentRepository.js'; export type { CreateDurableAgentInput, - DurableAgentStoreOptions, + DurableAgentRepositoryOptions, ProcessInspector, DurableRunCompletion, -} from './print/DurableAgentStore.js'; +} from './print/DurableAgentRepository.js'; export { ClaudeCliProbe } from './print/ClaudeCliProbe.js'; export type { ClaudeCliProbeOptions } from './print/ClaudeCliProbe.js'; export { ClaudePrintRunner } from './print/ClaudePrintRunner.js'; diff --git a/packages/agent-manager/src/print/ClaudePrintAgentService.ts b/packages/agent-manager/src/print/ClaudePrintAgentService.ts index e8e63e41..fa875ac7 100644 --- a/packages/agent-manager/src/print/ClaudePrintAgentService.ts +++ b/packages/agent-manager/src/print/ClaudePrintAgentService.ts @@ -2,9 +2,9 @@ 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 { DurableAgentStore, type CreateDurableAgentInput, type DurableRunCompletion } from './DurableAgentStore.js'; +import { DurableAgentRepository, type CreateDurableAgentInput, type DurableRunCompletion } from './DurableAgentRepository.js'; -interface StoreLike { +interface RepositoryLike { create(input: CreateDurableAgentInput): Promise; list(): Promise; resolve(reference: string): Promise; @@ -17,7 +17,7 @@ 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,13 +29,13 @@ 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 DurableAgentStore(); + this.repository = options.repository ?? new DurableAgentRepository(); this.probe = options.probe ?? new ClaudeCliProbe(); this.runner = options.runner ?? new ClaudePrintRunner(); this.executable = options.executable; @@ -43,25 +43,25 @@ export class ClaudePrintAgentService { 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); + const resolved = await this.repository.resolve(reference); if (!resolved) throw new DurableAgentNotFoundError(reference); if (Array.isArray(resolved)) { 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/print/ClaudePrintRunner.ts index c9d03bf1..5e248372 100644 --- a/packages/agent-manager/src/print/ClaudePrintRunner.ts +++ b/packages/agent-manager/src/print/ClaudePrintRunner.ts @@ -1,7 +1,7 @@ import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'child_process'; import type { DurableAgent, ProcessIdentity } from './DurableAgent.js'; import { ClaudePrintError } from './DurableAgent.js'; -import { LocalProcessInspector, type ProcessInspector } from './DurableAgentStore.js'; +import { LocalProcessInspector, type ProcessInspector } from './DurableAgentRepository.js'; type Spawn = ( command: string, diff --git a/packages/agent-manager/src/print/DurableAgent.ts b/packages/agent-manager/src/print/DurableAgent.ts index d4b4db47..87dd7461 100644 --- a/packages/agent-manager/src/print/DurableAgent.ts +++ b/packages/agent-manager/src/print/DurableAgent.ts @@ -64,10 +64,10 @@ export class DurableAgentNotFoundError extends DurableAgentError { } } -export class DurableAgentStoreError extends DurableAgentError { +export class DurableAgentRepositoryError extends DurableAgentError { constructor(message: string) { - super(message, 'DURABLE_AGENT_STORE'); - this.name = 'DurableAgentStoreError'; + super(message, 'DURABLE_AGENT_REPOSITORY'); + this.name = 'DurableAgentRepositoryError'; } } diff --git a/packages/agent-manager/src/print/DurableAgentStore.ts b/packages/agent-manager/src/print/DurableAgentRepository.ts similarity index 91% rename from packages/agent-manager/src/print/DurableAgentStore.ts rename to packages/agent-manager/src/print/DurableAgentRepository.ts index 1333f318..4440cc24 100644 --- a/packages/agent-manager/src/print/DurableAgentStore.ts +++ b/packages/agent-manager/src/print/DurableAgentRepository.ts @@ -7,7 +7,7 @@ import { DurableAgentBusyError, DurableAgentNameConflictError, DurableAgentNotFoundError, - DurableAgentStoreError, + DurableAgentRepositoryError, } from './DurableAgent.js'; interface DurableAgentRow { @@ -21,7 +21,7 @@ interface DurableAgentRow { export interface CreateDurableAgentInput { name: string; cwd: string } -export interface DurableAgentStoreOptions { +export interface DurableAgentRepositoryOptions { dbPath?: string; readonly?: boolean; /** @deprecated SQLite busy_timeout replaces filesystem lock polling. */ @@ -39,14 +39,14 @@ export interface DurableRunCompletion { status: DurableRunStatus; exitCode: number | null; summary: string; sessionHealth: DurableSessionHealth; } -export class DurableAgentStore { +export class DurableAgentRepository { readonly dbPath: string; private readonly now: () => Date; private readonly processInspector: ProcessInspector; private readonly readonly: boolean; private readonly db: DatabaseConnection; - constructor(options: DurableAgentStoreOptions = {}) { + constructor(options: DurableAgentRepositoryOptions = {}) { this.dbPath = options.dbPath ?? DEFAULT_AGENT_REGISTRY_DB_PATH; this.now = options.now ?? (() => new Date()); this.processInspector = options.processInspector ?? new LocalProcessInspector(); @@ -54,8 +54,8 @@ export class DurableAgentStore { try { this.db = new DatabaseConnection({ dbPath: this.dbPath, readonly: this.readonly }); } catch (error) { - if (error instanceof DurableAgentStoreError) throw error; - throw new DurableAgentStoreError(`Cannot open durable-agent database: ${(error as Error).message}`); + if (error instanceof DurableAgentRepositoryError) throw error; + throw new DurableAgentRepositoryError(`Cannot open durable-agent database: ${(error as Error).message}`); } } @@ -107,7 +107,7 @@ export class DurableAgentStore { 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 DurableAgentStoreError('Cannot determine the current process identity.'); + if (!owner) throw new DurableAgentRepositoryError('Cannot determine the current process identity.'); const token = randomUUID(); const startedAt = this.now().toISOString(); let recovered = false; @@ -143,7 +143,7 @@ export class DurableAgentStore { } const agent = this.requireById(id); if (recovered && agent.lastResult?.status !== 'interrupted') { - throw new DurableAgentStoreError('Failed to record interrupted print run.'); + throw new DurableAgentRepositoryError('Failed to record interrupted print run.'); } return { agent, token }; } @@ -154,7 +154,7 @@ export class DurableAgentStore { 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 DurableAgentStoreError('Print run ownership changed.'); + if (changed.changes !== 1) throw new DurableAgentRepositoryError('Print run ownership changed.'); } async completeRun(id: string, token: string, result: DurableRunCompletion): Promise { @@ -169,7 +169,7 @@ export class DurableAgentStore { 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 DurableAgentStoreError('Print run ownership changed.'); + if (changed.changes !== 1) throw new DurableAgentRepositoryError('Print run ownership changed.'); return this.requireById(id); } @@ -253,7 +253,7 @@ export class DurableAgentStore { if (!fs.statSync(resolved).isDirectory()) throw new Error('not a directory'); return resolved; } catch { - throw new DurableAgentStoreError(`Durable agent cwd is not an existing directory: ${input}`); + throw new DurableAgentRepositoryError(`Durable agent cwd is not an existing directory: ${input}`); } } @@ -262,7 +262,7 @@ export class DurableAgentStore { const stat = fs.lstatSync(bound); if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(bound) !== bound) throw new Error('binding changed'); } catch { - throw new DurableAgentStoreError(`Durable agent cwd binding is no longer safe: ${bound}`); + throw new DurableAgentRepositoryError(`Durable agent cwd binding is no longer safe: ${bound}`); } } @@ -276,12 +276,12 @@ export class DurableAgentStore { } private assertWritable(): void { - if (this.readonly) throw new DurableAgentStoreError('Durable-agent store is readonly.'); + if (this.readonly) throw new DurableAgentRepositoryError('Durable-agent repository is readonly.'); } - private storageError(prefix: string, error: unknown): DurableAgentStoreError { - return error instanceof DurableAgentStoreError ? error - : new DurableAgentStoreError(`${prefix}: ${(error as Error).message}`); + private storageError(prefix: string, error: unknown): DurableAgentRepositoryError { + return error instanceof DurableAgentRepositoryError ? error + : new DurableAgentRepositoryError(`${prefix}: ${(error as Error).message}`); } } diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 7698e6ff..28f215e2 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 mockDurableStore: any = { +const mockDurableRepository: any = { list: vi.fn().mockResolvedValue([]), resolve: vi.fn().mockResolvedValue(null), }; const mockDurableService: any = { - store: mockDurableStore, + repository: mockDurableRepository, create: vi.fn(), send: vi.fn(), }; @@ -97,7 +97,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({ GrokCliAdapter: vi.fn(), OpenCodeAdapter: vi.fn(), PiAdapter: vi.fn(), - DurableAgentStore: vi.fn(function () { return mockDurableStore; }), + 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) }, @@ -228,8 +228,8 @@ describe('agent command', () => { mockManager.resolveAgent.mockReset(); mockManager.getAdapter.mockReset(); mockAgentAdapter.getConversation.mockReset(); - mockDurableStore.list.mockReset().mockResolvedValue([]); - mockDurableStore.resolve.mockReset().mockResolvedValue(null); + mockDurableRepository.list.mockReset().mockResolvedValue([]); + mockDurableRepository.resolve.mockReset().mockResolvedValue(null); mockDurableService.create.mockReset(); mockDurableService.send.mockReset(); mockFocusManager.findTerminal.mockReset(); @@ -295,7 +295,7 @@ describe('agent command', () => { it('labels durable agents as durable in list JSON without a fake pid', async () => { mockManager.listAgents.mockResolvedValue([]); - mockDurableStore.list.mockResolvedValue([{ + mockDurableRepository.list.mockResolvedValue([{ id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, @@ -316,7 +316,7 @@ describe('agent command', () => { cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, }; - mockDurableStore.resolve.mockResolvedValue(durableAgent); + mockDurableRepository.resolve.mockResolvedValue(durableAgent); mockManager.listAgents.mockResolvedValue([]); const program = new Command(); @@ -389,7 +389,7 @@ describe('agent command', () => { it('renders durable agents as durable without leaking the internal print mode', async () => { mockManager.listAgents.mockResolvedValue([]); - mockDurableStore.list.mockResolvedValue([{ + mockDurableRepository.list.mockResolvedValue([{ id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, @@ -749,7 +749,7 @@ Waiting on user input`, id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', cwd: '/project', state: 'ready', }; - mockDurableStore.resolve.mockResolvedValue(durableAgent); + mockDurableRepository.resolve.mockResolvedValue(durableAgent); mockDurableService.send.mockResolvedValue({ ...durableAgent, result: '\x1b]0;unsafe\x07review complete', exitCode: 0 }); const program = new Command(); @@ -768,7 +768,7 @@ Waiting on user input`, id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', cwd: '/project', state: 'ready', }; - mockDurableStore.resolve.mockResolvedValue(durableAgent); + mockDurableRepository.resolve.mockResolvedValue(durableAgent); const program = new Command(); registerAgentCommand(program); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 3f84e4fa..a075b1d8 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -15,7 +15,7 @@ import { OpenCodeAdapter, PiAdapter, ClaudePrintAgentService, - DurableAgentStore, + DurableAgentRepository, AgentStatus, TerminalFocusManager, AgentRegistry, @@ -197,7 +197,7 @@ function createAgentManager(): AgentManager { } function createDurableAgentService(): ClaudePrintAgentService { - return new ClaudePrintAgentService({ store: new DurableAgentStore() }); + return new ClaudePrintAgentService({ repository: new DurableAgentRepository() }); } const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; @@ -346,7 +346,7 @@ export function registerAgentCommand(program: Command): void { .action(withErrorHandler('list agents', async (options) => { const manager = createAgentManager(); const agents = await manager.listAgents(); - const durableAgents = await createDurableAgentService().store.list(); + const durableAgents = await createDurableAgentService().repository.list(); if (options.json) { const output = [ @@ -628,7 +628,7 @@ export function registerAgentCommand(program: Command): void { } const durableService = createDurableAgentService(); - const durableResolved = await durableService.store.resolve(options.id); + const durableResolved = await durableService.repository.resolve(options.id); if (Array.isArray(durableResolved)) { throw new Error(`Multiple durable agents match "${options.id}".`); } @@ -717,7 +717,7 @@ export function registerAgentCommand(program: Command): void { .action(withErrorHandler('get agent detail', async (options) => { const manager = createAgentManager(); const agents = await manager.listAgents(); - const durableResolved = await createDurableAgentService().store.resolve(options.id); + const durableResolved = await createDurableAgentService().repository.resolve(options.id); if (Array.isArray(durableResolved)) { throw new Error(`Multiple durable agents match "${options.id}".`); } From 3101832ef500cc10c3ac34674fab8779f3194c50 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 19 Aug 2026 14:51:37 +0000 Subject: [PATCH 13/14] refactor(agent): move durable agents into durable module directory --- .../2026-08-07-feature-agent-print-mode.md | 2 +- ...026-08-18-feature-durable-agents-sqlite.md | 2 +- .../DurableAgentRepository.sqlite.test.ts | 2 +- .../src/{print => durable}/ClaudeCliProbe.ts | 0 .../ClaudePrintAgentService.ts | 0 .../{print => durable}/ClaudePrintRunner.ts | 0 .../src/{print => durable}/DurableAgent.ts | 0 .../DurableAgentRepository.ts | 0 packages/agent-manager/src/index.ts | 22 +++++++++---------- 9 files changed, 14 insertions(+), 14 deletions(-) rename packages/agent-manager/src/{print => durable}/ClaudeCliProbe.ts (100%) rename packages/agent-manager/src/{print => durable}/ClaudePrintAgentService.ts (100%) rename packages/agent-manager/src/{print => durable}/ClaudePrintRunner.ts (100%) rename packages/agent-manager/src/{print => durable}/DurableAgent.ts (100%) rename packages/agent-manager/src/{print => durable}/DurableAgentRepository.ts (100%) 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 b2fe73cc..97823fa5 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,7 +16,7 @@ description: Implementation record, decisions, validation, and deviations ### Task 1.1 -- Added `packages/agent-manager/src/print/DurableAgent.ts` with the durable record, state, session health, last-result, active-run, and process-identity contracts. +- 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`. 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 index d9ec3d2a..b884a9fa 100644 --- a/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/implementation/2026-08-18-feature-durable-agents-sqlite.md @@ -16,7 +16,7 @@ description: Implementation record for the durable-agent persistence backend ## Code Structure - `packages/agent-manager/src/database/`: connection behavior, schema runner, and migration SQL. -- `packages/agent-manager/src/print/DurableAgentRepository.ts`: unchanged public adapter backed by SQLite. +- `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 diff --git a/packages/agent-manager/src/__tests__/print/DurableAgentRepository.sqlite.test.ts b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.sqlite.test.ts index 696a8426..b6556b2e 100644 --- a/packages/agent-manager/src/__tests__/print/DurableAgentRepository.sqlite.test.ts +++ b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.sqlite.test.ts @@ -3,7 +3,7 @@ import os from 'os'; import path from 'path'; import Database from 'better-sqlite3'; import { afterEach, describe, expect, it } from 'vitest'; -import { DurableAgentRepository } from '../../print/DurableAgentRepository.js'; +import { DurableAgentRepository } from '../../durable/DurableAgentRepository.js'; const roots: string[] = []; afterEach(() => { diff --git a/packages/agent-manager/src/print/ClaudeCliProbe.ts b/packages/agent-manager/src/durable/ClaudeCliProbe.ts similarity index 100% rename from packages/agent-manager/src/print/ClaudeCliProbe.ts rename to packages/agent-manager/src/durable/ClaudeCliProbe.ts diff --git a/packages/agent-manager/src/print/ClaudePrintAgentService.ts b/packages/agent-manager/src/durable/ClaudePrintAgentService.ts similarity index 100% rename from packages/agent-manager/src/print/ClaudePrintAgentService.ts rename to packages/agent-manager/src/durable/ClaudePrintAgentService.ts diff --git a/packages/agent-manager/src/print/ClaudePrintRunner.ts b/packages/agent-manager/src/durable/ClaudePrintRunner.ts similarity index 100% rename from packages/agent-manager/src/print/ClaudePrintRunner.ts rename to packages/agent-manager/src/durable/ClaudePrintRunner.ts diff --git a/packages/agent-manager/src/print/DurableAgent.ts b/packages/agent-manager/src/durable/DurableAgent.ts similarity index 100% rename from packages/agent-manager/src/print/DurableAgent.ts rename to packages/agent-manager/src/durable/DurableAgent.ts diff --git a/packages/agent-manager/src/print/DurableAgentRepository.ts b/packages/agent-manager/src/durable/DurableAgentRepository.ts similarity index 100% rename from packages/agent-manager/src/print/DurableAgentRepository.ts rename to packages/agent-manager/src/durable/DurableAgentRepository.ts diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index e4617385..1fd82253 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -44,7 +44,7 @@ export { DurableAgentRepositoryError, DurableAgentNameConflictError, ClaudePrintError, -} from './print/DurableAgent.js'; +} from './durable/DurableAgent.js'; export type { DurableAgent, DurableAgentState, @@ -53,25 +53,25 @@ export type { DurableActiveRun, DurableLastResult, ProcessIdentity, -} from './print/DurableAgent.js'; -export { DurableAgentRepository } from './print/DurableAgentRepository.js'; -export { LocalProcessInspector } from './print/DurableAgentRepository.js'; +} from './durable/DurableAgent.js'; +export { DurableAgentRepository } from './durable/DurableAgentRepository.js'; +export { LocalProcessInspector } from './durable/DurableAgentRepository.js'; export type { CreateDurableAgentInput, DurableAgentRepositoryOptions, ProcessInspector, DurableRunCompletion, -} from './print/DurableAgentRepository.js'; -export { ClaudeCliProbe } from './print/ClaudeCliProbe.js'; -export type { ClaudeCliProbeOptions } from './print/ClaudeCliProbe.js'; -export { ClaudePrintRunner } from './print/ClaudePrintRunner.js'; +} 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'; From c8e0dca36503c54a786c5d1e1d4f9e7a6f29e14e Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 19 Aug 2026 15:00:22 +0000 Subject: [PATCH 14/14] refactor(agent): unify agent mode values on durable --- .../design/2026-08-07-feature-agent-print-mode.md | 2 +- .../2026-08-18-feature-durable-agents-sqlite.md | 2 +- .../2026-08-07-feature-agent-print-mode.md | 2 +- .../2026-08-07-feature-agent-print-mode.md | 2 +- .../database/DurableAgentsDatabase.test.ts | 3 ++- .../src/__tests__/print/ClaudePrintRunner.test.ts | 2 +- .../print/DurableAgentRepository.test.ts | 2 +- .../database/migrations/003_durable_agents.sql | 2 +- .../agent-manager/src/durable/DurableAgent.ts | 7 ++++++- .../src/durable/DurableAgentRepository.ts | 8 ++++---- packages/agent-manager/src/index.ts | 1 + packages/cli/src/__tests__/commands/agent.test.ts | 15 ++++++++------- packages/cli/src/commands/agent.ts | 13 +++++-------- 13 files changed, 33 insertions(+), 28 deletions(-) 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 1ceea09e..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 @@ -52,7 +52,7 @@ interface DurableAgent { id: string; // immutable AI DevKit UUID 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: DurableAgentState; 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 index 07cfd6a1..0435d4a7 100644 --- a/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md +++ b/docs/ai/design/2026-08-18-feature-durable-agents-sqlite.md @@ -22,7 +22,7 @@ flowchart LR `durable_agents` is one flattened row per durable agent: -- Identity: `id` primary key; case-insensitive unique `name`; unconstrained `provider`; `mode` defaulting to `print`; canonical `cwd`; unique `provider_session_id`. +- 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. 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 97823fa5..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 @@ -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. 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 c49da26e..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 @@ -150,7 +150,7 @@ 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 durable agent is visible as `running`; a provider/session/protocol failure is visible as `degraded`; a successful or safely recovered agent is `ready`. diff --git a/packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts b/packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts index 03e4ea0d..e8e1617a 100644 --- a/packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts +++ b/packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts @@ -25,6 +25,7 @@ describe('durable agents schema', () => { 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'", @@ -40,7 +41,7 @@ describe('durable agents schema', () => { INSERT INTO durable_agents ( id, name, provider, mode, cwd, provider_session_id, state, session_health, created_at, updated_at - ) VALUES (?, ?, ?, 'print', '/tmp', ?, ?, 'uninitialized', ?, ?) + ) 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); diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts index 7fd57da7..5ad793de 100644 --- a/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts @@ -5,7 +5,7 @@ import type { DurableAgent } from '../../index.js'; 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/DurableAgentRepository.test.ts b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.test.ts index 9e1fd08b..f98aa274 100644 --- a/packages/agent-manager/src/__tests__/print/DurableAgentRepository.test.ts +++ b/packages/agent-manager/src/__tests__/print/DurableAgentRepository.test.ts @@ -34,7 +34,7 @@ describe('DurableAgentRepository create/list/resolve', () => { expect(agent).toMatchObject({ name: 'reviewer', provider: 'claude', - mode: 'print', + mode: 'durable', cwd: fs.realpathSync(cwd), state: 'ready', sessionHealth: 'uninitialized', diff --git a/packages/agent-manager/src/database/migrations/003_durable_agents.sql b/packages/agent-manager/src/database/migrations/003_durable_agents.sql index 44960509..202dd8c6 100644 --- a/packages/agent-manager/src/database/migrations/003_durable_agents.sql +++ b/packages/agent-manager/src/database/migrations/003_durable_agents.sql @@ -2,7 +2,7 @@ CREATE TABLE durable_agents ( id TEXT PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE UNIQUE, provider TEXT NOT NULL, - mode TEXT NOT NULL DEFAULT 'print', + 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')), diff --git a/packages/agent-manager/src/durable/DurableAgent.ts b/packages/agent-manager/src/durable/DurableAgent.ts index 87dd7461..4db7b67a 100644 --- a/packages/agent-manager/src/durable/DurableAgent.ts +++ b/packages/agent-manager/src/durable/DurableAgent.ts @@ -2,6 +2,11 @@ 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; @@ -25,7 +30,7 @@ export interface DurableAgent { id: string; name: string; provider: 'claude'; - mode: 'print'; + mode: typeof AGENT_MODES.DURABLE; cwd: string; providerSessionId: string; state: DurableAgentState; diff --git a/packages/agent-manager/src/durable/DurableAgentRepository.ts b/packages/agent-manager/src/durable/DurableAgentRepository.ts index 4440cc24..a0122d09 100644 --- a/packages/agent-manager/src/durable/DurableAgentRepository.ts +++ b/packages/agent-manager/src/durable/DurableAgentRepository.ts @@ -2,7 +2,7 @@ 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 type { DurableActiveRun, DurableAgent, ProcessIdentity, DurableRunStatus, DurableSessionHealth } from './DurableAgent.js'; +import { AGENT_MODES, type DurableActiveRun, type DurableAgent, type ProcessIdentity, type DurableRunStatus, type DurableSessionHealth } from './DurableAgent.js'; import { DurableAgentBusyError, DurableAgentNameConflictError, @@ -11,7 +11,7 @@ import { } from './DurableAgent.js'; interface DurableAgentRow { - id: string; name: string; provider: 'claude'; mode: 'print'; cwd: string; provider_session_id: string; + 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; @@ -69,8 +69,8 @@ export class DurableAgentRepository { 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', 'print', ?, ?, 'ready', 'uninitialized', ?, ?)`, - [id, input.name, cwd, providerSessionId, timestamp, timestamp]); + ) 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); diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index 1fd82253..eef400cb 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -38,6 +38,7 @@ export type { AgentRequest } from './utils/agent-requests.js'; export { getAgentRequestPath, readLatestAgentRequest, writeAgentRequest } from './utils/agent-requests.js'; export { + AGENT_MODES, DurableAgentError, DurableAgentBusyError, DurableAgentNotFoundError, diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 28f215e2..7ebd70bf 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -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(), @@ -296,7 +297,7 @@ describe('agent command', () => { it('labels durable agents as durable in list JSON without a fake pid', async () => { mockManager.listAgents.mockResolvedValue([]); mockDurableRepository.list.mockResolvedValue([{ - 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: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, }]); @@ -312,7 +313,7 @@ describe('agent command', () => { it('shows durable durable-agent detail without requiring a transcript', async () => { const durableAgent = { - 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: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, }; @@ -324,7 +325,7 @@ describe('agent command', () => { 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: durableAgent.id, provider: 'claude', mode: 'print', state: 'ready' }); + expect(output).toMatchObject({ id: durableAgent.id, provider: 'claude', mode: 'durable', state: 'ready' }); expect(output).not.toHaveProperty('conversation'); }); @@ -390,7 +391,7 @@ describe('agent command', () => { it('renders durable agents as durable without leaking the internal print mode', async () => { mockManager.listAgents.mockResolvedValue([]); mockDurableRepository.list.mockResolvedValue([{ - 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: 'ready', sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, }]); @@ -730,7 +731,7 @@ Waiting on user input`, 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(); @@ -747,7 +748,7 @@ Waiting on user input`, 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', }; mockDurableRepository.resolve.mockResolvedValue(durableAgent); mockDurableService.send.mockResolvedValue({ ...durableAgent, result: '\x1b]0;unsafe\x07review complete', exitCode: 0 }); @@ -766,7 +767,7 @@ Waiting on user input`, it('rejects timeout for a synchronous print send instead of silently ignoring it', 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', }; mockDurableRepository.resolve.mockResolvedValue(durableAgent); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index a075b1d8..96d2c5df 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -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; } @@ -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,7 +301,7 @@ export function registerAgentCommand(program: Command): void { } try { - if (mode === 'print') { + 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)}`); @@ -646,7 +643,7 @@ export function registerAgentCommand(program: Command): void { 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,