fix: cancelled threads show distinct status instead of completed

Fixes #522
This commit is contained in:
2026-05-25 15:39:18 +00:00
parent 4a39d3fdef
commit 96039dbbbf
9 changed files with 119 additions and 10 deletions
+1 -1
View File
@@ -12,4 +12,4 @@ packages/workflow-template-develop/develop.esm.js
.DS_Store .DS_Store
*.py *.py
.claude .claude
tmp tmp.worktrees/
+1 -1
View File
@@ -8,7 +8,7 @@
], ],
"type": "module", "type": "module",
"bin": { "bin": {
"uwf": "./src/cli.ts" "uwf": "./dist/cli.js"
}, },
"dependencies": { "dependencies": {
"@uncaged/json-cas": "^0.5.3", "@uncaged/json-cas": "^0.5.3",
@@ -40,6 +40,7 @@ describe("resolveHeadHash", () => {
workflow: workflowHash, workflow: workflowHash,
head: headHash, head: headHash,
completedAt: Date.now(), completedAt: Date.now(),
reason: null,
}); });
const result = await resolveHeadHash(tmpDir, threadId); const result = await resolveHeadHash(tmpDir, threadId);
@@ -64,6 +65,7 @@ describe("resolveHeadHash", () => {
workflow: workflowHash, workflow: workflowHash,
head: historicalHash, head: historicalHash,
completedAt: Date.now(), completedAt: Date.now(),
reason: null,
}); });
const result = await resolveHeadHash(tmpDir, threadId); const result = await resolveHeadHash(tmpDir, threadId);
@@ -87,18 +89,21 @@ describe("resolveHeadHash", () => {
workflow: workflowHash, workflow: workflowHash,
head: hash1, head: hash1,
completedAt: Date.now() - 2000, completedAt: Date.now() - 2000,
reason: null,
}); });
await appendThreadHistory(tmpDir, { await appendThreadHistory(tmpDir, {
thread: threadId2, thread: threadId2,
workflow: workflowHash, workflow: workflowHash,
head: hash2, head: hash2,
completedAt: Date.now() - 1000, completedAt: Date.now() - 1000,
reason: null,
}); });
await appendThreadHistory(tmpDir, { await appendThreadHistory(tmpDir, {
thread: threadId3, thread: threadId3,
workflow: workflowHash, workflow: workflowHash,
head: hash3, head: hash3,
completedAt: Date.now(), completedAt: Date.now(),
reason: null,
}); });
const result = await resolveHeadHash(tmpDir, threadId2); const result = await resolveHeadHash(tmpDir, threadId2);
@@ -0,0 +1,85 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { appendThreadHistory, loadThreadHistory } from "../store.js";
describe("thread cancel status", () => {
test("cancelled history entry has reason 'cancelled'", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL1" as ThreadId;
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: "cancelled",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBe("cancelled");
});
test("completed history entry has reason 'completed'", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL2" as ThreadId;
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: "completed",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBe("completed");
});
test("legacy history entry without reason parses as null", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL3" as ThreadId;
// Simulate legacy entry without reason field
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: null,
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBeNull();
});
test("mixed completed and cancelled entries preserve distinct reasons", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
await appendThreadHistory(tmpDir, {
thread: "01JTEST000000000000CANCEL4" as ThreadId,
workflow: "test-workflow",
head: "head1" as CasRef,
completedAt: Date.now(),
reason: "completed",
});
await appendThreadHistory(tmpDir, {
thread: "01JTEST000000000000CANCEL5" as ThreadId,
workflow: "test-workflow",
head: "head2" as CasRef,
completedAt: Date.now(),
reason: "cancelled",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(2);
expect(history[0]?.reason).toBe("completed");
expect(history[1]?.reason).toBe("cancelled");
});
});
@@ -74,6 +74,7 @@ async function completeThread(
workflow: workflowHash, workflow: workflowHash,
head: headHash, head: headHash,
completedAt: Date.now(), completedAt: Date.now(),
reason: null,
}); });
} }
@@ -758,6 +758,7 @@ describe("cmdStepList with completed threads", () => {
workflow: workflowHash, workflow: workflowHash,
head: step2Hash, head: step2Hash,
completedAt: Date.now(), completedAt: Date.now(),
reason: null,
}); });
const result = await cmdStepList(tmpDir, threadId); const result = await cmdStepList(tmpDir, threadId);
@@ -886,6 +887,7 @@ describe("cmdStepShow with completed threads", () => {
workflow: workflowHash, workflow: workflowHash,
head: stepHash, head: stepHash,
completedAt: Date.now(), completedAt: Date.now(),
reason: null,
}); });
const result = await cmdStepShow(tmpDir, stepHash); const result = await cmdStepShow(tmpDir, stepHash);
@@ -949,6 +951,7 @@ describe("cmdThreadRead with completed threads", () => {
workflow: workflowHash, workflow: workflowHash,
head: stepHash, head: stepHash,
completedAt: Date.now(), completedAt: Date.now(),
reason: null,
}); });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
@@ -1011,6 +1014,7 @@ describe("cmdThreadRead with completed threads", () => {
workflow: workflowHash, workflow: workflowHash,
head: step3Hash, head: step3Hash,
completedAt: Date.now(), completedAt: Date.now(),
reason: null,
}); });
const markdown = await cmdThreadRead( const markdown = await cmdThreadRead(
+4 -4
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bun #!/usr/bin/env node
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol"; import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { Command } from "commander"; import { Command } from "commander";
@@ -181,11 +181,11 @@ function parseStatusFilter(status: string | undefined): ThreadStatus[] | null {
if (raw === "active") return ["idle", "running"]; if (raw === "active") return ["idle", "running"];
const parts = raw.split(",").map((s) => s.trim()); const parts = raw.split(",").map((s) => s.trim());
const validStatuses: ThreadStatus[] = ["idle", "running", "completed"]; const validStatuses: ThreadStatus[] = ["idle", "running", "completed", "cancelled"];
for (const part of parts) { for (const part of parts) {
if (!validStatuses.includes(part as ThreadStatus)) { if (!validStatuses.includes(part as ThreadStatus)) {
process.stderr.write( process.stderr.write(
`Invalid status: ${part}. Must be one of: idle, running, completed, active\n`, `Invalid status: ${part}. Must be one of: idle, running, completed, cancelled, active\n`,
); );
process.exit(1); process.exit(1);
} }
@@ -238,7 +238,7 @@ thread
.description("List threads") .description("List threads")
.option( .option(
"--status <status>", "--status <status>",
"Filter by status: idle, running, completed, active (idle+running), or comma-separated values", "Filter by status: idle, running, completed, cancelled, active (idle+running), or comma-separated values",
) )
.option("--after <date>", "Filter threads created after this date (ISO or relative like '7d')") .option("--after <date>", "Filter threads created after this date (ISO or relative like '7d')")
.option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')") .option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')")
+8 -3
View File
@@ -331,7 +331,7 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
fail(`thread not found: ${threadId}`); fail(`thread not found: ${threadId}`);
} }
export type ThreadStatus = "idle" | "running" | "completed"; export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
export type ThreadListItemWithStatus = ThreadListItem & { export type ThreadListItemWithStatus = ThreadListItem & {
status: ThreadStatus; status: ThreadStatus;
@@ -389,7 +389,7 @@ async function collectCompletedThreads(
thread: entry.thread, thread: entry.thread,
workflow: entry.workflow, workflow: entry.workflow,
head: entry.head, head: entry.head,
status: "completed", status: entry.reason === "cancelled" ? "cancelled" : "completed",
}); });
} }
} }
@@ -444,7 +444,10 @@ export async function cmdThreadList(
let items = await collectActiveThreads(storageRoot, uwf, index); let items = await collectActiveThreads(storageRoot, uwf, index);
// Collect completed threads (if relevant for status filter) // Collect completed threads (if relevant for status filter)
const includeCompleted = statusFilter === null || statusFilter.includes("completed"); const includeCompleted =
statusFilter === null ||
statusFilter.includes("completed") ||
statusFilter.includes("cancelled");
if (includeCompleted) { if (includeCompleted) {
const activeIds = new Set(items.map((i) => i.thread)); const activeIds = new Set(items.map((i) => i.thread));
const completedItems = await collectCompletedThreads(storageRoot, activeIds); const completedItems = await collectCompletedThreads(storageRoot, activeIds);
@@ -811,6 +814,7 @@ async function archiveThread(
workflow, workflow,
head, head,
completedAt: Date.now(), completedAt: Date.now(),
reason: "completed",
}); });
} }
@@ -1147,6 +1151,7 @@ export async function cmdThreadCancel(
workflow, workflow,
head, head,
completedAt: Date.now(), completedAt: Date.now(),
reason: "cancelled",
}; };
await appendThreadHistory(storageRoot, historyEntry); await appendThreadHistory(storageRoot, historyEntry);
+10 -1
View File
@@ -88,6 +88,7 @@ export function getHistoryPath(storageRoot: string): string {
export type ThreadHistoryLine = ThreadListItem & { export type ThreadHistoryLine = ThreadListItem & {
completedAt: number; completedAt: number;
reason: "completed" | "cancelled" | null;
}; };
export type UwfStore = { export type UwfStore = {
@@ -228,7 +229,15 @@ export async function loadThreadHistory(storageRoot: string): Promise<ThreadHist
typeof head === "string" && typeof head === "string" &&
typeof completedAt === "number" typeof completedAt === "number"
) { ) {
lines.push({ thread: thread as ThreadId, workflow, head, completedAt }); const reason = rec.reason;
const parsedReason = reason === "completed" || reason === "cancelled" ? reason : null;
lines.push({
thread: thread as ThreadId,
workflow,
head,
completedAt,
reason: parsedReason,
});
} }
} }
return lines; return lines;