gsd-pi 2.65.0-dev.d0517ff → 2.66.0-dev.1b4e601
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/finalize-timeout.js +2 -0
- package/dist/resources/extensions/gsd/auto/loop.js +2 -2
- package/dist/resources/extensions/gsd/auto/phases.js +48 -5
- package/dist/resources/extensions/gsd/auto/types.js +2 -0
- package/dist/resources/extensions/gsd/auto-dashboard.js +2 -1
- package/dist/resources/extensions/gsd/auto-start.js +134 -2
- package/dist/resources/extensions/gsd/bootstrap/system-context.js +3 -1
- package/dist/resources/extensions/gsd/commands/handlers/core.js +3 -2
- package/dist/resources/extensions/gsd/files.js +17 -0
- package/dist/resources/extensions/gsd/notification-overlay.js +1 -1
- package/dist/resources/extensions/gsd/notification-widget.js +2 -1
- package/dist/resources/extensions/gsd/parallel-monitor-overlay.js +1 -1
- package/dist/resources/extensions/gsd/pre-execution-checks.js +16 -2
- package/dist/resources/extensions/gsd/prompts/system.md +2 -2
- package/dist/resources/extensions/subagent/agents.js +19 -5
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +18 -18
- 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 +2 -2
- 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 +18 -18
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- 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/packages/pi-tui/dist/tui.d.ts +1 -0
- package/packages/pi-tui/dist/tui.d.ts.map +1 -1
- package/packages/pi-tui/dist/tui.js +8 -2
- package/packages/pi-tui/dist/tui.js.map +1 -1
- package/packages/pi-tui/src/tui.ts +8 -2
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto/finalize-timeout.ts +3 -0
- package/src/resources/extensions/gsd/auto/loop.ts +2 -2
- package/src/resources/extensions/gsd/auto/phases.ts +68 -3
- package/src/resources/extensions/gsd/auto/types.ts +5 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +2 -1
- package/src/resources/extensions/gsd/auto-start.ts +143 -0
- package/src/resources/extensions/gsd/bootstrap/system-context.ts +3 -1
- package/src/resources/extensions/gsd/commands/handlers/core.ts +3 -2
- package/src/resources/extensions/gsd/files.ts +19 -0
- package/src/resources/extensions/gsd/notification-overlay.ts +1 -1
- package/src/resources/extensions/gsd/notification-widget.ts +2 -1
- package/src/resources/extensions/gsd/parallel-monitor-overlay.ts +1 -1
- package/src/resources/extensions/gsd/pre-execution-checks.ts +19 -2
- package/src/resources/extensions/gsd/prompts/system.md +2 -2
- package/src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts +125 -0
- package/src/resources/extensions/gsd/tests/format-shortcut.test.ts +69 -0
- package/src/resources/extensions/gsd/tests/journal-integration.test.ts +11 -10
- package/src/resources/extensions/gsd/tests/orphaned-worktree-audit.test.ts +189 -0
- package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +66 -0
- package/src/resources/extensions/gsd/tests/subagent-agent-discovery.test.ts +47 -0
- package/src/resources/extensions/subagent/agents.ts +30 -6
- /package/dist/web/standalone/.next/static/{JwdBI3y1H8vtBKiYvWfEK → fcV2z87tmOazTEreFWNdG}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{JwdBI3y1H8vtBKiYvWfEK → fcV2z87tmOazTEreFWNdG}/_ssgManifest.js +0 -0
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
import { createTestContext } from "./test-helpers.ts";
|
|
20
20
|
import {
|
|
21
21
|
withTimeout,
|
|
22
|
+
FINALIZE_PRE_TIMEOUT_MS,
|
|
22
23
|
FINALIZE_POST_TIMEOUT_MS,
|
|
23
24
|
} from "../auto/finalize-timeout.ts";
|
|
25
|
+
import { MAX_FINALIZE_TIMEOUTS } from "../auto/types.ts";
|
|
24
26
|
|
|
25
27
|
const { assertTrue, assertEq, report } = createTestContext();
|
|
26
28
|
|
|
@@ -78,6 +80,25 @@ const { assertTrue, assertEq, report } = createTestContext();
|
|
|
78
80
|
assertTrue(caught, "rejection should propagate");
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
// ═══ Test: FINALIZE_PRE_TIMEOUT_MS is defined and reasonable ═════════════════
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
console.log("\n=== #3757: pre-verification timeout constant is defined and reasonable ===");
|
|
87
|
+
|
|
88
|
+
assertTrue(
|
|
89
|
+
typeof FINALIZE_PRE_TIMEOUT_MS === "number",
|
|
90
|
+
"FINALIZE_PRE_TIMEOUT_MS should be a number",
|
|
91
|
+
);
|
|
92
|
+
assertTrue(
|
|
93
|
+
FINALIZE_PRE_TIMEOUT_MS >= 30_000,
|
|
94
|
+
`pre timeout should be >= 30s (got ${FINALIZE_PRE_TIMEOUT_MS}ms)`,
|
|
95
|
+
);
|
|
96
|
+
assertTrue(
|
|
97
|
+
FINALIZE_PRE_TIMEOUT_MS <= 120_000,
|
|
98
|
+
`pre timeout should be <= 120s (got ${FINALIZE_PRE_TIMEOUT_MS}ms)`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
81
102
|
// ═══ Test: FINALIZE_POST_TIMEOUT_MS is defined and reasonable ═════════════════
|
|
82
103
|
|
|
83
104
|
{
|
|
@@ -113,4 +134,108 @@ const { assertTrue, assertEq, report } = createTestContext();
|
|
|
113
134
|
assertEq(result.timedOut, false, "should not time out");
|
|
114
135
|
}
|
|
115
136
|
|
|
137
|
+
// ═══ Test: runFinalize wraps BOTH pre and post verification with withTimeout ═
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
console.log("\n=== #3757: runFinalize wraps preVerification with timeout guard ===");
|
|
141
|
+
|
|
142
|
+
const { readFileSync } = await import("node:fs");
|
|
143
|
+
const phasesSource = readFileSync(
|
|
144
|
+
new URL("../auto/phases.ts", import.meta.url),
|
|
145
|
+
"utf-8",
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Find the runFinalize function body
|
|
149
|
+
const fnIdx = phasesSource.indexOf("export async function runFinalize(");
|
|
150
|
+
assertTrue(fnIdx > 0, "runFinalize function should exist in phases.ts");
|
|
151
|
+
|
|
152
|
+
const fnBody = phasesSource.slice(fnIdx, fnIdx + 8000);
|
|
153
|
+
|
|
154
|
+
// postUnitPreVerification must be wrapped in withTimeout
|
|
155
|
+
const preTimeoutIdx = fnBody.indexOf("withTimeout(");
|
|
156
|
+
assertTrue(preTimeoutIdx > 0, "withTimeout should appear in runFinalize");
|
|
157
|
+
|
|
158
|
+
const preVerIdx = fnBody.indexOf("postUnitPreVerification");
|
|
159
|
+
assertTrue(preVerIdx > 0, "postUnitPreVerification should appear in runFinalize");
|
|
160
|
+
|
|
161
|
+
// The first withTimeout should wrap postUnitPreVerification (not postUnitPostVerification)
|
|
162
|
+
const firstWithTimeout = fnBody.slice(preTimeoutIdx, preTimeoutIdx + 200);
|
|
163
|
+
assertTrue(
|
|
164
|
+
firstWithTimeout.includes("postUnitPreVerification"),
|
|
165
|
+
"first withTimeout in runFinalize should wrap postUnitPreVerification",
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// postUnitPostVerification must also be wrapped
|
|
169
|
+
const postVerIdx = fnBody.indexOf("postUnitPostVerification");
|
|
170
|
+
assertTrue(postVerIdx > 0, "postUnitPostVerification should appear in runFinalize");
|
|
171
|
+
|
|
172
|
+
// Count withTimeout occurrences — should be at least 2 (pre + post)
|
|
173
|
+
const timeoutCount = (fnBody.match(/withTimeout\(/g) || []).length;
|
|
174
|
+
assertTrue(
|
|
175
|
+
timeoutCount >= 2,
|
|
176
|
+
`runFinalize should have at least 2 withTimeout guards (found ${timeoutCount})`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ═══ Test: MAX_FINALIZE_TIMEOUTS is defined and reasonable ═══════════════════
|
|
181
|
+
|
|
182
|
+
{
|
|
183
|
+
console.log("\n=== #3757: MAX_FINALIZE_TIMEOUTS is defined and reasonable ===");
|
|
184
|
+
|
|
185
|
+
assertTrue(
|
|
186
|
+
typeof MAX_FINALIZE_TIMEOUTS === "number",
|
|
187
|
+
"MAX_FINALIZE_TIMEOUTS should be a number",
|
|
188
|
+
);
|
|
189
|
+
assertTrue(
|
|
190
|
+
MAX_FINALIZE_TIMEOUTS >= 2,
|
|
191
|
+
`threshold should be >= 2 (got ${MAX_FINALIZE_TIMEOUTS})`,
|
|
192
|
+
);
|
|
193
|
+
assertTrue(
|
|
194
|
+
MAX_FINALIZE_TIMEOUTS <= 10,
|
|
195
|
+
`threshold should be <= 10 (got ${MAX_FINALIZE_TIMEOUTS})`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ═══ Test: timeout handlers escalate after consecutive timeouts ══════════════
|
|
200
|
+
|
|
201
|
+
{
|
|
202
|
+
console.log("\n=== #3757: timeout handlers escalate and detach currentUnit ===");
|
|
203
|
+
|
|
204
|
+
const { readFileSync } = await import("node:fs");
|
|
205
|
+
const phasesSource = readFileSync(
|
|
206
|
+
new URL("../auto/phases.ts", import.meta.url),
|
|
207
|
+
"utf-8",
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const fnIdx = phasesSource.indexOf("export async function runFinalize(");
|
|
211
|
+
const fnBody = phasesSource.slice(fnIdx, fnIdx + 8000);
|
|
212
|
+
|
|
213
|
+
// Both timeout handlers should increment consecutiveFinalizeTimeouts
|
|
214
|
+
const incrementCount = (fnBody.match(/consecutiveFinalizeTimeouts\+\+/g) || []).length;
|
|
215
|
+
assertTrue(
|
|
216
|
+
incrementCount >= 2,
|
|
217
|
+
`should increment consecutiveFinalizeTimeouts in both pre and post handlers (found ${incrementCount})`,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Both timeout handlers should check MAX_FINALIZE_TIMEOUTS for escalation
|
|
221
|
+
const escalationCount = (fnBody.match(/MAX_FINALIZE_TIMEOUTS/g) || []).length;
|
|
222
|
+
assertTrue(
|
|
223
|
+
escalationCount >= 2,
|
|
224
|
+
`should check MAX_FINALIZE_TIMEOUTS in both handlers (found ${escalationCount})`,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// Both timeout handlers should null out s.currentUnit to prevent late mutations
|
|
228
|
+
const detachCount = (fnBody.match(/s\.currentUnit\s*=\s*null/g) || []).length;
|
|
229
|
+
assertTrue(
|
|
230
|
+
detachCount >= 2,
|
|
231
|
+
`should detach s.currentUnit in both timeout handlers (found ${detachCount})`,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// Successful finalize should reset the counter
|
|
235
|
+
assertTrue(
|
|
236
|
+
fnBody.includes("consecutiveFinalizeTimeouts = 0"),
|
|
237
|
+
"should reset consecutiveFinalizeTimeouts on successful finalize",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
116
241
|
report();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// GSD Extension — formatShortcut tests
|
|
2
|
+
// Verifies OS-specific keyboard shortcut rendering.
|
|
3
|
+
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { formatShortcut } from '../files.ts';
|
|
7
|
+
|
|
8
|
+
// ─── formatShortcut renders per-platform shortcuts ──────────────────────
|
|
9
|
+
|
|
10
|
+
test('formatShortcut: converts Ctrl+Alt combo on macOS', () => {
|
|
11
|
+
// formatShortcut uses process.platform at module load time.
|
|
12
|
+
// We can only test the current platform's behavior.
|
|
13
|
+
const result = formatShortcut('Ctrl+Alt+G');
|
|
14
|
+
if (process.platform === 'darwin') {
|
|
15
|
+
assert.strictEqual(result, '⌃⌥G', 'macOS should use ⌃⌥ symbols');
|
|
16
|
+
} else {
|
|
17
|
+
assert.strictEqual(result, 'Ctrl+Alt+G', 'non-macOS should pass through unchanged');
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('formatShortcut: converts Ctrl+Alt+N', () => {
|
|
22
|
+
const result = formatShortcut('Ctrl+Alt+N');
|
|
23
|
+
if (process.platform === 'darwin') {
|
|
24
|
+
assert.strictEqual(result, '⌃⌥N');
|
|
25
|
+
} else {
|
|
26
|
+
assert.strictEqual(result, 'Ctrl+Alt+N');
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('formatShortcut: converts Ctrl+Alt+B', () => {
|
|
31
|
+
const result = formatShortcut('Ctrl+Alt+B');
|
|
32
|
+
if (process.platform === 'darwin') {
|
|
33
|
+
assert.strictEqual(result, '⌃⌥B');
|
|
34
|
+
} else {
|
|
35
|
+
assert.strictEqual(result, 'Ctrl+Alt+B');
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('formatShortcut: converts standalone Ctrl modifier', () => {
|
|
40
|
+
const result = formatShortcut('Ctrl+C');
|
|
41
|
+
if (process.platform === 'darwin') {
|
|
42
|
+
assert.strictEqual(result, '⌃C');
|
|
43
|
+
} else {
|
|
44
|
+
assert.strictEqual(result, 'Ctrl+C');
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('formatShortcut: converts Shift modifier', () => {
|
|
49
|
+
const result = formatShortcut('Shift+Tab');
|
|
50
|
+
if (process.platform === 'darwin') {
|
|
51
|
+
assert.strictEqual(result, '⇧Tab');
|
|
52
|
+
} else {
|
|
53
|
+
assert.strictEqual(result, 'Shift+Tab');
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('formatShortcut: converts Cmd modifier', () => {
|
|
58
|
+
const result = formatShortcut('Cmd+S');
|
|
59
|
+
if (process.platform === 'darwin') {
|
|
60
|
+
assert.strictEqual(result, '⌘S');
|
|
61
|
+
} else {
|
|
62
|
+
assert.strictEqual(result, 'Cmd+S');
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('formatShortcut: passes through plain key names', () => {
|
|
67
|
+
assert.strictEqual(formatShortcut('Escape'), 'Escape');
|
|
68
|
+
assert.strictEqual(formatShortcut('Enter'), 'Enter');
|
|
69
|
+
});
|
|
@@ -216,7 +216,7 @@ test("runDispatch emits dispatch-match with correct rule and flowId", async () =
|
|
|
216
216
|
mid: "M001",
|
|
217
217
|
midTitle: "Test Milestone",
|
|
218
218
|
};
|
|
219
|
-
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
|
|
219
|
+
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
|
|
220
220
|
|
|
221
221
|
const result = await runDispatch(ic, preData, loopState);
|
|
222
222
|
|
|
@@ -248,7 +248,7 @@ test("runDispatch emits dispatch-stop when dispatch returns stop action", async
|
|
|
248
248
|
mid: "M001",
|
|
249
249
|
midTitle: "Test",
|
|
250
250
|
};
|
|
251
|
-
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
|
|
251
|
+
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
|
|
252
252
|
|
|
253
253
|
const result = await runDispatch(ic, preData, loopState);
|
|
254
254
|
assert.equal(result.action, "break");
|
|
@@ -303,6 +303,7 @@ test("runDispatch checks prior-slice completion against the project root in work
|
|
|
303
303
|
const result = await runDispatch(ic, preData, {
|
|
304
304
|
recentUnits: [],
|
|
305
305
|
stuckRecoveryAttempts: 0,
|
|
306
|
+
consecutiveFinalizeTimeouts: 0,
|
|
306
307
|
});
|
|
307
308
|
|
|
308
309
|
assert.equal(result.action, "next");
|
|
@@ -343,7 +344,7 @@ test("runUnitPhase emits unit-start and unit-end with causedBy reference", async
|
|
|
343
344
|
isRetry: false,
|
|
344
345
|
previousTier: undefined,
|
|
345
346
|
};
|
|
346
|
-
const loopState: LoopState = { recentUnits: [{ key: "execute-task/M001/S01/T01" }], stuckRecoveryAttempts: 0 };
|
|
347
|
+
const loopState: LoopState = { recentUnits: [{ key: "execute-task/M001/S01/T01" }], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
|
|
347
348
|
|
|
348
349
|
// Start runUnitPhase (it will block on runUnit internally)
|
|
349
350
|
const unitPromise = runUnitPhase(ic, iterData, loopState);
|
|
@@ -400,7 +401,7 @@ test("all events from a mock iteration have monotonically increasing seq and sam
|
|
|
400
401
|
mid: "M001",
|
|
401
402
|
midTitle: "Test",
|
|
402
403
|
};
|
|
403
|
-
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
|
|
404
|
+
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
|
|
404
405
|
const dispatchResult = await runDispatch(ic, preData, loopState);
|
|
405
406
|
assert.equal(dispatchResult.action, "next");
|
|
406
407
|
|
|
@@ -446,7 +447,7 @@ test("dispatch-match events include matchedRule field matching the rule name", a
|
|
|
446
447
|
midTitle: "Test",
|
|
447
448
|
};
|
|
448
449
|
|
|
449
|
-
await runDispatch(ic, preData, { recentUnits: [], stuckRecoveryAttempts: 0 });
|
|
450
|
+
await runDispatch(ic, preData, { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 });
|
|
450
451
|
|
|
451
452
|
const matchEvents = capture.events.filter(e => e.eventType === "dispatch-match");
|
|
452
453
|
assert.equal(matchEvents.length, 1);
|
|
@@ -475,7 +476,7 @@ test("pre-dispatch-hook event is emitted when hooks fire", async () => {
|
|
|
475
476
|
midTitle: "Test",
|
|
476
477
|
};
|
|
477
478
|
|
|
478
|
-
await runDispatch(ic, preData, { recentUnits: [], stuckRecoveryAttempts: 0 });
|
|
479
|
+
await runDispatch(ic, preData, { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 });
|
|
479
480
|
|
|
480
481
|
const hookEvents = capture.events.filter(e => e.eventType === "pre-dispatch-hook");
|
|
481
482
|
assert.equal(hookEvents.length, 1, "should emit one pre-dispatch-hook event");
|
|
@@ -497,7 +498,7 @@ test("terminal event is emitted on milestone-complete", async () => {
|
|
|
497
498
|
}) as any,
|
|
498
499
|
});
|
|
499
500
|
const ic = makeIC(deps);
|
|
500
|
-
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
|
|
501
|
+
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
|
|
501
502
|
|
|
502
503
|
const result = await runPreDispatch(ic, loopState);
|
|
503
504
|
assert.equal(result.action, "break");
|
|
@@ -521,7 +522,7 @@ test("terminal event is emitted on blocked state", async () => {
|
|
|
521
522
|
}) as any,
|
|
522
523
|
});
|
|
523
524
|
const ic = makeIC(deps);
|
|
524
|
-
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
|
|
525
|
+
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
|
|
525
526
|
|
|
526
527
|
const result = await runPreDispatch(ic, loopState);
|
|
527
528
|
assert.equal(result.action, "break");
|
|
@@ -550,7 +551,7 @@ test("milestone-transition event is emitted when milestone changes", async () =>
|
|
|
550
551
|
const ic = makeIC(deps);
|
|
551
552
|
// Session says current milestone is M001, but state will return M002
|
|
552
553
|
ic.s.currentMilestoneId = "M001";
|
|
553
|
-
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
|
|
554
|
+
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
|
|
554
555
|
|
|
555
556
|
await runPreDispatch(ic, loopState);
|
|
556
557
|
|
|
@@ -580,7 +581,7 @@ test("unit-end event contains errorContext when unit is cancelled with structure
|
|
|
580
581
|
isRetry: false,
|
|
581
582
|
previousTier: undefined,
|
|
582
583
|
};
|
|
583
|
-
const loopState: LoopState = { recentUnits: [{ key: "execute-task/M001/S01/T01" }], stuckRecoveryAttempts: 0 };
|
|
584
|
+
const loopState: LoopState = { recentUnits: [{ key: "execute-task/M001/S01/T01" }], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
|
|
584
585
|
|
|
585
586
|
const unitPromise = runUnitPhase(ic, iterData, loopState);
|
|
586
587
|
await new Promise(r => setTimeout(r, 50));
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// GSD2 — Tests for auditOrphanedMilestoneBranches bootstrap audit
|
|
2
|
+
import { describe, test, beforeEach, afterEach } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
import { auditOrphanedMilestoneBranches } from "../auto-start.ts";
|
|
10
|
+
import { openDatabase, closeDatabase, insertMilestone, updateMilestoneStatus } from "../gsd-db.ts";
|
|
11
|
+
|
|
12
|
+
function run(cmd: string, cwd: string): string {
|
|
13
|
+
return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Create a temp git repo with .gsd structure and DB. */
|
|
17
|
+
function createRepo(): string {
|
|
18
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "orphan-audit-test-")));
|
|
19
|
+
run("git init", dir);
|
|
20
|
+
run("git config user.email test@test.com", dir);
|
|
21
|
+
run("git config user.name Test", dir);
|
|
22
|
+
|
|
23
|
+
writeFileSync(join(dir, "README.md"), "# test\n");
|
|
24
|
+
run("git add .", dir);
|
|
25
|
+
run("git commit -m init", dir);
|
|
26
|
+
run("git branch -M main", dir);
|
|
27
|
+
|
|
28
|
+
// Create .gsd structure on disk (not tracked in git)
|
|
29
|
+
mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true });
|
|
30
|
+
|
|
31
|
+
return dir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("auditOrphanedMilestoneBranches", () => {
|
|
35
|
+
let dir: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
dir = createRepo();
|
|
39
|
+
openDatabase(join(dir, ".gsd", "gsd.db"));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
closeDatabase();
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("no milestone branches → no-op", () => {
|
|
48
|
+
const result = auditOrphanedMilestoneBranches(dir, "worktree");
|
|
49
|
+
assert.deepStrictEqual(result.recovered, []);
|
|
50
|
+
assert.deepStrictEqual(result.warnings, []);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("skips in none isolation mode", () => {
|
|
54
|
+
// Create a milestone branch that would otherwise be detected
|
|
55
|
+
run("git branch milestone/M001", dir);
|
|
56
|
+
insertMilestone({ id: "M001", title: "Test", status: "complete" });
|
|
57
|
+
|
|
58
|
+
const result = auditOrphanedMilestoneBranches(dir, "none");
|
|
59
|
+
assert.deepStrictEqual(result.recovered, []);
|
|
60
|
+
assert.deepStrictEqual(result.warnings, []);
|
|
61
|
+
|
|
62
|
+
// Branch should still exist
|
|
63
|
+
const branches = run("git branch --list milestone/M001", dir);
|
|
64
|
+
assert.ok(branches.includes("milestone/M001"), "branch should be preserved in none mode");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("deletes merged branch for completed milestone", () => {
|
|
68
|
+
// Create milestone branch from main (so it's already merged)
|
|
69
|
+
run("git branch milestone/M001", dir);
|
|
70
|
+
insertMilestone({ id: "M001", title: "Test", status: "complete" });
|
|
71
|
+
|
|
72
|
+
const result = auditOrphanedMilestoneBranches(dir, "worktree");
|
|
73
|
+
|
|
74
|
+
assert.ok(result.recovered.length > 0, "should have recovered actions");
|
|
75
|
+
assert.ok(
|
|
76
|
+
result.recovered.some(r => r.includes("Deleted merged branch milestone/M001")),
|
|
77
|
+
"should report branch deletion",
|
|
78
|
+
);
|
|
79
|
+
assert.deepStrictEqual(result.warnings, []);
|
|
80
|
+
|
|
81
|
+
// Branch should be gone
|
|
82
|
+
const branches = run("git branch --list milestone/M001", dir);
|
|
83
|
+
assert.deepStrictEqual(branches, "", "branch should be deleted");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("warns about unmerged branch for completed milestone", () => {
|
|
87
|
+
// Create milestone branch with divergent commits (not merged into main)
|
|
88
|
+
run("git checkout -b milestone/M001", dir);
|
|
89
|
+
writeFileSync(join(dir, "feature.txt"), "new feature\n");
|
|
90
|
+
run("git add feature.txt", dir);
|
|
91
|
+
run("git commit -m \"add feature on milestone branch\"", dir);
|
|
92
|
+
run("git checkout main", dir);
|
|
93
|
+
|
|
94
|
+
insertMilestone({ id: "M001", title: "Test", status: "complete" });
|
|
95
|
+
|
|
96
|
+
const result = auditOrphanedMilestoneBranches(dir, "worktree");
|
|
97
|
+
|
|
98
|
+
assert.deepStrictEqual(result.recovered, [], "should not delete unmerged branch");
|
|
99
|
+
assert.ok(result.warnings.length > 0, "should have warnings");
|
|
100
|
+
assert.ok(
|
|
101
|
+
result.warnings.some(w => w.includes("NOT merged")),
|
|
102
|
+
"should warn about unmerged branch",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Branch should still exist (data safety)
|
|
106
|
+
const branches = run("git branch --list milestone/M001", dir);
|
|
107
|
+
assert.ok(branches.includes("milestone/M001"), "unmerged branch must be preserved");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("skips active (non-complete) milestone branches", () => {
|
|
111
|
+
run("git branch milestone/M001", dir);
|
|
112
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
113
|
+
|
|
114
|
+
const result = auditOrphanedMilestoneBranches(dir, "worktree");
|
|
115
|
+
|
|
116
|
+
assert.deepStrictEqual(result.recovered, []);
|
|
117
|
+
assert.deepStrictEqual(result.warnings, []);
|
|
118
|
+
|
|
119
|
+
// Branch should still exist
|
|
120
|
+
const branches = run("git branch --list milestone/M001", dir);
|
|
121
|
+
assert.ok(branches.includes("milestone/M001"), "active milestone branch should be preserved");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("cleans up orphaned worktree directory for merged milestone", () => {
|
|
125
|
+
// Create milestone branch (merged — same as main)
|
|
126
|
+
run("git branch milestone/M001", dir);
|
|
127
|
+
insertMilestone({ id: "M001", title: "Test", status: "complete" });
|
|
128
|
+
|
|
129
|
+
// Create orphaned worktree directory
|
|
130
|
+
const wtDir = join(dir, ".gsd", "worktrees", "M001");
|
|
131
|
+
mkdirSync(wtDir, { recursive: true });
|
|
132
|
+
writeFileSync(join(wtDir, "leftover.txt"), "orphaned file\n");
|
|
133
|
+
|
|
134
|
+
const result = auditOrphanedMilestoneBranches(dir, "worktree");
|
|
135
|
+
|
|
136
|
+
assert.ok(result.recovered.length > 0, "should have recovered actions");
|
|
137
|
+
assert.ok(
|
|
138
|
+
result.recovered.some(r => r.includes("worktree directory")),
|
|
139
|
+
"should report worktree cleanup",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Worktree directory should be cleaned up
|
|
143
|
+
assert.ok(!existsSync(wtDir), "orphaned worktree directory should be removed");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("handles multiple milestones with mixed states", () => {
|
|
147
|
+
// M001: complete, branch merged → should clean up
|
|
148
|
+
run("git branch milestone/M001", dir);
|
|
149
|
+
insertMilestone({ id: "M001", title: "First", status: "complete" });
|
|
150
|
+
|
|
151
|
+
// M002: active, branch exists → should skip
|
|
152
|
+
run("git branch milestone/M002", dir);
|
|
153
|
+
insertMilestone({ id: "M002", title: "Second", status: "active" });
|
|
154
|
+
|
|
155
|
+
const result = auditOrphanedMilestoneBranches(dir, "worktree");
|
|
156
|
+
|
|
157
|
+
// M001 should be cleaned up
|
|
158
|
+
assert.ok(
|
|
159
|
+
result.recovered.some(r => r.includes("M001")),
|
|
160
|
+
"should clean up completed M001",
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// M002 should not be touched
|
|
164
|
+
const branches = run("git branch --list milestone/M002", dir);
|
|
165
|
+
assert.ok(branches.includes("milestone/M002"), "active M002 branch should be preserved");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("works in branch isolation mode", () => {
|
|
169
|
+
run("git branch milestone/M001", dir);
|
|
170
|
+
insertMilestone({ id: "M001", title: "Test", status: "complete" });
|
|
171
|
+
|
|
172
|
+
const result = auditOrphanedMilestoneBranches(dir, "branch");
|
|
173
|
+
|
|
174
|
+
assert.ok(result.recovered.length > 0, "should work in branch mode too");
|
|
175
|
+
assert.ok(
|
|
176
|
+
result.recovered.some(r => r.includes("Deleted merged branch")),
|
|
177
|
+
"should delete branch in branch mode",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("handles milestone in DB but no branch (no-op)", () => {
|
|
182
|
+
insertMilestone({ id: "M001", title: "Test", status: "complete" });
|
|
183
|
+
|
|
184
|
+
const result = auditOrphanedMilestoneBranches(dir, "worktree");
|
|
185
|
+
|
|
186
|
+
assert.deepStrictEqual(result.recovered, []);
|
|
187
|
+
assert.deepStrictEqual(result.warnings, []);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -1083,11 +1083,77 @@ describe("checkTaskOrdering false positive regression (#3677)", () => {
|
|
|
1083
1083
|
const results = checkTaskOrdering(tasks, "/tmp");
|
|
1084
1084
|
assert.equal(results.length, 0, "Normalized task.files path should not trigger a false positive");
|
|
1085
1085
|
});
|
|
1086
|
+
|
|
1087
|
+
test("annotated inputs still trigger ordering violations against later plain outputs", () => {
|
|
1088
|
+
const tasks = [
|
|
1089
|
+
createTask({
|
|
1090
|
+
id: "T01",
|
|
1091
|
+
sequence: 0,
|
|
1092
|
+
files: [],
|
|
1093
|
+
inputs: ["`later.ts` — needed first"],
|
|
1094
|
+
expected_output: [],
|
|
1095
|
+
}),
|
|
1096
|
+
createTask({
|
|
1097
|
+
id: "T02",
|
|
1098
|
+
sequence: 1,
|
|
1099
|
+
files: [],
|
|
1100
|
+
inputs: [],
|
|
1101
|
+
expected_output: ["later.ts"],
|
|
1102
|
+
}),
|
|
1103
|
+
];
|
|
1104
|
+
|
|
1105
|
+
const results = checkTaskOrdering(tasks, "/tmp");
|
|
1106
|
+
assert.equal(results.length, 1, "Annotated inputs should still match later plain expected_output entries");
|
|
1107
|
+
assert.equal(results[0].target, "`later.ts` — needed first");
|
|
1108
|
+
assert.ok(results[0].message.includes("sequence violation"));
|
|
1109
|
+
});
|
|
1086
1110
|
});
|
|
1087
1111
|
|
|
1088
1112
|
// ─── checkFilePathConsistency additional edge cases ──────────────────────────
|
|
1089
1113
|
|
|
1090
1114
|
describe("checkFilePathConsistency additional edge cases", () => {
|
|
1115
|
+
test("annotated inputs match files that already exist on disk", () => {
|
|
1116
|
+
const tempDir = join(tmpdir(), `pre-exec-test-annotated-input-${Date.now()}`);
|
|
1117
|
+
mkdirSync(tempDir, { recursive: true });
|
|
1118
|
+
writeFileSync(join(tempDir, "existing.ts"), "// content");
|
|
1119
|
+
|
|
1120
|
+
try {
|
|
1121
|
+
const tasks = [
|
|
1122
|
+
createTask({
|
|
1123
|
+
id: "T01",
|
|
1124
|
+
files: [],
|
|
1125
|
+
inputs: ["`existing.ts` — file already on disk"],
|
|
1126
|
+
expected_output: [],
|
|
1127
|
+
}),
|
|
1128
|
+
];
|
|
1129
|
+
|
|
1130
|
+
const results = checkFilePathConsistency(tasks, tempDir);
|
|
1131
|
+
assert.equal(results.length, 0, "Annotated inputs should resolve to the on-disk file path");
|
|
1132
|
+
} finally {
|
|
1133
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
test("plain inputs match prior annotated expected outputs", () => {
|
|
1138
|
+
const tasks = [
|
|
1139
|
+
createTask({
|
|
1140
|
+
id: "T01",
|
|
1141
|
+
files: [],
|
|
1142
|
+
inputs: [],
|
|
1143
|
+
expected_output: ["`generated.ts` — created earlier"],
|
|
1144
|
+
}),
|
|
1145
|
+
createTask({
|
|
1146
|
+
id: "T02",
|
|
1147
|
+
files: [],
|
|
1148
|
+
inputs: ["generated.ts"],
|
|
1149
|
+
expected_output: [],
|
|
1150
|
+
}),
|
|
1151
|
+
];
|
|
1152
|
+
|
|
1153
|
+
const results = checkFilePathConsistency(tasks, "/tmp");
|
|
1154
|
+
assert.equal(results.length, 0, "Prior annotated expected_output entries should satisfy later plain inputs");
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1091
1157
|
test("inputs referencing glob-like patterns should not crash", () => {
|
|
1092
1158
|
// A glob pattern in inputs is unusual but should be handled gracefully.
|
|
1093
1159
|
// The file won't exist on disk, so it should produce a blocking result.
|
|
@@ -42,3 +42,50 @@ test("discoverAgents falls back to legacy .pi/agents when needed", (t) => {
|
|
|
42
42
|
assert.equal(discovery.projectAgentsDir, agentsDir);
|
|
43
43
|
assert.deepEqual(discovery.agents.map((agent) => agent.name), ["ping"]);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
test("discoverAgents accepts tools frontmatter as a YAML list", (t) => {
|
|
47
|
+
const root = makeProjectRoot(t);
|
|
48
|
+
const agentsDir = join(root, ".gsd", "agents");
|
|
49
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(agentsDir, "reviewer.md"),
|
|
52
|
+
[
|
|
53
|
+
"---",
|
|
54
|
+
"name: reviewer",
|
|
55
|
+
"description: review agent",
|
|
56
|
+
"tools:",
|
|
57
|
+
" - bash",
|
|
58
|
+
" - read",
|
|
59
|
+
"---",
|
|
60
|
+
"Review code",
|
|
61
|
+
"",
|
|
62
|
+
].join("\n"),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const discovery = discoverAgents(root, "project");
|
|
66
|
+
|
|
67
|
+
assert.deepEqual(discovery.agents.map((agent) => agent.name), ["reviewer"]);
|
|
68
|
+
assert.deepEqual(discovery.agents[0]?.tools, ["bash", "read"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("discoverAgents still accepts comma-separated tools frontmatter", (t) => {
|
|
72
|
+
const root = makeProjectRoot(t);
|
|
73
|
+
const agentsDir = join(root, ".gsd", "agents");
|
|
74
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
75
|
+
writeFileSync(
|
|
76
|
+
join(agentsDir, "reviewer.md"),
|
|
77
|
+
[
|
|
78
|
+
"---",
|
|
79
|
+
"name: reviewer",
|
|
80
|
+
"description: review agent",
|
|
81
|
+
"tools: bash, read",
|
|
82
|
+
"---",
|
|
83
|
+
"Review code",
|
|
84
|
+
"",
|
|
85
|
+
].join("\n"),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const discovery = discoverAgents(root, "project");
|
|
89
|
+
|
|
90
|
+
assert.deepEqual(discovery.agents[0]?.tools, ["bash", "read"]);
|
|
91
|
+
});
|
|
@@ -25,6 +25,33 @@ export interface AgentDiscoveryResult {
|
|
|
25
25
|
projectAgentsDir: string | null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
interface AgentFrontmatter extends Record<string, unknown> {
|
|
29
|
+
name?: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
tools?: string | string[];
|
|
32
|
+
model?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseAgentTools(value: string | string[] | undefined): string[] | undefined {
|
|
36
|
+
if (typeof value === "string") {
|
|
37
|
+
const tools = value
|
|
38
|
+
.split(",")
|
|
39
|
+
.map((tool) => tool.trim())
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
return tools.length > 0 ? tools : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
const tools = value
|
|
46
|
+
.flatMap((tool) => typeof tool === "string" ? tool.split(",") : [])
|
|
47
|
+
.map((tool) => tool.trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
return tools.length > 0 ? tools : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
|
|
29
56
|
const agents: AgentConfig[] = [];
|
|
30
57
|
|
|
@@ -51,16 +78,13 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
|
|
|
51
78
|
continue;
|
|
52
79
|
}
|
|
53
80
|
|
|
54
|
-
const { frontmatter, body } = parseFrontmatter<
|
|
81
|
+
const { frontmatter, body } = parseFrontmatter<AgentFrontmatter>(content);
|
|
55
82
|
|
|
56
|
-
if (
|
|
83
|
+
if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") {
|
|
57
84
|
continue;
|
|
58
85
|
}
|
|
59
86
|
|
|
60
|
-
const tools = frontmatter.tools
|
|
61
|
-
?.split(",")
|
|
62
|
-
.map((t: string) => t.trim())
|
|
63
|
-
.filter(Boolean);
|
|
87
|
+
const tools = parseAgentTools(frontmatter.tools);
|
|
64
88
|
|
|
65
89
|
agents.push({
|
|
66
90
|
name: frontmatter.name,
|
|
File without changes
|
|
File without changes
|