diff --git a/.env.example b/.env.example index 3b5f346..f3d50f3 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,8 @@ AGENT_RELATIONSHIP_MAX_CHILDREN=4 # Resource provisioning (hard + soft constraints) AGENT_WORKTREE_ROOT=.ai_ops/worktrees AGENT_WORKTREE_BASE_REF=HEAD +# Optional relative path inside each worktree; enables sparse-checkout and sets working directory there. +AGENT_WORKTREE_TARGET_PATH= AGENT_PORT_BASE=36000 AGENT_PORT_BLOCK_SIZE=32 AGENT_PORT_BLOCK_COUNT=512 diff --git a/AGENTS.md b/AGENTS.md index f589202..0d6f3ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ - Provisioning/resource controls: - `AGENT_WORKTREE_ROOT` - `AGENT_WORKTREE_BASE_REF` + - `AGENT_WORKTREE_TARGET_PATH` - `AGENT_PORT_BASE` - `AGENT_PORT_BLOCK_SIZE` - `AGENT_PORT_BLOCK_COUNT` diff --git a/README.md b/README.md index e2b5347..6bf5ab9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ TypeScript runtime for deterministic multi-agent execution with: - `artifactPointers` - `taskQueue` +## Deep Dives + +- Session walkthrough with concrete artifacts from a successful provider run: `docs/session-walkthrough.md` +- Orchestration engine internals: `docs/orchestration-engine.md` +- Runtime event model and sinks: `docs/runtime-events.md` + ## Repository Layout - `src/agents` @@ -98,6 +104,7 @@ Provider mode notes: - `provider=codex` uses existing OpenAI/Codex auth settings (`OPENAI_AUTH_MODE`, `CODEX_API_KEY`, `OPENAI_API_KEY`). - `provider=claude` uses Claude auth resolution (`CLAUDE_CODE_OAUTH_TOKEN` preferred, otherwise `ANTHROPIC_API_KEY`, or existing Claude Code login state). +- `CLAUDE_MODEL` should be a Claude model id/alias recognized by Claude Code (for example `claude-sonnet-4-6`); `anthropic/...` prefixes are normalized automatically. ## Manifest Semantics @@ -268,6 +275,7 @@ jq -c 'select(.severity=="critical")' .ai_ops/events/runtime-events.ndjson - `AGENT_WORKTREE_ROOT` - `AGENT_WORKTREE_BASE_REF` +- `AGENT_WORKTREE_TARGET_PATH` (optional relative path; enables sparse checkout and sets session working directory to that subfolder) - `AGENT_PORT_BASE` - `AGENT_PORT_BLOCK_SIZE` - `AGENT_PORT_BLOCK_COUNT` diff --git a/docs/orchestration-engine.md b/docs/orchestration-engine.md index c789899..20b27f4 100644 --- a/docs/orchestration-engine.md +++ b/docs/orchestration-engine.md @@ -13,6 +13,7 @@ The orchestration runtime introduces explicit schema validation and deterministi - Project context store (`src/agents/project-context.ts`): project-scoped global flags, artifact pointers, and task queue persisted across sessions. - Orchestration facade (`src/agents/orchestration.ts`): wires manifest + registry + pipeline + state manager + project context with env-driven limits. - Hierarchical resource suballocation (`src/agents/provisioning.ts`): builds child `git-worktree` and child `port-range` requests from parent allocation data. + - Optional `AGENT_WORKTREE_TARGET_PATH` enables sparse-checkout for a subdirectory and sets per-session working directory to that target path. - Recursive manager runtime (`src/agents/manager.ts`): utility invoked by the pipeline engine for fan-out/retry-unrolled execution. ## Constraint model diff --git a/docs/session-walkthrough.md b/docs/session-walkthrough.md new file mode 100644 index 0000000..84e3998 --- /dev/null +++ b/docs/session-walkthrough.md @@ -0,0 +1,160 @@ +# Session Walkthrough (Concrete Example) + +This document walks through one successful provider run end-to-end using: + +- session id: `ui-session-mlzw94bv-cb753677` +- run id: `9287775f-a507-492a-9afa-347ed3f3a6b3` +- execution mode: `provider` +- provider: `claude` +- manifest: `.ai_ops/manifests/test.json` + +Use this as a mental model and as a debugging template for future sessions. + +## 1) What happened in this run + +The manifest defines two sequential nodes: + +1. `write-node` (persona: writer) +2. `copy-node` (persona: copy-editor) + +Edge routing is `write-node -> copy-node` on `success`. + +In this run: + +1. `write-node` succeeded on attempt 1 and emitted `validation_passed` and `tasks_planned`. +2. `copy-node` succeeded on attempt 1 and emitted `validation_passed`. +3. Session aggregate status was `success`. + +## 2) Timeline from runtime events + +From `.ai_ops/events/runtime-events.ndjson`: + +1. `2026-02-24T00:55:28.632Z` `session.started` +2. `2026-02-24T00:55:48.705Z` `node.attempt.completed` for `write-node` with `status=success` +3. `2026-02-24T00:55:48.706Z` `domain.validation_passed` for `write-node` +4. `2026-02-24T00:55:48.706Z` `domain.tasks_planned` for `write-node` +5. `2026-02-24T00:56:14.237Z` `node.attempt.completed` for `copy-node` with `status=success` +6. `2026-02-24T00:56:14.238Z` `domain.validation_passed` for `copy-node` +7. `2026-02-24T00:56:14.242Z` `session.completed` with `status=success` + +## 3) How artifacts map to runtime behavior + +### Run metadata (UI-level) + +`state//ui-run-meta.json` stores run summary fields: + +- run/provider/mode +- status (`running`, `success`, `failure`, `cancelled`) +- start/end timestamps + +For this run: + +```json +{ + "sessionId": "ui-session-mlzw94bv-cb753677", + "status": "success", + "executionMode": "provider", + "provider": "claude" +} +``` + +### Handoffs (node input payloads) + +`state//handoffs/*.json` stores payload handoffs per node. + +`write-node.json`: + +```json +{ + "nodeId": "write-node", + "payload": { "prompt": "be yourself" } +} +``` + +`copy-node.json` includes `fromNodeId: "write-node"` and carries the story generated by the writer node. + +Important: this is the payload pipeline edge transfer. If a downstream node output looks strange, inspect this file first. + +### Session state (flags + metadata + history) + +`state//state.json` is cumulative session state: + +- `flags`: merged boolean flags from node results +- `metadata`: merged metadata from node results/behavior patches +- `history`: domain-event history entries + +For this run, state includes: + +- flags: `story_written=true`, `copy_edited=true` +- history events: + - `write-node: validation_passed` + - `write-node: tasks_planned` + - `copy-node: validation_passed` + +### Project context pointer + +`.ai_ops/project-context.json` tracks cross-session pointers like: + +- `sessions//last_completed_node` +- `sessions//last_attempt` +- `sessions//final_state` + +This lets operators and tooling locate the final state file for any completed session. + +## 4) Code path (from button click to persisted state) + +1. UI starts run via `UiRunService.startRun(...)`. +2. Service loads config, parses manifest, creates engine, writes initial run meta. +3. Engine `runSession(...)` initializes state and writes entry handoff. +4. Pipeline executes ready nodes: + - builds fresh node context (`handoff + state`) + - renders persona system prompt + - invokes provider executor + - receives actor result +5. Lifecycle observer persists: + - state flags/metadata/history + - runtime events (`node.attempt.completed`, `domain.*`) + - project context pointers (`last_completed_node`, `last_attempt`) +6. Pipeline evaluates edges and writes downstream handoffs. +7. Pipeline computes aggregate status and emits `session.completed`. +8. UI run service writes final `ui-run-meta.json` status from pipeline summary. + +Primary entrypoints: + +- `src/ui/run-service.ts` +- `src/agents/orchestration.ts` +- `src/agents/pipeline.ts` +- `src/agents/lifecycle-observer.ts` +- `src/agents/state-context.ts` +- `src/ui/provider-executor.ts` + +## 5) Mental model that keeps this manageable + +Think of one session as five stores and one loop: + +1. Manifest (static plan): node graph + routing rules. +2. Handoffs (per-node input payload snapshots). +3. State (session memory): flags + metadata + domain history. +4. Runtime events (timeline/audit side channel). +5. Project context (cross-session pointers and shared context). +6. Loop: dequeue ready node -> execute -> persist result/events -> enqueue next nodes. + +If you track those six things, behavior becomes deterministic and explainable. + +## 6) Debug checklist for any future session id + +Given ``, inspect in this order: + +1. `state//ui-run-meta.json` +2. `.ai_ops/events/runtime-events.ndjson` filtered by `` +3. `state//handoffs/*.json` +4. `state//state.json` +5. `.ai_ops/project-context.json` pointer entries for `` + +Interpretation: + +1. No `session.started`: run failed before pipeline began. +2. `node.attempt.completed` with `failureCode=provider_*`: provider/runtime issue. +3. Missing downstream handoff file: edge condition did not pass. +4. `history` has `validation_failed`: retry/unrolled path or remediation branch likely triggered. +5. `ui-run-meta` disagrees with runtime events: check run-service status mapping and restart server on new code. diff --git a/src/agents/provisioning.ts b/src/agents/provisioning.ts index 8ee3c5c..1569e21 100644 --- a/src/agents/provisioning.ts +++ b/src/agents/provisioning.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdir, open, unlink, writeFile } from "node:fs/promises"; +import { mkdir, open, stat, unlink, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { promisify } from "node:util"; @@ -272,6 +272,7 @@ export class ResourceProvisioningOrchestrator { export type GitWorktreeProviderConfig = { rootDirectory: string; baseRef: string; + targetPath?: string; }; export type PortRangeProviderConfig = { @@ -313,6 +314,10 @@ export function createGitWorktreeProvider( provision: async ({ sessionId, workspaceRoot, options }) => { const rootDirectory = readOptionalString(options, "rootDirectory", config.rootDirectory); const baseRef = readOptionalString(options, "baseRef", config.baseRef); + const targetPath = normalizeWorktreeTargetPath( + readOptionalStringOrUndefined(options, "targetPath") ?? config.targetPath, + "targetPath", + ); const repoRoot = await runGit(["-C", workspaceRoot, "rev-parse", "--show-toplevel"]); const worktreeRoot = resolvePath(repoRoot, rootDirectory); @@ -321,6 +326,18 @@ export function createGitWorktreeProvider( const worktreeName = buildScopedName(sessionId); const worktreePath = resolve(worktreeRoot, worktreeName); await runGit(["-C", repoRoot, "worktree", "add", "--detach", worktreePath, baseRef]); + if (targetPath) { + await runGit(["-C", worktreePath, "sparse-checkout", "init", "--cone"]); + await runGit(["-C", worktreePath, "sparse-checkout", "set", targetPath]); + } + + const preferredWorkingDirectory = targetPath ? resolve(worktreePath, targetPath) : worktreePath; + await assertDirectoryExists( + preferredWorkingDirectory, + targetPath + ? `Configured worktree target path "${targetPath}" is not a directory in ref "${baseRef}".` + : `Provisioned worktree path "${preferredWorkingDirectory}" does not exist.`, + ); return { kind: "git-worktree", @@ -329,6 +346,7 @@ export function createGitWorktreeProvider( worktreeRoot, worktreePath, baseRef, + ...(targetPath ? { targetPath } : {}), }, soft: { env: { @@ -339,12 +357,14 @@ export function createGitWorktreeProvider( promptSections: [ `Git worktree: ${worktreePath}`, `Worktree base ref: ${baseRef}`, + ...(targetPath ? [`Worktree target path: ${targetPath} (sparse-checkout enabled)`] : []), ], metadata: { git_worktree_path: worktreePath, git_worktree_base_ref: baseRef, + ...(targetPath ? { git_worktree_target_path: targetPath } : {}), }, - preferredWorkingDirectory: worktreePath, + preferredWorkingDirectory, }, release: async () => { await runGit(["-C", repoRoot, "worktree", "remove", "--force", worktreePath]); @@ -576,6 +596,21 @@ function readOptionalString( return value.trim(); } +function readOptionalStringOrUndefined( + options: Record, + key: string, +): string | undefined { + const value = options[key]; + if (value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(`Option "${key}" must be a string when provided.`); + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function readOptionalInteger( options: Record, key: string, @@ -595,6 +630,46 @@ function readOptionalInteger( return value; } +function normalizeWorktreeTargetPath(value: string | undefined, key: string): string | undefined { + if (!value) { + return undefined; + } + + const slashNormalized = value.replace(/\\/g, "/"); + if (isAbsolute(slashNormalized) || /^[a-zA-Z]:\//.test(slashNormalized)) { + throw new Error(`Option "${key}" must be a relative path within the repository worktree.`); + } + + const normalizedSegments = slashNormalized + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0 && segment !== "."); + + if (normalizedSegments.some((segment) => segment === "..")) { + throw new Error(`Option "${key}" must not contain ".." path segments.`); + } + + if (normalizedSegments.length === 0) { + return undefined; + } + + return normalizedSegments.join("/"); +} + +async function assertDirectoryExists(path: string, errorMessage: string): Promise { + try { + const stats = await stat(path); + if (!stats.isDirectory()) { + throw new Error(errorMessage); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(errorMessage); + } + throw error; + } +} + function readNumberFromAllocation(allocation: Record, key: string): number { const value = allocation[key]; if (typeof value !== "number" || !Number.isInteger(value)) { @@ -642,6 +717,8 @@ export function buildChildResourceRequests(input: ChildResourceSuballocationInpu const parentWorktreePath = readStringFromAllocation(parentGit, "worktreePath"); const baseRefRaw = parentGit.baseRef; const baseRef = typeof baseRefRaw === "string" && baseRefRaw.trim().length > 0 ? baseRefRaw : "HEAD"; + const targetPathRaw = parentGit.targetPath; + const targetPath = typeof targetPathRaw === "string" ? targetPathRaw.trim() : ""; requests.push({ kind: "git-worktree", @@ -652,6 +729,7 @@ export function buildChildResourceRequests(input: ChildResourceSuballocationInpu buildScopedName(input.parentSnapshot.sessionId), ), baseRef, + ...(targetPath ? { targetPath } : {}), }, }); } diff --git a/src/agents/runtime.ts b/src/agents/runtime.ts index 31a0318..3d98f05 100644 --- a/src/agents/runtime.ts +++ b/src/agents/runtime.ts @@ -11,6 +11,7 @@ function toProvisioningConfig(input: Readonly): BuiltInProvisioningCo gitWorktree: { rootDirectory: input.provisioning.gitWorktree.rootDirectory, baseRef: input.provisioning.gitWorktree.baseRef, + targetPath: input.provisioning.gitWorktree.targetPath, }, portRange: { basePort: input.provisioning.portRange.basePort, diff --git a/src/config.ts b/src/config.ts index 53c110a..a914e7b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -124,6 +124,50 @@ function readOptionalString( return value; } +function readOptionalRelativePath( + env: NodeJS.ProcessEnv, + key: string, +): string | undefined { + const value = readOptionalString(env, key); + if (!value) { + return undefined; + } + + const slashNormalized = value.replace(/\\/g, "/"); + if (slashNormalized.startsWith("/") || /^[a-zA-Z]:\//.test(slashNormalized)) { + throw new Error(`Environment variable ${key} must be a relative path.`); + } + + const normalizedSegments = slashNormalized + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0 && segment !== "."); + + if (normalizedSegments.some((segment) => segment === "..")) { + throw new Error(`Environment variable ${key} must not contain ".." path segments.`); + } + + if (normalizedSegments.length === 0) { + return undefined; + } + + return normalizedSegments.join("/"); +} + +function normalizeClaudeModel(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + const anthropicPrefix = "anthropic/"; + if (!value.startsWith(anthropicPrefix)) { + return value; + } + + const normalized = value.slice(anthropicPrefix.length).trim(); + return normalized || undefined; +} + function readStringWithFallback( env: NodeJS.ProcessEnv, key: string, @@ -312,7 +356,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly { + return status === "success" ? "success" : "failure"; +} + type ActiveRun = { controller: AbortController; record: RunRecord; @@ -385,7 +393,7 @@ export class UiRunService { run: record, }); - await engine.runSession({ + const summary = await engine.runSession({ sessionId, initialPayload: { prompt: input.prompt, @@ -405,7 +413,7 @@ export class UiRunService { const next: RunRecord = { ...completedRecord, - status: "success", + status: toRunStatus(summary.status), endedAt: new Date().toISOString(), }; this.runHistory.set(runId, next); diff --git a/tests/config.test.ts b/tests/config.test.ts index 9ee3c10..fc74afa 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -104,3 +104,26 @@ test("resolveOpenAiApiKey prefers CODEX_API_KEY in auto mode", () => { assert.equal(resolveOpenAiApiKey(config.provider), "codex-key"); }); + +test("normalizes anthropic-prefixed CLAUDE_MODEL values", () => { + const config = loadConfig({ + CLAUDE_MODEL: "anthropic/claude-sonnet-4-6", + }); + + assert.equal(config.provider.claudeModel, "claude-sonnet-4-6"); +}); + +test("normalizes AGENT_WORKTREE_TARGET_PATH", () => { + const config = loadConfig({ + AGENT_WORKTREE_TARGET_PATH: "./src/agents/", + }); + + assert.equal(config.provisioning.gitWorktree.targetPath, "src/agents"); +}); + +test("validates AGENT_WORKTREE_TARGET_PATH against parent traversal", () => { + assert.throws( + () => loadConfig({ AGENT_WORKTREE_TARGET_PATH: "../secrets" }), + /must not contain "\.\." path segments/, + ); +}); diff --git a/tests/resource-suballocation.test.ts b/tests/resource-suballocation.test.ts index 78d37ed..a6f0485 100644 --- a/tests/resource-suballocation.test.ts +++ b/tests/resource-suballocation.test.ts @@ -18,6 +18,7 @@ function parentSnapshot(): DiscoverySnapshot { worktreeRoot: "/repo/.ai_ops/worktrees", worktreePath: "/repo/.ai_ops/worktrees/parent", baseRef: "HEAD", + targetPath: "src/agents", }, }, { @@ -55,6 +56,7 @@ test("builds deterministic child suballocation requests", () => { const gitRequest = requests.find((entry) => entry.kind === "git-worktree"); assert.ok(gitRequest); assert.equal(typeof gitRequest.options?.rootDirectory, "string"); + assert.equal(gitRequest.options?.targetPath, "src/agents"); const portRequest = requests.find((entry) => entry.kind === "port-range"); assert.ok(portRequest); diff --git a/tests/run-service.test.ts b/tests/run-service.test.ts new file mode 100644 index 0000000..097e510 --- /dev/null +++ b/tests/run-service.test.ts @@ -0,0 +1,96 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { UiRunService, readRunMetaBySession } from "../src/ui/run-service.js"; + +async function waitForTerminalRun( + runService: UiRunService, + runId: string, +): Promise<"success" | "failure" | "cancelled"> { + const maxPolls = 100; + for (let index = 0; index < maxPolls; index += 1) { + const run = runService.getRun(runId); + if (run && run.status !== "running") { + return run.status; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 20)); + } + throw new Error("Run did not reach a terminal status within polling window."); +} + +test("run service persists failure when pipeline summary is failure", async () => { + const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-run-service-")); + const stateRoot = resolve(workspaceRoot, "state"); + const projectContextPath = resolve(workspaceRoot, "project-context.json"); + const envPath = resolve(workspaceRoot, ".env"); + + await writeFile( + envPath, + [ + `AGENT_STATE_ROOT=${stateRoot}`, + `AGENT_PROJECT_CONTEXT_PATH=${projectContextPath}`, + ].join("\n"), + "utf8", + ); + + const runService = new UiRunService({ + workspaceRoot, + envFilePath: ".env", + }); + + const manifest = { + schemaVersion: "1", + topologies: ["sequential"], + personas: [ + { + id: "writer", + displayName: "Writer", + systemPromptTemplate: "Write the draft", + toolClearance: { + allowlist: ["read_file", "write_file"], + banlist: [], + }, + }, + ], + relationships: [], + topologyConstraints: { + maxDepth: 1, + maxRetries: 0, + }, + pipeline: { + entryNodeId: "write-node", + nodes: [ + { + id: "write-node", + actorId: "writer-actor", + personaId: "writer", + topology: { + kind: "sequential", + }, + constraints: { + maxRetries: 0, + }, + }, + ], + edges: [], + }, + }; + + const started = await runService.startRun({ + prompt: "force validation failure on first attempt", + manifest, + executionMode: "mock", + simulateValidationNodeIds: ["write-node"], + }); + + const terminalStatus = await waitForTerminalRun(runService, started.runId); + assert.equal(terminalStatus, "failure"); + + const persisted = await readRunMetaBySession({ + stateRoot, + sessionId: started.sessionId, + }); + assert.equal(persisted?.status, "failure"); +}); diff --git a/workspace/.gitkeep b/workspace/.gitkeep new file mode 100644 index 0000000..e69de29