gsd-pi 2.33.1-dev.ee47f1b → 2.34.0-dev.bbb5216
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/bundled-resource-path.d.ts +8 -0
- package/dist/bundled-resource-path.js +14 -0
- package/dist/headless-query.js +6 -6
- package/dist/resources/extensions/gsd/auto/session.js +27 -32
- package/dist/resources/extensions/gsd/auto-dashboard.js +29 -109
- package/dist/resources/extensions/gsd/auto-direct-dispatch.js +6 -1
- package/dist/resources/extensions/gsd/auto-dispatch.js +52 -81
- package/dist/resources/extensions/gsd/auto-loop.js +956 -0
- package/dist/resources/extensions/gsd/auto-observability.js +4 -2
- package/dist/resources/extensions/gsd/auto-post-unit.js +75 -185
- package/dist/resources/extensions/gsd/auto-prompts.js +133 -101
- package/dist/resources/extensions/gsd/auto-recovery.js +59 -97
- package/dist/resources/extensions/gsd/auto-start.js +330 -309
- package/dist/resources/extensions/gsd/auto-supervisor.js +5 -11
- package/dist/resources/extensions/gsd/auto-timeout-recovery.js +7 -7
- package/dist/resources/extensions/gsd/auto-timers.js +3 -4
- package/dist/resources/extensions/gsd/auto-verification.js +35 -73
- package/dist/resources/extensions/gsd/auto-worktree-sync.js +167 -0
- package/dist/resources/extensions/gsd/auto-worktree.js +291 -126
- package/dist/resources/extensions/gsd/auto.js +283 -1013
- package/dist/resources/extensions/gsd/captures.js +10 -4
- package/dist/resources/extensions/gsd/dispatch-guard.js +7 -8
- package/dist/resources/extensions/gsd/docs/preferences-reference.md +25 -18
- package/dist/resources/extensions/gsd/doctor-checks.js +3 -4
- package/dist/resources/extensions/gsd/git-service.js +1 -1
- package/dist/resources/extensions/gsd/gsd-db.js +296 -151
- package/dist/resources/extensions/gsd/index.js +92 -228
- package/dist/resources/extensions/gsd/post-unit-hooks.js +13 -13
- package/dist/resources/extensions/gsd/progress-score.js +61 -156
- package/dist/resources/extensions/gsd/quick.js +98 -122
- package/dist/resources/extensions/gsd/session-lock.js +13 -0
- package/dist/resources/extensions/gsd/templates/preferences.md +1 -0
- package/dist/resources/extensions/gsd/undo.js +43 -48
- package/dist/resources/extensions/gsd/unit-runtime.js +16 -15
- package/dist/resources/extensions/gsd/verification-evidence.js +0 -1
- package/dist/resources/extensions/gsd/verification-gate.js +6 -35
- package/dist/resources/extensions/gsd/worktree-command.js +30 -24
- package/dist/resources/extensions/gsd/worktree-manager.js +2 -3
- package/dist/resources/extensions/gsd/worktree-resolver.js +344 -0
- package/dist/resources/extensions/gsd/worktree.js +7 -44
- package/dist/tool-bootstrap.js +59 -11
- package/dist/worktree-cli.js +7 -7
- package/package.json +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +3630 -5483
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +735 -2588
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/src/models.generated.ts +1039 -2892
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto/session.ts +47 -30
- package/src/resources/extensions/gsd/auto-dashboard.ts +28 -131
- package/src/resources/extensions/gsd/auto-direct-dispatch.ts +6 -1
- package/src/resources/extensions/gsd/auto-dispatch.ts +135 -91
- package/src/resources/extensions/gsd/auto-loop.ts +1665 -0
- package/src/resources/extensions/gsd/auto-observability.ts +4 -2
- package/src/resources/extensions/gsd/auto-post-unit.ts +85 -228
- package/src/resources/extensions/gsd/auto-prompts.ts +138 -109
- package/src/resources/extensions/gsd/auto-recovery.ts +124 -118
- package/src/resources/extensions/gsd/auto-start.ts +440 -354
- package/src/resources/extensions/gsd/auto-supervisor.ts +5 -12
- package/src/resources/extensions/gsd/auto-timeout-recovery.ts +8 -8
- package/src/resources/extensions/gsd/auto-timers.ts +3 -4
- package/src/resources/extensions/gsd/auto-verification.ts +76 -90
- package/src/resources/extensions/gsd/auto-worktree-sync.ts +204 -0
- package/src/resources/extensions/gsd/auto-worktree.ts +389 -141
- package/src/resources/extensions/gsd/auto.ts +515 -1199
- package/src/resources/extensions/gsd/captures.ts +10 -4
- package/src/resources/extensions/gsd/dispatch-guard.ts +13 -9
- package/src/resources/extensions/gsd/docs/preferences-reference.md +25 -18
- package/src/resources/extensions/gsd/doctor-checks.ts +3 -4
- package/src/resources/extensions/gsd/git-service.ts +8 -1
- package/src/resources/extensions/gsd/gitignore.ts +4 -2
- package/src/resources/extensions/gsd/gsd-db.ts +375 -180
- package/src/resources/extensions/gsd/index.ts +104 -263
- package/src/resources/extensions/gsd/post-unit-hooks.ts +13 -13
- package/src/resources/extensions/gsd/progress-score.ts +65 -200
- package/src/resources/extensions/gsd/quick.ts +121 -125
- package/src/resources/extensions/gsd/session-lock.ts +11 -0
- package/src/resources/extensions/gsd/templates/preferences.md +1 -0
- package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +32 -59
- package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +75 -27
- package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +37 -0
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +1458 -0
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +8 -162
- package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +2 -108
- package/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts +1 -3
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +0 -3
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +58 -0
- package/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +0 -55
- package/src/resources/extensions/gsd/tests/headless-query.test.ts +22 -0
- package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +8 -11
- package/src/resources/extensions/gsd/tests/provider-errors.test.ts +4 -6
- package/src/resources/extensions/gsd/tests/run-uat.test.ts +3 -3
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +64 -0
- package/src/resources/extensions/gsd/tests/sidecar-queue.test.ts +181 -0
- package/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +0 -3
- package/src/resources/extensions/gsd/tests/token-profile.test.ts +6 -6
- package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +6 -6
- package/src/resources/extensions/gsd/tests/undo.test.ts +6 -0
- package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +24 -26
- package/src/resources/extensions/gsd/tests/verification-gate.test.ts +7 -201
- package/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts +205 -0
- package/src/resources/extensions/gsd/tests/worktree-db.test.ts +442 -0
- package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +0 -3
- package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +705 -0
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +57 -106
- package/src/resources/extensions/gsd/tests/worktree.test.ts +5 -1
- package/src/resources/extensions/gsd/tests/write-gate.test.ts +43 -132
- package/src/resources/extensions/gsd/types.ts +90 -81
- package/src/resources/extensions/gsd/undo.ts +42 -46
- package/src/resources/extensions/gsd/unit-runtime.ts +14 -18
- package/src/resources/extensions/gsd/verification-evidence.ts +1 -3
- package/src/resources/extensions/gsd/verification-gate.ts +6 -39
- package/src/resources/extensions/gsd/worktree-command.ts +36 -24
- package/src/resources/extensions/gsd/worktree-manager.ts +2 -3
- package/src/resources/extensions/gsd/worktree-resolver.ts +485 -0
- package/src/resources/extensions/gsd/worktree.ts +7 -44
- package/dist/resources/extensions/gsd/auto-constants.js +0 -5
- package/dist/resources/extensions/gsd/auto-idempotency.js +0 -106
- package/dist/resources/extensions/gsd/auto-stuck-detection.js +0 -165
- package/dist/resources/extensions/gsd/mechanical-completion.js +0 -351
- package/src/resources/extensions/gsd/auto-constants.ts +0 -6
- package/src/resources/extensions/gsd/auto-idempotency.ts +0 -151
- package/src/resources/extensions/gsd/auto-stuck-detection.ts +0 -221
- package/src/resources/extensions/gsd/mechanical-completion.ts +0 -430
- package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +0 -691
- package/src/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +0 -127
- package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +0 -123
- package/src/resources/extensions/gsd/tests/dispatch-stall-guard.test.ts +0 -126
- package/src/resources/extensions/gsd/tests/loop-regression.test.ts +0 -874
- package/src/resources/extensions/gsd/tests/mechanical-completion.test.ts +0 -356
- package/src/resources/extensions/gsd/tests/progress-score.test.ts +0 -206
- package/src/resources/extensions/gsd/tests/session-lock.test.ts +0 -434
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorktreeResolver — encapsulates worktree path state and merge/exit lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Replaces scattered `s.basePath`/`s.originalBasePath` mutation and 3 duplicated
|
|
5
|
+
* merge-or-teardown blocks in auto-loop.ts with single method calls. All
|
|
6
|
+
* `s.basePath` mutations (except session.reset() and initial setup) happen
|
|
7
|
+
* through this class.
|
|
8
|
+
*
|
|
9
|
+
* Design: Option A — mutates AutoSession fields directly so existing `s.basePath`
|
|
10
|
+
* reads continue to work everywhere without wiring changes.
|
|
11
|
+
*
|
|
12
|
+
* Key invariant: `createAutoWorktree()` and `enterAutoWorktree()` call
|
|
13
|
+
* `process.chdir()` internally — this class MUST NOT double-chdir.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AutoSession } from "./auto/session.js";
|
|
17
|
+
import { debugLog } from "./debug-logger.js";
|
|
18
|
+
|
|
19
|
+
// ─── Dependency Interface ──────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface WorktreeResolverDeps {
|
|
22
|
+
isInAutoWorktree: (basePath: string) => boolean;
|
|
23
|
+
shouldUseWorktreeIsolation: () => boolean;
|
|
24
|
+
getIsolationMode: () => "worktree" | "branch" | "none";
|
|
25
|
+
mergeMilestoneToMain: (
|
|
26
|
+
basePath: string,
|
|
27
|
+
milestoneId: string,
|
|
28
|
+
roadmapContent: string,
|
|
29
|
+
) => { pushed: boolean };
|
|
30
|
+
syncWorktreeStateBack: (
|
|
31
|
+
mainBasePath: string,
|
|
32
|
+
worktreePath: string,
|
|
33
|
+
milestoneId: string,
|
|
34
|
+
) => { synced: string[] };
|
|
35
|
+
teardownAutoWorktree: (
|
|
36
|
+
basePath: string,
|
|
37
|
+
milestoneId: string,
|
|
38
|
+
opts?: { preserveBranch?: boolean },
|
|
39
|
+
) => void;
|
|
40
|
+
createAutoWorktree: (basePath: string, milestoneId: string) => string;
|
|
41
|
+
enterAutoWorktree: (basePath: string, milestoneId: string) => string;
|
|
42
|
+
getAutoWorktreePath: (basePath: string, milestoneId: string) => string | null;
|
|
43
|
+
autoCommitCurrentBranch: (
|
|
44
|
+
basePath: string,
|
|
45
|
+
reason: string,
|
|
46
|
+
milestoneId: string,
|
|
47
|
+
) => void;
|
|
48
|
+
getCurrentBranch: (basePath: string) => string;
|
|
49
|
+
autoWorktreeBranch: (milestoneId: string) => string;
|
|
50
|
+
resolveMilestoneFile: (
|
|
51
|
+
basePath: string,
|
|
52
|
+
milestoneId: string,
|
|
53
|
+
fileType: string,
|
|
54
|
+
) => string | null;
|
|
55
|
+
readFileSync: (path: string, encoding: string) => string;
|
|
56
|
+
GitServiceImpl: new (basePath: string, gitConfig: unknown) => unknown;
|
|
57
|
+
loadEffectiveGSDPreferences: () =>
|
|
58
|
+
| { preferences?: { git?: Record<string, unknown> } }
|
|
59
|
+
| undefined;
|
|
60
|
+
invalidateAllCaches: () => void;
|
|
61
|
+
captureIntegrationBranch: (
|
|
62
|
+
basePath: string,
|
|
63
|
+
mid: string,
|
|
64
|
+
opts?: { commitDocs?: boolean },
|
|
65
|
+
) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Notify Context ────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export interface NotifyCtx {
|
|
71
|
+
notify: (
|
|
72
|
+
msg: string,
|
|
73
|
+
level?: "info" | "warning" | "error" | "success",
|
|
74
|
+
) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── WorktreeResolver ──────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export class WorktreeResolver {
|
|
80
|
+
private readonly s: AutoSession;
|
|
81
|
+
private readonly deps: WorktreeResolverDeps;
|
|
82
|
+
|
|
83
|
+
constructor(session: AutoSession, deps: WorktreeResolverDeps) {
|
|
84
|
+
this.s = session;
|
|
85
|
+
this.deps = deps;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Getters ────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** Current working path — may be worktree or project root. */
|
|
91
|
+
get workPath(): string {
|
|
92
|
+
return this.s.basePath;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Original project root — always the non-worktree path. */
|
|
96
|
+
get projectRoot(): string {
|
|
97
|
+
return this.s.originalBasePath || this.s.basePath;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Path for auto.lock file — same as the old lockBase(). */
|
|
101
|
+
get lockPath(): string {
|
|
102
|
+
return this.s.originalBasePath || this.s.basePath;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Private Helpers ────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
private rebuildGitService(): void {
|
|
108
|
+
const gitConfig =
|
|
109
|
+
this.deps.loadEffectiveGSDPreferences()?.preferences?.git ?? {};
|
|
110
|
+
this.s.gitService = new this.deps.GitServiceImpl(
|
|
111
|
+
this.s.basePath,
|
|
112
|
+
gitConfig,
|
|
113
|
+
) as AutoSession["gitService"];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Restore basePath to originalBasePath and rebuild GitService. */
|
|
117
|
+
private restoreToProjectRoot(): void {
|
|
118
|
+
if (!this.s.originalBasePath) return;
|
|
119
|
+
this.s.basePath = this.s.originalBasePath;
|
|
120
|
+
this.rebuildGitService();
|
|
121
|
+
this.deps.invalidateAllCaches();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Validation ──────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/** Validate milestoneId to prevent path traversal. */
|
|
127
|
+
private validateMilestoneId(milestoneId: string): void {
|
|
128
|
+
if (/[\/\\]|\.\./.test(milestoneId)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Invalid milestoneId: ${milestoneId} — contains path separators or traversal`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Enter Milestone ────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Enter or create a worktree for the given milestone.
|
|
139
|
+
*
|
|
140
|
+
* Only acts if `shouldUseWorktreeIsolation()` returns true.
|
|
141
|
+
* Delegates to `enterAutoWorktree` (existing) or `createAutoWorktree` (new).
|
|
142
|
+
* Those functions call `process.chdir()` internally — we do NOT double-chdir.
|
|
143
|
+
*
|
|
144
|
+
* Updates `s.basePath` and rebuilds GitService on success.
|
|
145
|
+
* On failure: notifies a warning and does NOT update `s.basePath`.
|
|
146
|
+
*/
|
|
147
|
+
enterMilestone(milestoneId: string, ctx: NotifyCtx): void {
|
|
148
|
+
this.validateMilestoneId(milestoneId);
|
|
149
|
+
if (!this.deps.shouldUseWorktreeIsolation()) {
|
|
150
|
+
debugLog("WorktreeResolver", {
|
|
151
|
+
action: "enterMilestone",
|
|
152
|
+
milestoneId,
|
|
153
|
+
skipped: true,
|
|
154
|
+
reason: "isolation-disabled",
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const basePath = this.s.originalBasePath || this.s.basePath;
|
|
160
|
+
debugLog("WorktreeResolver", {
|
|
161
|
+
action: "enterMilestone",
|
|
162
|
+
milestoneId,
|
|
163
|
+
basePath,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const existingPath = this.deps.getAutoWorktreePath(basePath, milestoneId);
|
|
168
|
+
let wtPath: string;
|
|
169
|
+
|
|
170
|
+
if (existingPath) {
|
|
171
|
+
wtPath = this.deps.enterAutoWorktree(basePath, milestoneId);
|
|
172
|
+
} else {
|
|
173
|
+
wtPath = this.deps.createAutoWorktree(basePath, milestoneId);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.s.basePath = wtPath;
|
|
177
|
+
this.rebuildGitService();
|
|
178
|
+
|
|
179
|
+
debugLog("WorktreeResolver", {
|
|
180
|
+
action: "enterMilestone",
|
|
181
|
+
milestoneId,
|
|
182
|
+
result: "success",
|
|
183
|
+
wtPath,
|
|
184
|
+
});
|
|
185
|
+
ctx.notify(`Entered worktree for ${milestoneId} at ${wtPath}`, "info");
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
188
|
+
debugLog("WorktreeResolver", {
|
|
189
|
+
action: "enterMilestone",
|
|
190
|
+
milestoneId,
|
|
191
|
+
result: "error",
|
|
192
|
+
error: msg,
|
|
193
|
+
});
|
|
194
|
+
ctx.notify(
|
|
195
|
+
`Auto-worktree creation for ${milestoneId} failed: ${msg}. Continuing in project root.`,
|
|
196
|
+
"warning",
|
|
197
|
+
);
|
|
198
|
+
// Do NOT update s.basePath — stay in project root
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Exit Milestone ─────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Exit the current worktree: auto-commit, teardown, reset basePath.
|
|
206
|
+
*
|
|
207
|
+
* Only acts if currently in an auto-worktree (checked via `isInAutoWorktree`).
|
|
208
|
+
* Resets `s.basePath` to `s.originalBasePath` and rebuilds GitService.
|
|
209
|
+
*/
|
|
210
|
+
exitMilestone(
|
|
211
|
+
milestoneId: string,
|
|
212
|
+
ctx: NotifyCtx,
|
|
213
|
+
opts?: { preserveBranch?: boolean },
|
|
214
|
+
): void {
|
|
215
|
+
this.validateMilestoneId(milestoneId);
|
|
216
|
+
if (!this.deps.isInAutoWorktree(this.s.basePath)) {
|
|
217
|
+
debugLog("WorktreeResolver", {
|
|
218
|
+
action: "exitMilestone",
|
|
219
|
+
milestoneId,
|
|
220
|
+
skipped: true,
|
|
221
|
+
reason: "not-in-worktree",
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
debugLog("WorktreeResolver", {
|
|
227
|
+
action: "exitMilestone",
|
|
228
|
+
milestoneId,
|
|
229
|
+
basePath: this.s.basePath,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
this.deps.autoCommitCurrentBranch(this.s.basePath, "stop", milestoneId);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
debugLog("WorktreeResolver", {
|
|
236
|
+
action: "exitMilestone",
|
|
237
|
+
milestoneId,
|
|
238
|
+
phase: "auto-commit-failed",
|
|
239
|
+
error: err instanceof Error ? err.message : String(err),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
this.deps.teardownAutoWorktree(this.s.originalBasePath, milestoneId, {
|
|
245
|
+
preserveBranch: opts?.preserveBranch ?? false,
|
|
246
|
+
});
|
|
247
|
+
} catch (err) {
|
|
248
|
+
debugLog("WorktreeResolver", {
|
|
249
|
+
action: "exitMilestone",
|
|
250
|
+
milestoneId,
|
|
251
|
+
phase: "teardown-failed",
|
|
252
|
+
error: err instanceof Error ? err.message : String(err),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.restoreToProjectRoot();
|
|
257
|
+
debugLog("WorktreeResolver", {
|
|
258
|
+
action: "exitMilestone",
|
|
259
|
+
milestoneId,
|
|
260
|
+
result: "done",
|
|
261
|
+
basePath: this.s.basePath,
|
|
262
|
+
});
|
|
263
|
+
ctx.notify(`Exited worktree for ${milestoneId}`, "info");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Merge and Exit ─────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Merge the completed milestone branch back to main and exit the worktree.
|
|
270
|
+
*
|
|
271
|
+
* Handles all three isolation modes:
|
|
272
|
+
* - **worktree**: Read roadmap, merge, teardown worktree, reset paths.
|
|
273
|
+
* Falls back to bare teardown if no roadmap exists.
|
|
274
|
+
* - **branch**: Check if on milestone branch, merge if so (no chdir/teardown).
|
|
275
|
+
* - **none**: No-op.
|
|
276
|
+
*
|
|
277
|
+
* Error recovery: on merge failure, always restore `s.basePath` to
|
|
278
|
+
* `s.originalBasePath` and `process.chdir(s.originalBasePath)`.
|
|
279
|
+
*/
|
|
280
|
+
mergeAndExit(milestoneId: string, ctx: NotifyCtx): void {
|
|
281
|
+
this.validateMilestoneId(milestoneId);
|
|
282
|
+
const mode = this.deps.getIsolationMode();
|
|
283
|
+
debugLog("WorktreeResolver", {
|
|
284
|
+
action: "mergeAndExit",
|
|
285
|
+
milestoneId,
|
|
286
|
+
mode,
|
|
287
|
+
basePath: this.s.basePath,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (mode === "none") {
|
|
291
|
+
debugLog("WorktreeResolver", {
|
|
292
|
+
action: "mergeAndExit",
|
|
293
|
+
milestoneId,
|
|
294
|
+
skipped: true,
|
|
295
|
+
reason: "mode-none",
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (
|
|
301
|
+
mode === "worktree" ||
|
|
302
|
+
(this.deps.isInAutoWorktree(this.s.basePath) && this.s.originalBasePath)
|
|
303
|
+
) {
|
|
304
|
+
this._mergeWorktreeMode(milestoneId, ctx);
|
|
305
|
+
} else if (mode === "branch") {
|
|
306
|
+
this._mergeBranchMode(milestoneId, ctx);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Worktree-mode merge: read roadmap, merge, teardown, reset paths. */
|
|
311
|
+
private _mergeWorktreeMode(milestoneId: string, ctx: NotifyCtx): void {
|
|
312
|
+
const originalBase = this.s.originalBasePath;
|
|
313
|
+
if (!originalBase) {
|
|
314
|
+
debugLog("WorktreeResolver", {
|
|
315
|
+
action: "mergeAndExit",
|
|
316
|
+
milestoneId,
|
|
317
|
+
mode: "worktree",
|
|
318
|
+
skipped: true,
|
|
319
|
+
reason: "missing-original-base",
|
|
320
|
+
});
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const { synced } = this.deps.syncWorktreeStateBack(
|
|
326
|
+
originalBase,
|
|
327
|
+
this.s.basePath,
|
|
328
|
+
milestoneId,
|
|
329
|
+
);
|
|
330
|
+
if (synced.length > 0) {
|
|
331
|
+
debugLog("WorktreeResolver", {
|
|
332
|
+
action: "mergeAndExit",
|
|
333
|
+
milestoneId,
|
|
334
|
+
phase: "reverse-sync",
|
|
335
|
+
synced: synced.length,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const roadmapPath = this.deps.resolveMilestoneFile(
|
|
340
|
+
originalBase,
|
|
341
|
+
milestoneId,
|
|
342
|
+
"ROADMAP",
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
if (roadmapPath) {
|
|
346
|
+
const roadmapContent = this.deps.readFileSync(roadmapPath, "utf-8");
|
|
347
|
+
const mergeResult = this.deps.mergeMilestoneToMain(
|
|
348
|
+
originalBase,
|
|
349
|
+
milestoneId,
|
|
350
|
+
roadmapContent,
|
|
351
|
+
);
|
|
352
|
+
ctx.notify(
|
|
353
|
+
`Milestone ${milestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
354
|
+
"info",
|
|
355
|
+
);
|
|
356
|
+
} else {
|
|
357
|
+
// No roadmap — fall back to bare teardown
|
|
358
|
+
this.deps.teardownAutoWorktree(originalBase, milestoneId);
|
|
359
|
+
ctx.notify(
|
|
360
|
+
`Exited worktree for ${milestoneId} (no roadmap for merge).`,
|
|
361
|
+
"info",
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
} catch (err) {
|
|
365
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
366
|
+
debugLog("WorktreeResolver", {
|
|
367
|
+
action: "mergeAndExit",
|
|
368
|
+
milestoneId,
|
|
369
|
+
result: "error",
|
|
370
|
+
error: msg,
|
|
371
|
+
fallback: "chdir-to-project-root",
|
|
372
|
+
});
|
|
373
|
+
ctx.notify(`Milestone merge failed: ${msg}`, "warning");
|
|
374
|
+
|
|
375
|
+
// Error recovery: always restore to project root
|
|
376
|
+
if (originalBase) {
|
|
377
|
+
try {
|
|
378
|
+
process.chdir(originalBase);
|
|
379
|
+
} catch {
|
|
380
|
+
/* best-effort */
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Always restore basePath and rebuild — whether merge succeeded or failed
|
|
386
|
+
this.restoreToProjectRoot();
|
|
387
|
+
debugLog("WorktreeResolver", {
|
|
388
|
+
action: "mergeAndExit",
|
|
389
|
+
milestoneId,
|
|
390
|
+
result: "done",
|
|
391
|
+
basePath: this.s.basePath,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Branch-mode merge: check current branch, merge if on milestone branch. */
|
|
396
|
+
private _mergeBranchMode(milestoneId: string, ctx: NotifyCtx): void {
|
|
397
|
+
try {
|
|
398
|
+
const currentBranch = this.deps.getCurrentBranch(this.s.basePath);
|
|
399
|
+
const milestoneBranch = this.deps.autoWorktreeBranch(milestoneId);
|
|
400
|
+
|
|
401
|
+
if (currentBranch !== milestoneBranch) {
|
|
402
|
+
debugLog("WorktreeResolver", {
|
|
403
|
+
action: "mergeAndExit",
|
|
404
|
+
milestoneId,
|
|
405
|
+
mode: "branch",
|
|
406
|
+
skipped: true,
|
|
407
|
+
reason: "not-on-milestone-branch",
|
|
408
|
+
currentBranch,
|
|
409
|
+
milestoneBranch,
|
|
410
|
+
});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const roadmapPath = this.deps.resolveMilestoneFile(
|
|
415
|
+
this.s.basePath,
|
|
416
|
+
milestoneId,
|
|
417
|
+
"ROADMAP",
|
|
418
|
+
);
|
|
419
|
+
if (!roadmapPath) {
|
|
420
|
+
debugLog("WorktreeResolver", {
|
|
421
|
+
action: "mergeAndExit",
|
|
422
|
+
milestoneId,
|
|
423
|
+
mode: "branch",
|
|
424
|
+
skipped: true,
|
|
425
|
+
reason: "no-roadmap",
|
|
426
|
+
});
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const roadmapContent = this.deps.readFileSync(roadmapPath, "utf-8");
|
|
431
|
+
const mergeResult = this.deps.mergeMilestoneToMain(
|
|
432
|
+
this.s.basePath,
|
|
433
|
+
milestoneId,
|
|
434
|
+
roadmapContent,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// Rebuild GitService after merge (branch HEAD changed)
|
|
438
|
+
this.rebuildGitService();
|
|
439
|
+
|
|
440
|
+
ctx.notify(
|
|
441
|
+
`Milestone ${milestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
442
|
+
"info",
|
|
443
|
+
);
|
|
444
|
+
debugLog("WorktreeResolver", {
|
|
445
|
+
action: "mergeAndExit",
|
|
446
|
+
milestoneId,
|
|
447
|
+
mode: "branch",
|
|
448
|
+
result: "success",
|
|
449
|
+
});
|
|
450
|
+
} catch (err) {
|
|
451
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
452
|
+
debugLog("WorktreeResolver", {
|
|
453
|
+
action: "mergeAndExit",
|
|
454
|
+
milestoneId,
|
|
455
|
+
mode: "branch",
|
|
456
|
+
result: "error",
|
|
457
|
+
error: msg,
|
|
458
|
+
});
|
|
459
|
+
ctx.notify(`Milestone merge failed (branch mode): ${msg}`, "warning");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ── Merge and Enter Next ───────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Milestone transition: merge the current milestone, then enter the next one.
|
|
467
|
+
*
|
|
468
|
+
* This is the pattern used when the loop detects that the active milestone
|
|
469
|
+
* has changed (e.g., current completed, next one is now active). The caller
|
|
470
|
+
* is responsible for re-deriving state between the merge and the enter.
|
|
471
|
+
*/
|
|
472
|
+
mergeAndEnterNext(
|
|
473
|
+
currentMilestoneId: string,
|
|
474
|
+
nextMilestoneId: string,
|
|
475
|
+
ctx: NotifyCtx,
|
|
476
|
+
): void {
|
|
477
|
+
debugLog("WorktreeResolver", {
|
|
478
|
+
action: "mergeAndEnterNext",
|
|
479
|
+
currentMilestoneId,
|
|
480
|
+
nextMilestoneId,
|
|
481
|
+
});
|
|
482
|
+
this.mergeAndExit(currentMilestoneId, ctx);
|
|
483
|
+
this.enterMilestone(nextMilestoneId, ctx);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* SLICE_BRANCH_RE) remain for backwards compatibility with legacy branches.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync,
|
|
15
|
+
import { existsSync, readFileSync, utimesSync } from "node:fs";
|
|
16
16
|
import { join, resolve, sep } from "node:path";
|
|
17
17
|
|
|
18
18
|
import { GitServiceImpl, writeIntegrationBranch, type TaskCommitContext } from "./git-service.js";
|
|
@@ -56,13 +56,13 @@ export function setActiveMilestoneId(basePath: string, milestoneId: string | nul
|
|
|
56
56
|
* record when the user starts from a different branch (#300). Always a no-op
|
|
57
57
|
* if on a GSD slice branch.
|
|
58
58
|
*/
|
|
59
|
-
export function captureIntegrationBranch(basePath: string, milestoneId: string): void {
|
|
59
|
+
export function captureIntegrationBranch(basePath: string, milestoneId: string, options?: { commitDocs?: boolean }): void {
|
|
60
60
|
// In a worktree, the base branch is implicit (worktree/<name>).
|
|
61
61
|
// Writing it to META.json would leave stale metadata after merge back to main.
|
|
62
62
|
if (detectWorktreeName(basePath)) return;
|
|
63
63
|
const svc = getService(basePath);
|
|
64
64
|
const current = svc.getCurrentBranch();
|
|
65
|
-
writeIntegrationBranch(basePath, milestoneId, current);
|
|
65
|
+
writeIntegrationBranch(basePath, milestoneId, current, options);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// ─── Pure Utility Functions (unchanged) ────────────────────────────────────
|
|
@@ -72,25 +72,6 @@ export function captureIntegrationBranch(basePath: string, milestoneId: string):
|
|
|
72
72
|
* Returns null if not inside a GSD worktree (.gsd/worktrees/<name>/).
|
|
73
73
|
*/
|
|
74
74
|
export function detectWorktreeName(basePath: string): string | null {
|
|
75
|
-
// Primary: use git metadata — .git file with gitdir: pointer
|
|
76
|
-
const gitPath = join(basePath, ".git");
|
|
77
|
-
try {
|
|
78
|
-
const stat = lstatSync(gitPath);
|
|
79
|
-
if (stat.isFile()) {
|
|
80
|
-
const content = readFileSync(gitPath, "utf-8").trim();
|
|
81
|
-
if (content.startsWith("gitdir:")) {
|
|
82
|
-
const gitdir = content.slice(7).trim();
|
|
83
|
-
// Git worktree gitdir format: <repo>/.git/worktrees/<name>
|
|
84
|
-
const parts = gitdir.replace(/\\/g, "/").split("/");
|
|
85
|
-
const wtIdx = parts.lastIndexOf("worktrees");
|
|
86
|
-
if (wtIdx !== -1 && wtIdx < parts.length - 1) {
|
|
87
|
-
return parts[wtIdx + 1] || null;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
} catch { /* fall through */ }
|
|
92
|
-
|
|
93
|
-
// Fallback: path-based detection for legacy setups
|
|
94
75
|
const normalizedPath = basePath.replaceAll("\\", "/");
|
|
95
76
|
const marker = "/.gsd/worktrees/";
|
|
96
77
|
const idx = normalizedPath.indexOf(marker);
|
|
@@ -109,32 +90,14 @@ export function detectWorktreeName(basePath: string): string | null {
|
|
|
109
90
|
* operate against the real project root, not a worktree subdirectory.
|
|
110
91
|
*/
|
|
111
92
|
export function resolveProjectRoot(basePath: string): string {
|
|
112
|
-
// Primary: use git metadata to resolve the main worktree root
|
|
113
|
-
const gitPath = join(basePath, ".git");
|
|
114
|
-
try {
|
|
115
|
-
const stat = lstatSync(gitPath);
|
|
116
|
-
if (stat.isFile()) {
|
|
117
|
-
const content = readFileSync(gitPath, "utf-8").trim();
|
|
118
|
-
if (content.startsWith("gitdir:")) {
|
|
119
|
-
const gitdir = resolve(basePath, content.slice(7).trim());
|
|
120
|
-
// Git worktree gitdir: <repo>/.git/worktrees/<name>
|
|
121
|
-
// Walk up to <repo>
|
|
122
|
-
const parts = gitdir.replace(/\\/g, "/").split("/");
|
|
123
|
-
const wtIdx = parts.lastIndexOf("worktrees");
|
|
124
|
-
if (wtIdx >= 2 && parts[wtIdx - 1] === ".git") {
|
|
125
|
-
return parts.slice(0, wtIdx - 1).join("/");
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
} catch { /* fall through */ }
|
|
130
|
-
|
|
131
|
-
// Fallback: legacy path-based detection
|
|
132
93
|
const normalizedPath = basePath.replaceAll("\\", "/");
|
|
133
94
|
const marker = "/.gsd/worktrees/";
|
|
134
95
|
const idx = normalizedPath.indexOf(marker);
|
|
135
96
|
if (idx === -1) return basePath;
|
|
136
|
-
|
|
137
|
-
|
|
97
|
+
// Return the original path up to the .gsd/ marker (un-normalized)
|
|
98
|
+
// Account for potential OS-specific separators
|
|
99
|
+
const sep = basePath.includes("\\") ? "\\" : "/";
|
|
100
|
+
const markerOs = `${sep}.gsd${sep}worktrees${sep}`;
|
|
138
101
|
const idxOs = basePath.indexOf(markerOs);
|
|
139
102
|
if (idxOs !== -1) return basePath.slice(0, idxOs);
|
|
140
103
|
return basePath.slice(0, idx);
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Idempotency checks for auto-mode unit dispatch.
|
|
3
|
-
*
|
|
4
|
-
* Handles completed-key membership, artifact cross-validation,
|
|
5
|
-
* consecutive skip counting, phantom skip loop detection, key eviction,
|
|
6
|
-
* and fallback persistence.
|
|
7
|
-
*
|
|
8
|
-
* Extracted from dispatchNextUnit() in auto.ts. Pure decision logic
|
|
9
|
-
* with set mutations — does NOT call dispatchNextUnit or stopAuto.
|
|
10
|
-
*/
|
|
11
|
-
import { invalidateAllCaches } from "./cache.js";
|
|
12
|
-
import { verifyExpectedArtifact, persistCompletedKey, removePersistedKey, } from "./auto-recovery.js";
|
|
13
|
-
import { resolveMilestoneFile } from "./paths.js";
|
|
14
|
-
import { MAX_CONSECUTIVE_SKIPS, MAX_LIFETIME_DISPATCHES } from "./auto/session.js";
|
|
15
|
-
import { parseUnitId } from "./unit-id.js";
|
|
16
|
-
/**
|
|
17
|
-
* Check whether a unit should be skipped (already completed), rerun
|
|
18
|
-
* (stale completion record), or dispatched normally.
|
|
19
|
-
*
|
|
20
|
-
* Mutates s.completedKeySet, s.unitConsecutiveSkips, s.unitLifetimeDispatches,
|
|
21
|
-
* and s.recentlyEvictedKeys as needed.
|
|
22
|
-
*/
|
|
23
|
-
export function checkIdempotency(ictx) {
|
|
24
|
-
const { s, unitType, unitId, basePath, notify } = ictx;
|
|
25
|
-
const idempotencyKey = `${unitType}/${unitId}`;
|
|
26
|
-
// ── Primary path: key exists in completed set ──
|
|
27
|
-
if (s.completedKeySet.has(idempotencyKey)) {
|
|
28
|
-
const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath);
|
|
29
|
-
if (artifactExists) {
|
|
30
|
-
// Guard against infinite skip loops
|
|
31
|
-
const skipCount = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
|
|
32
|
-
s.unitConsecutiveSkips.set(idempotencyKey, skipCount);
|
|
33
|
-
if (skipCount > MAX_CONSECUTIVE_SKIPS) {
|
|
34
|
-
// Cross-check: verify the unit's milestone is still active (#790)
|
|
35
|
-
const skippedMid = parseUnitId(unitId).milestone;
|
|
36
|
-
const skippedMilestoneComplete = skippedMid
|
|
37
|
-
? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY")
|
|
38
|
-
: false;
|
|
39
|
-
if (skippedMilestoneComplete) {
|
|
40
|
-
s.unitConsecutiveSkips.delete(idempotencyKey);
|
|
41
|
-
invalidateAllCaches();
|
|
42
|
-
notify(`Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid}. Re-dispatching from fresh state.`, "info");
|
|
43
|
-
return { action: "skip", reason: "phantom-loop-cleared" };
|
|
44
|
-
}
|
|
45
|
-
s.unitConsecutiveSkips.delete(idempotencyKey);
|
|
46
|
-
s.completedKeySet.delete(idempotencyKey);
|
|
47
|
-
s.recentlyEvictedKeys.add(idempotencyKey);
|
|
48
|
-
removePersistedKey(basePath, idempotencyKey);
|
|
49
|
-
invalidateAllCaches();
|
|
50
|
-
notify(`Skip loop detected: ${unitType} ${unitId} skipped ${skipCount} times without advancing. Evicting completion record and forcing reconciliation.`, "warning");
|
|
51
|
-
return { action: "skip", reason: "evicted" };
|
|
52
|
-
}
|
|
53
|
-
// Count toward lifetime cap
|
|
54
|
-
const lifeSkip = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
|
|
55
|
-
s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip);
|
|
56
|
-
if (lifeSkip > MAX_LIFETIME_DISPATCHES) {
|
|
57
|
-
return { action: "stop", reason: `Hard loop: ${unitType} ${unitId} (skip cycle)` };
|
|
58
|
-
}
|
|
59
|
-
notify(`Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`, "info");
|
|
60
|
-
return { action: "skip", reason: "completed" };
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
// Stale completion record — artifact missing. Remove and re-run.
|
|
64
|
-
s.completedKeySet.delete(idempotencyKey);
|
|
65
|
-
removePersistedKey(basePath, idempotencyKey);
|
|
66
|
-
notify(`Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`, "warning");
|
|
67
|
-
return { action: "rerun", reason: "stale-key" };
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
// ── Fallback: key missing but artifact exists ──
|
|
71
|
-
if (verifyExpectedArtifact(unitType, unitId, basePath) && !s.recentlyEvictedKeys.has(idempotencyKey)) {
|
|
72
|
-
persistCompletedKey(basePath, idempotencyKey);
|
|
73
|
-
s.completedKeySet.add(idempotencyKey);
|
|
74
|
-
invalidateAllCaches();
|
|
75
|
-
// Same consecutive-skip guard as the primary path
|
|
76
|
-
const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
|
|
77
|
-
s.unitConsecutiveSkips.set(idempotencyKey, skipCount2);
|
|
78
|
-
if (skipCount2 > MAX_CONSECUTIVE_SKIPS) {
|
|
79
|
-
const skippedMid2 = parseUnitId(unitId).milestone;
|
|
80
|
-
const skippedMilestoneComplete2 = skippedMid2
|
|
81
|
-
? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY")
|
|
82
|
-
: false;
|
|
83
|
-
if (skippedMilestoneComplete2) {
|
|
84
|
-
s.unitConsecutiveSkips.delete(idempotencyKey);
|
|
85
|
-
invalidateAllCaches();
|
|
86
|
-
notify(`Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid2}. Re-dispatching from fresh state.`, "info");
|
|
87
|
-
return { action: "skip", reason: "phantom-loop-cleared" };
|
|
88
|
-
}
|
|
89
|
-
s.unitConsecutiveSkips.delete(idempotencyKey);
|
|
90
|
-
s.completedKeySet.delete(idempotencyKey);
|
|
91
|
-
removePersistedKey(basePath, idempotencyKey);
|
|
92
|
-
invalidateAllCaches();
|
|
93
|
-
notify(`Skip loop detected: ${unitType} ${unitId} skipped ${skipCount2} times without advancing. Evicting completion record and forcing reconciliation.`, "warning");
|
|
94
|
-
return { action: "skip", reason: "evicted" };
|
|
95
|
-
}
|
|
96
|
-
// Count toward lifetime cap
|
|
97
|
-
const lifeSkip2 = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
|
|
98
|
-
s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip2);
|
|
99
|
-
if (lifeSkip2 > MAX_LIFETIME_DISPATCHES) {
|
|
100
|
-
return { action: "stop", reason: `Hard loop: ${unitType} ${unitId} (skip cycle)` };
|
|
101
|
-
}
|
|
102
|
-
notify(`Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`, "info");
|
|
103
|
-
return { action: "skip", reason: "fallback-persisted" };
|
|
104
|
-
}
|
|
105
|
-
return { action: "proceed" };
|
|
106
|
-
}
|