gsd-pi 2.65.0-dev.6cc5110 → 2.65.0-dev.800ece0
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/register-hooks.js +7 -2
- package/dist/resources/extensions/gsd/bootstrap/system-context.js +3 -1
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +31 -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/gsd-db.js +36 -2
- package/dist/resources/extensions/gsd/index.js +1 -1
- 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/discuss.md +2 -0
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
- package/dist/resources/extensions/gsd/prompts/queue.md +2 -0
- package/dist/resources/extensions/gsd/prompts/system.md +2 -2
- package/dist/resources/extensions/gsd/state.js +3 -6
- package/dist/resources/extensions/gsd/workflow-events.js +1 -0
- package/dist/resources/extensions/gsd/workflow-projections.js +3 -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 +20 -20
- 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 +20 -20
- 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-tui/dist/tui.d.ts.map +1 -1
- package/packages/pi-tui/dist/tui.js +3 -1
- package/packages/pi-tui/dist/tui.js.map +1 -1
- package/packages/pi-tui/src/tui.ts +3 -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/register-hooks.ts +7 -2
- package/src/resources/extensions/gsd/bootstrap/system-context.ts +3 -1
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +36 -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/gsd-db.ts +33 -2
- package/src/resources/extensions/gsd/index.ts +1 -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/discuss.md +2 -0
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
- package/src/resources/extensions/gsd/prompts/queue.md +2 -0
- package/src/resources/extensions/gsd/prompts/system.md +2 -2
- package/src/resources/extensions/gsd/state.ts +3 -6
- 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/gsd/tests/wave5-consistency-regressions.test.ts +165 -0
- package/src/resources/extensions/gsd/tests/write-gate.test.ts +127 -2
- package/src/resources/extensions/gsd/workflow-events.ts +5 -3
- package/src/resources/extensions/gsd/workflow-projections.ts +3 -2
- package/src/resources/extensions/subagent/agents.ts +30 -6
- /package/dist/web/standalone/.next/static/{iueakR5x5bQbax2sGz8Yr → E0hBt4ifuG7QBbhUR5-6U}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{iueakR5x5bQbax2sGz8Yr → E0hBt4ifuG7QBbhUR5-6U}/_ssgManifest.js +0 -0
|
@@ -47,6 +47,10 @@ import {
|
|
|
47
47
|
nativeGetCurrentBranch,
|
|
48
48
|
nativeDetectMainBranch,
|
|
49
49
|
nativeCheckoutBranch,
|
|
50
|
+
nativeBranchList,
|
|
51
|
+
nativeBranchListMerged,
|
|
52
|
+
nativeBranchDelete,
|
|
53
|
+
nativeWorktreeRemove,
|
|
50
54
|
} from "./native-git-bridge.js";
|
|
51
55
|
import { GitServiceImpl } from "./git-service.js";
|
|
52
56
|
import {
|
|
@@ -56,6 +60,7 @@ import {
|
|
|
56
60
|
} from "./worktree.js";
|
|
57
61
|
import { getAutoWorktreePath, isInAutoWorktree } from "./auto-worktree.js";
|
|
58
62
|
import { readResourceVersion, cleanStaleRuntimeUnits } from "./auto-worktree.js";
|
|
63
|
+
import { worktreePath as getWorktreeDir, isInsideWorktreesDir } from "./worktree-manager.js";
|
|
59
64
|
import { initMetrics } from "./metrics.js";
|
|
60
65
|
import { initRoutingHistory } from "./routing-history.js";
|
|
61
66
|
import { restoreHookState, resetHookState } from "./post-unit-hooks.js";
|
|
@@ -76,6 +81,7 @@ import {
|
|
|
76
81
|
existsSync,
|
|
77
82
|
mkdirSync,
|
|
78
83
|
readdirSync,
|
|
84
|
+
rmSync,
|
|
79
85
|
statSync,
|
|
80
86
|
unlinkSync,
|
|
81
87
|
} from "node:fs";
|
|
@@ -117,6 +123,123 @@ export async function openProjectDbIfPresent(basePath: string): Promise<void> {
|
|
|
117
123
|
}
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Audit for orphaned milestone branches at bootstrap.
|
|
128
|
+
*
|
|
129
|
+
* After a milestone completes, the teardown step (merge branch → main,
|
|
130
|
+
* delete branch, remove worktree) runs as a post-completion engine step.
|
|
131
|
+
* If the session ends between completion and teardown, the branch and
|
|
132
|
+
* worktree are orphaned — the DB says "complete" so auto-mode won't
|
|
133
|
+
* re-enter the milestone, and the teardown is never retried.
|
|
134
|
+
*
|
|
135
|
+
* This audit runs on every fresh bootstrap to catch that gap:
|
|
136
|
+
* 1. Lists all local `milestone/*` branches.
|
|
137
|
+
* 2. For each, checks if the milestone's DB status is "complete".
|
|
138
|
+
* 3. If the branch is already merged into main → deletes the branch
|
|
139
|
+
* and cleans up any orphaned worktree directory (safe, no data loss).
|
|
140
|
+
* 4. If the branch is NOT merged → preserves it and warns the user
|
|
141
|
+
* so they can merge manually (data safety first).
|
|
142
|
+
*
|
|
143
|
+
* Returns a summary of actions taken for the caller to surface via notify.
|
|
144
|
+
*/
|
|
145
|
+
export function auditOrphanedMilestoneBranches(
|
|
146
|
+
basePath: string,
|
|
147
|
+
isolationMode: "worktree" | "branch" | "none",
|
|
148
|
+
): { recovered: string[]; warnings: string[] } {
|
|
149
|
+
const recovered: string[] = [];
|
|
150
|
+
const warnings: string[] = [];
|
|
151
|
+
|
|
152
|
+
// Skip in none mode — no milestone branches are created
|
|
153
|
+
if (isolationMode === "none") return { recovered, warnings };
|
|
154
|
+
|
|
155
|
+
// Skip if DB not available — can't determine completion status
|
|
156
|
+
if (!isDbAvailable()) return { recovered, warnings };
|
|
157
|
+
|
|
158
|
+
let milestoneBranches: string[];
|
|
159
|
+
try {
|
|
160
|
+
milestoneBranches = nativeBranchList(basePath, "milestone/*");
|
|
161
|
+
} catch {
|
|
162
|
+
// git branch list failed — skip audit
|
|
163
|
+
return { recovered, warnings };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (milestoneBranches.length === 0) return { recovered, warnings };
|
|
167
|
+
|
|
168
|
+
// Detect main branch for merge-check
|
|
169
|
+
let mainBranch: string;
|
|
170
|
+
try {
|
|
171
|
+
mainBranch = nativeDetectMainBranch(basePath);
|
|
172
|
+
} catch {
|
|
173
|
+
mainBranch = "main";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Get branches already merged into main
|
|
177
|
+
let mergedBranches: Set<string>;
|
|
178
|
+
try {
|
|
179
|
+
mergedBranches = new Set(nativeBranchListMerged(basePath, mainBranch, "milestone/*"));
|
|
180
|
+
} catch {
|
|
181
|
+
mergedBranches = new Set();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const branch of milestoneBranches) {
|
|
185
|
+
const milestoneId = branch.replace(/^milestone\//, "");
|
|
186
|
+
const milestone = getMilestone(milestoneId);
|
|
187
|
+
|
|
188
|
+
// Only audit completed milestones
|
|
189
|
+
if (!milestone || milestone.status !== "complete") continue;
|
|
190
|
+
|
|
191
|
+
const isMerged = mergedBranches.has(branch);
|
|
192
|
+
|
|
193
|
+
if (isMerged) {
|
|
194
|
+
// Branch is merged — safe to delete branch and clean up worktree dir
|
|
195
|
+
try {
|
|
196
|
+
nativeBranchDelete(basePath, branch, true);
|
|
197
|
+
recovered.push(`Deleted merged branch ${branch} for completed milestone ${milestoneId}.`);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
warnings.push(`Failed to delete merged branch ${branch}: ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Clean up orphaned worktree directory if it exists
|
|
203
|
+
const wtDir = getWorktreeDir(basePath, milestoneId);
|
|
204
|
+
if (existsSync(wtDir)) {
|
|
205
|
+
// Try git worktree remove first (handles registered worktrees)
|
|
206
|
+
try {
|
|
207
|
+
nativeWorktreeRemove(basePath, wtDir, true);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
// Not a registered worktree — expected for orphaned dirs
|
|
210
|
+
logWarning("engine", `worktree remove failed (expected for orphaned dirs): ${e instanceof Error ? e.message : String(e)}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// If the directory still exists after git worktree remove (either it
|
|
214
|
+
// wasn't registered or the remove was a noop), fall back to direct
|
|
215
|
+
// filesystem removal — but only inside .gsd/worktrees/ for safety (#2365).
|
|
216
|
+
if (existsSync(wtDir)) {
|
|
217
|
+
if (isInsideWorktreesDir(basePath, wtDir)) {
|
|
218
|
+
try {
|
|
219
|
+
rmSync(wtDir, { recursive: true, force: true });
|
|
220
|
+
recovered.push(`Removed orphaned worktree directory for ${milestoneId}.`);
|
|
221
|
+
} catch (err2) {
|
|
222
|
+
warnings.push(`Failed to remove worktree directory for ${milestoneId}: ${err2 instanceof Error ? err2.message : String(err2)}`);
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
warnings.push(`Orphaned worktree directory for ${milestoneId} is outside .gsd/worktrees/ — skipping removal for safety.`);
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
recovered.push(`Removed orphaned worktree directory for ${milestoneId}.`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// Branch is NOT merged — preserve for safety, warn the user
|
|
233
|
+
warnings.push(
|
|
234
|
+
`Branch ${branch} exists for completed milestone ${milestoneId} but is NOT merged into ${mainBranch}. ` +
|
|
235
|
+
`This may contain unmerged work. Merge manually or run \`/gsd health --fix\` to resolve.`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { recovered, warnings };
|
|
241
|
+
}
|
|
242
|
+
|
|
120
243
|
export async function bootstrapAutoSession(
|
|
121
244
|
s: AutoSession,
|
|
122
245
|
ctx: ExtensionCommandContext,
|
|
@@ -300,6 +423,26 @@ export async function bootstrapAutoSession(
|
|
|
300
423
|
// derivation (queue-order, task status) works on a cold start (#2841).
|
|
301
424
|
await openProjectDbIfPresent(base);
|
|
302
425
|
|
|
426
|
+
// ── Orphaned milestone branch audit ──
|
|
427
|
+
// Catches completed milestones whose teardown (merge + branch delete)
|
|
428
|
+
// was lost due to session ending between completion and teardown.
|
|
429
|
+
// Must run after DB open and before worktree entry.
|
|
430
|
+
try {
|
|
431
|
+
const auditResult = auditOrphanedMilestoneBranches(base, getIsolationMode());
|
|
432
|
+
for (const msg of auditResult.recovered) {
|
|
433
|
+
ctx.ui.notify(`Orphan audit: ${msg}`, "info");
|
|
434
|
+
}
|
|
435
|
+
for (const msg of auditResult.warnings) {
|
|
436
|
+
ctx.ui.notify(`Orphan audit: ${msg}`, "warning");
|
|
437
|
+
}
|
|
438
|
+
if (auditResult.recovered.length > 0) {
|
|
439
|
+
debugLog("orphan-audit", { recovered: auditResult.recovered, warnings: auditResult.warnings });
|
|
440
|
+
}
|
|
441
|
+
} catch (err) {
|
|
442
|
+
// Non-fatal — the audit is defensive, never block bootstrap
|
|
443
|
+
logWarning("bootstrap", `orphaned milestone branch audit failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
303
446
|
let state = await deriveState(base);
|
|
304
447
|
|
|
305
448
|
// Stale worktree state recovery (#654)
|
|
@@ -6,7 +6,7 @@ import { isToolCallEventType } from "@gsd/pi-coding-agent";
|
|
|
6
6
|
import { buildMilestoneFileName, resolveMilestonePath, resolveSliceFile, resolveSlicePath } from "../paths.js";
|
|
7
7
|
import { buildBeforeAgentStartResult } from "./system-context.js";
|
|
8
8
|
import { handleAgentEnd } from "./agent-end-recovery.js";
|
|
9
|
-
import { clearDiscussionFlowState, isDepthVerified, isQueuePhaseActive, markDepthVerified, resetWriteGateState, shouldBlockContextWrite, shouldBlockQueueExecution } from "./write-gate.js";
|
|
9
|
+
import { clearDiscussionFlowState, isDepthVerified, isDepthConfirmationAnswer, isQueuePhaseActive, markDepthVerified, resetWriteGateState, shouldBlockContextWrite, shouldBlockQueueExecution } from "./write-gate.js";
|
|
10
10
|
import { isBlockedStateFile, isBashWriteToStateFile, BLOCKED_WRITE_ERROR } from "../write-intercept.js";
|
|
11
11
|
import { cleanupQuickBranch } from "../quick.js";
|
|
12
12
|
import { getDiscussionMilestoneId } from "../guided-flow.js";
|
|
@@ -249,7 +249,12 @@ export function registerHooks(pi: ExtensionAPI): void {
|
|
|
249
249
|
const questions: any[] = (event.input as any)?.questions ?? [];
|
|
250
250
|
for (const question of questions) {
|
|
251
251
|
if (typeof question.id === "string" && question.id.includes("depth_verification")) {
|
|
252
|
-
|
|
252
|
+
// Only unlock the gate if the user selected the first option (confirmation).
|
|
253
|
+
// Cross-references against the question's defined options to reject free-form "Other" text.
|
|
254
|
+
const answer = details.response?.answers?.[question.id];
|
|
255
|
+
if (isDepthConfirmationAnswer(answer?.selected, question.options)) {
|
|
256
|
+
markDepthVerified();
|
|
257
|
+
}
|
|
253
258
|
break;
|
|
254
259
|
}
|
|
255
260
|
}
|
|
@@ -15,7 +15,7 @@ import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "../skill-dis
|
|
|
15
15
|
import { getActiveAutoWorktreeContext } from "../auto-worktree.js";
|
|
16
16
|
import { getActiveWorktreeName, getWorktreeOriginalCwd } from "../worktree-command.js";
|
|
17
17
|
import { deriveState } from "../state.js";
|
|
18
|
-
import { formatOverridesSection, loadActiveOverrides, loadFile, parseContinue, parseSummary } from "../files.js";
|
|
18
|
+
import { formatOverridesSection, formatShortcut, loadActiveOverrides, loadFile, parseContinue, parseSummary } from "../files.js";
|
|
19
19
|
import { toPosixPath } from "../../shared/mod.js";
|
|
20
20
|
import { markCmuxPromptShown, shouldPromptToEnableCmux } from "../../cmux/index.js";
|
|
21
21
|
|
|
@@ -72,6 +72,8 @@ export async function buildBeforeAgentStartResult(
|
|
|
72
72
|
const systemContent = loadPrompt("system", {
|
|
73
73
|
bundledSkillsTable: buildBundledSkillsTable(),
|
|
74
74
|
templatesDir: getTemplatesDir(),
|
|
75
|
+
shortcutDashboard: formatShortcut("Ctrl+Alt+G"),
|
|
76
|
+
shortcutShell: formatShortcut("Ctrl+Alt+B"),
|
|
75
77
|
});
|
|
76
78
|
const loadedPreferences = loadEffectiveGSDPreferences();
|
|
77
79
|
if (shouldPromptToEnableCmux(loadedPreferences?.preferences)) {
|
|
@@ -54,6 +54,35 @@ export function markDepthVerified(): void {
|
|
|
54
54
|
depthVerificationDone = true;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Check whether a depth_verification answer confirms the discussion is complete.
|
|
59
|
+
* Uses structural validation: the selected answer must exactly match the first
|
|
60
|
+
* option label from the question definition (the confirmation option by convention).
|
|
61
|
+
* This rejects free-form "Other" text, decline options, and garbage input without
|
|
62
|
+
* coupling to any specific label substring.
|
|
63
|
+
*
|
|
64
|
+
* @param selected The answer's selected value from details.response.answers[id].selected
|
|
65
|
+
* @param options The question's options array from event.input.questions[n].options
|
|
66
|
+
*/
|
|
67
|
+
export function isDepthConfirmationAnswer(
|
|
68
|
+
selected: unknown,
|
|
69
|
+
options?: Array<{ label?: string }>,
|
|
70
|
+
): boolean {
|
|
71
|
+
const value = Array.isArray(selected) ? selected[0] : selected;
|
|
72
|
+
if (typeof value !== "string" || !value) return false;
|
|
73
|
+
|
|
74
|
+
// If options are available, structurally validate: selected must exactly match
|
|
75
|
+
// the first option (confirmation) label. Rejects free-form "Other" and decline options.
|
|
76
|
+
if (Array.isArray(options) && options.length > 0) {
|
|
77
|
+
const confirmLabel = options[0]?.label;
|
|
78
|
+
return typeof confirmLabel === "string" && value === confirmLabel;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fallback when options aren't available (e.g., older call sites):
|
|
82
|
+
// accept only if it contains "(Recommended)" — the prompt convention suffix.
|
|
83
|
+
return value.includes("(Recommended)");
|
|
84
|
+
}
|
|
85
|
+
|
|
57
86
|
export function shouldBlockContextWrite(
|
|
58
87
|
toolName: string,
|
|
59
88
|
inputPath: string,
|
|
@@ -71,7 +100,13 @@ export function shouldBlockContextWrite(
|
|
|
71
100
|
|
|
72
101
|
return {
|
|
73
102
|
block: true,
|
|
74
|
-
reason:
|
|
103
|
+
reason: [
|
|
104
|
+
`HARD BLOCK: Cannot write to milestone CONTEXT.md without depth verification.`,
|
|
105
|
+
`This is a mechanical gate — you MUST NOT proceed, retry, or rationalize past this block.`,
|
|
106
|
+
`Required action: call ask_user_questions with question id containing "depth_verification".`,
|
|
107
|
+
`The user MUST select the "(Recommended)" confirmation option to unlock this gate.`,
|
|
108
|
+
`If the user declines, cancels, or the tool fails, you must re-ask — not bypass.`,
|
|
109
|
+
].join(" "),
|
|
75
110
|
};
|
|
76
111
|
}
|
|
77
112
|
|
|
@@ -8,6 +8,7 @@ import { runEnvironmentChecks } from "../../doctor-environment.js";
|
|
|
8
8
|
import { deriveState } from "../../state.js";
|
|
9
9
|
import { handleCmux } from "../../commands-cmux.js";
|
|
10
10
|
import { projectRoot } from "../context.js";
|
|
11
|
+
import { formatShortcut } from "../../files.js";
|
|
11
12
|
|
|
12
13
|
export function showHelp(ctx: ExtensionCommandContext): void {
|
|
13
14
|
const lines = [
|
|
@@ -24,12 +25,12 @@ export function showHelp(ctx: ExtensionCommandContext): void {
|
|
|
24
25
|
" /gsd new-milestone Create milestone from headless context (used by gsd headless)",
|
|
25
26
|
"",
|
|
26
27
|
"VISIBILITY",
|
|
27
|
-
|
|
28
|
+
` /gsd status Show progress dashboard (${formatShortcut("Ctrl+Alt+G")})`,
|
|
28
29
|
" /gsd visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)",
|
|
29
30
|
" /gsd queue Show queued/dispatched units and execution order",
|
|
30
31
|
" /gsd history View execution history [--cost] [--phase] [--model] [N]",
|
|
31
32
|
" /gsd changelog Show categorized release notes [version]",
|
|
32
|
-
|
|
33
|
+
` /gsd notifications View persistent notification history [clear|tail|filter] (${formatShortcut("Ctrl+Alt+N")})`,
|
|
33
34
|
"",
|
|
34
35
|
"COURSE CORRECTION",
|
|
35
36
|
" /gsd steer <desc> Apply user override to active work",
|
|
@@ -70,6 +70,25 @@ export function clearParseCache(): void {
|
|
|
70
70
|
for (const cb of _cacheClearCallbacks) cb();
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// ─── Platform shortcuts ───────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const IS_MAC = process.platform === "darwin";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format a keyboard shortcut for the current OS.
|
|
79
|
+
* Input: modifier key combo like "Ctrl+Alt+G"
|
|
80
|
+
* Output: "⌃⌥G" on macOS, "Ctrl+Alt+G" on Windows/Linux.
|
|
81
|
+
*/
|
|
82
|
+
export function formatShortcut(combo: string): string {
|
|
83
|
+
if (!IS_MAC) return combo;
|
|
84
|
+
return combo
|
|
85
|
+
.replace(/Ctrl\+Alt\+/i, "⌃⌥")
|
|
86
|
+
.replace(/Ctrl\+/i, "⌃")
|
|
87
|
+
.replace(/Alt\+/i, "⌥")
|
|
88
|
+
.replace(/Shift\+/i, "⇧")
|
|
89
|
+
.replace(/Cmd\+/i, "⌘");
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
74
93
|
|
|
75
94
|
/** Extract the text after a heading at a given level, up to the next heading of same or higher level. */
|
|
@@ -451,6 +451,25 @@ function migrateSchema(db: DbAdapter): void {
|
|
|
451
451
|
const currentVersion = row ? (row["v"] as number) : 0;
|
|
452
452
|
if (currentVersion >= SCHEMA_VERSION) return;
|
|
453
453
|
|
|
454
|
+
// Backup database before migration so a mid-migration crash doesn't
|
|
455
|
+
// leave a partially-migrated DB with no recovery path.
|
|
456
|
+
// WAL-safe: checkpoint first to flush WAL into the main DB file, then copy.
|
|
457
|
+
if (currentPath && currentPath !== ":memory:" && existsSync(currentPath)) {
|
|
458
|
+
try {
|
|
459
|
+
const backupPath = `${currentPath}.backup-v${currentVersion}`;
|
|
460
|
+
if (!existsSync(backupPath)) {
|
|
461
|
+
// Flush WAL to main DB file before copying — without this, the backup
|
|
462
|
+
// may be missing committed data that only exists in the -wal file.
|
|
463
|
+
try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch { /* checkpoint is best-effort */ }
|
|
464
|
+
copyFileSync(currentPath, backupPath);
|
|
465
|
+
}
|
|
466
|
+
} catch (backupErr) {
|
|
467
|
+
// Log but proceed — blocking migration leaves the DB stuck at an old
|
|
468
|
+
// schema version permanently on read-only or full filesystems.
|
|
469
|
+
logWarning("db", `Pre-migration backup failed: ${backupErr instanceof Error ? backupErr.message : String(backupErr)}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
454
473
|
db.exec("BEGIN");
|
|
455
474
|
try {
|
|
456
475
|
if (currentVersion < 2) {
|
|
@@ -999,9 +1018,21 @@ export function _resetProvider(): void {
|
|
|
999
1018
|
|
|
1000
1019
|
export function upsertDecision(d: Omit<Decision, "seq">): void {
|
|
1001
1020
|
if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
|
|
1021
|
+
// Use ON CONFLICT DO UPDATE instead of INSERT OR REPLACE to preserve the
|
|
1022
|
+
// seq column. INSERT OR REPLACE deletes then reinserts, resetting seq and
|
|
1023
|
+
// corrupting decision ordering in DECISIONS.md after reconcile replay.
|
|
1002
1024
|
currentDb.prepare(
|
|
1003
|
-
`INSERT
|
|
1004
|
-
VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :made_by, :superseded_by)
|
|
1025
|
+
`INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by)
|
|
1026
|
+
VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :made_by, :superseded_by)
|
|
1027
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1028
|
+
when_context = excluded.when_context,
|
|
1029
|
+
scope = excluded.scope,
|
|
1030
|
+
decision = excluded.decision,
|
|
1031
|
+
choice = excluded.choice,
|
|
1032
|
+
rationale = excluded.rationale,
|
|
1033
|
+
revisable = excluded.revisable,
|
|
1034
|
+
made_by = excluded.made_by,
|
|
1035
|
+
superseded_by = excluded.superseded_by`,
|
|
1005
1036
|
).run({
|
|
1006
1037
|
":id": d.id,
|
|
1007
1038
|
":when_context": d.when_context,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GSD Extension — Notification History Overlay
|
|
2
2
|
// Scrollable panel showing all persisted notifications with severity filtering.
|
|
3
|
-
// Toggled with Ctrl+Alt+N or opened from /gsd notifications.
|
|
3
|
+
// Toggled with Ctrl+Alt+N (⌃⌥N on macOS) or opened from /gsd notifications.
|
|
4
4
|
|
|
5
5
|
import type { Theme } from "@gsd/pi-coding-agent";
|
|
6
6
|
import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { ExtensionContext } from "@gsd/pi-coding-agent";
|
|
7
7
|
|
|
8
8
|
import { getUnreadCount, readNotifications } from "./notification-store.js";
|
|
9
|
+
import { formatShortcut } from "./files.js";
|
|
9
10
|
|
|
10
11
|
// ─── Pure rendering ──���────────────────────────���─────────────────────────
|
|
11
12
|
|
|
@@ -24,7 +25,7 @@ export function buildNotificationWidgetLines(): string[] {
|
|
|
24
25
|
? latest.message.slice(0, msgMax - 1) + "…"
|
|
25
26
|
: latest.message;
|
|
26
27
|
|
|
27
|
-
return [` ${icon} [${badge}] ${truncated} (Ctrl+Alt+N to view)`];
|
|
28
|
+
return [` ${icon} [${badge}] ${truncated} (${formatShortcut("Ctrl+Alt+N")} to view)`];
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
// ─── Widget init ────────────────────────────────────────────────────────
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* GSD Parallel Monitor Overlay
|
|
3
3
|
*
|
|
4
4
|
* Full-screen TUI overlay showing real-time parallel worker progress.
|
|
5
|
-
* Opened via `/gsd parallel watch` or Ctrl+Alt+P.
|
|
5
|
+
* Opened via `/gsd parallel watch` or Ctrl+Alt+P (⌃⌥P on macOS).
|
|
6
6
|
* Reads the same data sources as `scripts/parallel-monitor.mjs` but
|
|
7
7
|
* renders as a native pi-tui overlay with theme integration.
|
|
8
8
|
*/
|
|
@@ -238,8 +238,7 @@ export async function checkPackageExistence(
|
|
|
238
238
|
export function normalizeFilePath(filePath: string): string {
|
|
239
239
|
if (!filePath) return filePath;
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
let normalized = filePath.replace(/`/g, "");
|
|
241
|
+
let normalized = extractPathFromAnnotation(filePath);
|
|
243
242
|
|
|
244
243
|
// Normalize path separators to forward slashes
|
|
245
244
|
normalized = normalized.replace(/\\/g, "/");
|
|
@@ -260,6 +259,24 @@ export function normalizeFilePath(filePath: string): string {
|
|
|
260
259
|
return normalized;
|
|
261
260
|
}
|
|
262
261
|
|
|
262
|
+
function extractPathFromAnnotation(raw: string): string {
|
|
263
|
+
const trimmed = raw.trim();
|
|
264
|
+
if (!trimmed) return trimmed;
|
|
265
|
+
|
|
266
|
+
const backtickMatch = trimmed.match(/^`([^`]+)`(?:\s+[—–-]\s+.*)?$/);
|
|
267
|
+
if (backtickMatch) {
|
|
268
|
+
return backtickMatch[1].trim();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const annotatedMatch = trimmed.match(/^(.+?)\s+[—–-]\s+.+$/);
|
|
272
|
+
if (annotatedMatch) {
|
|
273
|
+
return annotatedMatch[1].trim();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Fall back to the original behavior for already-plain paths.
|
|
277
|
+
return trimmed.replace(/`/g, "");
|
|
278
|
+
}
|
|
279
|
+
|
|
263
280
|
/**
|
|
264
281
|
* Build a set of files that will be created by tasks up to (but not including) taskIndex.
|
|
265
282
|
* All paths are normalized for consistent comparison.
|
|
@@ -114,6 +114,8 @@ If they clarify, absorb the correction and re-verify.
|
|
|
114
114
|
|
|
115
115
|
The depth verification is the required write-gate. Do **not** add another meta "ready to proceed?" checkpoint immediately after it unless there is still material ambiguity.
|
|
116
116
|
|
|
117
|
+
**CRITICAL — Non-bypassable gate:** The system mechanically blocks CONTEXT.md writes until the user selects the "(Recommended)" option. If the user declines, cancels, or the tool fails, you MUST re-ask — never rationalize past the block ("tool not responding, I'll proceed" is forbidden). The gate exists to protect the user's work; treat a block as an instruction, not an obstacle to work around.
|
|
118
|
+
|
|
117
119
|
## Wrap-up Gate
|
|
118
120
|
|
|
119
121
|
Once the depth checklist is fully satisfied, move directly into requirements and roadmap preview. Do not insert a separate "are you ready to continue?" gate unless the user explicitly wants to keep brainstorming or you still see material ambiguity.
|
|
@@ -100,6 +100,8 @@ If they clarify, absorb the correction and re-verify.
|
|
|
100
100
|
|
|
101
101
|
The depth verification is the only required confirmation gate. Do not add a second "ready to proceed?" gate after it.
|
|
102
102
|
|
|
103
|
+
**CRITICAL — Non-bypassable gate:** The system mechanically blocks CONTEXT.md writes until the user selects the "(Recommended)" option. If the user declines, cancels, or the tool fails, you MUST re-ask — never rationalize past the block ("tool not responding, I'll proceed" is forbidden). The gate exists to protect the user's work; treat a block as an instruction, not an obstacle to work around.
|
|
104
|
+
|
|
103
105
|
---
|
|
104
106
|
|
|
105
107
|
## Output
|
|
@@ -103,6 +103,8 @@ The user confirms or corrects before you write. One depth verification per miles
|
|
|
103
103
|
|
|
104
104
|
**If you skip this step, the system will block the CONTEXT.md write and return an error telling you to complete verification first.**
|
|
105
105
|
|
|
106
|
+
**CRITICAL — Non-bypassable gate:** The system mechanically blocks CONTEXT.md writes until the user selects the "(Recommended)" option. If the user declines, cancels, or the tool fails, you MUST re-ask — never rationalize past the block ("tool not responding, I'll proceed" is forbidden). The gate exists to protect the user's work; treat a block as an instruction, not an obstacle to work around.
|
|
107
|
+
|
|
106
108
|
## Output Phase
|
|
107
109
|
|
|
108
110
|
Once the user is satisfied, in a single pass for **each** new milestone:
|
|
@@ -131,8 +131,8 @@ Templates showing the expected format for each artifact type are in:
|
|
|
131
131
|
- `/gsd status` - progress dashboard overlay
|
|
132
132
|
- `/gsd queue` - queue future milestones (safe while auto-mode is running)
|
|
133
133
|
- `/gsd quick <task>` - quick task with GSD guarantees (atomic commits, state tracking) but no milestone ceremony
|
|
134
|
-
- `
|
|
135
|
-
- `
|
|
134
|
+
- `{{shortcutDashboard}}` - toggle dashboard overlay
|
|
135
|
+
- `{{shortcutShell}}` - show shell processes
|
|
136
136
|
|
|
137
137
|
## Execution Heuristics
|
|
138
138
|
|
|
@@ -304,12 +304,9 @@ function extractContextTitle(content: string | null, fallback: string): string {
|
|
|
304
304
|
|
|
305
305
|
// ─── DB-backed State Derivation ────────────────────────────────────────────
|
|
306
306
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
function isStatusDone(status: string): boolean {
|
|
311
|
-
return status === 'complete' || status === 'done' || status === 'skipped';
|
|
312
|
-
}
|
|
307
|
+
// isStatusDone replaced by isClosedStatus from status-guards.ts (single source of truth).
|
|
308
|
+
// Alias kept for backward compatibility within this file.
|
|
309
|
+
const isStatusDone = isClosedStatus;
|
|
313
310
|
|
|
314
311
|
/**
|
|
315
312
|
* Derive GSD state from the milestones/slices/tasks DB tables.
|
|
@@ -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();
|