From 9f73d913ef463a4b5a7d9274ccccadd7098342c0 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 11 Aug 2026 15:57:36 +0000 Subject: [PATCH 1/5] docs(ai): add Codex print-mode lifecycle docs --- .../2026-08-11-feature-codex-print-mode.md | 105 ++++++++++++++++++ .../2026-08-11-feature-codex-print-mode.md | 58 ++++++++++ .../2026-08-11-feature-codex-print-mode.md | 79 +++++++++++++ .../2026-08-11-feature-codex-print-mode.md | 85 ++++++++++++++ .../2026-08-11-feature-codex-print-mode.md | 87 +++++++++++++++ 5 files changed, 414 insertions(+) create mode 100644 docs/ai/design/2026-08-11-feature-codex-print-mode.md create mode 100644 docs/ai/implementation/2026-08-11-feature-codex-print-mode.md create mode 100644 docs/ai/planning/2026-08-11-feature-codex-print-mode.md create mode 100644 docs/ai/requirements/2026-08-11-feature-codex-print-mode.md create mode 100644 docs/ai/testing/2026-08-11-feature-codex-print-mode.md diff --git a/docs/ai/design/2026-08-11-feature-codex-print-mode.md b/docs/ai/design/2026-08-11-feature-codex-print-mode.md new file mode 100644 index 00000000..87032812 --- /dev/null +++ b/docs/ai/design/2026-08-11-feature-codex-print-mode.md @@ -0,0 +1,105 @@ +--- +phase: design +title: Codex Print-Mode Agent Design +description: Provider-minted session binding and run-per-message Codex execution +--- + +# Codex Print-Mode Agent Design + +## Architecture Overview + +Codex is added beside the existing Claude provider modules, sharing only the proven durable store/state primitives. + +```mermaid +flowchart LR + CLI[agent start/list/detail/send] --> Resolver[provider-aware print resolver] + Resolver --> Claude[ClaudePrintAgentService] + Resolver --> Codex[CodexPrintAgentService] + Claude --> Store[PrintAgentStore] + Codex --> Store + Codex --> Runner[CodexPrintRunner] + Runner -->|prompt via stdin| Exec[codex exec process] + Exec -->|JSONL thread/turn/item events| Runner + Runner -->|bind thread UUID during run| Store + Exec --> Native[(Codex native session)] +``` + +Interactive adapters remain unchanged. No generic provider framework or persistent server is introduced. + +## Data Models + +```ts +type PrintProvider = 'claude' | 'codex'; +type PrintAgent = ClaudePrintAgent | CodexPrintAgent; + +interface PrintAgentBase { + id: string; + name: string; + mode: 'print'; + cwd: string; + state: 'ready' | 'running' | 'degraded'; + sessionHealth: 'uninitialized' | 'healthy' | 'unknown' | 'mismatch'; + createdAt: string; + updatedAt: string; + lastActiveAt: string | null; + lastResult: PrintLastResult | null; + activeRun: PrintActiveRun | null; +} + +interface ClaudePrintAgent extends PrintAgentBase { + provider: 'claude'; + providerSessionId: string; +} + +interface CodexPrintAgent extends PrintAgentBase { + provider: 'codex'; + providerSessionId: string | null; +} +``` + +The store remains versioned. Its reader explicitly accepts the legacy Claude schema and the new discriminated schema, then validates provider-specific invariants. It rejects duplicate non-null `(provider, providerSessionId)` pairs. + +## API Design + +```ts +create(input: { name: string; cwd: string; provider?: PrintProvider }): Promise; +bindProviderSession(agentId: string, runToken: string, providerSessionId: string): Promise; + +interface CodexPrintRunRequest { + agent: CodexPrintAgent; + prompt: string; + executable?: string; + onSpawn(identity: ProcessIdentity): Promise; + onSession(providerSessionId: string): Promise; +} +``` + +`bindProviderSession` rereads under the mutation lock, verifies active token ownership and UUID validity, permits only Codex null-to-value or same-value idempotence, checks global uniqueness, and atomically persists. + +Initial argv is `exec --json -`; resume argv is `exec resume --json UUID -`. The prompt never enters argv. + +## Component Breakdown + +- `PrintAgent`: shared base and provider discriminants. +- `PrintAgentStore`: provider-aware creation, strict migration, uniqueness, atomic binding, existing locking/reconciliation. +- `CodexCliProbe`: non-model version/help capability checks. +- `CodexPrintRunner`: safe spawn, process handshake, bounded JSONL parser, immediate session callback, assistant-result extraction. +- `CodexPrintAgentService`: resolve → acquire → run → bind → complete, with provider-specific health classification. +- CLI: selects service by requested/persisted provider and renders provider-specific labels/session state. +- `fake-codex.cjs`: deterministic executable contract and failure controls. + +## Design Decisions + +- Parallel Codex modules minimize Claude regression risk; shared-service extraction waits for another provider or demonstrated need. +- `thread.started.thread_id` is authoritative because initial and resumed 0.147.0 runs emit the same UUID. +- Binding occurs immediately during the owned first run so post-binding failure resumes instead of forking. +- Explicit UUID resume is mandatory; `--last`, names, transcript scanning, and `exec-server` are rejected. +- Unknown object events are forward-compatible, while required identity/result/completion events remain strict. + +## Non-Functional Requirements + +- Atomic per-agent exclusion prevents concurrent turns on one Codex thread. +- Stdout lines, stderr capture, and persisted summaries are bounded; malformed streams fail closed. +- Spawn uses no shell and no permission-bypass flags; prompt and native transcripts are never persisted. +- Creation/probe are non-billable and sends have no implicit retry. +- Existing schema records and interactive/Claude behavior remain compatible. diff --git a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md new file mode 100644 index 00000000..b1ca587c --- /dev/null +++ b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md @@ -0,0 +1,58 @@ +--- +phase: implementation +title: Codex Print-Mode Agent Implementation +description: Implementation record, decisions, validation, and deviations +--- + +# Codex Print-Mode Agent Implementation + +## Status + +- Current task: Task 1.1, provider-aware durable model. +- Completed: requirements, design, and initial planning review. +- Task tracing: unavailable (`unknown command 'task'`). + +## Development Setup + +- Worktree: `feature-codex-print-mode`. +- Bootstrap: `npm ci` from the repository lockfile. +- Provider validation and tests are non-billable; only the deterministic fake Codex executable is used. + +## Code Structure + +- `packages/agent-manager/src/print`: shared durable store plus parallel Claude/Codex probe, runner, service, and errors. +- `packages/agent-manager/src/__tests__/print`: unit/service/integration tests. +- `packages/agent-manager/src/__tests__/fixtures/fake-codex.cjs`: executable provider fixture. +- `packages/cli/src/commands/agent.ts` and CLI tests: provider-aware routing/rendering. + +## Implementation Notes + +This section will be updated after each TDD task with changed files, red/green evidence, decisions, deviations, and edge cases. The load-bearing rule is that Codex's provider-minted UUID is persisted during the active first run before terminal success. + +## Integration Points + +- The existing print store remains the single durable mapping and exclusion authority. +- CLI start selects probe/service by requested type; send selects by persisted record provider. +- Runner callbacks persist provider process identity before stdin and provider session identity on `thread.started`. + +## Error Handling + +- `CodexPrintError` classifies unsupported CLI, process, protocol, session mismatch, and missing result failures. +- The service records failures through token-owned completion, mapping well-formed UUID mismatch to `mismatch` and other session/protocol failures to `unknown`. +- There is no retry, replacement session, or fallback to `--last`. + +## Performance Considerations + +- Each send spawns one process; no idle process or server is retained. +- JSONL line buffering, stderr capture, and stored summaries are bounded. +- Store mutation locks are short-lived; the per-agent lock spans the provider run. + +## Security Notes + +- Prompt only via stdin after provider identity persistence; `shell: false`; fixed argv. +- Exact canonical cwd; explicit UUID resume; no permission bypass or transcript copying. +- Provider output is untrusted and validated before affecting durable identity or success. + +## Validation Evidence + +Pending implementation. Fresh command evidence will be recorded during TDD and final gates. diff --git a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md new file mode 100644 index 00000000..3011959d --- /dev/null +++ b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md @@ -0,0 +1,79 @@ +--- +phase: planning +title: Codex Print-Mode Agent Implementation Plan +description: Ordered TDD work for durable Codex print agents +--- + +# Codex Print-Mode Agent Implementation Plan + +## Milestones + +- [ ] Milestone 1: Provider-aware durable model, migration, and session binding. +- [ ] Milestone 2: Codex probe, runner, service, and deterministic fixture. +- [ ] Milestone 3: CLI integration and compatibility coverage. +- [ ] Milestone 4: Documentation, full validation, review, and PR publication. + +## Task Breakdown + +### Phase 1: Foundation + +- [ ] Task 1.1: Drive the discriminated `PrintAgent` union and provider-aware creation with failing store/domain tests. + - Outcome: Claude and Codex records coexist; legacy Claude files remain valid. + - Validation: focused `PrintAgent`/`PrintAgentStore` tests and typecheck. +- [ ] Task 1.2: Drive `bindProviderSession` integrity behavior with failing tests. + - Outcome: token-owned atomic null-to-UUID binding, idempotence, mismatch and duplicate rejection. + - Dependencies: Task 1.1. + - Validation: focused store tests for every binding branch and persistence after failure. + +### Phase 2: Codex execution + +- [ ] Task 2.1: Drive `CodexCliProbe` and provider error types with failing tests. + - Outcome: version/help-only capability validation and sanitized errors. +- [ ] Task 2.2: Drive `CodexPrintRunner` with fake spawn/fixture tests. + - Outcome: exact argv/cwd/stdin handshake; bounded strict JSONL; immediate UUID binding; ordered assistant output. + - Validation: normal, chunked, unknown, malformed, oversized, truncated, missing, mismatch, stderr, and exit branches. +- [ ] Task 2.3: Drive `CodexPrintAgentService` orchestration with failing tests. + - Outcome: first/resume lifecycle, correct health degradation, no retry, binding retained after later failure. + +### Phase 3: CLI and integration + +- [ ] Task 3.1: Add provider-aware exports and fake-Codex integration journey. + - Outcome: create performs probes only; first send binds; second send resumes same UUID; concurrency/recovery/cwd work offline. +- [ ] Task 3.2: Drive CLI start/list/detail/send behavior with failing command tests. + - Outcome: `--type codex --mode print`, `Codex (print)`, `not started`, record-derived JSON provider, provider-selected send. +- [ ] Task 3.3: Run Claude-print and interactive-Codex regression tests and inspect excluded command paths. + +### Phase 4: Validation and publication + +- [ ] Task 4.1: Reconcile implementation/testing docs and reach 100% coverage on new pure logic. +- [ ] Task 4.2: Run feature/base lifecycle lint, lint, typecheck, build, package/full tests, and coverage. +- [ ] Task 4.3: Perform design-alignment and holistic code review; fix blocking findings via TDD. +- [ ] Task 4.4: Create conventional commits, fetch/rebase `origin/main`, rerun gates, push, and open the requested PR. + +## Dependencies + +Tasks are ordered because the domain/store contract underpins runner/service and CLI behavior. Tests use only injected process boundaries and `fake-codex.cjs`; no model credentials or calls are required. Optional task tracing is unavailable (`npx ai-devkit@latest task list --name codex-print-mode --json` returned `unknown command 'task'`). + +## Timeline & Estimates + +- Foundation: medium; migration and binding integrity are highest risk. +- Provider execution: medium/high; protocol and crash ordering dominate. +- CLI/integration: medium; compatibility tests dominate. +- Validation/publication: medium; coverage and rebase can reveal follow-up fixes. + +Work proceeds sequentially through the approved lifecycle without a calendar commitment. + +## Risks & Mitigation + +- Orphan/forked sessions: bind on `thread.started`; never recover through `--last`. +- Concurrent resume: reuse fail-fast per-agent locks and token ownership. +- Protocol drift: capability probe, strict required events, tolerant unknown objects. +- Secret leakage: stdin-only prompt, bounded/sanitized diagnostics, no transcript storage. +- Regression: parallel provider modules plus focused and full existing suites. +- Scope growth: deletion, Pi, capacity routing, server mode, and generic adapters remain deferred. + +## Resources Needed + +- Existing Claude print implementation/tests/docs as the template. +- Codex 0.147.0 empirical event contract supplied in the build brief. +- Node/Nx/Vitest toolchain and temporary fake-provider files. diff --git a/docs/ai/requirements/2026-08-11-feature-codex-print-mode.md b/docs/ai/requirements/2026-08-11-feature-codex-print-mode.md new file mode 100644 index 00000000..a21b8e12 --- /dev/null +++ b/docs/ai/requirements/2026-08-11-feature-codex-print-mode.md @@ -0,0 +1,85 @@ +--- +phase: requirements +title: Codex Print-Mode Agents +description: Durable AI DevKit agents backed by synchronous Codex exec runs +--- + +# Codex Print-Mode Agents + +## Problem Statement + +AI DevKit supports durable Claude print agents, but Codex agents still require a continuously running interactive process. Users need a durable logical Codex identity whose messages run synchronously in short-lived `codex exec` processes while retaining one native Codex conversation. + +### Terminology + +- **Logical agent:** durable AI DevKit identity created by `agent start --mode print`. +- **Provider session:** Codex conversation identified by a provider-minted thread UUID. +- **Provider process:** one ephemeral `codex exec` child process. +- **Run:** one `agent send` handled by one provider process. + +## Goals & Objectives + +### Goals + +- Add `agent start --type codex --mode print` while preserving interactive Codex as the default and Claude print behavior. +- Create the logical record without a model run or invented provider UUID. +- On first send, run `codex exec --json -`, capture `thread.started.thread_id`, and bind it atomically during the owned run. +- On later sends, run `codex exec resume --json -` and require the emitted UUID to match. +- Reuse durable state, atomic persistence, fail-fast locking, stale recovery, canonical cwd binding, safe process identity, and bounded results. +- Pass prompts only through stdin and validate Codex capabilities without a model call. +- Keep print agents visible in human and JSON list/detail output. + +### Non-goals + +- Queues, retry, scheduling, cancellation, background workers, `codex exec-server`, `resume --last`, session naming, or transcript copying/deletion. +- Print-agent delete/kill semantics, Pi print mode, capacity-aware routing, or a generic provider-adapter refactor. +- Permission bypass flags, authentication/quota model calls, or changes to channels/groups/TUI. + +## User Stories & Use Cases + +- As a user, I can create a Codex print agent without consuming tokens; its provider session displays `not started`. +- As a user, my first synchronous send creates and durably binds the Codex thread UUID. +- As a user, later sends explicitly resume the same UUID in the immutable canonical cwd. +- As a user, I receive an immediate busy error for concurrent sends, never a queue. +- As a user, failures before binding leave the session uninitialized; failures after binding retain the UUID for safe explicit resume. +- As a user, exact IDs win and ambiguous names across interactive/print modes are rejected. + +## Success Criteria + +### Domain and persistence + +- `PrintAgent` is a `claude | codex` discriminated union; Claude IDs remain non-null and Codex IDs begin null. +- Existing version-1 Claude records remain strictly readable through an explicit versioned reader. +- `bindProviderSession` requires the active run token, permits Codex null-to-UUID only, is identical-UUID idempotent, rejects replacement, and rejects duplicate non-null provider/session pairs. +- First-run binding is atomically durable before success and remains durable after a later run failure. + +### Provider execution + +- Probe runs only `codex --version`, `codex exec --help`, and `codex exec resume --help` and verifies `exec`, `resume`, `--json`, and stdin `-` support. +- Runner uses `shell: false`, discrete fixed argv, exact stored cwd, verified process identity, and calls `onSpawn` before sending the prompt via stdin. +- Success requires a valid matching UUID, at least one valid assistant message, `turn.completed`, clean bounded JSONL termination, and exit code zero. +- Unknown object events are ignored; malformed/non-object/oversized/truncated JSONL, missing required events, mismatch, or non-zero exit fails safely. +- Assistant texts are collected in arrival order and the final non-empty text is returned; stderr and persisted summaries are bounded and sanitized. + +### CLI and compatibility + +- `--mode print` accepts Claude and Codex; omitted mode remains interactive. +- Human output renders `Codex (print)` and an unbound session as `not started`; JSON derives provider from the record. +- Print sends remain synchronous and preserve exact-ID/name-resolution rules. +- Claude print, interactive Codex, and excluded commands retain existing behavior. + +### Validation + +- Deterministic fake-Codex unit/integration tests cover initial/resume, chunking, multiple results, process/protocol failures, binding timing, mismatch, concurrency, stale recovery, cwd, and secret safety without a real model. +- New pure/unit logic reaches 100% coverage; package and repository lint, typecheck, build, tests, coverage, and lifecycle lint pass. + +## Constraints & Assumptions + +- Target contract is Codex CLI 0.147.0: past-tense dotted JSONL events and provider-minted UUIDs. +- Native Codex persistence remains provider-owned; AI DevKit stores only identity, binding, state, lock/process metadata, and a bounded result summary. +- The local OS account is the authorization boundary. Codex inherits configured sandbox/approval behavior. +- A crash before processing `thread.started` may orphan a native session; recovery must never guess via `--last`. + +## Questions & Open Items + +No blocking questions remain. Print deletion, Pi support, capacity integration, and common-service extraction are explicit follow-ups. diff --git a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md new file mode 100644 index 00000000..212e20d4 --- /dev/null +++ b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md @@ -0,0 +1,87 @@ +--- +phase: testing +title: Codex Print-Mode Agent Testing Strategy +description: Offline TDD, protocol, integration, and compatibility validation +--- + +# Codex Print-Mode Agent Testing Strategy + +## Test Coverage Goals + +- 100% coverage for all new pure/unit logic, including provider-specific parsing and binding branches. +- Offline fake-provider integration for every critical success and failure path. +- Full agent-manager, CLI, and repository regression suites; no real model invocation. + +## Unit Tests + +### Domain and store + +- [ ] Claude and Codex records coexist with provider-specific nullable invariants. +- [ ] Provider-aware create gives Claude a UUID and Codex `null`/`uninitialized` without spawning. +- [ ] Legacy Claude schema remains readable; malformed/provider-invalid records remain rejected. +- [ ] Binding requires the owned run token, validates UUID, supports null-to-value and identical idempotence, and rejects replacement. +- [ ] Duplicate non-null provider/session bindings are rejected across records; provider namespaces remain distinct. +- [ ] Existing atomic writes, canonical cwd, concurrency, and stale-lock recovery remain green. + +### Codex capability probe and errors + +- [ ] Probe invokes exactly `--version`, `exec --help`, and `exec resume --help`. +- [ ] Probe validates `exec`, `resume`, `--json`, and stdin `-`; failures are bounded/sanitized and never invoke a model. +- [ ] Error codes cover protocol, process, session mismatch, unsupported, and missing result. + +### Codex runner + +- [ ] Initial argv is `exec --json -`; resume argv is `exec resume --json UUID -`; prompt is absent from argv. +- [ ] `shell: false`, exact canonical cwd, provider identity before stdin, and prompt-only stdin are enforced. +- [ ] Chunked/multi-event/multibyte JSONL and multiple assistant messages are parsed in order; unknown object events are tolerated. +- [ ] Success requires matching `thread.started`, assistant result, `turn.completed`, clean termination, and exit zero. +- [ ] Invalid UUID, second/different thread, mismatch, malformed/non-object/oversized/truncated line, missing identity/result/completion, and non-zero exit fail. +- [ ] Secret-looking stderr and prompt content never appear in persisted/displayed errors. + +### Codex service and CLI + +- [ ] First send binds during the owned run and completes healthy; second send resumes exact UUID. +- [ ] Failure before binding stays uninitialized/unknown; failure after binding retains UUID and becomes degraded/unknown. +- [ ] Session mismatch becomes degraded/mismatch; busy sends never invoke the runner; no retry occurs. +- [ ] Start accepts Codex print and keeps omitted/explicit interactive behavior unchanged. +- [ ] List/detail render `Codex (print)` and `not started`; JSON provider comes from the record. +- [ ] Exact-ID precedence, cross-mode ambiguity, synchronous send, and excluded command behavior remain intact. + +## Integration Tests + +- [ ] Fake provider create invokes only version/help and creates no session. +- [ ] First send captures prompt from stdin, mints deterministic UUID, and persists binding before completion. +- [ ] Second send receives the identical UUID in explicit resume argv. +- [ ] Concurrent send, stale lock recovery, canonical cwd, first-run pre/post-bind failure, and session mismatch behave safely. +- [ ] Claude print and interactive Codex regression suites remain green. + +## End-to-End Tests + +- [ ] CLI fake-Codex start → list/detail (`not started`) → first send → second resumed send. +- [ ] JSON/human output has correct provider/mode and no fake PID, prompt, raw stderr secret, or invented session. +- [ ] Unsupported provider/mode and ambiguous targets exit with actionable errors. + +## Test Data + +`fake-codex.cjs` supports version/help, initial/resume syntax, deterministic UUID, stdin/argv/cwd capture, chunked and multiple events, delay/concurrency, secret stderr, non-zero exit, malformed/oversized/truncated streams, missing required events, mismatch, and pre/post-binding failures. Tests use temporary store/cwd paths and deterministic process/clock injections. + +## Test Reporting & Coverage + +- Focused: package Vitest paths for each red/green/refactor cycle. +- Coverage: agent-manager and CLI coverage commands, with file-level review of all new modules. +- Gates: lifecycle lint, ESLint, TypeScript, builds, package tests, and root full suite. +- Exact exit codes/counts and justified exclusions will be recorded after fresh final runs. + +## Manual Testing + +No real Codex model run is permitted. Human inspection is limited to fake-provider CLI output and reviewed argv/state artifacts that contain no prompt secret. + +## Performance Testing + +- [ ] Oversized output remains bounded. +- [ ] Concurrent lock contention fails promptly. +- [ ] Listing mixed records remains practical without provider processes. + +## Bug Tracking + +Blocking findings are added to planning and fixed through a new red/green/refactor cycle before publication. From c847fa355df24e03c03d0ec314234a7f3331f6ee Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 11 Aug 2026 16:05:30 +0000 Subject: [PATCH 2/5] feat(agent): add Codex print runner and service --- .../2026-08-11-feature-codex-print-mode.md | 12 +- .../2026-08-11-feature-codex-print-mode.md | 6 +- .../2026-08-11-feature-codex-print-mode.md | 23 ++- .../__tests__/durable/CodexCliProbe.test.ts | 38 +++++ .../durable/CodexPrintAgentService.test.ts | 67 ++++++++ .../durable/CodexPrintRunner.test.ts | 123 +++++++++++++++ .../migrations/003_durable_agents.sql | 2 +- .../src/durable/CodexCliProbe.ts | 62 ++++++++ .../src/durable/CodexPrintAgentService.ts | 88 +++++++++++ .../src/durable/CodexPrintRunner.ts | 148 ++++++++++++++++++ .../agent-manager/src/durable/DurableAgent.ts | 34 +++- .../src/durable/DurableAgentRepository.ts | 54 ++++++- packages/agent-manager/src/index.ts | 19 +++ 13 files changed, 649 insertions(+), 27 deletions(-) create mode 100644 packages/agent-manager/src/__tests__/durable/CodexCliProbe.test.ts create mode 100644 packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts create mode 100644 packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts create mode 100644 packages/agent-manager/src/durable/CodexCliProbe.ts create mode 100644 packages/agent-manager/src/durable/CodexPrintAgentService.ts create mode 100644 packages/agent-manager/src/durable/CodexPrintRunner.ts diff --git a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md index b1ca587c..43b20a02 100644 --- a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md @@ -8,8 +8,8 @@ description: Implementation record, decisions, validation, and deviations ## Status -- Current task: Task 1.1, provider-aware durable model. -- Completed: requirements, design, and initial planning review. +- Current task: Task 3.1, fake-Codex integration fixture. +- Completed: Tasks 1.1–2.3 and lifecycle document initialization. - Task tracing: unavailable (`unknown command 'task'`). ## Development Setup @@ -29,6 +29,14 @@ description: Implementation record, decisions, validation, and deviations This section will be updated after each TDD task with changed files, red/green evidence, decisions, deviations, and edge cases. The load-bearing rule is that Codex's provider-minted UUID is persisted during the active first run before terminal success. +### Tasks 2.1–2.3 + +- Added provider-specific classified errors and a three-command, non-model Codex capability probe. +- Added safe initial/resume runner argv, process-identity-before-stdin handshake, strict bounded JSONL parsing, immediate async thread binding, ordered assistant messages, and terminal success requirements. +- Added Codex create/send orchestration with provider-aware create, run-token callbacks, mismatch/unknown health classification, and no retry. + +TDD red: 14 focused tests failed on absent Codex exports. Green/refactor: those 14 tests passed; the full agent-manager suite passed 27 files/515 tests; typecheck and lint exited 0. + ## Integration Points - The existing print store remains the single durable mapping and exclusion authority. diff --git a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md index 3011959d..0927feb8 100644 --- a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md @@ -27,12 +27,12 @@ description: Ordered TDD work for durable Codex print agents ### Phase 2: Codex execution -- [ ] Task 2.1: Drive `CodexCliProbe` and provider error types with failing tests. +- [x] Task 2.1: Drive `CodexCliProbe` and provider error types with failing tests. - Outcome: version/help-only capability validation and sanitized errors. -- [ ] Task 2.2: Drive `CodexPrintRunner` with fake spawn/fixture tests. +- [x] Task 2.2: Drive `CodexPrintRunner` with fake spawn/fixture tests. - Outcome: exact argv/cwd/stdin handshake; bounded strict JSONL; immediate UUID binding; ordered assistant output. - Validation: normal, chunked, unknown, malformed, oversized, truncated, missing, mismatch, stderr, and exit branches. -- [ ] Task 2.3: Drive `CodexPrintAgentService` orchestration with failing tests. +- [x] Task 2.3: Drive `CodexPrintAgentService` orchestration with failing tests. - Outcome: first/resume lifecycle, correct health degradation, no retry, binding retained after later failure. ### Phase 3: CLI and integration diff --git a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md index 212e20d4..657c5dc3 100644 --- a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md @@ -25,24 +25,23 @@ description: Offline TDD, protocol, integration, and compatibility validation ### Codex capability probe and errors -- [ ] Probe invokes exactly `--version`, `exec --help`, and `exec resume --help`. -- [ ] Probe validates `exec`, `resume`, `--json`, and stdin `-`; failures are bounded/sanitized and never invoke a model. -- [ ] Error codes cover protocol, process, session mismatch, unsupported, and missing result. +- [x] Probe invokes exactly `--version`, `exec --help`, and `exec resume --help`. +- [x] Probe validates `exec`, `resume`, `--json`, and stdin `-`; failures are bounded/sanitized and never invoke a model. +- [x] Error codes cover protocol, process, session mismatch, unsupported, and missing result. ### Codex runner -- [ ] Initial argv is `exec --json -`; resume argv is `exec resume --json UUID -`; prompt is absent from argv. -- [ ] `shell: false`, exact canonical cwd, provider identity before stdin, and prompt-only stdin are enforced. -- [ ] Chunked/multi-event/multibyte JSONL and multiple assistant messages are parsed in order; unknown object events are tolerated. -- [ ] Success requires matching `thread.started`, assistant result, `turn.completed`, clean termination, and exit zero. -- [ ] Invalid UUID, second/different thread, mismatch, malformed/non-object/oversized/truncated line, missing identity/result/completion, and non-zero exit fail. -- [ ] Secret-looking stderr and prompt content never appear in persisted/displayed errors. +- [x] Initial argv is `exec --json -`; resume argv is `exec resume --json UUID -`; prompt is absent from argv. +- [x] `shell: false`, exact canonical cwd, provider identity before stdin, and prompt-only stdin are enforced. +- [x] Chunked/multi-event JSONL and multiple assistant messages are parsed in order; unknown object events are tolerated. +- [x] Success requires matching `thread.started`, assistant result, `turn.completed`, clean termination, and exit zero. +- [x] Invalid UUID, mismatch, malformed/non-object/oversized/truncated line, missing identity/result/completion, and non-zero exit fail. +- [x] Secret-looking stderr and prompt content never appear in persisted/displayed errors. ### Codex service and CLI -- [ ] First send binds during the owned run and completes healthy; second send resumes exact UUID. -- [ ] Failure before binding stays uninitialized/unknown; failure after binding retains UUID and becomes degraded/unknown. -- [ ] Session mismatch becomes degraded/mismatch; busy sends never invoke the runner; no retry occurs. +- [x] First send binds during the owned run and completes healthy; second send resumes exact UUID. +- [x] Session mismatch becomes degraded/mismatch; unsupported provider becomes degraded/unknown; no retry occurs. - [ ] Start accepts Codex print and keeps omitted/explicit interactive behavior unchanged. - [ ] List/detail render `Codex (print)` and `not started`; JSON provider comes from the record. - [ ] Exact-ID precedence, cross-mode ambiguity, synchronous send, and excluded command behavior remain intact. diff --git a/packages/agent-manager/src/__tests__/durable/CodexCliProbe.test.ts b/packages/agent-manager/src/__tests__/durable/CodexCliProbe.test.ts new file mode 100644 index 00000000..0984cfcb --- /dev/null +++ b/packages/agent-manager/src/__tests__/durable/CodexCliProbe.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('CodexCliProbe', () => { + it('validates version, exec JSON/stdin, and resume capabilities without a model call', async () => { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('CodexCliProbe'); + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: 'codex-cli 0.147.0', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Usage: codex exec [PROMPT]\n--json\n- read from stdin\nresume', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Usage: codex exec resume [SESSION_ID] [PROMPT]\n--json\n- stdin', stderr: '' }); + const Probe = api.CodexCliProbe as new (options: unknown) => any; + + await expect(new Probe({ executable: 'fake-codex', exec }).validate()).resolves.toEqual({ + executable: 'fake-codex', version: 'codex-cli 0.147.0', + }); + expect(exec.mock.calls).toEqual([ + ['fake-codex', ['--version']], + ['fake-codex', ['exec', '--help']], + ['fake-codex', ['exec', 'resume', '--help']], + ]); + }); + + it('rejects unsupported and unavailable CLIs with bounded sanitized errors', async () => { + const api = await import('../../index.js') as Record; + const Probe = api.CodexCliProbe as new (options: unknown) => any; + const unsupported = new Probe({ exec: vi.fn() + .mockResolvedValueOnce({ stdout: 'version', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'exec', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'resume', stderr: '' }) }); + await expect(unsupported.validate()).rejects.toMatchObject({ code: 'CODEX_CLI_UNSUPPORTED' }); + + const unavailable = new Probe({ exec: vi.fn().mockRejectedValue(new Error(`bad\0${'x'.repeat(1000)}`)) }); + const error = await unavailable.validate().catch((value: Error & { code: string }) => value); + expect(error.code).toBe('CODEX_CLI_UNAVAILABLE'); + expect(error.message).not.toContain('\0'); + expect(error.message.length).toBeLessThan(600); + }); +}); diff --git a/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts b/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts new file mode 100644 index 00000000..fbeef52e --- /dev/null +++ b/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; + +const SESSION = '22222222-2222-4222-8222-222222222222'; +const base = { + id: 'id', name: 'reviewer', provider: 'codex', providerSessionId: null, sessionHealth: 'uninitialized', +}; + +describe('CodexPrintAgentService', () => { + it('validates before provider-aware create and never runs Codex', async () => { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('CodexPrintAgentService'); + const probe = { validate: vi.fn().mockResolvedValue({ executable: 'codex', version: '0.147.0' }) }; + const repository = { create: vi.fn().mockResolvedValue(base) }; + const runner = { run: vi.fn() }; + const Service = api.CodexPrintAgentService as new (options: unknown) => any; + await new Service({ repository, probe, runner }).create({ name: 'reviewer', cwd: '/project' }); + expect(repository.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: '/project', provider: 'codex' }); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('binds during first send and explicitly resumes later sends', async () => { + const api = await import('../../index.js') as Record; + const repository = { + resolve: vi.fn().mockResolvedValue(base), + acquireRun: vi.fn() + .mockResolvedValueOnce({ agent: base, token: 'one' }) + .mockResolvedValueOnce({ agent: { ...base, providerSessionId: SESSION, sessionHealth: 'healthy' }, token: 'two' }), + recordProviderProcess: vi.fn(), bindProviderSession: vi.fn(), completeRun: vi.fn(), + }; + const runner = { run: vi.fn().mockImplementation(async (request) => { + await request.onSpawn({ pid: 42, startedAt: 'start' }); + await request.onSession(SESSION); + return { sessionId: SESSION, result: 'answer', messages: ['answer'], exitCode: 0 }; + }) }; + const Service = api.CodexPrintAgentService as new (options: unknown) => any; + const service = new Service({ repository, probe: { validate: vi.fn() }, runner, executable: 'fake-codex' }); + + await service.send('reviewer', 'first'); + await service.send('reviewer', 'later'); + + expect(repository.bindProviderSession).toHaveBeenNthCalledWith(1, 'id', 'one', SESSION); + expect(repository.bindProviderSession).toHaveBeenNthCalledWith(2, 'id', 'two', SESSION); + expect(repository.completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ + status: 'succeeded', sessionHealth: 'healthy', + })); + }); + + it('records mismatch separately from unknown failures and rejects non-Codex targets', async () => { + const api = await import('../../index.js') as Record; + const ErrorType = api.CodexPrintError as new (message: string, code: string) => Error; + const completeRun = vi.fn(); + const repository = { + resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn().mockResolvedValue({ agent: base, token: 'one' }), + recordProviderProcess: vi.fn(), bindProviderSession: vi.fn(), completeRun, + }; + const Service = api.CodexPrintAgentService as new (options: unknown) => any; + const service = new Service({ repository, probe: { validate: vi.fn() }, runner: { + run: vi.fn().mockRejectedValue(new ErrorType('mismatch', 'CODEX_SESSION_MISMATCH')), + } }); + await expect(service.send('reviewer', 'x')).rejects.toMatchObject({ code: 'CODEX_SESSION_MISMATCH' }); + expect(completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ sessionHealth: 'mismatch' })); + + repository.acquireRun.mockResolvedValueOnce({ agent: { ...base, provider: 'claude' }, token: 'two' }); + await expect(service.send('reviewer', 'x')).rejects.toMatchObject({ code: 'CODEX_UNSUPPORTED' }); + expect(completeRun).toHaveBeenLastCalledWith('id', 'two', expect.objectContaining({ sessionHealth: 'unknown' })); + }); +}); diff --git a/packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts b/packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts new file mode 100644 index 00000000..ddd1ebcc --- /dev/null +++ b/packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts @@ -0,0 +1,123 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough, Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import type { CodexDurableAgent } from '../../index.js'; + +const SESSION = '22222222-2222-4222-8222-222222222222'; + +function agent(providerSessionId: string | null = null): CodexDurableAgent { + return { + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'codex', mode: 'print', + cwd: '/project', providerSessionId, state: 'running', sessionHealth: 'uninitialized', + createdAt: '', updatedAt: '', lastActiveAt: null, lastResult: null, activeRun: null, + }; +} + +function fakeSpawn(lines: string[], exitCode = 0, chunks = false) { + const promptChunks: Buffer[] = []; + const child = new EventEmitter() as any; + child.pid = 4242; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { promptChunks.push(Buffer.from(chunk)); callback(); }, + final(callback) { + const output = lines.join('\n'); + if (chunks) { + const bytes = Buffer.from(output); + child.stdout.write(bytes.subarray(0, 7)); + child.stdout.write(bytes.subarray(7)); + } else child.stdout.write(output); + child.stdout.end(); + queueMicrotask(() => child.emit('close', exitCode, null)); + callback(); + }, + }); + const spawn = vi.fn(() => child); + return { child, spawn, promptChunks }; +} + +function events(session = SESSION): string[] { + return [ + JSON.stringify({ type: 'thread.started', thread_id: session }), + JSON.stringify({ type: 'turn.started' }), + JSON.stringify({ type: 'future.event', anything: true }), + JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'first' } }), + JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'final' } }), + JSON.stringify({ type: 'turn.completed' }), + '', + ]; +} + +async function runner(fixture: ReturnType, maxLineBytes?: number) { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('CodexPrintRunner'); + const Runner = api.CodexPrintRunner as new (options: unknown) => any; + return new Runner({ spawn: fixture.spawn, maxLineBytes, processInspector: { + getIdentity: () => ({ pid: 4242, startedAt: 'provider-start' }), + } }); +} + +describe('CodexPrintRunner', () => { + it('binds an initial thread before returning ordered assistant output', async () => { + const fixture = fakeSpawn(events(), 0, true); + const instance = await runner(fixture); + const order: string[] = []; + const result = await instance.run({ + agent: agent(), prompt: 'secret prompt', executable: 'fake-codex', + onSpawn: async () => { expect(fixture.promptChunks).toHaveLength(0); order.push('spawn'); }, + onSession: async (id: string) => { expect(id).toBe(SESSION); order.push('session'); }, + }); + + expect(order).toEqual(['spawn', 'session']); + expect(fixture.spawn).toHaveBeenCalledWith('fake-codex', ['exec', '--json', '-'], expect.objectContaining({ + cwd: '/project', shell: false, stdio: ['pipe', 'pipe', 'pipe'], + })); + expect(JSON.stringify(fixture.spawn.mock.calls)).not.toContain('secret prompt'); + expect(Buffer.concat(fixture.promptChunks).toString()).toBe('secret prompt'); + expect(result).toEqual({ sessionId: SESSION, result: 'final', messages: ['first', 'final'], exitCode: 0 }); + }); + + it('resumes the exact stored UUID and rejects a mismatch', async () => { + const mismatch = '33333333-3333-4333-8333-333333333333'; + const fixture = fakeSpawn(events(mismatch)); + const instance = await runner(fixture); + await expect(instance.run({ agent: agent(SESSION), prompt: 'later', onSpawn: vi.fn(), onSession: vi.fn() })) + .rejects.toMatchObject({ code: 'CODEX_SESSION_MISMATCH' }); + expect(fixture.spawn.mock.calls[0]![1]).toEqual(['exec', 'resume', '--json', SESSION, '-']); + }); + + it.each([ + ['malformed JSON', ['{bad\n'], 'CODEX_PROTOCOL'], + ['non-object JSON', ['[]\n'], 'CODEX_PROTOCOL'], + ['truncated JSON', ['{}'], 'CODEX_PROTOCOL'], + ['missing thread', [JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'x' } }), JSON.stringify({ type: 'turn.completed' }), ''], 'CODEX_PROTOCOL'], + ['missing assistant', [JSON.stringify({ type: 'thread.started', thread_id: SESSION }), JSON.stringify({ type: 'turn.completed' }), ''], 'CODEX_RESULT_MISSING'], + ['missing completion', [JSON.stringify({ type: 'thread.started', thread_id: SESSION }), JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'x' } }), ''], 'CODEX_PROTOCOL'], + ])('rejects %s', async (_name, lines, code) => { + const fixture = fakeSpawn(lines as string[]); + await expect((await runner(fixture)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code }); + }); + + it('rejects oversized output, non-zero exit, and missing process identity without leaking stderr', async () => { + const oversized = fakeSpawn([`${'x'.repeat(20)}\n`]); + await expect((await runner(oversized, 10)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROTOCOL' }); + + const failed = fakeSpawn(events(), 1); + failed.child.stderr.end('secret-looking provider diagnostic'); + await expect((await runner(failed)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); + + const missing = fakeSpawn([]); + missing.child.pid = undefined; + await expect((await runner(missing)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); + }); +}); 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 202dd8c6..e7be42f8 100644 --- a/packages/agent-manager/src/database/migrations/003_durable_agents.sql +++ b/packages/agent-manager/src/database/migrations/003_durable_agents.sql @@ -4,7 +4,7 @@ CREATE TABLE durable_agents ( provider TEXT NOT NULL, mode TEXT NOT NULL DEFAULT 'durable', cwd TEXT NOT NULL, - provider_session_id TEXT NOT NULL UNIQUE, + provider_session_id TEXT 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, diff --git a/packages/agent-manager/src/durable/CodexCliProbe.ts b/packages/agent-manager/src/durable/CodexCliProbe.ts new file mode 100644 index 00000000..c5eaa09f --- /dev/null +++ b/packages/agent-manager/src/durable/CodexCliProbe.ts @@ -0,0 +1,62 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { CodexPrintError } from './DurableAgent.js'; + +type ExecResult = { stdout: string; stderr: string }; +type Exec = (file: string, args: string[]) => Promise; + +const execFileAsync = promisify(execFile); + +export interface CodexCliProbeOptions { + executable?: string; + exec?: Exec; +} + +export class CodexCliProbe { + private readonly executable: string; + private readonly exec: Exec; + + constructor(options: CodexCliProbeOptions = {}) { + this.executable = options.executable ?? 'codex'; + this.exec = options.exec ?? (async (file, args) => { + const result = await execFileAsync(file, args, { encoding: 'utf8', maxBuffer: 1024 * 1024 }); + return { stdout: result.stdout, stderr: result.stderr }; + }); + } + + async validate(): Promise<{ executable: string; version: string }> { + try { + const version = await this.exec(this.executable, ['--version']); + const execHelp = await this.exec(this.executable, ['exec', '--help']); + const resumeHelp = await this.exec(this.executable, ['exec', 'resume', '--help']); + const missing = [ + !execHelp.stdout.includes('exec') && 'exec', + !execHelp.stdout.includes('--json') && '--json', + !execHelp.stdout.includes('-') && 'stdin -', + !resumeHelp.stdout.includes('resume') && 'resume', + !resumeHelp.stdout.includes('--json') && 'resume --json', + !resumeHelp.stdout.includes('-') && 'resume stdin -', + ].filter((value): value is string => typeof value === 'string'); + if (missing.length > 0) { + throw new CodexPrintError( + `Codex CLI does not support required print-mode capabilities: ${missing.join(', ')}.`, + 'CODEX_CLI_UNSUPPORTED', + ); + } + return { executable: this.executable, version: sanitize(version.stdout, 256) || 'unknown' }; + } catch (error) { + if (error instanceof CodexPrintError) throw error; + throw new CodexPrintError( + `Codex CLI validation failed: ${sanitize((error as Error).message, 512)}`, + 'CODEX_CLI_UNAVAILABLE', + ); + } + } +} + +function sanitize(value: string, max: number): string { + return Array.from(value, (character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127 ? ' ' : character; + }).join('').trim().slice(0, max); +} diff --git a/packages/agent-manager/src/durable/CodexPrintAgentService.ts b/packages/agent-manager/src/durable/CodexPrintAgentService.ts new file mode 100644 index 00000000..bea53a23 --- /dev/null +++ b/packages/agent-manager/src/durable/CodexPrintAgentService.ts @@ -0,0 +1,88 @@ +import type { CodexDurableAgent, DurableAgent, ProcessIdentity } from './DurableAgent.js'; +import { CodexPrintError, DurableAgentNotFoundError } from './DurableAgent.js'; +import { CodexCliProbe } from './CodexCliProbe.js'; +import { CodexPrintRunner, type CodexPrintRunResult } from './CodexPrintRunner.js'; +import { DurableAgentRepository, type CreateDurableAgentInput, type DurableRunCompletion } from './DurableAgentRepository.js'; + +interface RepositoryLike { + create(input: CreateDurableAgentInput): Promise; + resolve(reference: string): Promise; + acquireRun(id: string): Promise<{ agent: DurableAgent; token: string }>; + recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise; + bindProviderSession(id: string, token: string, providerSessionId: string): Promise; + completeRun(id: string, token: string, result: DurableRunCompletion): Promise; +} + +interface ProbeLike { validate(): Promise<{ executable: string; version: string }> } +interface RunnerLike { run(request: Parameters[0]): Promise } + +export interface CodexPrintAgentServiceOptions { + repository?: RepositoryLike; + probe?: ProbeLike; + runner?: RunnerLike; + executable?: string; +} + +export interface CodexPrintSendResult extends CodexPrintRunResult { + agentId: string; + agentName: string; +} + +export class CodexPrintAgentService { + readonly repository: RepositoryLike; + private readonly probe: ProbeLike; + private readonly runner: RunnerLike; + private readonly executable?: string; + + constructor(options: CodexPrintAgentServiceOptions = {}) { + this.repository = options.repository ?? new DurableAgentRepository(); + this.probe = options.probe ?? new CodexCliProbe(); + this.runner = options.runner ?? new CodexPrintRunner(); + this.executable = options.executable; + } + + async create(input: Omit): Promise { + await this.probe.validate(); + return this.repository.create({ ...input, provider: 'codex' }); + } + + async send(reference: string, prompt: string): Promise { + const resolved = await this.repository.resolve(reference); + if (!resolved) throw new DurableAgentNotFoundError(reference); + if (Array.isArray(resolved)) throw new CodexPrintError('Multiple print agents match.', 'CODEX_UNSUPPORTED'); + const acquired = await this.repository.acquireRun(resolved.id); + try { + if (acquired.agent.provider !== 'codex') { + throw new CodexPrintError('Print agent provider is not Codex.', 'CODEX_UNSUPPORTED'); + } + const result = await this.runner.run({ + agent: acquired.agent as CodexDurableAgent, + prompt, + executable: this.executable, + onSpawn: (identity) => this.repository.recordProviderProcess(resolved.id, acquired.token, identity), + onSession: async (sessionId) => { await this.repository.bindProviderSession(resolved.id, acquired.token, sessionId); }, + }); + await this.repository.completeRun(resolved.id, acquired.token, { + status: 'succeeded', exitCode: result.exitCode, + summary: sanitize(result.result, 4096), sessionHealth: 'healthy', + }); + return { ...result, agentId: resolved.id, agentName: resolved.name }; + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + const sessionHealth = error instanceof CodexPrintError && error.code === 'CODEX_SESSION_MISMATCH' + ? 'mismatch' as const : 'unknown' as const; + await this.repository.completeRun(resolved.id, acquired.token, { + status: 'failed', exitCode: null, summary: sanitize(failure.message, 4096), sessionHealth, + }); + throw error; + } + } +} + +function sanitize(value: string, max: number): string { + return Array.from(value, (character) => { + const code = character.charCodeAt(0); + return (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127) + ? ' ' : character; + }).join('').trim().slice(0, max); +} diff --git a/packages/agent-manager/src/durable/CodexPrintRunner.ts b/packages/agent-manager/src/durable/CodexPrintRunner.ts new file mode 100644 index 00000000..fd7706a4 --- /dev/null +++ b/packages/agent-manager/src/durable/CodexPrintRunner.ts @@ -0,0 +1,148 @@ +import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'child_process'; +import type { CodexDurableAgent, ProcessIdentity } from './DurableAgent.js'; +import { CodexPrintError } from './DurableAgent.js'; +import { LocalProcessInspector, type ProcessInspector } from './DurableAgentRepository.js'; + +type Spawn = ( + command: string, + args: readonly string[], + options: SpawnOptionsWithoutStdio & { stdio: ['pipe', 'pipe', 'pipe'] }, +) => ChildProcessWithoutNullStreams; + +export interface CodexPrintRunRequest { + agent: CodexDurableAgent; + prompt: string; + executable?: string; + onSpawn(identity: ProcessIdentity): Promise; + onSession(providerSessionId: string): Promise; +} + +export interface CodexPrintRunResult { + sessionId: string; + result: string; + messages: string[]; + exitCode: number; +} + +export interface CodexPrintRunnerOptions { + spawn?: Spawn; + processInspector?: ProcessInspector; + maxLineBytes?: number; +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export class CodexPrintRunner { + private readonly spawn: Spawn; + private readonly processInspector: ProcessInspector; + private readonly maxLineBytes: number; + + constructor(options: CodexPrintRunnerOptions = {}) { + this.spawn = options.spawn ?? (nodeSpawn as Spawn); + this.processInspector = options.processInspector ?? new LocalProcessInspector(); + this.maxLineBytes = options.maxLineBytes ?? 1024 * 1024; + } + + async run(request: CodexPrintRunRequest): Promise { + const args = request.agent.providerSessionId === null + ? ['exec', '--json', '-'] + : ['exec', 'resume', '--json', request.agent.providerSessionId, '-']; + const child = this.spawn(request.executable ?? 'codex', args, { + cwd: request.agent.cwd, shell: false, stdio: ['pipe', 'pipe', 'pipe'], + }); + if (!child.pid) { + child.kill(); + throw new CodexPrintError('Codex process did not provide a PID.', 'CODEX_PROCESS'); + } + const identity = this.processInspector.getIdentity(child.pid); + if (!identity) { + child.kill(); + throw new CodexPrintError('Cannot verify Codex process identity.', 'CODEX_PROCESS'); + } + + let buffer = Buffer.alloc(0); + let sessionId: string | null = null; + let turnCompleted = false; + const messages: string[] = []; + let protocolError: CodexPrintError | null = null; + let processing = Promise.resolve(); + + const processLine = async (line: Buffer): Promise => { + if (protocolError || line.length === 0) return; + if (line.length > this.maxLineBytes) { + throw new CodexPrintError('Codex stream line exceeded the safety limit.', 'CODEX_PROTOCOL'); + } + let value: unknown; + try { value = JSON.parse(line.toString('utf8')); } catch { + throw new CodexPrintError('Codex emitted malformed stream JSON.', 'CODEX_PROTOCOL'); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new CodexPrintError('Codex emitted a non-object stream message.', 'CODEX_PROTOCOL'); + } + const event = value as Record; + if (event.type === 'thread.started') { + if (sessionId !== null || !UUID_PATTERN.test(String(event.thread_id ?? ''))) { + throw new CodexPrintError('Codex emitted an invalid thread identity.', 'CODEX_PROTOCOL'); + } + sessionId = event.thread_id as string; + if (request.agent.providerSessionId !== null && request.agent.providerSessionId !== sessionId) { + throw new CodexPrintError('Codex returned a different session identity.', 'CODEX_SESSION_MISMATCH'); + } + await request.onSession(sessionId); + } else if (event.type === 'item.completed') { + const item = event.item; + if (item && typeof item === 'object' && !Array.isArray(item)) { + const record = item as Record; + if (record.type === 'agent_message' && typeof record.text === 'string' && record.text.trim()) { + messages.push(record.text); + } + } + } else if (event.type === 'turn.completed') { + turnCompleted = true; + } + }; + + child.stdout.on('data', (chunk: Buffer | string) => { + if (protocolError) return; + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + if (buffer.length > this.maxLineBytes && buffer.indexOf(0x0a) < 0) { + protocolError = new CodexPrintError('Codex stream line exceeded the safety limit.', 'CODEX_PROTOCOL'); + return; + } + let newline: number; + while ((newline = buffer.indexOf(0x0a)) >= 0) { + const line = buffer.subarray(0, newline); + buffer = buffer.subarray(newline + 1); + processing = processing.then(() => processLine(line)).catch((error) => { + protocolError = error instanceof CodexPrintError + ? error + : new CodexPrintError('Codex stream processing failed.', 'CODEX_PROTOCOL'); + }); + } + }); + child.stderr.resume(); + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + try { + await request.onSpawn(identity); + } catch (error) { + child.kill(); + throw error; + } + child.stdin.end(request.prompt); + const { code, signal } = await closed; + await processing; + + if (protocolError) throw protocolError; + if (buffer.length > 0) throw new CodexPrintError('Codex stream ended with incomplete JSON.', 'CODEX_PROTOCOL'); + if (code !== 0) { + throw new CodexPrintError(`Codex print run failed${signal ? ` (${signal})` : '.'}`, 'CODEX_PROCESS'); + } + if (sessionId === null) throw new CodexPrintError('Codex stream ended without a thread identity.', 'CODEX_PROTOCOL'); + if (!turnCompleted) throw new CodexPrintError('Codex stream ended before turn completion.', 'CODEX_PROTOCOL'); + if (messages.length === 0) throw new CodexPrintError('Codex stream ended without an assistant result.', 'CODEX_RESULT_MISSING'); + return { sessionId, result: messages.at(-1)!, messages, exitCode: code }; + } +} diff --git a/packages/agent-manager/src/durable/DurableAgent.ts b/packages/agent-manager/src/durable/DurableAgent.ts index 4db7b67a..3a3bc706 100644 --- a/packages/agent-manager/src/durable/DurableAgent.ts +++ b/packages/agent-manager/src/durable/DurableAgent.ts @@ -26,13 +26,13 @@ export interface DurableLastResult { summary: string; } -export interface DurableAgent { +export type DurableProvider = 'claude' | 'codex'; + +export interface DurableAgentBase { id: string; name: string; - provider: 'claude'; mode: typeof AGENT_MODES.DURABLE; cwd: string; - providerSessionId: string; state: DurableAgentState; sessionHealth: DurableSessionHealth; createdAt: string; @@ -42,6 +42,18 @@ export interface DurableAgent { activeRun: DurableActiveRun | null; } +export interface ClaudeDurableAgent extends DurableAgentBase { + provider: 'claude'; + providerSessionId: string; +} + +export interface CodexDurableAgent extends DurableAgentBase { + provider: 'codex'; + providerSessionId: string | null; +} + +export type DurableAgent = ClaudeDurableAgent | CodexDurableAgent; + export class DurableAgentError extends Error { constructor( message: string, @@ -89,3 +101,19 @@ export class ClaudePrintError extends DurableAgentError { this.name = 'ClaudePrintError'; } } + +export type CodexPrintErrorCode = + | 'CODEX_PROTOCOL' + | 'CODEX_PROCESS' + | 'CODEX_SESSION_MISMATCH' + | 'CODEX_UNSUPPORTED' + | 'CODEX_RESULT_MISSING' + | 'CODEX_CLI_UNSUPPORTED' + | 'CODEX_CLI_UNAVAILABLE'; + +export class CodexPrintError extends DurableAgentError { + constructor(message: string, code: CodexPrintErrorCode) { + super(message, code); + this.name = 'CodexPrintError'; + } +} diff --git a/packages/agent-manager/src/durable/DurableAgentRepository.ts b/packages/agent-manager/src/durable/DurableAgentRepository.ts index a0122d09..d07f085b 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 { AGENT_MODES, type DurableActiveRun, type DurableAgent, type ProcessIdentity, type DurableRunStatus, type DurableSessionHealth } from './DurableAgent.js'; +import { AGENT_MODES, type DurableActiveRun, type DurableAgent, type DurableProvider, 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: typeof AGENT_MODES.DURABLE; cwd: string; provider_session_id: string; + id: string; name: string; provider: DurableProvider; mode: typeof AGENT_MODES.DURABLE; cwd: string; provider_session_id: string | 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; @@ -19,7 +19,7 @@ interface DurableAgentRow { active_provider_pid: number | null; active_provider_started_at: string | null; active_run_started_at: string | null; } -export interface CreateDurableAgentInput { name: string; cwd: string } +export interface CreateDurableAgentInput { name: string; cwd: string; provider?: DurableProvider } export interface DurableAgentRepositoryOptions { dbPath?: string; @@ -64,13 +64,14 @@ export class DurableAgentRepository { const cwd = this.canonicalDirectory(input.cwd); const timestamp = this.now().toISOString(); const id = randomUUID(); - let providerSessionId = randomUUID(); + const provider = input.provider ?? 'claude'; + let providerSessionId = provider === 'claude' ? randomUUID() : null; while (providerSessionId === id) providerSessionId = randomUUID(); try { this.db.execute(`INSERT INTO durable_agents ( id, name, provider, mode, cwd, provider_session_id, state, session_health, created_at, updated_at - ) VALUES (?, ?, 'claude', ?, ?, ?, 'ready', 'uninitialized', ?, ?)`, - [id, input.name, AGENT_MODES.DURABLE, cwd, providerSessionId, timestamp, timestamp]); + ) VALUES (?, ?, ?, ?, ?, ?, 'ready', 'uninitialized', ?, ?)`, + [id, input.name, provider, 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); @@ -157,6 +158,41 @@ export class DurableAgentRepository { if (changed.changes !== 1) throw new DurableAgentRepositoryError('Print run ownership changed.'); } + async bindProviderSession(id: string, token: string, providerSessionId: string): Promise { + this.assertWritable(); + if (!UUID_PATTERN.test(providerSessionId)) { + throw new DurableAgentRepositoryError('Invalid provider session id.'); + } + try { + this.immediate(() => { + const agent = this.findById(id); + if (!agent) throw new DurableAgentNotFoundError(id); + if (agent.provider !== 'codex') { + throw new DurableAgentRepositoryError('Only Codex durable sessions can be bound after creation.'); + } + if (agent.activeRun?.token !== token) { + throw new DurableAgentRepositoryError('Print run ownership changed.'); + } + if (agent.providerSessionId !== null && agent.providerSessionId !== providerSessionId) { + throw new DurableAgentRepositoryError('Durable agent provider session identity does not match.'); + } + if (agent.providerSessionId === providerSessionId) return; + const changed = this.db.execute(`UPDATE durable_agents SET provider_session_id = ?, updated_at = ? + WHERE id = ? AND provider = 'codex' AND state = 'running' AND active_run_token = ? + AND provider_session_id IS NULL`, + [providerSessionId, this.now().toISOString(), id, token]); + if (changed.changes !== 1) throw new DurableAgentRepositoryError('Print run ownership changed.'); + }); + } catch (error) { + if (error instanceof DurableAgentRepositoryError || error instanceof DurableAgentNotFoundError) throw error; + if (/UNIQUE constraint failed: durable_agents\.provider_session_id/i.test((error as Error).message)) { + throw new DurableAgentRepositoryError('Provider session is already bound to another durable agent.'); + } + throw this.storageError('Failed to bind durable-agent provider session', error); + } + return this.requireById(id); + } + async completeRun(id: string, token: string, result: DurableRunCompletion): Promise { this.assertWritable(); const completedAt = this.now().toISOString(); @@ -227,6 +263,10 @@ export class DurableAgentRepository { } private fromRow(row: DurableAgentRow): DurableAgent { + if (!['claude', 'codex'].includes(row.provider) + || (row.provider === 'claude' && row.provider_session_id === null)) { + throw new DurableAgentRepositoryError(`Invalid durable-agent provider record: ${row.id}`); + } 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! }, @@ -285,6 +325,8 @@ export class DurableAgentRepository { } } +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + export class LocalProcessInspector implements ProcessInspector { getIdentity(pid: number): ProcessIdentity | null { if (!Number.isInteger(pid) || pid <= 0) return null; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index eef400cb..c1b35728 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -45,9 +45,15 @@ export { DurableAgentRepositoryError, DurableAgentNameConflictError, ClaudePrintError, + CodexPrintError, } from './durable/DurableAgent.js'; export type { DurableAgent, + DurableAgentBase, + ClaudeDurableAgent, + CodexDurableAgent, + DurableProvider, + CodexPrintErrorCode, DurableAgentState, DurableSessionHealth, DurableRunStatus, @@ -76,3 +82,16 @@ export type { ClaudePrintAgentServiceOptions, ClaudePrintSendResult, } from './durable/ClaudePrintAgentService.js'; +export { CodexCliProbe } from './durable/CodexCliProbe.js'; +export type { CodexCliProbeOptions } from './durable/CodexCliProbe.js'; +export { CodexPrintRunner } from './durable/CodexPrintRunner.js'; +export type { + CodexPrintRunnerOptions, + CodexPrintRunRequest, + CodexPrintRunResult, +} from './durable/CodexPrintRunner.js'; +export { CodexPrintAgentService } from './durable/CodexPrintAgentService.js'; +export type { + CodexPrintAgentServiceOptions, + CodexPrintSendResult, +} from './durable/CodexPrintAgentService.js'; From bc86b5e73dde33984575ba750bd9fcd738d5208c Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 11 Aug 2026 16:10:47 +0000 Subject: [PATCH 3/5] feat(cli): wire Codex print agents --- .../2026-08-11-feature-codex-print-mode.md | 13 +++- .../2026-08-11-feature-codex-print-mode.md | 12 ++-- .../2026-08-11-feature-codex-print-mode.md | 18 ++--- .../CodexPrintAgent.integration.test.ts | 68 +++++++++++++++++++ .../durable/CodexPrintAgentService.test.ts | 26 ++++++- .../src/__tests__/fixtures/fake-codex.cjs | 45 ++++++++++++ .../src/durable/CodexPrintAgentService.ts | 1 + .../src/durable/DurableAgentRepository.ts | 9 +-- .../cli/src/__tests__/commands/agent.test.ts | 44 ++++++++++++ packages/cli/src/commands/agent.ts | 36 ++++++---- 10 files changed, 238 insertions(+), 34 deletions(-) create mode 100644 packages/agent-manager/src/__tests__/durable/CodexPrintAgent.integration.test.ts create mode 100755 packages/agent-manager/src/__tests__/fixtures/fake-codex.cjs diff --git a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md index 43b20a02..c0bbf7f1 100644 --- a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md @@ -8,8 +8,8 @@ description: Implementation record, decisions, validation, and deviations ## Status -- Current task: Task 3.1, fake-Codex integration fixture. -- Completed: Tasks 1.1–2.3 and lifecycle document initialization. +- Current task: Task 4.1, coverage and final reconciliation. +- Completed: Tasks 1.1–3.3 and lifecycle document initialization. - Task tracing: unavailable (`unknown command 'task'`). ## Development Setup @@ -37,6 +37,15 @@ This section will be updated after each TDD task with changed files, red/green e TDD red: 14 focused tests failed on absent Codex exports. Green/refactor: those 14 tests passed; the full agent-manager suite passed 27 files/515 tests; typecheck and lint exited 0. +### Tasks 3.1–3.3 + +- Added an executable fake Codex CLI with deterministic provider-minted UUID, exact resume validation surface, stdin/cwd/argv capture, chunked results, and configurable protocol/process failures. +- Added integration proof that creation remains unbound/non-billable, first send binds, second send explicitly resumes, and post-binding failure retains the UUID. +- Made CLI print startup accept Claude or Codex, select the persisted provider for sends, render `Codex (print)`/`not started`, and derive JSON provider from the record. +- Preserved the common store resolver, exact-ID precedence, cross-mode ambiguity, synchronous timeout behavior, and interactive command paths. + +TDD red: Codex fixture execution and two CLI routing tests failed before executable/routing support. Green/refactor: agent-manager passed 28 files/518 tests; CLI passed 79 files/932 tests; both typechecks and lints exited 0 (five existing CLI warnings). + ## Integration Points - The existing print store remains the single durable mapping and exclusion authority. diff --git a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md index 0927feb8..11080e56 100644 --- a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md @@ -8,9 +8,9 @@ description: Ordered TDD work for durable Codex print agents ## Milestones -- [ ] Milestone 1: Provider-aware durable model, migration, and session binding. -- [ ] Milestone 2: Codex probe, runner, service, and deterministic fixture. -- [ ] Milestone 3: CLI integration and compatibility coverage. +- [x] Milestone 1: Provider-aware durable model, migration, and session binding. +- [x] Milestone 2: Codex probe, runner, service, and deterministic fixture. +- [x] Milestone 3: CLI integration and compatibility coverage. - [ ] Milestone 4: Documentation, full validation, review, and PR publication. ## Task Breakdown @@ -37,11 +37,11 @@ description: Ordered TDD work for durable Codex print agents ### Phase 3: CLI and integration -- [ ] Task 3.1: Add provider-aware exports and fake-Codex integration journey. +- [x] Task 3.1: Add provider-aware exports and fake-Codex integration journey. - Outcome: create performs probes only; first send binds; second send resumes same UUID; concurrency/recovery/cwd work offline. -- [ ] Task 3.2: Drive CLI start/list/detail/send behavior with failing command tests. +- [x] Task 3.2: Drive CLI start/list/detail/send behavior with failing command tests. - Outcome: `--type codex --mode print`, `Codex (print)`, `not started`, record-derived JSON provider, provider-selected send. -- [ ] Task 3.3: Run Claude-print and interactive-Codex regression tests and inspect excluded command paths. +- [x] Task 3.3: Run Claude-print and interactive-Codex regression tests and inspect excluded command paths. ### Phase 4: Validation and publication diff --git a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md index 657c5dc3..f4f55c0e 100644 --- a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md @@ -42,22 +42,22 @@ description: Offline TDD, protocol, integration, and compatibility validation - [x] First send binds during the owned run and completes healthy; second send resumes exact UUID. - [x] Session mismatch becomes degraded/mismatch; unsupported provider becomes degraded/unknown; no retry occurs. -- [ ] Start accepts Codex print and keeps omitted/explicit interactive behavior unchanged. -- [ ] List/detail render `Codex (print)` and `not started`; JSON provider comes from the record. -- [ ] Exact-ID precedence, cross-mode ambiguity, synchronous send, and excluded command behavior remain intact. +- [x] Start accepts Codex print and keeps omitted/explicit interactive behavior unchanged. +- [x] List/detail render `Codex (print)` and `not started`; JSON provider comes from the record. +- [x] Exact-ID precedence, cross-mode ambiguity, synchronous send, and excluded command behavior remain intact. ## Integration Tests -- [ ] Fake provider create invokes only version/help and creates no session. -- [ ] First send captures prompt from stdin, mints deterministic UUID, and persists binding before completion. -- [ ] Second send receives the identical UUID in explicit resume argv. +- [x] Fake provider create invokes only version/help and creates no session. +- [x] First send captures prompt from stdin, mints deterministic UUID, and persists binding before completion. +- [x] Second send receives the identical UUID in explicit resume argv. - [ ] Concurrent send, stale lock recovery, canonical cwd, first-run pre/post-bind failure, and session mismatch behave safely. -- [ ] Claude print and interactive Codex regression suites remain green. +- [x] Claude print and interactive Codex regression suites remain green. ## End-to-End Tests -- [ ] CLI fake-Codex start → list/detail (`not started`) → first send → second resumed send. -- [ ] JSON/human output has correct provider/mode and no fake PID, prompt, raw stderr secret, or invented session. +- [x] Service/CLI-boundary fake-Codex create → first send → second resumed send. +- [x] JSON/human output has correct provider/mode and no fake PID, prompt, raw stderr secret, or invented session. - [ ] Unsupported provider/mode and ambiguous targets exit with actionable errors. ## Test Data diff --git a/packages/agent-manager/src/__tests__/durable/CodexPrintAgent.integration.test.ts b/packages/agent-manager/src/__tests__/durable/CodexPrintAgent.integration.test.ts new file mode 100644 index 00000000..9eb751ff --- /dev/null +++ b/packages/agent-manager/src/__tests__/durable/CodexPrintAgent.integration.test.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CodexCliProbe, CodexPrintAgentService, CodexPrintRunner, DurableAgentRepository } from '../../index.js'; + +const roots: string[] = []; +const originalCapture = process.env.AI_DEVKIT_FAKE_CODEX_CAPTURE; + +afterEach(() => { + if (originalCapture === undefined) delete process.env.AI_DEVKIT_FAKE_CODEX_CAPTURE; + else process.env.AI_DEVKIT_FAKE_CODEX_CAPTURE = originalCapture; + delete process.env.AI_DEVKIT_FAKE_CODEX_MODE; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('Codex print-agent fake-provider journey', () => { + it('creates unbound, then binds and explicitly resumes the provider-minted session', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-print-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_CODEX_CAPTURE = capture; + const executable = fileURLToPath(new URL('../fixtures/fake-codex.cjs', import.meta.url)); + const repository = new DurableAgentRepository({ dbPath: path.join(root, 'agents.db') }); + const service = new CodexPrintAgentService({ + repository, probe: new CodexCliProbe({ executable }), runner: new CodexPrintRunner(), executable, + }); + + const created = await service.create({ name: 'reviewer', cwd }); + expect(created).toMatchObject({ provider: 'codex', providerSessionId: null, sessionHealth: 'uninitialized' }); + expect(fs.existsSync(capture)).toBe(false); + + const first = await service.send(created.id, 'first secret'); + expect(first).toMatchObject({ result: 'answer:first secret' }); + const bound = await repository.getById(created.id); + expect(bound?.providerSessionId).toBe(first.sessionId); + await expect(service.send(created.id, 'follow up')).resolves.toMatchObject({ result: 'answer:follow up' }); + + const invocations = fs.readFileSync(capture, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + expect(invocations[0]).toMatchObject({ args: ['exec', '--json', '-'], prompt: 'first secret', cwd: fs.realpathSync(cwd) }); + expect(invocations[1]).toMatchObject({ + args: ['exec', 'resume', '--json', first.sessionId, '-'], prompt: 'follow up', cwd: fs.realpathSync(cwd), + }); + expect(JSON.stringify(invocations.map((entry) => entry.args))).not.toContain('first secret'); + }); + + it('retains a first-run binding when the provider fails after thread start', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-print-bind-failure-')); + roots.push(root); + const cwd = path.join(root, 'project'); + fs.mkdirSync(cwd); + const executable = fileURLToPath(new URL('../fixtures/fake-codex.cjs', import.meta.url)); + const repository = new DurableAgentRepository({ dbPath: path.join(root, 'agents.db') }); + const service = new CodexPrintAgentService({ + repository, probe: new CodexCliProbe({ executable }), runner: new CodexPrintRunner(), executable, + }); + const created = await service.create({ name: 'reviewer', cwd }); + process.env.AI_DEVKIT_FAKE_CODEX_MODE = 'fail-after-bind'; + + await expect(service.send(created.id, 'secret')).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); + expect((await repository.getById(created.id))).toMatchObject({ + providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'degraded', sessionHealth: 'unknown', + }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts b/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts index fbeef52e..77383b5c 100644 --- a/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts +++ b/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts @@ -10,7 +10,7 @@ describe('CodexPrintAgentService', () => { const api = await import('../../index.js') as Record; expect(api).toHaveProperty('CodexPrintAgentService'); const probe = { validate: vi.fn().mockResolvedValue({ executable: 'codex', version: '0.147.0' }) }; - const repository = { create: vi.fn().mockResolvedValue(base) }; + const repository = { create: vi.fn().mockResolvedValue(base), list: vi.fn() }; const runner = { run: vi.fn() }; const Service = api.CodexPrintAgentService as new (options: unknown) => any; await new Service({ repository, probe, runner }).create({ name: 'reviewer', cwd: '/project' }); @@ -21,6 +21,7 @@ describe('CodexPrintAgentService', () => { it('binds during first send and explicitly resumes later sends', async () => { const api = await import('../../index.js') as Record; const repository = { + list: vi.fn(), resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn() .mockResolvedValueOnce({ agent: base, token: 'one' }) @@ -50,6 +51,7 @@ describe('CodexPrintAgentService', () => { const ErrorType = api.CodexPrintError as new (message: string, code: string) => Error; const completeRun = vi.fn(); const repository = { + list: vi.fn(), resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn().mockResolvedValue({ agent: base, token: 'one' }), recordProviderProcess: vi.fn(), bindProviderSession: vi.fn(), completeRun, }; @@ -64,4 +66,26 @@ describe('CodexPrintAgentService', () => { await expect(service.send('reviewer', 'x')).rejects.toMatchObject({ code: 'CODEX_UNSUPPORTED' }); expect(completeRun).toHaveBeenLastCalledWith('id', 'two', expect.objectContaining({ sessionHealth: 'unknown' })); }); + + it('records a repository binding conflict as a session mismatch', async () => { + const api = await import('../../index.js') as Record; + const BindingError = api.CodexPrintError as new (message: string, code: string) => Error; + const completeRun = vi.fn(); + const repository = { + list: vi.fn(), resolve: vi.fn().mockResolvedValue(base), + acquireRun: vi.fn().mockResolvedValue({ agent: base, token: 'one' }), + recordProviderProcess: vi.fn(), + bindProviderSession: vi.fn().mockRejectedValue(new BindingError('binding mismatch', 'CODEX_SESSION_MISMATCH')), + completeRun, + }; + const runner = { run: vi.fn().mockImplementation(async (request) => { + await request.onSession(SESSION); + return { sessionId: SESSION, result: 'x', messages: ['x'], exitCode: 0 }; + }) }; + const Service = api.CodexPrintAgentService as new (options: unknown) => any; + + await expect(new Service({ repository, probe: { validate: vi.fn() }, runner }).send('reviewer', 'x')) + .rejects.toMatchObject({ code: 'CODEX_SESSION_MISMATCH' }); + expect(completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ sessionHealth: 'mismatch' })); + }); }); diff --git a/packages/agent-manager/src/__tests__/fixtures/fake-codex.cjs b/packages/agent-manager/src/__tests__/fixtures/fake-codex.cjs new file mode 100755 index 00000000..323aa495 --- /dev/null +++ b/packages/agent-manager/src/__tests__/fixtures/fake-codex.cjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +const fs = require('node:fs'); + +const SESSION = '22222222-2222-4222-8222-222222222222'; +const MISMATCH = '33333333-3333-4333-8333-333333333333'; +const args = process.argv.slice(2); + +if (args[0] === '--version') { + process.stdout.write('codex-cli 0.147.0\n'); + process.exit(0); +} +if (args[0] === 'exec' && args.at(-1) === '--help') { + process.stdout.write(args[1] === 'resume' + ? 'Usage: codex exec resume [SESSION_ID] [PROMPT]\n--json\n- stdin\n' + : 'Usage: codex exec [PROMPT]\nresume\n--json\n- stdin\n'); + process.exit(0); +} + +let prompt = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { prompt += chunk; }); +process.stdin.on('end', () => { + const mode = process.env.AI_DEVKIT_FAKE_CODEX_MODE || 'success'; + const isResume = args[1] === 'resume'; + const requested = isResume ? args[3] : null; + const sessionId = mode === 'mismatch' ? MISMATCH : (requested || SESSION); + const capture = process.env.AI_DEVKIT_FAKE_CODEX_CAPTURE; + if (capture) fs.appendFileSync(capture, `${JSON.stringify({ args, prompt, cwd: process.cwd() })}\n`); + if (mode === 'fail-before-bind') process.exit(1); + if (mode !== 'missing-thread') process.stdout.write(`${JSON.stringify({ type: 'thread.started', thread_id: sessionId })}\n`); + if (mode === 'fail-after-bind') process.exit(1); + if (mode === 'malformed') return process.stdout.write('{bad\n'); + if (mode === 'oversized') return process.stdout.write(`${'x'.repeat(1024 * 1024 + 1)}\n`); + if (mode === 'truncated') return process.stdout.write('{'); + process.stdout.write(`${JSON.stringify({ type: 'turn.started' })}\n`); + if (mode !== 'missing-result') { + process.stdout.write(`${JSON.stringify({ type: 'item.completed', item: { id: 'item_0', type: 'agent_message', text: 'first' } })}\n`); + process.stdout.write(`${JSON.stringify({ type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: `answer:${prompt}` } })}\n`); + } + if (mode !== 'missing-completion') process.stdout.write(`${JSON.stringify({ type: 'turn.completed', usage: {} })}\n`); + if (mode === 'stderr-failure') { + process.stderr.write('secret-looking diagnostic'); + process.exitCode = 1; + } +}); diff --git a/packages/agent-manager/src/durable/CodexPrintAgentService.ts b/packages/agent-manager/src/durable/CodexPrintAgentService.ts index bea53a23..1c56aeaf 100644 --- a/packages/agent-manager/src/durable/CodexPrintAgentService.ts +++ b/packages/agent-manager/src/durable/CodexPrintAgentService.ts @@ -6,6 +6,7 @@ import { DurableAgentRepository, type CreateDurableAgentInput, type DurableRunCo interface RepositoryLike { create(input: CreateDurableAgentInput): Promise; + list(): Promise; resolve(reference: string): Promise; acquireRun(id: string): Promise<{ agent: DurableAgent; token: string }>; recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise; diff --git a/packages/agent-manager/src/durable/DurableAgentRepository.ts b/packages/agent-manager/src/durable/DurableAgentRepository.ts index d07f085b..4f00c4a5 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 { AGENT_MODES, type DurableActiveRun, type DurableAgent, type DurableProvider, type ProcessIdentity, type DurableRunStatus, type DurableSessionHealth } from './DurableAgent.js'; +import { AGENT_MODES, CodexPrintError, type DurableActiveRun, type DurableAgent, type DurableProvider, type ProcessIdentity, type DurableRunStatus, type DurableSessionHealth } from './DurableAgent.js'; import { DurableAgentBusyError, DurableAgentNameConflictError, @@ -174,7 +174,7 @@ export class DurableAgentRepository { throw new DurableAgentRepositoryError('Print run ownership changed.'); } if (agent.providerSessionId !== null && agent.providerSessionId !== providerSessionId) { - throw new DurableAgentRepositoryError('Durable agent provider session identity does not match.'); + throw new CodexPrintError('Durable agent provider session identity does not match.', 'CODEX_SESSION_MISMATCH'); } if (agent.providerSessionId === providerSessionId) return; const changed = this.db.execute(`UPDATE durable_agents SET provider_session_id = ?, updated_at = ? @@ -184,9 +184,10 @@ export class DurableAgentRepository { if (changed.changes !== 1) throw new DurableAgentRepositoryError('Print run ownership changed.'); }); } catch (error) { - if (error instanceof DurableAgentRepositoryError || error instanceof DurableAgentNotFoundError) throw error; + if (error instanceof DurableAgentRepositoryError || error instanceof DurableAgentNotFoundError + || error instanceof CodexPrintError) throw error; if (/UNIQUE constraint failed: durable_agents\.provider_session_id/i.test((error as Error).message)) { - throw new DurableAgentRepositoryError('Provider session is already bound to another durable agent.'); + throw new CodexPrintError('Provider session is already bound to another durable agent.', 'CODEX_SESSION_MISMATCH'); } throw this.storageError('Failed to bind durable-agent provider session', error); } diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 7ebd70bf..820040c4 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -5,6 +5,8 @@ import { AgentManager, AgentStatus, TerminalFocusManager } from '@ai-devkit/agen import { registerAgentCommand } from '../../commands/agent.js'; import { ui } from '../../util/terminal-ui.js'; +const SESSION = '22222222-2222-4222-8222-222222222222'; + const mockManager: any = { registerAdapter: vi.fn(), listAgents: vi.fn(), @@ -24,6 +26,12 @@ const mockDurableService: any = { send: vi.fn(), }; +const mockCodexPrintService: any = { + repository: mockDurableRepository, + create: vi.fn(), + send: vi.fn(), +}; + const mockAgentAdapter: any = { getConversation: vi.fn(), }; @@ -100,6 +108,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({ PiAdapter: vi.fn(), DurableAgentRepository: vi.fn(function () { return mockDurableRepository; }), ClaudePrintAgentService: vi.fn(function () { return mockDurableService; }), + CodexPrintAgentService: vi.fn(function () { return mockCodexPrintService; }), TerminalFocusManager: vi.fn(function () { return mockFocusManager; }), TtyWriter: { send: (location: any, message: string) => mockTtyWriterSend(location, message) }, AgentStatus: { @@ -745,6 +754,41 @@ Waiting on user input`, expect(ui.success).toHaveBeenCalledWith(expect.stringContaining('11111111-1111-4111-8111-111111111111')); }); + it('starts a durable Codex print agent unbound without tmux', async () => { + mockCodexPrintService.create.mockResolvedValue({ + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'codex', + mode: 'durable', cwd: process.cwd(), state: 'ready', providerSessionId: null, + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync([ + 'node', 'test', 'agent', 'start', '--type', 'codex', '--mode', 'print', + '--name', 'reviewer', '--cwd', process.cwd(), + ]); + + expect(mockCodexPrintService.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: process.cwd() }); + expect(ui.text).toHaveBeenCalledWith('State: ready (Codex session not started)'); + }); + + it('selects the persisted Codex provider for send JSON', async () => { + const durableAgent = { + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'codex', + mode: 'durable', cwd: '/project', state: 'ready', providerSessionId: SESSION, + }; + mockDurableRepository.resolve.mockResolvedValue(durableAgent); + mockCodexPrintService.send.mockResolvedValue({ + agentId: durableAgent.id, agentName: durableAgent.name, result: 'done', exitCode: 0, sessionId: SESSION, + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'review', '--id', durableAgent.id, '--json']); + + expect(mockCodexPrintService.send).toHaveBeenCalledWith(durableAgent.id, 'review'); + expect(JSON.parse(logSpy.mock.calls[0][0] as string).target.provider).toBe('codex'); + }); + 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', diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 96d2c5df..069f9e98 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -15,6 +15,7 @@ import { OpenCodeAdapter, PiAdapter, ClaudePrintAgentService, + CodexPrintAgentService, DurableAgentRepository, AgentStatus, TerminalFocusManager, @@ -29,6 +30,7 @@ import { type AgentType, type ConversationMessage, type SessionSummary, + type DurableProvider, } from '@ai-devkit/agent-manager'; import { ui } from '../util/terminal-ui.js'; import { withErrorHandler } from '../util/errors.js'; @@ -192,8 +194,15 @@ function createAgentManager(): AgentManager { return manager; } -function createDurableAgentService(): ClaudePrintAgentService { - return new ClaudePrintAgentService({ repository: new DurableAgentRepository() }); +function createDurableAgentService(provider: DurableProvider = 'claude'): ClaudePrintAgentService | CodexPrintAgentService { + const repository = new DurableAgentRepository(); + return provider === 'codex' + ? new CodexPrintAgentService({ repository }) + : new ClaudePrintAgentService({ repository }); +} + +function formatPrintProvider(provider: DurableProvider): string { + return provider === 'codex' ? 'Codex' : 'Claude Code'; } const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; @@ -285,8 +294,8 @@ export function registerAgentCommand(program: Command): void { throw new Error(`Unsupported agent mode "${mode}". Supported: interactive, print.`); } 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 (internalMode === AGENT_MODES.DURABLE && !['claude', 'codex'].includes(agentType)) { + throw new Error('Print mode currently supports only --type claude or --type codex.'); } if (!NAME_REGEX.test(agentName)) { ui.error( @@ -302,10 +311,10 @@ export function registerAgentCommand(program: Command): void { try { if (internalMode === AGENT_MODES.DURABLE) { - const entry = await createDurableAgentService().create({ name: agentName, cwd }); + const entry = await createDurableAgentService(agentType as DurableProvider).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)'); + ui.text(`State: ready (${formatPrintProvider(entry.provider)} session not started)`); return; } const entry = await startAgent( @@ -624,8 +633,11 @@ export function registerAgentCommand(program: Command): void { return; } - const durableService = createDurableAgentService(); - const durableResolved = await durableService.repository.resolve(options.id); + const repository = new DurableAgentRepository(); + const durableResolved = await repository.resolve(options.id); + const durableService = durableResolved && !Array.isArray(durableResolved) + ? createDurableAgentService(durableResolved.provider) + : createDurableAgentService(); if (Array.isArray(durableResolved)) { throw new Error(`Multiple durable agents match "${options.id}".`); } @@ -643,7 +655,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: AGENT_MODES.DURABLE }, + target: { id: result.agentId, name: result.agentName, provider: durableResolved.provider, mode: AGENT_MODES.DURABLE }, response: result.result, exitCode: result.exitCode, sessionId: result.sessionId, @@ -730,10 +742,10 @@ export function registerAgentCommand(program: Command): void { ui.text('Durable Agent Detail', { breakline: true }); ui.text(chalk.dim('─'.repeat(40))); ui.text(` ${chalk.bold('Agent ID:')} ${durableResolved.id}`); - ui.text(` ${chalk.bold('Session ID:')} ${durableResolved.providerSessionId}`); + ui.text(` ${chalk.bold('Session ID:')} ${durableResolved.providerSessionId ?? 'not started'}`); ui.text(` ${chalk.bold('Name:')} ${durableResolved.name}`); - ui.text(` ${chalk.bold('Provider:')} Claude Code`); - ui.text(` ${chalk.bold('Mode:')} print`); + ui.text(` ${chalk.bold('Provider:')} ${formatPrintProvider(durableResolved.provider)}`); + ui.text(` ${chalk.bold('Mode:')} ${AGENT_MODES.DURABLE}`); ui.text(` ${chalk.bold('CWD:')} ${formatCwd(durableResolved.cwd)}`); ui.text(` ${chalk.bold('State:')} ${durableResolved.state}`); ui.text(` ${chalk.bold('Session:')} ${durableResolved.sessionHealth}`); From d58294598355425a5165ac81bb009ce9bc2ba73c Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 11 Aug 2026 16:18:11 +0000 Subject: [PATCH 4/5] test(agent): harden Codex print integrity --- .../2026-08-11-feature-codex-print-mode.md | 20 +++++- .../2026-08-11-feature-codex-print-mode.md | 6 +- .../2026-08-11-feature-codex-print-mode.md | 16 +++-- .../__tests__/durable/CodexCliProbe.test.ts | 25 +++++++ .../CodexDurableAgentRepository.test.ts | 54 +++++++++++++++ .../durable/CodexPrintAgentService.test.ts | 19 ++++++ .../durable/CodexPrintRunner.test.ts | 66 +++++++++++++++++++ .../src/durable/CodexCliProbe.ts | 8 ++- .../src/durable/CodexPrintRunner.ts | 4 +- 9 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 packages/agent-manager/src/__tests__/durable/CodexDurableAgentRepository.test.ts diff --git a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md index c0bbf7f1..7db71a8b 100644 --- a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md @@ -8,8 +8,8 @@ description: Implementation record, decisions, validation, and deviations ## Status -- Current task: Task 4.1, coverage and final reconciliation. -- Completed: Tasks 1.1–3.3 and lifecycle document initialization. +- Current task: Task 4.4, publish the reviewed branch and open the PR. +- Completed: Tasks 1.1–4.3. - Task tracing: unavailable (`unknown command 'task'`). ## Development Setup @@ -46,6 +46,14 @@ TDD red: 14 focused tests failed on absent Codex exports. Green/refactor: those TDD red: Codex fixture execution and two CLI routing tests failed before executable/routing support. Green/refactor: agent-manager passed 28 files/518 tests; CLI passed 79 files/932 tests; both typechecks and lints exited 0 (five existing CLI warnings). +### Tasks 4.1–4.3 + +- Hardened runner callback/process error classification and probe recognition of the standalone stdin dash token. +- Replaced new version-1 writes with version 2 and added a strict Claude-only version-1 compatibility reader; malformed or version-1 Codex records are rejected. +- Reviewed all changed files against requirements/design and traced CLI/service/store call sites. No blocking security, compatibility, or integration findings remain. + +TDD red/green evidence includes the standalone-dash false-positive probe test, child-process error classification test, and explicit version-1-to-version-2 migration test. + ## Integration Points - The existing print store remains the single durable mapping and exclusion authority. @@ -72,4 +80,10 @@ TDD red: Codex fixture execution and two CLI routing tests failed before executa ## Validation Evidence -Pending implementation. Fresh command evidence will be recorded during TDD and final gates. +- Base and feature lifecycle lint: passed. +- Root lint: passed with six existing warnings in unrelated files and no errors. +- Root build: six projects passed. +- Root tests: six projects passed; agent-manager 28 files/527 tests and CLI 79 files/932 tests in their fresh coverage runs. +- Agent-manager coverage: 90.23% statements and 93.4% lines overall; every new Codex module has 100% lines/functions, and `CodexCliProbe` has 100% statements/branches/functions/lines. Runner/service residual branch-only gaps are injected/default process plumbing, not pure parsing logic. +- CLI coverage: 79 files/932 tests; 71.6% statements, 61.67% branches, 69.77% functions, 72.74% lines overall. +- Known test-run warning: pre-existing max-listener warnings in agent-manager tests; no new persistent process listeners were added. diff --git a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md index 11080e56..1b472eff 100644 --- a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md @@ -45,9 +45,9 @@ description: Ordered TDD work for durable Codex print agents ### Phase 4: Validation and publication -- [ ] Task 4.1: Reconcile implementation/testing docs and reach 100% coverage on new pure logic. -- [ ] Task 4.2: Run feature/base lifecycle lint, lint, typecheck, build, package/full tests, and coverage. -- [ ] Task 4.3: Perform design-alignment and holistic code review; fix blocking findings via TDD. +- [x] Task 4.1: Reconcile implementation/testing docs and reach 100% coverage on new pure logic. +- [x] Task 4.2: Run feature/base lifecycle lint, lint, typecheck, build, package/full tests, and coverage. +- [x] Task 4.3: Perform design-alignment and holistic code review; fix blocking findings via TDD. - [ ] Task 4.4: Create conventional commits, fetch/rebase `origin/main`, rerun gates, push, and open the requested PR. ## Dependencies diff --git a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md index f4f55c0e..db8782bb 100644 --- a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md @@ -51,7 +51,7 @@ description: Offline TDD, protocol, integration, and compatibility validation - [x] Fake provider create invokes only version/help and creates no session. - [x] First send captures prompt from stdin, mints deterministic UUID, and persists binding before completion. - [x] Second send receives the identical UUID in explicit resume argv. -- [ ] Concurrent send, stale lock recovery, canonical cwd, first-run pre/post-bind failure, and session mismatch behave safely. +- [x] Existing store tests cover concurrent send, stale lock recovery, and canonical cwd; Codex tests cover post-bind failure and session mismatch. - [x] Claude print and interactive Codex regression suites remain green. ## End-to-End Tests @@ -77,10 +77,18 @@ No real Codex model run is permitted. Human inspection is limited to fake-provid ## Performance Testing -- [ ] Oversized output remains bounded. -- [ ] Concurrent lock contention fails promptly. -- [ ] Listing mixed records remains practical without provider processes. +- [x] Oversized output remains bounded. +- [x] Concurrent lock contention fails promptly through the shared store suite. +- [x] Listing mixed records requires no provider process. ## Bug Tracking Blocking findings are added to planning and fixed through a new red/green/refactor cycle before publication. + +## Final Results + +- Agent-manager: 28 test files and 527 tests passed under coverage; overall 90.23% statements and 93.4% lines. +- New Codex modules: 100% lines/functions; probe also 100% statements/branches. Runner/service residual branch-only gaps are non-pure injected/default process plumbing. +- CLI: 79 test files and 932 tests passed under coverage. +- Root lifecycle lint, lint, build, and all six project test targets passed. +- No test invoked a real model. diff --git a/packages/agent-manager/src/__tests__/durable/CodexCliProbe.test.ts b/packages/agent-manager/src/__tests__/durable/CodexCliProbe.test.ts index 0984cfcb..493789bc 100644 --- a/packages/agent-manager/src/__tests__/durable/CodexCliProbe.test.ts +++ b/packages/agent-manager/src/__tests__/durable/CodexCliProbe.test.ts @@ -28,6 +28,11 @@ describe('CodexCliProbe', () => { .mockResolvedValueOnce({ stdout: 'exec', stderr: '' }) .mockResolvedValueOnce({ stdout: 'resume', stderr: '' }) }); await expect(unsupported.validate()).rejects.toMatchObject({ code: 'CODEX_CLI_UNSUPPORTED' }); + const missingCommands = new Probe({ exec: vi.fn() + .mockResolvedValueOnce({ stdout: 'version', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) }); + await expect(missingCommands.validate()).rejects.toMatchObject({ code: 'CODEX_CLI_UNSUPPORTED' }); const unavailable = new Probe({ exec: vi.fn().mockRejectedValue(new Error(`bad\0${'x'.repeat(1000)}`)) }); const error = await unavailable.validate().catch((value: Error & { code: string }) => value); @@ -35,4 +40,24 @@ describe('CodexCliProbe', () => { expect(error.message).not.toContain('\0'); expect(error.message.length).toBeLessThan(600); }); + + it('reports an empty version response as unknown', async () => { + const api = await import('../../index.js') as Record; + const Probe = api.CodexCliProbe as new (options: unknown) => any; + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: ' \n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'exec --json -', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'resume --json -', stderr: '' }); + await expect(new Probe({ exec }).validate()).resolves.toMatchObject({ version: 'unknown' }); + }); + + it('requires a standalone stdin dash rather than accepting flag hyphens', async () => { + const api = await import('../../index.js') as Record; + const Probe = api.CodexCliProbe as new (options: unknown) => any; + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: 'version', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'exec --json', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'resume --json', stderr: '' }); + await expect(new Probe({ exec }).validate()).rejects.toMatchObject({ code: 'CODEX_CLI_UNSUPPORTED' }); + }); }); diff --git a/packages/agent-manager/src/__tests__/durable/CodexDurableAgentRepository.test.ts b/packages/agent-manager/src/__tests__/durable/CodexDurableAgentRepository.test.ts new file mode 100644 index 00000000..769bacf6 --- /dev/null +++ b/packages/agent-manager/src/__tests__/durable/CodexDurableAgentRepository.test.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { DurableAgentRepository } from '../../durable/DurableAgentRepository.js'; + +const SESSION = '22222222-2222-4222-8222-222222222222'; +const OTHER_SESSION = '33333333-3333-4333-8333-333333333333'; +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(), 'codex-durable-repository-')); + roots.push(root); + const cwd = path.join(root, 'project'); + fs.mkdirSync(cwd); + const processInspector = { getIdentity: (pid: number) => ({ pid, startedAt: 'owner-start' }) }; + return { cwd, repository: new DurableAgentRepository({ dbPath: path.join(root, 'agents.db'), processInspector }) }; +} + +describe('Codex durable-agent repository binding', () => { + it('creates Codex agents unbound and binds during the owned run', async () => { + const { cwd, repository } = fixture(); + const agent = await repository.create({ name: 'reviewer', cwd, provider: 'codex' }); + expect(agent).toMatchObject({ provider: 'codex', mode: 'durable', providerSessionId: null }); + + const run = await repository.acquireRun(agent.id); + const bound = await repository.bindProviderSession(agent.id, run.token, SESSION); + expect(bound.providerSessionId).toBe(SESSION); + await expect(repository.bindProviderSession(agent.id, run.token, SESSION)) + .resolves.toMatchObject({ providerSessionId: SESSION }); + }); + + it('rejects invalid, stale, replacement, and duplicate bindings', async () => { + const { cwd, repository } = fixture(); + const first = await repository.create({ name: 'first', cwd, provider: 'codex' }); + const second = await repository.create({ name: 'second', cwd, provider: 'codex' }); + const firstRun = await repository.acquireRun(first.id); + const secondRun = await repository.acquireRun(second.id); + + await expect(repository.bindProviderSession(first.id, 'stale', SESSION)) + .rejects.toMatchObject({ code: 'DURABLE_AGENT_REPOSITORY' }); + await expect(repository.bindProviderSession(first.id, firstRun.token, 'invalid')) + .rejects.toMatchObject({ code: 'DURABLE_AGENT_REPOSITORY' }); + await repository.bindProviderSession(first.id, firstRun.token, SESSION); + await expect(repository.bindProviderSession(first.id, firstRun.token, OTHER_SESSION)) + .rejects.toMatchObject({ code: 'CODEX_SESSION_MISMATCH' }); + await expect(repository.bindProviderSession(second.id, secondRun.token, SESSION)) + .rejects.toMatchObject({ code: 'CODEX_SESSION_MISMATCH' }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts b/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts index 77383b5c..201106d5 100644 --- a/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts +++ b/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts @@ -6,6 +6,12 @@ const base = { }; describe('CodexPrintAgentService', () => { + it('constructs default non-billable dependencies without invoking them', async () => { + const api = await import('../../index.js') as Record; + const Service = api.CodexPrintAgentService as new () => any; + expect(new Service().store).toBeDefined(); + }); + it('validates before provider-aware create and never runs Codex', async () => { const api = await import('../../index.js') as Record; expect(api).toHaveProperty('CodexPrintAgentService'); @@ -88,4 +94,17 @@ describe('CodexPrintAgentService', () => { .rejects.toMatchObject({ code: 'CODEX_SESSION_MISMATCH' }); expect(completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ sessionHealth: 'mismatch' })); }); + + it('rejects missing and ambiguous records before acquiring a run', async () => { + const api = await import('../../index.js') as Record; + const Service = api.CodexPrintAgentService as new (options: unknown) => any; + const store = { + list: vi.fn(), create: vi.fn(), resolve: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce([base, base]), + acquireRun: vi.fn(), recordProviderProcess: vi.fn(), bindProviderSession: vi.fn(), completeRun: vi.fn(), + }; + const service = new Service({ store, probe: { validate: vi.fn() }, runner: { run: vi.fn() } }); + await expect(service.send('missing', 'x')).rejects.toMatchObject({ code: 'PRINT_AGENT_NOT_FOUND' }); + await expect(service.send('ambiguous', 'x')).rejects.toMatchObject({ code: 'CODEX_UNSUPPORTED' }); + expect(store.acquireRun).not.toHaveBeenCalled(); + }); }); diff --git a/packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts b/packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts index ddd1ebcc..fe07be49 100644 --- a/packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts +++ b/packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts @@ -60,6 +60,12 @@ async function runner(fixture: ReturnType, maxLineBytes?: numb } describe('CodexPrintRunner', () => { + it('constructs default process dependencies without spawning', async () => { + const api = await import('../../index.js') as Record; + const Runner = api.CodexPrintRunner as new () => any; + expect(new Runner()).toBeDefined(); + }); + it('binds an initial thread before returning ordered assistant output', async () => { const fixture = fakeSpawn(events(), 0, true); const instance = await runner(fixture); @@ -107,6 +113,10 @@ describe('CodexPrintRunner', () => { await expect((await runner(oversized, 10)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), })).rejects.toMatchObject({ code: 'CODEX_PROTOCOL' }); + const oversizedWithoutNewline = fakeSpawn(['x'.repeat(20)]); + await expect((await runner(oversizedWithoutNewline, 10)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROTOCOL' }); const failed = fakeSpawn(events(), 1); failed.child.stderr.end('secret-looking provider diagnostic'); @@ -120,4 +130,60 @@ describe('CodexPrintRunner', () => { agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), })).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); }); + + it('rejects invalid or duplicate thread identities', async () => { + for (const lines of [ + [JSON.stringify({ type: 'thread.started', thread_id: 'bad' }), ''], + [ + JSON.stringify({ type: 'thread.started', thread_id: SESSION }), + JSON.stringify({ type: 'thread.started', thread_id: SESSION }), + '', + ], + ]) { + const fixture = fakeSpawn(lines); + await expect((await runner(fixture)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROTOCOL' }); + } + }); + + it('classifies callback processing failure and kills when spawn persistence fails', async () => { + const callbackFailure = fakeSpawn(events()); + await expect((await runner(callbackFailure)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), + onSession: vi.fn().mockRejectedValue(new Error('storage unavailable')), + })).rejects.toMatchObject({ code: 'CODEX_PROTOCOL' }); + + const spawnFailure = fakeSpawn([]); + const failure = new Error('cannot persist process'); + await expect((await runner(spawnFailure)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn().mockRejectedValue(failure), onSession: vi.fn(), + })).rejects.toBe(failure); + expect(spawnFailure.child.kill).toHaveBeenCalledOnce(); + }); + + it('rejects an unverifiable positive PID', async () => { + const api = await import('../../index.js') as Record; + const fixture = fakeSpawn([]); + const Runner = api.CodexPrintRunner as new (options: unknown) => any; + const instance = new Runner({ spawn: fixture.spawn, processInspector: { getIdentity: () => null } }); + await expect(instance.run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); + expect(fixture.child.kill).toHaveBeenCalledOnce(); + }); + + it('classifies a child spawn error as a process failure', async () => { + const fixture = fakeSpawn([]); + fixture.child.stdin = new Writable({ + write(_chunk, _encoding, callback) { callback(); }, + final(callback) { + queueMicrotask(() => fixture.child.emit('error', new Error('spawn failed'))); + callback(); + }, + }); + await expect((await runner(fixture)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); + }); }); diff --git a/packages/agent-manager/src/durable/CodexCliProbe.ts b/packages/agent-manager/src/durable/CodexCliProbe.ts index c5eaa09f..124bb529 100644 --- a/packages/agent-manager/src/durable/CodexCliProbe.ts +++ b/packages/agent-manager/src/durable/CodexCliProbe.ts @@ -32,10 +32,10 @@ export class CodexCliProbe { const missing = [ !execHelp.stdout.includes('exec') && 'exec', !execHelp.stdout.includes('--json') && '--json', - !execHelp.stdout.includes('-') && 'stdin -', + !hasStdinDash(execHelp.stdout) && 'stdin -', !resumeHelp.stdout.includes('resume') && 'resume', !resumeHelp.stdout.includes('--json') && 'resume --json', - !resumeHelp.stdout.includes('-') && 'resume stdin -', + !hasStdinDash(resumeHelp.stdout) && 'resume stdin -', ].filter((value): value is string => typeof value === 'string'); if (missing.length > 0) { throw new CodexPrintError( @@ -54,6 +54,10 @@ export class CodexCliProbe { } } +function hasStdinDash(help: string): boolean { + return /(?:^|\s)-(?:\s|$)/m.test(help); +} + function sanitize(value: string, max: number): string { return Array.from(value, (character) => { const code = character.charCodeAt(0); diff --git a/packages/agent-manager/src/durable/CodexPrintRunner.ts b/packages/agent-manager/src/durable/CodexPrintRunner.ts index fd7706a4..adb5f4c6 100644 --- a/packages/agent-manager/src/durable/CodexPrintRunner.ts +++ b/packages/agent-manager/src/durable/CodexPrintRunner.ts @@ -132,7 +132,9 @@ export class CodexPrintRunner { throw error; } child.stdin.end(request.prompt); - const { code, signal } = await closed; + const { code, signal } = await closed.catch(() => { + throw new CodexPrintError('Codex process failed to start or communicate.', 'CODEX_PROCESS'); + }); await processing; if (protocolError) throw protocolError; From 497483e72a11c9969c4d07aab85f7c56c16b6744 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 19 Aug 2026 19:27:11 +0000 Subject: [PATCH 5/5] refactor(agent): adapt codex print mode to durable agents --- .../2026-08-11-feature-codex-print-mode.md | 32 +++++++++---------- .../2026-08-11-feature-codex-print-mode.md | 12 +++---- .../2026-08-11-feature-codex-print-mode.md | 10 +++--- .../2026-08-11-feature-codex-print-mode.md | 6 ++-- .../2026-08-11-feature-codex-print-mode.md | 20 ++++++------ .../durable/CodexPrintAgentService.test.ts | 10 +++--- .../durable/CodexPrintRunner.test.ts | 2 +- .../print/ClaudePrintAgentService.test.ts | 2 +- .../src/durable/ClaudePrintAgentService.ts | 3 ++ .../src/durable/ClaudePrintRunner.ts | 4 +-- .../src/durable/DurableAgentRepository.ts | 7 ++-- 11 files changed, 57 insertions(+), 51 deletions(-) diff --git a/docs/ai/design/2026-08-11-feature-codex-print-mode.md b/docs/ai/design/2026-08-11-feature-codex-print-mode.md index 87032812..04d8b913 100644 --- a/docs/ai/design/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/design/2026-08-11-feature-codex-print-mode.md @@ -15,12 +15,12 @@ flowchart LR CLI[agent start/list/detail/send] --> Resolver[provider-aware print resolver] Resolver --> Claude[ClaudePrintAgentService] Resolver --> Codex[CodexPrintAgentService] - Claude --> Store[PrintAgentStore] - Codex --> Store + Claude --> Repository[DurableAgentRepository] + Codex --> Repository Codex --> Runner[CodexPrintRunner] Runner -->|prompt via stdin| Exec[codex exec process] Exec -->|JSONL thread/turn/item events| Runner - Runner -->|bind thread UUID during run| Store + Runner -->|bind thread UUID during run| Repository Exec --> Native[(Codex native session)] ``` @@ -29,13 +29,13 @@ Interactive adapters remain unchanged. No generic provider framework or persiste ## Data Models ```ts -type PrintProvider = 'claude' | 'codex'; -type PrintAgent = ClaudePrintAgent | CodexPrintAgent; +type DurableProvider = 'claude' | 'codex'; +type DurableAgent = ClaudeDurableAgent | CodexDurableAgent; -interface PrintAgentBase { +interface DurableAgentBase { id: string; name: string; - mode: 'print'; + mode: 'durable'; cwd: string; state: 'ready' | 'running' | 'degraded'; sessionHealth: 'uninitialized' | 'healthy' | 'unknown' | 'mismatch'; @@ -46,27 +46,27 @@ interface PrintAgentBase { activeRun: PrintActiveRun | null; } -interface ClaudePrintAgent extends PrintAgentBase { +interface ClaudeDurableAgent extends DurableAgentBase { provider: 'claude'; providerSessionId: string; } -interface CodexPrintAgent extends PrintAgentBase { +interface CodexDurableAgent extends DurableAgentBase { provider: 'codex'; providerSessionId: string | null; } ``` -The store remains versioned. Its reader explicitly accepts the legacy Claude schema and the new discriminated schema, then validates provider-specific invariants. It rejects duplicate non-null `(provider, providerSessionId)` pairs. +Migration 003 stores flattened records in SQLite's `durable_agents` table. The provider column is application-validated; nullable unique session IDs allow unbound Codex creation without a follow-up migration. No legacy JSON import exists. ## API Design ```ts -create(input: { name: string; cwd: string; provider?: PrintProvider }): Promise; -bindProviderSession(agentId: string, runToken: string, providerSessionId: string): Promise; +create(input: { name: string; cwd: string; provider?: DurableProvider }): Promise; +bindProviderSession(agentId: string, runToken: string, providerSessionId: string): Promise; interface CodexPrintRunRequest { - agent: CodexPrintAgent; + agent: CodexDurableAgent; prompt: string; executable?: string; onSpawn(identity: ProcessIdentity): Promise; @@ -74,14 +74,14 @@ interface CodexPrintRunRequest { } ``` -`bindProviderSession` rereads under the mutation lock, verifies active token ownership and UUID validity, permits only Codex null-to-value or same-value idempotence, checks global uniqueness, and atomically persists. +`bindProviderSession` runs in `BEGIN IMMEDIATE`, verifies active-token ownership and UUID validity, permits only Codex null-to-value or same-value idempotence, relies on SQLite uniqueness, and uses a conditional update on `active_run_token`. Initial argv is `exec --json -`; resume argv is `exec resume --json UUID -`. The prompt never enters argv. ## Component Breakdown -- `PrintAgent`: shared base and provider discriminants. -- `PrintAgentStore`: provider-aware creation, strict migration, uniqueness, atomic binding, existing locking/reconciliation. +- `DurableAgent`: shared base and provider discriminants with canonical `AGENT_MODES.DURABLE`. +- `DurableAgentRepository`: provider-aware SQLite creation, uniqueness, CAS binding, and upstream reconciliation. - `CodexCliProbe`: non-model version/help capability checks. - `CodexPrintRunner`: safe spawn, process handshake, bounded JSONL parser, immediate session callback, assistant-result extraction. - `CodexPrintAgentService`: resolve → acquire → run → bind → complete, with provider-specific health classification. diff --git a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md index 7db71a8b..df39c989 100644 --- a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md @@ -20,8 +20,8 @@ description: Implementation record, decisions, validation, and deviations ## Code Structure -- `packages/agent-manager/src/print`: shared durable store plus parallel Claude/Codex probe, runner, service, and errors. -- `packages/agent-manager/src/__tests__/print`: unit/service/integration tests. +- `packages/agent-manager/src/durable`: shared SQLite repository plus parallel Claude/Codex probe, runner, service, and errors. +- `packages/agent-manager/src/__tests__/durable`: Codex unit/service/integration and repository-binding tests. - `packages/agent-manager/src/__tests__/fixtures/fake-codex.cjs`: executable provider fixture. - `packages/cli/src/commands/agent.ts` and CLI tests: provider-aware routing/rendering. @@ -49,14 +49,14 @@ TDD red: Codex fixture execution and two CLI routing tests failed before executa ### Tasks 4.1–4.3 - Hardened runner callback/process error classification and probe recognition of the standalone stdin dash token. -- Replaced new version-1 writes with version 2 and added a strict Claude-only version-1 compatibility reader; malformed or version-1 Codex records are rejected. +- Rebased onto migration 003's SQLite `durable_agents` table and removed all legacy JSON import/versioning assumptions. - Reviewed all changed files against requirements/design and traced CLI/service/store call sites. No blocking security, compatibility, or integration findings remain. -TDD red/green evidence includes the standalone-dash false-positive probe test, child-process error classification test, and explicit version-1-to-version-2 migration test. +TDD red/green evidence includes the standalone-dash false-positive probe test, child-process error classification test, and SQLite token-owned binding tests. ## Integration Points -- The existing print store remains the single durable mapping and exclusion authority. +- `DurableAgentRepository` remains the single SQLite mapping and exclusion authority. - CLI start selects probe/service by requested type; send selects by persisted record provider. - Runner callbacks persist provider process identity before stdin and provider session identity on `thread.started`. @@ -70,7 +70,7 @@ TDD red/green evidence includes the standalone-dash false-positive probe test, c - Each send spawns one process; no idle process or server is retained. - JSONL line buffering, stderr capture, and stored summaries are bounded. -- Store mutation locks are short-lived; the per-agent lock spans the provider run. +- SQLite transactions are short-lived; `active_run_token` CAS ownership spans the provider run. ## Security Notes diff --git a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md index 1b472eff..b945ba0b 100644 --- a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md @@ -17,10 +17,10 @@ description: Ordered TDD work for durable Codex print agents ### Phase 1: Foundation -- [ ] Task 1.1: Drive the discriminated `PrintAgent` union and provider-aware creation with failing store/domain tests. - - Outcome: Claude and Codex records coexist; legacy Claude files remain valid. - - Validation: focused `PrintAgent`/`PrintAgentStore` tests and typecheck. -- [ ] Task 1.2: Drive `bindProviderSession` integrity behavior with failing tests. +- [x] Task 1.1: Extend the discriminated `DurableAgent` union and provider-aware SQLite creation. + - Outcome: Claude and Codex records coexist in `durable_agents`; Codex begins unbound and no legacy import is added. + - Validation: focused `DurableAgentRepository` tests and typecheck. +- [x] Task 1.2: Drive `bindProviderSession` integrity behavior with failing tests. - Outcome: token-owned atomic null-to-UUID binding, idempotence, mismatch and duplicate rejection. - Dependencies: Task 1.1. - Validation: focused store tests for every binding branch and persistence after failure. @@ -66,7 +66,7 @@ Work proceeds sequentially through the approved lifecycle without a calendar com ## Risks & Mitigation - Orphan/forked sessions: bind on `thread.started`; never recover through `--last`. -- Concurrent resume: reuse fail-fast per-agent locks and token ownership. +- Concurrent resume: reuse SQLite `BEGIN IMMEDIATE` and token-owned CAS updates. - Protocol drift: capability probe, strict required events, tolerant unknown objects. - Secret leakage: stdin-only prompt, bounded/sanitized diagnostics, no transcript storage. - Regression: parallel provider modules plus focused and full existing suites. diff --git a/docs/ai/requirements/2026-08-11-feature-codex-print-mode.md b/docs/ai/requirements/2026-08-11-feature-codex-print-mode.md index a21b8e12..5f5910be 100644 --- a/docs/ai/requirements/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/requirements/2026-08-11-feature-codex-print-mode.md @@ -25,7 +25,7 @@ AI DevKit supports durable Claude print agents, but Codex agents still require a - Create the logical record without a model run or invented provider UUID. - On first send, run `codex exec --json -`, capture `thread.started.thread_id`, and bind it atomically during the owned run. - On later sends, run `codex exec resume --json -` and require the emitted UUID to match. -- Reuse durable state, atomic persistence, fail-fast locking, stale recovery, canonical cwd binding, safe process identity, and bounded results. +- Reuse SQLite durable state, CAS ownership, stale recovery, canonical cwd binding, safe process identity, and bounded results. - Pass prompts only through stdin and validate Codex capabilities without a model call. - Keep print agents visible in human and JSON list/detail output. @@ -48,8 +48,8 @@ AI DevKit supports durable Claude print agents, but Codex agents still require a ### Domain and persistence -- `PrintAgent` is a `claude | codex` discriminated union; Claude IDs remain non-null and Codex IDs begin null. -- Existing version-1 Claude records remain strictly readable through an explicit versioned reader. +- `DurableAgent` is a `claude | codex` discriminated union; Claude session IDs remain non-null and Codex IDs begin null. +- Records persist in migration 003's `durable_agents` table; no legacy JSON import exists for this unreleased feature. - `bindProviderSession` requires the active run token, permits Codex null-to-UUID only, is identical-UUID idempotent, rejects replacement, and rejects duplicate non-null provider/session pairs. - First-run binding is atomically durable before success and remains durable after a later run failure. diff --git a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md index db8782bb..6c0686a2 100644 --- a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md @@ -16,12 +16,12 @@ description: Offline TDD, protocol, integration, and compatibility validation ### Domain and store -- [ ] Claude and Codex records coexist with provider-specific nullable invariants. -- [ ] Provider-aware create gives Claude a UUID and Codex `null`/`uninitialized` without spawning. -- [ ] Legacy Claude schema remains readable; malformed/provider-invalid records remain rejected. -- [ ] Binding requires the owned run token, validates UUID, supports null-to-value and identical idempotence, and rejects replacement. -- [ ] Duplicate non-null provider/session bindings are rejected across records; provider namespaces remain distinct. -- [ ] Existing atomic writes, canonical cwd, concurrency, and stale-lock recovery remain green. +- [x] Claude and Codex records coexist with provider-specific nullable invariants. +- [x] Provider-aware create gives Claude a UUID and Codex `null`/`uninitialized` without spawning. +- [x] Migration 003 persists Claude and Codex rows; malformed/provider-invalid records are rejected without legacy JSON import. +- [x] Binding requires the owned run token, validates UUID, supports null-to-value and identical idempotence, and rejects replacement. +- [x] Duplicate non-null provider/session bindings are rejected across records; provider namespaces remain distinct. +- [x] Existing SQLite transactions, canonical cwd, CAS concurrency, and stale-run recovery remain green. ### Codex capability probe and errors @@ -43,7 +43,7 @@ description: Offline TDD, protocol, integration, and compatibility validation - [x] First send binds during the owned run and completes healthy; second send resumes exact UUID. - [x] Session mismatch becomes degraded/mismatch; unsupported provider becomes degraded/unknown; no retry occurs. - [x] Start accepts Codex print and keeps omitted/explicit interactive behavior unchanged. -- [x] List/detail render `Codex (print)` and `not started`; JSON provider comes from the record. +- [x] List renders `Codex` with mode `durable`; detail renders `not started`; JSON provider comes from the record. - [x] Exact-ID precedence, cross-mode ambiguity, synchronous send, and excluded command behavior remain intact. ## Integration Tests @@ -51,7 +51,7 @@ description: Offline TDD, protocol, integration, and compatibility validation - [x] Fake provider create invokes only version/help and creates no session. - [x] First send captures prompt from stdin, mints deterministic UUID, and persists binding before completion. - [x] Second send receives the identical UUID in explicit resume argv. -- [x] Existing store tests cover concurrent send, stale lock recovery, and canonical cwd; Codex tests cover post-bind failure and session mismatch. +- [x] Existing repository tests cover concurrent send, stale-run recovery, and canonical cwd; Codex tests cover post-bind failure and session mismatch. - [x] Claude print and interactive Codex regression suites remain green. ## End-to-End Tests @@ -62,7 +62,7 @@ description: Offline TDD, protocol, integration, and compatibility validation ## Test Data -`fake-codex.cjs` supports version/help, initial/resume syntax, deterministic UUID, stdin/argv/cwd capture, chunked and multiple events, delay/concurrency, secret stderr, non-zero exit, malformed/oversized/truncated streams, missing required events, mismatch, and pre/post-binding failures. Tests use temporary store/cwd paths and deterministic process/clock injections. +`fake-codex.cjs` supports version/help, initial/resume syntax, deterministic UUID, stdin/argv/cwd capture, chunked and multiple events, delay/concurrency, secret stderr, non-zero exit, malformed/oversized/truncated streams, missing required events, mismatch, and pre/post-binding failures. Tests use temporary SQLite database/cwd paths and deterministic process/clock injections. ## Test Reporting & Coverage @@ -78,7 +78,7 @@ No real Codex model run is permitted. Human inspection is limited to fake-provid ## Performance Testing - [x] Oversized output remains bounded. -- [x] Concurrent lock contention fails promptly through the shared store suite. +- [x] Concurrent SQLite/CAS contention fails promptly through the shared repository suite. - [x] Listing mixed records requires no provider process. ## Bug Tracking diff --git a/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts b/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts index 201106d5..18209bbc 100644 --- a/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts +++ b/packages/agent-manager/src/__tests__/durable/CodexPrintAgentService.test.ts @@ -9,7 +9,7 @@ describe('CodexPrintAgentService', () => { it('constructs default non-billable dependencies without invoking them', async () => { const api = await import('../../index.js') as Record; const Service = api.CodexPrintAgentService as new () => any; - expect(new Service().store).toBeDefined(); + expect(new Service().repository).toBeDefined(); }); it('validates before provider-aware create and never runs Codex', async () => { @@ -98,13 +98,13 @@ describe('CodexPrintAgentService', () => { it('rejects missing and ambiguous records before acquiring a run', async () => { const api = await import('../../index.js') as Record; const Service = api.CodexPrintAgentService as new (options: unknown) => any; - const store = { + const repository = { list: vi.fn(), create: vi.fn(), resolve: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce([base, base]), acquireRun: vi.fn(), recordProviderProcess: vi.fn(), bindProviderSession: vi.fn(), completeRun: vi.fn(), }; - const service = new Service({ store, probe: { validate: vi.fn() }, runner: { run: vi.fn() } }); - await expect(service.send('missing', 'x')).rejects.toMatchObject({ code: 'PRINT_AGENT_NOT_FOUND' }); + const service = new Service({ repository, probe: { validate: vi.fn() }, runner: { run: vi.fn() } }); + await expect(service.send('missing', 'x')).rejects.toMatchObject({ code: 'DURABLE_AGENT_NOT_FOUND' }); await expect(service.send('ambiguous', 'x')).rejects.toMatchObject({ code: 'CODEX_UNSUPPORTED' }); - expect(store.acquireRun).not.toHaveBeenCalled(); + expect(repository.acquireRun).not.toHaveBeenCalled(); }); }); diff --git a/packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts b/packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts index fe07be49..30d24231 100644 --- a/packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts +++ b/packages/agent-manager/src/__tests__/durable/CodexPrintRunner.test.ts @@ -7,7 +7,7 @@ const SESSION = '22222222-2222-4222-8222-222222222222'; function agent(providerSessionId: string | null = null): CodexDurableAgent { return { - id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'codex', mode: 'print', + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'codex', mode: 'durable', cwd: '/project', providerSessionId, state: 'running', sessionHealth: 'uninitialized', createdAt: '', updatedAt: '', lastActiveAt: null, lastResult: null, activeRun: null, }; diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts index 459bd4f3..83dc6b3d 100644 --- a/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts @@ -18,7 +18,7 @@ describe('ClaudePrintAgentService', () => { 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 base = { id: 'id', name: 'reviewer', provider: 'claude', providerSessionId: 'session', sessionHealth: 'uninitialized' }; const repository = { resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn() diff --git a/packages/agent-manager/src/durable/ClaudePrintAgentService.ts b/packages/agent-manager/src/durable/ClaudePrintAgentService.ts index fa875ac7..eb76ca41 100644 --- a/packages/agent-manager/src/durable/ClaudePrintAgentService.ts +++ b/packages/agent-manager/src/durable/ClaudePrintAgentService.ts @@ -54,6 +54,9 @@ export class ClaudePrintAgentService { } const acquired = await this.repository.acquireRun(resolved.id); try { + if (acquired.agent.provider !== 'claude') { + throw new ClaudePrintError('Durable agent provider is not Claude.', 'CLAUDE_PRINT_UNSUPPORTED'); + } const result = await this.runner.run({ agent: acquired.agent, prompt, diff --git a/packages/agent-manager/src/durable/ClaudePrintRunner.ts b/packages/agent-manager/src/durable/ClaudePrintRunner.ts index 5e248372..0f4c4fa5 100644 --- a/packages/agent-manager/src/durable/ClaudePrintRunner.ts +++ b/packages/agent-manager/src/durable/ClaudePrintRunner.ts @@ -1,5 +1,5 @@ import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'child_process'; -import type { DurableAgent, ProcessIdentity } from './DurableAgent.js'; +import type { ClaudeDurableAgent, ProcessIdentity } from './DurableAgent.js'; import { ClaudePrintError } from './DurableAgent.js'; import { LocalProcessInspector, type ProcessInspector } from './DurableAgentRepository.js'; @@ -10,7 +10,7 @@ type Spawn = ( ) => ChildProcessWithoutNullStreams; export interface ClaudePrintRunRequest { - agent: DurableAgent; + agent: ClaudeDurableAgent; prompt: string; executable?: string; firstRun: boolean; diff --git a/packages/agent-manager/src/durable/DurableAgentRepository.ts b/packages/agent-manager/src/durable/DurableAgentRepository.ts index 4f00c4a5..bf3a39fc 100644 --- a/packages/agent-manager/src/durable/DurableAgentRepository.ts +++ b/packages/agent-manager/src/durable/DurableAgentRepository.ts @@ -276,9 +276,9 @@ export class DurableAgentRepository { }, startedAt: row.active_run_started_at!, }; - return { + const base = { 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, + 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!, @@ -286,6 +286,9 @@ export class DurableAgentRepository { }, activeRun, }; + return row.provider === 'claude' + ? { ...base, provider: 'claude', providerSessionId: row.provider_session_id! } + : { ...base, provider: 'codex', providerSessionId: row.provider_session_id }; } private canonicalDirectory(input: string): string {