gsd-pi 2.73.0-dev.e1c09f2 → 2.73.1-dev.d987996
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/resources/extensions/gsd/auto.js +5 -1
- package/dist/resources/extensions/gsd/bootstrap/crash-log.js +31 -0
- package/dist/resources/extensions/gsd/bootstrap/register-extension.js +18 -7
- package/dist/resources/extensions/gsd/crash-recovery.js +51 -0
- package/dist/resources/extensions/gsd/gsd-db.js +36 -2
- package/dist/resources/extensions/gsd/milestone-actions.js +19 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +7 -7
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +7 -7
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto.ts +5 -0
- package/src/resources/extensions/gsd/bootstrap/crash-log.ts +32 -0
- package/src/resources/extensions/gsd/bootstrap/register-extension.ts +19 -7
- package/src/resources/extensions/gsd/crash-recovery.ts +59 -0
- package/src/resources/extensions/gsd/gsd-db.ts +52 -2
- package/src/resources/extensions/gsd/milestone-actions.ts +19 -1
- package/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts +235 -0
- package/src/resources/extensions/gsd/tests/gsd-db.test.ts +59 -1
- package/src/resources/extensions/gsd/tests/park-milestone.test.ts +64 -0
- /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → cGmbVq2su4f9tMpgIkG8u}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → cGmbVq2su4f9tMpgIkG8u}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crash-log.ts — Write crash diagnostics to ~/.gsd/crash/<timestamp>.log
|
|
3
|
+
*
|
|
4
|
+
* Zero cross-dependencies: only uses Node.js built-ins so it can be imported
|
|
5
|
+
* safely from uncaughtException / unhandledRejection handlers and from tests
|
|
6
|
+
* without pulling in the full extension dependency tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Write a crash log to ~/.gsd/crash/<timestamp>.log (or $GSD_HOME/crash/).
|
|
15
|
+
* Never throws — must be safe to call from any error handler.
|
|
16
|
+
*/
|
|
17
|
+
export function writeCrashLog(err: Error, source: string): void {
|
|
18
|
+
try {
|
|
19
|
+
const crashDir = join(process.env.GSD_HOME ?? join(homedir(), ".gsd"), "crash");
|
|
20
|
+
mkdirSync(crashDir, { recursive: true });
|
|
21
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
22
|
+
const logPath = join(crashDir, `${ts}.log`);
|
|
23
|
+
const lines = [
|
|
24
|
+
`[gsd] ${source}: ${err.message}`,
|
|
25
|
+
`timestamp: ${new Date().toISOString()}`,
|
|
26
|
+
`pid: ${process.pid}`,
|
|
27
|
+
err.stack ?? "(no stack trace available)",
|
|
28
|
+
"",
|
|
29
|
+
];
|
|
30
|
+
appendFileSync(logPath, lines.join("\n"));
|
|
31
|
+
} catch { /* never throw from crash handler */ }
|
|
32
|
+
}
|
|
@@ -11,6 +11,9 @@ import { registerJournalTools } from "./journal-tools.js";
|
|
|
11
11
|
import { registerQueryTools } from "./query-tools.js";
|
|
12
12
|
import { registerHooks } from "./register-hooks.js";
|
|
13
13
|
import { registerShortcuts } from "./register-shortcuts.js";
|
|
14
|
+
import { writeCrashLog } from "./crash-log.js";
|
|
15
|
+
|
|
16
|
+
export { writeCrashLog } from "./crash-log.js";
|
|
14
17
|
|
|
15
18
|
export function handleRecoverableExtensionProcessError(err: Error): boolean {
|
|
16
19
|
if ((err as NodeJS.ErrnoException).code === "EPIPE") {
|
|
@@ -33,16 +36,25 @@ export function handleRecoverableExtensionProcessError(err: Error): boolean {
|
|
|
33
36
|
function installEpipeGuard(): void {
|
|
34
37
|
if (!process.listeners("uncaughtException").some((listener) => listener.name === "_gsdEpipeGuard")) {
|
|
35
38
|
const _gsdEpipeGuard = (err: Error): void => {
|
|
36
|
-
if (handleRecoverableExtensionProcessError(err))
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
process.
|
|
42
|
-
if (err.stack) process.stderr.write(`${err.stack}\n`);
|
|
39
|
+
if (handleRecoverableExtensionProcessError(err)) return;
|
|
40
|
+
// Write crash log and exit cleanly for unrecoverable errors.
|
|
41
|
+
// Logging and continuing was the original double-fault fix (#3163), but
|
|
42
|
+
// continuing in an indeterminate state is worse than a clean exit (#3348).
|
|
43
|
+
writeCrashLog(err, "uncaughtException");
|
|
44
|
+
process.exit(1);
|
|
43
45
|
};
|
|
44
46
|
process.on("uncaughtException", _gsdEpipeGuard);
|
|
45
47
|
}
|
|
48
|
+
|
|
49
|
+
if (!process.listeners("unhandledRejection").some((listener) => listener.name === "_gsdRejectionGuard")) {
|
|
50
|
+
const _gsdRejectionGuard = (reason: unknown, _promise: Promise<unknown>): void => {
|
|
51
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
52
|
+
if (handleRecoverableExtensionProcessError(err)) return;
|
|
53
|
+
writeCrashLog(err, "unhandledRejection");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
};
|
|
56
|
+
process.on("unhandledRejection", _gsdRejectionGuard);
|
|
57
|
+
}
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
export function registerGsdExtension(pi: ExtensionAPI): void {
|
|
@@ -15,6 +15,7 @@ import { join } from "node:path";
|
|
|
15
15
|
import { gsdRoot } from "./paths.js";
|
|
16
16
|
import { atomicWriteSync } from "./atomic-write.js";
|
|
17
17
|
import { effectiveLockFile } from "./session-lock.js";
|
|
18
|
+
import { emitJournalEvent, queryJournal } from "./journal.js";
|
|
18
19
|
|
|
19
20
|
export interface LockData {
|
|
20
21
|
pid: number;
|
|
@@ -118,3 +119,61 @@ export function formatCrashInfo(lock: LockData): string {
|
|
|
118
119
|
|
|
119
120
|
return lines.join("\n");
|
|
120
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Emit a synthetic unit-end event for a unit that crashed without emitting its own.
|
|
125
|
+
*
|
|
126
|
+
* Queries the journal to find the most recent unit-start for the crashed unit.
|
|
127
|
+
* If a matching unit-end already exists (e.g. the hard timeout fired), this is a
|
|
128
|
+
* no-op. Called during crash recovery, before clearing the stale lock.
|
|
129
|
+
*
|
|
130
|
+
* Addresses the gap reported in #3348 where `unit-start` was emitted but no
|
|
131
|
+
* `unit-end` followed — side effects landed but the worker died before closeout.
|
|
132
|
+
*/
|
|
133
|
+
export function emitCrashRecoveredUnitEnd(basePath: string, lock: LockData): void {
|
|
134
|
+
// Skip bootstrap / starting pseudo-units — they have no meaningful unit-start event.
|
|
135
|
+
if (!lock.unitType || !lock.unitId || lock.unitType === "starting") return;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const all = queryJournal(basePath);
|
|
139
|
+
|
|
140
|
+
// Find the most recent unit-start for this unitId
|
|
141
|
+
const starts = all.filter(
|
|
142
|
+
(e) => e.eventType === "unit-start" && e.data?.unitId === lock.unitId,
|
|
143
|
+
);
|
|
144
|
+
if (starts.length === 0) return;
|
|
145
|
+
|
|
146
|
+
const lastStart = starts[starts.length - 1];
|
|
147
|
+
|
|
148
|
+
// Check if a unit-end was already emitted (e.g. hard timeout fired after the crash)
|
|
149
|
+
const alreadyClosed = all.some(
|
|
150
|
+
(e) =>
|
|
151
|
+
e.eventType === "unit-end" &&
|
|
152
|
+
e.data?.unitId === lock.unitId &&
|
|
153
|
+
e.causedBy?.flowId === lastStart.flowId &&
|
|
154
|
+
e.causedBy?.seq === lastStart.seq,
|
|
155
|
+
);
|
|
156
|
+
if (alreadyClosed) return;
|
|
157
|
+
|
|
158
|
+
// Find the highest seq in this flow for monotonic ordering
|
|
159
|
+
const maxSeq = all
|
|
160
|
+
.filter((e) => e.flowId === lastStart.flowId)
|
|
161
|
+
.reduce((max, e) => Math.max(max, e.seq), lastStart.seq);
|
|
162
|
+
|
|
163
|
+
emitJournalEvent(basePath, {
|
|
164
|
+
ts: new Date().toISOString(),
|
|
165
|
+
flowId: lastStart.flowId,
|
|
166
|
+
seq: maxSeq + 1,
|
|
167
|
+
eventType: "unit-end",
|
|
168
|
+
data: {
|
|
169
|
+
unitType: lock.unitType,
|
|
170
|
+
unitId: lock.unitId,
|
|
171
|
+
status: "crash-recovered",
|
|
172
|
+
artifactVerified: false,
|
|
173
|
+
},
|
|
174
|
+
causedBy: { flowId: lastStart.flowId, seq: lastStart.seq },
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
// Never throw from crash recovery path — journal failure must not block recovery
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -1564,6 +1564,23 @@ export interface TaskRow {
|
|
|
1564
1564
|
sequence: number;
|
|
1565
1565
|
}
|
|
1566
1566
|
|
|
1567
|
+
function parseTaskArrayColumn(raw: unknown): string[] {
|
|
1568
|
+
if (typeof raw !== "string" || raw.trim() === "") return [];
|
|
1569
|
+
|
|
1570
|
+
try {
|
|
1571
|
+
const parsed = JSON.parse(raw);
|
|
1572
|
+
if (Array.isArray(parsed)) return parsed.map((value) => String(value));
|
|
1573
|
+
if (parsed === null || parsed === undefined || parsed === "") return [];
|
|
1574
|
+
return [String(parsed)];
|
|
1575
|
+
} catch {
|
|
1576
|
+
// Older/corrupt rows may contain comma-separated strings instead of JSON.
|
|
1577
|
+
return raw
|
|
1578
|
+
.split(",")
|
|
1579
|
+
.map((value) => value.trim())
|
|
1580
|
+
.filter(Boolean);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1567
1584
|
function rowToTask(row: Record<string, unknown>): TaskRow {
|
|
1568
1585
|
const parseTaskArray = (value: unknown): string[] => {
|
|
1569
1586
|
if (Array.isArray(value)) {
|
|
@@ -1603,8 +1620,8 @@ function rowToTask(row: Record<string, unknown>): TaskRow {
|
|
|
1603
1620
|
blocker_discovered: (row["blocker_discovered"] as number) === 1,
|
|
1604
1621
|
deviations: row["deviations"] as string,
|
|
1605
1622
|
known_issues: row["known_issues"] as string,
|
|
1606
|
-
key_files:
|
|
1607
|
-
key_decisions:
|
|
1623
|
+
key_files: parseTaskArrayColumn(row["key_files"]),
|
|
1624
|
+
key_decisions: parseTaskArrayColumn(row["key_decisions"]),
|
|
1608
1625
|
full_summary_md: row["full_summary_md"] as string,
|
|
1609
1626
|
description: (row["description"] as string) ?? "",
|
|
1610
1627
|
estimate: (row["estimate"] as string) ?? "",
|
|
@@ -2200,6 +2217,39 @@ export function deleteSlice(milestoneId: string, sliceId: string): void {
|
|
|
2200
2217
|
});
|
|
2201
2218
|
}
|
|
2202
2219
|
|
|
2220
|
+
export function deleteMilestone(milestoneId: string): void {
|
|
2221
|
+
if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
|
|
2222
|
+
transaction(() => {
|
|
2223
|
+
currentDb!.prepare(
|
|
2224
|
+
`DELETE FROM verification_evidence WHERE milestone_id = :mid`,
|
|
2225
|
+
).run({ ":mid": milestoneId });
|
|
2226
|
+
currentDb!.prepare(
|
|
2227
|
+
`DELETE FROM quality_gates WHERE milestone_id = :mid`,
|
|
2228
|
+
).run({ ":mid": milestoneId });
|
|
2229
|
+
currentDb!.prepare(
|
|
2230
|
+
`DELETE FROM tasks WHERE milestone_id = :mid`,
|
|
2231
|
+
).run({ ":mid": milestoneId });
|
|
2232
|
+
currentDb!.prepare(
|
|
2233
|
+
`DELETE FROM slice_dependencies WHERE milestone_id = :mid`,
|
|
2234
|
+
).run({ ":mid": milestoneId });
|
|
2235
|
+
currentDb!.prepare(
|
|
2236
|
+
`DELETE FROM slices WHERE milestone_id = :mid`,
|
|
2237
|
+
).run({ ":mid": milestoneId });
|
|
2238
|
+
currentDb!.prepare(
|
|
2239
|
+
`DELETE FROM replan_history WHERE milestone_id = :mid`,
|
|
2240
|
+
).run({ ":mid": milestoneId });
|
|
2241
|
+
currentDb!.prepare(
|
|
2242
|
+
`DELETE FROM assessments WHERE milestone_id = :mid`,
|
|
2243
|
+
).run({ ":mid": milestoneId });
|
|
2244
|
+
currentDb!.prepare(
|
|
2245
|
+
`DELETE FROM artifacts WHERE milestone_id = :mid`,
|
|
2246
|
+
).run({ ":mid": milestoneId });
|
|
2247
|
+
currentDb!.prepare(
|
|
2248
|
+
`DELETE FROM milestones WHERE id = :mid`,
|
|
2249
|
+
).run({ ":mid": milestoneId });
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2203
2253
|
export function updateSliceFields(milestoneId: string, sliceId: string, fields: {
|
|
2204
2254
|
title?: string;
|
|
2205
2255
|
risk?: string;
|
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
} from "./paths.js";
|
|
21
21
|
import { invalidateAllCaches } from "./cache.js";
|
|
22
22
|
import { loadQueueOrder, saveQueueOrder } from "./queue-order.js";
|
|
23
|
-
import { getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
|
|
23
|
+
import { deleteMilestone, getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
|
|
24
|
+
import { removeWorktree } from "./worktree-manager.js";
|
|
24
25
|
import { logWarning } from "./workflow-logger.js";
|
|
25
26
|
|
|
26
27
|
// ─── Park ──────────────────────────────────────────────────────────────────
|
|
@@ -110,6 +111,15 @@ export function discardMilestone(basePath: string, milestoneId: string): boolean
|
|
|
110
111
|
const mDir = resolveMilestonePath(basePath, milestoneId);
|
|
111
112
|
if (!mDir || !existsSync(mDir)) return false;
|
|
112
113
|
|
|
114
|
+
try {
|
|
115
|
+
removeWorktree(basePath, milestoneId, {
|
|
116
|
+
branch: `milestone/${milestoneId}`,
|
|
117
|
+
deleteBranch: true,
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
logWarning("engine", `discardMilestone worktree cleanup failed for ${milestoneId}: ${(err as Error).message}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
113
123
|
rmSync(mDir, { recursive: true, force: true });
|
|
114
124
|
|
|
115
125
|
// Prune from queue order if present
|
|
@@ -118,6 +128,14 @@ export function discardMilestone(basePath: string, milestoneId: string): boolean
|
|
|
118
128
|
saveQueueOrder(basePath, order.filter(id => id !== milestoneId));
|
|
119
129
|
}
|
|
120
130
|
|
|
131
|
+
if (isDbAvailable()) {
|
|
132
|
+
try {
|
|
133
|
+
deleteMilestone(milestoneId);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
logWarning("engine", `discardMilestone DB cleanup failed for ${milestoneId}: ${(err as Error).message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
121
139
|
invalidateAllCaches();
|
|
122
140
|
return true;
|
|
123
141
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for #3348 secondary issues — crash handler gaps surfaced after #3696
|
|
3
|
+
*
|
|
4
|
+
* 1. register-extension.ts: writeCrashLog writes to ~/.gsd/crash/ directory
|
|
5
|
+
* 2. register-extension.ts: _gsdRejectionGuard registered for unhandledRejection
|
|
6
|
+
* 3. register-extension.ts: _gsdEpipeGuard exits with code 1 for unrecoverable errors (no log-and-continue)
|
|
7
|
+
* 4. crash-recovery.ts: emitCrashRecoveredUnitEnd closes open unit-start journal entries
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, test } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = dirname(__filename);
|
|
21
|
+
|
|
22
|
+
function makeTmpBase(): string {
|
|
23
|
+
const base = join(tmpdir(), `gsd-test-${randomUUID()}`);
|
|
24
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
25
|
+
return base;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── register-extension source assertions ────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const registerExtSrc = readFileSync(
|
|
31
|
+
join(__dirname, '..', 'bootstrap', 'register-extension.ts'),
|
|
32
|
+
'utf-8',
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
describe('register-extension crash handler secondary fixes (#3348)', () => {
|
|
36
|
+
test('writeCrashLog is exported and writes a file to the crash directory', async () => {
|
|
37
|
+
// Dynamic import so GSD_HOME can be pointed at a temp dir without polluting ~/.gsd
|
|
38
|
+
const tmpHome = join(tmpdir(), `gsd-crash-test-${randomUUID()}`);
|
|
39
|
+
const origHome = process.env.GSD_HOME;
|
|
40
|
+
process.env.GSD_HOME = tmpHome;
|
|
41
|
+
try {
|
|
42
|
+
const { writeCrashLog } = await import('../bootstrap/crash-log.ts');
|
|
43
|
+
const err = new Error('test crash from secondary regression test');
|
|
44
|
+
writeCrashLog(err, 'uncaughtException');
|
|
45
|
+
|
|
46
|
+
const crashDir = join(tmpHome, 'crash');
|
|
47
|
+
assert.ok(existsSync(crashDir), 'crash directory should be created');
|
|
48
|
+
|
|
49
|
+
const logs = readdirSync(crashDir).filter((f) => f.endsWith('.log'));
|
|
50
|
+
assert.equal(logs.length, 1, 'exactly one crash log should be written');
|
|
51
|
+
|
|
52
|
+
const content = readFileSync(join(crashDir, logs[0]), 'utf-8');
|
|
53
|
+
assert.ok(content.includes('test crash from secondary regression test'), 'log should contain error message');
|
|
54
|
+
assert.ok(content.includes('uncaughtException'), 'log should identify the source');
|
|
55
|
+
assert.ok(content.includes('pid:'), 'log should include process pid');
|
|
56
|
+
} finally {
|
|
57
|
+
process.env.GSD_HOME = origHome;
|
|
58
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('_gsdRejectionGuard is registered for unhandledRejection', () => {
|
|
63
|
+
assert.match(
|
|
64
|
+
registerExtSrc,
|
|
65
|
+
/_gsdRejectionGuard/,
|
|
66
|
+
'_gsdRejectionGuard handler should be defined',
|
|
67
|
+
);
|
|
68
|
+
assert.match(
|
|
69
|
+
registerExtSrc,
|
|
70
|
+
/unhandledRejection/,
|
|
71
|
+
'installEpipeGuard should register an unhandledRejection handler',
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('_gsdEpipeGuard calls process.exit(1) for unrecoverable errors, not log-and-continue', () => {
|
|
76
|
+
// The original #3696 fix replaced "throw err" with a log-and-continue.
|
|
77
|
+
// The secondary fix replaces that with writeCrashLog + process.exit(1).
|
|
78
|
+
assert.ok(
|
|
79
|
+
!registerExtSrc.includes('process.stderr.write(`[gsd] uncaught extension error (non-fatal)'),
|
|
80
|
+
'_gsdEpipeGuard should NOT log errors as non-fatal and continue',
|
|
81
|
+
);
|
|
82
|
+
assert.match(
|
|
83
|
+
registerExtSrc,
|
|
84
|
+
/process\.exit\(1\)/,
|
|
85
|
+
'_gsdEpipeGuard should call process.exit(1) for unrecoverable errors',
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('writeCrashLog never throws even when directory is unwritable', async () => {
|
|
90
|
+
const { writeCrashLog } = await import('../bootstrap/crash-log.ts');
|
|
91
|
+
const origHome = process.env.GSD_HOME;
|
|
92
|
+
// Point at a path that will fail to mkdir (e.g. a file that exists as non-dir)
|
|
93
|
+
const tmpFile = join(tmpdir(), `gsd-not-a-dir-${randomUUID()}`);
|
|
94
|
+
// Don't create it — mkdirSync with bad path should be caught internally
|
|
95
|
+
process.env.GSD_HOME = join(tmpFile, 'nested', 'deeply');
|
|
96
|
+
try {
|
|
97
|
+
// Should not throw
|
|
98
|
+
assert.doesNotThrow(() => {
|
|
99
|
+
writeCrashLog(new Error('should not throw'), 'test');
|
|
100
|
+
});
|
|
101
|
+
} finally {
|
|
102
|
+
process.env.GSD_HOME = origHome;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ─── emitCrashRecoveredUnitEnd ────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
describe('emitCrashRecoveredUnitEnd (#3348)', () => {
|
|
110
|
+
test('emits synthetic unit-end when unit-start has no matching unit-end', async () => {
|
|
111
|
+
const base = makeTmpBase();
|
|
112
|
+
try {
|
|
113
|
+
const { emitJournalEvent, queryJournal } = await import('../journal.ts');
|
|
114
|
+
const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
|
|
115
|
+
|
|
116
|
+
const flowId = randomUUID();
|
|
117
|
+
const unitStartSeq = 5;
|
|
118
|
+
|
|
119
|
+
// Emit a unit-start with no corresponding unit-end (simulating a crash)
|
|
120
|
+
emitJournalEvent(base, {
|
|
121
|
+
ts: new Date().toISOString(),
|
|
122
|
+
flowId,
|
|
123
|
+
seq: unitStartSeq,
|
|
124
|
+
eventType: 'unit-start',
|
|
125
|
+
data: { unitType: 'execute-task', unitId: 'M001/S01/T01' },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const lock = {
|
|
129
|
+
pid: 99999,
|
|
130
|
+
startedAt: new Date().toISOString(),
|
|
131
|
+
unitType: 'execute-task',
|
|
132
|
+
unitId: 'M001/S01/T01',
|
|
133
|
+
unitStartedAt: new Date().toISOString(),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
emitCrashRecoveredUnitEnd(base, lock);
|
|
137
|
+
|
|
138
|
+
const events = queryJournal(base);
|
|
139
|
+
const ends = events.filter((e) => e.eventType === 'unit-end');
|
|
140
|
+
assert.equal(ends.length, 1, 'should emit exactly one unit-end');
|
|
141
|
+
assert.equal(ends[0].data?.unitId, 'M001/S01/T01');
|
|
142
|
+
assert.equal(ends[0].data?.status, 'crash-recovered');
|
|
143
|
+
assert.equal(ends[0].causedBy?.flowId, flowId);
|
|
144
|
+
assert.equal(ends[0].causedBy?.seq, unitStartSeq);
|
|
145
|
+
assert.ok(ends[0].seq > unitStartSeq, 'unit-end seq must be higher than unit-start seq');
|
|
146
|
+
} finally {
|
|
147
|
+
rmSync(base, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('is a no-op when unit-end was already emitted (e.g. hard timeout fired)', async () => {
|
|
152
|
+
const base = makeTmpBase();
|
|
153
|
+
try {
|
|
154
|
+
const { emitJournalEvent, queryJournal } = await import('../journal.ts');
|
|
155
|
+
const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
|
|
156
|
+
|
|
157
|
+
const flowId = randomUUID();
|
|
158
|
+
emitJournalEvent(base, {
|
|
159
|
+
ts: new Date().toISOString(),
|
|
160
|
+
flowId,
|
|
161
|
+
seq: 3,
|
|
162
|
+
eventType: 'unit-start',
|
|
163
|
+
data: { unitType: 'plan-slice', unitId: 'M001/S02' },
|
|
164
|
+
});
|
|
165
|
+
// Hard timeout already emitted a unit-end
|
|
166
|
+
emitJournalEvent(base, {
|
|
167
|
+
ts: new Date().toISOString(),
|
|
168
|
+
flowId,
|
|
169
|
+
seq: 4,
|
|
170
|
+
eventType: 'unit-end',
|
|
171
|
+
data: { unitType: 'plan-slice', unitId: 'M001/S02', status: 'cancelled' },
|
|
172
|
+
causedBy: { flowId, seq: 3 },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const lock = {
|
|
176
|
+
pid: 99999,
|
|
177
|
+
startedAt: new Date().toISOString(),
|
|
178
|
+
unitType: 'plan-slice',
|
|
179
|
+
unitId: 'M001/S02',
|
|
180
|
+
unitStartedAt: new Date().toISOString(),
|
|
181
|
+
};
|
|
182
|
+
emitCrashRecoveredUnitEnd(base, lock);
|
|
183
|
+
|
|
184
|
+
const ends = queryJournal(base).filter((e) => e.eventType === 'unit-end');
|
|
185
|
+
assert.equal(ends.length, 1, 'should not emit a duplicate unit-end');
|
|
186
|
+
assert.equal(ends[0].data?.status, 'cancelled', 'original unit-end should be preserved');
|
|
187
|
+
} finally {
|
|
188
|
+
rmSync(base, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('is a no-op for "starting" pseudo-units (bootstrap crash)', async () => {
|
|
193
|
+
const base = makeTmpBase();
|
|
194
|
+
try {
|
|
195
|
+
const { queryJournal } = await import('../journal.ts');
|
|
196
|
+
const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
|
|
197
|
+
|
|
198
|
+
const lock = {
|
|
199
|
+
pid: 99999,
|
|
200
|
+
startedAt: new Date().toISOString(),
|
|
201
|
+
unitType: 'starting',
|
|
202
|
+
unitId: 'bootstrap',
|
|
203
|
+
unitStartedAt: new Date().toISOString(),
|
|
204
|
+
};
|
|
205
|
+
emitCrashRecoveredUnitEnd(base, lock);
|
|
206
|
+
|
|
207
|
+
const events = queryJournal(base);
|
|
208
|
+
assert.equal(events.length, 0, 'should emit nothing for starting/bootstrap pseudo-units');
|
|
209
|
+
} finally {
|
|
210
|
+
rmSync(base, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('is a no-op when no unit-start exists in the journal', async () => {
|
|
215
|
+
const base = makeTmpBase();
|
|
216
|
+
try {
|
|
217
|
+
const { queryJournal } = await import('../journal.ts');
|
|
218
|
+
const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
|
|
219
|
+
|
|
220
|
+
const lock = {
|
|
221
|
+
pid: 99999,
|
|
222
|
+
startedAt: new Date().toISOString(),
|
|
223
|
+
unitType: 'execute-task',
|
|
224
|
+
unitId: 'M002/S01/T03',
|
|
225
|
+
unitStartedAt: new Date().toISOString(),
|
|
226
|
+
};
|
|
227
|
+
emitCrashRecoveredUnitEnd(base, lock);
|
|
228
|
+
|
|
229
|
+
const events = queryJournal(base);
|
|
230
|
+
assert.equal(events.length, 0, 'should emit nothing when there is no journal entry to close');
|
|
231
|
+
} finally {
|
|
232
|
+
rmSync(base, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -15,10 +15,14 @@ import {
|
|
|
15
15
|
getRequirementById,
|
|
16
16
|
getActiveDecisions,
|
|
17
17
|
getActiveRequirements,
|
|
18
|
-
getTask,
|
|
19
18
|
transaction,
|
|
20
19
|
_getAdapter,
|
|
21
20
|
_resetProvider,
|
|
21
|
+
insertMilestone,
|
|
22
|
+
insertSlice,
|
|
23
|
+
insertTask,
|
|
24
|
+
getTask,
|
|
25
|
+
getSliceTasks,
|
|
22
26
|
} from '../gsd-db.ts';
|
|
23
27
|
|
|
24
28
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -460,6 +464,60 @@ describe('gsd-db', () => {
|
|
|
460
464
|
assert.ok(!wasDbOpenAttempted(), 'wasDbOpenAttempted should reset after closeDatabase');
|
|
461
465
|
});
|
|
462
466
|
|
|
467
|
+
test('gsd-db: rowToTask tolerates corrupt comma-separated task arrays', () => {
|
|
468
|
+
openDatabase(':memory:');
|
|
469
|
+
insertMilestone({ id: 'M001', status: 'active' });
|
|
470
|
+
insertSlice({ milestoneId: 'M001', id: 'S01', status: 'active' });
|
|
471
|
+
insertTask({
|
|
472
|
+
milestoneId: 'M001',
|
|
473
|
+
sliceId: 'S01',
|
|
474
|
+
id: 'T01',
|
|
475
|
+
title: 'Recover corrupt arrays',
|
|
476
|
+
planning: {
|
|
477
|
+
description: 'desc',
|
|
478
|
+
estimate: 'small',
|
|
479
|
+
files: ['src/original.ts'],
|
|
480
|
+
verify: 'npm test',
|
|
481
|
+
inputs: ['docs/original.md'],
|
|
482
|
+
expectedOutput: ['dist/original.md'],
|
|
483
|
+
observabilityImpact: '',
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const adapter = _getAdapter()!;
|
|
488
|
+
adapter.prepare(
|
|
489
|
+
`UPDATE tasks
|
|
490
|
+
SET files = ?, inputs = ?, expected_output = ?, key_files = ?, key_decisions = ?
|
|
491
|
+
WHERE milestone_id = ? AND slice_id = ? AND id = ?`,
|
|
492
|
+
).run(
|
|
493
|
+
'src-erf/Models/foo.cs, src-erf/Models/bar.cs',
|
|
494
|
+
'docs/input-a.md, docs/input-b.md',
|
|
495
|
+
'dist/out-a.md, dist/out-b.md',
|
|
496
|
+
'src/resources/extensions/gsd/gsd-db.ts, src/resources/extensions/gsd/state.ts',
|
|
497
|
+
'"decision-1"',
|
|
498
|
+
'M001',
|
|
499
|
+
'S01',
|
|
500
|
+
'T01',
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const task = getTask('M001', 'S01', 'T01');
|
|
504
|
+
assert.ok(task, 'getTask should still return the corrupt row');
|
|
505
|
+
assert.deepStrictEqual(task!.files, ['src-erf/Models/foo.cs', 'src-erf/Models/bar.cs']);
|
|
506
|
+
assert.deepStrictEqual(task!.inputs, ['docs/input-a.md', 'docs/input-b.md']);
|
|
507
|
+
assert.deepStrictEqual(task!.expected_output, ['dist/out-a.md', 'dist/out-b.md']);
|
|
508
|
+
assert.deepStrictEqual(
|
|
509
|
+
task!.key_files,
|
|
510
|
+
['src/resources/extensions/gsd/gsd-db.ts', 'src/resources/extensions/gsd/state.ts'],
|
|
511
|
+
);
|
|
512
|
+
assert.deepStrictEqual(task!.key_decisions, ['decision-1']);
|
|
513
|
+
|
|
514
|
+
const sliceTasks = getSliceTasks('M001', 'S01');
|
|
515
|
+
assert.equal(sliceTasks.length, 1, 'getSliceTasks should also survive corrupt rows');
|
|
516
|
+
assert.deepStrictEqual(sliceTasks[0]!.files, task!.files);
|
|
517
|
+
|
|
518
|
+
closeDatabase();
|
|
519
|
+
});
|
|
520
|
+
|
|
463
521
|
// ─── Final Report ──────────────────────────────────────────────────────────
|
|
464
522
|
|
|
465
523
|
});
|
|
@@ -3,10 +3,22 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
6
7
|
|
|
7
8
|
import { deriveState, invalidateStateCache, getActiveMilestoneId } from '../state.ts';
|
|
8
9
|
import { clearPathCache } from '../paths.ts';
|
|
9
10
|
import { parkMilestone, unparkMilestone, discardMilestone, isParked, getParkedReason } from '../milestone-actions.ts';
|
|
11
|
+
import {
|
|
12
|
+
closeDatabase,
|
|
13
|
+
getMilestone,
|
|
14
|
+
getMilestoneSlices,
|
|
15
|
+
getSliceTasks,
|
|
16
|
+
insertMilestone,
|
|
17
|
+
insertSlice,
|
|
18
|
+
insertTask,
|
|
19
|
+
openDatabase,
|
|
20
|
+
} from "../gsd-db.ts";
|
|
21
|
+
import { createWorktree } from "../worktree-manager.ts";
|
|
10
22
|
|
|
11
23
|
|
|
12
24
|
|
|
@@ -60,9 +72,29 @@ function createMilestone(base: string, mid: string, opts?: { withRoadmap?: boole
|
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
function cleanup(base: string): void {
|
|
75
|
+
try {
|
|
76
|
+
closeDatabase();
|
|
77
|
+
} catch {
|
|
78
|
+
// ignore
|
|
79
|
+
}
|
|
63
80
|
rmSync(base, { recursive: true, force: true });
|
|
64
81
|
}
|
|
65
82
|
|
|
83
|
+
function run(cmd: string, cwd: string): string {
|
|
84
|
+
return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function initGitRepo(base: string): void {
|
|
88
|
+
writeFileSync(join(base, "README.md"), "# test\n", "utf-8");
|
|
89
|
+
writeFileSync(join(base, ".gsd", "STATE.md"), "# State\n", "utf-8");
|
|
90
|
+
run("git init", base);
|
|
91
|
+
run("git config user.email test@test.com", base);
|
|
92
|
+
run("git config user.name Test", base);
|
|
93
|
+
run("git add .", base);
|
|
94
|
+
run('git commit -m "init"', base);
|
|
95
|
+
run("git branch -M main", base);
|
|
96
|
+
}
|
|
97
|
+
|
|
66
98
|
function clearCaches(): void {
|
|
67
99
|
clearPathCache();
|
|
68
100
|
invalidateStateCache();
|
|
@@ -294,6 +326,38 @@ test('discardMilestone updates queue order', () => {
|
|
|
294
326
|
}
|
|
295
327
|
});
|
|
296
328
|
|
|
329
|
+
test('discardMilestone removes DB rows, worktree, and milestone branch', () => {
|
|
330
|
+
const base = createFixtureBase();
|
|
331
|
+
try {
|
|
332
|
+
createMilestone(base, 'M001', { withRoadmap: true });
|
|
333
|
+
initGitRepo(base);
|
|
334
|
+
clearCaches();
|
|
335
|
+
|
|
336
|
+
assert.ok(openDatabase(join(base, '.gsd', 'gsd.db')), 'database opens');
|
|
337
|
+
insertMilestone({ id: 'M001', title: 'Discard me', status: 'active' });
|
|
338
|
+
insertSlice({ milestoneId: 'M001', id: 'S01', title: 'Only slice', status: 'pending' });
|
|
339
|
+
insertTask({ milestoneId: 'M001', sliceId: 'S01', id: 'T01', title: 'Only task', status: 'pending' });
|
|
340
|
+
|
|
341
|
+
const wt = createWorktree(base, 'M001', { branch: 'milestone/M001' });
|
|
342
|
+
assert.ok(existsSync(wt.path), 'worktree exists before discard');
|
|
343
|
+
assert.ok(run('git branch', base).includes('milestone/M001'), 'milestone branch exists before discard');
|
|
344
|
+
assert.ok(getMilestone('M001'), 'milestone exists in DB before discard');
|
|
345
|
+
assert.equal(getMilestoneSlices('M001').length, 1, 'slice exists in DB before discard');
|
|
346
|
+
assert.equal(getSliceTasks('M001', 'S01').length, 1, 'task exists in DB before discard');
|
|
347
|
+
|
|
348
|
+
const success = discardMilestone(base, 'M001');
|
|
349
|
+
assert.ok(success, 'discardMilestone returns true');
|
|
350
|
+
|
|
351
|
+
assert.equal(getMilestone('M001'), null, 'milestone row removed from DB');
|
|
352
|
+
assert.equal(getMilestoneSlices('M001').length, 0, 'slice rows removed from DB');
|
|
353
|
+
assert.equal(getSliceTasks('M001', 'S01').length, 0, 'task rows removed from DB');
|
|
354
|
+
assert.ok(!existsSync(wt.path), 'worktree removed after discard');
|
|
355
|
+
assert.ok(!run('git branch', base).includes('milestone/M001'), 'milestone branch removed after discard');
|
|
356
|
+
} finally {
|
|
357
|
+
cleanup(base);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
297
361
|
// ─── Test 12: All milestones parked → no active milestone ─────────────
|
|
298
362
|
test('All milestones parked → no active', async () => {
|
|
299
363
|
const base = createFixtureBase();
|
|
File without changes
|
|
File without changes
|