From c00ccc773ef1ec18ae21348e4fcd4a824f290c94 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sat, 18 Apr 2026 22:50:59 +0530 Subject: [PATCH 01/16] fix: serialize ao start and stop numbered orchestrators (#1306) --- packages/cli/src/lib/running-state.ts | 84 +++++++++++++++++++++------ 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/lib/running-state.ts b/packages/cli/src/lib/running-state.ts index 666a64780..797960e90 100644 --- a/packages/cli/src/lib/running-state.ts +++ b/packages/cli/src/lib/running-state.ts @@ -1,4 +1,12 @@ -import { readFileSync, writeFileSync, mkdirSync, unlinkSync, openSync, closeSync, constants } from "node:fs"; +import { + readFileSync, + writeFileSync, + mkdirSync, + unlinkSync, + openSync, + closeSync, + constants, +} from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { setTimeout as sleep } from "node:timers/promises"; @@ -13,7 +21,13 @@ export interface RunningState { const STATE_DIR = join(homedir(), ".agent-orchestrator"); const STATE_FILE = join(STATE_DIR, "running.json"); -const LOCK_FILE = join(STATE_DIR, "running.lock"); +const STATE_LOCK_FILE = join(STATE_DIR, "running.lock"); +const STARTUP_LOCK_FILE = join(STATE_DIR, "startup.lock"); + +interface LockMetadata { + pid: number; + acquiredAt: string; +} function ensureDir(): void { mkdirSync(STATE_DIR, { recursive: true }); @@ -28,13 +42,33 @@ function isProcessAlive(pid: number): boolean { } } -/** Try to create the lockfile atomically. Returns a release function on success, null on failure. */ -function tryAcquire(): (() => void) | null { +function readLockMetadata(lockFile: string): LockMetadata | null { try { - const fd = openSync(LOCK_FILE, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY); + const raw = readFileSync(lockFile, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.pid !== "number") return null; + return { + pid: parsed.pid, + acquiredAt: + typeof parsed.acquiredAt === "string" ? parsed.acquiredAt : new Date(0).toISOString(), + }; + } catch { + return null; + } +} + +/** Try to create the lockfile atomically. Returns a release function on success, null on failure. */ +function tryAcquire(lockFile: string): (() => void) | null { + try { + const fd = openSync(lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY); + const metadata: LockMetadata = { + pid: process.pid, + acquiredAt: new Date().toISOString(), + }; + writeFileSync(fd, JSON.stringify(metadata), "utf-8"); closeSync(fd); return () => { - try { unlinkSync(LOCK_FILE); } catch { /* best effort */ } + try { unlinkSync(lockFile); } catch { /* best effort */ } }; } catch { return null; @@ -43,25 +77,31 @@ function tryAcquire(): (() => void) | null { /** * Advisory lockfile using O_EXCL for atomic creation. - * Retries with jittered backoff. After timeout, assumes the lock is stale - * (holder crashed) and force-removes it before one final atomic attempt. + * Retries with jittered backoff. Dead owners are treated as stale and cleaned + * up automatically. Live owners are never stolen; callers get a clear timeout. */ -async function acquireLock(timeoutMs = 5000): Promise<() => void> { +async function acquireLock( + lockFile: string, + timeoutMs = 5000, + resourceName = "lock", +): Promise<() => void> { ensureDir(); const start = Date.now(); let attempt = 0; while (true) { - const release = tryAcquire(); + const release = tryAcquire(lockFile); if (release) return release; if (Date.now() - start > timeoutMs) { - // Likely stale — remove and make one final atomic attempt. - try { unlinkSync(LOCK_FILE); } catch { /* ignore */ } - const finalRelease = tryAcquire(); - if (finalRelease) return finalRelease; - throw new Error("Could not acquire running.json lock"); + const owner = readLockMetadata(lockFile); + if (!owner || !isProcessAlive(owner.pid)) { + try { unlinkSync(lockFile); } catch { /* ignore */ } + const finalRelease = tryAcquire(lockFile); + if (finalRelease) return finalRelease; + } + throw new Error(`Could not acquire ${resourceName}`); } // Jittered backoff: 30-70ms base, growing with attempts (capped at 200ms) @@ -97,7 +137,7 @@ function writeState(state: RunningState | null): void { * Uses a lockfile to prevent concurrent registration. */ export async function register(entry: RunningState): Promise { - const release = await acquireLock(); + const release = await acquireLock(STATE_LOCK_FILE, 5000, "running.json lock"); try { writeState(entry); } finally { @@ -109,7 +149,7 @@ export async function register(entry: RunningState): Promise { * Unregister the running AO instance. */ export async function unregister(): Promise { - const release = await acquireLock(); + const release = await acquireLock(STATE_LOCK_FILE, 5000, "running.json lock"); try { writeState(null); } finally { @@ -122,7 +162,7 @@ export async function unregister(): Promise { * Auto-prunes stale entries (dead PIDs). */ export async function getRunning(): Promise { - const release = await acquireLock(); + const release = await acquireLock(STATE_LOCK_FILE, 5000, "running.json lock"); try { const state = readState(); if (!state) return null; @@ -147,6 +187,14 @@ export async function isAlreadyRunning(): Promise { return getRunning(); } +/** + * Serialize `ao start` so concurrent startups cannot both observe an empty + * running.json and create competing orchestrator/dashboard processes. + */ +export async function acquireStartupLock(timeoutMs = 30000): Promise<() => void> { + return await acquireLock(STARTUP_LOCK_FILE, timeoutMs, "startup lock"); +} + /** * Wait for a process to exit, polling isProcessAlive. * Returns true if the process exited, false if timeout reached. From c6129de1c90b12e94444d0e3015a00d7df78ee93 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 00:30:02 +0530 Subject: [PATCH 02/16] fix: restore dead orchestrators on start (#1306) --- packages/cli/CHANGELOG.md | 6 ++ packages/cli/__tests__/commands/start.test.ts | 90 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 3ebefb021..bf1f80b5f 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,11 @@ # @composio/ao-cli +## [Unreleased] + +### Fixed + +- Restore the most recently active dead orchestrator on `ao start` when its tmux session is gone, instead of silently spawning a new orchestrator. + ## 0.2.2 ### Patch Changes diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 1eab54ec4..c78715386 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -30,6 +30,7 @@ const { mockConfigRef: { current: null as Record | null }, mockSessionManager: { list: vi.fn(), + restore: vi.fn(), kill: vi.fn(), cleanup: vi.fn(), get: vi.fn(), @@ -247,6 +248,8 @@ beforeEach(async () => { mockSessionManager.list.mockReset(); mockSessionManager.list.mockResolvedValue([]); + mockSessionManager.restore.mockReset(); + mockSessionManager.restore.mockResolvedValue({ id: "app-orchestrator-restored" }); mockSessionManager.get.mockReset(); mockSessionManager.spawnOrchestrator.mockReset(); mockSessionManager.restore.mockReset(); @@ -953,6 +956,93 @@ describe("start command — orchestrator session strategy display", () => { expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled(); }); + it("restores the latest restorable orchestrator when tmux is gone", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + + const now = new Date(); + mockSessionManager.list.mockResolvedValue([ + { + id: "app-orchestrator-1", + projectId: "my-app", + status: "killed", + activity: "exited", + metadata: { role: "orchestrator" }, + lastActivityAt: new Date(now.getTime() - 1000), + lifecycle: { + version: 2, + session: { + kind: "orchestrator", + state: "working", + reason: "task_in_progress", + startedAt: now.toISOString(), + completedAt: null, + terminatedAt: null, + lastTransitionAt: now.toISOString(), + }, + pr: { + state: "none", + reason: "not_created", + number: null, + url: null, + lastObservedAt: null, + }, + runtime: { + state: "missing", + reason: "tmux_missing", + lastObservedAt: now.toISOString(), + handle: null, + tmuxName: "tmux-old-1", + }, + }, + }, + { + id: "app-orchestrator-2", + projectId: "my-app", + status: "killed", + activity: "exited", + metadata: { role: "orchestrator" }, + lastActivityAt: now, + lifecycle: { + version: 2, + session: { + kind: "orchestrator", + state: "working", + reason: "task_in_progress", + startedAt: now.toISOString(), + completedAt: null, + terminatedAt: null, + lastTransitionAt: now.toISOString(), + }, + pr: { + state: "none", + reason: "not_created", + number: null, + url: null, + lastObservedAt: null, + }, + runtime: { + state: "missing", + reason: "tmux_missing", + lastObservedAt: now.toISOString(), + handle: null, + tmuxName: "tmux-old-2", + }, + }, + }, + ]); + mockSessionManager.restore.mockResolvedValue({ + id: "app-orchestrator-2", + runtimeHandle: { id: "tmux-restored-2" }, + }); + + await program.parseAsync(["node", "test", "start", "--no-dashboard"]); + + const output = getLoggedOutput(); + expect(output).toContain("ao session attach app-orchestrator-2"); + expect(mockSessionManager.restore).toHaveBeenCalledWith("app-orchestrator-2"); + expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled(); + }); + it("navigates directly to session page when one existing orchestrator found with dashboard enabled", async () => { mockConfigRef.current = makeConfig({ "my-app": makeProject() }); From 0481dbf158691609fc8e89af521e5e45f46da644 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 00:32:50 +0530 Subject: [PATCH 03/16] fix: harden startup lock handling (#1306) --- packages/cli/src/lib/running-state.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/lib/running-state.ts b/packages/cli/src/lib/running-state.ts index 797960e90..2aa6d68aa 100644 --- a/packages/cli/src/lib/running-state.ts +++ b/packages/cli/src/lib/running-state.ts @@ -37,7 +37,10 @@ function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; - } catch { + } catch (error: unknown) { + if ((error as { code?: string }).code === "EPERM") { + return true; + } return false; } } @@ -94,13 +97,14 @@ async function acquireLock( const release = tryAcquire(lockFile); if (release) return release; + const owner = readLockMetadata(lockFile); + if (!owner || !isProcessAlive(owner.pid)) { + try { unlinkSync(lockFile); } catch { /* ignore */ } + const retryRelease = tryAcquire(lockFile); + if (retryRelease) return retryRelease; + } + if (Date.now() - start > timeoutMs) { - const owner = readLockMetadata(lockFile); - if (!owner || !isProcessAlive(owner.pid)) { - try { unlinkSync(lockFile); } catch { /* ignore */ } - const finalRelease = tryAcquire(lockFile); - if (finalRelease) return finalRelease; - } throw new Error(`Could not acquire ${resourceName}`); } From 611ded42ca1d7e7ed9b83fe0b2f1501a942427ba Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 01:16:00 +0530 Subject: [PATCH 04/16] fix: prefer live orchestrators in worker navigation --- packages/web/CHANGELOG.md | 6 ++ packages/web/src/__tests__/api-routes.test.ts | 59 +++++++++++++++++++ packages/web/src/app/api/sessions/route.ts | 32 +++++++++- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 5df34b062..101e40670 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,11 @@ # @composio/ao-web +## [Unreleased] + +### Fixed + +- Make worker-session orchestrator navigation prefer the most recently active live orchestrator for the project instead of defaulting to the first sorted orchestrator id. + ## 0.2.2 ### Patch Changes diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 07b9097c4..bb225630b 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -336,6 +336,65 @@ describe("API Routes", () => { expect(mockSessionManager.listCached).toHaveBeenCalledWith("docs-app"); }); + it("prefers the most recently active live orchestrator for project-scoped worker navigation", async () => { + const deadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T11:00:00.000Z")); + deadLifecycle.session.state = "terminated"; + deadLifecycle.session.reason = "runtime_missing"; + deadLifecycle.session.terminatedAt = "2026-04-19T11:00:00.000Z"; + deadLifecycle.session.lastTransitionAt = "2026-04-19T11:00:00.000Z"; + deadLifecycle.runtime.state = "missing"; + deadLifecycle.runtime.reason = "process_missing"; + deadLifecycle.runtime.lastObservedAt = "2026-04-19T11:00:00.000Z"; + + const olderLive = makeSession({ + id: "my-app-orchestrator-1", + projectId: "my-app", + metadata: { role: "orchestrator" }, + lastActivityAt: new Date("2026-04-19T09:00:00.000Z"), + }); + const newerLive = makeSession({ + id: "my-app-orchestrator-2", + projectId: "my-app", + metadata: { role: "orchestrator" }, + lastActivityAt: new Date("2026-04-19T10:00:00.000Z"), + }); + const deadOlder = makeSession({ + id: "my-app-orchestrator-0", + projectId: "my-app", + metadata: { role: "orchestrator" }, + status: "killed", + activity: "exited", + lastActivityAt: new Date("2026-04-19T11:00:00.000Z"), + lifecycle: deadLifecycle, + }); + (mockSessionManager.listCached as ReturnType).mockResolvedValueOnce([ + deadOlder, + olderLive, + newerLive, + makeSession({ + id: "backend-3", + projectId: "my-app", + status: "working", + activity: "active", + }), + ]); + + const res = await sessionsGET( + makeRequest("http://localhost:3000/api/sessions?project=my-app&orchestratorOnly=true"), + ); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.orchestratorId).toBe("my-app-orchestrator-2"); + expect(data.orchestrators.map((session: { id: string }) => session.id)).toEqual([ + "my-app-orchestrator-0", + "my-app-orchestrator-1", + "my-app-orchestrator-2", + ]); + expect(data.sessions).toEqual([]); + expect(mockSessionManager.listCached).toHaveBeenCalledWith("my-app"); + }); + it("enriches all PRs concurrently, not sequentially", async () => { vi.useFakeTimers(); diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 85a563671..c35da9d3b 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -1,4 +1,4 @@ -import { ACTIVITY_STATE, isOrchestratorSession } from "@aoagents/ao-core"; +import { ACTIVITY_STATE, isOrchestratorSession, isTerminalSession } from "@aoagents/ao-core"; import { getServices, getSCM } from "@/lib/services"; import { sessionToDashboard, @@ -16,6 +16,32 @@ const METADATA_ENRICH_TIMEOUT_MS = 3_000; const PR_ENRICH_TIMEOUT_MS = 4_000; const PER_PR_ENRICH_TIMEOUT_MS = 1_500; +function selectPreferredOrchestratorId( + sessions: Parameters[0], + projects: Parameters[1], +): string | null { + const allSessionPrefixes = Object.entries(projects).map( + ([projectId, project]) => project.sessionPrefix ?? projectId, + ); + + const liveOrchestrators = sessions + .filter((session) => + isOrchestratorSession( + session, + projects[session.projectId]?.sessionPrefix ?? session.projectId, + allSessionPrefixes, + ) && !isTerminalSession(session), + ) + .sort( + (a, b) => + (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0) || + (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0) || + a.id.localeCompare(b.id), + ); + + return liveOrchestrators[0]?.id ?? null; +} + export async function GET(request: Request) { const correlationId = getCorrelationId(request); const startedAt = Date.now(); @@ -33,7 +59,9 @@ export async function GET(request: Request) { const coreSessions = await sessionManager.listCached(requestedProjectId); const visibleSessions = filterProjectSessions(coreSessions, projectFilter, config.projects); const orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); - const orchestratorId = orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null; + const orchestratorId = requestedProjectId + ? selectPreferredOrchestratorId(visibleSessions, config.projects) + : (orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null); if (orchestratorOnly) { recordApiObservation({ From 3ba526d282e55cbbb4ffa1a44fa723e1a7a75659 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 01:21:17 +0530 Subject: [PATCH 05/16] fix: relax codex restore approval mode --- packages/plugins/agent-codex/CHANGELOG.md | 6 +++++ .../plugins/agent-codex/src/index.test.ts | 8 +++---- packages/plugins/agent-codex/src/index.ts | 22 ++++++++++++++++++- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/plugins/agent-codex/CHANGELOG.md b/packages/plugins/agent-codex/CHANGELOG.md index c3d90a4ba..dc37bf392 100644 --- a/packages/plugins/agent-codex/CHANGELOG.md +++ b/packages/plugins/agent-codex/CHANGELOG.md @@ -1,5 +1,11 @@ # @composio/ao-plugin-agent-codex +## [Unreleased] + +### Fixed + +- Make Codex worker restore commands use `--ask-for-approval on-request` instead of resuming in `never` approval mode, while keeping permissionless orchestrator restores explicitly bypassed. + ## 0.2.0 ### Patch Changes diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 0b9d2664c..abd25886c 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -1470,7 +1470,7 @@ describe("getRestoreCommand", () => { expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox"); }); - it("demotes worker restore permissionless mode to ask-for-approval never", async () => { + it("demotes worker restore permissionless mode to ask-for-approval on-request", async () => { const content = jsonl( { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, { threadId: "thread-1" }, @@ -1489,11 +1489,11 @@ describe("getRestoreCommand", () => { }), ); - expect(cmd).toContain("--ask-for-approval never"); + expect(cmd).toContain("--ask-for-approval on-request"); expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox"); }); - it("includes --ask-for-approval never from project config", async () => { + it("downgrades auto-edit restore policy to ask-for-approval on-request", async () => { const content = jsonl( { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, { threadId: "thread-1" }, @@ -1512,7 +1512,7 @@ describe("getRestoreCommand", () => { }), ); - expect(cmd).toContain("--ask-for-approval never"); + expect(cmd).toContain("--ask-for-approval on-request"); expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox"); }); diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 4ac3714f9..05aaa7869 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -404,6 +404,26 @@ function appendApprovalFlags( } } +/** Append restore-time approval flags, avoiding worker resumes in `never` mode. */ +function appendRestoreApprovalFlags( + parts: string[], + permissions: string | undefined, + isOrchestrator: boolean, +): void { + const mode = normalizeAgentPermissionMode(permissions); + if (mode === "permissionless") { + if (isOrchestrator) { + parts.push("--dangerously-bypass-approvals-and-sandbox"); + } else { + parts.push("--ask-for-approval", "on-request"); + } + } else if (mode === "auto-edit") { + parts.push("--ask-for-approval", "on-request"); + } else if (mode === "suggest") { + parts.push("--ask-for-approval", "untrusted"); + } +} + /** Append model and reasoning flags to a command parts array */ function appendModelFlags(parts: string[], model: string | undefined): void { if (!model) return; @@ -734,7 +754,7 @@ function createCodexAgent(): Agent { appendNoUpdateCheckFlag(parts); const isOrchestrator = session.metadata?.["role"] === "orchestrator"; - appendApprovalFlags(parts, project.agentConfig?.permissions, isOrchestrator); + appendRestoreApprovalFlags(parts, project.agentConfig?.permissions, isOrchestrator); const effectiveModel = (project.agentConfig?.model ?? data.model) as string | undefined; appendModelFlags(parts, effectiveModel ?? undefined); From e1bb51f42a34a527f54a8e6dfa63dd630d8f8d73 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 01:24:31 +0530 Subject: [PATCH 06/16] chore: replace changelog edits with changeset --- .changeset/fix-start-restore-and-codex-resume.md | 10 ++++++++++ packages/cli/CHANGELOG.md | 6 ------ packages/plugins/agent-codex/CHANGELOG.md | 6 ------ packages/web/CHANGELOG.md | 6 ------ 4 files changed, 10 insertions(+), 18 deletions(-) create mode 100644 .changeset/fix-start-restore-and-codex-resume.md diff --git a/.changeset/fix-start-restore-and-codex-resume.md b/.changeset/fix-start-restore-and-codex-resume.md new file mode 100644 index 000000000..6015d76c2 --- /dev/null +++ b/.changeset/fix-start-restore-and-codex-resume.md @@ -0,0 +1,10 @@ +"@aoagents/ao-cli": patch +"@aoagents/ao-web": patch +"@aoagents/ao-plugin-agent-codex": patch +--- + +Fix restore behavior across AO session recovery flows. + +- restore the latest dead-but-restorable orchestrator on `ao start` instead of silently spawning a new orchestrator when tmux is gone +- make worker session orchestrator navigation prefer the most recently active live orchestrator for the project +- make Codex worker restore commands resume with `--ask-for-approval on-request` instead of `never`, while keeping permissionless orchestrator restores bypassed diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index bf1f80b5f..3ebefb021 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,11 +1,5 @@ # @composio/ao-cli -## [Unreleased] - -### Fixed - -- Restore the most recently active dead orchestrator on `ao start` when its tmux session is gone, instead of silently spawning a new orchestrator. - ## 0.2.2 ### Patch Changes diff --git a/packages/plugins/agent-codex/CHANGELOG.md b/packages/plugins/agent-codex/CHANGELOG.md index dc37bf392..c3d90a4ba 100644 --- a/packages/plugins/agent-codex/CHANGELOG.md +++ b/packages/plugins/agent-codex/CHANGELOG.md @@ -1,11 +1,5 @@ # @composio/ao-plugin-agent-codex -## [Unreleased] - -### Fixed - -- Make Codex worker restore commands use `--ask-for-approval on-request` instead of resuming in `never` approval mode, while keeping permissionless orchestrator restores explicitly bypassed. - ## 0.2.0 ### Patch Changes diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 101e40670..5df34b062 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,11 +1,5 @@ # @composio/ao-web -## [Unreleased] - -### Fixed - -- Make worker-session orchestrator navigation prefer the most recently active live orchestrator for the project instead of defaulting to the first sorted orchestrator id. - ## 0.2.2 ### Patch Changes From 27135eab0e242bf208c8817b7bcfc8926f0ab9cc Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 01:30:11 +0530 Subject: [PATCH 07/16] fix: close failed startup lock writes --- packages/cli/src/lib/running-state.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/running-state.ts b/packages/cli/src/lib/running-state.ts index 2aa6d68aa..4ccc7d38a 100644 --- a/packages/cli/src/lib/running-state.ts +++ b/packages/cli/src/lib/running-state.ts @@ -68,8 +68,14 @@ function tryAcquire(lockFile: string): (() => void) | null { pid: process.pid, acquiredAt: new Date().toISOString(), }; - writeFileSync(fd, JSON.stringify(metadata), "utf-8"); - closeSync(fd); + try { + writeFileSync(fd, JSON.stringify(metadata), "utf-8"); + } catch { + try { unlinkSync(lockFile); } catch { /* best effort */ } + return null; + } finally { + try { closeSync(fd); } catch { /* best effort */ } + } return () => { try { unlinkSync(lockFile); } catch { /* best effort */ } }; From aede68e8da3bb5ba54f0308eedd3879b9a1fddd8 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 13:36:18 +0530 Subject: [PATCH 08/16] test: remove duplicate restore mock --- packages/cli/__tests__/commands/start.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index c78715386..a28d50c5a 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -36,7 +36,6 @@ const { get: vi.fn(), spawn: vi.fn(), spawnOrchestrator: vi.fn(), - restore: vi.fn(), send: vi.fn(), claimPR: vi.fn(), }, From bb5dcf0af710ea14550bfcda7dc9d39dac893f7e Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 14:36:36 +0530 Subject: [PATCH 09/16] fix: address #1306 review follow-ups --- .../fix-start-restore-and-codex-resume.md | 2 -- packages/cli/__tests__/commands/start.test.ts | 27 +++++++++++++-- packages/cli/src/commands/start.ts | 24 ++++++++++++- packages/cli/src/lib/running-state.ts | 10 ++++-- .../plugins/agent-codex/src/index.test.ts | 8 ++--- packages/plugins/agent-codex/src/index.ts | 22 +----------- packages/web/src/__tests__/api-routes.test.ts | 34 ++++++++++++++++++- packages/web/src/app/api/sessions/route.ts | 14 +++++++- 8 files changed, 107 insertions(+), 34 deletions(-) diff --git a/.changeset/fix-start-restore-and-codex-resume.md b/.changeset/fix-start-restore-and-codex-resume.md index 6015d76c2..7283e24e1 100644 --- a/.changeset/fix-start-restore-and-codex-resume.md +++ b/.changeset/fix-start-restore-and-codex-resume.md @@ -1,10 +1,8 @@ "@aoagents/ao-cli": patch "@aoagents/ao-web": patch -"@aoagents/ao-plugin-agent-codex": patch --- Fix restore behavior across AO session recovery flows. - restore the latest dead-but-restorable orchestrator on `ao start` instead of silently spawning a new orchestrator when tmux is gone - make worker session orchestrator navigation prefer the most recently active live orchestrator for the project -- make Codex worker restore commands resume with `--ask-for-approval on-request` instead of `never`, while keeping permissionless orchestrator restores bypassed diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index a28d50c5a..b3b4eee20 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -57,8 +57,10 @@ const { mockPromptSelect, mockPromptConfirm } = vi.hoisted(() => ({ mockPromptConfirm: vi.fn().mockResolvedValue(true), })); -const { mockIsAlreadyRunning, mockUnregister, mockWaitForExit } = vi.hoisted(() => ({ +const { mockAcquireStartupLock, mockIsAlreadyRunning, mockRegister, mockUnregister, mockWaitForExit } = vi.hoisted(() => ({ + mockAcquireStartupLock: vi.fn().mockResolvedValue(() => {}), mockIsAlreadyRunning: vi.fn().mockReturnValue(null), + mockRegister: vi.fn(), mockUnregister: vi.fn(), mockWaitForExit: vi.fn().mockReturnValue(true), })); @@ -142,7 +144,8 @@ vi.mock("../../src/lib/preflight.js", () => ({ })); vi.mock("../../src/lib/running-state.js", () => ({ - register: vi.fn(), + acquireStartupLock: (...args: unknown[]) => mockAcquireStartupLock(...args), + register: (...args: unknown[]) => mockRegister(...args), unregister: (...args: unknown[]) => mockUnregister(...args), isAlreadyRunning: (...args: unknown[]) => mockIsAlreadyRunning(...args), getRunning: vi.fn().mockReturnValue(null), @@ -283,8 +286,12 @@ beforeEach(async () => { mockPromptSelect.mockReset(); mockPromptConfirm.mockReset(); mockPromptConfirm.mockResolvedValue(true); + mockAcquireStartupLock.mockReset(); + mockAcquireStartupLock.mockResolvedValue(() => {}); mockIsAlreadyRunning.mockReset(); mockIsAlreadyRunning.mockResolvedValue(null); + mockRegister.mockReset(); + mockRegister.mockResolvedValue(undefined); mockUnregister.mockReset(); mockWaitForExit.mockReset(); mockWaitForExit.mockResolvedValue(true); @@ -1321,6 +1328,22 @@ describe("start command — orchestrator session strategy display", () => { expect(fakeDashboard.kill).toHaveBeenCalled(); }); + it("reports startup lock acquisition failures through the normal CLI error path", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + mockAcquireStartupLock.mockRejectedValueOnce( + new Error("Could not acquire startup lock (/tmp/startup.lock)"), + ); + + await expect(program.parseAsync(["node", "test", "start"])).rejects.toThrow("process.exit(1)"); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => c.join(" ")) + .join("\n"); + expect(errors).toContain("Could not acquire startup lock (/tmp/startup.lock)"); + expect(mockIsAlreadyRunning).not.toHaveBeenCalled(); + }); + it("fails and cleans up dashboard when sm.restore throws on a killed orchestrator", async () => { mockConfigRef.current = makeConfig({ "my-app": makeProject() }); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 25de264e9..27a09ad4e 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -52,7 +52,14 @@ import { } from "../lib/web-dir.js"; import { rebuildDashboardProductionArtifacts } from "../lib/dashboard-rebuild.js"; import { preflight } from "../lib/preflight.js"; -import { register, unregister, isAlreadyRunning, getRunning, waitForExit } from "../lib/running-state.js"; +import { + register, + unregister, + isAlreadyRunning, + getRunning, + waitForExit, + acquireStartupLock, +} from "../lib/running-state.js"; import { preventIdleSleep } from "../lib/prevent-sleep.js"; import { isHumanCaller } from "../lib/caller-context.js"; import { detectEnvironment } from "../lib/detect-env.js"; @@ -1379,7 +1386,16 @@ export function registerStart(program: Command): void { interactive?: boolean; }, ) => { + let releaseStartupLock: (() => void) | undefined; + let startupLockReleased = false; + const unlockStartup = (): void => { + if (startupLockReleased || !releaseStartupLock) return; + startupLockReleased = true; + releaseStartupLock(); + }; + try { + releaseStartupLock = await acquireStartupLock(); let config: OrchestratorConfig; let projectId: string; let project: ProjectConfig; @@ -1408,6 +1424,7 @@ export function registerStart(program: Command): void { if (choice === "open") { const url = `http://localhost:${running.port}`; openUrl(url); + unlockStartup(); process.exit(0); } else if (choice === "new") { // Defer config mutation until after config is loaded below @@ -1427,6 +1444,7 @@ export function registerStart(program: Command): void { console.log(chalk.yellow("\n Stopped existing instance. Restarting...\n")); // Continue to startup below } else { + unlockStartup(); process.exit(0); } } else { @@ -1436,6 +1454,7 @@ export function registerStart(program: Command): void { console.log(`PID: ${running.pid}`); console.log(`Projects: ${running.projects.join(", ")}`); console.log(`To restart: ao stop && ao start`); + unlockStartup(); process.exit(0); } } @@ -1565,6 +1584,7 @@ export function registerStart(program: Command): void { startedAt: new Date().toISOString(), projects: [projectId], }); + unlockStartup(); // Install shutdown handlers so `ao stop` (which sends SIGTERM to // this pid) flushes lifecycle health state before exit. Handlers @@ -1591,6 +1611,8 @@ export function registerStart(program: Command): void { console.error(chalk.red("\nError:"), String(err)); } process.exit(1); + } finally { + unlockStartup(); } }, ); diff --git a/packages/cli/src/lib/running-state.ts b/packages/cli/src/lib/running-state.ts index 4ccc7d38a..c6876d4c1 100644 --- a/packages/cli/src/lib/running-state.ts +++ b/packages/cli/src/lib/running-state.ts @@ -23,6 +23,7 @@ const STATE_DIR = join(homedir(), ".agent-orchestrator"); const STATE_FILE = join(STATE_DIR, "running.json"); const STATE_LOCK_FILE = join(STATE_DIR, "running.lock"); const STARTUP_LOCK_FILE = join(STATE_DIR, "startup.lock"); +const MAX_LOCK_AGE_MS = 30 * 60 * 1000; interface LockMetadata { pid: number; @@ -60,6 +61,11 @@ function readLockMetadata(lockFile: string): LockMetadata | null { } } +function isStaleLockOwner(owner: LockMetadata): boolean { + const acquiredAt = Date.parse(owner.acquiredAt); + return Number.isFinite(acquiredAt) && Date.now() - acquiredAt > MAX_LOCK_AGE_MS; +} + /** Try to create the lockfile atomically. Returns a release function on success, null on failure. */ function tryAcquire(lockFile: string): (() => void) | null { try { @@ -104,14 +110,14 @@ async function acquireLock( if (release) return release; const owner = readLockMetadata(lockFile); - if (!owner || !isProcessAlive(owner.pid)) { + if (owner && (isStaleLockOwner(owner) || !isProcessAlive(owner.pid))) { try { unlinkSync(lockFile); } catch { /* ignore */ } const retryRelease = tryAcquire(lockFile); if (retryRelease) return retryRelease; } if (Date.now() - start > timeoutMs) { - throw new Error(`Could not acquire ${resourceName}`); + throw new Error(`Could not acquire ${resourceName} (${lockFile})`); } // Jittered backoff: 30-70ms base, growing with attempts (capped at 200ms) diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index abd25886c..fc760ca71 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -1470,7 +1470,7 @@ describe("getRestoreCommand", () => { expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox"); }); - it("demotes worker restore permissionless mode to ask-for-approval on-request", async () => { + it("keeps worker restore permissionless mode at ask-for-approval never", async () => { const content = jsonl( { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, { threadId: "thread-1" }, @@ -1489,11 +1489,11 @@ describe("getRestoreCommand", () => { }), ); - expect(cmd).toContain("--ask-for-approval on-request"); + expect(cmd).toContain("--ask-for-approval never"); expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox"); }); - it("downgrades auto-edit restore policy to ask-for-approval on-request", async () => { + it("keeps auto-edit restore policy at ask-for-approval never", async () => { const content = jsonl( { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, { threadId: "thread-1" }, @@ -1512,7 +1512,7 @@ describe("getRestoreCommand", () => { }), ); - expect(cmd).toContain("--ask-for-approval on-request"); + expect(cmd).toContain("--ask-for-approval never"); expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox"); }); diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 05aaa7869..4ac3714f9 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -404,26 +404,6 @@ function appendApprovalFlags( } } -/** Append restore-time approval flags, avoiding worker resumes in `never` mode. */ -function appendRestoreApprovalFlags( - parts: string[], - permissions: string | undefined, - isOrchestrator: boolean, -): void { - const mode = normalizeAgentPermissionMode(permissions); - if (mode === "permissionless") { - if (isOrchestrator) { - parts.push("--dangerously-bypass-approvals-and-sandbox"); - } else { - parts.push("--ask-for-approval", "on-request"); - } - } else if (mode === "auto-edit") { - parts.push("--ask-for-approval", "on-request"); - } else if (mode === "suggest") { - parts.push("--ask-for-approval", "untrusted"); - } -} - /** Append model and reasoning flags to a command parts array */ function appendModelFlags(parts: string[], model: string | undefined): void { if (!model) return; @@ -754,7 +734,7 @@ function createCodexAgent(): Agent { appendNoUpdateCheckFlag(parts); const isOrchestrator = session.metadata?.["role"] === "orchestrator"; - appendRestoreApprovalFlags(parts, project.agentConfig?.permissions, isOrchestrator); + appendApprovalFlags(parts, project.agentConfig?.permissions, isOrchestrator); const effectiveModel = (project.agentConfig?.model ?? data.model) as string | undefined; appendModelFlags(parts, effectiveModel ?? undefined); diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index bb225630b..39505f483 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -387,7 +387,6 @@ describe("API Routes", () => { expect(data.orchestratorId).toBe("my-app-orchestrator-2"); expect(data.orchestrators.map((session: { id: string }) => session.id)).toEqual([ - "my-app-orchestrator-0", "my-app-orchestrator-1", "my-app-orchestrator-2", ]); @@ -395,6 +394,39 @@ describe("API Routes", () => { expect(mockSessionManager.listCached).toHaveBeenCalledWith("my-app"); }); + it("omits dead orchestrators from project-scoped orchestrator payloads when none are live", async () => { + const deadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T11:00:00.000Z")); + deadLifecycle.session.state = "terminated"; + deadLifecycle.session.reason = "runtime_missing"; + deadLifecycle.session.terminatedAt = "2026-04-19T11:00:00.000Z"; + deadLifecycle.session.lastTransitionAt = "2026-04-19T11:00:00.000Z"; + deadLifecycle.runtime.state = "missing"; + deadLifecycle.runtime.reason = "process_missing"; + deadLifecycle.runtime.lastObservedAt = "2026-04-19T11:00:00.000Z"; + + (mockSessionManager.listCached as ReturnType).mockResolvedValueOnce([ + makeSession({ + id: "my-app-orchestrator-0", + projectId: "my-app", + metadata: { role: "orchestrator" }, + status: "killed", + activity: "exited", + lastActivityAt: new Date("2026-04-19T11:00:00.000Z"), + lifecycle: deadLifecycle, + }), + ]); + + const res = await sessionsGET( + makeRequest("http://localhost:3000/api/sessions?project=my-app&orchestratorOnly=true"), + ); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.orchestratorId).toBeNull(); + expect(data.orchestrators).toEqual([]); + expect(data.sessions).toEqual([]); + }); + it("enriches all PRs concurrently, not sequentially", async () => { vi.useFakeTimers(); diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index c35da9d3b..1744ae9ac 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -42,6 +42,16 @@ function selectPreferredOrchestratorId( return liveOrchestrators[0]?.id ?? null; } +function listLiveDashboardOrchestrators( + sessions: Parameters[0], + projects: Parameters[1], +) { + return listDashboardOrchestrators( + sessions.filter((session) => !isTerminalSession(session)), + projects, + ); +} + export async function GET(request: Request) { const correlationId = getCorrelationId(request); const startedAt = Date.now(); @@ -58,7 +68,9 @@ export async function GET(request: Request) { : undefined; const coreSessions = await sessionManager.listCached(requestedProjectId); const visibleSessions = filterProjectSessions(coreSessions, projectFilter, config.projects); - const orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); + const orchestrators = requestedProjectId + ? listLiveDashboardOrchestrators(visibleSessions, config.projects) + : listDashboardOrchestrators(visibleSessions, config.projects); const orchestratorId = requestedProjectId ? selectPreferredOrchestratorId(visibleSessions, config.projects) : (orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null); From 350c5c08e84f30e529178045047ca8e1b7b45d5b Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 14:43:48 +0530 Subject: [PATCH 10/16] fix: restore permissionless codex workers with bypass --- packages/plugins/agent-codex/src/index.test.ts | 6 +++--- packages/plugins/agent-codex/src/index.ts | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index fc760ca71..86c2bfabd 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -1470,7 +1470,7 @@ describe("getRestoreCommand", () => { expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox"); }); - it("keeps worker restore permissionless mode at ask-for-approval never", async () => { + it("uses dangerous bypass for worker restore permissionless mode", async () => { const content = jsonl( { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, { threadId: "thread-1" }, @@ -1489,8 +1489,8 @@ describe("getRestoreCommand", () => { }), ); - expect(cmd).toContain("--ask-for-approval never"); - expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(cmd).not.toContain("--ask-for-approval"); }); it("keeps auto-edit restore policy at ask-for-approval never", async () => { diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 4ac3714f9..ccea0b538 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -733,8 +733,7 @@ function createCodexAgent(): Agent { const parts: string[] = [shellEscape(binary), "resume"]; appendNoUpdateCheckFlag(parts); - const isOrchestrator = session.metadata?.["role"] === "orchestrator"; - appendApprovalFlags(parts, project.agentConfig?.permissions, isOrchestrator); + appendApprovalFlags(parts, project.agentConfig?.permissions); const effectiveModel = (project.agentConfig?.model ?? data.model) as string | undefined; appendModelFlags(parts, effectiveModel ?? undefined); From 330fe7e63c69bdc0e315231a2084650da33ce03a Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 14:45:10 +0530 Subject: [PATCH 11/16] docs: align changeset with codex restore behavior --- .changeset/fix-start-restore-and-codex-resume.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/fix-start-restore-and-codex-resume.md b/.changeset/fix-start-restore-and-codex-resume.md index 7283e24e1..84e465c47 100644 --- a/.changeset/fix-start-restore-and-codex-resume.md +++ b/.changeset/fix-start-restore-and-codex-resume.md @@ -1,8 +1,10 @@ "@aoagents/ao-cli": patch "@aoagents/ao-web": patch +"@aoagents/ao-plugin-agent-codex": patch --- Fix restore behavior across AO session recovery flows. - restore the latest dead-but-restorable orchestrator on `ao start` instead of silently spawning a new orchestrator when tmux is gone - make worker session orchestrator navigation prefer the most recently active live orchestrator for the project +- make permissionless Codex restores preserve dangerous bypass semantics so resumed workers behave like fresh permissionless launches From 6b15e68fc341382bf4afcfedff134fa8f003e9ca Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 19:46:12 +0530 Subject: [PATCH 12/16] fix: address follow-up review regressions --- packages/cli/src/lib/running-state.ts | 15 ++++++++++++++- packages/web/src/__tests__/api-routes.test.ts | 6 ++++-- packages/web/src/app/api/sessions/route.ts | 12 +++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/running-state.ts b/packages/cli/src/lib/running-state.ts index c6876d4c1..5e6eb7535 100644 --- a/packages/cli/src/lib/running-state.ts +++ b/packages/cli/src/lib/running-state.ts @@ -6,6 +6,7 @@ import { openSync, closeSync, constants, + statSync, } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; @@ -24,6 +25,7 @@ const STATE_FILE = join(STATE_DIR, "running.json"); const STATE_LOCK_FILE = join(STATE_DIR, "running.lock"); const STARTUP_LOCK_FILE = join(STATE_DIR, "startup.lock"); const MAX_LOCK_AGE_MS = 30 * 60 * 1000; +const UNPARSEABLE_LOCK_GRACE_MS = 5_000; interface LockMetadata { pid: number; @@ -62,10 +64,20 @@ function readLockMetadata(lockFile: string): LockMetadata | null { } function isStaleLockOwner(owner: LockMetadata): boolean { + if (isProcessAlive(owner.pid)) return false; const acquiredAt = Date.parse(owner.acquiredAt); return Number.isFinite(acquiredAt) && Date.now() - acquiredAt > MAX_LOCK_AGE_MS; } +function isStaleUnparseableLock(lockFile: string): boolean { + try { + const mtimeMs = statSync(lockFile).mtimeMs; + return Date.now() - mtimeMs > UNPARSEABLE_LOCK_GRACE_MS; + } catch { + return false; + } +} + /** Try to create the lockfile atomically. Returns a release function on success, null on failure. */ function tryAcquire(lockFile: string): (() => void) | null { try { @@ -110,7 +122,8 @@ async function acquireLock( if (release) return release; const owner = readLockMetadata(lockFile); - if (owner && (isStaleLockOwner(owner) || !isProcessAlive(owner.pid))) { + if ((!owner && isStaleUnparseableLock(lockFile)) + || (owner && (isStaleLockOwner(owner) || !isProcessAlive(owner.pid)))) { try { unlinkSync(lockFile); } catch { /* ignore */ } const retryRelease = tryAcquire(lockFile); if (retryRelease) return retryRelease; diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 39505f483..a297c9c45 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -394,7 +394,7 @@ describe("API Routes", () => { expect(mockSessionManager.listCached).toHaveBeenCalledWith("my-app"); }); - it("omits dead orchestrators from project-scoped orchestrator payloads when none are live", async () => { + it("keeps dead orchestrators as the fallback project-scoped payload when none are live", async () => { const deadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T11:00:00.000Z")); deadLifecycle.session.state = "terminated"; deadLifecycle.session.reason = "runtime_missing"; @@ -423,7 +423,9 @@ describe("API Routes", () => { const data = await res.json(); expect(data.orchestratorId).toBeNull(); - expect(data.orchestrators).toEqual([]); + expect(data.orchestrators).toEqual([ + { id: "my-app-orchestrator-0", projectId: "my-app", projectName: "My App" }, + ]); expect(data.sessions).toEqual([]); }); diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 1744ae9ac..ec74de844 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -52,6 +52,16 @@ function listLiveDashboardOrchestrators( ); } +function listPreferredProjectOrchestrators( + sessions: Parameters[0], + projects: Parameters[1], +) { + const liveOrchestrators = listLiveDashboardOrchestrators(sessions, projects); + return liveOrchestrators.length > 0 + ? liveOrchestrators + : listDashboardOrchestrators(sessions, projects); +} + export async function GET(request: Request) { const correlationId = getCorrelationId(request); const startedAt = Date.now(); @@ -69,7 +79,7 @@ export async function GET(request: Request) { const coreSessions = await sessionManager.listCached(requestedProjectId); const visibleSessions = filterProjectSessions(coreSessions, projectFilter, config.projects); const orchestrators = requestedProjectId - ? listLiveDashboardOrchestrators(visibleSessions, config.projects) + ? listPreferredProjectOrchestrators(visibleSessions, config.projects) : listDashboardOrchestrators(visibleSessions, config.projects); const orchestratorId = requestedProjectId ? selectPreferredOrchestratorId(visibleSessions, config.projects) From eee8e66ffcba148545587be12cd398351c9739a5 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 19 Apr 2026 19:57:51 +0530 Subject: [PATCH 13/16] fix: address pr-review regressions (#1306) --- .../cli/__tests__/lib/running-state.test.ts | 67 +++++++++++++++++++ packages/cli/src/lib/running-state.ts | 28 +++++--- packages/web/src/__tests__/api-routes.test.ts | 56 +++++++++++++++- packages/web/src/app/api/sessions/route.ts | 45 +++++++------ 4 files changed, 165 insertions(+), 31 deletions(-) create mode 100644 packages/cli/__tests__/lib/running-state.test.ts diff --git a/packages/cli/__tests__/lib/running-state.test.ts b/packages/cli/__tests__/lib/running-state.test.ts new file mode 100644 index 000000000..cf013122e --- /dev/null +++ b/packages/cli/__tests__/lib/running-state.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +const testHome = join(process.cwd(), ".tmp-running-state-home"); + +vi.mock("node:os", () => ({ + homedir: () => testHome, +})); + +describe("running-state", () => { + beforeEach(() => { + rmSync(testHome, { recursive: true, force: true }); + vi.restoreAllMocks(); + vi.resetModules(); + }); + + afterEach(() => { + rmSync(testHome, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("prunes running.json when the pid probe returns EPERM", async () => { + const runningState = await import("../../src/lib/running-state.js"); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { + const error = new Error("operation not permitted") as Error & { code?: string }; + error.code = "EPERM"; + throw error; + }); + + await runningState.register({ + pid: 424242, + configPath: "/tmp/agent-orchestrator.yaml", + port: 4321, + startedAt: new Date("2026-04-19T00:00:00.000Z").toISOString(), + projects: ["my-app"], + }); + + const state = await runningState.getRunning(); + const stateFile = join(testHome, ".agent-orchestrator", "running.json"); + + expect(state).toBeNull(); + expect(existsSync(stateFile)).toBe(false); + expect(killSpy).toHaveBeenCalledWith(424242, 0); + }); + + it("keeps startup locks alive when the pid probe returns EPERM", async () => { + const runningState = await import("../../src/lib/running-state.js"); + const lockDir = join(testHome, ".agent-orchestrator"); + const lockFile = join(lockDir, "startup.lock"); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { + const error = new Error("operation not permitted") as Error & { code?: string }; + error.code = "EPERM"; + throw error; + }); + + const release = await runningState.acquireStartupLock(100); + + await expect(runningState.acquireStartupLock(100)).rejects.toThrow( + `Could not acquire startup lock (${lockFile})`, + ); + expect(readFileSync(lockFile, "utf-8")).toContain(`"pid":${process.pid}`); + + release(); + expect(killSpy).toHaveBeenCalledWith(process.pid, 0); + }); +}); diff --git a/packages/cli/src/lib/running-state.ts b/packages/cli/src/lib/running-state.ts index 5e6eb7535..e2f75f229 100644 --- a/packages/cli/src/lib/running-state.ts +++ b/packages/cli/src/lib/running-state.ts @@ -32,22 +32,32 @@ interface LockMetadata { acquiredAt: string; } +type ProcessProbeResult = "alive" | "forbidden" | "missing"; + function ensureDir(): void { mkdirSync(STATE_DIR, { recursive: true }); } -function isProcessAlive(pid: number): boolean { +function probeProcess(pid: number): ProcessProbeResult { try { process.kill(pid, 0); - return true; + return "alive"; } catch (error: unknown) { if ((error as { code?: string }).code === "EPERM") { - return true; + return "forbidden"; } - return false; + return "missing"; } } +function isLockOwnerAlive(pid: number): boolean { + return probeProcess(pid) !== "missing"; +} + +function isRunningProcessAlive(pid: number): boolean { + return probeProcess(pid) === "alive"; +} + function readLockMetadata(lockFile: string): LockMetadata | null { try { const raw = readFileSync(lockFile, "utf-8"); @@ -64,7 +74,7 @@ function readLockMetadata(lockFile: string): LockMetadata | null { } function isStaleLockOwner(owner: LockMetadata): boolean { - if (isProcessAlive(owner.pid)) return false; + if (isLockOwnerAlive(owner.pid)) return false; const acquiredAt = Date.parse(owner.acquiredAt); return Number.isFinite(acquiredAt) && Date.now() - acquiredAt > MAX_LOCK_AGE_MS; } @@ -123,7 +133,7 @@ async function acquireLock( const owner = readLockMetadata(lockFile); if ((!owner && isStaleUnparseableLock(lockFile)) - || (owner && (isStaleLockOwner(owner) || !isProcessAlive(owner.pid)))) { + || (owner && (isStaleLockOwner(owner) || !isLockOwnerAlive(owner.pid)))) { try { unlinkSync(lockFile); } catch { /* ignore */ } const retryRelease = tryAcquire(lockFile); if (retryRelease) return retryRelease; @@ -196,7 +206,7 @@ export async function getRunning(): Promise { const state = readState(); if (!state) return null; - if (!isProcessAlive(state.pid)) { + if (!isRunningProcessAlive(state.pid)) { // Stale entry — process is dead, clean up writeState(null); return null; @@ -231,8 +241,8 @@ export async function acquireStartupLock(timeoutMs = 30000): Promise<() => void> export async function waitForExit(pid: number, timeoutMs = 5000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - if (!isProcessAlive(pid)) return true; + if (!isRunningProcessAlive(pid)) return true; await sleep(100); } - return !isProcessAlive(pid); + return !isRunningProcessAlive(pid); } diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index a297c9c45..b5676ad97 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -422,13 +422,67 @@ describe("API Routes", () => { expect(res.status).toBe(200); const data = await res.json(); - expect(data.orchestratorId).toBeNull(); + expect(data.orchestratorId).toBe("my-app-orchestrator-0"); expect(data.orchestrators).toEqual([ { id: "my-app-orchestrator-0", projectId: "my-app", projectName: "My App" }, ]); expect(data.sessions).toEqual([]); }); + it("prefers the most recently active dead orchestrator when no live project orchestrator exists", async () => { + const olderDeadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T10:00:00.000Z")); + olderDeadLifecycle.session.state = "terminated"; + olderDeadLifecycle.session.reason = "runtime_missing"; + olderDeadLifecycle.session.terminatedAt = "2026-04-19T10:00:00.000Z"; + olderDeadLifecycle.session.lastTransitionAt = "2026-04-19T10:00:00.000Z"; + olderDeadLifecycle.runtime.state = "missing"; + olderDeadLifecycle.runtime.reason = "process_missing"; + olderDeadLifecycle.runtime.lastObservedAt = "2026-04-19T10:00:00.000Z"; + + const newerDeadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T11:00:00.000Z")); + newerDeadLifecycle.session.state = "terminated"; + newerDeadLifecycle.session.reason = "runtime_missing"; + newerDeadLifecycle.session.terminatedAt = "2026-04-19T11:00:00.000Z"; + newerDeadLifecycle.session.lastTransitionAt = "2026-04-19T11:00:00.000Z"; + newerDeadLifecycle.runtime.state = "missing"; + newerDeadLifecycle.runtime.reason = "process_missing"; + newerDeadLifecycle.runtime.lastObservedAt = "2026-04-19T11:00:00.000Z"; + + (mockSessionManager.listCached as ReturnType).mockResolvedValueOnce([ + makeSession({ + id: "my-app-orchestrator-0", + projectId: "my-app", + metadata: { role: "orchestrator" }, + status: "killed", + activity: "exited", + lastActivityAt: new Date("2026-04-19T10:00:00.000Z"), + lifecycle: olderDeadLifecycle, + }), + makeSession({ + id: "my-app-orchestrator-9", + projectId: "my-app", + metadata: { role: "orchestrator" }, + status: "killed", + activity: "exited", + lastActivityAt: new Date("2026-04-19T11:00:00.000Z"), + lifecycle: newerDeadLifecycle, + }), + ]); + + const res = await sessionsGET( + makeRequest("http://localhost:3000/api/sessions?project=my-app&orchestratorOnly=true"), + ); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.orchestratorId).toBe("my-app-orchestrator-9"); + expect(data.orchestrators).toEqual([ + { id: "my-app-orchestrator-0", projectId: "my-app", projectName: "My App" }, + { id: "my-app-orchestrator-9", projectId: "my-app", projectName: "My App" }, + ]); + expect(data.sessions).toEqual([]); + }); + it("enriches all PRs concurrently, not sequentially", async () => { vi.useFakeTimers(); diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index ec74de844..59c73a5df 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -16,50 +16,53 @@ const METADATA_ENRICH_TIMEOUT_MS = 3_000; const PR_ENRICH_TIMEOUT_MS = 4_000; const PER_PR_ENRICH_TIMEOUT_MS = 1_500; -function selectPreferredOrchestratorId( +function compareOrchestratorRecency(a: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, b: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }): number { + return ( + (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0) || + (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0) || + a.id.localeCompare(b.id) + ); +} + +function listProjectOrchestratorSessions( sessions: Parameters[0], projects: Parameters[1], -): string | null { +): Parameters[0] { const allSessionPrefixes = Object.entries(projects).map( ([projectId, project]) => project.sessionPrefix ?? projectId, ); - const liveOrchestrators = sessions + const projectOrchestrators = sessions .filter((session) => isOrchestratorSession( session, projects[session.projectId]?.sessionPrefix ?? session.projectId, allSessionPrefixes, - ) && !isTerminalSession(session), + ), ) - .sort( - (a, b) => - (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0) || - (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0) || - a.id.localeCompare(b.id), - ); + .sort(compareOrchestratorRecency); - return liveOrchestrators[0]?.id ?? null; + const liveOrchestrators = projectOrchestrators.filter((session) => !isTerminalSession(session)); + return liveOrchestrators.length > 0 ? liveOrchestrators : projectOrchestrators; } -function listLiveDashboardOrchestrators( +function selectPreferredOrchestratorId( sessions: Parameters[0], projects: Parameters[1], -) { - return listDashboardOrchestrators( - sessions.filter((session) => !isTerminalSession(session)), - projects, - ); +): string | null { + return listProjectOrchestratorSessions(sessions, projects)[0]?.id ?? null; } function listPreferredProjectOrchestrators( sessions: Parameters[0], projects: Parameters[1], ) { - const liveOrchestrators = listLiveDashboardOrchestrators(sessions, projects); - return liveOrchestrators.length > 0 - ? liveOrchestrators - : listDashboardOrchestrators(sessions, projects); + const preferredOrchestrators = listProjectOrchestratorSessions(sessions, projects); + const liveOrchestrators = preferredOrchestrators.filter((session) => !isTerminalSession(session)); + return listDashboardOrchestrators( + liveOrchestrators.length > 0 ? liveOrchestrators : preferredOrchestrators, + projects, + ); } export async function GET(request: Request) { From dcd003fbd67edc78f0364ea1cecf1dcc7d078f23 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Mon, 20 Apr 2026 13:18:49 +0530 Subject: [PATCH 14/16] fix: restore dead orchestrators from session detail --- packages/web/src/components/SessionDetail.tsx | 24 +++++++-------- .../__tests__/SessionDetail.desktop.test.tsx | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 1e3b57ed2..5d1d91774 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -594,25 +594,23 @@ export function SessionDetail({ ) : null} - {/* Kill / Restore — only for non-orchestrator worker sessions */} - {!isOrchestrator && ( - isRestorable ? ( - - ) : !terminalEnded ? ( + {/* Restore is available for any restorable session; Kill stays worker-only. */} + {isRestorable ? ( + + ) : !isOrchestrator && !terminalEnded ? ( - ) : null - )} + ) : null} {!isOrchestrator && orchestratorHref ? ( { expect(screen.queryByTestId("direct-terminal")).not.toBeInTheDocument(); }); + it("shows restore for restorable orchestrator sessions", () => { + render( + , + ); + + expect(within(screen.getByRole("banner")).getByRole("button", { name: "Restore" })).toBeInTheDocument(); + expect(within(screen.getByRole("banner")).queryByRole("button", { name: "Kill" })).not.toBeInTheDocument(); + }); + it("hides the desktop orchestrator button on orchestrator session pages", () => { render( Date: Mon, 20 Apr 2026 13:30:07 +0530 Subject: [PATCH 15/16] fix: tighten startup lock cleanup --- packages/cli/__tests__/commands/start.test.ts | 12 ++++++++++++ packages/cli/__tests__/lib/running-state.test.ts | 12 +++++++++--- packages/cli/src/commands/start.ts | 1 + packages/cli/src/lib/running-state.ts | 13 +++---------- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index b3b4eee20..3e75f6800 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -1344,6 +1344,18 @@ describe("start command — orchestrator session strategy display", () => { expect(mockIsAlreadyRunning).not.toHaveBeenCalled(); }); + it("releases the startup lock before exiting on startup failures", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + const releaseStartupLock = vi.fn(); + mockAcquireStartupLock.mockResolvedValueOnce(releaseStartupLock); + mockSessionManager.list.mockResolvedValue([]); + mockSessionManager.spawnOrchestrator.mockRejectedValue(new Error("Spawn failed")); + + await expect(program.parseAsync(["node", "test", "start"])).rejects.toThrow("process.exit(1)"); + + expect(releaseStartupLock).toHaveBeenCalledTimes(1); + }); + it("fails and cleans up dashboard when sm.restore throws on a killed orchestrator", async () => { mockConfigRef.current = makeConfig({ "my-app": makeProject() }); diff --git a/packages/cli/__tests__/lib/running-state.test.ts b/packages/cli/__tests__/lib/running-state.test.ts index cf013122e..f3b2f6319 100644 --- a/packages/cli/__tests__/lib/running-state.test.ts +++ b/packages/cli/__tests__/lib/running-state.test.ts @@ -20,7 +20,7 @@ describe("running-state", () => { vi.restoreAllMocks(); }); - it("prunes running.json when the pid probe returns EPERM", async () => { + it("keeps running.json when the pid probe returns EPERM", async () => { const runningState = await import("../../src/lib/running-state.js"); const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { const error = new Error("operation not permitted") as Error & { code?: string }; @@ -39,8 +39,14 @@ describe("running-state", () => { const state = await runningState.getRunning(); const stateFile = join(testHome, ".agent-orchestrator", "running.json"); - expect(state).toBeNull(); - expect(existsSync(stateFile)).toBe(false); + expect(state).toEqual({ + pid: 424242, + configPath: "/tmp/agent-orchestrator.yaml", + port: 4321, + startedAt: new Date("2026-04-19T00:00:00.000Z").toISOString(), + projects: ["my-app"], + }); + expect(existsSync(stateFile)).toBe(true); expect(killSpy).toHaveBeenCalledWith(424242, 0); }); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 27a09ad4e..fa6d5b97b 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1610,6 +1610,7 @@ export function registerStart(program: Command): void { } else { console.error(chalk.red("\nError:"), String(err)); } + unlockStartup(); process.exit(1); } finally { unlockStartup(); diff --git a/packages/cli/src/lib/running-state.ts b/packages/cli/src/lib/running-state.ts index e2f75f229..6e51911c2 100644 --- a/packages/cli/src/lib/running-state.ts +++ b/packages/cli/src/lib/running-state.ts @@ -24,7 +24,6 @@ const STATE_DIR = join(homedir(), ".agent-orchestrator"); const STATE_FILE = join(STATE_DIR, "running.json"); const STATE_LOCK_FILE = join(STATE_DIR, "running.lock"); const STARTUP_LOCK_FILE = join(STATE_DIR, "startup.lock"); -const MAX_LOCK_AGE_MS = 30 * 60 * 1000; const UNPARSEABLE_LOCK_GRACE_MS = 5_000; interface LockMetadata { @@ -55,7 +54,7 @@ function isLockOwnerAlive(pid: number): boolean { } function isRunningProcessAlive(pid: number): boolean { - return probeProcess(pid) === "alive"; + return probeProcess(pid) !== "missing"; } function readLockMetadata(lockFile: string): LockMetadata | null { @@ -73,12 +72,6 @@ function readLockMetadata(lockFile: string): LockMetadata | null { } } -function isStaleLockOwner(owner: LockMetadata): boolean { - if (isLockOwnerAlive(owner.pid)) return false; - const acquiredAt = Date.parse(owner.acquiredAt); - return Number.isFinite(acquiredAt) && Date.now() - acquiredAt > MAX_LOCK_AGE_MS; -} - function isStaleUnparseableLock(lockFile: string): boolean { try { const mtimeMs = statSync(lockFile).mtimeMs; @@ -133,7 +126,7 @@ async function acquireLock( const owner = readLockMetadata(lockFile); if ((!owner && isStaleUnparseableLock(lockFile)) - || (owner && (isStaleLockOwner(owner) || !isLockOwnerAlive(owner.pid)))) { + || (owner && !isLockOwnerAlive(owner.pid))) { try { unlinkSync(lockFile); } catch { /* ignore */ } const retryRelease = tryAcquire(lockFile); if (retryRelease) return retryRelease; @@ -235,7 +228,7 @@ export async function acquireStartupLock(timeoutMs = 30000): Promise<() => void> } /** - * Wait for a process to exit, polling isProcessAlive. + * Wait for a process to exit, polling isRunningProcessAlive. * Returns true if the process exited, false if timeout reached. */ export async function waitForExit(pid: number, timeoutMs = 5000): Promise { From b2abcb5c3cd2ab7e40fba25e16389fc94875b86d Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Mon, 20 Apr 2026 13:33:34 +0530 Subject: [PATCH 16/16] test: fix restore mock setup in start tests --- packages/cli/__tests__/commands/start.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 3e75f6800..cd78d8e8c 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -254,7 +254,6 @@ beforeEach(async () => { mockSessionManager.restore.mockResolvedValue({ id: "app-orchestrator-restored" }); mockSessionManager.get.mockReset(); mockSessionManager.spawnOrchestrator.mockReset(); - mockSessionManager.restore.mockReset(); mockSessionManager.kill.mockReset(); mockExec.mockReset(); mockExecSilent.mockReset();