From 35d2426b8d75dcae84c87f4a086f2fb200fb2101 Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Fri, 20 Feb 2026 11:12:15 +0200 Subject: [PATCH 1/2] fix(taskctl): use assignee_pid for liveness in resurrectionScan and heartbeatActiveAgents (#254) --- packages/opencode/src/tasks/pulse.ts | 91 +++++++++++++++++----------- 1 file changed, 55 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/tasks/pulse.ts b/packages/opencode/src/tasks/pulse.ts index 439e407407f8..7267f0ccbef5 100644 --- a/packages/opencode/src/tasks/pulse.ts +++ b/packages/opencode/src/tasks/pulse.ts @@ -114,43 +114,60 @@ export async function resurrectionScan(jobId: string, projectId: string): Promis for (const task of jobTasks) { if (task.status === "in_progress" || task.status === "review") { - // Check: Is the session actively running? - const running = task.assignee ? isSessionActivelyRunning(task.assignee) : false - if (!running) { - let worktreeRemoved = false - const safeWorktree = sanitizeWorktree(task.worktree) - if (safeWorktree) { - try { - await Worktree.remove({ directory: safeWorktree }) - worktreeRemoved = true - log.info("removed worktree during resurrection", { taskId: task.id, worktree: safeWorktree }) - } catch (e) { - log.error("failed to remove worktree during resurrection", { taskId: task.id, error: String(e) }) - } - } - - await Store.updateTask( - projectId, - task.id, - { - status: "open", + const pidAlive = task.assignee_pid ? isPidAlive(task.assignee_pid) : false + const sessionAlive = task.assignee ? isSessionActivelyRunning(task.assignee) : false + const alive = pidAlive || sessionAlive + + if (!alive) { + if (task.pipeline.stage === "developing") { + // Developer finished before restart — advance to reviewing, preserve worktree/branch + await Store.updateTask(projectId, task.id, { assignee: null, assignee_pid: null, - worktree: null, - branch: null, - pipeline: { ...task.pipeline, stage: "idle", last_activity: null }, - }, - true, - ) - - await Store.addComment(projectId, task.id, { - author: "system", - message: worktreeRemoved - ? "Resurrected: agent session not found on Pulse restart. Worktree cleaned up." - : "Resurrected: agent session not found on Pulse restart.", - created_at: new Date().toISOString(), - }) - log.info("resurrected task", { taskId: task.id, jobId, worktreeRemoved }) + pipeline: { ...task.pipeline, stage: "reviewing", last_activity: new Date().toISOString() }, + }, true) + await Store.addComment(projectId, task.id, { + author: "system", + message: "Resurrected: developer session ended before restart. Advanced to reviewing.", + created_at: new Date().toISOString(), + }) + log.info("resurrected developing task to reviewing", { taskId: task.id, jobId }) + } else { + // Other stages — reset to idle (existing behavior) + let worktreeRemoved = false + const safeWorktree = sanitizeWorktree(task.worktree) + if (safeWorktree) { + try { + await Worktree.remove({ directory: safeWorktree }) + worktreeRemoved = true + log.info("removed worktree during resurrection", { taskId: task.id, worktree: safeWorktree }) + } catch (e) { + log.error("failed to remove worktree during resurrection", { taskId: task.id, error: String(e) }) + } + } + await Store.updateTask( + projectId, + task.id, + { + status: "open", + assignee: null, + assignee_pid: null, + worktree: null, + branch: null, + pipeline: { ...task.pipeline, stage: "idle", last_activity: null }, + }, + true, + ) + + await Store.addComment(projectId, task.id, { + author: "system", + message: worktreeRemoved + ? "Resurrected: agent session not found on Pulse restart. Worktree cleaned up." + : "Resurrected: agent session not found on Pulse restart.", + created_at: new Date().toISOString(), + }) + log.info("resurrected task", { taskId: task.id, jobId, worktreeRemoved }) + } } } } @@ -374,10 +391,12 @@ async function heartbeatActiveAgents(jobId: string, projectId: string): Promise< if (task.status === "in_progress" && task.assignee) { // Check: Session is actively running (prompt not finished) const sessionAlive = isSessionActivelyRunning(task.assignee) + const pidAlive = task.assignee_pid ? isPidAlive(task.assignee_pid) : true + const alive = sessionAlive && pidAlive const updated = await Store.getTask(projectId, task.id) if (!updated) continue - if (!sessionAlive) { + if (!alive) { log.info("developer session ended, transitioning to review stage", { taskId: task.id }) await Store.updateTask(projectId, task.id, { pipeline: { ...updated.pipeline, stage: "reviewing", last_activity: now }, From 2af1b95a909aeb3e4c763b0e03c79df1ebfcf392 Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Fri, 20 Feb 2026 11:21:52 +0200 Subject: [PATCH 2/2] fix(taskctl): default pidAlive to false when assignee_pid is null --- packages/opencode/src/tasks/pulse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tasks/pulse.ts b/packages/opencode/src/tasks/pulse.ts index 7267f0ccbef5..73346d1a9e97 100644 --- a/packages/opencode/src/tasks/pulse.ts +++ b/packages/opencode/src/tasks/pulse.ts @@ -391,7 +391,7 @@ async function heartbeatActiveAgents(jobId: string, projectId: string): Promise< if (task.status === "in_progress" && task.assignee) { // Check: Session is actively running (prompt not finished) const sessionAlive = isSessionActivelyRunning(task.assignee) - const pidAlive = task.assignee_pid ? isPidAlive(task.assignee_pid) : true + const pidAlive = task.assignee_pid ? isPidAlive(task.assignee_pid) : false const alive = sessionAlive && pidAlive const updated = await Store.getTask(projectId, task.id) if (!updated) continue