Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit 75ba75d

Browse files
authored
Merge pull request #43 from closedloop-ai/FEAT-165
FEAT-165: Fix job status detection across process lifecycle
2 parents 03571e7 + 788c7e3 commit 75ba75d

10 files changed

Lines changed: 1069 additions & 46 deletions

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.8.7",
3+
"version": "0.8.8",
44
"description": "ClosedLoop Desktop",
55
"author": "ClosedLoop AI <support@closedloop.ai>",
66
"private": true,

apps/desktop/src/server/operations/symphony-job-snapshot.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ export async function readEffectiveStatusFromState(statePath: string): Promise<{
5353
}
5454
}
5555

56+
// ---------------------------------------------------------------------------
57+
// Guard terminal status from state.json when process is alive
58+
// ---------------------------------------------------------------------------
59+
60+
/**
61+
* Suppress terminal status from state.json when the process is still alive.
62+
*/
63+
export function shouldApplyStateStatus(
64+
stateStatus: string,
65+
processRunning: boolean
66+
): boolean {
67+
if (!processRunning) return true;
68+
return !isTerminalJobStatus(stateStatus as LocalJobStatus);
69+
}
70+
5671
// ---------------------------------------------------------------------------
5772
// Task progress / currentTaskId from plan.json
5873
// ---------------------------------------------------------------------------
@@ -179,13 +194,19 @@ export async function enrichJobSnapshot(job: LocalJob): Promise<JobSnapshot> {
179194
if (job.statePath) {
180195
const stateData = await readEffectiveStatusFromState(job.statePath);
181196
if (stateData.phase) {
182-
phase = stateData.phase;
197+
if (processRunning && stateData.status && isTerminalJobStatus(stateData.status)) {
198+
// Don't apply "Completed" phase text while process is still alive
199+
} else {
200+
phase = stateData.phase;
201+
}
183202
}
184203
// Apply effective status from state.json for non-terminal jobs.
185204
// Terminal statuses (COMPLETED, FAILED, CANCELLED, STOPPED) set by the
186205
// process exit handler are authoritative and should not be overridden.
187206
if (stateData.status && !isTerminalJobStatus(status)) {
188-
status = stateData.status;
207+
if (shouldApplyStateStatus(stateData.status, processRunning)) {
208+
status = stateData.status;
209+
}
189210
}
190211
}
191212

apps/desktop/src/server/operations/symphony-kill.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,46 @@ import path from "node:path";
33
import type { OperationDispatcher, OperationRequestContext } from "../operation-dispatcher.js";
44
import { DirectoryNotAllowedError, assertPathAllowed } from "../security.js";
55
import { expandHome, resolveWorktreeDir } from "./symphony-utils.js";
6+
import type { JobStore, LocalJob } from "../../main/job-store.js";
67

78
type ResolveResult =
89
| { pid: number; pidFilePath: string | null; worktreeDir: string | null }
910
| { noPidFile: true; worktreeDir: string }
1011
| { error: string; status: number };
1112

13+
function findJobForKill(
14+
jobStore: JobStore,
15+
pid: number | null,
16+
worktreeDir: string | null
17+
): LocalJob | undefined {
18+
const running = jobStore.listRunning();
19+
if (pid != null) {
20+
const byPid = running.find((j) => j.pid === pid);
21+
if (byPid) return byPid;
22+
}
23+
if (worktreeDir != null) {
24+
return running.find((j) => j.worktreeDir === worktreeDir);
25+
}
26+
return undefined;
27+
}
28+
29+
function markJobStopped(
30+
jobStore: JobStore | undefined,
31+
pid: number | null,
32+
worktreeDir: string | null
33+
): void {
34+
if (!jobStore) return;
35+
const job = findJobForKill(jobStore, pid, worktreeDir);
36+
if (job) {
37+
const now = new Date().toISOString();
38+
jobStore.upsert({ ...job, status: "STOPPED", updatedAt: now, completedAt: now });
39+
}
40+
}
41+
1242
export function registerSymphonyKillRoutes(
1343
dispatcher: OperationDispatcher,
14-
getAllowedDirectories: () => string[]
44+
getAllowedDirectories: () => string[],
45+
jobStore?: JobStore
1546
): void {
1647
dispatcher.register("POST", "/api/engineer/symphony/kill", async (context) => {
1748
try {
@@ -30,6 +61,7 @@ export function registerSymphonyKillRoutes(
3061
if ("noPidFile" in resolved) {
3162
cancelLoop(resolved.worktreeDir);
3263
markStateAsStopped(resolved.worktreeDir);
64+
markJobStopped(jobStore, null, resolved.worktreeDir);
3365
json(context, 200, {
3466
success: true,
3567
message: "No process to kill (no PID file), state marked as stopped"
@@ -50,6 +82,7 @@ export function registerSymphonyKillRoutes(
5082
if (worktreeDir) {
5183
markStateAsStopped(worktreeDir);
5284
}
85+
markJobStopped(jobStore, pid, worktreeDir);
5386
json(context, 200, { success: true, message: "Process already terminated", pid });
5487
return;
5588
}
@@ -69,6 +102,7 @@ export function registerSymphonyKillRoutes(
69102
if (worktreeDir) {
70103
markStateAsStopped(worktreeDir);
71104
}
105+
markJobStopped(jobStore, pid, worktreeDir);
72106

73107
json(context, 200, { success: true, message: "Process terminated", pid });
74108
} catch (error) {
@@ -78,6 +112,13 @@ export function registerSymphonyKillRoutes(
78112
if (worktreeDir) {
79113
markStateAsStopped(worktreeDir);
80114
}
115+
if (jobStore) {
116+
const job = findJobForKill(jobStore, pid, worktreeDir);
117+
if (job) {
118+
const now = new Date().toISOString();
119+
jobStore.upsert({ ...job, status: "STOPPED", updatedAt: now, completedAt: now });
120+
}
121+
}
81122
json(context, 200, { success: true, message: "Process already terminated", pid });
82123
return;
83124
}

0 commit comments

Comments
 (0)