From 0d341a7bf175a06162d312f59d02afbcd9360b1d Mon Sep 17 00:00:00 2001 From: Bdandc <106955286+Bdandc@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:03:49 +0300 Subject: [PATCH] fix(core): exempt orchestrator sessions from idle-beyond-threshold stuck transition Orchestrator sessions are human-driven and idle by design while waiting for user input. The idle-beyond-threshold stuck transition did not exempt them, so after the default 10m agent-stuck threshold they were flagged stuck, emitting session.stuck and firing an URGENT desktop notification. Add the same authoritative lifecycle.session.kind === "orchestrator" exemption already used by the agent-report branch immediately above (string-matching role/id suffixes misses numbered orchestrator IDs). Invariants preserved: runtime death, activity waiting_input, and SCM ground truth all short-circuit earlier in determineStatus and are unaffected; worker stuck detection is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/lifecycle-manager.test.ts | 63 +++++++++++++++++++ packages/core/src/lifecycle-manager.ts | 5 ++ 2 files changed, 68 insertions(+) diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index d1fa5ade5..360ca2fb0 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -857,6 +857,69 @@ describe("check (single session)", () => { expect(lm.getStates().get("app-1")).toBe("stuck"); }); + it("does not mark orchestrator sessions stuck when idle exceeds agent-stuck threshold", async () => { + config.reactions = { + "agent-stuck": { auto: true, action: "notify", threshold: "1m", priority: "urgent" }, + }; + + const notifier = createMockNotifier(); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + notifier, + }); + + vi.mocked(plugins.agent.getActivityState).mockResolvedValue({ + state: "idle", + timestamp: new Date(Date.now() - 120_000), + }); + + const session = makeSession({ status: "working", metadata: { agent: "mock-agent" } }); + session.lifecycle.session.kind = "orchestrator"; + + const lm = setupCheck("app-1", { + session, + metaOverrides: { agent: "mock-agent" }, + registry, + }); + + await lm.check("app-1"); + + expect(lm.getStates().get("app-1")).toBe("working"); + expect(notifier.notify).not.toHaveBeenCalled(); + }); + + it("still marks worker sessions stuck and fires the agent-stuck reaction when idle exceeds threshold", async () => { + config.reactions = { + "agent-stuck": { auto: true, action: "notify", threshold: "1m", priority: "urgent" }, + }; + + const notifier = createMockNotifier(); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + notifier, + }); + + vi.mocked(plugins.agent.getActivityState).mockResolvedValue({ + state: "idle", + timestamp: new Date(Date.now() - 120_000), + }); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "working", metadata: { agent: "mock-agent" } }), + metaOverrides: { agent: "mock-agent" }, + registry, + }); + + await lm.check("app-1"); + + expect(lm.getStates().get("app-1")).toBe("stuck"); + expect(notifier.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: "reaction.triggered" }), + ); + }); + it("still auto-detects PR before marking idle sessions as stuck", async () => { config.reactions = { "agent-stuck": { auto: true, action: "notify", threshold: "1m" }, diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 5accdf39e..2008075a0 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -1521,9 +1521,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan }); } + // Orchestrator sessions are human-driven and idle by design while waiting + // for user input — never escalate them to stuck via idle decay. Same + // authoritative `lifecycle.session.kind` check as the agent-report branch + // above (string-matching role/id suffixes misses numbered orchestrator IDs). if ( detectedIdleTimestamp && hasPositiveIdleEvidence(activitySignal) && + lifecycle.session.kind !== "orchestrator" && isIdleBeyondThreshold(session, detectedIdleTimestamp) ) { return commit({