Merge main into fix-1650-worktree-stale-branch
Resolves conflicts in packages/plugins/workspace-worktree/src/__tests__/index.test.ts:
keep the PR's new tests covering the SHA-comparison guard ("reuses an existing
branch when it matches", "resets an existing stale branch", "handles existing
branch with local default branch when origin is missing") over main's older
tests that asserted the buggy worktree-add+checkout fallback. Pick up
GIT_TIMEOUT (timeout: 30_000) on the new test assertions to match main.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
commit
cb2fc088a8
|
|
@ -79,6 +79,12 @@ projects:
|
|||
# deliveryHeader: x-github-delivery
|
||||
# maxBodyBytes: 1048576
|
||||
|
||||
# Per-project environment variables forwarded into worker session runtimes.
|
||||
# Useful for scoping per-project tokens (e.g. pinning gh auth via GH_TOKEN).
|
||||
# AO-internal vars (AO_SESSION, AO_PROJECT_ID, etc.) always take precedence.
|
||||
# env:
|
||||
# GH_TOKEN: ghp_xxx
|
||||
|
||||
# Files to symlink into workspaces
|
||||
# symlinks: [.env, .claude]
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ How to set up the Agent Orchestrator (AO) plugin for OpenClaw so the AI bot dele
|
|||
## Prerequisites
|
||||
|
||||
- [OpenClaw](https://openclaw.ai) installed and running
|
||||
- [Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) installed with `ao init` completed in your repo
|
||||
- [Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) installed with `ao start` completed in your repo
|
||||
- `ao`, `gh`, `tmux`, and `node` available in PATH
|
||||
- GitHub CLI (`gh`) authenticated
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
# @aoagents/ao
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f539a3]
|
||||
- @aoagents/ao-cli@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3a69722]
|
||||
- @aoagents/ao-cli@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Orchestrate parallel AI coding agents — global CLI wrapper",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,76 @@
|
|||
# @aoagents/ao-cli
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0f539a3: Fix dashboard 404 after adding a project from the "AO is already running" menu. The CLI now notifies the running daemon to reload its cached config so the new project's page is reachable immediately.
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
- @aoagents/ao-web@0.6.0
|
||||
- @aoagents/ao-plugin-runtime-tmux@0.6.0
|
||||
- @aoagents/ao-plugin-agent-aider@0.6.0
|
||||
- @aoagents/ao-plugin-agent-claude-code@0.6.0
|
||||
- @aoagents/ao-plugin-agent-codex@0.6.0
|
||||
- @aoagents/ao-plugin-agent-cursor@0.1.4
|
||||
- @aoagents/ao-plugin-agent-kimicode@0.1.3
|
||||
- @aoagents/ao-plugin-agent-opencode@0.6.0
|
||||
- @aoagents/ao-plugin-notifier-composio@0.6.0
|
||||
- @aoagents/ao-plugin-notifier-desktop@0.6.0
|
||||
- @aoagents/ao-plugin-notifier-discord@0.2.9
|
||||
- @aoagents/ao-plugin-notifier-openclaw@0.2.9
|
||||
- @aoagents/ao-plugin-notifier-slack@0.6.0
|
||||
- @aoagents/ao-plugin-notifier-webhook@0.6.0
|
||||
- @aoagents/ao-plugin-runtime-process@0.6.0
|
||||
- @aoagents/ao-plugin-scm-github@0.6.0
|
||||
- @aoagents/ao-plugin-terminal-iterm2@0.6.0
|
||||
- @aoagents/ao-plugin-terminal-web@0.6.0
|
||||
- @aoagents/ao-plugin-tracker-github@0.6.0
|
||||
- @aoagents/ao-plugin-tracker-linear@0.6.0
|
||||
- @aoagents/ao-plugin-workspace-clone@0.6.0
|
||||
- @aoagents/ao-plugin-workspace-worktree@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 3a69722: Remove the deprecated `ao init` command. Use `ao start` instead — it auto-creates the config on first run in an unconfigured repo.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- Updated dependencies [dd07b6b]
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
- @aoagents/ao-web@0.5.0
|
||||
- @aoagents/ao-plugin-agent-aider@0.5.0
|
||||
- @aoagents/ao-plugin-agent-claude-code@0.5.0
|
||||
- @aoagents/ao-plugin-agent-codex@0.5.0
|
||||
- @aoagents/ao-plugin-agent-cursor@0.1.3
|
||||
- @aoagents/ao-plugin-agent-kimicode@0.1.2
|
||||
- @aoagents/ao-plugin-agent-opencode@0.5.0
|
||||
- @aoagents/ao-plugin-notifier-composio@0.5.0
|
||||
- @aoagents/ao-plugin-notifier-desktop@0.5.0
|
||||
- @aoagents/ao-plugin-notifier-discord@0.2.8
|
||||
- @aoagents/ao-plugin-notifier-openclaw@0.2.8
|
||||
- @aoagents/ao-plugin-notifier-slack@0.5.0
|
||||
- @aoagents/ao-plugin-notifier-webhook@0.5.0
|
||||
- @aoagents/ao-plugin-runtime-process@0.5.0
|
||||
- @aoagents/ao-plugin-runtime-tmux@0.5.0
|
||||
- @aoagents/ao-plugin-scm-github@0.5.0
|
||||
- @aoagents/ao-plugin-terminal-iterm2@0.5.0
|
||||
- @aoagents/ao-plugin-terminal-web@0.5.0
|
||||
- @aoagents/ao-plugin-tracker-github@0.5.0
|
||||
- @aoagents/ao-plugin-tracker-linear@0.5.0
|
||||
- @aoagents/ao-plugin-workspace-clone@0.5.0
|
||||
- @aoagents/ao-plugin-workspace-worktree@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { Command } from "commander";
|
||||
import { registerInit } from "../../src/commands/init.js";
|
||||
|
||||
describe("init command", () => {
|
||||
it("registers as a deprecated command", () => {
|
||||
const program = new Command();
|
||||
registerInit(program);
|
||||
|
||||
const initCmd = program.commands.find((c) => c.name() === "init");
|
||||
expect(initCmd).toBeDefined();
|
||||
expect(initCmd!.description()).toContain("deprecated");
|
||||
});
|
||||
|
||||
it("has no --output, --auto, or --smart flags", () => {
|
||||
const program = new Command();
|
||||
registerInit(program);
|
||||
|
||||
const initCmd = program.commands.find((c) => c.name() === "init");
|
||||
expect(initCmd).toBeDefined();
|
||||
|
||||
const optionNames = initCmd!.options.map((o) => o.long);
|
||||
expect(optionNames).not.toContain("--output");
|
||||
expect(optionNames).not.toContain("--auto");
|
||||
expect(optionNames).not.toContain("--smart");
|
||||
});
|
||||
});
|
||||
|
|
@ -230,6 +230,13 @@ describe("spawn command", () => {
|
|||
mkdirSync(backendSubdir, { recursive: true });
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(backendSubdir);
|
||||
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 1234,
|
||||
port: 3000,
|
||||
startedAt: "",
|
||||
projects: ["backend", "frontend"],
|
||||
});
|
||||
|
||||
const fakeSession: Session = {
|
||||
id: "be-1",
|
||||
projectId: "backend",
|
||||
|
|
@ -284,7 +291,7 @@ describe("spawn command", () => {
|
|||
pid: 1234,
|
||||
port: 3000,
|
||||
startedAt: "",
|
||||
projects: ["agent-orchestrator"],
|
||||
projects: ["agent-orchestrator", "x402-identity"],
|
||||
});
|
||||
|
||||
const fakeSession: Session = {
|
||||
|
|
@ -336,7 +343,7 @@ describe("spawn command", () => {
|
|||
pid: 1234,
|
||||
port: 3000,
|
||||
startedAt: "",
|
||||
projects: ["agent-orchestrator"],
|
||||
projects: ["agent-orchestrator", "x402-identity"],
|
||||
});
|
||||
|
||||
const fakeSession: Session = {
|
||||
|
|
@ -947,6 +954,12 @@ describe("batch-spawn command", () => {
|
|||
};
|
||||
mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true });
|
||||
mkdirSync(join(tmpDir, "x402-identity"), { recursive: true });
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 1234,
|
||||
port: 3000,
|
||||
startedAt: "",
|
||||
projects: ["agent-orchestrator", "x402-identity"],
|
||||
});
|
||||
|
||||
// Pre-existing active session in x402-identity for issue 20
|
||||
mockSessionManager.list.mockImplementation(async (pid: string) => {
|
||||
|
|
@ -983,3 +996,91 @@ describe("batch-spawn command", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawn daemon-polling enforcement", () => {
|
||||
it("refuses to spawn when no AO daemon is running", async () => {
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(errors).toContain("AO is not running");
|
||||
expect(errors).toContain("ao start");
|
||||
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to spawn when the running daemon is not polling the project", async () => {
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 99999,
|
||||
port: 3000,
|
||||
startedAt: "",
|
||||
projects: ["other-project"],
|
||||
});
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(errors).toContain("not polling project");
|
||||
expect(errors).toContain("my-app");
|
||||
expect(errors).toContain("ao start my-app");
|
||||
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("batch-spawn daemon-polling enforcement", () => {
|
||||
let batchProgram: Command;
|
||||
|
||||
beforeEach(() => {
|
||||
batchProgram = new Command();
|
||||
batchProgram.exitOverride();
|
||||
registerBatchSpawn(batchProgram);
|
||||
});
|
||||
|
||||
it("refuses to batch-spawn when no AO daemon is running", async () => {
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
batchProgram.parseAsync(["node", "test", "batch-spawn", "INT-1", "INT-2"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(errors).toContain("AO is not running");
|
||||
expect(errors).toContain("ao start");
|
||||
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to batch-spawn when the running daemon is not polling the project", async () => {
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 99999,
|
||||
port: 3000,
|
||||
startedAt: "",
|
||||
projects: ["other-project"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
batchProgram.parseAsync(["node", "test", "batch-spawn", "INT-1", "INT-2"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(errors).toContain("not polling project");
|
||||
expect(errors).toContain("my-app");
|
||||
expect(errors).toContain("ao start my-app");
|
||||
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ vi.mock("node:process", async (importOriginal) => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { Command } from "commander";
|
||||
import { registerStart, registerStop, createConfigOnly } from "../../src/commands/start.js";
|
||||
import { registerStart, registerStop, autoCreateConfig } from "../../src/commands/start.js";
|
||||
|
||||
let tmpDir: string;
|
||||
let program: Command;
|
||||
|
|
@ -2133,7 +2133,7 @@ describe("start command — autoCreateConfig", () => {
|
|||
const callerContext = await import("../../src/lib/caller-context.js");
|
||||
vi.spyOn(callerContext, "isHumanCaller").mockReturnValue(false);
|
||||
|
||||
await createConfigOnly();
|
||||
await autoCreateConfig(tmpDir);
|
||||
|
||||
const configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
expect(existsSync(configPath)).toBe(true);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-cli",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "CLI for agent-orchestrator — the `ao` command",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ check_config_dirs() {
|
|||
local config_path data_dir worktree_dir
|
||||
config_path="$(find_config || true)"
|
||||
if [ -z "$config_path" ]; then
|
||||
warn "No agent-orchestrator config was found. Fix: run ao init --auto in a target repo"
|
||||
warn "No agent-orchestrator config was found. Fix: run ao start in a target repo"
|
||||
return
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
|
||||
export function registerInit(program: Command): void {
|
||||
program
|
||||
.command("init")
|
||||
.description("[deprecated] Use 'ao start' instead — it auto-creates config on first run")
|
||||
.action(async () => {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
"⚠ 'ao init' is deprecated. Use 'ao start' instead.\n" +
|
||||
" 'ao start' auto-detects your project and creates the config.\n",
|
||||
),
|
||||
);
|
||||
const { createConfigOnly } = await import("./start.js");
|
||||
await createConfigOnly();
|
||||
});
|
||||
}
|
||||
|
|
@ -102,25 +102,25 @@ interface SpawnClaimOptions {
|
|||
/**
|
||||
* Lifecycle polling runs in-process inside the long-lived `ao start` process.
|
||||
* `ao spawn` is a one-shot CLI — it can't start polling in its own process
|
||||
* (the interval would keep the CLI alive forever and duplicate work). Warn
|
||||
* when no `ao start` is running, or when the running instance isn't covering
|
||||
* this project (e.g. `ao start A` then `ao spawn` in B).
|
||||
* (the interval would keep the CLI alive forever and duplicate work).
|
||||
*
|
||||
* Refuse to spawn if no `ao start` is running, or if the running instance is
|
||||
* not polling this project. Without an active daemon, sessions get worktrees
|
||||
* and tmux panes but no lifecycle reactions (CI-failure routing, review
|
||||
* comments, revive transitions, event log). That silent blackout is a
|
||||
* worse failure mode than creating no session at all — so fail fast with
|
||||
* an actionable error.
|
||||
*/
|
||||
async function warnIfAONotRunning(projectId: string): Promise<void> {
|
||||
async function ensureAOPollingProject(projectId: string): Promise<void> {
|
||||
const running = await getRunning();
|
||||
if (!running) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
"⚠ AO is not running — lifecycle polling is inactive. Run `ao start` so the new session is tracked.",
|
||||
),
|
||||
throw new Error(
|
||||
`AO is not running — lifecycle polling is inactive. Run \`ao start\` before spawning sessions so they get CI/review routing and state advancement.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!running.projects.includes(projectId)) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠ The running AO instance (pid ${running.pid}) is not polling project "${projectId}" yet. Lifecycle polling will attach within ~60s.`,
|
||||
),
|
||||
throw new Error(
|
||||
`The running AO instance (pid ${running.pid}) is not polling project "${projectId}". Run \`ao start ${projectId}\` before spawning so sessions get tracked.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -248,17 +248,6 @@ async function spawnSession(
|
|||
);
|
||||
console.log(` View: ${chalk.dim(projectSessionUrl(port, projectId, session.id))}`);
|
||||
|
||||
// Warn if prompt delivery failed (for post-launch agents like Claude Code)
|
||||
const promptDelivered = session.metadata?.promptDelivered;
|
||||
if (promptDelivered === "false") {
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
` ⚠ Prompt delivery failed — agent may be idle.\n` +
|
||||
` Use '${chalk.cyan("ao send " + session.id + ' "message..."')}' to send instructions manually.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Open terminal tab if requested
|
||||
if (openTab) {
|
||||
try {
|
||||
|
|
@ -336,7 +325,7 @@ export function registerSpawn(program: Command): void {
|
|||
|
||||
try {
|
||||
await runSpawnPreflight(config, projectId, claimOptions);
|
||||
await warnIfAONotRunning(projectId);
|
||||
await ensureAOPollingProject(projectId);
|
||||
|
||||
await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt);
|
||||
} catch (err) {
|
||||
|
|
@ -413,7 +402,7 @@ export function registerBatchSpawn(program: Command): void {
|
|||
// Pre-flight once per project group so a missing prerequisite fails fast.
|
||||
try {
|
||||
await runSpawnPreflight(config, groupProjectId);
|
||||
await warnIfAONotRunning(groupProjectId);
|
||||
await ensureAOPollingProject(groupProjectId);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@ async function cloneRepo(parsed: ParsedRepoUrl, targetDir: string, cwd: string):
|
|||
* Detects environment, project type, and generates config with smart defaults.
|
||||
* Returns the loaded config.
|
||||
*/
|
||||
async function autoCreateConfig(workingDir: string): Promise<OrchestratorConfig> {
|
||||
export async function autoCreateConfig(workingDir: string): Promise<OrchestratorConfig> {
|
||||
console.log(chalk.bold.cyan("\n Agent Orchestrator — First Run Setup\n"));
|
||||
console.log(chalk.dim(" Detecting project and generating config...\n"));
|
||||
|
||||
|
|
@ -760,14 +760,6 @@ async function addProjectToConfig(
|
|||
return projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create config without starting dashboard/orchestrator.
|
||||
* Used by deprecated `ao init` wrapper.
|
||||
*/
|
||||
export async function createConfigOnly(): Promise<void> {
|
||||
await autoCreateConfig(cwd());
|
||||
}
|
||||
|
||||
/**
|
||||
* Start dashboard server in the background.
|
||||
* Returns the child process handle for cleanup.
|
||||
|
|
@ -1419,6 +1411,14 @@ export function registerStart(program: Command): void {
|
|||
`\n✓ Added "${addedId}" — open the dashboard to start an orchestrator.\n`,
|
||||
),
|
||||
);
|
||||
const notifyResult = await attachToDaemon(running).notifyProjectChange();
|
||||
if (!notifyResult.ok) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` ⚠ ${notifyResult.reason}. Refresh the page if the project doesn't show up.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
openUrl(`http://localhost:${running.port}`);
|
||||
unlockStartup();
|
||||
process.exit(0);
|
||||
|
|
|
|||
|
|
@ -348,7 +348,7 @@ export function registerStatus(program: Command): void {
|
|||
try {
|
||||
config = loadConfig();
|
||||
} catch {
|
||||
console.log(chalk.yellow("No config found. Run `ao init` first."));
|
||||
console.log(chalk.yellow("No config found. Run `ao start` first."));
|
||||
console.log(chalk.dim("Falling back to session discovery...\n"));
|
||||
await showFallbackStatus();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ function resolveProject(
|
|||
|
||||
const ids = Object.keys(config.projects);
|
||||
if (ids.length === 0) {
|
||||
console.error(chalk.red("No projects configured. Run `ao init` first."));
|
||||
console.error(chalk.red("No projects configured. Run `ao start` first."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (ids.length > 1) {
|
||||
|
|
@ -91,7 +91,7 @@ export function registerVerify(program: Command): void {
|
|||
try {
|
||||
config = loadConfig();
|
||||
} catch {
|
||||
console.error(chalk.red("No config found. Run `ao init` first."));
|
||||
console.error(chalk.red("No config found. Run `ao start` first."));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ export const MAX_PORT_SCAN = 100;
|
|||
/**
|
||||
* Find the first available port starting from `start`, scanning upward.
|
||||
* Returns `null` if no free port is found within `maxScan` attempts.
|
||||
* Shared between `ao init` and `ao start <url>`.
|
||||
*/
|
||||
export async function findFreePort(start: number, maxScan = MAX_PORT_SCAN): Promise<number | null> {
|
||||
for (let port = start; port < start + maxScan; port++) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Command } from "commander";
|
||||
import { registerInit } from "./commands/init.js";
|
||||
import { registerStatus } from "./commands/status.js";
|
||||
import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js";
|
||||
import { registerSession } from "./commands/session.js";
|
||||
|
|
@ -29,7 +28,6 @@ export function createProgram(): Command {
|
|||
.description("Agent Orchestrator — manage parallel AI coding agents")
|
||||
.version(getCliVersion());
|
||||
|
||||
registerInit(program);
|
||||
registerStart(program);
|
||||
registerStop(program);
|
||||
registerStatus(program);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
# @aoagents/ao-core
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Wire activity events into `lifecycle-manager` failure paths so failures surface as activity entries for downstream consumers (#1620).
|
||||
- 40aeb78: Add optional per-project `env` block to `ProjectConfig` that forwards string-to-string env vars into worker session runtimes (e.g. pin `GH_TOKEN` per project). AO-internal vars (`AO_SESSION`, `AO_PROJECT_ID`, etc.) always take precedence.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Disable the tmux status bar in the runtime-tmux plugin and clean up dead code in core (#1711).
|
||||
- Disable tmux status bar at session creation (#1683). The change touched dead code; the actual user-visible fix landed in #1711.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dd07b6b: Adopt existing managed orchestrator worktrees instead of failing to create a fresh one. Previously, a leftover worktree from a prior run could block `spawnOrchestrator` with a "worktree already exists" error; spawn now detects and reuses the matching managed worktree. Also normalizes CRLF in `parseWorktreeList` (Windows), filters prunable/deleted entries in `findManagedWorkspace`, and applies `GIT_TIMEOUT` to all internal `git()` calls.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-core",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Core library — types, config, session manager, lifecycle manager, event bus",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -213,4 +213,176 @@ describe("recordActivityEvent", () => {
|
|||
expect((capturedSummary as string).length).toBe(500);
|
||||
expect(capturedSummary).toMatch(/\.\.\.$/);
|
||||
});
|
||||
|
||||
// ─── Token-shape redaction (sanitizeString) ───────────────────────────────
|
||||
// These tests cover the P1 finding from PR #1620 review: free-form strings
|
||||
// under non-sensitive keys (data.message, data.errorMessage) used to leak
|
||||
// bare tokens through to the FTS-indexed `data` column. sanitizeString now
|
||||
// redacts known token shapes anywhere in a string value.
|
||||
|
||||
function recordAndCaptureData(input: Record<string, unknown>): Record<string, unknown> {
|
||||
let capturedData: unknown;
|
||||
const captureDb = {
|
||||
prepare: (_sql: string) => ({
|
||||
run: (...args: unknown[]) => {
|
||||
capturedData = args[8]; // data is 9th param (index 8)
|
||||
},
|
||||
all: () => [],
|
||||
}),
|
||||
};
|
||||
vi.mocked(eventsDb.getDb).mockReturnValueOnce(captureDb as any);
|
||||
recordActivityEvent({
|
||||
source: "lifecycle",
|
||||
kind: "lifecycle.poll_failed",
|
||||
summary: "test",
|
||||
data: input,
|
||||
});
|
||||
return JSON.parse(capturedData as string);
|
||||
}
|
||||
|
||||
it("redacts Bearer tokens in string values (preserves prefix)", () => {
|
||||
const out = recordAndCaptureData({
|
||||
errorMessage: "401 from https://api.example.com Bearer eyJhbGciOiJIUzI1NiJ9.abc",
|
||||
});
|
||||
expect(out["errorMessage"]).not.toContain("eyJhbGciOiJIUzI1NiJ9");
|
||||
expect(out["errorMessage"]).toContain("Bearer [redacted]");
|
||||
});
|
||||
|
||||
it("redacts GitHub PATs (ghp_, gho_, github_pat_) in error messages", () => {
|
||||
const out = recordAndCaptureData({
|
||||
errorMessage: "git push failed: bad credentials ghp_abcdefghijklmnopqrstuvwxyz12345",
|
||||
message: "trying github_pat_11ABCDEFG0abcdefghijklmnopqrstuvwxyz1234567890",
|
||||
});
|
||||
expect(out["errorMessage"]).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz");
|
||||
expect(out["errorMessage"]).toContain("[redacted]");
|
||||
expect(out["message"]).not.toContain("github_pat_11");
|
||||
expect(out["message"]).toContain("[redacted]");
|
||||
});
|
||||
|
||||
it("redacts OpenAI / Anthropic sk- keys in free-form values", () => {
|
||||
const out = recordAndCaptureData({
|
||||
message: "stuck on sk-proj-abcdefghijklmnopqrstuvwx returns 429",
|
||||
errorMessage: "auth failed for sk-ant-api03-abcdefghijklmnopqrst-xyz",
|
||||
});
|
||||
expect(out["message"]).not.toContain("sk-proj-abcdefghijklmnopqrstuvwx");
|
||||
expect(out["errorMessage"]).not.toContain("sk-ant-api03");
|
||||
expect(out["message"]).toContain("[redacted]");
|
||||
expect(out["errorMessage"]).toContain("[redacted]");
|
||||
});
|
||||
|
||||
it("redacts Slack xox tokens", () => {
|
||||
const out = recordAndCaptureData({
|
||||
errorMessage: "webhook rejected with xoxb-1234567890-abcdefghij",
|
||||
});
|
||||
expect(out["errorMessage"]).not.toContain("xoxb-1234567890");
|
||||
expect(out["errorMessage"]).toContain("[redacted]");
|
||||
});
|
||||
|
||||
it("redacts AWS access key IDs (AKIA...)", () => {
|
||||
const out = recordAndCaptureData({
|
||||
errorMessage: "s3 upload failed for AKIAIOSFODNN7EXAMPLE",
|
||||
});
|
||||
expect(out["errorMessage"]).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
||||
expect(out["errorMessage"]).toContain("[redacted]");
|
||||
});
|
||||
|
||||
it("redacts JWTs (three base64url segments with eyJ prefix)", () => {
|
||||
// Build the JWT string at runtime so the literal pattern doesn't appear
|
||||
// in source (gitleaks pre-commit hook flags real-shaped JWT literals).
|
||||
const jwt = "ey" + "JTESTHEADERabcde" + "." + "TESTPAYLOADabcdef" + "." + "TESTSIGNATUREabc";
|
||||
const out = recordAndCaptureData({ message: `token=${jwt} expired` });
|
||||
expect(out["message"]).not.toContain("JTESTHEADERabcde");
|
||||
expect(out["message"]).toContain("[redacted]");
|
||||
});
|
||||
|
||||
it("redacts ENV-style assignments (ALL_CAPS_KEY=value with sensitive suffix)", () => {
|
||||
const out = recordAndCaptureData({
|
||||
message: "agent reported: OPENAI_API_KEY=sk-test-abcdefghijklmnopqr returns 429",
|
||||
errorMessage: "config: GITHUB_TOKEN=ghp_xyz_invalid + DATABASE_URL=postgres://x",
|
||||
});
|
||||
// The ENV assignment redacts to KEY=[redacted]; the inner sk-/ghp_ also
|
||||
// matches its own pattern. Either way the secret value is gone.
|
||||
expect(out["message"]).not.toContain("sk-test-abcdefghijklmnopqr");
|
||||
expect(out["errorMessage"]).not.toContain("ghp_xyz_invalid");
|
||||
expect(out["message"]).toContain("[redacted]");
|
||||
expect(out["errorMessage"]).toContain("[redacted]");
|
||||
});
|
||||
|
||||
it("preserves prose that mentions sensitive words but isn't token-shaped", () => {
|
||||
// Regression guard for the existing "preserves error messages that mention
|
||||
// sensitive words in values" behavior — Greptile's earlier finding noted
|
||||
// this is intentional. Pattern-redaction must not over-match plain prose.
|
||||
const out = recordAndCaptureData({
|
||||
reason: "token expired",
|
||||
message: "authorization header missing",
|
||||
detail: "the cookie was rejected",
|
||||
note: "user pressed cancel on password prompt",
|
||||
});
|
||||
expect(out["reason"]).toBe("token expired");
|
||||
expect(out["message"]).toBe("authorization header missing");
|
||||
expect(out["detail"]).toBe("the cookie was rejected");
|
||||
expect(out["note"]).toBe("user pressed cancel on password prompt");
|
||||
});
|
||||
|
||||
it("caps individual string values at 500 chars (matches sanitizeSummary cap)", () => {
|
||||
const out = recordAndCaptureData({
|
||||
stack: "x".repeat(600),
|
||||
});
|
||||
expect((out["stack"] as string).length).toBe(500);
|
||||
expect(out["stack"]).toMatch(/\.\.\.$/);
|
||||
});
|
||||
|
||||
it("redacts tokens nested in arrays and objects", () => {
|
||||
const out = recordAndCaptureData({
|
||||
attempts: [
|
||||
{ url: "https://api.x", error: "Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig failed" },
|
||||
{ url: "https://api.y", error: "ghp_abcdefghijklmnopqrstuvwxyz12345 invalid" },
|
||||
],
|
||||
});
|
||||
const attempts = out["attempts"] as Array<Record<string, string>>;
|
||||
expect(attempts[0]!["error"]).toContain("Bearer [redacted]");
|
||||
expect(attempts[0]!["error"]).not.toContain("eyJhbGciOiJIUzI1NiJ9");
|
||||
expect(attempts[1]!["error"]).toContain("[redacted]");
|
||||
expect(attempts[1]!["error"]).not.toContain("ghp_abc");
|
||||
});
|
||||
|
||||
it("REGRESSION: redactCredentialUrls handles pathological input in <100ms (was ReDoS)", () => {
|
||||
// Replaced the regex-based CREDENTIAL_URL_RE with a linear scan — no
|
||||
// backtracking possible. Kept as a regression guard in case of regression.
|
||||
const pathological = "http://".repeat(2000); // ~14KB, ~2000 prefix repetitions, no @
|
||||
const start = Date.now();
|
||||
const out = recordAndCaptureData({ errorMessage: pathological });
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(100);
|
||||
expect((out["errorMessage"] as string).length).toBeLessThanOrEqual(500);
|
||||
});
|
||||
|
||||
it("REGRESSION: redactCredentialUrls handles >200-char userinfo (P1 from PR #1620 review)", () => {
|
||||
// The previous CREDENTIAL_URL_RE had {1,200} which let userinfo >200 chars
|
||||
// pass through unredacted. The linear scan has no length limit.
|
||||
const longPass = "a".repeat(300);
|
||||
const input = `https://user:${longPass}@github.com/org/repo.git`;
|
||||
const out = recordAndCaptureData({ remoteUrl: input });
|
||||
const result = out["remoteUrl"] as string;
|
||||
expect(result).not.toContain(longPass);
|
||||
expect(result).toContain("[redacted]");
|
||||
});
|
||||
|
||||
it("redactCredentialUrls does not touch URLs without userinfo", () => {
|
||||
const input = "https://github.com/org/repo.git pushed successfully";
|
||||
const out = recordAndCaptureData({ message: input });
|
||||
expect(out["message"]).toBe(input);
|
||||
});
|
||||
|
||||
it("redactCredentialUrls handles multiple credential URLs in one string", () => {
|
||||
const input = "remote: https://token123@github.com/a.git origin: https://pass@github.com/b.git";
|
||||
const out = recordAndCaptureData({ message: input });
|
||||
const result = out["message"] as string;
|
||||
expect(result).not.toContain("token123");
|
||||
expect(result).not.toContain("pass");
|
||||
expect(result).toMatch(/\[redacted\]/g);
|
||||
// Should still contain the host parts
|
||||
expect(result).toContain("github.com/a.git");
|
||||
expect(result).toContain("github.com/b.git");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -447,6 +447,45 @@ describe("Config Schema Validation", () => {
|
|||
expect(validated.projects.proj1.sessionPrefix).toBe("test"); // "test" is 4 chars, used as-is
|
||||
});
|
||||
|
||||
it("accepts a string-to-string env map at the project level", () => {
|
||||
const config = {
|
||||
projects: {
|
||||
proj1: {
|
||||
path: "/repos/test",
|
||||
repo: "org/test",
|
||||
defaultBranch: "main",
|
||||
env: {
|
||||
GH_TOKEN: "ghp_xxx",
|
||||
CUSTOM_FLAG: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const validated = validateConfig(config);
|
||||
expect(validated.projects.proj1.env).toEqual({
|
||||
GH_TOKEN: "ghp_xxx",
|
||||
CUSTOM_FLAG: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-string values in project env map", () => {
|
||||
const config = {
|
||||
projects: {
|
||||
proj1: {
|
||||
path: "/repos/test",
|
||||
repo: "org/test",
|
||||
defaultBranch: "main",
|
||||
env: {
|
||||
GH_TOKEN: 123,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => validateConfig(config)).toThrow();
|
||||
});
|
||||
|
||||
it("accepts orchestratorModel in agentConfig", () => {
|
||||
const config = {
|
||||
projects: {
|
||||
|
|
|
|||
|
|
@ -168,4 +168,59 @@ describe("FTS5 integration (real SQLite)", () => {
|
|||
expect(recent.some((e) => e.summary === "new event")).toBe(true);
|
||||
expect(recent.every((e) => e.summary !== "old event")).toBe(true);
|
||||
});
|
||||
|
||||
// ─── Token-shape redaction regression (PR #1620 P1) ────────────────────────
|
||||
// The `data` column is FTS-indexed alongside `summary`. Before this fix, a
|
||||
// free-form value like `data.errorMessage = "Bearer eyJ..."` would land in
|
||||
// the events DB unredacted and become searchable. This test drives the full
|
||||
// pipeline: real recordActivityEvent → real SQLite write → FTS5 trigger →
|
||||
// both direct SELECT and FTS MATCH must report zero token leakage.
|
||||
|
||||
itIfAvailable("REGRESSION: token-shaped values in data don't survive in stored row or FTS index", () => {
|
||||
// Sentinels are built dynamically (string concatenation across the prefix
|
||||
// boundary) so the literal token shapes don't appear in source — gitleaks'
|
||||
// pre-commit hook would otherwise flag these as real leaked secrets.
|
||||
const sentinels = {
|
||||
bearer: "ey" + "JfakeTOKENxyz0abcdefghijklmnop.payload.sig",
|
||||
ghPat: "gh" + "p_REGRESSIONfakeABCDEFGHIJKLMN1234",
|
||||
openAi: "sk-" + "proj-REGRESSIONfakeXYZabc123456",
|
||||
slack: "xox" + "b-9999999999-REGRESSIONfakeABCDE",
|
||||
aws: "AKIA" + "REGRESSIONFAKE12", // AKIA + 16 alphanumeric per AWS spec
|
||||
};
|
||||
|
||||
recordActivityEvent({
|
||||
source: "lifecycle",
|
||||
kind: "lifecycle.poll_failed",
|
||||
summary: "poll cycle failed",
|
||||
data: {
|
||||
// Mirror the real leak vector: free-form text under non-sensitive keys.
|
||||
errorMessage: `git push failed: 401 Bearer ${sentinels.bearer}`,
|
||||
message: `agent reported OPENAI_API_KEY=${sentinels.openAi} returns 429`,
|
||||
nested: {
|
||||
headers: { url: `https://api.example.com with ${sentinels.ghPat}` },
|
||||
attempts: [
|
||||
`xoxb webhook rejected: ${sentinels.slack}`,
|
||||
`s3 PutObject failed for ${sentinels.aws}`,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 1. Direct row read — none of the sentinels survive in stored data.
|
||||
const rows = db
|
||||
.prepare("SELECT data FROM activity_events WHERE type = 'lifecycle.poll_failed'")
|
||||
.all() as Array<{ data: string }>;
|
||||
expect(rows).toHaveLength(1);
|
||||
const storedData = rows[0]!.data;
|
||||
for (const sentinel of Object.values(sentinels)) {
|
||||
expect(storedData).not.toContain(sentinel);
|
||||
}
|
||||
expect(storedData).toContain("[redacted]");
|
||||
|
||||
// 2. FTS MATCH — searching for any sentinel returns zero hits.
|
||||
for (const [name, sentinel] of Object.entries(sentinels)) {
|
||||
const matches = searchActivityEvents(sentinel);
|
||||
expect(matches, `FTS leak for ${name}: '${sentinel}' is searchable`).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { hasRecentCommits } from "../git-activity.js";
|
||||
|
||||
describe("hasRecentCommits", () => {
|
||||
let repoDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repoDir = mkdtempSync(join(tmpdir(), "ao-git-activity-"));
|
||||
execFileSync("git", ["init", "-q", "-b", "main"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (repoDir) rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns true when a commit exists within the default 60s window", async () => {
|
||||
await writeFile(join(repoDir, "a.txt"), "hello");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir });
|
||||
execFileSync("git", ["commit", "-q", "-m", "initial"], { cwd: repoDir });
|
||||
|
||||
expect(await hasRecentCommits(repoDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no commits have been made", async () => {
|
||||
expect(await hasRecentCommits(repoDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the path is not a git repo", async () => {
|
||||
const notARepo = mkdtempSync(join(tmpdir(), "ao-git-activity-notrepo-"));
|
||||
try {
|
||||
expect(await hasRecentCommits(notARepo)).toBe(false);
|
||||
} finally {
|
||||
rmSync(notARepo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects a custom window — excludes commits outside it, includes them inside it", async () => {
|
||||
await writeFile(join(repoDir, "a.txt"), "hello");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir });
|
||||
// Backdate the commit by ~2 minutes so a 30s window excludes it but a
|
||||
// 10-minute window includes it — this discriminates between "parameter
|
||||
// forwarded" and "parameter silently ignored / hardcoded".
|
||||
const twoMinAgo = new Date(Date.now() - 120_000).toISOString();
|
||||
execFileSync("git", ["commit", "-q", "-m", "two-min-ago"], {
|
||||
cwd: repoDir,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_DATE: twoMinAgo,
|
||||
GIT_COMMITTER_DATE: twoMinAgo,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await hasRecentCommits(repoDir, 30)).toBe(false);
|
||||
expect(await hasRecentCommits(repoDir, 600)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,962 @@
|
|||
/**
|
||||
* Tests for activity-event emits added to lifecycle-manager's silent
|
||||
* failure paths (scm.review_fetch_failed, scm.poll_pr_failed).
|
||||
*
|
||||
* Design choices:
|
||||
* - vi.mock("../activity-events.js") at module scope so the emit calls in
|
||||
* lifecycle-manager become inspectable via vi.mocked.
|
||||
* - Reuses createMockSCM/createMockNotifier from test-utils so the SCM/Notifier
|
||||
* surface stays in sync with the project's existing test patterns.
|
||||
* - One test per event for the happy "emit fires" path, plus one cross-cutting
|
||||
* test that proves the lifecycle check completes successfully even if
|
||||
* recordActivityEvent itself throws (B2 invariant).
|
||||
*
|
||||
* Notifier instrumentation is intentionally omitted — the notifier subsystem
|
||||
* is undergoing larger work and AE evidence there is not currently useful.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type * as ActivityEventsModule from "../activity-events.js";
|
||||
|
||||
vi.mock("../activity-events.js", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof ActivityEventsModule>();
|
||||
return {
|
||||
...original,
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
import { createLifecycleManager } from "../lifecycle-manager.js";
|
||||
import { writeMetadata } from "../metadata.js";
|
||||
import type {
|
||||
OpenCodeSessionManager,
|
||||
OrchestratorConfig,
|
||||
PRInfo,
|
||||
PluginRegistry,
|
||||
SessionMetadata,
|
||||
} from "../types.js";
|
||||
import {
|
||||
createMockNotifier,
|
||||
createMockPlugins,
|
||||
createMockRegistry,
|
||||
createMockSCM,
|
||||
createMockSessionManager,
|
||||
createTestEnvironment,
|
||||
makePR,
|
||||
makeSession,
|
||||
type MockPlugins,
|
||||
type TestEnvironment,
|
||||
} from "./test-utils.js";
|
||||
|
||||
let env: TestEnvironment;
|
||||
let plugins: MockPlugins;
|
||||
let mockSessionManager: OpenCodeSessionManager;
|
||||
let config: OrchestratorConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
env = createTestEnvironment();
|
||||
plugins = createMockPlugins();
|
||||
mockSessionManager = createMockSessionManager();
|
||||
config = env.config;
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
/** Helper: persist session metadata + register the session with the mock manager. */
|
||||
function persistSession(
|
||||
sessionId: string,
|
||||
session: ReturnType<typeof makeSession>,
|
||||
metaOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const persistedMetadata: Record<string, unknown> = {
|
||||
worktree: "/tmp",
|
||||
branch: session.branch ?? "main",
|
||||
status: session.status,
|
||||
project: "my-app",
|
||||
runtimeHandle: session.runtimeHandle ?? undefined,
|
||||
...metaOverrides,
|
||||
};
|
||||
const persistedStringMetadata = Object.fromEntries(
|
||||
Object.entries(persistedMetadata).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
|
||||
const enriched = {
|
||||
...session,
|
||||
metadata: { ...session.metadata, ...persistedStringMetadata },
|
||||
};
|
||||
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(enriched);
|
||||
vi.mocked(mockSessionManager.list).mockResolvedValue([enriched]);
|
||||
writeMetadata(env.sessionsDir, sessionId, persistedMetadata as unknown as SessionMetadata);
|
||||
return enriched;
|
||||
}
|
||||
|
||||
function buildLM(registry: PluginRegistry) {
|
||||
return createLifecycleManager({ config, registry, sessionManager: mockSessionManager });
|
||||
}
|
||||
|
||||
function makeMatchingPR(overrides: Partial<PRInfo> = {}): PRInfo {
|
||||
return makePR({ owner: "org", repo: "my-app", ...overrides });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scm.review_fetch_failed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("scm.review_fetch_failed", () => {
|
||||
it("records an AE event when scm.getReviewThreads throws during review backlog dispatch", async () => {
|
||||
const mockSCM = createMockSCM({
|
||||
getReviewThreads: vi.fn().mockRejectedValue(new Error("403 forbidden")),
|
||||
});
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makeMatchingPR() });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const reviewFailures = calls.filter((c) => c.kind === "scm.review_fetch_failed");
|
||||
expect(reviewFailures).toHaveLength(1);
|
||||
|
||||
const ev = reviewFailures[0]!;
|
||||
expect(ev.source).toBe("scm");
|
||||
expect(ev.level).toBe("warn");
|
||||
expect(ev.summary).toContain("review fetch failed for PR #42");
|
||||
expect(ev.projectId).toBe(session.projectId);
|
||||
expect(ev.sessionId).toBe(session.id);
|
||||
expect(ev.data).toMatchObject({
|
||||
prNumber: 42,
|
||||
prUrl: "https://github.com/org/my-app/pull/42",
|
||||
errorMessage: "403 forbidden",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scm.poll_pr_failed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("scm.poll_pr_failed", () => {
|
||||
it("records an AE event when scm.getPRState throws on the cache-miss fallback", async () => {
|
||||
// Force a cache miss by returning an empty enrichment map, then make
|
||||
// getPRState throw — exercises the inner try/catch at lifecycle-manager.ts:1053.
|
||||
const mockSCM = createMockSCM({
|
||||
enrichSessionsPRBatch: vi.fn().mockResolvedValue(new Map()),
|
||||
getPRState: vi.fn().mockRejectedValue(new Error("rate limited")),
|
||||
});
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makeMatchingPR() });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const pollFailures = calls.filter((c) => c.kind === "scm.poll_pr_failed");
|
||||
expect(pollFailures).toHaveLength(1);
|
||||
|
||||
const ev = pollFailures[0]!;
|
||||
expect(ev.source).toBe("scm");
|
||||
expect(ev.level).toBe("warn");
|
||||
expect(ev.summary).toContain("getPRState failed for PR #42");
|
||||
expect(ev.data).toMatchObject({
|
||||
prNumber: 42,
|
||||
prUrl: "https://github.com/org/my-app/pull/42",
|
||||
errorMessage: "rate limited",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scm.batch_enrich_failed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("scm.batch_enrich_failed", () => {
|
||||
it("records an AE event when scm.enrichSessionsPRBatch throws", async () => {
|
||||
const mockSCM = createMockSCM({
|
||||
enrichSessionsPRBatch: vi.fn().mockRejectedValue(new Error("rate limited")),
|
||||
});
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makeMatchingPR() });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const batchFailures = calls.filter((c) => c.kind === "scm.batch_enrich_failed");
|
||||
expect(batchFailures.length).toBeGreaterThan(0);
|
||||
|
||||
const ev = batchFailures[0]!;
|
||||
expect(ev.source).toBe("scm");
|
||||
expect(ev.level).toBe("warn");
|
||||
expect(ev.summary).toContain("batch_enrich failed");
|
||||
expect(ev.data).toMatchObject({
|
||||
plugin: "github",
|
||||
prCount: 1,
|
||||
errorMessage: "rate limited",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scm.detect_pr_succeeded / scm.detect_pr_failed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("scm.detect_pr", () => {
|
||||
it("emits scm.detect_pr_succeeded when scm.detectPR finds a PR for a previously-PR-less session", async () => {
|
||||
const detectedPR = makeMatchingPR({ number: 99, url: "https://github.com/org/my-app/pull/99" });
|
||||
const mockSCM = createMockSCM({
|
||||
detectPR: vi.fn().mockResolvedValue(detectedPR),
|
||||
});
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "working" });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "scm.detect_pr_succeeded");
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.data).toMatchObject({ prNumber: 99 });
|
||||
});
|
||||
|
||||
it("emits scm.detect_pr_failed when scm.detectPR throws", async () => {
|
||||
const mockSCM = createMockSCM({
|
||||
detectPR: vi.fn().mockRejectedValue(new Error("403 forbidden")),
|
||||
});
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "working" });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "scm.detect_pr_failed");
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.data).toMatchObject({ errorMessage: "403 forbidden" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runtime.probe_failed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("runtime.probe_failed", () => {
|
||||
it("records an AE event when runtime.isAlive throws", async () => {
|
||||
vi.mocked(plugins.runtime.isAlive).mockRejectedValue(new Error("kill -0 EPERM"));
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: createMockSCM(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "working" });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "runtime.probe_failed");
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0]!.source).toBe("runtime");
|
||||
expect(events[0]!.data).toMatchObject({ errorMessage: "kill -0 EPERM" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agent.activity_probe_failed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("agent.activity_probe_failed", () => {
|
||||
it("records an AE event when the activity probing block throws", async () => {
|
||||
vi.mocked(plugins.agent.getActivityState).mockRejectedValue(new Error("native probe died"));
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: createMockSCM(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "working" });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "agent.activity_probe_failed");
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0]!.source).toBe("agent");
|
||||
expect(events[0]!.data).toMatchObject({ errorMessage: "native probe died" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agent.process_probe_failed (standalone path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("agent.process_probe_failed", () => {
|
||||
it("records an AE event with where=standalone when isProcessRunning throws on the standalone probe", async () => {
|
||||
// Drive activity probe to return null + force standalone path:
|
||||
// - getActivityState resolves null → falls into terminal-output fallback
|
||||
// - getOutput returns empty → no terminal-fallback isProcessRunning call
|
||||
// - then standalone isProcessRunning at line ~1132 fires (processProbe still unknown)
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
|
||||
vi.mocked(plugins.runtime.getOutput).mockResolvedValue("");
|
||||
vi.mocked(plugins.agent.isProcessRunning).mockRejectedValue(new Error("ps lookup failed"));
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: createMockSCM(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "working" });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "agent.process_probe_failed");
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0]!.source).toBe("agent");
|
||||
expect(events[0]!.data).toMatchObject({
|
||||
where: "standalone",
|
||||
errorMessage: "ps lookup failed",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reaction.escalated / reaction.send_to_agent_failed / reaction.action_succeeded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build an SCM mock whose batch enrichment drives the session to ci_failed. */
|
||||
function makeCiFailedScm(overrides: Partial<Parameters<typeof createMockSCM>[0]> = {}) {
|
||||
return createMockSCM({
|
||||
enrichSessionsPRBatch: vi.fn().mockImplementation(async (prs: PRInfo[]) => {
|
||||
const result = new Map();
|
||||
for (const pr of prs) {
|
||||
result.set(`${pr.owner}/${pr.repo}#${pr.number}`, {
|
||||
state: "open",
|
||||
ciStatus: "failing",
|
||||
reviewDecision: "none",
|
||||
mergeable: false,
|
||||
ciChecks: [{ name: "test", status: "failed" }],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("reaction.action_succeeded", () => {
|
||||
it("emits AE on successful send-to-agent reaction", async () => {
|
||||
config.reactions = {
|
||||
"ci-failed": { auto: true, action: "send-to-agent", message: "fix CI", retries: 5 },
|
||||
};
|
||||
vi.mocked(mockSessionManager.send).mockResolvedValue(undefined);
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: makeCiFailedScm(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makeMatchingPR() });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "reaction.action_succeeded");
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
const ev = events[0]!;
|
||||
expect(ev.source).toBe("reaction");
|
||||
expect(ev.data).toMatchObject({
|
||||
reactionKey: "ci-failed",
|
||||
action: "send-to-agent",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reaction.send_to_agent_failed", () => {
|
||||
it("emits AE when sessionManager.send throws inside a send-to-agent reaction", async () => {
|
||||
config.reactions = {
|
||||
"ci-failed": { auto: true, action: "send-to-agent", message: "fix CI", retries: 5 },
|
||||
};
|
||||
vi.mocked(mockSessionManager.send).mockRejectedValue(new Error("agent unreachable"));
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: makeCiFailedScm(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makeMatchingPR() });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "reaction.send_to_agent_failed");
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.data).toMatchObject({
|
||||
reactionKey: "ci-failed",
|
||||
errorMessage: "agent unreachable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reaction.escalated", () => {
|
||||
it("emits AE with escalationCause=max_retries when attempts exceed retries", async () => {
|
||||
// retries: 0 → first attempt escalates (attempts=1 > 0)
|
||||
config.reactions = {
|
||||
"ci-failed": { auto: true, action: "send-to-agent", message: "fix CI", retries: 0, priority: "urgent" },
|
||||
};
|
||||
vi.mocked(mockSessionManager.send).mockResolvedValue(undefined);
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: makeCiFailedScm(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makeMatchingPR() });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "reaction.escalated");
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.data).toMatchObject({
|
||||
reactionKey: "ci-failed",
|
||||
attempts: 1,
|
||||
escalationCause: "max_retries",
|
||||
});
|
||||
});
|
||||
|
||||
it("REGRESSION: escalationCause=max_attempts when numeric escalateAfter triggers (no retries cfg)", async () => {
|
||||
// Numeric escalateAfter is an attempt-count gate, not a duration. Without
|
||||
// `retries`, maxRetries defaults to Infinity, so the max_retries branch
|
||||
// never fires. The cause must reflect the actual triggering check
|
||||
// (numeric threshold), not be misattributed to "max_duration" — there
|
||||
// was no time-based check involved at all.
|
||||
config.reactions = {
|
||||
"ci-failed": {
|
||||
auto: true,
|
||||
action: "send-to-agent",
|
||||
message: "fix CI",
|
||||
escalateAfter: 0, // first attempt: 1 > 0 → escalates immediately
|
||||
priority: "urgent",
|
||||
},
|
||||
};
|
||||
vi.mocked(mockSessionManager.send).mockResolvedValue(undefined);
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: makeCiFailedScm(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makeMatchingPR() });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const events = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.filter((c) => c.kind === "reaction.escalated");
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.data).toMatchObject({
|
||||
reactionKey: "ci-failed",
|
||||
attempts: 1,
|
||||
escalationCause: "max_attempts",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// session.auto_cleanup_{deferred,completed,failed}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** SCM mock whose batch enrichment drives session into MERGED status. */
|
||||
function makeMergedScm() {
|
||||
return createMockSCM({
|
||||
getPRState: vi.fn().mockResolvedValue("merged"),
|
||||
enrichSessionsPRBatch: vi.fn().mockImplementation(async (prs: PRInfo[]) => {
|
||||
const result = new Map();
|
||||
for (const pr of prs) {
|
||||
result.set(`${pr.owner}/${pr.repo}#${pr.number}`, {
|
||||
state: "merged",
|
||||
ciStatus: "none",
|
||||
reviewDecision: "none",
|
||||
mergeable: false,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function configWithAutoCleanup(): OrchestratorConfig {
|
||||
return {
|
||||
...config,
|
||||
lifecycle: { autoCleanupOnMerge: true, mergeCleanupIdleGraceMs: 300_000 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("session.auto_cleanup_completed", () => {
|
||||
it("emits AE when sessionManager.kill succeeds after PR merges", async () => {
|
||||
vi.mocked(mockSessionManager.kill).mockResolvedValue({
|
||||
cleaned: true,
|
||||
alreadyTerminated: false,
|
||||
});
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: makeMergedScm(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({
|
||||
status: "approved",
|
||||
pr: makeMatchingPR(),
|
||||
activity: "idle",
|
||||
});
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config: configWithAutoCleanup(),
|
||||
registry,
|
||||
sessionManager: mockSessionManager,
|
||||
});
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "session.auto_cleanup_completed");
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.source).toBe("lifecycle");
|
||||
expect(events[0]!.data).toMatchObject({ cleaned: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.auto_cleanup_failed", () => {
|
||||
it("emits AE when sessionManager.kill throws after PR merges", async () => {
|
||||
vi.mocked(mockSessionManager.kill).mockRejectedValue(new Error("worktree busy"));
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: makeMergedScm(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({
|
||||
status: "approved",
|
||||
pr: makeMatchingPR(),
|
||||
activity: "idle",
|
||||
});
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config: configWithAutoCleanup(),
|
||||
registry,
|
||||
sessionManager: mockSessionManager,
|
||||
});
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "session.auto_cleanup_failed");
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.level).toBe("error");
|
||||
expect(events[0]!.data).toMatchObject({ errorMessage: "worktree busy" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// lifecycle.poll_failed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("lifecycle.poll_failed", () => {
|
||||
it("emits AE when sessionManager.list throws inside pollAll", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(mockSessionManager.list).mockRejectedValue(new Error("storage unreadable"));
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: createMockSCM(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
// pollAll only emits per-project poll metrics when scopedProjectId is set.
|
||||
// Force it via the deps.
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry,
|
||||
sessionManager: mockSessionManager,
|
||||
projectId: "my-app",
|
||||
});
|
||||
|
||||
try {
|
||||
lm.start(60_000);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "lifecycle.poll_failed");
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0]!.level).toBe("error");
|
||||
expect(events[0]!.data).toMatchObject({ errorMessage: "storage unreadable" });
|
||||
} finally {
|
||||
lm.stop();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("REGRESSION: summary must not interpolate raw error text (credential leak via FTS)", async () => {
|
||||
// sanitizeSummary only truncates; sanitizeData (which runs on `data`)
|
||||
// redacts credential URLs. Because activity_events_fts indexes summary,
|
||||
// any credential interpolated into summary becomes searchable from the DB.
|
||||
// Lifecycle code must keep summary generic and put error reasons in `data`.
|
||||
vi.useFakeTimers();
|
||||
const credentialUrl = "https://x-oauth-basic:SECRET_TOKEN_xyz123@github.com/foo/bar.git";
|
||||
vi.mocked(mockSessionManager.list).mockRejectedValue(
|
||||
new Error(`fatal: unable to access '${credentialUrl}/'`),
|
||||
);
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: createMockSCM(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry,
|
||||
sessionManager: mockSessionManager,
|
||||
projectId: "my-app",
|
||||
});
|
||||
|
||||
try {
|
||||
lm.start(60_000);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
const events = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.filter((c) => c.kind === "lifecycle.poll_failed");
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
// Summary stays generic — no secret material.
|
||||
expect(events[0]!.summary).not.toContain("SECRET_TOKEN");
|
||||
expect(events[0]!.summary).not.toContain("x-oauth-basic");
|
||||
|
||||
// Error reason still surfaces — but only via `data`, where it's sanitized.
|
||||
expect(events[0]!.data).toMatchObject({
|
||||
errorMessage: expect.stringContaining("unable to access"),
|
||||
});
|
||||
} finally {
|
||||
lm.stop();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detecting.escalated
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("detecting.escalated", () => {
|
||||
it("emits AE exactly once when detecting transitions to stuck via attempts limit", async () => {
|
||||
// Drive determineStatus to commit STUCK by failing every probe path,
|
||||
// and pre-load detectingAttempts to MAX so the next attempt escalates.
|
||||
vi.mocked(plugins.runtime.isAlive).mockRejectedValue(new Error("probe down"));
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
|
||||
vi.mocked(plugins.runtime.getOutput).mockResolvedValue("");
|
||||
vi.mocked(plugins.agent.isProcessRunning).mockRejectedValue(new Error("ps down"));
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: createMockSCM(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "detecting" });
|
||||
persistSession("app-1", session, {
|
||||
detectingAttempts: "3", // DETECTING_MAX_ATTEMPTS — next attempt = 4 > 3 → escalates
|
||||
});
|
||||
|
||||
const lm = buildLM(registry);
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "detecting.escalated");
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.data).toMatchObject({ cause: "max_attempts" });
|
||||
|
||||
// Idempotency guard: a second poll while still stuck must NOT re-emit.
|
||||
// The first emit set detectingEscalatedAt in metadata; subsequent polls
|
||||
// see it non-empty and skip the emit.
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
await lm.check("app-1");
|
||||
const repeat = vi.mocked(recordActivityEvent).mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((c) => c.kind === "detecting.escalated");
|
||||
expect(repeat).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// report_watcher.triggered
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("report_watcher.triggered", () => {
|
||||
it("emits AE when the report watcher detects a no_acknowledge trigger", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-01T12:00:00.000Z"));
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: createMockSCM(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
// Session created 20 minutes ago (> default 10min ack timeout) and never reported.
|
||||
const staleSession = makeSession({
|
||||
id: "app-1",
|
||||
status: "working",
|
||||
workspacePath: null,
|
||||
createdAt: new Date("2025-01-01T11:40:00.000Z"),
|
||||
metadata: { createdAt: "2025-01-01T11:40:00.000Z" },
|
||||
});
|
||||
persistSession("app-1", staleSession, { createdAt: "2025-01-01T11:40:00.000Z" });
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry,
|
||||
sessionManager: mockSessionManager,
|
||||
});
|
||||
|
||||
try {
|
||||
await lm.check("app-1");
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const events = calls.filter((c) => c.kind === "report_watcher.triggered");
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0]!.source).toBe("report-watcher");
|
||||
expect(events[0]!.level).toBe("warn");
|
||||
expect(events[0]!.data).toMatchObject({ trigger: "no_acknowledge" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT re-emit while the same trigger persists across polls (one-shot guard)", async () => {
|
||||
// Regression: we observed 116 identical report_watcher.triggered events
|
||||
// landing in production over a few hours because the emit was unguarded
|
||||
// and fired every 30s poll while the trigger stayed active. The fix gates
|
||||
// the emit on `isNewTrigger` so it fires only on the first poll that
|
||||
// detects the trigger.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-01T12:00:00.000Z"));
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: createMockSCM(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const staleSession = makeSession({
|
||||
id: "app-1",
|
||||
status: "working",
|
||||
workspacePath: null,
|
||||
createdAt: new Date("2025-01-01T11:40:00.000Z"),
|
||||
metadata: { createdAt: "2025-01-01T11:40:00.000Z" },
|
||||
});
|
||||
persistSession("app-1", staleSession, { createdAt: "2025-01-01T11:40:00.000Z" });
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry,
|
||||
sessionManager: mockSessionManager,
|
||||
});
|
||||
|
||||
try {
|
||||
// First check — trigger fires fresh, AE event lands.
|
||||
await lm.check("app-1");
|
||||
const firstPass = vi.mocked(recordActivityEvent).mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((c) => c.kind === "report_watcher.triggered");
|
||||
expect(firstPass).toHaveLength(1);
|
||||
|
||||
// Second check — same trigger still active. Must NOT re-emit.
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
await lm.check("app-1");
|
||||
const secondPass = vi.mocked(recordActivityEvent).mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((c) => c.kind === "report_watcher.triggered");
|
||||
expect(secondPass).toHaveLength(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("REGRESSION: summary must not interpolate auditResult.message (report.note credential leak)", async () => {
|
||||
// `auditResult.message` for the agent_needs_input trigger embeds the
|
||||
// free-form `report.note` supplied via `ao report --note "..."`. Since
|
||||
// sanitizeSummary only truncates and FTS5 indexes summary, a note that
|
||||
// happens to contain a credential URL becomes persistently searchable
|
||||
// from the DB. Summary must stay generic; the message stays in `data`
|
||||
// where sanitizeData redacts credentials.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-01T12:00:00.000Z"));
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: createMockSCM(),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
// Seed a needs_input report whose note contains a credential URL.
|
||||
// Per agent-report.ts:560, readAgentReport pulls these out of metadata.
|
||||
const credentialNote = "stuck on git push https://x-oauth-basic:SECRET_NOTE_TOKEN@github.com/foo/bar.git";
|
||||
const blockedSession = makeSession({
|
||||
id: "app-1",
|
||||
status: "working",
|
||||
workspacePath: null,
|
||||
createdAt: new Date("2025-01-01T11:55:00.000Z"),
|
||||
metadata: { createdAt: "2025-01-01T11:55:00.000Z" },
|
||||
});
|
||||
persistSession("app-1", blockedSession, {
|
||||
createdAt: "2025-01-01T11:55:00.000Z",
|
||||
agentReportedState: "needs_input",
|
||||
agentReportedAt: "2025-01-01T11:58:00.000Z",
|
||||
agentReportedNote: credentialNote,
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry,
|
||||
sessionManager: mockSessionManager,
|
||||
});
|
||||
|
||||
try {
|
||||
await lm.check("app-1");
|
||||
|
||||
const events = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.filter((c) => c.kind === "report_watcher.triggered");
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0]!.data).toMatchObject({ trigger: "agent_needs_input" });
|
||||
|
||||
// Summary stays generic — no secret material from report.note.
|
||||
expect(events[0]!.summary).not.toContain("SECRET_NOTE_TOKEN");
|
||||
expect(events[0]!.summary).not.toContain("x-oauth-basic");
|
||||
|
||||
// The full message still surfaces via `data` where sanitizeData runs.
|
||||
expect(events[0]!.data).toMatchObject({
|
||||
message: expect.stringContaining("Agent needs input"),
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B2 invariant: emits never break the lifecycle flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("invariant: recordActivityEvent failures do not break the lifecycle flow", () => {
|
||||
it("lm.check completes successfully even if recordActivityEvent throws", async () => {
|
||||
vi.mocked(recordActivityEvent).mockImplementation(() => {
|
||||
throw new Error("AE went boom");
|
||||
});
|
||||
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: createMockSCM({
|
||||
getReviewThreads: vi.fn().mockRejectedValue(new Error("review fetch down")),
|
||||
}),
|
||||
notifier: createMockNotifier(),
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makeMatchingPR() });
|
||||
persistSession("app-1", session);
|
||||
|
||||
const lm = buildLM(registry);
|
||||
// Per gist + B2: lifecycle code MUST NOT depend on recordActivityEvent
|
||||
// success. If this test fails, a new emit was added without the wrapper
|
||||
// safety the real recordActivityEvent provides — find the unsafe call site
|
||||
// and wrap it (or rely on the real impl's internal try/catch).
|
||||
await expect(lm.check("app-1")).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -60,7 +60,7 @@ describe("buildPrompt split output", () => {
|
|||
expect(systemPrompt).not.toContain("## Additional Instructions");
|
||||
|
||||
expect(taskPrompt).toContain("Focus on the API layer only.");
|
||||
expect(taskPrompt).not.toContain("Work on issue: INT-1343");
|
||||
expect(taskPrompt).not.toContain("Work on issue #INT-1343");
|
||||
expect(taskPrompt).not.toContain("Layered Prompt System");
|
||||
});
|
||||
|
||||
|
|
@ -83,15 +83,16 @@ describe("buildPrompt", () => {
|
|||
expect(taskPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes base prompt when issue is provided", () => {
|
||||
it("includes base prompt when issue is provided without context", () => {
|
||||
const { systemPrompt, taskPrompt } = buildPrompt({
|
||||
project,
|
||||
projectId: "test-app",
|
||||
issueId: "INT-1343",
|
||||
});
|
||||
expect(systemPrompt).toContain(BASE_AGENT_PROMPT);
|
||||
expect(systemPrompt).toContain("Work on issue: INT-1343");
|
||||
expect(taskPrompt).toBe("Work on issue: INT-1343");
|
||||
expect(systemPrompt).toContain("Work on issue #INT-1343");
|
||||
expect(taskPrompt).toContain("Work on issue #INT-1343");
|
||||
expect(taskPrompt).toContain("Issue details were not pre-fetched");
|
||||
});
|
||||
|
||||
it("includes project context", () => {
|
||||
|
|
@ -115,18 +116,20 @@ describe("buildPrompt", () => {
|
|||
expect(systemPrompt).not.toContain("Repository:");
|
||||
});
|
||||
|
||||
it("includes issue ID in task section", () => {
|
||||
it("tells agent to fetch issue when context is missing", () => {
|
||||
const { systemPrompt, taskPrompt } = buildPrompt({
|
||||
project,
|
||||
projectId: "test-app",
|
||||
issueId: "INT-1343",
|
||||
});
|
||||
expect(systemPrompt).toContain("Work on issue: INT-1343");
|
||||
expect(systemPrompt).toContain("Work on issue #INT-1343");
|
||||
expect(systemPrompt).toContain("feat/INT-1343");
|
||||
expect(taskPrompt).toBe("Work on issue: INT-1343");
|
||||
expect(taskPrompt).toContain("Work on issue #INT-1343");
|
||||
expect(taskPrompt).toContain("Issue details were not pre-fetched");
|
||||
expect(taskPrompt).toContain("gh issue view INT-1343");
|
||||
});
|
||||
|
||||
it("includes issue context when provided", () => {
|
||||
it("tells agent details are pre-fetched when context is provided", () => {
|
||||
const { systemPrompt, taskPrompt } = buildPrompt({
|
||||
project,
|
||||
projectId: "test-app",
|
||||
|
|
@ -136,10 +139,23 @@ describe("buildPrompt", () => {
|
|||
expect(systemPrompt).toContain("## Issue Details");
|
||||
expect(systemPrompt).toContain("Layered Prompt System");
|
||||
expect(systemPrompt).toContain("Priority: High");
|
||||
expect(taskPrompt).toBe("Work on issue: INT-1343");
|
||||
expect(taskPrompt).toContain("Work on issue #INT-1343");
|
||||
expect(taskPrompt).toContain("start implementing without re-fetching the issue");
|
||||
expect(taskPrompt).not.toContain("Layered Prompt System");
|
||||
});
|
||||
|
||||
it("normalizes issue ID with leading # to avoid double-hash", () => {
|
||||
const { systemPrompt, taskPrompt } = buildPrompt({
|
||||
project,
|
||||
projectId: "test-app",
|
||||
issueId: "#42",
|
||||
});
|
||||
expect(systemPrompt).toContain("Work on issue #42");
|
||||
expect(systemPrompt).not.toContain("##42");
|
||||
expect(taskPrompt).toContain("Work on issue #42");
|
||||
expect(taskPrompt).not.toContain("##42");
|
||||
});
|
||||
|
||||
it("includes inline agentRules", () => {
|
||||
project.agentRules = "Always run pnpm test before pushing.";
|
||||
const { systemPrompt } = buildPrompt({
|
||||
|
|
|
|||
|
|
@ -103,6 +103,89 @@ describe("spawn", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("forwards project.env into spawned agent runtime env", async () => {
|
||||
const projectConfig = config.projects["my-app"];
|
||||
if (!projectConfig) throw new Error("test setup: my-app missing");
|
||||
const configWithEnv: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...projectConfig,
|
||||
env: {
|
||||
GH_TOKEN: "ghp_project_scoped",
|
||||
CUSTOM_VAR: "hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sm = createSessionManager({ config: configWithEnv, registry: mockRegistry });
|
||||
await sm.spawn({ projectId: "my-app" });
|
||||
|
||||
expect(mockRuntime.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
environment: expect.objectContaining({
|
||||
GH_TOKEN: "ghp_project_scoped",
|
||||
CUSTOM_VAR: "hello",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("AO_* internals override project.env values with the same key", async () => {
|
||||
const projectConfig = config.projects["my-app"];
|
||||
if (!projectConfig) throw new Error("test setup: my-app missing");
|
||||
const configWithEnv: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...projectConfig,
|
||||
env: {
|
||||
AO_SESSION: "should-not-win",
|
||||
AO_PROJECT_ID: "should-not-win",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sm = createSessionManager({ config: configWithEnv, registry: mockRegistry });
|
||||
await sm.spawn({ projectId: "my-app" });
|
||||
|
||||
const call = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
|
||||
expect(call?.environment?.AO_SESSION).not.toBe("should-not-win");
|
||||
expect(call?.environment?.AO_SESSION).toBe("app-1");
|
||||
expect(call?.environment?.AO_PROJECT_ID).toBe("my-app");
|
||||
});
|
||||
|
||||
it("PATH and GH_PATH override project.env values with the same key", async () => {
|
||||
const projectConfig = config.projects["my-app"];
|
||||
if (!projectConfig) throw new Error("test setup: my-app missing");
|
||||
const configWithEnv: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...projectConfig,
|
||||
env: {
|
||||
PATH: "/should/not/win",
|
||||
GH_PATH: "/should/not/win",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sm = createSessionManager({ config: configWithEnv, registry: mockRegistry });
|
||||
await sm.spawn({ projectId: "my-app" });
|
||||
|
||||
const call = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
|
||||
expect(call?.environment?.PATH).not.toBe("/should/not/win");
|
||||
expect(call?.environment?.PATH).toContain(".ao/bin");
|
||||
expect(call?.environment?.GH_PATH).not.toBe("/should/not/win");
|
||||
expect(call?.environment?.GH_PATH).toBe("/usr/local/bin/gh");
|
||||
});
|
||||
|
||||
it("uses issue ID to derive branch name", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
|
|
@ -1015,33 +1098,17 @@ describe("spawn", () => {
|
|||
expect(session.branch).not.toBe("main");
|
||||
});
|
||||
|
||||
it("sends prompt post-launch when agent.promptDelivery is 'post-launch'", async () => {
|
||||
vi.useFakeTimers();
|
||||
const postLaunchAgent = {
|
||||
...mockAgent,
|
||||
promptDelivery: "post-launch" as const,
|
||||
};
|
||||
const registryWithPostLaunch: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return postLaunchAgent;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
it("passes prompt inline to agent via getLaunchCommand", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
|
||||
|
||||
// Prompt should be passed to getLaunchCommand, not sent via runtime.sendMessage
|
||||
expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining("Fix the bug"),
|
||||
}),
|
||||
};
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithPostLaunch });
|
||||
const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await spawnPromise;
|
||||
|
||||
// Prompt should be sent via runtime.sendMessage, not included in launch command
|
||||
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.any(String) }),
|
||||
expect.stringContaining("Fix the bug"),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes worker system prompt to file and passes only explicit task prompt to agent", async () => {
|
||||
|
|
@ -1073,7 +1140,7 @@ describe("spawn", () => {
|
|||
expect(systemPrompt).toContain("Session Lifecycle");
|
||||
expect(systemPrompt).toContain("## Project Context");
|
||||
expect(systemPrompt).toContain("## Task");
|
||||
expect(systemPrompt).toContain("Work on issue: INT-1343");
|
||||
expect(systemPrompt).toContain("Work on issue #INT-1343");
|
||||
expect(systemPrompt).not.toContain("## Additional Instructions");
|
||||
});
|
||||
|
||||
|
|
@ -1144,149 +1211,61 @@ describe("spawn", () => {
|
|||
expect(opencodeConfig.instructions[0]).toContain("worker-prompt-app-1.md");
|
||||
|
||||
const systemPromptPath = opencodeConfig.instructions[0]!;
|
||||
expect(readFileSync(systemPromptPath, "utf-8")).toContain("Work on issue: INT-1343");
|
||||
expect(readFileSync(systemPromptPath, "utf-8")).toContain("Work on issue #INT-1343");
|
||||
expect(readFileSync(systemPromptPath, "utf-8")).not.toContain("## Additional Instructions");
|
||||
|
||||
const agentsMdPath = getWorkspaceAgentsMdPath(workspacePath);
|
||||
expect(existsSync(agentsMdPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not send prompt post-launch when agent.promptDelivery is not set", async () => {
|
||||
it("passes generated task prompt to agent for issue-only spawns", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
|
||||
await sm.spawn({ projectId: "my-app", issueId: "INT-1343" });
|
||||
|
||||
// Default agent (no promptDelivery) should NOT trigger sendMessage for prompt
|
||||
expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send a post-launch message when no task prompt is available", async () => {
|
||||
vi.useFakeTimers();
|
||||
const postLaunchAgent = {
|
||||
...mockAgent,
|
||||
promptDelivery: "post-launch" as const,
|
||||
};
|
||||
const registryWithPostLaunch: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return postLaunchAgent;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithPostLaunch });
|
||||
const spawnPromise = sm.spawn({ projectId: "my-app" });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
const session = await spawnPromise;
|
||||
|
||||
expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
|
||||
expect(session.metadata.promptDelivered).toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sends a minimal post-launch task for issue-only spawns", async () => {
|
||||
vi.useFakeTimers();
|
||||
const postLaunchAgent = {
|
||||
...mockAgent,
|
||||
promptDelivery: "post-launch" as const,
|
||||
};
|
||||
const registryWithPostLaunch: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return postLaunchAgent;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithPostLaunch });
|
||||
const spawnPromise = sm.spawn({ projectId: "my-app", issueId: "INT-1343" });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
const session = await spawnPromise;
|
||||
|
||||
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.any(String) }),
|
||||
"Work on issue: INT-1343",
|
||||
);
|
||||
expect(session.metadata.promptDelivered).toBe("true");
|
||||
|
||||
const callArgs = vi.mocked(postLaunchAgent.getLaunchCommand).mock.calls[0][0];
|
||||
expect(callArgs.prompt).toBe("Work on issue: INT-1343");
|
||||
const callArgs = vi.mocked(mockAgent.getLaunchCommand).mock.calls[0][0];
|
||||
expect(callArgs.prompt).toContain("Work on issue #INT-1343");
|
||||
expect(callArgs.systemPromptFile).toContain("worker-prompt-app-1.md");
|
||||
|
||||
const systemPrompt = readFileSync(callArgs.systemPromptFile!, "utf-8");
|
||||
expect(systemPrompt).toContain("## Task");
|
||||
expect(systemPrompt).toContain("Work on issue: INT-1343");
|
||||
vi.useRealTimers();
|
||||
expect(systemPrompt).toContain("Work on issue #INT-1343");
|
||||
});
|
||||
|
||||
it("does not destroy session when post-launch prompt delivery fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const failingRuntime: Runtime = {
|
||||
it("installs workspace hooks before launching the agent", async () => {
|
||||
const callOrder: string[] = [];
|
||||
const trackingAgent = {
|
||||
...mockAgent,
|
||||
setupWorkspaceHooks: vi.fn().mockImplementation(() => {
|
||||
callOrder.push("setupWorkspaceHooks");
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
const trackingRuntime: Runtime = {
|
||||
...mockRuntime,
|
||||
sendMessage: vi.fn().mockRejectedValue(new Error("tmux send failed")),
|
||||
create: vi.fn().mockImplementation((...args: Parameters<Runtime["create"]>) => {
|
||||
callOrder.push("runtime.create");
|
||||
return vi.mocked(mockRuntime.create)(...args);
|
||||
}),
|
||||
};
|
||||
const postLaunchAgent = {
|
||||
...mockAgent,
|
||||
promptDelivery: "post-launch" as const,
|
||||
};
|
||||
const registryWithFailingSend: PluginRegistry = {
|
||||
const trackingRegistry: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return failingRuntime;
|
||||
if (slot === "agent") return postLaunchAgent;
|
||||
if (slot === "runtime") return trackingRuntime;
|
||||
if (slot === "agent") return trackingAgent;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithFailingSend });
|
||||
const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
|
||||
// With retry logic (3 attempts at 3s, 6s, 9s delays before each attempt), need to advance 18s for all retries
|
||||
await vi.advanceTimersByTimeAsync(18_000);
|
||||
const session = await spawnPromise;
|
||||
const sm = createSessionManager({ config, registry: trackingRegistry });
|
||||
await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
|
||||
|
||||
// Session should still be returned successfully despite sendMessage failure
|
||||
expect(session.id).toBe("app-1");
|
||||
expect(session.status).toBe("spawning");
|
||||
// Runtime should NOT have been destroyed
|
||||
expect(failingRuntime.destroy).not.toHaveBeenCalled();
|
||||
// Verify promptDelivered is set to false in metadata
|
||||
expect(session.metadata.promptDelivered).toBe("false");
|
||||
vi.useRealTimers();
|
||||
}, 30_000);
|
||||
|
||||
it("waits before sending post-launch prompt", async () => {
|
||||
vi.useFakeTimers();
|
||||
const postLaunchAgent = {
|
||||
...mockAgent,
|
||||
promptDelivery: "post-launch" as const,
|
||||
};
|
||||
const registryWithPostLaunch: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return postLaunchAgent;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithPostLaunch });
|
||||
const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
|
||||
|
||||
// Advance only 2s — not enough, message should not have been sent yet
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
// Advance the remaining 1s — now the first attempt should fire (3s total = 3000 * 1)
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await spawnPromise;
|
||||
expect(mockRuntime.sendMessage).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
}, 20_000);
|
||||
const hooksIdx = callOrder.indexOf("setupWorkspaceHooks");
|
||||
const createIdx = callOrder.indexOf("runtime.create");
|
||||
expect(hooksIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(createIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(hooksIdx).toBeLessThan(createIdx);
|
||||
});
|
||||
|
||||
describe("rollback on failure", () => {
|
||||
it("cleans up reserved metadata when workspace creation fails", async () => {
|
||||
|
|
@ -1567,6 +1546,29 @@ describe("spawn", () => {
|
|||
expect(session.branch).toBe("orchestrator/app-orchestrator");
|
||||
});
|
||||
|
||||
it("adopts an existing managed orchestrator worktree instead of creating a fresh one", async () => {
|
||||
const adoptedPath = join(tmpDir, "legacy-orchestrator-ws");
|
||||
(mockWorkspace.findManagedWorkspace as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
path: adoptedPath,
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
sessionId: "app-orchestrator",
|
||||
projectId: "my-app",
|
||||
});
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const session = await sm.spawnOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(session.workspacePath).toBe(adoptedPath);
|
||||
expect(mockWorkspace.findManagedWorkspace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "my-app",
|
||||
sessionId: "app-orchestrator",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
}),
|
||||
);
|
||||
expect(mockWorkspace.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes metadata with proper fields", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
|
|
@ -1732,6 +1734,27 @@ describe("spawn", () => {
|
|||
expect(readMetadataRaw(sessionsDir, "app-orchestrator")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an adopted worktree in place when runtime creation fails", async () => {
|
||||
const adoptedPath = join(tmpDir, "adopted-orchestrator-ws");
|
||||
(mockWorkspace.findManagedWorkspace as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
path: adoptedPath,
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
sessionId: "app-orchestrator",
|
||||
projectId: "my-app",
|
||||
});
|
||||
(mockRuntime.create as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("runtime creation failed"),
|
||||
);
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow(
|
||||
"runtime creation failed",
|
||||
);
|
||||
|
||||
expect(mockWorkspace.destroy).not.toHaveBeenCalled();
|
||||
expect(readMetadataRaw(sessionsDir, "app-orchestrator")).toBeNull();
|
||||
});
|
||||
|
||||
it("destroys the worktree when post-launch setup fails", async () => {
|
||||
const worktreePath = join(tmpDir, "orchestrator-ws-postlaunch-fail");
|
||||
(mockWorkspace.create as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ export function createMockPlugins(): MockPlugins {
|
|||
}),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
findManagedWorkspace: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
return { runtime, agent, workspace };
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
isTmuxAvailable,
|
||||
listSessions,
|
||||
hasSession,
|
||||
newSession,
|
||||
sendKeys,
|
||||
capturePane,
|
||||
killSession,
|
||||
|
|
@ -118,65 +117,6 @@ describe("hasSession", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("newSession", () => {
|
||||
it("creates a basic session", async () => {
|
||||
mockTmuxSuccess("");
|
||||
|
||||
await newSession({ name: "test-1", cwd: "/tmp/workspace" });
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["new-session", "-d", "-s", "test-1", "-c", "/tmp/workspace"],
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes environment variables", async () => {
|
||||
mockTmuxSuccess("");
|
||||
|
||||
await newSession({
|
||||
name: "test-2",
|
||||
cwd: "/tmp",
|
||||
environment: { AO_SESSION: "test-2", SOME_VAR: "value" },
|
||||
});
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain("-e");
|
||||
expect(args).toContain("AO_SESSION=test-2");
|
||||
expect(args).toContain("SOME_VAR=value");
|
||||
});
|
||||
|
||||
it("includes window size", async () => {
|
||||
mockTmuxSuccess("");
|
||||
|
||||
await newSession({ name: "test-3", cwd: "/tmp", width: 200, height: 50 });
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain("-x");
|
||||
expect(args).toContain("200");
|
||||
expect(args).toContain("-y");
|
||||
expect(args).toContain("50");
|
||||
});
|
||||
|
||||
it("sends initial command after creation", async () => {
|
||||
// Calls: new-session, send-keys Escape, send-keys text, send-keys Enter
|
||||
mockTmuxSequence([{ stdout: "" }, { stdout: "" }, { stdout: "" }, { stdout: "" }]);
|
||||
|
||||
await newSession({ name: "test-4", cwd: "/tmp", command: "echo hello" });
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(4);
|
||||
// Call 0: new-session
|
||||
// Call 1: send-keys Escape (clear partial input)
|
||||
const escapeArgs = mockExecFile.mock.calls[1][1] as string[];
|
||||
expect(escapeArgs).toEqual(["send-keys", "-t", "test-4", "Escape"]);
|
||||
// Call 2: send-keys text
|
||||
const textArgs = mockExecFile.mock.calls[2][1] as string[];
|
||||
expect(textArgs).toContain("send-keys");
|
||||
expect(textArgs).toContain("echo hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendKeys", () => {
|
||||
it("sends short text with send-keys", async () => {
|
||||
// Calls: send-keys Escape, send-keys text, send-keys Enter
|
||||
|
|
|
|||
|
|
@ -12,7 +12,16 @@
|
|||
import { getDb } from "./events-db.js";
|
||||
|
||||
// Distinct names to avoid collision with types.ts EventType / EventSource.
|
||||
export type ActivityEventSource = "lifecycle" | "session-manager" | "api" | "ui";
|
||||
export type ActivityEventSource =
|
||||
| "lifecycle"
|
||||
| "session-manager"
|
||||
| "api"
|
||||
| "ui"
|
||||
| "scm"
|
||||
| "runtime"
|
||||
| "agent"
|
||||
| "reaction"
|
||||
| "report-watcher";
|
||||
|
||||
export type ActivityEventKind =
|
||||
| "session.spawn_started"
|
||||
|
|
@ -22,7 +31,28 @@ export type ActivityEventKind =
|
|||
| "activity.transition"
|
||||
| "lifecycle.transition"
|
||||
| "ci.failing"
|
||||
| "review.pending";
|
||||
| "review.pending"
|
||||
// Lifecycle-manager plugin-call failures
|
||||
| "scm.batch_enrich_failed"
|
||||
| "scm.detect_pr_succeeded"
|
||||
| "scm.detect_pr_failed"
|
||||
| "scm.review_fetch_failed"
|
||||
| "scm.poll_pr_failed"
|
||||
| "runtime.probe_failed"
|
||||
| "agent.process_probe_failed"
|
||||
| "agent.activity_probe_failed"
|
||||
// Reaction lifecycle
|
||||
| "reaction.escalated"
|
||||
| "reaction.send_to_agent_failed"
|
||||
| "reaction.action_succeeded"
|
||||
// Auto-cleanup + poll cycle
|
||||
| "session.auto_cleanup_deferred"
|
||||
| "session.auto_cleanup_completed"
|
||||
| "session.auto_cleanup_failed"
|
||||
| "lifecycle.poll_failed"
|
||||
| "detecting.escalated"
|
||||
// Report watcher
|
||||
| "report_watcher.triggered";
|
||||
|
||||
export type ActivityEventLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
|
|
@ -74,12 +104,101 @@ function pruneOldEvents(db: ReturnType<typeof getDb>, cutoff: number): void {
|
|||
|
||||
// Patterns that indicate sensitive field names
|
||||
const SENSITIVE_KEY_RE = /token|password|secret|authorization|cookie|api[-_]?key/i;
|
||||
// URL credentials: https://token@host or http://user:pass@host
|
||||
const CREDENTIAL_URL_RE = /https?:\/\/[^@\s]+@/gi;
|
||||
// URL credentials: https://token@host or http://user:pass@host.
|
||||
// Linear scan — find :// then scan forward for the next @ before a path
|
||||
// separator or whitespace. O(n) worst case, no regex backtracking, no length
|
||||
// limits. Replaces the previous CREDENTIAL_URL_RE which either ReDoS'd
|
||||
// (unbounded quantifier) or missed >200-char userinfo (bounded quantifier).
|
||||
function redactCredentialUrls(input: string): string {
|
||||
let result = input;
|
||||
let offset = 0;
|
||||
while (offset < result.length) {
|
||||
const proto = result.indexOf("://", offset);
|
||||
if (proto === -1) break;
|
||||
// Only match http:// or https:// (case-insensitive, matching old /gi flag)
|
||||
if (proto < 4) { offset = proto + 3; continue; }
|
||||
const schemeEnd = result.slice(Math.max(0, proto - 5), proto).toLowerCase();
|
||||
if (!schemeEnd.endsWith("http") && !schemeEnd.endsWith("https")) {
|
||||
offset = proto + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
let cursor = proto + 3;
|
||||
while (cursor < result.length) {
|
||||
const ch = result.charCodeAt(cursor);
|
||||
// Space/control chars or '/' mean no '@' is coming in userinfo
|
||||
if (ch <= 0x20 || ch === 0x2F) break;
|
||||
if (ch === 0x40) {
|
||||
// '@' found — redact everything between :// and @
|
||||
// Lowercase the scheme to match the old /gi regex behavior
|
||||
const before = result.slice(0, proto + 3).toLowerCase();
|
||||
const suffix = result.slice(cursor);
|
||||
result = before + "[redacted]" + suffix;
|
||||
offset = proto + 3 + "[redacted]".length + 1;
|
||||
break;
|
||||
}
|
||||
cursor++;
|
||||
}
|
||||
// No '@' found — not a credential URL, move past this ://
|
||||
if (cursor >= result.length || result.charCodeAt(cursor) <= 0x20 || result.charCodeAt(cursor) === 0x2F) {
|
||||
offset = proto + 3;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Per-string-value cap. The whole-data 16 KB cap still applies on top of this;
|
||||
// truncating individual strings limits blast radius if a pattern below misses a
|
||||
// new token format and a long error message gets pasted in.
|
||||
const STRING_VALUE_MAX_CHARS = 500;
|
||||
|
||||
// Token-shape patterns matched against ANY string value, not just keys.
|
||||
// Order: more-specific first. Replacement strings preserve the prefix where
|
||||
// the prefix itself is informative (e.g. "Bearer [redacted]" so RCA can still
|
||||
// see this was a bearer-auth failure).
|
||||
//
|
||||
// SENSITIVE_KEY_RE above redacts entire values under sensitive *key* names;
|
||||
// these patterns redact token-shaped *substrings* anywhere — including under
|
||||
// keys like `message` and `errorMessage`, which are the leak vector flagged
|
||||
// in PR #1620 review (data column is FTS5-indexed in events-db.ts).
|
||||
const TOKEN_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
// Bearer auth headers (also catches JWTs prefixed with Bearer)
|
||||
[/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi, "Bearer [redacted]"],
|
||||
// GitHub Personal Access Tokens — classic (ghp_/gho_/ghu_/ghs_/ghr_)
|
||||
[/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "[redacted]"],
|
||||
// GitHub fine-grained PATs
|
||||
[/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "[redacted]"],
|
||||
// OpenAI / Anthropic sk- keys (incl. sk-proj-, sk-svcacct-, sk-ant-)
|
||||
[/\bsk-(?:ant-)?(?:proj-|svcacct-)?[A-Za-z0-9_-]{16,}\b/g, "[redacted]"],
|
||||
// Slack tokens (xoxb-, xoxp-, xoxa-, xoxr-, xoxs-)
|
||||
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, "[redacted]"],
|
||||
// AWS access key IDs (16 trailing chars exactly per AWS spec)
|
||||
[/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]"],
|
||||
// JWTs — three base64url segments, eyJ prefix on header
|
||||
[/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]"],
|
||||
// ENV-style assignments: MY_API_TOKEN=value, GITHUB_SECRET=..., etc.
|
||||
// Scoped to ALL_CAPS keys containing a sensitive word so prose like
|
||||
// "the message=hello" doesn't redact.
|
||||
[
|
||||
/\b([A-Z][A-Z0-9_]*(?:TOKEN|PASSWORD|SECRET|AUTHORIZATION|COOKIE|API_KEY|APIKEY)[A-Z0-9_]*)=([^\s"'`]{6,})/g,
|
||||
"$1=[redacted]",
|
||||
],
|
||||
];
|
||||
|
||||
function sanitizeString(value: string): string {
|
||||
let cleaned = redactCredentialUrls(value);
|
||||
for (const [pattern, replacement] of TOKEN_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern, replacement);
|
||||
}
|
||||
if (cleaned.length > STRING_VALUE_MAX_CHARS) {
|
||||
cleaned = `${cleaned.slice(0, STRING_VALUE_MAX_CHARS - 3)}...`;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function sanitizeValue(value: unknown, seen: WeakSet<object>): unknown {
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (typeof value === "string") return value.replace(CREDENTIAL_URL_RE, "https://[redacted]@");
|
||||
if (typeof value === "string") return sanitizeString(value);
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
|
||||
if (seen.has(value)) return "[circular]";
|
||||
|
|
|
|||
|
|
@ -249,6 +249,7 @@ const ProjectConfigSchema = z.object({
|
|||
runtime: z.string().optional(),
|
||||
agent: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
tracker: TrackerConfigSchema.optional(),
|
||||
scm: SCMConfigSchema.optional(),
|
||||
symlinks: z.array(z.string()).optional(),
|
||||
|
|
@ -995,7 +996,7 @@ export function validateConfig(raw: unknown): OrchestratorConfig {
|
|||
return config;
|
||||
}
|
||||
|
||||
/** Get the default config (useful for `ao init`) */
|
||||
/** Get the default config (useful for first-run setup) */
|
||||
export function getDefaultConfig(): OrchestratorConfig {
|
||||
return validateConfig({
|
||||
projects: {},
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export interface GhTraceContext {
|
|||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface GhTraceResult {
|
||||
interface GhTraceResult {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Git-commit-based activity detection helpers for agent plugins.
|
||||
*
|
||||
* Agents without native JSONL introspection (Aider, Cursor, etc.) can use
|
||||
* recent git commits as a signal that the agent has been actively working.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Check whether the given workspace has any git commits within the last
|
||||
* `windowSeconds` seconds. Swallows errors (e.g. not a git repo, git missing)
|
||||
* and returns `false` so callers can use this as a best-effort liveness signal.
|
||||
*
|
||||
* @param workspacePath Absolute path to the workspace (must be a git repo).
|
||||
* @param windowSeconds How far back to look for commits. Defaults to 60s.
|
||||
*/
|
||||
export async function hasRecentCommits(
|
||||
workspacePath: string,
|
||||
windowSeconds = 60,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["log", `--since=${windowSeconds} seconds ago`, "--format=%H"],
|
||||
{ cwd: workspacePath, timeout: 5_000 },
|
||||
);
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -110,7 +110,6 @@ export {
|
|||
isTmuxAvailable,
|
||||
listSessions as listTmuxSessions,
|
||||
hasSession as hasTmuxSession,
|
||||
newSession as newTmuxSession,
|
||||
sendKeys as tmuxSendKeys,
|
||||
capturePane as tmuxCapturePane,
|
||||
killSession as killTmuxSession,
|
||||
|
|
@ -204,6 +203,9 @@ export {
|
|||
buildAgentPath,
|
||||
PREFERRED_GH_PATH,
|
||||
} from "./agent-workspace-hooks.js";
|
||||
|
||||
// Git-based activity helpers — recent-commit liveness signal for agent plugins
|
||||
export { hasRecentCommits } from "./git-activity.js";
|
||||
export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -627,6 +627,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
level: "warn",
|
||||
data: { plugin: pluginKey, prCount: pluginPRs.length },
|
||||
});
|
||||
recordActivityEvent({
|
||||
// Tag with scopedProjectId when the lifecycle worker is project-scoped
|
||||
// so `ao events list --project <id>` surfaces this failure. Unscoped
|
||||
// (multi-project) supervisors leave projectId null because the batch
|
||||
// crosses project boundaries — RCA there should query without --project.
|
||||
projectId: scopedProjectId,
|
||||
source: "scm",
|
||||
kind: "scm.batch_enrich_failed",
|
||||
level: "warn",
|
||||
summary: `batch_enrich failed for ${pluginPRs.length} PR(s)`,
|
||||
data: {
|
||||
plugin: pluginKey,
|
||||
prCount: pluginPRs.length,
|
||||
errorMessage: errorMsg,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -660,8 +676,23 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
session.pr = detectedPR;
|
||||
const sessionsDir = getProjectSessionsDir(session.projectId);
|
||||
updateMetadata(sessionsDir, session.id, { pr: detectedPR.url });
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "scm",
|
||||
kind: "scm.detect_pr_succeeded",
|
||||
summary: `PR #${detectedPR.number} detected`,
|
||||
data: {
|
||||
plugin: project.scm.plugin,
|
||||
prNumber: detectedPR.number,
|
||||
prUrl: detectedPR.url,
|
||||
prOwner: detectedPR.owner,
|
||||
prRepo: detectedPR.repo,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
observer?.recordOperation?.({
|
||||
metric: "lifecycle_poll",
|
||||
operation: "scm.detect_pr",
|
||||
|
|
@ -669,9 +700,21 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
correlationId: createCorrelationId("detect-pr"),
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
reason: errorMsg,
|
||||
level: "warn",
|
||||
});
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "scm",
|
||||
kind: "scm.detect_pr_failed",
|
||||
level: "warn",
|
||||
summary: `detect_pr failed for ${session.id}`,
|
||||
data: {
|
||||
plugin: project.scm.plugin,
|
||||
errorMessage: errorMsg,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -897,11 +940,23 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
lifecycle.runtime.reason =
|
||||
session.runtimeHandle.runtimeName === "tmux" ? "tmux_missing" : "process_missing";
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
lifecycle.runtime.state = "probe_failed";
|
||||
lifecycle.runtime.reason = "probe_error";
|
||||
lifecycle.runtime.lastObservedAt = nowIso;
|
||||
runtimeProbe = { state: "unknown", failed: true };
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "runtime",
|
||||
kind: "runtime.probe_failed",
|
||||
level: "warn",
|
||||
summary: `runtime.isAlive probe failed for ${session.id}`,
|
||||
data: {
|
||||
runtimeName: session.runtimeHandle.runtimeName,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1013,17 +1068,42 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
lifecycle.runtime.reason = "process_missing";
|
||||
lifecycle.runtime.lastObservedAt = nowIso;
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
processProbe = { state: "unknown", failed: true };
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "agent",
|
||||
kind: "agent.process_probe_failed",
|
||||
level: "warn",
|
||||
summary: `agent.isProcessRunning failed for ${session.id}`,
|
||||
data: {
|
||||
agentName,
|
||||
where: "fallback",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
activitySignal = createActivitySignal("null", { source: "native" });
|
||||
activityEvidence = formatActivitySignalEvidence(activitySignal);
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
activitySignal = createActivitySignal("probe_failure", { source: "native" });
|
||||
activityEvidence = formatActivitySignalEvidence(activitySignal);
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "agent",
|
||||
kind: "agent.activity_probe_failed",
|
||||
level: "warn",
|
||||
summary: `activity probing failed for ${session.id}`,
|
||||
data: {
|
||||
agentName,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (
|
||||
lifecycle.session.state === "stuck" ||
|
||||
lifecycle.session.state === "needs_input" ||
|
||||
|
|
@ -1061,8 +1141,21 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
lifecycle.runtime.reason = "process_missing";
|
||||
lifecycle.runtime.lastObservedAt = nowIso;
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
processProbe = { state: "unknown", failed: true };
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "agent",
|
||||
kind: "agent.process_probe_failed",
|
||||
level: "warn",
|
||||
summary: `agent.isProcessRunning failed for ${session.id}`,
|
||||
data: {
|
||||
agentName,
|
||||
where: "standalone",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1131,8 +1224,23 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — batch will retry next cycle
|
||||
} catch (err) {
|
||||
// Best-effort — batch will retry next cycle. Record AE evidence so
|
||||
// RCA can answer "why didn't AO transition to merged/closed in time?"
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "scm",
|
||||
kind: "scm.poll_pr_failed",
|
||||
level: "warn",
|
||||
summary: `getPRState failed for PR #${session.pr.number}`,
|
||||
data: {
|
||||
plugin: project.scm?.plugin,
|
||||
prNumber: session.pr.number,
|
||||
prUrl: session.pr.url,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
observer?.recordOperation?.({
|
||||
|
|
@ -1294,6 +1402,29 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
}
|
||||
|
||||
if (shouldEscalate) {
|
||||
// Mirror the trigger checks above so the cause matches the gate that
|
||||
// actually fired. Numeric escalateAfter is an attempt-count gate, not a
|
||||
// duration; without this distinction it gets misattributed to max_duration.
|
||||
const escalationCause: "max_retries" | "max_attempts" | "max_duration" =
|
||||
tracker.attempts > maxRetries
|
||||
? "max_retries"
|
||||
: typeof escalateAfter === "number" && tracker.attempts > escalateAfter
|
||||
? "max_attempts"
|
||||
: "max_duration";
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
sessionId,
|
||||
source: "reaction",
|
||||
kind: "reaction.escalated",
|
||||
level: "warn",
|
||||
summary: `reaction ${reactionKey} escalated after ${tracker.attempts} attempts`,
|
||||
data: {
|
||||
reactionKey,
|
||||
attempts: tracker.attempts,
|
||||
durationSinceFirstMs: Date.now() - tracker.firstTriggered.getTime(),
|
||||
escalationCause,
|
||||
},
|
||||
});
|
||||
// Escalate to human
|
||||
const context = buildEventContext(session, prEnrichmentCache);
|
||||
const event = createEvent("reaction.escalated", {
|
||||
|
|
@ -1324,7 +1455,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
if (reactionConfig.message) {
|
||||
try {
|
||||
await sessionManager.send(sessionId, reactionConfig.message);
|
||||
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
sessionId,
|
||||
source: "reaction",
|
||||
kind: "reaction.action_succeeded",
|
||||
summary: `send-to-agent ${reactionKey}`,
|
||||
data: { reactionKey, action: "send-to-agent", attempts: tracker.attempts },
|
||||
});
|
||||
return {
|
||||
reactionType: reactionKey,
|
||||
success: true,
|
||||
|
|
@ -1332,8 +1470,21 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
message: reactionConfig.message,
|
||||
escalated: false,
|
||||
};
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Send failed — allow retry on next poll cycle (don't escalate immediately)
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
sessionId,
|
||||
source: "reaction",
|
||||
kind: "reaction.send_to_agent_failed",
|
||||
level: "warn",
|
||||
summary: `send-to-agent failed for ${sessionId}`,
|
||||
data: {
|
||||
reactionKey,
|
||||
attempts: tracker.attempts,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
return {
|
||||
reactionType: reactionKey,
|
||||
success: false,
|
||||
|
|
@ -1354,6 +1505,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
data: { reactionKey, context, schemaVersion: 2 },
|
||||
});
|
||||
await notifyHuman(event, reactionConfig.priority ?? "info");
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
sessionId,
|
||||
source: "reaction",
|
||||
kind: "reaction.action_succeeded",
|
||||
summary: `notify ${reactionKey}`,
|
||||
data: { reactionKey, action: "notify", attempts: tracker.attempts },
|
||||
});
|
||||
return {
|
||||
reactionType: reactionKey,
|
||||
success: true,
|
||||
|
|
@ -1373,6 +1532,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
data: { reactionKey, context, schemaVersion: 2 },
|
||||
});
|
||||
await notifyHuman(event, "action");
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
sessionId,
|
||||
source: "reaction",
|
||||
kind: "reaction.action_succeeded",
|
||||
summary: `auto-merge ${reactionKey}`,
|
||||
data: { reactionKey, action: "auto-merge", attempts: tracker.attempts },
|
||||
});
|
||||
return {
|
||||
reactionType: reactionKey,
|
||||
success: true,
|
||||
|
|
@ -1498,8 +1665,23 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
// Fallback for SCM plugins that don't implement getReviewThreads yet
|
||||
allThreads = await scm.getPendingComments(session.pr);
|
||||
}
|
||||
} catch {
|
||||
// Failed to fetch — preserve existing metadata.
|
||||
} catch (err) {
|
||||
// Failed to fetch — preserve existing metadata; record AE evidence so
|
||||
// RCA can answer "why aren't review comments being dispatched?"
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "scm",
|
||||
kind: "scm.review_fetch_failed",
|
||||
level: "warn",
|
||||
summary: `review fetch failed for PR #${session.pr.number}`,
|
||||
data: {
|
||||
plugin: project.scm?.plugin,
|
||||
prNumber: session.pr.number,
|
||||
prUrl: session.pr.url,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
// Don't update the throttle timestamp so the next poll retries immediately
|
||||
// instead of being blocked for 2 minutes with the agent left on a bare notification.
|
||||
return;
|
||||
|
|
@ -2016,6 +2198,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
data: { activity, pendingSince, graceMs },
|
||||
level: "info",
|
||||
});
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "lifecycle",
|
||||
kind: "session.auto_cleanup_deferred",
|
||||
summary: `auto-cleanup deferred for ${session.id}`,
|
||||
data: {
|
||||
activity,
|
||||
// Elapsed wall-time since cleanup was first deferred. NOT a Unix
|
||||
// timestamp — naming it `pendingSinceMs` was misleading (Greptile).
|
||||
pendingElapsedMs: Number.isFinite(pendingSinceMs)
|
||||
? Date.now() - pendingSinceMs
|
||||
: null,
|
||||
graceMs,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2041,6 +2239,19 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
},
|
||||
level: "info",
|
||||
});
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "lifecycle",
|
||||
kind: "session.auto_cleanup_completed",
|
||||
summary: `auto-cleanup completed for ${session.id}`,
|
||||
data: {
|
||||
cleaned: result.cleaned,
|
||||
alreadyTerminated: result.alreadyTerminated,
|
||||
graceElapsed,
|
||||
activity,
|
||||
},
|
||||
});
|
||||
states.delete(session.id);
|
||||
} catch (err) {
|
||||
// Leave `merged` status in place so the next poll retries. Preserve the
|
||||
|
|
@ -2048,6 +2259,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
if (!session.metadata["mergedPendingCleanupSince"]) {
|
||||
updateSessionMetadata(session, { mergedPendingCleanupSince: nowIso });
|
||||
}
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
observer.recordOperation({
|
||||
metric: "lifecycle_poll",
|
||||
operation: "lifecycle.merge_cleanup.failed",
|
||||
|
|
@ -2055,9 +2267,18 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
correlationId,
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
reason: errorMsg,
|
||||
level: "warn",
|
||||
});
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "lifecycle",
|
||||
kind: "session.auto_cleanup_failed",
|
||||
level: "error",
|
||||
summary: `auto-cleanup failed for ${session.id}`,
|
||||
data: { errorMessage: errorMsg },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2090,6 +2311,27 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
? session.metadata["detectingEscalatedAt"] || new Date().toISOString()
|
||||
: "";
|
||||
|
||||
// Emit ONCE per escalation — guarded by detectingEscalatedAt being empty.
|
||||
// Subsequent polls while session stays stuck have detectingEscalatedAt set
|
||||
// and won't re-fire (per invariant: don't repeat escalation events).
|
||||
if (isDetectingEscalated && !session.metadata["detectingEscalatedAt"]) {
|
||||
const cause: "max_attempts" | "max_duration" =
|
||||
assessment.detectingAttempts > DETECTING_MAX_ATTEMPTS ? "max_attempts" : "max_duration";
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "lifecycle",
|
||||
kind: "detecting.escalated",
|
||||
level: "warn",
|
||||
summary: `detecting → stuck via ${cause}`,
|
||||
data: {
|
||||
attempts: assessment.detectingAttempts,
|
||||
cause,
|
||||
startedAt: nextDetectingStartedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const metadataUpdates: Record<string, string> = {};
|
||||
if (session.metadata["lifecycleEvidence"] !== nextLifecycleEvidence) {
|
||||
metadataUpdates["lifecycleEvidence"] = nextLifecycleEvidence;
|
||||
|
|
@ -2411,6 +2653,34 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
},
|
||||
level: "warn",
|
||||
});
|
||||
// Emit ONCE per trigger activation (matches the detecting.escalated guard
|
||||
// pattern). Without this guard the audit would fire every poll cycle while
|
||||
// a trigger stays active, producing hundreds of identical events. The
|
||||
// observer.recordOperation above is unguarded by design (it's a metric);
|
||||
// the activity-event trail is for actionable evidence, not heartbeat.
|
||||
if (isNewTrigger) {
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
source: "report-watcher",
|
||||
kind: "report_watcher.triggered",
|
||||
level: "warn",
|
||||
// Trigger is a bounded enum (no_acknowledge | stale_report |
|
||||
// agent_needs_input); auditResult.message includes free-form
|
||||
// report.note text from `ao report` and must not land in summary,
|
||||
// which is FTS-indexed and only truncated by sanitizeSummary.
|
||||
// Full message stays in `data.message` where sanitizeData redacts
|
||||
// credential URLs.
|
||||
summary: `${auditResult.trigger} triggered`,
|
||||
data: {
|
||||
trigger: auditResult.trigger,
|
||||
message: auditResult.message,
|
||||
timeSinceSpawnMs: auditResult.timeSinceSpawnMs,
|
||||
timeSinceReportMs: auditResult.timeSinceReportMs,
|
||||
reportState: auditResult.report?.state,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Execute reaction if configured
|
||||
if (isNewTrigger && reactionConfig && reactionConfig.auto !== false) {
|
||||
|
|
@ -2539,6 +2809,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
reason: errorReason,
|
||||
level: "error",
|
||||
});
|
||||
recordActivityEvent({
|
||||
projectId: scopedProjectId,
|
||||
source: "lifecycle",
|
||||
kind: "lifecycle.poll_failed",
|
||||
level: "error",
|
||||
// Keep summary generic — sanitizeSummary only truncates, but the FTS
|
||||
// index covers it. Error text (which can contain credential URLs from
|
||||
// git/gh subprocess output) is routed through `data` where sanitizeData
|
||||
// redacts credentials.
|
||||
summary: "poll cycle failed",
|
||||
data: {
|
||||
errorMessage: errorReason,
|
||||
durationMs: Date.now() - startedAt,
|
||||
projectScope: scopedProjectId ?? "all",
|
||||
},
|
||||
});
|
||||
observer.setHealth({
|
||||
surface: "lifecycle.worker",
|
||||
status: "error",
|
||||
|
|
|
|||
|
|
@ -116,10 +116,11 @@ function buildConfigLayer(config: PromptBuildConfig): string {
|
|||
}
|
||||
|
||||
if (issueId) {
|
||||
const normalizedId = issueId.replace(/^#/, "");
|
||||
lines.push(`\n## Task`);
|
||||
lines.push(`Work on issue: ${issueId}`);
|
||||
lines.push(`Work on issue #${normalizedId}`);
|
||||
lines.push(
|
||||
`Create a branch named so that it auto-links to the issue tracker (e.g. feat/${issueId}).`,
|
||||
`Create a branch named so that it auto-links to the issue tracker (e.g. feat/${normalizedId}).`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +204,9 @@ export function buildPrompt(
|
|||
taskPrompt: config.userPrompt
|
||||
? config.userPrompt
|
||||
: config.issueId
|
||||
? `Work on issue: ${config.issueId}`
|
||||
? config.issueContext
|
||||
? `Work on issue #${config.issueId.replace(/^#/, "")}. The issue title, description, and labels are already in your system prompt — start implementing without re-fetching the issue. Fetch comments or linked issues only if you need additional context.`
|
||||
: `Work on issue #${config.issueId.replace(/^#/, "")}. Issue details were not pre-fetched — start by reading the issue (e.g. \`gh issue view ${config.issueId.replace(/^#/, "")}\`), then implement.`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
type Runtime,
|
||||
type Agent,
|
||||
type Workspace,
|
||||
type WorkspaceCreateConfig,
|
||||
type Tracker,
|
||||
type SCM,
|
||||
type PluginRegistry,
|
||||
|
|
@ -1291,6 +1292,16 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
await plugins.agent.preLaunchSetup(workspacePath);
|
||||
}
|
||||
|
||||
// Install workspace hooks before launching the agent so that
|
||||
// PostToolUse hooks (e.g. Claude Code's metadata-updater) are
|
||||
// in place before the agent's first tool call.
|
||||
if (plugins.agent.setupWorkspaceHooks) {
|
||||
await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir });
|
||||
}
|
||||
if (plugins.agent.name !== "claude-code") {
|
||||
await setupPathWrapperWorkspace(workspacePath);
|
||||
}
|
||||
|
||||
const handle = await plugins.runtime.create({
|
||||
sessionId: tmuxName ?? sessionId, // Use tmux name for runtime if available
|
||||
workspacePath,
|
||||
|
|
@ -1298,6 +1309,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
environment: {
|
||||
...environment,
|
||||
...(opencodeConfigFile ? { OPENCODE_CONFIG: opencodeConfigFile } : {}),
|
||||
...(project.env ?? {}),
|
||||
PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
|
||||
GH_PATH: PREFERRED_GH_PATH,
|
||||
...(process.env["AO_AGENT_GH_TRACE"] && {
|
||||
|
|
@ -1408,53 +1420,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
invalidateCache();
|
||||
|
||||
// Past this point every resource that needed an undo is on disk in its
|
||||
// final form. Dismiss the stack so the prompt-delivery loop below (which
|
||||
// is intentionally non-fatal) cannot trigger a rollback.
|
||||
// final form. Dismiss the stack so nothing below can trigger a rollback.
|
||||
cleanupStack.dismiss();
|
||||
|
||||
// Send the task-specific prompt post-launch for agents that need it
|
||||
// (e.g. Claude Code exits after -p, so we send the prompt after it starts
|
||||
// in interactive mode). Prompt delivery failure must NOT destroy the
|
||||
// session — the agent is running; user can retry with `ao send`.
|
||||
let promptDelivered = false;
|
||||
if (plugins.agent.promptDelivery === "post-launch" && agentLaunchConfig.prompt) {
|
||||
const maxRetries = 3;
|
||||
const baseDelayMs = 3_000;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Wait for agent to start and be ready for input
|
||||
// Use exponential backoff: 3s, 6s, 9s between attempts
|
||||
await new Promise((resolve) => setTimeout(resolve, baseDelayMs * attempt));
|
||||
await plugins.runtime.sendMessage(handle, agentLaunchConfig.prompt);
|
||||
promptDelivered = true;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
console.error(
|
||||
`[session-manager] Prompt delivery attempt ${attempt}/${maxRetries} failed: ${lastError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!promptDelivered) {
|
||||
console.error(
|
||||
`[session-manager] FAILED to deliver prompt to session ${sessionId} after ${maxRetries} attempts. ` +
|
||||
`User must send manually with 'ao send'. Last error: ${lastError?.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
session.metadata["promptDelivered"] = String(promptDelivered);
|
||||
} else if (agentLaunchConfig.prompt) {
|
||||
session.metadata["promptDelivered"] = "true";
|
||||
}
|
||||
|
||||
if (session.metadata["promptDelivered"]) {
|
||||
updateMetadata(sessionsDir, sessionId, session.metadata);
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
// Prompt is delivered inline via the agent's launch command (positional argument).
|
||||
// No post-launch polling needed — the prompt is part of process invocation.
|
||||
recordActivityEvent({
|
||||
projectId: spawnConfig.projectId,
|
||||
sessionId,
|
||||
|
|
@ -1521,16 +1491,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
);
|
||||
}
|
||||
|
||||
const workspaceConfig = {
|
||||
projectId: orchestratorConfig.projectId,
|
||||
project,
|
||||
sessionId,
|
||||
branch,
|
||||
worktreeDir: getProjectWorktreesDir(orchestratorConfig.projectId),
|
||||
} satisfies WorkspaceCreateConfig;
|
||||
|
||||
let workspacePath: string;
|
||||
let adoptedManagedWorkspace = false;
|
||||
try {
|
||||
const wsInfo = await plugins.workspace.create({
|
||||
projectId: orchestratorConfig.projectId,
|
||||
project,
|
||||
sessionId,
|
||||
branch,
|
||||
worktreeDir: getProjectWorktreesDir(orchestratorConfig.projectId),
|
||||
});
|
||||
const adoptedInfo = await plugins.workspace.findManagedWorkspace?.(workspaceConfig);
|
||||
const wsInfo = adoptedInfo ?? (await plugins.workspace.create(workspaceConfig));
|
||||
workspacePath = wsInfo.path;
|
||||
adoptedManagedWorkspace = adoptedInfo !== undefined && adoptedInfo !== null;
|
||||
} catch (err) {
|
||||
try {
|
||||
deleteMetadata(sessionsDir, sessionId);
|
||||
|
|
@ -1543,11 +1518,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
// Helper: undo worktree + metadata if anything between workspace creation
|
||||
// and a fully-written metadata record fails.
|
||||
const cleanupWorktreeAndMetadata = async (promptFile?: string): Promise<void> => {
|
||||
try {
|
||||
// plugins.workspace is guaranteed non-null here: we threw above if it was null
|
||||
await plugins.workspace!.destroy(workspacePath);
|
||||
} catch {
|
||||
/* best effort */
|
||||
if (!adoptedManagedWorkspace) {
|
||||
try {
|
||||
// plugins.workspace is guaranteed non-null here: we threw above if it was null
|
||||
await plugins.workspace!.destroy(workspacePath);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
try {
|
||||
deleteMetadata(sessionsDir, sessionId);
|
||||
|
|
@ -1662,6 +1639,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
launchCommand,
|
||||
environment: {
|
||||
...environment,
|
||||
...(project.env ?? {}),
|
||||
PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
|
||||
GH_PATH: PREFERRED_GH_PATH,
|
||||
...(process.env["AO_AGENT_GH_TRACE"] && {
|
||||
|
|
@ -2927,6 +2905,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
environment: {
|
||||
...environment,
|
||||
...(opencodeConfigPath ? { OPENCODE_CONFIG: opencodeConfigPath } : {}),
|
||||
...(project.env ?? {}),
|
||||
PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
|
||||
GH_PATH: PREFERRED_GH_PATH,
|
||||
...(process.env["AO_AGENT_GH_TRACE"] && {
|
||||
|
|
|
|||
|
|
@ -75,47 +75,6 @@ export async function hasSession(sessionName: string): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
export interface NewSessionOptions {
|
||||
/** Session name */
|
||||
name: string;
|
||||
/** Working directory */
|
||||
cwd: string;
|
||||
/** Initial command to run */
|
||||
command?: string;
|
||||
/** Environment variables to set */
|
||||
environment?: Record<string, string>;
|
||||
/** Window width/height */
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** Create a new tmux session (detached). */
|
||||
export async function newSession(opts: NewSessionOptions): Promise<void> {
|
||||
const args = ["new-session", "-d", "-s", opts.name, "-c", opts.cwd];
|
||||
|
||||
// Add environment variables
|
||||
if (opts.environment) {
|
||||
for (const [key, value] of Object.entries(opts.environment)) {
|
||||
args.push("-e", `${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Window size
|
||||
if (opts.width) {
|
||||
args.push("-x", String(opts.width));
|
||||
}
|
||||
if (opts.height) {
|
||||
args.push("-y", String(opts.height));
|
||||
}
|
||||
|
||||
await tmux(...args);
|
||||
|
||||
// Send the initial command if provided
|
||||
if (opts.command) {
|
||||
await sendKeys(opts.name, opts.command);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send keys (text + Enter) to a tmux session.
|
||||
* For long/multiline messages, uses load-buffer + paste-buffer with
|
||||
|
|
|
|||
|
|
@ -464,15 +464,6 @@ export interface Agent {
|
|||
/** Process name to look for (e.g. "claude", "codex", "aider") */
|
||||
readonly processName: string;
|
||||
|
||||
/**
|
||||
* How the initial prompt should be delivered to the agent.
|
||||
* - "inline" (default): prompt is included in the launch command (e.g. -p flag)
|
||||
* - "post-launch": prompt is sent via runtime.sendMessage() after the agent starts,
|
||||
* keeping the agent in interactive mode. Use this for agents where inlining
|
||||
* the prompt causes one-shot/exit behavior (e.g. Claude Code's -p flag).
|
||||
*/
|
||||
readonly promptDelivery?: "inline" | "post-launch";
|
||||
|
||||
/** Get the shell command to launch this agent */
|
||||
getLaunchCommand(config: AgentLaunchConfig): string;
|
||||
|
||||
|
|
@ -522,7 +513,7 @@ export interface Agent {
|
|||
|
||||
/**
|
||||
* Optional: Set up agent-specific hooks/config in the workspace for automatic metadata updates.
|
||||
* Called once per workspace during ao init/start and when creating new worktrees.
|
||||
* Called once per workspace during ao start and when creating new worktrees.
|
||||
*
|
||||
* Each agent plugin implements this for their own config format:
|
||||
* - Claude Code: writes .claude/settings.json with PostToolUse hook
|
||||
|
|
@ -603,7 +594,7 @@ export interface AgentLaunchConfig {
|
|||
export interface WorkspaceHooksConfig {
|
||||
/** Data directory where session metadata files are stored */
|
||||
dataDir: string;
|
||||
/** Optional session ID (may not be known at ao init time) */
|
||||
/** Optional session ID (may not be known at workspace setup time) */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -645,6 +636,12 @@ export interface Workspace {
|
|||
/** List existing workspaces for a project */
|
||||
list(projectId: string): Promise<WorkspaceInfo[]>;
|
||||
|
||||
/**
|
||||
* Optional: find a pre-existing AO-managed workspace that already tracks the
|
||||
* requested branch and can be adopted instead of creating a fresh workspace.
|
||||
*/
|
||||
findManagedWorkspace?(config: WorkspaceCreateConfig): Promise<WorkspaceInfo | null>;
|
||||
|
||||
/** Optional: run hooks after workspace creation (symlinks, installs, etc.) */
|
||||
postCreate?(info: WorkspaceInfo, project: ProjectConfig): Promise<void>;
|
||||
|
||||
|
|
@ -1498,6 +1495,9 @@ export interface ProjectConfig {
|
|||
/** Override default workspace */
|
||||
workspace?: string;
|
||||
|
||||
/** Environment variables forwarded into worker session runtimes (AO_* internals always win) */
|
||||
env?: Record<string, string>;
|
||||
|
||||
/** Issue tracker configuration */
|
||||
tracker?: TrackerConfig;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,256 +0,0 @@
|
|||
/**
|
||||
* Integration test: Claude Code prompt delivery — `-p` (one-shot) vs interactive mode.
|
||||
*
|
||||
* Demonstrates the bug and the fix:
|
||||
*
|
||||
* 1. "claude -p exits after work" — launches Claude with -p, proves the process
|
||||
* exits after responding. This is the bug: the agent can't receive follow-ups.
|
||||
*
|
||||
* 2. "interactive claude stays alive" — launches Claude without -p, delivers
|
||||
* the prompt post-launch via runtime.sendMessage(), proves the process stays
|
||||
* alive and can receive follow-up messages.
|
||||
*
|
||||
* Requires: tmux, claude binary, ANTHROPIC_API_KEY.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import claudeCodePlugin from "@aoagents/ao-plugin-agent-claude-code";
|
||||
import runtimeTmuxPlugin from "@aoagents/ao-plugin-runtime-tmux";
|
||||
import {
|
||||
isTmuxAvailable,
|
||||
killSessionsByPrefix,
|
||||
createSession,
|
||||
killSession,
|
||||
capturePane,
|
||||
} from "./helpers/tmux.js";
|
||||
import { pollUntilEqual, sleep } from "./helpers/polling.js";
|
||||
import { makeTmuxHandle } from "./helpers/session-factory.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prerequisites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SESSION_PREFIX = "ao-inttest-prompt-";
|
||||
|
||||
/** Lines of scrollback to capture — generous to account for Claude's TUI output. */
|
||||
const CAPTURE_LINES = 500;
|
||||
|
||||
async function findClaudeBinary(): Promise<string | null> {
|
||||
try {
|
||||
await execFileAsync("which", ["claude"], { timeout: 5_000 });
|
||||
return "claude";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Claude Code's TUI to be ready for input.
|
||||
* Polls the tmux pane for Claude's prompt character (❯) on its own line.
|
||||
* Returns false if the OAuth/login screen is detected instead.
|
||||
*/
|
||||
async function waitForTuiReady(
|
||||
sessionName: string,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const output = await capturePane(sessionName, CAPTURE_LINES);
|
||||
|
||||
// Bail early if OAuth/login screen is showing — TUI won't become ready
|
||||
if (/sign in|oauth|Paste code here/i.test(output)) return false;
|
||||
|
||||
// Claude Code shows ❯ when idle. Check the last non-empty line specifically
|
||||
// (not the end of the full output) to avoid matching "prompted >" etc.
|
||||
const lastLine = output
|
||||
.split("\n")
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.pop();
|
||||
if (lastLine && /^\s*❯\s*$/.test(lastLine)) return true;
|
||||
|
||||
await sleep(2_000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const tmuxOk = await isTmuxAvailable();
|
||||
const claudeBin = await findClaudeBinary();
|
||||
const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY);
|
||||
const canRun = tmuxOk && claudeBin !== null && hasApiKey;
|
||||
// Interactive mode requires OAuth login. ANTHROPIC_API_KEY alone only works with -p.
|
||||
// CI environments (GitHub Actions) typically only have API keys, not OAuth sessions.
|
||||
const canRunInteractive = canRun && !process.env.CI;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: -p flag causes Claude to exit (the bug)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!canRun)("claude -p exits after completing work (the bug)", () => {
|
||||
const agent = claudeCodePlugin.create();
|
||||
const sessionName = `${SESSION_PREFIX}print-${Date.now()}`;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await killSessionsByPrefix(`${SESSION_PREFIX}print-`);
|
||||
const raw = await mkdtemp(join(tmpdir(), "ao-inttest-prompt-print-"));
|
||||
tmpDir = await realpath(raw);
|
||||
|
||||
// Create a bash session (keeps tmux pane alive after Claude exits)
|
||||
await createSession(sessionName, "bash", tmpDir);
|
||||
await sleep(500);
|
||||
|
||||
// Launch Claude with -p (print-and-exit mode)
|
||||
await execFileAsync(
|
||||
"tmux",
|
||||
[
|
||||
"send-keys",
|
||||
"-t",
|
||||
sessionName,
|
||||
"-l",
|
||||
"CLAUDECODE= claude --dangerously-skip-permissions -p 'Respond with exactly: SENTINEL_P_DELIVERED'",
|
||||
],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await execFileAsync("tmux", ["send-keys", "-t", sessionName, "Enter"], {
|
||||
timeout: 5_000,
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await killSession(sessionName);
|
||||
if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}, 30_000);
|
||||
|
||||
it("claude exits after responding to -p prompt", async () => {
|
||||
const handle = makeTmuxHandle(sessionName);
|
||||
|
||||
// Wait for Claude to start
|
||||
await pollUntilEqual(() => agent.isProcessRunning(handle), true, {
|
||||
timeoutMs: 30_000,
|
||||
intervalMs: 1_000,
|
||||
});
|
||||
|
||||
// Wait for it to exit — -p mode always exits after responding
|
||||
const exited = await pollUntilEqual(() => agent.isProcessRunning(handle), false, {
|
||||
timeoutMs: 90_000,
|
||||
intervalMs: 2_000,
|
||||
});
|
||||
expect(exited).toBe(false);
|
||||
|
||||
// Verify the work was done (Claude did respond before exiting)
|
||||
const output = await capturePane(sessionName, CAPTURE_LINES);
|
||||
expect(output).toContain("SENTINEL_P_DELIVERED");
|
||||
|
||||
// This is the problem: process is gone, ao send has nothing to talk to
|
||||
const running = await agent.isProcessRunning(handle);
|
||||
expect(running).toBe(false);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: interactive mode + post-launch prompt keeps Claude alive (the fix)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!canRunInteractive)(
|
||||
"interactive claude stays alive after post-launch prompt (the fix)",
|
||||
() => {
|
||||
const agent = claudeCodePlugin.create();
|
||||
const runtime = runtimeTmuxPlugin.create();
|
||||
const sessionName = `${SESSION_PREFIX}interactive-${Date.now()}`;
|
||||
let tmpDir: string;
|
||||
const handle = makeTmuxHandle(sessionName);
|
||||
|
||||
beforeAll(async () => {
|
||||
await killSessionsByPrefix(`${SESSION_PREFIX}interactive-`);
|
||||
const raw = await mkdtemp(join(tmpdir(), "ao-inttest-prompt-interactive-"));
|
||||
tmpDir = await realpath(raw);
|
||||
|
||||
// Create a bash session
|
||||
await createSession(sessionName, "bash", tmpDir);
|
||||
await sleep(500);
|
||||
|
||||
// Launch Claude WITHOUT -p (interactive mode) — this is the fix
|
||||
await execFileAsync(
|
||||
"tmux",
|
||||
[
|
||||
"send-keys",
|
||||
"-t",
|
||||
sessionName,
|
||||
"-l",
|
||||
"CLAUDECODE= claude --dangerously-skip-permissions",
|
||||
],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await execFileAsync("tmux", ["send-keys", "-t", sessionName, "Enter"], {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Wait for Claude to start
|
||||
await pollUntilEqual(() => agent.isProcessRunning(handle), true, {
|
||||
timeoutMs: 30_000,
|
||||
intervalMs: 1_000,
|
||||
});
|
||||
|
||||
// Wait for Claude's TUI to be fully ready (shows prompt character ❯)
|
||||
await waitForTuiReady(sessionName, 60_000);
|
||||
|
||||
// Deliver the initial prompt post-launch (what the fix does)
|
||||
await runtime.sendMessage(handle, "Respond with exactly: SENTINEL_I_DELIVERED");
|
||||
|
||||
// Wait for Claude to process the prompt and return to idle (❯)
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
const output = await capturePane(sessionName, CAPTURE_LINES);
|
||||
// Check that Claude responded (sentinel in output) AND is back at idle prompt (❯)
|
||||
const lastLine = output
|
||||
.split("\n")
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.pop();
|
||||
if (output.includes("SENTINEL_I_DELIVERED") && lastLine && /^\s*❯\s*$/.test(lastLine)) {
|
||||
break;
|
||||
}
|
||||
await sleep(2_000);
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await killSession(sessionName);
|
||||
if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}, 30_000);
|
||||
|
||||
it("prompt was delivered and processed", async () => {
|
||||
const output = await capturePane(sessionName, CAPTURE_LINES);
|
||||
expect(output).toContain("SENTINEL_I_DELIVERED");
|
||||
});
|
||||
|
||||
it("claude is still running after completing the task", async () => {
|
||||
const running = await agent.isProcessRunning(handle);
|
||||
expect(running).toBe(true);
|
||||
});
|
||||
|
||||
it("can receive and process a follow-up message", async () => {
|
||||
await runtime.sendMessage(handle, "Respond with exactly: SENTINEL_FOLLOWUP_OK");
|
||||
|
||||
const deadline = Date.now() + 90_000;
|
||||
let output = "";
|
||||
while (Date.now() < deadline) {
|
||||
output = await capturePane(sessionName, CAPTURE_LINES);
|
||||
if (output.includes("SENTINEL_FOLLOWUP_OK")) break;
|
||||
await sleep(2_000);
|
||||
}
|
||||
|
||||
expect(output).toContain("SENTINEL_FOLLOWUP_OK");
|
||||
|
||||
// Still alive after follow-up
|
||||
const stillRunning = await agent.isProcessRunning(handle);
|
||||
expect(stillRunning).toBe(true);
|
||||
}, 120_000);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-agent-aider
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-aider",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Agent plugin: Aider",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
recordTerminalActivity,
|
||||
hasRecentCommits,
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
type Agent,
|
||||
|
|
@ -29,22 +30,6 @@ const execFileAsync = promisify(execFile);
|
|||
// Aider Activity Detection Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if Aider has made recent commits (within last 60 seconds).
|
||||
*/
|
||||
async function hasRecentCommits(workspacePath: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["log", "--since=60 seconds ago", "--format=%H"],
|
||||
{ cwd: workspacePath, timeout: 5_000 },
|
||||
);
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get modification time of Aider chat history file.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-agent-claude-code
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-claude-code",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Agent plugin: Claude Code",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -199,7 +199,6 @@ describe("plugin manifest & exports", () => {
|
|||
const agent = create();
|
||||
expect(agent.name).toBe("claude-code");
|
||||
expect(agent.processName).toBe("claude");
|
||||
expect(agent.promptDelivery).toBe("post-launch");
|
||||
});
|
||||
|
||||
it("default export is a valid PluginModule", () => {
|
||||
|
|
@ -244,17 +243,22 @@ describe("getLaunchCommand", () => {
|
|||
expect(cmd).toContain("--model 'claude-opus-4-6'");
|
||||
});
|
||||
|
||||
it("does not include -p flag (prompt delivered post-launch)", () => {
|
||||
it("includes prompt as positional argument with -- separator (not -p flag)", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix the bug" }));
|
||||
expect(cmd).not.toContain("-p");
|
||||
expect(cmd).not.toContain("Fix the bug");
|
||||
expect(cmd).toContain("-- 'Fix the bug'");
|
||||
});
|
||||
|
||||
it("combines all options without prompt", () => {
|
||||
it("combines all options with prompt as positional arg after --", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ permissions: "permissionless", model: "opus", prompt: "Hello" }),
|
||||
);
|
||||
expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus'");
|
||||
expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus' -- 'Hello'");
|
||||
});
|
||||
|
||||
it("handles prompts starting with dashes safely via -- separator", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "--investigate this" }));
|
||||
expect(cmd).toContain("-- '--investigate this'");
|
||||
});
|
||||
|
||||
it("omits --dangerously-skip-permissions when permissions=default", () => {
|
||||
|
|
@ -268,25 +272,24 @@ describe("getLaunchCommand", () => {
|
|||
expect(cmd).not.toContain("-p");
|
||||
});
|
||||
|
||||
it("includes --append-system-prompt alongside omitted -p", () => {
|
||||
it("includes --append-system-prompt and prompt as positional arg after --", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPrompt: "You are a helper", prompt: "Do the task" }),
|
||||
);
|
||||
expect(cmd).toContain("--append-system-prompt");
|
||||
expect(cmd).toContain("You are a helper");
|
||||
// -p as a standalone flag (not substring of --append-system-prompt)
|
||||
expect(cmd).not.toMatch(/\s-p\s/);
|
||||
expect(cmd).not.toContain("Do the task");
|
||||
expect(cmd).toContain("-- 'Do the task'");
|
||||
});
|
||||
|
||||
it("uses systemPromptFile via shell substitution alongside omitted -p", () => {
|
||||
it("uses systemPromptFile via shell substitution with prompt after --", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md", prompt: "Do the task" }),
|
||||
);
|
||||
expect(cmd).toContain('--append-system-prompt "$(cat');
|
||||
expect(cmd).toContain("/tmp/prompt.md");
|
||||
expect(cmd).not.toMatch(/\s-p\s/);
|
||||
expect(cmd).not.toContain("Do the task");
|
||||
expect(cmd).toContain("-- 'Do the task'");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -870,14 +873,17 @@ describe("hook setup — relative path (symlink-safe)", () => {
|
|||
expect(hookCommand).not.toMatch(/^\//);
|
||||
});
|
||||
|
||||
it("postLaunchSetup writes a relative hook command (not absolute)", async () => {
|
||||
it("postLaunchSetup is a no-op (hooks installed pre-launch via setupWorkspaceHooks)", async () => {
|
||||
mockWriteFile.mockClear();
|
||||
await agent.postLaunchSetup!(
|
||||
makeSession({ workspacePath: "/Users/equinox/.worktrees/integrator/integrator-10" }),
|
||||
);
|
||||
|
||||
const hookCommand = getWrittenHookCommand();
|
||||
expect(hookCommand).toBe(".claude/metadata-updater.sh");
|
||||
expect(hookCommand).not.toMatch(/^\//);
|
||||
// No files should be written — hooks are installed before launch
|
||||
const settingsWrites = mockWriteFile.mock.calls.filter(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
|
||||
);
|
||||
expect(settingsWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("different worktree paths produce identical settings.json content", async () => {
|
||||
|
|
|
|||
|
|
@ -683,8 +683,6 @@ function createClaudeCodeAgent(): Agent {
|
|||
return {
|
||||
name: "claude-code",
|
||||
processName: "claude",
|
||||
promptDelivery: "post-launch",
|
||||
|
||||
getLaunchCommand(config: AgentLaunchConfig): string {
|
||||
// Note: CLAUDECODE is unset via getEnvironment() (set to ""), not here.
|
||||
// This command must be safe for both shell and execFile contexts.
|
||||
|
|
@ -708,9 +706,12 @@ function createClaudeCodeAgent(): Agent {
|
|||
parts.push("--append-system-prompt", shellEscape(config.systemPrompt));
|
||||
}
|
||||
|
||||
// NOTE: prompt is NOT included here — it's delivered post-launch via
|
||||
// runtime.sendMessage() to keep Claude in interactive mode.
|
||||
// Using -p causes one-shot mode (Claude exits after responding).
|
||||
// The positional [prompt] argument auto-submits as the first user turn
|
||||
// and keeps Claude in interactive mode. -p / --print is what triggers
|
||||
// headless one-shot exit, not the presence of a prompt.
|
||||
if (config.prompt) {
|
||||
parts.push("--", shellEscape(config.prompt));
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
},
|
||||
|
|
@ -876,10 +877,9 @@ function createClaudeCodeAgent(): Agent {
|
|||
await setupHookInWorkspace(workspacePath, ".claude/metadata-updater.sh");
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
|
||||
await setupHookInWorkspace(session.workspacePath, ".claude/metadata-updater.sh");
|
||||
async postLaunchSetup(_session: Session): Promise<void> {
|
||||
// Hooks are installed pre-launch via setupWorkspaceHooks so that
|
||||
// PostToolUse hooks exist before the agent's first tool call.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-agent-codex
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-codex",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Agent plugin: OpenAI Codex CLI",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest";
|
|||
import { readFileSync } from "node:fs";
|
||||
|
||||
describe("package manifest version", () => {
|
||||
it("is bumped to 0.4.0", () => {
|
||||
it("is bumped to 0.6.0", () => {
|
||||
const packageJsonUrl = new URL("../package.json", import.meta.url);
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonUrl, "utf8")) as { version?: string };
|
||||
|
||||
expect(packageJson.version).toBe("0.4.0");
|
||||
expect(packageJson.version).toBe("0.6.0");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-agent-cursor
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-cursor",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"description": "Agent plugin: Cursor CLI",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
recordTerminalActivity,
|
||||
hasRecentCommits,
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
type Agent,
|
||||
|
|
@ -30,22 +31,6 @@ const execFileAsync = promisify(execFile);
|
|||
// Cursor Activity Detection Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if Cursor has made recent commits (within last 60 seconds).
|
||||
*/
|
||||
async function hasRecentCommits(workspacePath: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["log", "--since=60 seconds ago", "--format=%H"],
|
||||
{ cwd: workspacePath, timeout: 5_000 },
|
||||
);
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get modification time of Cursor session file if it exists.
|
||||
* Cursor may create a .cursor directory with session data.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-agent-kimicode
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-kimicode",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"description": "Agent plugin: Kimi Code CLI (MoonshotAI)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-agent-opencode
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-opencode",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Agent plugin: OpenCode",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-notifier-composio
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-composio",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Notifier plugin: Composio unified notifications (Slack, Discord, email)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-notifier-desktop
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-desktop",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Notifier plugin: OS desktop notifications",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-notifier-discord
|
||||
|
||||
## 0.2.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.2.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-discord",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.9",
|
||||
"description": "Notifier plugin: Discord webhook notifications",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-notifier-openclaw
|
||||
|
||||
## 0.2.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.2.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-openclaw",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.9",
|
||||
"description": "Notifier plugin: OpenClaw webhook notifications",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-notifier-slack
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-slack",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Notifier plugin: Slack",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-notifier-webhook
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-webhook",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Notifier plugin: generic webhook",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-runtime-process
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-runtime-process",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Runtime plugin: child processes",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
# @aoagents/ao-plugin-runtime-tmux
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Disable the tmux status bar in the runtime-tmux plugin and clean up dead code in core (#1711).
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-runtime-tmux",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Runtime plugin: tmux sessions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ describe("runtime.create()", () => {
|
|||
it("calls new-session with correct args", async () => {
|
||||
const runtime = create();
|
||||
|
||||
// 1: new-session, 2: send-keys (launch command)
|
||||
// 1: new-session, 2: set-option status off, 3: send-keys (launch command)
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
|
||||
|
|
@ -106,9 +107,34 @@ describe("runtime.create()", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("disables the tmux status bar immediately after new-session", async () => {
|
||||
const runtime = create();
|
||||
|
||||
// 1: new-session, 2: set-option status off, 3: send-keys
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
|
||||
await runtime.create({
|
||||
sessionId: "status-bar-off",
|
||||
workspacePath: "/tmp/ws",
|
||||
launchCommand: "echo hi",
|
||||
environment: {},
|
||||
});
|
||||
|
||||
// Second call must be set-option ... status off, scoped to the session
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tmux",
|
||||
["set-option", "-t", "status-bar-off", "status", "off"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes -e KEY=VALUE flags for environment variables", async () => {
|
||||
const runtime = create();
|
||||
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
|
||||
|
|
@ -130,6 +156,7 @@ describe("runtime.create()", () => {
|
|||
it("sends launch command via send-keys", async () => {
|
||||
const runtime = create();
|
||||
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
|
||||
|
|
@ -140,7 +167,7 @@ describe("runtime.create()", () => {
|
|||
environment: {},
|
||||
});
|
||||
|
||||
// Second call: send-keys with the launch command
|
||||
// Third call: send-keys with the launch command (after new-session and set-option)
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["send-keys", "-t", "launch-test", "claude --session abc", "Enter"],
|
||||
|
|
@ -152,6 +179,8 @@ describe("runtime.create()", () => {
|
|||
const runtime = create();
|
||||
const longCommand = "x".repeat(250);
|
||||
|
||||
// 1: new-session, 2: set-option, 3: send-keys -l, 4: send-keys Enter
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
|
|
@ -170,7 +199,7 @@ describe("runtime.create()", () => {
|
|||
);
|
||||
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
"tmux",
|
||||
[
|
||||
"send-keys",
|
||||
|
|
@ -183,7 +212,7 @@ describe("runtime.create()", () => {
|
|||
);
|
||||
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
4,
|
||||
"tmux",
|
||||
["send-keys", "-t", "launch-long", "Enter"],
|
||||
expectedTmuxOptions,
|
||||
|
|
@ -195,9 +224,11 @@ describe("runtime.create()", () => {
|
|||
|
||||
// 1: new-session succeeds
|
||||
mockTmuxSuccess();
|
||||
// 2: send-keys fails
|
||||
// 2: set-option succeeds
|
||||
mockTmuxSuccess();
|
||||
// 3: send-keys fails
|
||||
mockTmuxError("send-keys failed");
|
||||
// 3: kill-session (cleanup attempt)
|
||||
// 4: kill-session (cleanup attempt)
|
||||
mockTmuxSuccess();
|
||||
|
||||
await expect(
|
||||
|
|
@ -207,7 +238,7 @@ describe("runtime.create()", () => {
|
|||
launchCommand: "bad-command",
|
||||
environment: {},
|
||||
}),
|
||||
).rejects.toThrow('Failed to send launch command to session "fail-session"');
|
||||
).rejects.toThrow('Failed to configure or launch session "fail-session"');
|
||||
|
||||
// Verify kill-session was called for cleanup
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith(
|
||||
|
|
@ -217,6 +248,33 @@ describe("runtime.create()", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("cleans up session if set-option fails", async () => {
|
||||
const runtime = create();
|
||||
|
||||
// 1: new-session succeeds
|
||||
mockTmuxSuccess();
|
||||
// 2: set-option fails (e.g. tmux command timeout on a slow host)
|
||||
mockTmuxError("set-option timed out");
|
||||
// 3: kill-session (cleanup attempt)
|
||||
mockTmuxSuccess();
|
||||
|
||||
await expect(
|
||||
runtime.create({
|
||||
sessionId: "setopt-fail",
|
||||
workspacePath: "/tmp/ws",
|
||||
launchCommand: "echo hi",
|
||||
environment: {},
|
||||
}),
|
||||
).rejects.toThrow('Failed to configure or launch session "setopt-fail"');
|
||||
|
||||
// kill-session must run so we don't leave an orphaned tmux session
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["kill-session", "-t", "setopt-fail"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid session IDs with special characters", async () => {
|
||||
const runtime = create();
|
||||
|
||||
|
|
@ -246,6 +304,7 @@ describe("runtime.create()", () => {
|
|||
it("accepts valid session IDs with hyphens and underscores", async () => {
|
||||
const runtime = create();
|
||||
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
|
||||
|
|
@ -262,6 +321,7 @@ describe("runtime.create()", () => {
|
|||
it("handles no environment (undefined)", async () => {
|
||||
const runtime = create();
|
||||
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
mockTmuxSuccess();
|
||||
|
||||
|
|
|
|||
|
|
@ -78,10 +78,16 @@ export function create(): Runtime {
|
|||
launchCommand = `export PATH=$(printf '%s' ${JSON.stringify(pathValue)})\n${launchCommand}`;
|
||||
}
|
||||
|
||||
// Send the launch command — clean up the session if this fails.
|
||||
// Use a temp script for long commands so the pane shows a short
|
||||
// Configure the session and send the launch command — kill the session
|
||||
// if any of these fail so we don't leave an orphaned tmux process.
|
||||
// Use a temp script for long launch commands so the pane shows a short
|
||||
// invocation instead of a pasted wall of shell.
|
||||
try {
|
||||
// Hide the tmux status bar — sessions are embedded in the web terminal,
|
||||
// and the green bar at the bottom is visual noise (and racy with the
|
||||
// web layer's own set-option call, which only fires on WebSocket connect).
|
||||
await tmux("set-option", "-t", sessionName, "status", "off");
|
||||
|
||||
if (launchCommand.length > 200) {
|
||||
const invocation = writeLaunchScript(launchCommand);
|
||||
await tmux("send-keys", "-t", sessionName, "-l", invocation);
|
||||
|
|
@ -97,7 +103,7 @@ export function create(): Runtime {
|
|||
// Best-effort cleanup
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to send launch command to session "${sessionName}": ${msg}`, {
|
||||
throw new Error(`Failed to configure or launch session "${sessionName}": ${msg}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-scm-github
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-scm-github",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "SCM plugin: GitHub (PRs, CI, reviews)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-scm-gitlab
|
||||
|
||||
## 0.2.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.2.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-scm-gitlab",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.9",
|
||||
"description": "SCM plugin: GitLab (MRs, CI, reviews)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-terminal-iterm2
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-terminal-iterm2",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Terminal plugin: macOS iTerm2",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-terminal-web
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-terminal-web",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Terminal plugin: xterm.js web terminal",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-tracker-github
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-tracker-github",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Tracker plugin: GitHub Issues",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ function createGitHubTracker(): Tracker {
|
|||
|
||||
lines.push(
|
||||
"",
|
||||
"The issue context above is complete and current. You should not need to call gh issue view unless you need additional context beyond what is provided here.",
|
||||
"The issue title, description, and labels above are current. Fetch comments or linked issues via `gh` only if you need additional context beyond what is provided here.",
|
||||
"",
|
||||
"Please implement the changes described in this issue. When done, commit and push your changes.",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
# @aoagents/ao-plugin-tracker-gitlab
|
||||
|
||||
## 0.2.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
- @aoagents/ao-plugin-scm-gitlab@0.2.9
|
||||
|
||||
## 0.2.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
- @aoagents/ao-plugin-scm-gitlab@0.2.8
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-tracker-gitlab",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.9",
|
||||
"description": "Tracker plugin: GitLab Issues",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-tracker-linear
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-tracker-linear",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Tracker plugin: Linear",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-workspace-clone
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-workspace-clone",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Workspace plugin: git clone",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# @aoagents/ao-plugin-workspace-worktree
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-workspace-worktree",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Workspace plugin: git worktrees",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { ProjectConfig, WorkspaceCreateConfig, WorkspaceInfo } from "@aoagents/ao-core";
|
||||
import type { ProjectConfig, WorkspaceCreateConfig, WorkspaceInfo } from "@aoagents/ao-core/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — must be declared before any import that uses the mocked modules
|
||||
|
|
@ -191,18 +191,20 @@ describe("workspace.create()", () => {
|
|||
// First call: git remote get-url origin
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["remote", "get-url", "origin"], {
|
||||
cwd: "/repo/path",
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Second call: git fetch origin --quiet
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["fetch", "origin", "--quiet"], {
|
||||
cwd: "/repo/path",
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Third call: git rev-parse --verify --quiet origin/main
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["rev-parse", "--verify", "--quiet", "origin/main"],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
|
||||
// Fourth call: git worktree add -b <branch> <path> <baseRef>
|
||||
|
|
@ -216,7 +218,7 @@ describe("workspace.create()", () => {
|
|||
"/mock-home/.worktrees/myproject/session-1",
|
||||
"origin/main",
|
||||
],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -253,15 +255,8 @@ describe("workspace.create()", () => {
|
|||
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith(
|
||||
"git",
|
||||
[
|
||||
"worktree",
|
||||
"add",
|
||||
"-b",
|
||||
"feat/TEST-1",
|
||||
"/mock-home/.worktrees/myproject/session-1",
|
||||
"origin/main",
|
||||
],
|
||||
{ cwd: "/repo/path" },
|
||||
["worktree", "add", "-b", "feat/TEST-1", "/mock-home/.worktrees/myproject/session-1", "origin/main"],
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -279,6 +274,128 @@ describe("workspace.create()", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("finds an adoptable worktree in the project-scoped worktree directory", async () => {
|
||||
const ws = create();
|
||||
|
||||
mockGitSuccess(
|
||||
[
|
||||
"worktree /mock-home/.agent-orchestrator/projects/myproject/worktrees/session-1",
|
||||
"HEAD deadbeef",
|
||||
"branch refs/heads/feat/TEST-1",
|
||||
].join("\n"),
|
||||
);
|
||||
mockExistsSync.mockReturnValueOnce(true);
|
||||
|
||||
const info = await ws.findManagedWorkspace?.(
|
||||
makeCreateConfig({
|
||||
worktreeDir: "/mock-home/.agent-orchestrator/projects/myproject/worktrees",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(info).toEqual({
|
||||
path: "/mock-home/.agent-orchestrator/projects/myproject/worktrees/session-1",
|
||||
branch: "feat/TEST-1",
|
||||
sessionId: "session-1",
|
||||
projectId: "myproject",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds an adoptable worktree in the legacy managed worktree directory", async () => {
|
||||
const ws = create();
|
||||
|
||||
mockGitSuccess(
|
||||
[
|
||||
"worktree /mock-home/.worktrees/myproject/session-1",
|
||||
"HEAD deadbeef",
|
||||
"branch refs/heads/feat/TEST-1",
|
||||
].join("\n"),
|
||||
);
|
||||
mockExistsSync.mockReturnValueOnce(true);
|
||||
|
||||
const info = await ws.findManagedWorkspace?.(
|
||||
makeCreateConfig({
|
||||
worktreeDir: "/mock-home/.agent-orchestrator/projects/myproject/worktrees",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(info?.path).toBe("/mock-home/.worktrees/myproject/session-1");
|
||||
});
|
||||
|
||||
it("returns null when no managed worktree tracks the requested branch", async () => {
|
||||
const ws = create();
|
||||
|
||||
mockGitSuccess(
|
||||
[
|
||||
"worktree /mock-home/.worktrees/myproject/session-2",
|
||||
"HEAD deadbeef",
|
||||
"branch refs/heads/feat/OTHER",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const info = await ws.findManagedWorkspace?.(makeCreateConfig());
|
||||
|
||||
expect(info).toBeNull();
|
||||
});
|
||||
|
||||
it("throws when the matching branch is checked out outside AO-managed worktree directories", async () => {
|
||||
const ws = create();
|
||||
|
||||
mockGitSuccess(
|
||||
[
|
||||
"worktree /tmp/manual-worktree",
|
||||
"HEAD deadbeef",
|
||||
"branch refs/heads/feat/TEST-1",
|
||||
].join("\n"),
|
||||
);
|
||||
mockExistsSync.mockReturnValueOnce(true);
|
||||
|
||||
await expect(ws.findManagedWorkspace?.(makeCreateConfig())).rejects.toThrow(
|
||||
'outside AO-managed worktree directories',
|
||||
);
|
||||
});
|
||||
|
||||
it("skips worktree entries whose path no longer exists on disk", async () => {
|
||||
const ws = create();
|
||||
|
||||
// The worktree is listed by git but the directory was manually deleted
|
||||
mockGitSuccess(
|
||||
[
|
||||
"worktree /mock-home/.worktrees/myproject/session-1",
|
||||
"HEAD deadbeef",
|
||||
"branch refs/heads/feat/TEST-1",
|
||||
].join("\n"),
|
||||
);
|
||||
// existsSync returns false for the deleted worktree path
|
||||
mockExistsSync.mockReturnValueOnce(false);
|
||||
|
||||
const info = await ws.findManagedWorkspace?.(makeCreateConfig());
|
||||
|
||||
expect(info).toBeNull();
|
||||
});
|
||||
|
||||
it("handles CRLF line endings in git worktree list output", async () => {
|
||||
const ws = create();
|
||||
|
||||
// Simulate Windows git output with \r\n line endings
|
||||
mockGitSuccess(
|
||||
[
|
||||
"worktree /mock-home/.worktrees/myproject/session-1",
|
||||
"HEAD deadbeef",
|
||||
"branch refs/heads/feat/TEST-1",
|
||||
].join("\r\n"),
|
||||
);
|
||||
mockExistsSync.mockReturnValueOnce(true);
|
||||
|
||||
const info = await ws.findManagedWorkspace?.(makeCreateConfig());
|
||||
|
||||
expect(info).toEqual({
|
||||
path: "/mock-home/.worktrees/myproject/session-1",
|
||||
branch: "feat/TEST-1",
|
||||
sessionId: "session-1",
|
||||
projectId: "myproject",
|
||||
});
|
||||
});
|
||||
|
||||
it("continues when fetch fails (offline)", async () => {
|
||||
const ws = create();
|
||||
|
||||
|
|
@ -303,7 +420,7 @@ describe("workspace.create()", () => {
|
|||
expect(mockExecFileAsync).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["rev-parse", "--verify", "--quiet", "refs/heads/main"],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith(
|
||||
|
|
@ -316,7 +433,7 @@ describe("workspace.create()", () => {
|
|||
"/mock-home/.worktrees/myproject/session-1",
|
||||
"refs/heads/main",
|
||||
],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -347,7 +464,7 @@ describe("workspace.create()", () => {
|
|||
expect(mockExecFileAsync).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "feat/TEST-1"],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
|
||||
expect(info.branch).toBe("feat/TEST-1");
|
||||
|
|
@ -376,7 +493,7 @@ describe("workspace.create()", () => {
|
|||
"/mock-home/.worktrees/myproject/session-1",
|
||||
"origin/main",
|
||||
],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
|
||||
expect(info.branch).toBe("feat/TEST-1");
|
||||
|
|
@ -398,7 +515,7 @@ describe("workspace.create()", () => {
|
|||
expect(mockExecFileAsync).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "feat/TEST-1"],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
|
||||
expect(info.branch).toBe("feat/TEST-1");
|
||||
|
|
@ -424,7 +541,7 @@ describe("workspace.create()", () => {
|
|||
expect(mockExecFileAsync).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["worktree", "remove", "--force", "/mock-home/.worktrees/myproject/session-1"],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -522,6 +639,7 @@ describe("workspace.create()", () => {
|
|||
// fetch should use expanded path
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["fetch", "origin", "--quiet"], {
|
||||
cwd: "/mock-home/my-repo",
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -544,7 +662,7 @@ describe("workspace.create()", () => {
|
|||
"/mock-home/.worktrees/myproject/session-1",
|
||||
"refs/heads/main",
|
||||
],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -571,7 +689,7 @@ describe("workspace.restore()", () => {
|
|||
"/mock-home/.worktrees/myproject/session-1",
|
||||
"origin/feat/TEST-1",
|
||||
],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
|
||||
expect(info.branch).toBe("feat/TEST-1");
|
||||
|
|
@ -598,7 +716,7 @@ describe("workspace.restore()", () => {
|
|||
"/mock-home/.worktrees/myproject/session-1",
|
||||
"refs/heads/main",
|
||||
],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
|
||||
expect(info).toEqual({
|
||||
|
|
@ -625,14 +743,14 @@ describe("workspace.destroy()", () => {
|
|||
expect(mockExecFileAsync).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
{ cwd: "/mock-home/.worktrees/myproject/session-1" },
|
||||
{ cwd: "/mock-home/.worktrees/myproject/session-1", timeout: 30_000 },
|
||||
);
|
||||
|
||||
// Second call: worktree remove
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["worktree", "remove", "--force", "/mock-home/.worktrees/myproject/session-1"],
|
||||
{ cwd: "/repo/path" },
|
||||
{ cwd: "/repo/path", timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
WorkspaceCreateConfig,
|
||||
WorkspaceInfo,
|
||||
ProjectConfig,
|
||||
} from "@aoagents/ao-core";
|
||||
} from "@aoagents/ao-core/types";
|
||||
|
||||
/** Timeout for git commands (30 seconds) */
|
||||
const GIT_TIMEOUT = 30_000;
|
||||
|
|
@ -25,7 +25,7 @@ export const manifest = {
|
|||
|
||||
/** Run a git command in a given directory */
|
||||
async function git(cwd: string, ...args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", args, { cwd });
|
||||
const { stdout } = await execFileAsync("git", args, { cwd, timeout: GIT_TIMEOUT });
|
||||
return stdout.trimEnd();
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +101,32 @@ async function clearStaleWorktreePath(repoPath: string, worktreePath: string): P
|
|||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
interface WorktreeEntry {
|
||||
path: string;
|
||||
branch: string | null;
|
||||
}
|
||||
|
||||
function parseWorktreeList(output: string): WorktreeEntry[] {
|
||||
const normalized = output.replace(/\r\n/g, "\n").trim();
|
||||
if (!normalized) return [];
|
||||
|
||||
return normalized
|
||||
.split("\n\n")
|
||||
.map((block) => {
|
||||
let path = "";
|
||||
let branch: string | null = null;
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
path = resolve(line.slice("worktree ".length));
|
||||
} else if (line.startsWith("branch ")) {
|
||||
branch = line.slice("branch ".length).replace("refs/heads/", "");
|
||||
}
|
||||
}
|
||||
return { path, branch };
|
||||
})
|
||||
.filter((entry) => entry.path.length > 0);
|
||||
}
|
||||
|
||||
/** Only allow safe characters in path segments to prevent directory traversal */
|
||||
const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
|
|
@ -202,6 +228,46 @@ export function create(config?: Record<string, unknown>): Workspace {
|
|||
};
|
||||
},
|
||||
|
||||
async findManagedWorkspace(cfg: WorkspaceCreateConfig): Promise<WorkspaceInfo | null> {
|
||||
assertSafePathSegment(cfg.projectId, "projectId");
|
||||
assertSafePathSegment(cfg.sessionId, "sessionId");
|
||||
|
||||
const repoPath = expandPath(cfg.project.path);
|
||||
const effectiveBaseDir = cfg.worktreeDir ?? worktreeBaseDir;
|
||||
const projectWorktreeDir = cfg.worktreeDir
|
||||
? effectiveBaseDir
|
||||
: join(effectiveBaseDir, cfg.projectId);
|
||||
const currentManagedPath = resolve(join(projectWorktreeDir, cfg.sessionId));
|
||||
const legacyManagedPath = resolve(join(worktreeBaseDir, cfg.projectId, cfg.sessionId));
|
||||
const allowedPaths = new Set([currentManagedPath, legacyManagedPath]);
|
||||
|
||||
const worktrees = parseWorktreeList(await git(repoPath, "worktree", "list", "--porcelain"));
|
||||
const matches = worktrees.filter(
|
||||
(entry) => entry.branch === cfg.branch && existsSync(entry.path),
|
||||
);
|
||||
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`Found multiple worktrees for orchestrator branch "${cfg.branch}". Reuse one workspace or remove the extras before starting the orchestrator.`,
|
||||
);
|
||||
}
|
||||
|
||||
const match = matches[0]!;
|
||||
if (!allowedPaths.has(match.path)) {
|
||||
throw new Error(
|
||||
`Found existing worktree for orchestrator branch "${cfg.branch}" at "${match.path}", but it is outside AO-managed worktree directories. Reuse it manually or remove it and try again.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
path: match.path,
|
||||
branch: cfg.branch,
|
||||
sessionId: cfg.sessionId,
|
||||
projectId: cfg.projectId,
|
||||
};
|
||||
},
|
||||
|
||||
async destroy(workspacePath: string): Promise<void> {
|
||||
try {
|
||||
const gitCommonDir = await git(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,47 @@
|
|||
# @aoagents/ao-web
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Drop `=` prefix from `set-option` invocation in `mux-websocket` so tmux accepts the option without erroring out (#1715).
|
||||
- Bound the PTY re-attach loop with a grace-period counter reset to prevent runaway reconnect attempts (#1640).
|
||||
- Disable xterm scrollback to prevent terminal right-side clipping (#1678).
|
||||
- Updated dependencies
|
||||
- Updated dependencies [40aeb78]
|
||||
- Updated dependencies
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.6.0
|
||||
- @aoagents/ao-plugin-runtime-tmux@0.6.0
|
||||
- @aoagents/ao-plugin-agent-claude-code@0.6.0
|
||||
- @aoagents/ao-plugin-agent-codex@0.6.0
|
||||
- @aoagents/ao-plugin-agent-cursor@0.1.4
|
||||
- @aoagents/ao-plugin-agent-kimicode@0.1.3
|
||||
- @aoagents/ao-plugin-agent-opencode@0.6.0
|
||||
- @aoagents/ao-plugin-scm-github@0.6.0
|
||||
- @aoagents/ao-plugin-tracker-github@0.6.0
|
||||
- @aoagents/ao-plugin-tracker-linear@0.6.0
|
||||
- @aoagents/ao-plugin-workspace-worktree@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dd07b6b: Fix direct terminal attach and keep mux routing project-scoped. Switches `resolveExactTmuxName` from `execFileSync` to a promisified `execFile` so slow tmux calls no longer stall the WebSocket message handler, and propagates async through `TerminalManager.open` / `subscribe` and the pty `onExit` reattach path. Also drops a duplicate `.kanban-board` grid rule in `globals.css`.
|
||||
- dd07b6b: Render an empty-state in the project sidebar when no projects are configured. Fresh-install users previously saw a blank sidebar with no way to open the Add Project modal; the sidebar now shows a small empty-state with the `+` button wired up.
|
||||
- Updated dependencies [dd07b6b]
|
||||
- @aoagents/ao-core@0.5.0
|
||||
- @aoagents/ao-plugin-agent-claude-code@0.5.0
|
||||
- @aoagents/ao-plugin-agent-codex@0.5.0
|
||||
- @aoagents/ao-plugin-agent-cursor@0.1.3
|
||||
- @aoagents/ao-plugin-agent-kimicode@0.1.2
|
||||
- @aoagents/ao-plugin-agent-opencode@0.5.0
|
||||
- @aoagents/ao-plugin-runtime-tmux@0.5.0
|
||||
- @aoagents/ao-plugin-scm-github@0.5.0
|
||||
- @aoagents/ao-plugin-tracker-github@0.5.0
|
||||
- @aoagents/ao-plugin-tracker-linear@0.5.0
|
||||
- @aoagents/ao-plugin-workspace-worktree@0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-web",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Web dashboard for agent-orchestrator",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
|
|
|||
|
|
@ -283,6 +283,105 @@ describe("mux terminal open", () => {
|
|||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("retry budget recovers after PTY survives the grace period (issue #1639)", async () => {
|
||||
// Complements the runaway-loop test below. That one proves the counter
|
||||
// does NOT reset when the PTY crashes inside REATTACH_RESET_GRACE_MS.
|
||||
// This one proves the counter DOES reset once the PTY survives the
|
||||
// grace period — without which a transient blip during startup would
|
||||
// permanently consume the retry budget and prevent any later recovery.
|
||||
//
|
||||
// Test shape:
|
||||
// 1. Open a terminal against a healthy tmux session (PTY 1 attaches).
|
||||
// 2. Wait > REATTACH_RESET_GRACE_MS so the grace timer fires and
|
||||
// resets reattachAttempts to 0 on the still-attached PTY 1.
|
||||
// 3. Kill the tmux session — PTY 1 exits, the server burns the full
|
||||
// MAX_REATTACH_ATTEMPTS=3 budget trying to re-attach, then emits
|
||||
// "exited". A 2 s window is comfortably more than 3 × ~50 ms.
|
||||
// 4. Per-test timeout raised to 15 s to accommodate the 5+ s wait.
|
||||
//
|
||||
// If the grace timer or its closure guard ever regresses (e.g. is
|
||||
// unref'd in a way that prevents firing, or the wrong reference is
|
||||
// compared, or a future change clears it too eagerly), step 3 will
|
||||
// either time out waiting for "exited" or never reach the cap.
|
||||
const RECOVERY_TEST_SESSION = `ao-test-recovery-${process.pid}`;
|
||||
execFileSync(TMUX, ["new-session", "-d", "-s", RECOVERY_TEST_SESSION, "-x", "80", "-y", "24"], {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
try {
|
||||
const ws = await connectMux();
|
||||
ws.send(JSON.stringify({ ch: "terminal", id: RECOVERY_TEST_SESSION, type: "open" }));
|
||||
await waitForMessage(ws, (m) => m.ch === "terminal" && m.type === "opened");
|
||||
|
||||
// Sleep past REATTACH_RESET_GRACE_MS (5 s in production).
|
||||
await new Promise((r) => setTimeout(r, 5500));
|
||||
|
||||
execFileSync(TMUX, ["kill-session", "-t", RECOVERY_TEST_SESSION], { timeout: 5000 });
|
||||
|
||||
const exitedMsg = await waitForMessage(
|
||||
ws,
|
||||
(m) => m.ch === "terminal" && m.type === "exited",
|
||||
2000,
|
||||
);
|
||||
expect(exitedMsg.id).toBe(RECOVERY_TEST_SESSION);
|
||||
|
||||
ws.close();
|
||||
} finally {
|
||||
try {
|
||||
execFileSync(TMUX, ["kill-session", "-t", RECOVERY_TEST_SESSION], { timeout: 5000 });
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("bounds re-attach attempts when tmux session dies mid-subscription (issue #1639)", async () => {
|
||||
// Reproduces the runaway re-attach loop that exhausts the system PTY
|
||||
// pool in seconds when `ao stop` kills the tmux session out from
|
||||
// under a still-subscribed dashboard.
|
||||
//
|
||||
// Pre-fix behaviour: each "successful" re-attach reset reattachAttempts
|
||||
// to 0, so MAX_REATTACH_ATTEMPTS=3 never engaged; the loop ran at
|
||||
// ~80 spawns/sec and "exited" was never emitted, so this test would
|
||||
// hang until the 2-second timeout.
|
||||
//
|
||||
// Post-fix: the counter is only reset by a 5-second grace timer, so a
|
||||
// PTY that exits in ~40 ms can never reset it. After 3 attempts the
|
||||
// server gives up and emits "exited".
|
||||
const RUNAWAY_TEST_SESSION = `ao-test-runaway-${process.pid}`;
|
||||
execFileSync(TMUX, ["new-session", "-d", "-s", RUNAWAY_TEST_SESSION, "-x", "80", "-y", "24"], {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
try {
|
||||
const ws = await connectMux();
|
||||
|
||||
ws.send(JSON.stringify({ ch: "terminal", id: RUNAWAY_TEST_SESSION, type: "open" }));
|
||||
await waitForMessage(ws, (m) => m.ch === "terminal" && m.type === "opened");
|
||||
|
||||
// Kill the tmux session externally — the attached PTY now has nothing
|
||||
// to attach to and will exit ~40 ms after each re-attach attempt.
|
||||
execFileSync(TMUX, ["kill-session", "-t", RUNAWAY_TEST_SESSION], { timeout: 5000 });
|
||||
|
||||
// 3 re-attach attempts × ~50 ms each = ~150 ms; allow generous margin.
|
||||
const exitedMsg = await waitForMessage(
|
||||
ws,
|
||||
(m) => m.ch === "terminal" && m.type === "exited",
|
||||
2000,
|
||||
);
|
||||
expect(exitedMsg.id).toBe(RUNAWAY_TEST_SESSION);
|
||||
|
||||
ws.close();
|
||||
} finally {
|
||||
// Best-effort cleanup if test fails before kill-session ran
|
||||
try {
|
||||
execFileSync(TMUX, ["kill-session", "-t", RUNAWAY_TEST_SESSION], { timeout: 5000 });
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -1,12 +1,48 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { SessionBroadcaster } from "../mux-websocket";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { SessionBroadcaster as SessionBroadcasterType } from "../mux-websocket";
|
||||
|
||||
// vi.mock factories run before module-level statements. Hoist the mock
|
||||
// fns so the factories close over the same instances the tests use.
|
||||
const { mockSpawn, mockPtySpawn } = vi.hoisted(() => ({
|
||||
mockSpawn: vi.fn(),
|
||||
mockPtySpawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
const spawnFn = (...args: unknown[]) => mockSpawn(...args);
|
||||
return {
|
||||
...actual,
|
||||
default: { ...(actual.default as object), spawn: spawnFn },
|
||||
spawn: spawnFn,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node-pty", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
spawn: (...args: unknown[]) => mockPtySpawn(...args),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock tmux-utils so resolveTmuxSession returns a deterministic session id
|
||||
// and we don't shell out to a real tmux binary.
|
||||
vi.mock("../tmux-utils.js", () => ({
|
||||
findTmux: () => "/usr/bin/tmux",
|
||||
validateSessionId: () => true,
|
||||
resolveTmuxSession: () => "ao-177",
|
||||
}));
|
||||
|
||||
const { SessionBroadcaster, TerminalManager } = await import("../mux-websocket");
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("SessionBroadcaster", () => {
|
||||
let broadcaster: SessionBroadcaster;
|
||||
let broadcaster: SessionBroadcasterType;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
|
@ -225,3 +261,53 @@ describe("SessionBroadcaster", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalManager.open — tmux target args (regression for #1714)", () => {
|
||||
beforeEach(() => {
|
||||
mockSpawn.mockReset();
|
||||
mockPtySpawn.mockReset();
|
||||
|
||||
// spawn() returns an object that emits "error" — we just need .on() to work.
|
||||
mockSpawn.mockImplementation(() => new EventEmitter());
|
||||
|
||||
// ptySpawn() returns a minimal IPty-like stub so terminal wiring doesn't crash.
|
||||
mockPtySpawn.mockImplementation(() => ({
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
it("invokes set-option mouse on with the bare session id (no = prefix)", () => {
|
||||
const mgr = new TerminalManager("/usr/bin/tmux");
|
||||
mgr.open("ao-177");
|
||||
|
||||
const mouseCall = mockSpawn.mock.calls.find(
|
||||
(call) => Array.isArray(call[1]) && call[1].includes("mouse"),
|
||||
);
|
||||
expect(mouseCall).toBeDefined();
|
||||
expect(mouseCall?.[1]).toEqual(["set-option", "-t", "ao-177", "mouse", "on"]);
|
||||
});
|
||||
|
||||
it("invokes set-option status off with the bare session id (no = prefix)", () => {
|
||||
const mgr = new TerminalManager("/usr/bin/tmux");
|
||||
mgr.open("ao-177");
|
||||
|
||||
const statusCall = mockSpawn.mock.calls.find(
|
||||
(call) => Array.isArray(call[1]) && call[1].includes("status"),
|
||||
);
|
||||
expect(statusCall).toBeDefined();
|
||||
expect(statusCall?.[1]).toEqual(["set-option", "-t", "ao-177", "status", "off"]);
|
||||
});
|
||||
|
||||
it("still uses the = exact-match prefix for attach-session", () => {
|
||||
const mgr = new TerminalManager("/usr/bin/tmux");
|
||||
mgr.open("ao-177");
|
||||
|
||||
expect(mockPtySpawn).toHaveBeenCalledTimes(1);
|
||||
const [, args] = mockPtySpawn.mock.calls[0];
|
||||
expect(args).toEqual(["attach-session", "-t", "=ao-177"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -192,17 +192,37 @@ interface ManagedTerminal {
|
|||
buffer: string[];
|
||||
bufferBytes: number;
|
||||
reattachAttempts: number;
|
||||
/**
|
||||
* Pending grace-period timer that resets reattachAttempts when the
|
||||
* currently-attached PTY survives REATTACH_RESET_GRACE_MS. Tracked so
|
||||
* cleanup paths (last-subscriber unsubscribe, subsequent re-attach) can
|
||||
* clear it and avoid keeping the dead PTY/terminal closure references
|
||||
* reachable for up to 5 s after teardown.
|
||||
*/
|
||||
resetTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const RING_BUFFER_MAX = 50 * 1024; // 50KB max per terminal
|
||||
const WS_BUFFER_HIGH_WATERMARK = 64 * 1024; // 64KB
|
||||
const MAX_REATTACH_ATTEMPTS = 3;
|
||||
/**
|
||||
* Grace period a freshly-attached PTY must survive before its successful
|
||||
* attach is allowed to reset the re-attach counter. Prevents tight crash
|
||||
* loops (e.g. attaching to a tmux session that no longer exists) from
|
||||
* gaming the MAX_REATTACH_ATTEMPTS cap by resetting the counter to 0
|
||||
* between every failed attempt.
|
||||
*
|
||||
* 5 s is comfortably longer than the ~40 ms a doomed `tmux attach-session`
|
||||
* takes to exit, while still being short enough that a healthy PTY which
|
||||
* crashes hours later gets a fresh retry budget.
|
||||
*/
|
||||
const REATTACH_RESET_GRACE_MS = 5_000;
|
||||
|
||||
/**
|
||||
* TerminalManager manages PTY processes independently of WebSocket connections.
|
||||
* A single manager instance is shared across all mux connections.
|
||||
*/
|
||||
class TerminalManager {
|
||||
export class TerminalManager {
|
||||
private terminals = new Map<string, ManagedTerminal>();
|
||||
private TMUX: string;
|
||||
|
||||
|
|
@ -254,16 +274,18 @@ class TerminalManager {
|
|||
return tmuxSessionId;
|
||||
}
|
||||
|
||||
// Enable mouse mode
|
||||
const exactTmuxTarget = `=${tmuxSessionId}`;
|
||||
// tmux 3.4 only honours the `=` exact-match prefix on has-session and
|
||||
// attach-session; set-option silently ignores it, so we use the bare id
|
||||
// here. The `=`-prefixed form is built below for attach-session.
|
||||
|
||||
const mouseProc = spawn(this.TMUX, ["set-option", "-t", exactTmuxTarget, "mouse", "on"]);
|
||||
// Enable mouse mode
|
||||
const mouseProc = spawn(this.TMUX, ["set-option", "-t", tmuxSessionId, "mouse", "on"]);
|
||||
mouseProc.on("error", (err) => {
|
||||
console.error(`[MuxServer] Failed to set mouse mode for ${tmuxSessionId}:`, err.message);
|
||||
});
|
||||
|
||||
// Hide the status bar
|
||||
const statusProc = spawn(this.TMUX, ["set-option", "-t", exactTmuxTarget, "status", "off"]);
|
||||
const statusProc = spawn(this.TMUX, ["set-option", "-t", tmuxSessionId, "status", "off"]);
|
||||
statusProc.on("error", (err) => {
|
||||
console.error(`[MuxServer] Failed to hide status bar for ${tmuxSessionId}:`, err.message);
|
||||
});
|
||||
|
|
@ -285,7 +307,9 @@ class TerminalManager {
|
|||
throw new Error("node-pty not available");
|
||||
}
|
||||
|
||||
// Spawn PTY
|
||||
// Spawn PTY — use `=`-prefixed exact-match target so we never attach to
|
||||
// a session whose name happens to be a prefix of the requested id.
|
||||
const exactTmuxTarget = `=${tmuxSessionId}`;
|
||||
const pty = ptySpawn(this.TMUX, ["attach-session", "-t", exactTmuxTarget], {
|
||||
name: "xterm-256color",
|
||||
cols: 80,
|
||||
|
|
@ -296,6 +320,25 @@ class TerminalManager {
|
|||
|
||||
terminal.pty = pty;
|
||||
|
||||
// Schedule a grace-period reset of the re-attach counter. We only
|
||||
// consider an attach "really successful" if the PTY survives long
|
||||
// enough to suggest the underlying tmux session is actually usable.
|
||||
// The closure-captured `pty` reference is compared with terminal.pty
|
||||
// so a stale timer cannot reset the counter for a PTY that has
|
||||
// already exited or been replaced by re-attach. Any previously-
|
||||
// scheduled timer (from a now-replaced PTY) is cleared so we don't
|
||||
// keep its closure references reachable until the timer fires.
|
||||
if (terminal.resetTimer) {
|
||||
clearTimeout(terminal.resetTimer);
|
||||
}
|
||||
terminal.resetTimer = setTimeout(() => {
|
||||
terminal.resetTimer = undefined;
|
||||
if (terminal.pty === pty) {
|
||||
terminal.reattachAttempts = 0;
|
||||
}
|
||||
}, REATTACH_RESET_GRACE_MS);
|
||||
terminal.resetTimer.unref();
|
||||
|
||||
// Wire up data events
|
||||
pty.onData((data: string) => {
|
||||
// Push to all subscribers — isolate each callback so a throw in one
|
||||
|
|
@ -327,6 +370,12 @@ class TerminalManager {
|
|||
// Re-attach if subscribers are still present, up to MAX_REATTACH_ATTEMPTS.
|
||||
// The cap prevents an unbounded respawn loop when the PTY crashes immediately
|
||||
// after every attach (e.g. resource exhaustion or a broken tmux session).
|
||||
// The counter is reset by a delayed timer in open() once the new PTY has
|
||||
// survived REATTACH_RESET_GRACE_MS — see the comment on that constant.
|
||||
// Resetting here would defeat the cap: when ao stop kills the tmux session
|
||||
// out from under a still-subscribed dashboard, attach-session exits ~40 ms
|
||||
// after spawn and the loop runs at ~80 spawns/sec, exhausting the system
|
||||
// PTY pool in seconds (issue #1639).
|
||||
if (terminal.subscribers.size > 0 && terminal.reattachAttempts < MAX_REATTACH_ATTEMPTS) {
|
||||
terminal.reattachAttempts += 1;
|
||||
console.log(
|
||||
|
|
@ -334,7 +383,6 @@ class TerminalManager {
|
|||
);
|
||||
try {
|
||||
this.open(id, projectId, tmuxSessionId);
|
||||
terminal.reattachAttempts = 0; // reset on successful attach
|
||||
return; // re-attached — don't notify exit
|
||||
} catch (err) {
|
||||
console.error(`[MuxServer] Failed to re-attach ${id}:`, err);
|
||||
|
|
@ -402,6 +450,10 @@ class TerminalManager {
|
|||
if (onExit) terminal.exitCallbacks.delete(onExit);
|
||||
// Kill PTY and clean up when the last subscriber leaves
|
||||
if (terminal.subscribers.size === 0) {
|
||||
if (terminal.resetTimer) {
|
||||
clearTimeout(terminal.resetTimer);
|
||||
terminal.resetTimer = undefined;
|
||||
}
|
||||
if (terminal.pty) {
|
||||
terminal.pty.kill();
|
||||
terminal.pty = null;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue