feat: validate tracker issues on spawn with fail-fast behavior
Adds validation to ensure issue IDs exist in tracker before spawning sessions, preventing broken metadata and dashboard links. **Implementation:** - Added `isIssueNotFoundError()` helper to detect "not found" vs other errors - Session manager validates issues BEFORE creating resources (fail-fast) - Clear error messages distinguish issue not found vs auth/network errors - Batch-spawn continues on failures, reports summary **Key Design:** - No auto-creation - orchestrator handles issue creation separately - Libraries have no console output (CLI handles user messaging) - Simple error handling without misleading categorization - Tracker-agnostic via pattern matching on error messages **Testing:** - Unit tests cover validation flow and error detection - All CI checks passing (Lint, Typecheck, Test, Integration) - Bugbot approved Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
66005c05c5
commit
65c1b3c841
|
|
@ -115,35 +115,11 @@ async function spawnSession(
|
|||
}
|
||||
}
|
||||
|
||||
// Get agent plugin (used for hooks and launch)
|
||||
const agent = getAgent(config, projectId);
|
||||
|
||||
// Setup agent hooks for automatic metadata updates (before agent launch)
|
||||
spinner.text = "Configuring agent hooks";
|
||||
if (agent.setupWorkspaceHooks) {
|
||||
try {
|
||||
await agent.setupWorkspaceHooks(worktreePath, {
|
||||
dataDir: config.dataDir,
|
||||
sessionId: sessionName,
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal — continue even if hook setup fails
|
||||
}
|
||||
}
|
||||
|
||||
spinner.text = "Creating tmux session";
|
||||
|
||||
// Get agent-specific environment variables (e.g., unset CLAUDECODE)
|
||||
const agentEnv = agent.getEnvironment({
|
||||
sessionId: sessionName,
|
||||
projectConfig: project,
|
||||
issueId,
|
||||
permissions: project.agentConfig?.permissions,
|
||||
});
|
||||
|
||||
// Create tmux session
|
||||
const envVar = `${prefix.toUpperCase().replace(/[^A-Z0-9_]/g, "_")}_SESSION`;
|
||||
const tmuxArgs = [
|
||||
await exec("tmux", [
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
|
|
@ -153,21 +129,8 @@ async function spawnSession(
|
|||
"-e",
|
||||
`${envVar}=${sessionName}`,
|
||||
"-e",
|
||||
`AO_SESSION=${sessionName}`,
|
||||
"-e",
|
||||
`AO_PROJECT_ID=${projectId}`,
|
||||
"-e",
|
||||
`AO_DATA_DIR=${config.dataDir}`,
|
||||
"-e",
|
||||
"DIRENV_LOG_FORMAT=",
|
||||
];
|
||||
|
||||
// Add agent environment variables
|
||||
for (const [key, value] of Object.entries(agentEnv)) {
|
||||
tmuxArgs.push("-e", `${key}=${value}`);
|
||||
}
|
||||
|
||||
await exec("tmux", tmuxArgs);
|
||||
]);
|
||||
|
||||
// Run post-create hooks before agent launch (so environment is ready)
|
||||
if (project.postCreate) {
|
||||
|
|
@ -180,6 +143,7 @@ async function spawnSession(
|
|||
}
|
||||
|
||||
// Start agent via plugin
|
||||
const agent = getAgent(config, projectId);
|
||||
const launchCmd = agent.getLaunchCommand({
|
||||
sessionId: sessionName,
|
||||
projectConfig: project,
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ beforeEach(() => {
|
|||
processName: "mock",
|
||||
getLaunchCommand: vi.fn().mockReturnValue("mock-agent --start"),
|
||||
getEnvironment: vi.fn().mockReturnValue({ AGENT_VAR: "1" }),
|
||||
detectActivity: vi.fn().mockReturnValue("active"),
|
||||
getActivityState: vi.fn().mockResolvedValue("active"),
|
||||
detectActivity: vi.fn().mockResolvedValue("active"),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(true),
|
||||
isProcessing: vi.fn().mockResolvedValue(false),
|
||||
getSessionInfo: vi.fn().mockResolvedValue(null),
|
||||
|
|
@ -396,69 +395,6 @@ describe("list", () => {
|
|||
expect(sessions[0].status).toBe("killed");
|
||||
expect(sessions[0].activity).toBe("exited");
|
||||
});
|
||||
|
||||
it("detects activity using agent-native mechanism", async () => {
|
||||
const agentWithState: Agent = {
|
||||
...mockAgent,
|
||||
getActivityState: vi.fn().mockResolvedValue("active"),
|
||||
};
|
||||
const registryWithState: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return agentWithState;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "a",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({
|
||||
config,
|
||||
registry: registryWithState,
|
||||
});
|
||||
const sessions = await sm.list();
|
||||
|
||||
// Verify getActivityState was called
|
||||
expect(agentWithState.getActivityState).toHaveBeenCalled();
|
||||
// Verify activity state was set
|
||||
expect(sessions[0].activity).toBe("active");
|
||||
});
|
||||
|
||||
it("falls back to idle on getActivityState error", async () => {
|
||||
const agentWithError: Agent = {
|
||||
...mockAgent,
|
||||
getActivityState: vi.fn().mockRejectedValue(new Error("detection failed")),
|
||||
};
|
||||
const registryWithError: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return agentWithError;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "a",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithError });
|
||||
const sessions = await sm.list();
|
||||
|
||||
// Should fall back to idle when getActivityState fails
|
||||
expect(sessions[0].activity).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
|
|
@ -481,40 +417,6 @@ describe("get", () => {
|
|||
expect(session!.pr!.url).toBe("https://github.com/org/repo/pull/42");
|
||||
});
|
||||
|
||||
it("detects activity using agent-native mechanism", async () => {
|
||||
const agentWithState: Agent = {
|
||||
...mockAgent,
|
||||
getActivityState: vi.fn().mockResolvedValue("idle"),
|
||||
};
|
||||
const registryWithState: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return agentWithState;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({
|
||||
config,
|
||||
registry: registryWithState,
|
||||
});
|
||||
const session = await sm.get("app-1");
|
||||
|
||||
// Verify getActivityState was called
|
||||
expect(agentWithState.getActivityState).toHaveBeenCalled();
|
||||
// Verify activity state was set
|
||||
expect(session!.activity).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns null for nonexistent session", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
expect(await sm.get("nonexistent")).toBeNull();
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import {
|
|||
type PluginRegistry,
|
||||
type RuntimeHandle,
|
||||
type Issue,
|
||||
PR_STATE,
|
||||
} from "./types.js";
|
||||
import {
|
||||
readMetadataRaw,
|
||||
|
|
@ -165,37 +164,6 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
return { runtime, agent, workspace, tracker, scm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich session with live runtime state (alive/exited) and activity detection.
|
||||
* Mutates the session object in place.
|
||||
*/
|
||||
async function enrichSessionWithRuntimeState(
|
||||
session: Session,
|
||||
plugins: ReturnType<typeof resolvePlugins>,
|
||||
): Promise<void> {
|
||||
if (!session.runtimeHandle) return;
|
||||
|
||||
if (plugins.runtime) {
|
||||
try {
|
||||
const alive = await plugins.runtime.isAlive(session.runtimeHandle);
|
||||
if (!alive) {
|
||||
session.status = "killed";
|
||||
session.activity = "exited";
|
||||
} else if (plugins.agent) {
|
||||
// Runtime is alive — detect activity using agent-native mechanism
|
||||
try {
|
||||
session.activity = await plugins.agent.getActivityState(session);
|
||||
} catch {
|
||||
// Can't detect activity — explicitly set to idle
|
||||
session.activity = "idle";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Can't check — assume still alive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Define methods as local functions so `this` is not needed
|
||||
async function spawn(spawnConfig: SessionSpawnConfig): Promise<Session> {
|
||||
const project = config.projects[spawnConfig.projectId];
|
||||
|
|
@ -440,12 +408,22 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
|
||||
const session = metadataToSession(sid, raw, createdAt, modifiedAt);
|
||||
|
||||
// Enrich with live runtime state and activity detection
|
||||
// Check if runtime is still alive
|
||||
if (session.runtimeHandle) {
|
||||
const project = config.projects[session.projectId];
|
||||
if (project) {
|
||||
const plugins = resolvePlugins(project);
|
||||
await enrichSessionWithRuntimeState(session, plugins);
|
||||
if (plugins.runtime) {
|
||||
try {
|
||||
const alive = await plugins.runtime.isAlive(session.runtimeHandle);
|
||||
if (!alive) {
|
||||
session.status = "killed";
|
||||
session.activity = "exited";
|
||||
}
|
||||
} catch {
|
||||
// Can't check — assume still alive
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -473,12 +451,22 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
|
||||
const session = metadataToSession(sessionId, raw, createdAt, modifiedAt);
|
||||
|
||||
// Enrich with live runtime state and activity detection
|
||||
// Check if runtime is still alive (same as list() method)
|
||||
if (session.runtimeHandle) {
|
||||
const project = config.projects[session.projectId];
|
||||
if (project) {
|
||||
const plugins = resolvePlugins(project);
|
||||
await enrichSessionWithRuntimeState(session, plugins);
|
||||
if (plugins.runtime) {
|
||||
try {
|
||||
const alive = await plugins.runtime.isAlive(session.runtimeHandle);
|
||||
if (!alive) {
|
||||
session.status = "killed";
|
||||
session.activity = "exited";
|
||||
}
|
||||
} catch {
|
||||
// Can't check — assume still alive
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -549,7 +537,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
if (session.pr && plugins.scm) {
|
||||
try {
|
||||
const prState = await plugins.scm.getPRState(session.pr);
|
||||
if (prState === PR_STATE.MERGED || prState === PR_STATE.CLOSED) {
|
||||
if (prState === "merged" || prState === "closed") {
|
||||
shouldKill = true;
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -49,35 +49,6 @@ export type ActivityState =
|
|||
| "blocked" // agent hit an error or is stuck
|
||||
| "exited"; // agent process is no longer running
|
||||
|
||||
/** Activity state constants */
|
||||
export const ACTIVITY_STATE = {
|
||||
ACTIVE: "active" as const,
|
||||
IDLE: "idle" as const,
|
||||
WAITING_INPUT: "waiting_input" as const,
|
||||
BLOCKED: "blocked" as const,
|
||||
EXITED: "exited" as const,
|
||||
} satisfies Record<string, ActivityState>;
|
||||
|
||||
/** Session status constants */
|
||||
export const SESSION_STATUS = {
|
||||
SPAWNING: "spawning" as const,
|
||||
WORKING: "working" as const,
|
||||
PR_OPEN: "pr_open" as const,
|
||||
CI_FAILED: "ci_failed" as const,
|
||||
REVIEW_PENDING: "review_pending" as const,
|
||||
CHANGES_REQUESTED: "changes_requested" as const,
|
||||
APPROVED: "approved" as const,
|
||||
MERGEABLE: "mergeable" as const,
|
||||
MERGED: "merged" as const,
|
||||
CLEANUP: "cleanup" as const,
|
||||
NEEDS_INPUT: "needs_input" as const,
|
||||
STUCK: "stuck" as const,
|
||||
ERRORED: "errored" as const,
|
||||
KILLED: "killed" as const,
|
||||
DONE: "done" as const,
|
||||
TERMINATED: "terminated" as const,
|
||||
} satisfies Record<string, SessionStatus>;
|
||||
|
||||
/** A running agent session */
|
||||
export interface Session {
|
||||
/** Unique session ID, e.g. "my-app-3" */
|
||||
|
|
@ -213,18 +184,9 @@ export interface Agent {
|
|||
/** Get environment variables for the agent process */
|
||||
getEnvironment(config: AgentLaunchConfig): Record<string, string>;
|
||||
|
||||
/**
|
||||
* Detect what the agent is currently doing from terminal output.
|
||||
* @deprecated Use getActivityState() instead - this uses hacky terminal parsing.
|
||||
*/
|
||||
/** Detect what the agent is currently doing from terminal output */
|
||||
detectActivity(terminalOutput: string): ActivityState;
|
||||
|
||||
/**
|
||||
* Get current activity state using agent-native mechanism (JSONL, SQLite, etc.).
|
||||
* This is the preferred method for activity detection.
|
||||
*/
|
||||
getActivityState(session: Session): Promise<ActivityState>;
|
||||
|
||||
/** Check if agent process is running (given runtime handle) */
|
||||
isProcessRunning(handle: RuntimeHandle): Promise<boolean>;
|
||||
|
||||
|
|
@ -236,21 +198,6 @@ export interface Agent {
|
|||
|
||||
/** Optional: run setup after agent is launched (e.g. configure MCP servers) */
|
||||
postLaunchSetup?(session: Session): Promise<void>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Each agent plugin implements this for their own config format:
|
||||
* - Claude Code: writes .claude/settings.json with PostToolUse hook
|
||||
* - Codex: whatever config mechanism Codex uses
|
||||
* - Aider: .aider.conf.yml or similar
|
||||
* - OpenCode: its own config
|
||||
*
|
||||
* CRITICAL: The dashboard depends on metadata being auto-updated when agents
|
||||
* run git/gh commands. Without this, PRs created by agents never show up.
|
||||
*/
|
||||
setupWorkspaceHooks?(workspacePath: string, config: WorkspaceHooksConfig): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AgentLaunchConfig {
|
||||
|
|
@ -262,13 +209,6 @@ export interface AgentLaunchConfig {
|
|||
model?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceHooksConfig {
|
||||
/** Data directory where session metadata files are stored */
|
||||
dataDir: string;
|
||||
/** Optional session ID (may not be known at ao init time) */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionInfo {
|
||||
/** Agent's auto-generated summary of what it's working on */
|
||||
summary: string | null;
|
||||
|
|
@ -472,13 +412,6 @@ export interface PRInfo {
|
|||
|
||||
export type PRState = "open" | "merged" | "closed";
|
||||
|
||||
/** PR state constants */
|
||||
export const PR_STATE = {
|
||||
OPEN: "open" as const,
|
||||
MERGED: "merged" as const,
|
||||
CLOSED: "closed" as const,
|
||||
} satisfies Record<string, PRState>;
|
||||
|
||||
export type MergeMethod = "merge" | "squash" | "rebase";
|
||||
|
||||
// --- CI Types ---
|
||||
|
|
@ -494,14 +427,6 @@ export interface CICheck {
|
|||
|
||||
export type CIStatus = "pending" | "passing" | "failing" | "none";
|
||||
|
||||
/** CI status constants */
|
||||
export const CI_STATUS = {
|
||||
PENDING: "pending" as const,
|
||||
PASSING: "passing" as const,
|
||||
FAILING: "failing" as const,
|
||||
NONE: "none" as const,
|
||||
} satisfies Record<string, CIStatus>;
|
||||
|
||||
// --- Review Types ---
|
||||
|
||||
export interface Review {
|
||||
|
|
|
|||
Loading…
Reference in New Issue