feat(core): round out session-manager activity event instrumentation (#1657)

PR #1620 wired activity events for the worker-spawn happy path
(spawn_started/spawned/spawn_failed/killed). This extends the same
pattern across the rest of session-manager's failure surfaces so RCA
can answer questions today's logs can't:

- Did the orchestrator spawn fail? (orchestrator path had no AE
  envelope at all; now wrapped like worker spawn)
- Did the agent's startup prompt deliver, or did all 3 retries fail?
- Did `runtime.destroy` / `workspace.destroy` succeed during kill, or
  silently leak?
- Did `runtime_lost` reconciliation fire for this session?
- Did `ao send` actually deliver?

New event kinds (added to ActivityEventKind union):
  session.kill_started, session.prompt_delivery_failed, session.send_failed,
  session.restore_failed, session.restore_fallback, session.rollback_started,
  session.rollback_step_failed, session.workspace_hooks_failed,
  session.cleanup_error, session.orchestrator_conflict,
  runtime.lost_detected, runtime.lost_persist_failed, runtime.destroy_failed,
  workspace.destroy_failed, agent.opencode_purge_failed,
  tracker.issue_fetch_failed, tracker.generate_prompt_failed,
  metadata.corrupt_detected

Coverage (per #1657 spec):
- All 8 MUST emits implemented and have a regression test in
  session-manager-instrumentation.test.ts
- 13 SHOULD emits implemented (cleanup loop, orchestrator subpaths,
  kill silent catches, send/restore failure paths, etc.)
- 4 COULD emits implemented
- Out-of-scope per-list enrichment paths intentionally NOT instrumented

Invariants preserved (extends #1620's B1-B16):
- B1: state mutation BEFORE event emission (runtime_lost persist
  happens before the AE fires; verified by test)
- B2: never wrap recordActivityEvent in try/catch
- B11: prompt content excluded from prompt_delivery_failed data
  (verified by test that asserts the prompt string is absent)
- B16: failure-only — emit on final retry exhaustion only, not per
  attempt
- B25 (new): cleanup-stack rollbacks emit per-step via the onError
  callback hook, not in aggregate

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dhruv Sharma 2026-05-07 12:07:40 +05:30
parent a33b2ba0ef
commit 43fca94099
4 changed files with 932 additions and 34 deletions

View File

@ -0,0 +1,442 @@
/**
* Regression tests for session-manager activity event instrumentation
* (issue #1657 extends PR #1620 to cover the rest of the failure paths
* inside spawn / kill / restore / send / list).
*
* One test per MUST-class emit. Pattern follows the lifecycle-manager-
* instrumentation tests: mock `recordActivityEvent`, drive the manager into
* the failure path, then assert the right kind/level/data was logged.
*
* Invariants asserted by these tests (PR #1620 B1/B2 plus #1657 B25):
* - state mutation happens BEFORE event emission
* - failure-only emits no event on a successful send/spawn
* - cleanup-stack rollbacks emit per failed step (not in aggregate)
* - data payload omits prompt content (`session.prompt_delivery_failed`)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { createSessionManager } from "../session-manager.js";
import { writeMetadata, readMetadataRaw } from "../metadata.js";
import { recordActivityEvent } from "../activity-events.js";
import type { OrchestratorConfig, PluginRegistry, Agent } from "../types.js";
import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "./test-utils.js";
vi.mock("../activity-events.js", () => ({
recordActivityEvent: vi.fn(),
}));
let ctx: TestContext;
let sessionsDir: string;
let mockRegistry: PluginRegistry;
let config: OrchestratorConfig;
beforeEach(() => {
ctx = setupTestContext();
({ sessionsDir, mockRegistry, config } = ctx);
vi.mocked(recordActivityEvent).mockClear();
});
afterEach(() => {
teardownTestContext(ctx);
vi.restoreAllMocks();
vi.useRealTimers();
});
function findEvent(kind: string) {
return vi
.mocked(recordActivityEvent)
.mock.calls.map((c) => c[0])
.find((e) => e.kind === kind);
}
function findAllEvents(kind: string) {
return vi
.mocked(recordActivityEvent)
.mock.calls.map((c) => c[0])
.filter((e) => e.kind === kind);
}
describe("session.kill_started (MUST)", () => {
it("emits before runtime.destroy is attempted", async () => {
let destroyCalled = false;
let killStartedEmittedBeforeDestroy = false;
vi.mocked(ctx.mockRuntime.destroy).mockImplementation(async () => {
destroyCalled = true;
killStartedEmittedBeforeDestroy = !!findEvent("session.kill_started");
});
writeMetadata(sessionsDir, "app-killed", {
worktree: "/tmp/ws",
branch: "main",
status: "working",
project: "my-app",
runtimeHandle: makeHandle("rt-1"),
});
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.kill("app-killed");
expect(destroyCalled).toBe(true);
expect(killStartedEmittedBeforeDestroy).toBe(true);
const start = findEvent("session.kill_started");
expect(start).toMatchObject({
projectId: "my-app",
sessionId: "app-killed",
source: "session-manager",
kind: "session.kill_started",
});
});
it("does not emit kill_started when session is already terminated (idempotent)", async () => {
writeMetadata(sessionsDir, "app-already-killed", {
worktree: "/tmp/ws",
branch: "main",
status: "killed",
project: "my-app",
lifecycle: {
version: 2,
session: {
kind: "worker",
state: "terminated",
reason: "manually_killed",
startedAt: "2025-01-01T00:00:00.000Z",
completedAt: null,
terminatedAt: "2025-01-01T00:00:00.000Z",
lastTransitionAt: "2025-01-01T00:00:00.000Z",
},
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
runtime: {
state: "missing",
reason: "manual_kill_requested",
lastObservedAt: "2025-01-01T00:00:00.000Z",
handle: null,
tmuxName: null,
},
},
});
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.kill("app-already-killed");
expect(findEvent("session.kill_started")).toBeUndefined();
});
});
describe("session.prompt_delivery_failed (MUST)", () => {
it("emits after all 3 retries exhaust, omitting prompt content", async () => {
vi.useFakeTimers();
const promptDeliveryAgent: Agent = {
name: "prompt-delivery-agent",
processName: "pda",
promptDelivery: "post-launch",
getLaunchCommand: vi.fn().mockReturnValue("agent --start"),
getEnvironment: vi.fn().mockReturnValue({}),
detectActivity: vi.fn().mockReturnValue("active"),
getActivityState: vi.fn().mockResolvedValue({ state: "active" }),
isProcessRunning: vi.fn().mockResolvedValue(true),
getSessionInfo: vi.fn().mockResolvedValue(null),
};
const originalGet = mockRegistry.get;
mockRegistry.get = vi.fn().mockImplementation((slot: string, name?: string) => {
if (slot === "agent" && (!name || name === "prompt-delivery-agent")) return promptDeliveryAgent;
return (originalGet as any)(slot, name);
});
config.projects["my-app"]!.agent = "prompt-delivery-agent";
vi.mocked(ctx.mockRuntime.sendMessage).mockRejectedValue(new Error("PTY closed"));
const sm = createSessionManager({ config, registry: mockRegistry });
const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "secret task content" });
await vi.runAllTimersAsync();
await spawnPromise;
const event = findEvent("session.prompt_delivery_failed");
expect(event).toBeDefined();
expect(event!.level).toBe("error");
expect(event!.source).toBe("session-manager");
expect(event!.data).toMatchObject({ attempts: 3 });
expect(JSON.stringify(event!.data ?? {})).not.toContain("secret task content");
});
it("does not emit when prompt delivery succeeds on first attempt", async () => {
vi.useFakeTimers();
const promptDeliveryAgent: Agent = {
name: "prompt-delivery-agent",
processName: "pda",
promptDelivery: "post-launch",
getLaunchCommand: vi.fn().mockReturnValue("agent --start"),
getEnvironment: vi.fn().mockReturnValue({}),
detectActivity: vi.fn().mockReturnValue("active"),
getActivityState: vi.fn().mockResolvedValue({ state: "active" }),
isProcessRunning: vi.fn().mockResolvedValue(true),
getSessionInfo: vi.fn().mockResolvedValue(null),
};
const originalGet = mockRegistry.get;
mockRegistry.get = vi.fn().mockImplementation((slot: string, name?: string) => {
if (slot === "agent" && (!name || name === "prompt-delivery-agent")) return promptDeliveryAgent;
return (originalGet as any)(slot, name);
});
config.projects["my-app"]!.agent = "prompt-delivery-agent";
const sm = createSessionManager({ config, registry: mockRegistry });
const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "task" });
await vi.runAllTimersAsync();
await spawnPromise;
expect(findEvent("session.prompt_delivery_failed")).toBeUndefined();
});
});
describe("session.spawn_failed — orchestrator path (MUST)", () => {
it("emits when spawnOrchestrator's workspace.create throws", async () => {
vi.mocked(ctx.mockWorkspace.create).mockRejectedValue(new Error("disk full"));
const sm = createSessionManager({ config, registry: mockRegistry });
await expect(
sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }),
).rejects.toThrow("disk full");
const events = findAllEvents("session.spawn_failed");
expect(events.length).toBeGreaterThanOrEqual(1);
const orchestratorFailure = events.find((e) => e.data && (e.data as Record<string, unknown>)["role"] === "orchestrator");
expect(orchestratorFailure).toBeDefined();
expect(orchestratorFailure!.level).toBe("error");
expect(orchestratorFailure!.projectId).toBe("my-app");
});
it("emits when spawnOrchestrator's runtime.create throws", async () => {
vi.mocked(ctx.mockRuntime.create).mockRejectedValue(new Error("tmux not found"));
const sm = createSessionManager({ config, registry: mockRegistry });
await expect(
sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }),
).rejects.toThrow("tmux not found");
const events = findAllEvents("session.spawn_failed");
const orchestratorFailure = events.find((e) => e.data && (e.data as Record<string, unknown>)["role"] === "orchestrator");
expect(orchestratorFailure).toBeDefined();
expect(orchestratorFailure!.level).toBe("error");
});
});
describe("session.workspace_hooks_failed (MUST)", () => {
it("emits when setupWorkspaceHooks throws during orchestrator spawn", async () => {
const hookFailingAgent: Agent = {
...ctx.mockAgent,
name: "hook-failing-agent",
setupWorkspaceHooks: vi.fn().mockRejectedValue(new Error("settings.json EACCES")),
};
const originalGet = mockRegistry.get;
mockRegistry.get = vi.fn().mockImplementation((slot: string, name?: string) => {
if (slot === "agent") return hookFailingAgent;
return (originalGet as any)(slot, name);
});
const sm = createSessionManager({ config, registry: mockRegistry });
await expect(
sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }),
).rejects.toThrow("settings.json EACCES");
const event = findEvent("session.workspace_hooks_failed");
expect(event).toBeDefined();
expect(event!.level).toBe("error");
expect(event!.projectId).toBe("my-app");
});
});
describe("runtime.lost_detected (MUST)", () => {
it("emits when sm.list() persists runtime_lost for a dead runtime", async () => {
writeMetadata(sessionsDir, "app-dead", {
worktree: "/tmp/ws",
branch: "main",
status: "working",
project: "my-app",
runtimeHandle: makeHandle("rt-dead"),
});
// Runtime claims dead, agent process gone
vi.mocked(ctx.mockRuntime.isAlive).mockResolvedValue(false);
vi.mocked(ctx.mockAgent.isProcessRunning).mockResolvedValue(false);
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.list();
const event = findEvent("runtime.lost_detected");
expect(event).toBeDefined();
expect(event!.projectId).toBe("my-app");
expect(event!.sessionId).toBe("app-dead");
expect(event!.level).toBe("warn");
// B1: state mutation BEFORE event emission — verify metadata was persisted
const persisted = readMetadataRaw(sessionsDir, "app-dead");
expect(persisted).not.toBeNull();
const lifecycleStr = persisted!["lifecycle"];
expect(lifecycleStr).toBeDefined();
const lc = JSON.parse(lifecycleStr!) as { session: { state: string; reason: string } };
expect(lc.session.state).toBe("terminated");
expect(lc.session.reason).toBe("runtime_lost");
});
});
describe("session.send_failed (MUST)", () => {
it("emits after send retry-with-restore exhausts", async () => {
writeMetadata(sessionsDir, "app-send", {
worktree: "/tmp/ws",
branch: "main",
status: "killed",
project: "my-app",
runtimeHandle: makeHandle("rt-1"),
lifecycle: {
version: 2,
session: {
kind: "worker",
state: "terminated",
reason: "manually_killed",
startedAt: "2025-01-01T00:00:00.000Z",
completedAt: null,
terminatedAt: "2025-01-01T00:00:00.000Z",
lastTransitionAt: "2025-01-01T00:00:00.000Z",
},
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
runtime: {
state: "missing",
reason: "process_missing",
lastObservedAt: "2025-01-01T00:00:00.000Z",
handle: null,
tmuxName: null,
},
},
});
vi.mocked(ctx.mockRuntime.sendMessage).mockRejectedValue(new Error("send broke"));
// restore() throws so retry-with-restore exhausts
vi.mocked(ctx.mockRuntime.create).mockRejectedValue(new Error("restore failed"));
const sm = createSessionManager({ config, registry: mockRegistry });
await expect(sm.send("app-send", "hi")).rejects.toThrow();
const event = findEvent("session.send_failed");
expect(event).toBeDefined();
expect(event!.level).toBe("error");
expect(event!.sessionId).toBe("app-send");
// B11: data must not contain message content
expect(JSON.stringify(event!.data ?? {})).not.toContain("hi");
});
});
describe("session.restore_failed (MUST)", () => {
it("emits when restore's workspace restore throws", async () => {
const wsPath = join(ctx.tmpDir, "missing-ws");
writeMetadata(sessionsDir, "app-rest", {
worktree: wsPath,
branch: "feat/x",
status: "killed",
project: "my-app",
runtimeHandle: makeHandle("rt-old"),
lifecycle: {
version: 2,
session: {
kind: "worker",
state: "terminated",
reason: "manually_killed",
startedAt: "2025-01-01T00:00:00.000Z",
completedAt: null,
terminatedAt: "2025-01-01T00:00:00.000Z",
lastTransitionAt: "2025-01-01T00:00:00.000Z",
},
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
runtime: {
state: "missing",
reason: "process_missing",
lastObservedAt: "2025-01-01T00:00:00.000Z",
handle: null,
tmuxName: null,
},
},
});
// workspace doesn't exist + restore throws
vi.mocked(ctx.mockWorkspace.exists ?? (() => false)).mockResolvedValue?.(false);
ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false);
ctx.mockWorkspace.restore = vi.fn().mockRejectedValue(new Error("clone failed"));
const sm = createSessionManager({ config, registry: mockRegistry });
await expect(sm.restore("app-rest")).rejects.toThrow();
const event = findEvent("session.restore_failed");
expect(event).toBeDefined();
expect(event!.level).toBe("error");
expect(event!.sessionId).toBe("app-rest");
});
it("emits SessionNotRestorableError when session is not restorable", async () => {
writeMetadata(sessionsDir, "app-not-rest", {
worktree: "/tmp/ws",
branch: "feat/x",
status: "working",
project: "my-app",
runtimeHandle: makeHandle("rt-1"),
lifecycle: {
version: 2,
session: {
kind: "worker",
state: "working",
reason: "task_in_progress",
startedAt: "2025-01-01T00:00:00.000Z",
completedAt: null,
terminatedAt: null,
lastTransitionAt: "2025-01-01T00:00:00.000Z",
},
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
runtime: {
state: "alive",
reason: "process_running",
lastObservedAt: "2025-01-01T00:00:00.000Z",
handle: makeHandle("rt-1"),
tmuxName: null,
},
},
});
// active session — not restorable
vi.mocked(ctx.mockRuntime.isAlive).mockResolvedValue(true);
vi.mocked(ctx.mockAgent.isProcessRunning).mockResolvedValue(true);
const sm = createSessionManager({ config, registry: mockRegistry });
await expect(sm.restore("app-not-rest")).rejects.toThrow();
const event = findEvent("session.restore_failed");
expect(event).toBeDefined();
});
});
describe("metadata.corrupt_detected (MUST)", () => {
it("emits when mutateMetadata side-renames a corrupt file", async () => {
// Simulate a corrupt metadata file in the sessions dir
const sessionPath = join(sessionsDir, "app-corrupt.json");
mkdirSync(sessionsDir, { recursive: true });
writeFileSync(sessionPath, "{ this is not json", "utf-8");
const { mutateMetadata } = await import("../metadata.js");
mutateMetadata(
sessionsDir,
"app-corrupt",
() => ({ branch: "feat/x", project: "my-app" }),
{ createIfMissing: true },
);
const event = findEvent("metadata.corrupt_detected");
expect(event).toBeDefined();
expect(event!.level).toBe("warn");
expect(event!.source).toBe("session-manager");
expect(event!.sessionId).toBe("app-corrupt");
const data = event!.data as Record<string, unknown>;
expect(data["renamed"]).toBe(true);
expect(data["corruptPath"]).toMatch(/\.corrupt-\d+$/);
});
});

View File

@ -28,6 +28,24 @@ export type ActivityEventKind =
| "session.spawned"
| "session.spawn_failed"
| "session.killed"
| "session.kill_started"
| "session.prompt_delivery_failed"
| "session.send_failed"
| "session.restore_failed"
| "session.restore_fallback"
| "session.rollback_started"
| "session.rollback_step_failed"
| "session.workspace_hooks_failed"
| "session.cleanup_error"
| "session.orchestrator_conflict"
| "runtime.lost_detected"
| "runtime.lost_persist_failed"
| "runtime.destroy_failed"
| "workspace.destroy_failed"
| "agent.opencode_purge_failed"
| "tracker.issue_fetch_failed"
| "tracker.generate_prompt_failed"
| "metadata.corrupt_detected"
| "activity.transition"
| "lifecycle.transition"
| "ci.failing"

View File

@ -35,6 +35,7 @@ import { assertValidSessionIdComponent, SESSION_ID_COMPONENT_PATTERN } from "./u
import { flattenToStringRecord } from "./utils/metadata-flatten.js";
import { validateStatus } from "./utils/validation.js";
import { withFileLockSync } from "./file-lock.js";
import { recordActivityEvent } from "./activity-events.js";
const JSON_EXTENSION = ".json";
@ -356,8 +357,10 @@ export function mutateMetadata(
// that anything was wrong — the file just becomes "not
// corrupt anymore — and missing fields".
const corruptPath = `${path}.corrupt-${Date.now()}`;
let renamed = false;
try {
renameSync(path, corruptPath);
renamed = true;
// eslint-disable-next-line no-console
console.warn(
`[metadata] corrupt JSON at ${path}; preserved as ${corruptPath} before rewriting`,
@ -365,6 +368,22 @@ export function mutateMetadata(
} catch {
// best effort — proceed even if the rename fails (e.g. EACCES)
}
// Surface forensically — the original `console.warn` is invisible
// to anyone not tailing the orchestrator log. AE makes corrupt
// detection queryable.
recordActivityEvent({
sessionId,
source: "session-manager",
kind: "metadata.corrupt_detected",
level: "warn",
summary: `corrupt metadata JSON detected: ${sessionId}`,
data: {
path,
corruptPath: renamed ? corruptPath : null,
renamed,
bytes: content.length,
},
});
}
}
} else if (!options.createIfMissing) {

View File

@ -1168,6 +1168,18 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// Branch will be generated as feat/{issueId} (line 329-331)
} else {
// Other error (auth, network, etc) - fail fast
recordActivityEvent({
projectId: spawnConfig.projectId,
source: "session-manager",
kind: "tracker.issue_fetch_failed",
level: "error",
summary: `tracker getIssue failed for ${spawnConfig.issueId}`,
data: {
issueId: spawnConfig.issueId,
tracker: plugins.tracker.name,
reason: err instanceof Error ? err.message : String(err),
},
});
throw new Error(`Failed to fetch issue ${spawnConfig.issueId}: ${err}`, { cause: err });
}
}
@ -1242,9 +1254,23 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
if (spawnConfig.issueId && plugins.tracker && resolvedIssue) {
try {
issueContext = await plugins.tracker.generatePrompt(spawnConfig.issueId, project);
} catch {
// Non-fatal: continue without detailed issue context
// Silently ignore errors - caller can check if issueContext is undefined
} catch (err) {
// Non-fatal: continue without detailed issue context. Surface the
// failure via AE so RCA can answer "did the agent get an enriched
// prompt or just the bare issue ID?"
recordActivityEvent({
projectId: spawnConfig.projectId,
sessionId,
source: "session-manager",
kind: "tracker.generate_prompt_failed",
level: "warn",
summary: `tracker generatePrompt failed for ${spawnConfig.issueId}`,
data: {
issueId: spawnConfig.issueId,
tracker: plugins.tracker.name,
reason: err instanceof Error ? err.message : String(err),
},
});
}
}
@ -1437,8 +1463,73 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// final form. Dismiss the stack so nothing below can trigger a rollback.
cleanupStack.dismiss();
// Prompt is delivered inline via the agent's launch command (positional argument).
// No post-launch polling needed — the prompt is part of process invocation.
// 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;
let promptLastError: Error | undefined;
const maxRetries = 3;
if (plugins.agent.promptDelivery === "post-launch" && agentLaunchConfig.prompt) {
const baseDelayMs = 3_000;
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) {
promptLastError = err instanceof Error ? err : new Error(String(err));
console.error(
`[session-manager] Prompt delivery attempt ${attempt}/${maxRetries} failed: ${promptLastError.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: ${promptLastError?.message}`,
);
}
session.metadata["promptDelivered"] = String(promptDelivered);
} else if (agentLaunchConfig.prompt) {
session.metadata["promptDelivered"] = "true";
}
if (session.metadata["promptDelivered"]) {
updateMetadata(sessionsDir, sessionId, session.metadata);
invalidateCache();
}
// Emit failure-only AE after metadata has been persisted (B1) and only
// when all retries exhausted (B16). Prompt content is intentionally
// omitted from `data` because user prompts may include free-form text
// (B11 — see #1620 review).
if (
plugins.agent.promptDelivery === "post-launch" &&
agentLaunchConfig.prompt &&
!promptDelivered
) {
recordActivityEvent({
projectId: spawnConfig.projectId,
sessionId,
source: "session-manager",
kind: "session.prompt_delivery_failed",
level: "error",
summary: `prompt delivery failed after ${maxRetries} retries: ${sessionId}`,
data: {
attempts: maxRetries,
agent: plugins.agent.name,
lastError: promptLastError?.message,
},
});
}
recordActivityEvent({
projectId: spawnConfig.projectId,
sessionId,
@ -1453,14 +1544,59 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// Log cleanup failures so they don't disappear silently. The original
// code used /* best effort */ swallows; the stack preserves that
// behavior (cleanup errors don't propagate) but surfaces them for debug.
recordActivityEvent({
projectId: spawnConfig.projectId,
source: "session-manager",
kind: "session.rollback_started",
level: "warn",
summary: "spawn rollback started",
data: { reason: err instanceof Error ? err.message : String(err) },
});
await cleanupStack.runAll((cleanupErr) => {
console.error("[session-manager] spawn rollback step failed:", cleanupErr);
// B25: emit per-step rollback failure so each leaked resource is queryable.
recordActivityEvent({
projectId: spawnConfig.projectId,
source: "session-manager",
kind: "session.rollback_step_failed",
level: "error",
summary: "spawn rollback step failed",
data: {
reason: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
},
});
});
throw err;
}
}
async function spawnOrchestrator(orchestratorConfig: OrchestratorSpawnConfig): Promise<Session> {
recordActivityEvent({
projectId: orchestratorConfig.projectId,
source: "session-manager",
kind: "session.spawn_started",
summary: "orchestrator spawn started",
data: { agent: orchestratorConfig.agent ?? undefined, role: "orchestrator" },
});
try {
return await _spawnOrchestratorInner(orchestratorConfig);
} catch (err) {
recordActivityEvent({
projectId: orchestratorConfig.projectId,
source: "session-manager",
kind: "session.spawn_failed",
level: "error",
summary: "orchestrator spawn failed",
data: {
role: "orchestrator",
reason: err instanceof Error ? err.message : String(err),
},
});
throw err;
}
}
async function _spawnOrchestratorInner(orchestratorConfig: OrchestratorSpawnConfig): Promise<Session> {
const project = config.projects[orchestratorConfig.projectId];
if (!project) {
throw new Error(`Unknown project: ${orchestratorConfig.projectId}`);
@ -1521,6 +1657,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
workspacePath = wsInfo.path;
adoptedManagedWorkspace = adoptedInfo !== undefined && adoptedInfo !== null;
} catch (err) {
recordActivityEvent({
projectId: orchestratorConfig.projectId,
sessionId,
source: "session-manager",
kind: "session.spawn_failed",
level: "error",
summary: "orchestrator workspace.create failed",
data: {
role: "orchestrator",
stage: "workspace_create",
reason: err instanceof Error ? err.message : String(err),
},
});
try {
deleteMetadata(sessionsDir, sessionId);
} catch {
@ -1566,6 +1715,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
await setupPathWrapperWorkspace(workspacePath);
}
} catch (err) {
// PR tracking and CI fetch hooks are wired here — emit a dedicated AE
// before rolling back so RCA can answer "did the orchestrator launch
// succeed but lose its hook integration?".
recordActivityEvent({
projectId: orchestratorConfig.projectId,
sessionId,
source: "session-manager",
kind: "session.workspace_hooks_failed",
level: "error",
summary: "orchestrator workspace hooks installation failed",
data: {
agent: plugins.agent.name,
reason: err instanceof Error ? err.message : String(err),
},
});
await cleanupWorktreeAndMetadata();
throw err;
}
@ -1581,6 +1745,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
systemPromptFile = join(projectDir, `orchestrator-prompt-${sessionId}.md`);
writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8");
} catch (err) {
recordActivityEvent({
projectId: orchestratorConfig.projectId,
sessionId,
source: "session-manager",
kind: "session.spawn_failed",
level: "error",
summary: "orchestrator systemPrompt write failed",
data: {
role: "orchestrator",
stage: "system_prompt_write",
reason: err instanceof Error ? err.message : String(err),
},
});
await cleanupWorktreeAndMetadata(systemPromptFile);
throw err;
}
@ -1590,6 +1767,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
try {
writeWorkspaceOpenCodeAgentsMd(workspacePath, systemPromptFile);
} catch (err) {
recordActivityEvent({
projectId: orchestratorConfig.projectId,
sessionId,
source: "session-manager",
kind: "session.spawn_failed",
level: "error",
summary: "orchestrator AGENTS.md write failed",
data: {
role: "orchestrator",
stage: "agents_md_write",
reason: err instanceof Error ? err.message : String(err),
},
});
await cleanupWorktreeAndMetadata(systemPromptFile);
throw err;
}
@ -1614,6 +1804,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
});
}
} catch (err) {
recordActivityEvent({
projectId: orchestratorConfig.projectId,
sessionId,
source: "session-manager",
kind: "session.spawn_failed",
level: "error",
summary: "orchestrator opencode session resolution failed",
data: {
role: "orchestrator",
stage: "opencode_session_reuse",
reason: err instanceof Error ? err.message : String(err),
},
});
await cleanupWorktreeAndMetadata(systemPromptFile);
throw err;
}
@ -1671,6 +1874,22 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
},
});
} catch (err) {
// Outer envelope catches and emits session.spawn_failed; this inner emit
// tags the runtime.create failure path specifically so RCA can answer
// "did the orchestrator runtime fail to start at all?".
recordActivityEvent({
projectId: orchestratorConfig.projectId,
sessionId,
source: "session-manager",
kind: "session.spawn_failed",
level: "error",
summary: "orchestrator runtime.create failed",
data: {
role: "orchestrator",
stage: "runtime_create",
reason: err instanceof Error ? err.message : String(err),
},
});
await cleanupWorktreeAndMetadata(systemPromptFile);
throw err;
}
@ -1758,6 +1977,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
}
invalidateCache();
} catch (err) {
recordActivityEvent({
projectId: orchestratorConfig.projectId,
sessionId,
source: "session-manager",
kind: "session.spawn_failed",
level: "error",
summary: "orchestrator post-launch metadata write failed",
data: {
role: "orchestrator",
stage: "post_launch_metadata",
reason: err instanceof Error ? err.message : String(err),
},
});
// Clean up runtime on post-launch failure
try {
await plugins.runtime.destroy(handle);
@ -1828,6 +2060,16 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
throw err;
}
recordActivityEvent({
projectId: orchestratorConfig.projectId,
sessionId,
source: "session-manager",
kind: "session.orchestrator_conflict",
level: "warn",
summary: "concurrent orchestrator reservation conflict",
data: { reason: err instanceof Error ? err.message : String(err) },
});
const concurrent = await waitForConcurrentOrchestrator(sessionId);
if (concurrent) return concurrent;
throw err;
@ -1927,21 +2169,45 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
session.lifecycle.session.state !== "terminated" &&
session.lifecycle.session.state !== "done"
) {
const runtimeStateBefore = session.lifecycle.runtime.state;
const runtimeReasonBefore = session.lifecycle.runtime.reason;
try {
const persisted = buildUpdatedLifecycle(sessionName, raw, (next) => {
next.session.state = "terminated";
next.session.reason = "runtime_lost";
next.session.terminatedAt = new Date().toISOString();
next.session.lastTransitionAt = next.session.terminatedAt;
next.runtime.state = session.lifecycle!.runtime.state;
next.runtime.reason = session.lifecycle!.runtime.reason;
next.runtime.state = runtimeStateBefore;
next.runtime.reason = runtimeReasonBefore;
next.runtime.lastObservedAt = new Date().toISOString();
});
// B1: persist BEFORE emitting the event
updateMetadata(sessionsDir, sessionName, lifecycleMetadataUpdates(raw, persisted));
session.lifecycle = persisted;
session.status = deriveLegacyStatus(persisted);
} catch {
recordActivityEvent({
projectId: sessionProjectId,
sessionId: sessionName,
source: "session-manager",
kind: "runtime.lost_detected",
level: "warn",
summary: `runtime lost reconciled: ${sessionName}`,
data: {
runtimeState: runtimeStateBefore,
runtimeReason: runtimeReasonBefore,
},
});
} catch (err) {
// Persist failed — in-memory state is still correct for this request
recordActivityEvent({
projectId: sessionProjectId,
sessionId: sessionName,
source: "session-manager",
kind: "runtime.lost_persist_failed",
level: "error",
summary: `runtime_lost persist failed: ${sessionName}`,
data: { reason: err instanceof Error ? err.message : String(err) },
});
}
}
@ -2049,6 +2315,17 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
const killReason: LifecycleKillReason = options?.reason ?? "manually_killed";
const cleanupAgent = resolveSelectionForSession(project, sessionId, raw).agentName;
// Emit kill_started up-front — this is the only signal that the kill
// intent reached the manager (the destroys below are silent on failure).
recordActivityEvent({
projectId,
sessionId,
source: "session-manager",
kind: "session.kill_started",
summary: `kill started: ${sessionId}`,
data: { reason: killReason },
});
// Destroy runtime — prefer handle.runtimeName to find the correct plugin
if (raw["runtimeHandle"]) {
const handle = safeJsonParse<RuntimeHandle>(raw["runtimeHandle"]);
@ -2061,8 +2338,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
if (runtimePlugin) {
try {
await runtimePlugin.destroy(handle);
} catch {
// Runtime might already be gone
} catch (err) {
// Runtime might already be gone — surface as AE so leaks are queryable.
recordActivityEvent({
projectId,
sessionId,
source: "session-manager",
kind: "runtime.destroy_failed",
level: "warn",
summary: `runtime.destroy failed during kill: ${sessionId}`,
data: {
runtime: handle.runtimeName ?? null,
reason: err instanceof Error ? err.message : String(err),
},
});
}
}
}
@ -2076,8 +2365,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
if (workspacePlugin) {
try {
await workspacePlugin.destroy(worktree);
} catch {
// Workspace might already be gone
} catch (err) {
// Workspace might already be gone — emit AE so abandoned worktrees
// surface for cleanup tooling.
recordActivityEvent({
projectId,
sessionId,
source: "session-manager",
kind: "workspace.destroy_failed",
level: "warn",
summary: `workspace.destroy failed during kill: ${sessionId}`,
data: {
workspace: workspacePlugin.name,
reason: err instanceof Error ? err.message : String(err),
},
});
}
}
}
@ -2095,8 +2397,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
try {
await deleteOpenCodeSession(mappedOpenCodeSessionId);
didPurgeOpenCodeSession = true;
} catch {
void 0;
} catch (err) {
// Dangling opencode session is a real leak — surface for RCA.
recordActivityEvent({
projectId,
sessionId,
source: "session-manager",
kind: "agent.opencode_purge_failed",
level: "warn",
summary: `opencode session purge failed: ${sessionId}`,
data: {
opencodeSessionId: mappedOpenCodeSessionId,
reason: err instanceof Error ? err.message : String(err),
},
});
}
}
}
@ -2229,9 +2543,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
pushSkipped(session.projectId, session.id);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
result.errors.push({
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
error: errorMessage,
});
recordActivityEvent({
projectId: session.projectId,
sessionId: session.id,
source: "session-manager",
kind: "session.cleanup_error",
level: "warn",
summary: `cleanup error: ${session.id}`,
data: { reason: errorMessage },
});
}
}
@ -2275,9 +2599,22 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
opencodeCleanedAt: new Date().toISOString(),
}));
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
result.errors.push({
sessionId: terminatedId,
error: `Failed to delete OpenCode session ${mappedOpenCodeSessionId}: ${err instanceof Error ? err.message : String(err)}`,
error: `Failed to delete OpenCode session ${mappedOpenCodeSessionId}: ${errorMessage}`,
});
recordActivityEvent({
projectId: projectKey,
sessionId: terminatedId,
source: "session-manager",
kind: "agent.opencode_purge_failed",
level: "warn",
summary: `opencode session purge failed during cleanup: ${terminatedId}`,
data: {
opencodeSessionId: mappedOpenCodeSessionId,
reason: errorMessage,
},
});
continue;
}
@ -2308,7 +2645,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
}
async function send(sessionId: SessionId, message: string): Promise<void> {
const { raw, sessionsDir, project } = requireSessionRecord(sessionId);
const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId);
const selection = resolveSelectionForSession(project, sessionId, raw);
const selectedAgent = selection.agentName;
@ -2469,6 +2806,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
return restored;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
recordActivityEvent({
projectId,
sessionId,
source: "session-manager",
kind: "session.restore_failed",
level: "error",
summary: `restore for delivery failed: ${sessionId}`,
data: { reason: detail, trigger: "send" },
});
throw new Error(`Cannot send to session ${sessionId}: ${reason} (${detail})`, {
cause: err,
});
@ -2564,29 +2910,52 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
return;
};
let prepared = await prepareSession();
// Top-level try/catch: any final send failure (initial preparation,
// retry-with-restore, etc.) emits a single `session.send_failed` event
// (B16 — failure-only). Stage tag distinguishes which branch failed.
let stage: "prepare" | "initial" | "restore_retry" = "prepare";
try {
await sendWithConfirmation(prepared);
} catch (err) {
const shouldRetryWithRestore = prepared.restoredAt === undefined && isRestorable(prepared);
let prepared = await prepareSession();
if (!shouldRetryWithRestore) {
if (err instanceof Error) {
throw err;
}
throw new Error(String(err), { cause: err });
}
prepared = await prepareSession(true);
try {
stage = "initial";
await sendWithConfirmation(prepared);
} catch (retryErr) {
if (retryErr instanceof Error) {
throw retryErr;
} catch (err) {
const shouldRetryWithRestore =
prepared.restoredAt === undefined && isRestorable(prepared);
if (!shouldRetryWithRestore) {
if (err instanceof Error) {
throw err;
}
throw new Error(String(err), { cause: err });
}
stage = "restore_retry";
prepared = await prepareSession(true);
try {
await sendWithConfirmation(prepared);
} catch (retryErr) {
if (retryErr instanceof Error) {
throw retryErr;
}
throw new Error(String(retryErr), { cause: retryErr });
}
throw new Error(String(retryErr), { cause: retryErr });
}
} catch (err) {
recordActivityEvent({
projectId,
sessionId,
source: "session-manager",
kind: "session.send_failed",
level: "error",
summary: `send failed: ${sessionId}`,
data: {
stage,
reason: err instanceof Error ? err.message : String(err),
},
});
throw err;
}
}
@ -2779,6 +3148,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
const reason = NON_RESTORABLE_STATUSES.has(session.status)
? `status "${session.status}" is not restorable`
: `session is not in a terminal state (status: "${session.status}", activity: "${session.activity}")`;
recordActivityEvent({
projectId,
sessionId,
source: "session-manager",
kind: "session.restore_failed",
level: "error",
summary: `restore not allowed: ${sessionId}`,
data: {
stage: "validation",
status: session.status,
activity: session.activity,
reason,
},
});
throw new SessionNotRestorableError(sessionId, reason);
}
@ -2821,6 +3204,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
await plugins.workspace.postCreate(wsInfo, project);
}
} catch (err) {
recordActivityEvent({
projectId,
sessionId,
source: "session-manager",
kind: "session.restore_failed",
level: "error",
summary: `workspace restore failed: ${sessionId}`,
data: {
stage: "workspace_restore",
workspacePath,
reason: err instanceof Error ? err.message : String(err),
},
});
throw new WorkspaceMissingError(
workspacePath,
`restore failed: ${err instanceof Error ? err.message : String(err)}`,
@ -2911,8 +3307,31 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
restoreFallbackReason: reason,
});
if (requiresNativeRestore(plugins.agent.name)) {
recordActivityEvent({
projectId,
sessionId,
source: "session-manager",
kind: "session.restore_failed",
level: "error",
summary: `native restore required but unavailable: ${sessionId}`,
data: {
stage: "native_restore_required",
agent: plugins.agent.name,
reason,
},
});
throw new SessionNotRestorableError(sessionId, reason);
}
// Surface that AO fell back to a fresh launch instead of native restore.
recordActivityEvent({
projectId,
sessionId,
source: "session-manager",
kind: "session.restore_fallback",
level: "warn",
summary: `using fresh launch instead of native restore: ${sessionId}`,
data: { agent: plugins.agent.name, reason },
});
launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig);
}
} else {