Compare commits
2 Commits
e7dbc9870f
...
47e20d8ec6
| Author | SHA1 | Date | |
|---|---|---|---|
| 47e20d8ec6 | |||
| 83bbf1a9ce |
@@ -32,6 +32,8 @@ AGENT_RELATIONSHIP_MAX_CHILDREN=4
|
|||||||
# Resource provisioning (hard + soft constraints)
|
# Resource provisioning (hard + soft constraints)
|
||||||
AGENT_WORKTREE_ROOT=.ai_ops/worktrees
|
AGENT_WORKTREE_ROOT=.ai_ops/worktrees
|
||||||
AGENT_WORKTREE_BASE_REF=HEAD
|
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_BASE=36000
|
||||||
AGENT_PORT_BLOCK_SIZE=32
|
AGENT_PORT_BLOCK_SIZE=32
|
||||||
AGENT_PORT_BLOCK_COUNT=512
|
AGENT_PORT_BLOCK_COUNT=512
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
- Provisioning/resource controls:
|
- Provisioning/resource controls:
|
||||||
- `AGENT_WORKTREE_ROOT`
|
- `AGENT_WORKTREE_ROOT`
|
||||||
- `AGENT_WORKTREE_BASE_REF`
|
- `AGENT_WORKTREE_BASE_REF`
|
||||||
|
- `AGENT_WORKTREE_TARGET_PATH`
|
||||||
- `AGENT_PORT_BASE`
|
- `AGENT_PORT_BASE`
|
||||||
- `AGENT_PORT_BLOCK_SIZE`
|
- `AGENT_PORT_BLOCK_SIZE`
|
||||||
- `AGENT_PORT_BLOCK_COUNT`
|
- `AGENT_PORT_BLOCK_COUNT`
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ TypeScript runtime for deterministic multi-agent execution with:
|
|||||||
- `artifactPointers`
|
- `artifactPointers`
|
||||||
- `taskQueue`
|
- `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
|
## Repository Layout
|
||||||
|
|
||||||
- `src/agents`
|
- `src/agents`
|
||||||
@@ -97,6 +103,7 @@ Provider mode notes:
|
|||||||
|
|
||||||
- `provider=codex` uses existing OpenAI/Codex auth settings (`OPENAI_AUTH_MODE`, `CODEX_API_KEY`, `OPENAI_API_KEY`).
|
- `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).
|
- `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
|
## Manifest Semantics
|
||||||
|
|
||||||
@@ -267,6 +274,7 @@ jq -c 'select(.severity=="critical")' .ai_ops/events/runtime-events.ndjson
|
|||||||
|
|
||||||
- `AGENT_WORKTREE_ROOT`
|
- `AGENT_WORKTREE_ROOT`
|
||||||
- `AGENT_WORKTREE_BASE_REF`
|
- `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_BASE`
|
||||||
- `AGENT_PORT_BLOCK_SIZE`
|
- `AGENT_PORT_BLOCK_SIZE`
|
||||||
- `AGENT_PORT_BLOCK_COUNT`
|
- `AGENT_PORT_BLOCK_COUNT`
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- Recursive manager runtime (`src/agents/manager.ts`): utility invoked by the pipeline engine for fan-out/retry-unrolled execution.
|
||||||
|
|
||||||
## Constraint model
|
## Constraint model
|
||||||
|
|||||||
160
docs/session-walkthrough.md
Normal file
160
docs/session-walkthrough.md
Normal file
@@ -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/<session>/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/<session>/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/<session>/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/<session>/last_completed_node`
|
||||||
|
- `sessions/<session>/last_attempt`
|
||||||
|
- `sessions/<session>/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 `<sid>`, inspect in this order:
|
||||||
|
|
||||||
|
1. `state/<sid>/ui-run-meta.json`
|
||||||
|
2. `.ai_ops/events/runtime-events.ndjson` filtered by `<sid>`
|
||||||
|
3. `state/<sid>/handoffs/*.json`
|
||||||
|
4. `state/<sid>/state.json`
|
||||||
|
5. `.ai_ops/project-context.json` pointer entries for `<sid>`
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
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 { dirname, isAbsolute, resolve } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
@@ -272,6 +272,7 @@ export class ResourceProvisioningOrchestrator {
|
|||||||
export type GitWorktreeProviderConfig = {
|
export type GitWorktreeProviderConfig = {
|
||||||
rootDirectory: string;
|
rootDirectory: string;
|
||||||
baseRef: string;
|
baseRef: string;
|
||||||
|
targetPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PortRangeProviderConfig = {
|
export type PortRangeProviderConfig = {
|
||||||
@@ -313,6 +314,10 @@ export function createGitWorktreeProvider(
|
|||||||
provision: async ({ sessionId, workspaceRoot, options }) => {
|
provision: async ({ sessionId, workspaceRoot, options }) => {
|
||||||
const rootDirectory = readOptionalString(options, "rootDirectory", config.rootDirectory);
|
const rootDirectory = readOptionalString(options, "rootDirectory", config.rootDirectory);
|
||||||
const baseRef = readOptionalString(options, "baseRef", config.baseRef);
|
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 repoRoot = await runGit(["-C", workspaceRoot, "rev-parse", "--show-toplevel"]);
|
||||||
const worktreeRoot = resolvePath(repoRoot, rootDirectory);
|
const worktreeRoot = resolvePath(repoRoot, rootDirectory);
|
||||||
@@ -321,6 +326,18 @@ export function createGitWorktreeProvider(
|
|||||||
const worktreeName = buildScopedName(sessionId);
|
const worktreeName = buildScopedName(sessionId);
|
||||||
const worktreePath = resolve(worktreeRoot, worktreeName);
|
const worktreePath = resolve(worktreeRoot, worktreeName);
|
||||||
await runGit(["-C", repoRoot, "worktree", "add", "--detach", worktreePath, baseRef]);
|
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 {
|
return {
|
||||||
kind: "git-worktree",
|
kind: "git-worktree",
|
||||||
@@ -329,6 +346,7 @@ export function createGitWorktreeProvider(
|
|||||||
worktreeRoot,
|
worktreeRoot,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
baseRef,
|
baseRef,
|
||||||
|
...(targetPath ? { targetPath } : {}),
|
||||||
},
|
},
|
||||||
soft: {
|
soft: {
|
||||||
env: {
|
env: {
|
||||||
@@ -339,12 +357,14 @@ export function createGitWorktreeProvider(
|
|||||||
promptSections: [
|
promptSections: [
|
||||||
`Git worktree: ${worktreePath}`,
|
`Git worktree: ${worktreePath}`,
|
||||||
`Worktree base ref: ${baseRef}`,
|
`Worktree base ref: ${baseRef}`,
|
||||||
|
...(targetPath ? [`Worktree target path: ${targetPath} (sparse-checkout enabled)`] : []),
|
||||||
],
|
],
|
||||||
metadata: {
|
metadata: {
|
||||||
git_worktree_path: worktreePath,
|
git_worktree_path: worktreePath,
|
||||||
git_worktree_base_ref: baseRef,
|
git_worktree_base_ref: baseRef,
|
||||||
|
...(targetPath ? { git_worktree_target_path: targetPath } : {}),
|
||||||
},
|
},
|
||||||
preferredWorkingDirectory: worktreePath,
|
preferredWorkingDirectory,
|
||||||
},
|
},
|
||||||
release: async () => {
|
release: async () => {
|
||||||
await runGit(["-C", repoRoot, "worktree", "remove", "--force", worktreePath]);
|
await runGit(["-C", repoRoot, "worktree", "remove", "--force", worktreePath]);
|
||||||
@@ -576,6 +596,21 @@ function readOptionalString(
|
|||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalStringOrUndefined(
|
||||||
|
options: Record<string, unknown>,
|
||||||
|
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(
|
function readOptionalInteger(
|
||||||
options: Record<string, unknown>,
|
options: Record<string, unknown>,
|
||||||
key: string,
|
key: string,
|
||||||
@@ -595,6 +630,46 @@ function readOptionalInteger(
|
|||||||
return value;
|
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<void> {
|
||||||
|
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<string, JsonValue>, key: string): number {
|
function readNumberFromAllocation(allocation: Record<string, JsonValue>, key: string): number {
|
||||||
const value = allocation[key];
|
const value = allocation[key];
|
||||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||||
@@ -642,6 +717,8 @@ export function buildChildResourceRequests(input: ChildResourceSuballocationInpu
|
|||||||
const parentWorktreePath = readStringFromAllocation(parentGit, "worktreePath");
|
const parentWorktreePath = readStringFromAllocation(parentGit, "worktreePath");
|
||||||
const baseRefRaw = parentGit.baseRef;
|
const baseRefRaw = parentGit.baseRef;
|
||||||
const baseRef = typeof baseRefRaw === "string" && baseRefRaw.trim().length > 0 ? baseRefRaw : "HEAD";
|
const baseRef = typeof baseRefRaw === "string" && baseRefRaw.trim().length > 0 ? baseRefRaw : "HEAD";
|
||||||
|
const targetPathRaw = parentGit.targetPath;
|
||||||
|
const targetPath = typeof targetPathRaw === "string" ? targetPathRaw.trim() : "";
|
||||||
|
|
||||||
requests.push({
|
requests.push({
|
||||||
kind: "git-worktree",
|
kind: "git-worktree",
|
||||||
@@ -652,6 +729,7 @@ export function buildChildResourceRequests(input: ChildResourceSuballocationInpu
|
|||||||
buildScopedName(input.parentSnapshot.sessionId),
|
buildScopedName(input.parentSnapshot.sessionId),
|
||||||
),
|
),
|
||||||
baseRef,
|
baseRef,
|
||||||
|
...(targetPath ? { targetPath } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function toProvisioningConfig(input: Readonly<AppConfig>): BuiltInProvisioningCo
|
|||||||
gitWorktree: {
|
gitWorktree: {
|
||||||
rootDirectory: input.provisioning.gitWorktree.rootDirectory,
|
rootDirectory: input.provisioning.gitWorktree.rootDirectory,
|
||||||
baseRef: input.provisioning.gitWorktree.baseRef,
|
baseRef: input.provisioning.gitWorktree.baseRef,
|
||||||
|
targetPath: input.provisioning.gitWorktree.targetPath,
|
||||||
},
|
},
|
||||||
portRange: {
|
portRange: {
|
||||||
basePort: input.provisioning.portRange.basePort,
|
basePort: input.provisioning.portRange.basePort,
|
||||||
|
|||||||
@@ -124,6 +124,50 @@ function readOptionalString(
|
|||||||
return value;
|
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(
|
function readStringWithFallback(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
key: string,
|
key: string,
|
||||||
@@ -312,7 +356,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly<AppCo
|
|||||||
codexSkipGitCheck: readBooleanWithFallback(env, "CODEX_SKIP_GIT_CHECK", true),
|
codexSkipGitCheck: readBooleanWithFallback(env, "CODEX_SKIP_GIT_CHECK", true),
|
||||||
anthropicOauthToken,
|
anthropicOauthToken,
|
||||||
anthropicApiKey,
|
anthropicApiKey,
|
||||||
claudeModel: readOptionalString(env, "CLAUDE_MODEL"),
|
claudeModel: normalizeClaudeModel(readOptionalString(env, "CLAUDE_MODEL")),
|
||||||
claudeCodePath: readOptionalString(env, "CLAUDE_CODE_PATH"),
|
claudeCodePath: readOptionalString(env, "CLAUDE_CODE_PATH"),
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
@@ -380,6 +424,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly<AppCo
|
|||||||
"AGENT_WORKTREE_BASE_REF",
|
"AGENT_WORKTREE_BASE_REF",
|
||||||
DEFAULT_PROVISIONING.gitWorktree.baseRef,
|
DEFAULT_PROVISIONING.gitWorktree.baseRef,
|
||||||
),
|
),
|
||||||
|
targetPath: readOptionalRelativePath(env, "AGENT_WORKTREE_TARGET_PATH"),
|
||||||
},
|
},
|
||||||
portRange: {
|
portRange: {
|
||||||
basePort: readIntegerWithBounds(
|
basePort: readIntegerWithBounds(
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ const CLAUDE_OUTPUT_FORMAT = {
|
|||||||
schema: ACTOR_RESPONSE_SCHEMA,
|
schema: ACTOR_RESPONSE_SCHEMA,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const CLAUDE_PROVIDER_MAX_TURNS = 2;
|
||||||
|
|
||||||
function toErrorMessage(error: unknown): string {
|
function toErrorMessage(error: unknown): string {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return error.message;
|
return error.message;
|
||||||
@@ -433,7 +435,7 @@ function buildClaudeOptions(input: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
maxTurns: 1,
|
maxTurns: CLAUDE_PROVIDER_MAX_TURNS,
|
||||||
...(runtime.config.provider.claudeModel
|
...(runtime.config.provider.claudeModel
|
||||||
? { model: runtime.config.provider.claudeModel }
|
? { model: runtime.config.provider.claudeModel }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { SchemaDrivenExecutionEngine } from "../agents/orchestration.js";
|
import { SchemaDrivenExecutionEngine } from "../agents/orchestration.js";
|
||||||
import { parseAgentManifest, type AgentManifest } from "../agents/manifest.js";
|
import { parseAgentManifest, type AgentManifest } from "../agents/manifest.js";
|
||||||
import type { ActorExecutionResult, ActorExecutor } from "../agents/pipeline.js";
|
import type {
|
||||||
|
ActorExecutionResult,
|
||||||
|
ActorExecutor,
|
||||||
|
PipelineAggregateStatus,
|
||||||
|
} from "../agents/pipeline.js";
|
||||||
import { loadConfig, type AppConfig } from "../config.js";
|
import { loadConfig, type AppConfig } from "../config.js";
|
||||||
import { parseEnvFile } from "./env-store.js";
|
import { parseEnvFile } from "./env-store.js";
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +47,10 @@ export type RunRecord = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function toRunStatus(status: PipelineAggregateStatus): Extract<RunStatus, "success" | "failure"> {
|
||||||
|
return status === "success" ? "success" : "failure";
|
||||||
|
}
|
||||||
|
|
||||||
type ActiveRun = {
|
type ActiveRun = {
|
||||||
controller: AbortController;
|
controller: AbortController;
|
||||||
record: RunRecord;
|
record: RunRecord;
|
||||||
@@ -385,7 +393,7 @@ export class UiRunService {
|
|||||||
run: record,
|
run: record,
|
||||||
});
|
});
|
||||||
|
|
||||||
await engine.runSession({
|
const summary = await engine.runSession({
|
||||||
sessionId,
|
sessionId,
|
||||||
initialPayload: {
|
initialPayload: {
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
@@ -405,7 +413,7 @@ export class UiRunService {
|
|||||||
|
|
||||||
const next: RunRecord = {
|
const next: RunRecord = {
|
||||||
...completedRecord,
|
...completedRecord,
|
||||||
status: "success",
|
status: toRunStatus(summary.status),
|
||||||
endedAt: new Date().toISOString(),
|
endedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
this.runHistory.set(runId, next);
|
this.runHistory.set(runId, next);
|
||||||
|
|||||||
@@ -104,3 +104,26 @@ test("resolveOpenAiApiKey prefers CODEX_API_KEY in auto mode", () => {
|
|||||||
|
|
||||||
assert.equal(resolveOpenAiApiKey(config.provider), "codex-key");
|
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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function parentSnapshot(): DiscoverySnapshot {
|
|||||||
worktreeRoot: "/repo/.ai_ops/worktrees",
|
worktreeRoot: "/repo/.ai_ops/worktrees",
|
||||||
worktreePath: "/repo/.ai_ops/worktrees/parent",
|
worktreePath: "/repo/.ai_ops/worktrees/parent",
|
||||||
baseRef: "HEAD",
|
baseRef: "HEAD",
|
||||||
|
targetPath: "src/agents",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,6 +56,7 @@ test("builds deterministic child suballocation requests", () => {
|
|||||||
const gitRequest = requests.find((entry) => entry.kind === "git-worktree");
|
const gitRequest = requests.find((entry) => entry.kind === "git-worktree");
|
||||||
assert.ok(gitRequest);
|
assert.ok(gitRequest);
|
||||||
assert.equal(typeof gitRequest.options?.rootDirectory, "string");
|
assert.equal(typeof gitRequest.options?.rootDirectory, "string");
|
||||||
|
assert.equal(gitRequest.options?.targetPath, "src/agents");
|
||||||
|
|
||||||
const portRequest = requests.find((entry) => entry.kind === "port-range");
|
const portRequest = requests.find((entry) => entry.kind === "port-range");
|
||||||
assert.ok(portRequest);
|
assert.ok(portRequest);
|
||||||
|
|||||||
96
tests/run-service.test.ts
Normal file
96
tests/run-service.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
0
workspace/.gitkeep
Normal file
0
workspace/.gitkeep
Normal file
Reference in New Issue
Block a user