fix(gsd): set completed_at when reconciling task status to complete

reconcileSliceTasks called updateTaskStatus without a completedAt
timestamp, leaving tasks.completed_at NULL for all tasks completed
via the file-existence reconcile path.

Closes #4129

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nils Reeh 2026-04-14 02:10:09 +02:00
parent 6ba83c83c2
commit 365b36d96a
2 changed files with 43 additions and 1 deletions

View file

@ -684,7 +684,7 @@ async function reconcileSliceTasks(
const summaryPath = resolveTaskFile(basePath, milestoneId, sliceId, t.id, "SUMMARY");
if (summaryPath && existsSync(summaryPath)) {
try {
updateTaskStatus(milestoneId, sliceId, t.id, "complete");
updateTaskStatus(milestoneId, sliceId, t.id, "complete", new Date().toISOString());
logWarning("reconcile", `task ${milestoneId}/${sliceId}/${t.id} status reconciled from "${t.status}" to "complete" (#2514)`, { mid: milestoneId, sid: sliceId, tid: t.id });
reconciled = true;
} catch (e) {

View file

@ -0,0 +1,42 @@
/**
* Regression test for #4129: tasks.completed_at stays NULL when status is
* reconciled to 'complete' via the file-existence path in state.ts.
*
* Root cause: reconcileSliceTasks called
* updateTaskStatus(milestoneId, sliceId, t.id, "complete")
* without a completedAt timestamp, so the column stays NULL.
*
* Fix: pass new Date().toISOString() as the 5th argument.
*/
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const stateSource = readFileSync(join(__dirname, "..", "state.ts"), "utf-8");
describe("completed-at reconcile (#4129)", () => {
test("reconcileSliceTasks passes a completedAt timestamp when setting status to complete", () => {
// Before the fix, state.ts had:
// updateTaskStatus(milestoneId, sliceId, t.id, "complete")
// which leaves completed_at NULL in the DB.
// After the fix, a timestamp must be passed as the 5th argument.
assert.doesNotMatch(
stateSource,
/updateTaskStatus\(\s*milestoneId\s*,\s*sliceId\s*,\s*t\.id\s*,\s*["']complete["']\s*\)/,
"updateTaskStatus must not be called without a completedAt timestamp when reconciling tasks to 'complete' (#4129)",
);
});
test("reconcileSliceTasks passes new Date().toISOString() as the completedAt argument", () => {
// Positive assertion: the fixed call must include a timestamp.
assert.match(
stateSource,
/updateTaskStatus\(\s*milestoneId\s*,\s*sliceId\s*,\s*t\.id\s*,\s*["']complete["']\s*,\s*new Date\(\)\.toISOString\(\)\s*\)/,
"reconcileSliceTasks must pass new Date().toISOString() as completedAt when setting task status to 'complete' (#4129)",
);
});
});