Skip to content
36 changes: 36 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,42 @@ 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("preserves stuck state when getActivityState throws", async () => {
vi.mocked(mockAgent.getActivityState).mockRejectedValue(new Error("probe failed"));

Expand Down
82 changes: 67 additions & 15 deletions packages/core/src/lifecycle-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,19 @@ 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 =
config.projects[session.projectId]?.reactions?.["agent-stuck"] ??
config.reactions["agent-stuck"];
const thresholdStr = (stuckReaction as Record<string, unknown> | undefined)?.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 +202,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 +222,26 @@ 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

// Stuck detection: if agent is idle/blocked beyond the configured threshold,
// transition to "stuck" so the agent-stuck reaction can fire.
// BUT: if the session already has a PR, fall through to step 4 so
// merge-readiness is checked first. Without this, stuck detection
// short-circuits before the PR state checks and "mergeable" is
// never reached — causing the pipeline to stall.
if (
(activityState.state === "idle" || activityState.state === "blocked") &&
activityState.timestamp
) {
if (isIdleBeyondThreshold(session, activityState.timestamp) && !session.pr) {
return "stuck";
}
// Store idle timestamp for post-PR-check stuck detection (step 4b)
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 +302,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 @@ -702,18 +754,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
99 changes: 99 additions & 0 deletions packages/plugins/agent-opencode/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,105 @@ describe("detectActivity", () => {
describe("getActivityState", () => {
const agent = create();

function mockOpencodeSessionRows(rows: Array<Record<string, unknown>>) {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
if (cmd === "ps") {
return Promise.resolve({
stdout: " PID TT ARGS\n 789 ttys003 opencode\n",
stderr: "",
});
}
if (cmd === "opencode") {
return Promise.resolve({
stdout: JSON.stringify(rows),
stderr: "",
});
}
return Promise.reject(new Error("unexpected"));
});
}

function mockOpencodeSessionList(updated: string | number) {
mockOpencodeSessionRows([{ id: "ses_abc123", updated }]);
}

it("returns idle when last activity is older than ready threshold", async () => {
mockOpencodeSessionList(new Date(Date.now() - 120_000).toISOString());

const state = await agent.getActivityState(
makeSession({
runtimeHandle: makeTmuxHandle(),
metadata: { opencodeSessionId: "ses_abc123" },
}),
60_000,
);

expect(state?.state).toBe("idle");
});

it("returns ready when last activity is between active window and ready threshold", async () => {
mockOpencodeSessionList(new Date(Date.now() - 45_000).toISOString());

const state = await agent.getActivityState(
makeSession({
runtimeHandle: makeTmuxHandle(),
metadata: { opencodeSessionId: "ses_abc123" },
}),
60_000,
);

expect(state?.state).toBe("ready");
});

it("returns active when last activity is recent", async () => {
mockOpencodeSessionList(new Date(Date.now() - 10_000).toISOString());

const state = await agent.getActivityState(
makeSession({
runtimeHandle: makeTmuxHandle(),
metadata: { opencodeSessionId: "ses_abc123" },
}),
60_000,
);

expect(state?.state).toBe("active");
});

it("returns null when matching session has invalid updated timestamp", async () => {
mockOpencodeSessionRows([{ id: "ses_abc123", updated: "not-a-date" }]);

const state = await agent.getActivityState(
makeSession({
runtimeHandle: makeTmuxHandle(),
metadata: { opencodeSessionId: "ses_abc123" },
}),
60_000,
);

expect(state).toBeNull();
});

it("falls back to AO session title when opencodeSessionId metadata is missing", async () => {
mockOpencodeSessionRows([
{
id: "ses_different",
title: "AO:test-1",
updated: new Date(Date.now() - 5_000).toISOString(),
},
]);

const state = await agent.getActivityState(
makeSession({
runtimeHandle: makeTmuxHandle(),
metadata: {},
}),
60_000,
);

expect(state?.state).toBe("active");
});

it("returns null when opencode session list output is malformed JSON", async () => {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
Expand Down
81 changes: 59 additions & 22 deletions packages/plugins/agent-opencode/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
DEFAULT_READY_THRESHOLD_MS,
shellEscape,
asValidOpenCodeSessionId,
type Agent,
Expand All @@ -19,7 +20,31 @@ const execFileAsync = promisify(execFile);
interface OpenCodeSessionListEntry {
id: string;
title?: string;
updated?: string;
updated?: string | number;
}

function parseUpdatedTimestamp(updated: string | number | undefined): Date | null {
if (typeof updated === "number") {
if (!Number.isFinite(updated)) return null;
const date = new Date(updated);
return Number.isNaN(date.getTime()) ? null : date;
}

if (typeof updated !== "string") return null;

const trimmed = updated.trim();
if (trimmed.length === 0) return null;

if (/^\d+$/.test(trimmed)) {
const epochMs = Number(trimmed);
if (!Number.isFinite(epochMs)) return null;
const date = new Date(epochMs);
return Number.isNaN(date.getTime()) ? null : date;
}

const parsedMs = Date.parse(trimmed);
if (!Number.isFinite(parsedMs)) return null;
return new Date(parsedMs);
}

function parseSessionList(raw: string): OpenCodeSessionListEntry[] {
Expand Down Expand Up @@ -174,38 +199,50 @@ function createOpenCodeAgent(): Agent {

async getActivityState(
session: Session,
_readyThresholdMs?: number,
readyThresholdMs?: number,
): Promise<ActivityDetection | null> {
const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS;
const activeWindowMs = Math.min(30_000, threshold);

// Check if process is running first
const exitedAt = new Date();
if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt };
const running = await this.isProcessRunning(session.runtimeHandle);
if (!running) return { state: "exited", timestamp: exitedAt };

if (session.metadata?.opencodeSessionId) {
try {
const { stdout } = await execFileAsync(
"opencode",
["session", "list", "--format", "json"],
{ timeout: 30_000 },
);
try {
const { stdout } = await execFileAsync(
"opencode",
["session", "list", "--format", "json"],
{
timeout: 30_000,
},
);

const sessions = parseSessionList(stdout);
const targetSession =
(session.metadata?.opencodeSessionId
? sessions.find((s) => s.id === session.metadata.opencodeSessionId)
: undefined) ?? sessions.find((s) => s.title === `AO:${session.id}`);

const sessions = parseSessionList(stdout);
const targetSession = sessions.find((s) => s.id === session.metadata.opencodeSessionId);

if (targetSession) {
const lastActivity = targetSession.updated
? new Date(targetSession.updated)
: undefined;
return {
state: "active",
...(lastActivity &&
!Number.isNaN(lastActivity.getTime()) && { timestamp: lastActivity }),
};
if (targetSession) {
const lastActivity = parseUpdatedTimestamp(targetSession.updated);

if (lastActivity) {
const ageMs = Math.max(0, Date.now() - lastActivity.getTime());
if (ageMs <= activeWindowMs) {
return { state: "active", timestamp: lastActivity };
}
if (ageMs <= threshold) {
return { state: "ready", timestamp: lastActivity };
}
return { state: "idle", timestamp: lastActivity };
}
} catch {

return null;
}
} catch {
return null;
}

return null;
Expand Down