gsd-pi 2.52.0-dev.585e355 → 2.52.0-dev.655ad8a
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/README.md +55 -32
- package/dist/headless-query.js +1 -1
- package/dist/resources/extensions/get-secrets-from-user.js +7 -0
- package/dist/resources/extensions/gsd/auto/phases.js +28 -8
- package/dist/resources/extensions/gsd/auto-dispatch.js +5 -1
- package/dist/resources/extensions/gsd/auto-worktree.js +70 -14
- package/dist/resources/extensions/gsd/auto.js +22 -0
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +4 -10
- package/dist/resources/extensions/gsd/guided-flow.js +4 -3
- package/dist/resources/extensions/gsd/parallel-orchestrator.js +18 -2
- package/dist/resources/extensions/gsd/state.js +5 -11
- package/dist/resources/extensions/shared/rtk.js +9 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
- package/dist/web/standalone/.next/build-manifest.json +3 -3
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
- 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 +14 -14
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
- 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/dist/web/standalone/.next/static/chunks/4024.87fd909ae0110f50.js +9 -0
- package/dist/web/standalone/.next/static/chunks/{webpack-024d82be84800e52.js → webpack-bca0e732db0dcec3.js} +1 -1
- package/package.json +1 -1
- package/src/resources/extensions/get-secrets-from-user.ts +8 -0
- package/src/resources/extensions/gsd/auto/phases.ts +38 -7
- package/src/resources/extensions/gsd/auto-dispatch.ts +6 -1
- package/src/resources/extensions/gsd/auto-worktree.ts +73 -14
- package/src/resources/extensions/gsd/auto.ts +21 -0
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +4 -11
- package/src/resources/extensions/gsd/guided-flow.ts +4 -3
- package/src/resources/extensions/gsd/parallel-orchestrator.ts +23 -1
- package/src/resources/extensions/gsd/state.ts +5 -10
- package/src/resources/extensions/gsd/tests/active-milestone-id-guard.test.ts +91 -0
- package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +87 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-auto-resolve.test.ts +80 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +39 -0
- package/src/resources/extensions/gsd/tests/git-service.test.ts +28 -27
- package/src/resources/extensions/gsd/tests/milestone-report-path.test.ts +51 -0
- package/src/resources/extensions/gsd/tests/parallel-orchestrator-zombie-cleanup.test.ts +277 -0
- package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +103 -0
- package/src/resources/extensions/gsd/tests/rate-limit-model-fallback.test.ts +90 -0
- package/src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts +9 -8
- package/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts +125 -0
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +12 -2
- package/src/resources/extensions/gsd/tests/validation-gate-patterns.test.ts +124 -0
- package/src/resources/extensions/shared/rtk.ts +10 -1
- package/dist/web/standalone/.next/static/chunks/4024.21054f459af5cc78.js +0 -9
- /package/dist/web/standalone/.next/static/{KTe1kB5nPLQFIIFz2OcmI → zpvUPKoW5jRAMB_fWHlPi}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{KTe1kB5nPLQFIIFz2OcmI → zpvUPKoW5jRAMB_fWHlPi}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* phases-merge-error-stops-auto.test.ts — Regression test for #2766.
|
|
3
|
+
*
|
|
4
|
+
* When mergeAndExit throws a non-MergeConflictError, the auto loop must
|
|
5
|
+
* stop instead of continuing with unmerged work. This test verifies that
|
|
6
|
+
* all catch blocks in auto/phases.ts that handle mergeAndExit errors
|
|
7
|
+
* call stopAuto and return { action: "break" } for non-conflict errors.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { createTestContext } from "./test-helpers.ts";
|
|
13
|
+
|
|
14
|
+
const { assertTrue, report } = createTestContext();
|
|
15
|
+
|
|
16
|
+
const phasesPath = join(import.meta.dirname, "..", "auto", "phases.ts");
|
|
17
|
+
const phasesSrc = readFileSync(phasesPath, "utf-8");
|
|
18
|
+
|
|
19
|
+
console.log("\n=== #2766: Non-MergeConflictError stops auto mode ===");
|
|
20
|
+
|
|
21
|
+
// ── Test 1: phases.ts calls logError for non-conflict merge errors ──────
|
|
22
|
+
|
|
23
|
+
assertTrue(
|
|
24
|
+
phasesPath.length > 0 && phasesPath.endsWith("phases.ts"),
|
|
25
|
+
"phases.ts file exists and is readable",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Count all mergeAndExit catch blocks by finding "} catch (mergeErr)" patterns
|
|
29
|
+
const mergeErrCatches = [...phasesPath.matchAll(/\} catch \(mergeErr\)/g)];
|
|
30
|
+
// Use the source itself for matching
|
|
31
|
+
const mergeErrCatchCount = [...phasesSrc.matchAll(/\} catch \(mergeErr\)/g)].length;
|
|
32
|
+
assertTrue(
|
|
33
|
+
mergeErrCatchCount >= 3,
|
|
34
|
+
`all mergeAndExit call sites have catch (mergeErr) blocks (found ${mergeErrCatchCount}, expected >= 3)`,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// ── Test 2: Every mergeErr catch block handles non-MergeConflictError ───
|
|
38
|
+
|
|
39
|
+
// Find each catch block and verify it has the non-conflict error handling pattern
|
|
40
|
+
const catchPattern = /\} catch \(mergeErr\) \{/g;
|
|
41
|
+
let match;
|
|
42
|
+
let blocksWithNonConflictHandling = 0;
|
|
43
|
+
let blocksTotal = 0;
|
|
44
|
+
|
|
45
|
+
while ((match = catchPattern.exec(phasesSrc)) !== null) {
|
|
46
|
+
blocksTotal++;
|
|
47
|
+
// Look at the ~800 chars after the catch to find both the MergeConflictError
|
|
48
|
+
// instanceof check AND the non-conflict handling
|
|
49
|
+
const afterCatch = phasesSrc.slice(match.index, match.index + 1200);
|
|
50
|
+
|
|
51
|
+
const hasInstanceofCheck = afterCatch.includes("instanceof MergeConflictError");
|
|
52
|
+
const hasNonConflictStop = afterCatch.includes('reason: "merge-failed"');
|
|
53
|
+
const hasStopAuto = afterCatch.includes("stopAuto");
|
|
54
|
+
const hasLogError = afterCatch.includes("logError");
|
|
55
|
+
|
|
56
|
+
if (hasInstanceofCheck && hasNonConflictStop && hasStopAuto && hasLogError) {
|
|
57
|
+
blocksWithNonConflictHandling++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
assertTrue(
|
|
62
|
+
blocksWithNonConflictHandling === blocksTotal && blocksTotal >= 3,
|
|
63
|
+
`all ${blocksTotal} mergeAndExit catch blocks stop auto on non-conflict errors (${blocksWithNonConflictHandling}/${blocksTotal})`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// ── Test 3: Non-conflict handler returns break (does not continue) ──────
|
|
67
|
+
|
|
68
|
+
// Verify the pattern: after the MergeConflictError instanceof block,
|
|
69
|
+
// the non-conflict path returns { action: "break", reason: "merge-failed" }
|
|
70
|
+
const mergeFailedReasons = [...phasesSrc.matchAll(/reason: "merge-failed"/g)].length;
|
|
71
|
+
assertTrue(
|
|
72
|
+
mergeFailedReasons >= 3,
|
|
73
|
+
`all catch blocks return reason: "merge-failed" (found ${mergeFailedReasons}, expected >= 3)`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// ── Test 4: Non-conflict handler notifies user ──────────────────────────
|
|
77
|
+
|
|
78
|
+
// Each non-conflict block should call ctx.ui.notify with error severity
|
|
79
|
+
const notifyErrorPattern = /Merge failed:.*Resolve and run \/gsd auto to resume/g;
|
|
80
|
+
const notifyCount = [...phasesSrc.matchAll(notifyErrorPattern)].length;
|
|
81
|
+
assertTrue(
|
|
82
|
+
notifyCount >= 3,
|
|
83
|
+
`all catch blocks notify user about merge failure (found ${notifyCount}, expected >= 3)`,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// ── Test 5: logError replaces logWarning for non-conflict merge errors ──
|
|
87
|
+
|
|
88
|
+
// The old code used logWarning — verify logError is used instead
|
|
89
|
+
const logWarningMergePattern = /logWarning\(.*Milestone merge failed with non-conflict error/g;
|
|
90
|
+
const logWarningCount = [...phasesSrc.matchAll(logWarningMergePattern)].length;
|
|
91
|
+
assertTrue(
|
|
92
|
+
logWarningCount === 0,
|
|
93
|
+
"logWarning is no longer used for non-conflict merge errors (replaced by logError)",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const logErrorMergePattern = /logError\(.*Milestone merge failed with non-conflict error/g;
|
|
97
|
+
const logErrorCount = [...phasesSrc.matchAll(logErrorMergePattern)].length;
|
|
98
|
+
assertTrue(
|
|
99
|
+
logErrorCount >= 3,
|
|
100
|
+
`logError is used for non-conflict merge errors (found ${logErrorCount}, expected >= 3)`,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
report();
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rate-limit-model-fallback.test.ts — Regression test for #2770.
|
|
3
|
+
*
|
|
4
|
+
* Rate-limit errors enter the model fallback path before falling through
|
|
5
|
+
* to pause. This verifies the structural contract in agent-end-recovery.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const RECOVERY_PATH = join(__dirname, "..", "bootstrap", "agent-end-recovery.ts");
|
|
16
|
+
|
|
17
|
+
function getRecoverySource(): string {
|
|
18
|
+
return readFileSync(RECOVERY_PATH, "utf-8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Rate-limit errors attempt model fallback (#2770) ─────────────────────────
|
|
22
|
+
|
|
23
|
+
test("rate-limit errors enter the model fallback branch alongside other transient errors", () => {
|
|
24
|
+
const src = getRecoverySource();
|
|
25
|
+
|
|
26
|
+
// The condition that gates model fallback must include rate-limit.
|
|
27
|
+
// Match the if-condition that contains both "rate-limit" and fallback-related kinds.
|
|
28
|
+
const fallbackConditionRe = /if\s*\([^)]*cls\.kind\s*===\s*"rate-limit"[^)]*cls\.kind\s*===\s*"network"/;
|
|
29
|
+
const fallbackConditionReAlt = /if\s*\([^)]*cls\.kind\s*===\s*"network"[^)]*cls\.kind\s*===\s*"rate-limit"/;
|
|
30
|
+
|
|
31
|
+
assert.ok(
|
|
32
|
+
fallbackConditionRe.test(src) || fallbackConditionReAlt.test(src),
|
|
33
|
+
'rate-limit must appear in the same if-condition as network/server for model fallback (#2770)',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("rate-limit errors are NOT short-circuited to pause before model fallback", () => {
|
|
38
|
+
const src = getRecoverySource();
|
|
39
|
+
|
|
40
|
+
// The old code had a dedicated rate-limit early-return block before the fallback block.
|
|
41
|
+
// Verify it no longer exists.
|
|
42
|
+
const earlyRateLimitPause = /if\s*\(\s*cls\.kind\s*===\s*"rate-limit"\s*\)\s*\{[^}]*pauseTransientWithBackoff/;
|
|
43
|
+
assert.ok(
|
|
44
|
+
!earlyRateLimitPause.test(src),
|
|
45
|
+
'rate-limit must NOT have a dedicated early pause before the model fallback path (#2770)',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rate-limit errors fall through to pause if no fallback model is available", () => {
|
|
50
|
+
const src = getRecoverySource();
|
|
51
|
+
|
|
52
|
+
// After the fallback block, the transient fallback pause must still fire for rate-limit.
|
|
53
|
+
// The isTransient check covers rate-limit (verified by error-classifier tests).
|
|
54
|
+
// Verify pauseTransientWithBackoff is called with isRateLimit derived from cls.kind.
|
|
55
|
+
assert.ok(
|
|
56
|
+
src.includes('cls.kind === "rate-limit"'),
|
|
57
|
+
'agent-end-recovery.ts must reference cls.kind === "rate-limit" for fallback and pause paths (#2770)',
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// The transient fallback pause must pass the isRateLimit flag correctly.
|
|
61
|
+
const pauseCallRe = /pauseTransientWithBackoff\([^)]*cls\.kind\s*===\s*"rate-limit"/;
|
|
62
|
+
assert.ok(
|
|
63
|
+
pauseCallRe.test(src),
|
|
64
|
+
'pauseTransientWithBackoff must receive isRateLimit based on cls.kind === "rate-limit" (#2770)',
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("other transient errors (server, connection, stream) still attempt model fallback", () => {
|
|
69
|
+
const src = getRecoverySource();
|
|
70
|
+
|
|
71
|
+
// All transient kinds must appear in the fallback condition.
|
|
72
|
+
for (const kind of ["server", "connection", "stream"]) {
|
|
73
|
+
assert.ok(
|
|
74
|
+
src.includes(`cls.kind === "${kind}"`),
|
|
75
|
+
`model fallback condition must include cls.kind === "${kind}"`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("permanent errors still bypass model fallback and pause indefinitely", () => {
|
|
81
|
+
const src = getRecoverySource();
|
|
82
|
+
|
|
83
|
+
// The permanent/unknown error handler must exist and call pauseAutoForProviderError
|
|
84
|
+
// with isTransient: false.
|
|
85
|
+
const permanentPauseRe = /pauseAutoForProviderError[\s\S]{0,300}isTransient:\s*false/;
|
|
86
|
+
assert.ok(
|
|
87
|
+
permanentPauseRe.test(src),
|
|
88
|
+
'permanent errors must pause with isTransient: false (no auto-resume)',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
@@ -95,13 +95,13 @@ async function main(): Promise<void> {
|
|
|
95
95
|
writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
|
|
96
96
|
|
|
97
97
|
// Simulate transient unavailability: move file away, spawn a child process
|
|
98
|
-
// to restore it after
|
|
99
|
-
// fires even during busy-wait retries.
|
|
98
|
+
// to restore it shortly after. The child runs outside our event loop so it
|
|
99
|
+
// fires even during busy-wait retries. Give the test extra retry budget so
|
|
100
|
+
// it stays stable under full-suite CPU contention.
|
|
100
101
|
renameSync(lockFile, tmpFile);
|
|
101
|
-
spawn('bash', ['-c', `sleep 0.
|
|
102
|
+
spawn('bash', ['-c', `sleep 0.05 && mv "${tmpFile}" "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 3, delayMs: 200 });
|
|
104
|
+
const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 8, delayMs: 400 });
|
|
105
105
|
assertTrue(result !== null, 'data recovered after transient unavailability');
|
|
106
106
|
if (result) {
|
|
107
107
|
assertEq(result.pid, process.pid, 'correct PID after recovery');
|
|
@@ -131,11 +131,12 @@ async function main(): Promise<void> {
|
|
|
131
131
|
writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
|
|
132
132
|
|
|
133
133
|
// Remove read permission to simulate NFS/CIFS latency, then spawn a child
|
|
134
|
-
// to restore permissions after
|
|
134
|
+
// to restore permissions shortly after (runs outside our event loop).
|
|
135
|
+
// Use the same wider retry window as the rename case for full-suite stability.
|
|
135
136
|
chmodSync(lockFile, 0o000);
|
|
136
|
-
spawn('bash', ['-c', `sleep 0.
|
|
137
|
+
spawn('bash', ['-c', `sleep 0.05 && chmod 644 "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
|
|
137
138
|
|
|
138
|
-
const result = readExistingLockDataWithRetry(lockFile, { maxAttempts:
|
|
139
|
+
const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 8, delayMs: 400 });
|
|
139
140
|
assertTrue(result !== null, 'data recovered after transient permission error');
|
|
140
141
|
if (result) {
|
|
141
142
|
assertEq(result.pid, process.pid, 'correct PID after permission recovery');
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stash-pop-gsd-conflict.test.ts — Regression test for #2766.
|
|
3
|
+
*
|
|
4
|
+
* When a squash merge stash-pops and hits conflicts on .gsd/ state files,
|
|
5
|
+
* the UU entries block every subsequent merge. This test verifies that
|
|
6
|
+
* mergeMilestoneToMain auto-resolves .gsd/ conflicts by accepting HEAD
|
|
7
|
+
* and drops the stash, leaving the repo in a clean state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, realpathSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { execSync } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
import { createAutoWorktree, mergeMilestoneToMain } from "../auto-worktree.ts";
|
|
18
|
+
|
|
19
|
+
function run(cmd: string, cwd: string): string {
|
|
20
|
+
return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createTempRepo(): string {
|
|
24
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-stashpop-test-")));
|
|
25
|
+
run("git init", dir);
|
|
26
|
+
run("git config user.email test@test.com", dir);
|
|
27
|
+
run("git config user.name Test", dir);
|
|
28
|
+
writeFileSync(join(dir, "README.md"), "# test\n");
|
|
29
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
30
|
+
writeFileSync(join(dir, ".gsd", "STATE.md"), "version: 1\n");
|
|
31
|
+
run("git add .", dir);
|
|
32
|
+
run("git commit -m init", dir);
|
|
33
|
+
run("git branch -M main", dir);
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeRoadmap(milestoneId: string, title: string, slices: Array<{ id: string; title: string }>): string {
|
|
38
|
+
const sliceLines = slices.map(s => `- [x] **${s.id}: ${s.title}**`).join("\n");
|
|
39
|
+
return `# ${milestoneId}: ${title}\n\n## Slices\n${sliceLines}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("#2766: stash pop conflict on .gsd/ files is auto-resolved", () => {
|
|
43
|
+
const repo = createTempRepo();
|
|
44
|
+
try {
|
|
45
|
+
const wtPath = createAutoWorktree(repo, "M300");
|
|
46
|
+
|
|
47
|
+
// Add a slice with real code on the milestone branch
|
|
48
|
+
const normalizedPath = wtPath.replaceAll("\\", "/");
|
|
49
|
+
const worktreeName = normalizedPath.split("/").pop() || "M300";
|
|
50
|
+
const sliceBranch = `slice/${worktreeName}/S01`;
|
|
51
|
+
run(`git checkout -b "${sliceBranch}"`, wtPath);
|
|
52
|
+
writeFileSync(join(wtPath, "feature.ts"), "export const feature = true;\n");
|
|
53
|
+
|
|
54
|
+
// Modify .gsd/STATE.md on the milestone branch (diverges from main)
|
|
55
|
+
writeFileSync(join(wtPath, ".gsd", "STATE.md"), "version: 2-milestone\n");
|
|
56
|
+
run("git add .", wtPath);
|
|
57
|
+
run('git commit -m "add feature and update state"', wtPath);
|
|
58
|
+
run("git checkout milestone/M300", wtPath);
|
|
59
|
+
run(`git merge --no-ff "${sliceBranch}" -m "merge S01: feature"`, wtPath);
|
|
60
|
+
|
|
61
|
+
// Dirty .gsd/STATE.md in the main repo (stash will conflict on pop)
|
|
62
|
+
writeFileSync(join(repo, ".gsd", "STATE.md"), "version: 2-main-dirty\n");
|
|
63
|
+
|
|
64
|
+
const roadmap = makeRoadmap("M300", "Stash pop conflict test", [
|
|
65
|
+
{ id: "S01", title: "Feature" },
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
// mergeMilestoneToMain should succeed — .gsd/ conflict auto-resolved
|
|
69
|
+
const result = mergeMilestoneToMain(repo, "M300", roadmap);
|
|
70
|
+
assert.ok(
|
|
71
|
+
result.commitMessage.includes("GSD-Milestone: M300"),
|
|
72
|
+
"merge succeeds despite stash pop conflict on .gsd/ file",
|
|
73
|
+
);
|
|
74
|
+
assert.ok(existsSync(join(repo, "feature.ts")), "milestone code merged to main");
|
|
75
|
+
|
|
76
|
+
// Verify repo is clean (no UU entries blocking future merges)
|
|
77
|
+
const status = run("git status --porcelain", repo);
|
|
78
|
+
assert.ok(
|
|
79
|
+
!status.includes("UU "),
|
|
80
|
+
"no unmerged (UU) entries remain after stash pop conflict resolution",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Stash should be dropped (no remaining stash entries)
|
|
84
|
+
let stashList = "";
|
|
85
|
+
try { stashList = run("git stash list", repo); } catch { /* empty stash */ }
|
|
86
|
+
assert.strictEqual(stashList, "", "stash is empty after .gsd/ conflict auto-resolution");
|
|
87
|
+
} finally {
|
|
88
|
+
try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* cleanup best-effort */ }
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("#2766: stash pop conflict on non-.gsd files preserves stash for manual resolution", () => {
|
|
93
|
+
const repo = createTempRepo();
|
|
94
|
+
try {
|
|
95
|
+
const wtPath = createAutoWorktree(repo, "M301");
|
|
96
|
+
|
|
97
|
+
// Add a slice that modifies a file also dirty on main
|
|
98
|
+
const normalizedPath = wtPath.replaceAll("\\", "/");
|
|
99
|
+
const worktreeName = normalizedPath.split("/").pop() || "M301";
|
|
100
|
+
const sliceBranch = `slice/${worktreeName}/S01`;
|
|
101
|
+
run(`git checkout -b "${sliceBranch}"`, wtPath);
|
|
102
|
+
writeFileSync(join(wtPath, "README.md"), "# milestone version\n");
|
|
103
|
+
run("git add .", wtPath);
|
|
104
|
+
run('git commit -m "update readme"', wtPath);
|
|
105
|
+
run("git checkout milestone/M301", wtPath);
|
|
106
|
+
run(`git merge --no-ff "${sliceBranch}" -m "merge S01: readme"`, wtPath);
|
|
107
|
+
|
|
108
|
+
// Dirty README.md in the main repo — this will conflict on stash pop
|
|
109
|
+
// and is NOT a .gsd/ file, so it should be left for manual resolution
|
|
110
|
+
writeFileSync(join(repo, "README.md"), "# locally modified\n");
|
|
111
|
+
|
|
112
|
+
const roadmap = makeRoadmap("M301", "Non-gsd stash conflict", [
|
|
113
|
+
{ id: "S01", title: "Readme update" },
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
// The merge itself should still succeed (stash pop conflict is non-fatal)
|
|
117
|
+
const result = mergeMilestoneToMain(repo, "M301", roadmap);
|
|
118
|
+
assert.ok(
|
|
119
|
+
result.commitMessage.includes("GSD-Milestone: M301"),
|
|
120
|
+
"merge succeeds even with non-.gsd stash pop conflict",
|
|
121
|
+
);
|
|
122
|
+
} finally {
|
|
123
|
+
try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* cleanup best-effort */ }
|
|
124
|
+
}
|
|
125
|
+
});
|
|
@@ -110,6 +110,16 @@ test("isValidationTerminal returns true for verdict: passed (#1429)", () => {
|
|
|
110
110
|
assert.equal(isValidationTerminal(content), true);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
test("isValidationTerminal returns true for verdict: fail (#2769)", () => {
|
|
114
|
+
const content = "---\nverdict: fail\nremediation_round: 1\n---\n\n# Validation";
|
|
115
|
+
assert.equal(isValidationTerminal(content), true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("isValidationTerminal returns true for any arbitrary verdict string (#2769)", () => {
|
|
119
|
+
const content = "---\nverdict: custom-verdict\nremediation_round: 0\n---\n\n# Validation";
|
|
120
|
+
assert.equal(isValidationTerminal(content), true);
|
|
121
|
+
});
|
|
122
|
+
|
|
113
123
|
test("isValidationTerminal returns false for missing frontmatter", () => {
|
|
114
124
|
const content = "# Validation\nNo frontmatter here.";
|
|
115
125
|
assert.equal(isValidationTerminal(content), false);
|
|
@@ -327,14 +337,14 @@ test("verifyExpectedArtifact rejects VALIDATION with missing verdict field", ()
|
|
|
327
337
|
}
|
|
328
338
|
});
|
|
329
339
|
|
|
330
|
-
test("verifyExpectedArtifact
|
|
340
|
+
test("verifyExpectedArtifact accepts VALIDATION with any extracted verdict", () => {
|
|
331
341
|
const base = makeTmpBase();
|
|
332
342
|
try {
|
|
333
343
|
writeValidation(base, "M001", "---\nverdict: unknown-value\nremediation_round: 0\n---\n\n# Validation");
|
|
334
344
|
clearPathCache();
|
|
335
345
|
clearParseCache();
|
|
336
346
|
const result = verifyExpectedArtifact("validate-milestone", "M001", base);
|
|
337
|
-
assert.equal(result,
|
|
347
|
+
assert.equal(result, true, "VALIDATION with any extracted verdict should pass verification");
|
|
338
348
|
} finally {
|
|
339
349
|
cleanup(base);
|
|
340
350
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the milestone completion validation gate pattern matching.
|
|
3
|
+
*
|
|
4
|
+
* The gate in auto-dispatch accepts two evidence formats:
|
|
5
|
+
* 1. Structured template: content contains "Operational" AND ("MET" or "N/A")
|
|
6
|
+
* 2. Prose evidence: matches /[Oo]perational[\s:][^\n]*(?:pass|verified|...)/i
|
|
7
|
+
*
|
|
8
|
+
* These tests exercise the exact same expressions used in auto-dispatch.ts
|
|
9
|
+
* to ensure both formats are correctly recognized, and that content without
|
|
10
|
+
* operational evidence is properly rejected.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
|
|
16
|
+
// ─── Replicate the gate matching logic from auto-dispatch.ts ─────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns true when validation content contains acceptable operational
|
|
20
|
+
* verification evidence (structured or prose). Mirrors the inline checks
|
|
21
|
+
* in the "execute → complete-milestone" dispatch rule.
|
|
22
|
+
*/
|
|
23
|
+
function hasOperationalEvidence(validationContent: string): boolean {
|
|
24
|
+
const structuredMatch =
|
|
25
|
+
validationContent.includes("Operational") &&
|
|
26
|
+
(validationContent.includes("MET") || validationContent.includes("N/A"));
|
|
27
|
+
const proseMatch =
|
|
28
|
+
/[Oo]perational[\s:][^\n]*(?:pass|verified|confirmed|met|complete|true|yes|addressed|covered|n\/a|not\s+applicable)/i.test(
|
|
29
|
+
validationContent,
|
|
30
|
+
);
|
|
31
|
+
return structuredMatch || proseMatch;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Structured format ───────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
test("structured: Operational + MET passes", () => {
|
|
37
|
+
const content = `| Criteria | Status |
|
|
38
|
+
| Operational | MET |
|
|
39
|
+
| Functional | MET |`;
|
|
40
|
+
assert.ok(hasOperationalEvidence(content));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("structured: Operational + N/A passes", () => {
|
|
44
|
+
const content = `| Criteria | Status |
|
|
45
|
+
| Operational | N/A |
|
|
46
|
+
| Functional | MET |`;
|
|
47
|
+
assert.ok(hasOperationalEvidence(content));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("structured: Operational present with MET on another row still passes (includes is content-wide)", () => {
|
|
51
|
+
// The structured check uses .includes() across the entire content,
|
|
52
|
+
// so "MET" on the Functional row satisfies the condition alongside
|
|
53
|
+
// "Operational" anywhere in the document.
|
|
54
|
+
const content = `| Criteria | Status |
|
|
55
|
+
| Operational | PENDING |
|
|
56
|
+
| Functional | MET |`;
|
|
57
|
+
assert.ok(hasOperationalEvidence(content));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("structured: Operational alone without any MET or N/A anywhere fails", () => {
|
|
61
|
+
const content = `| Criteria | Status |
|
|
62
|
+
| Operational | PENDING |
|
|
63
|
+
| Functional | PENDING |`;
|
|
64
|
+
assert.ok(!hasOperationalEvidence(content));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ─── Prose format ────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
test('prose: "Operational: verified" passes', () => {
|
|
70
|
+
const content = `## Validation Report
|
|
71
|
+
Operational: verified — all endpoints responsive.
|
|
72
|
+
Functional: tests pass.`;
|
|
73
|
+
assert.ok(hasOperationalEvidence(content));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('prose: "Operational checks confirmed" passes', () => {
|
|
77
|
+
const content = `## Validation Report
|
|
78
|
+
Operational checks confirmed by smoke test suite.`;
|
|
79
|
+
assert.ok(hasOperationalEvidence(content));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('prose: "Operational — pass" passes', () => {
|
|
83
|
+
const content = `Operational — pass (all services healthy)`;
|
|
84
|
+
assert.ok(hasOperationalEvidence(content));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('prose: "operational: addressed" passes (case-insensitive)', () => {
|
|
88
|
+
const content = `operational: addressed in CI pipeline run #42.`;
|
|
89
|
+
assert.ok(hasOperationalEvidence(content));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('prose: "Operational: not applicable" passes', () => {
|
|
93
|
+
const content = `Operational: not applicable for this library-only change.`;
|
|
94
|
+
assert.ok(hasOperationalEvidence(content));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('prose: "Operational: n/a" passes', () => {
|
|
98
|
+
const content = `Operational: n/a — no runtime components.`;
|
|
99
|
+
assert.ok(hasOperationalEvidence(content));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('prose: "Operational: complete" passes', () => {
|
|
103
|
+
const content = `Operational: complete — all health checks green.`;
|
|
104
|
+
assert.ok(hasOperationalEvidence(content));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ─── Rejection cases ─────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
test("no operational evidence: unrelated content fails", () => {
|
|
110
|
+
const content = `## Validation Report
|
|
111
|
+
All functional tests pass.
|
|
112
|
+
Code coverage at 92%.`;
|
|
113
|
+
assert.ok(!hasOperationalEvidence(content));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("no operational evidence: word 'operational' buried without qualifying keyword fails", () => {
|
|
117
|
+
const content = `## Validation Report
|
|
118
|
+
The operational aspects were not evaluated in this round.`;
|
|
119
|
+
assert.ok(!hasOperationalEvidence(content));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("no operational evidence: empty content fails", () => {
|
|
123
|
+
assert.ok(!hasOperationalEvidence(""));
|
|
124
|
+
});
|
|
@@ -5,6 +5,7 @@ import { delimiter, join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
const GSD_RTK_PATH_ENV = "GSD_RTK_PATH";
|
|
7
7
|
const GSD_RTK_DISABLED_ENV = "GSD_RTK_DISABLED";
|
|
8
|
+
const GSD_RTK_REWRITE_TIMEOUT_MS_ENV = "GSD_RTK_REWRITE_TIMEOUT_MS";
|
|
8
9
|
const RTK_TELEMETRY_DISABLED_ENV = "RTK_TELEMETRY_DISABLED";
|
|
9
10
|
const RTK_REWRITE_TIMEOUT_MS = 5_000;
|
|
10
11
|
|
|
@@ -14,6 +15,14 @@ function isTruthy(value: string | undefined): boolean {
|
|
|
14
15
|
return normalized === "1" || normalized === "true" || normalized === "yes";
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
function getRewriteTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
19
|
+
const configured = Number.parseInt(env[GSD_RTK_REWRITE_TIMEOUT_MS_ENV] ?? "", 10);
|
|
20
|
+
if (Number.isFinite(configured) && configured > 0) {
|
|
21
|
+
return configured;
|
|
22
|
+
}
|
|
23
|
+
return RTK_REWRITE_TIMEOUT_MS;
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
export function isRtkEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
18
27
|
return !isTruthy(env[GSD_RTK_DISABLED_ENV]);
|
|
19
28
|
}
|
|
@@ -116,7 +125,7 @@ export function rewriteCommandWithRtk(command: string, options: RewriteCommandOp
|
|
|
116
125
|
encoding: "utf-8",
|
|
117
126
|
env: buildRtkEnv(env),
|
|
118
127
|
stdio: ["ignore", "pipe", "ignore"],
|
|
119
|
-
timeout:
|
|
128
|
+
timeout: getRewriteTimeoutMs(env),
|
|
120
129
|
// .cmd/.bat wrappers (used by fake-rtk in tests) require shell:true on Windows
|
|
121
130
|
shell: /\.(cmd|bat)$/i.test(binaryPath),
|
|
122
131
|
});
|