Skip to content
156 changes: 156 additions & 0 deletions packages/core/src/__tests__/lifecycle-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,162 @@ describe("check (single session)", () => {
expect(lm.getStates().get("app-1")).toBe("needs_input");
});

it("transitions to stuck when idle exceeds agent-stuck threshold (OpenCode-style activity)", async () => {
config.reactions = {
"agent-stuck": {
auto: true,
action: "notify",
threshold: "1m",
},
};

vi.mocked(mockAgent.getActivityState).mockResolvedValue({
state: "idle",
timestamp: new Date(Date.now() - 120_000),
});

const session = makeSession({ status: "working", metadata: { agent: "opencode" } });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);

writeMetadata(sessionsDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
agent: "opencode",
});

const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
});

await lm.check("app-1");

expect(lm.getStates().get("app-1")).toBe("stuck");
});

it("uses global agent-stuck threshold when project override omits threshold", async () => {
config.reactions = {
"agent-stuck": {
auto: true,
action: "notify",
threshold: "1m",
},
};
config.projects["my-app"] = {
...config.projects["my-app"],
reactions: {
"agent-stuck": {
auto: true,
action: "notify",
},
},
};

vi.mocked(mockAgent.getActivityState).mockResolvedValue({
state: "idle",
timestamp: new Date(Date.now() - 120_000),
});

const session = makeSession({ status: "working", metadata: { agent: "opencode" } });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);

writeMetadata(sessionsDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
agent: "opencode",
});

const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
});

await lm.check("app-1");

expect(lm.getStates().get("app-1")).toBe("stuck");
});

it("still auto-detects PR before marking idle sessions as stuck", async () => {
config.reactions = {
"agent-stuck": {
auto: true,
action: "notify",
threshold: "1m",
},
};

const mockSCM: SCM = {
name: "mock-scm",
detectPR: vi.fn().mockResolvedValue(makePR()),
getPRState: vi.fn().mockResolvedValue("open"),
mergePR: vi.fn(),
closePR: vi.fn(),
getCIChecks: vi.fn(),
getCISummary: vi.fn().mockResolvedValue("passing"),
getReviews: vi.fn(),
getReviewDecision: vi.fn().mockResolvedValue("none"),
getPendingComments: vi.fn(),
getAutomatedComments: vi.fn(),
getMergeability: vi.fn().mockResolvedValue({
mergeable: false,
ciPassing: true,
approved: false,
noConflicts: true,
blockers: [],
}),
};

const registryWithSCM: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "scm") return mockSCM;
return null;
}),
};

vi.mocked(mockAgent.getActivityState).mockResolvedValue({
state: "idle",
timestamp: new Date(Date.now() - 120_000),
});

const session = makeSession({
status: "working",
branch: "feat/test",
pr: null,
metadata: { agent: "opencode" },
});
vi.mocked(mockSessionManager.get).mockResolvedValue(session);

writeMetadata(sessionsDir, "app-1", {
worktree: "/tmp",
branch: "feat/test",
status: "working",
project: "my-app",
agent: "opencode",
});

const lm = createLifecycleManager({
config,
registry: registryWithSCM,
sessionManager: mockSessionManager,
});

await lm.check("app-1");

expect(mockSCM.detectPR).toHaveBeenCalledOnce();
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta?.["pr"]).toBe(makePR().url);
expect(lm.getStates().get("app-1")).toBe("stuck");
});

it("preserves stuck state when getActivityState throws", async () => {
vi.mocked(mockAgent.getActivityState).mockRejectedValue(new Error("probe failed"));

Expand Down
82 changes: 58 additions & 24 deletions packages/core/src/lifecycle-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
let polling = false; // re-entrancy guard
let allCompleteEmitted = false; // guard against repeated all_complete

/** Check if idle time exceeds the agent-stuck threshold. */
function isIdleBeyondThreshold(session: Session, idleTimestamp: Date): boolean {
const stuckReaction = getReactionConfigForSession(session, "agent-stuck");
const thresholdStr = stuckReaction?.threshold;
if (typeof thresholdStr !== "string") return false;
const stuckThresholdMs = parseDuration(thresholdStr);
if (stuckThresholdMs <= 0) return false;
const idleMs = Date.now() - idleTimestamp.getTime();
return idleMs > stuckThresholdMs;
}

/** Determine current status for a session by polling plugins. */
async function determineStatus(session: Session): Promise<SessionStatus> {
const project = config.projects[session.projectId];
Expand All @@ -189,6 +200,9 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
const agent = registry.get<Agent>("agent", agentName);
const scm = project.scm ? registry.get<SCM>("scm", project.scm.plugin) : null;

// Track activity state across steps so stuck detection can run after PR checks
let detectedIdleTimestamp: Date | null = null;

// 1. Check if runtime is alive
if (session.runtimeHandle) {
const runtime = registry.get<Runtime>("runtime", project.runtime ?? config.defaults.runtime);
Expand All @@ -206,7 +220,16 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
if (activityState) {
if (activityState.state === "waiting_input") return "needs_input";
if (activityState.state === "exited") return "killed";
// active/ready/idle/blocked — proceed to PR checks below

if (
(activityState.state === "idle" || activityState.state === "blocked") &&
activityState.timestamp
) {
detectedIdleTimestamp = activityState.timestamp;
}

// active/ready/idle (below threshold)/blocked (below threshold) —
// proceed to PR checks below
} else {
// getActivityState returned null — fall back to terminal output parsing
const runtime = registry.get<Runtime>(
Expand Down Expand Up @@ -267,21 +290,38 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
// Check reviews
const reviewDecision = await scm.getReviewDecision(session.pr);
if (reviewDecision === "changes_requested") return "changes_requested";
if (reviewDecision === "approved") {
// Check merge readiness
if (reviewDecision === "approved" || reviewDecision === "none") {
// Check merge readiness — treat "none" (no reviewers required)
// the same as "approved" so CI-green PRs reach "mergeable" status
// and fire the merge.ready event / approved-and-green reaction.
const mergeReady = await scm.getMergeability(session.pr);
if (mergeReady.mergeable) return "mergeable";
return "approved";
if (reviewDecision === "approved") return "approved";
}
if (reviewDecision === "pending") return "review_pending";

// 4b. Post-PR stuck detection: agent has a PR open but is idle beyond
// threshold. This catches the case where step 2's stuck check was
// bypassed (getActivityState returned null) or the idle timestamp
// wasn't available during step 2 but the session has been at pr_open
// for a long time. Without this, sessions get stuck at "pr_open" forever.
if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) {
return "stuck";
}

return "pr_open";
} catch {
// SCM check failed — keep current status
}
}

// 5. Default: if agent is active, it's working
// 5. Post-all stuck detection: if we detected idle in step 2 but had no PR,
// still check stuck threshold. This handles agents that finish without creating a PR.
if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) {
return "stuck";
}

// 6. Default: if agent is active, it's working
if (
session.status === "spawning" ||
session.status === SESSION_STATUS.STUCK ||
Expand Down Expand Up @@ -436,10 +476,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
return reactionConfig ? (reactionConfig as ReactionConfig) : null;
}

function updateSessionMetadata(
session: Session,
updates: Partial<Record<string, string>>,
): void {
function updateSessionMetadata(session: Session, updates: Partial<Record<string, string>>): void {
const project = config.projects[session.projectId];
if (!project) return;

Expand Down Expand Up @@ -582,12 +619,9 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
);
}
if (automatedComments !== null) {
const automatedFingerprint = makeFingerprint(
automatedComments.map((comment) => comment.id),
);
const automatedFingerprint = makeFingerprint(automatedComments.map((comment) => comment.id));
const lastAutomatedFingerprint = session.metadata["lastAutomatedReviewFingerprint"] ?? "";
const lastAutomatedDispatchHash =
session.metadata["lastAutomatedReviewDispatchHash"] ?? "";
const lastAutomatedDispatchHash = session.metadata["lastAutomatedReviewDispatchHash"] ?? "";

if (automatedFingerprint !== lastAutomatedFingerprint) {
clearReactionTracker(session.id, automatedReactionKey);
Expand Down Expand Up @@ -702,18 +736,18 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
}
}

// For significant transitions not already notified by a reaction, notify humans
// For transitions not already notified by a reaction, notify humans.
// All priorities (including "info") are routed through notificationRouting
// so the config controls which notifiers receive each priority level.
if (!reactionHandledNotify) {
const priority = inferPriority(eventType);
if (priority !== "info") {
const event = createEvent(eventType, {
sessionId: session.id,
projectId: session.projectId,
message: `${session.id}: ${oldStatus} → ${newStatus}`,
data: { oldStatus, newStatus },
});
await notifyHuman(event, priority);
}
const event = createEvent(eventType, {
sessionId: session.id,
projectId: session.projectId,
message: `${session.id}: ${oldStatus} → ${newStatus}`,
data: { oldStatus, newStatus },
});
await notifyHuman(event, priority);
}
}
} else {
Expand Down
Loading
Loading