gsd-pi 2.73.0-dev.1cfd50c → 2.73.0-dev.27730dc
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/cli.js +0 -47
- package/dist/help-text.js +1 -1
- package/dist/resources/extensions/gsd/auto-dispatch.js +5 -3
- package/dist/resources/extensions/gsd/auto-prompts.js +9 -6
- 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/bootstrap/system-context.js +6 -1
- 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/startup-model-validation.js +8 -5
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
- 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 +11 -11
- 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/dist/core/auth-storage.js +1 -1
- package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/auth-storage.test.js +27 -0
- package/packages/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.js +25 -68
- package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
- package/packages/pi-coding-agent/src/core/auth-storage.test.ts +38 -0
- package/packages/pi-coding-agent/src/core/auth-storage.ts +1 -1
- package/packages/pi-coding-agent/src/core/model-resolver.ts +26 -70
- package/src/resources/extensions/gsd/auto-dispatch.ts +5 -0
- package/src/resources/extensions/gsd/auto-prompts.ts +9 -3
- 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/bootstrap/system-context.ts +8 -1
- 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/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts +267 -0
- /package/dist/web/standalone/.next/static/{uNGVqSkAnszMl0okA4nnp → jNiH700EcljeLnbQ2_RCv}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{uNGVqSkAnszMl0okA4nnp → jNiH700EcljeLnbQ2_RCv}/_ssgManifest.js +0 -0
|
@@ -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();
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for subagent model preference wiring.
|
|
3
|
+
*
|
|
4
|
+
* Fixes: subagent_model config in reactive_execution was validated and stored
|
|
5
|
+
* but never passed through to subagent dispatch instruction strings, so the
|
|
6
|
+
* executing agent autonomously chose "sonnet" instead of the configured model.
|
|
7
|
+
*
|
|
8
|
+
* Issue: gsd-build/gsd-2#4078
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { validatePreferences } from "../preferences-validation.ts";
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const promptsSrc = readFileSync(join(__dirname, "..", "auto-prompts.ts"), "utf-8");
|
|
21
|
+
const dispatchSrc = readFileSync(join(__dirname, "..", "auto-dispatch.ts"), "utf-8");
|
|
22
|
+
|
|
23
|
+
// ─── Preference Validation ────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
test("reactive_execution: subagent_model is preserved in validated preferences", () => {
|
|
26
|
+
const result = validatePreferences({
|
|
27
|
+
reactive_execution: {
|
|
28
|
+
enabled: true,
|
|
29
|
+
max_parallel: 2,
|
|
30
|
+
isolation_mode: "same-tree",
|
|
31
|
+
subagent_model: "claude-opus-4-6",
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
assert.equal(result.errors.length, 0);
|
|
35
|
+
assert.equal(
|
|
36
|
+
result.preferences.reactive_execution?.subagent_model,
|
|
37
|
+
"claude-opus-4-6",
|
|
38
|
+
"subagent_model should be preserved through validation",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("reactive_execution: subagent_model rejects empty string", () => {
|
|
43
|
+
const result = validatePreferences({
|
|
44
|
+
reactive_execution: {
|
|
45
|
+
enabled: true,
|
|
46
|
+
max_parallel: 2,
|
|
47
|
+
isolation_mode: "same-tree",
|
|
48
|
+
subagent_model: "",
|
|
49
|
+
} as any,
|
|
50
|
+
});
|
|
51
|
+
assert.ok(
|
|
52
|
+
result.errors.some((e) => e.includes("subagent_model")),
|
|
53
|
+
"empty subagent_model should produce a validation error",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ─── Structural: Prompt Builders Accept subagentModel ────────────────────
|
|
58
|
+
|
|
59
|
+
test("buildReactiveExecutePrompt: accepts subagentModel parameter", () => {
|
|
60
|
+
const fnStart = promptsSrc.indexOf("export async function buildReactiveExecutePrompt");
|
|
61
|
+
assert.ok(fnStart !== -1, "buildReactiveExecutePrompt should be exported");
|
|
62
|
+
const signature = promptsSrc.slice(fnStart, fnStart + 300);
|
|
63
|
+
assert.ok(
|
|
64
|
+
signature.includes("subagentModel"),
|
|
65
|
+
"buildReactiveExecutePrompt should accept a subagentModel parameter",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("buildParallelResearchSlicesPrompt: accepts subagentModel parameter", () => {
|
|
70
|
+
const fnStart = promptsSrc.indexOf("export async function buildParallelResearchSlicesPrompt");
|
|
71
|
+
assert.ok(fnStart !== -1, "buildParallelResearchSlicesPrompt should be exported");
|
|
72
|
+
const signature = promptsSrc.slice(fnStart, fnStart + 300);
|
|
73
|
+
assert.ok(
|
|
74
|
+
signature.includes("subagentModel"),
|
|
75
|
+
"buildParallelResearchSlicesPrompt should accept a subagentModel parameter",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("buildGateEvaluatePrompt: accepts subagentModel parameter", () => {
|
|
80
|
+
const fnStart = promptsSrc.indexOf("export async function buildGateEvaluatePrompt");
|
|
81
|
+
assert.ok(fnStart !== -1, "buildGateEvaluatePrompt should be exported");
|
|
82
|
+
const signature = promptsSrc.slice(fnStart, fnStart + 300);
|
|
83
|
+
assert.ok(
|
|
84
|
+
signature.includes("subagentModel"),
|
|
85
|
+
"buildGateEvaluatePrompt should accept a subagentModel parameter",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ─── Structural: Instruction Strings Inject Model ────────────────────────
|
|
90
|
+
|
|
91
|
+
test("buildReactiveExecutePrompt: instruction string uses subagentModel when set", () => {
|
|
92
|
+
const fnStart = promptsSrc.indexOf("export async function buildReactiveExecutePrompt");
|
|
93
|
+
const fnEnd = promptsSrc.indexOf("\nexport async function", fnStart + 1);
|
|
94
|
+
const fnBody = promptsSrc.slice(fnStart, fnEnd);
|
|
95
|
+
assert.ok(
|
|
96
|
+
fnBody.includes("subagentModel"),
|
|
97
|
+
"buildReactiveExecutePrompt body should reference subagentModel",
|
|
98
|
+
);
|
|
99
|
+
// The instruction line must be dynamic (not a plain string literal)
|
|
100
|
+
assert.ok(
|
|
101
|
+
!fnBody.includes('"Use this as the prompt for a `subagent` call:"'),
|
|
102
|
+
"instruction should not be a plain static string — model must be injectable",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("buildParallelResearchSlicesPrompt: instruction string uses subagentModel when set", () => {
|
|
107
|
+
const fnStart = promptsSrc.indexOf("export async function buildParallelResearchSlicesPrompt");
|
|
108
|
+
const fnEnd = promptsSrc.indexOf("\nexport async function", fnStart + 1);
|
|
109
|
+
const fnBody = promptsSrc.slice(fnStart, fnEnd);
|
|
110
|
+
assert.ok(
|
|
111
|
+
fnBody.includes("subagentModel"),
|
|
112
|
+
"buildParallelResearchSlicesPrompt body should reference subagentModel",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("buildGateEvaluatePrompt: instruction string uses subagentModel when set", () => {
|
|
117
|
+
const fnStart = promptsSrc.indexOf("export async function buildGateEvaluatePrompt");
|
|
118
|
+
const fnEnd = promptsSrc.indexOf("\nexport async function", fnStart + 1);
|
|
119
|
+
const fnBody = promptsSrc.slice(fnStart, fnEnd);
|
|
120
|
+
assert.ok(
|
|
121
|
+
fnBody.includes("subagentModel"),
|
|
122
|
+
"buildGateEvaluatePrompt body should reference subagentModel",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ─── Structural: Dispatch Wires Model to Prompt Builders ─────────────────
|
|
127
|
+
|
|
128
|
+
test("auto-dispatch: passes model to buildReactiveExecutePrompt", () => {
|
|
129
|
+
// Find the reactive-execute dispatch rule
|
|
130
|
+
const ruleStart = dispatchSrc.indexOf("reactive-execute (parallel dispatch)");
|
|
131
|
+
assert.ok(ruleStart !== -1, "reactive-execute dispatch rule should exist");
|
|
132
|
+
const ruleBlock = dispatchSrc.slice(ruleStart, ruleStart + 1000);
|
|
133
|
+
assert.ok(
|
|
134
|
+
ruleBlock.includes("subagent_model") || ruleBlock.includes("subagentModel"),
|
|
135
|
+
"reactive-execute rule should resolve and pass the subagent model",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("auto-dispatch: passes model to buildParallelResearchSlicesPrompt", () => {
|
|
140
|
+
const callIdx = dispatchSrc.indexOf("buildParallelResearchSlicesPrompt(");
|
|
141
|
+
assert.ok(callIdx !== -1, "buildParallelResearchSlicesPrompt call should exist");
|
|
142
|
+
// The call site should pass a model argument (not just 4 args)
|
|
143
|
+
const callSite = dispatchSrc.slice(callIdx, callIdx + 300);
|
|
144
|
+
assert.ok(
|
|
145
|
+
callSite.includes("subagentModel") || callSite.includes("resolveModelWithFallbacksForUnit"),
|
|
146
|
+
"buildParallelResearchSlicesPrompt call should include model argument",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("auto-dispatch: passes model to buildGateEvaluatePrompt", () => {
|
|
151
|
+
const callIdx = dispatchSrc.indexOf("buildGateEvaluatePrompt(");
|
|
152
|
+
assert.ok(callIdx !== -1, "buildGateEvaluatePrompt call should exist");
|
|
153
|
+
const callSite = dispatchSrc.slice(callIdx, callIdx + 300);
|
|
154
|
+
assert.ok(
|
|
155
|
+
callSite.includes("subagentModel") || callSite.includes("resolveModelWithFallbacksForUnit"),
|
|
156
|
+
"buildGateEvaluatePrompt call should include model argument",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ─── Integration: Prompt Output Contains Model String ────────────────────
|
|
161
|
+
|
|
162
|
+
test("buildReactiveExecutePrompt: output contains model string when subagentModel provided", async (t) => {
|
|
163
|
+
const { buildReactiveExecutePrompt } = await import("../auto-prompts.ts");
|
|
164
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-subagent-model-reactive-"));
|
|
165
|
+
t.after(() => rmSync(repo, { recursive: true, force: true }));
|
|
166
|
+
|
|
167
|
+
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
|
|
168
|
+
mkdirSync(join(gsd, "tasks"), { recursive: true });
|
|
169
|
+
|
|
170
|
+
writeFileSync(
|
|
171
|
+
join(gsd, "S01-PLAN.md"),
|
|
172
|
+
[
|
|
173
|
+
"# S01: Test Slice",
|
|
174
|
+
"",
|
|
175
|
+
"**Goal:** Verify model injection",
|
|
176
|
+
"**Demo:** Model appears in subagent prompt",
|
|
177
|
+
"",
|
|
178
|
+
"## Tasks",
|
|
179
|
+
"",
|
|
180
|
+
"- [ ] **T01: Task One** `est:15m`",
|
|
181
|
+
" Do something.",
|
|
182
|
+
"",
|
|
183
|
+
].join("\n"),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
writeFileSync(
|
|
187
|
+
join(gsd, "tasks", "T01-PLAN.md"),
|
|
188
|
+
[
|
|
189
|
+
"# T01: Task One",
|
|
190
|
+
"",
|
|
191
|
+
"## Description",
|
|
192
|
+
"Do something.",
|
|
193
|
+
"",
|
|
194
|
+
"## Inputs",
|
|
195
|
+
"",
|
|
196
|
+
"- `src/config.json` — Config",
|
|
197
|
+
"",
|
|
198
|
+
"## Expected Output",
|
|
199
|
+
"",
|
|
200
|
+
"- `src/out.ts` — Result",
|
|
201
|
+
].join("\n"),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const prompt = await buildReactiveExecutePrompt(
|
|
205
|
+
"M001", "Test Milestone", "S01", "Test Slice",
|
|
206
|
+
["T01"], repo, "claude-opus-4-6",
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
assert.ok(
|
|
210
|
+
prompt.includes('model: "claude-opus-4-6"'),
|
|
211
|
+
`Prompt should contain model instruction. Got:\n${prompt.slice(0, 500)}`,
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("buildReactiveExecutePrompt: no model instruction when subagentModel omitted", async (t) => {
|
|
216
|
+
const { buildReactiveExecutePrompt } = await import("../auto-prompts.ts");
|
|
217
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-subagent-model-none-"));
|
|
218
|
+
t.after(() => rmSync(repo, { recursive: true, force: true }));
|
|
219
|
+
|
|
220
|
+
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
|
|
221
|
+
mkdirSync(join(gsd, "tasks"), { recursive: true });
|
|
222
|
+
|
|
223
|
+
writeFileSync(
|
|
224
|
+
join(gsd, "S01-PLAN.md"),
|
|
225
|
+
[
|
|
226
|
+
"# S01: Test Slice",
|
|
227
|
+
"",
|
|
228
|
+
"**Goal:** Verify no model when omitted",
|
|
229
|
+
"**Demo:** No model string",
|
|
230
|
+
"",
|
|
231
|
+
"## Tasks",
|
|
232
|
+
"",
|
|
233
|
+
"- [ ] **T01: Task One** `est:15m`",
|
|
234
|
+
" Do something.",
|
|
235
|
+
"",
|
|
236
|
+
].join("\n"),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
writeFileSync(
|
|
240
|
+
join(gsd, "tasks", "T01-PLAN.md"),
|
|
241
|
+
[
|
|
242
|
+
"# T01: Task One",
|
|
243
|
+
"",
|
|
244
|
+
"## Description",
|
|
245
|
+
"Do something.",
|
|
246
|
+
"",
|
|
247
|
+
"## Inputs",
|
|
248
|
+
"",
|
|
249
|
+
"- `src/config.json` — Config",
|
|
250
|
+
"",
|
|
251
|
+
"## Expected Output",
|
|
252
|
+
"",
|
|
253
|
+
"- `src/out.ts` — Result",
|
|
254
|
+
].join("\n"),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const prompt = await buildReactiveExecutePrompt(
|
|
258
|
+
"M001", "Test Milestone", "S01", "Test Slice",
|
|
259
|
+
["T01"], repo,
|
|
260
|
+
// no subagentModel
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
assert.ok(
|
|
264
|
+
!prompt.includes('with model:'),
|
|
265
|
+
"Prompt should not contain model instruction when subagentModel is omitted",
|
|
266
|
+
);
|
|
267
|
+
});
|
|
File without changes
|
|
File without changes
|