gsd-pi 3.0.0-dev.2e8b124f7 → 3.0.0-dev.6c9a50fd0
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/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/loop.js +2 -3
- package/dist/resources/extensions/gsd/auto/orchestrator.js +2 -2
- package/dist/resources/extensions/gsd/auto/phases.js +12 -4
- package/dist/resources/extensions/gsd/auto-dispatch.js +34 -4
- package/dist/resources/extensions/gsd/auto-recovery.js +1 -0
- package/dist/resources/extensions/gsd/auto.js +27 -11
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +35 -4
- package/dist/resources/extensions/gsd/crash-recovery.js +4 -1
- package/dist/resources/extensions/gsd/db/auto-workers.js +21 -0
- package/dist/resources/extensions/gsd/preferences.js +4 -0
- package/dist/resources/extensions/gsd/repo-identity.js +39 -22
- package/dist/resources/extensions/gsd/session-lock.js +15 -2
- package/dist/resources/extensions/gsd/tools/complete-milestone.js +9 -1
- package/dist/resources/extensions/gsd/tools/complete-slice.js +50 -2
- package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +66 -40
- package/dist/resources/extensions/gsd/worktree-safety.js +10 -3
- package/dist/resources/extensions/shared/next-action-ui.js +13 -5
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto/contracts.ts +2 -0
- package/src/resources/extensions/gsd/auto/loop.ts +2 -2
- package/src/resources/extensions/gsd/auto/orchestrator.ts +2 -2
- package/src/resources/extensions/gsd/auto/phases.ts +14 -4
- package/src/resources/extensions/gsd/auto-dispatch.ts +52 -3
- package/src/resources/extensions/gsd/auto-recovery.ts +1 -0
- package/src/resources/extensions/gsd/auto.ts +63 -18
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +25 -4
- package/src/resources/extensions/gsd/crash-recovery.ts +3 -0
- package/src/resources/extensions/gsd/db/auto-workers.ts +25 -0
- package/src/resources/extensions/gsd/preferences.ts +4 -0
- package/src/resources/extensions/gsd/repo-identity.ts +45 -25
- package/src/resources/extensions/gsd/session-lock.ts +15 -2
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +135 -0
- package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +64 -35
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +17 -15
- package/src/resources/extensions/gsd/tests/auto-workers.test.ts +13 -0
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +51 -1
- package/src/resources/extensions/gsd/tests/complete-slice.test.ts +55 -0
- package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +111 -1
- package/src/resources/extensions/gsd/tests/integration/state-machine-live-validation.test.ts +15 -0
- package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +28 -1
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +35 -0
- package/src/resources/extensions/gsd/tests/session-switch-abort-misclassification.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/worktree-safety.test.ts +44 -0
- package/src/resources/extensions/gsd/tools/complete-milestone.ts +10 -0
- package/src/resources/extensions/gsd/tools/complete-slice.ts +51 -2
- package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +31 -17
- package/src/resources/extensions/gsd/worktree-safety.ts +12 -4
- package/src/resources/extensions/shared/next-action-ui.ts +11 -5
- package/src/resources/extensions/shared/tests/next-action-ui-hasui.test.ts +32 -0
- /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_ssgManifest.js +0 -0
|
@@ -51,7 +51,15 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export function _hasEmptyAgentEndContent(content: unknown): boolean {
|
|
54
|
-
|
|
54
|
+
if (content == null) return true;
|
|
55
|
+
if (!Array.isArray(content)) return false;
|
|
56
|
+
if (content.length === 0) return true;
|
|
57
|
+
return content.every((block) => {
|
|
58
|
+
if (!block || typeof block !== "object") return true;
|
|
59
|
+
const typedBlock = block as { type?: unknown; text?: unknown };
|
|
60
|
+
if (typedBlock.type !== "text") return false;
|
|
61
|
+
return typeof typedBlock.text !== "string" || typedBlock.text.trim() === "";
|
|
62
|
+
});
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
/**
|
|
@@ -316,9 +324,22 @@ export async function handleAgentEnd(
|
|
|
316
324
|
}
|
|
317
325
|
|
|
318
326
|
if (isBareClaudeCodeStreamAbortPlaceholder(lastMsg)) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
327
|
+
if (isSessionSwitchAbortGraceActive()) {
|
|
328
|
+
// Old turn leaking through after a session switch — drop it.
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Mid-unit stream abort with no diagnostic. Treat as non-fatal so the loop
|
|
333
|
+
// can continue, but surface it to the user and resolve the in-flight unit.
|
|
334
|
+
ctx.ui.notify("Claude Code stream aborted mid-unit (no diagnostic). Continuing.", "warning");
|
|
335
|
+
try {
|
|
336
|
+
resetRetryState(retryState);
|
|
337
|
+
resolveAgentEnd(event);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
340
|
+
ctx.ui.notify(`Auto-mode error after stream-abort placeholder: ${message}. Stopping auto-mode.`, "error");
|
|
341
|
+
try { await pauseAuto(ctx, pi); } catch (e) { logWarning("bootstrap", `pauseAuto failed after stream-abort placeholder: ${(e as Error).message}`); }
|
|
342
|
+
}
|
|
322
343
|
return;
|
|
323
344
|
}
|
|
324
345
|
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
getAllAutoWorkers,
|
|
33
33
|
markWorkerCrashed,
|
|
34
34
|
markWorkerStopping,
|
|
35
|
+
markWorkerStoppingByPid,
|
|
35
36
|
type AutoWorkerRow,
|
|
36
37
|
} from "./db/auto-workers.js";
|
|
37
38
|
import { forceReleaseLeasesForWorker } from "./db/milestone-leases.js";
|
|
@@ -234,6 +235,8 @@ export function clearLock(basePath: string): void {
|
|
|
234
235
|
deleteRuntimeKv("worker", staleWorker.worker_id, SESSION_FILE_KV_KEY);
|
|
235
236
|
return;
|
|
236
237
|
}
|
|
238
|
+
const lock = readLegacyLock(basePath);
|
|
239
|
+
if (lock?.pid) markWorkerStoppingByPid(projectRoot, lock.pid);
|
|
237
240
|
const worker = findActiveWorkerForCurrentProcess(projectRoot);
|
|
238
241
|
if (worker) deleteRuntimeKv("worker", worker.worker_id, SESSION_FILE_KV_KEY);
|
|
239
242
|
|
|
@@ -157,6 +157,31 @@ export function markWorkerStopping(workerId: string): void {
|
|
|
157
157
|
});
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Mark the active worker row for a specific PID/project root as stopping.
|
|
162
|
+
* Used when we detect a dead PID from lock metadata before heartbeat expiry.
|
|
163
|
+
*/
|
|
164
|
+
export function markWorkerStoppingByPid(
|
|
165
|
+
projectRootRealpath: string,
|
|
166
|
+
pid: number,
|
|
167
|
+
): void {
|
|
168
|
+
if (!isDbAvailable()) return;
|
|
169
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
170
|
+
const db = _getAdapter()!;
|
|
171
|
+
transaction(() => {
|
|
172
|
+
db.prepare(
|
|
173
|
+
`UPDATE workers
|
|
174
|
+
SET status = 'stopping'
|
|
175
|
+
WHERE pid = :pid
|
|
176
|
+
AND project_root_realpath = :project_root
|
|
177
|
+
AND status = 'active'`,
|
|
178
|
+
).run({
|
|
179
|
+
":pid": pid,
|
|
180
|
+
":project_root": projectRootRealpath,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
160
185
|
/**
|
|
161
186
|
* Return all workers whose status is 'active' AND whose heartbeat is within
|
|
162
187
|
* the TTL window. Workers older than the TTL are NOT auto-marked crashed
|
|
@@ -378,6 +378,10 @@ export function applyModeDefaults(mode: WorkflowMode, prefs: GSDPreferences): GS
|
|
|
378
378
|
|
|
379
379
|
function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPreferences {
|
|
380
380
|
return {
|
|
381
|
+
// Preserve validated preference keys that do not need custom merge logic.
|
|
382
|
+
// The explicit fields below still own defaults, arrays, and deep merges.
|
|
383
|
+
...base,
|
|
384
|
+
...override,
|
|
381
385
|
version: override.version ?? base.version,
|
|
382
386
|
mode: override.mode ?? base.mode,
|
|
383
387
|
always_use_skills: mergeStringLists(base.always_use_skills, override.always_use_skills),
|
|
@@ -427,22 +427,38 @@ function hasProjectState(externalPath: string): boolean {
|
|
|
427
427
|
*
|
|
428
428
|
* Returns the resolved external path (may differ from the computed identity).
|
|
429
429
|
*/
|
|
430
|
-
function resolveExternalPathWithRecovery(projectPath: string): string {
|
|
431
|
-
const
|
|
430
|
+
function resolveExternalPathWithRecovery(projectPath: string): { path: string; identity: string } {
|
|
431
|
+
const base = process.env.GSD_STATE_DIR || gsdHome();
|
|
432
432
|
const computedId = repoIdentity(projectPath);
|
|
433
|
+
const computedPath = join(base, "projects", computedId);
|
|
434
|
+
const computedHasState = hasProjectState(computedPath);
|
|
435
|
+
const markerId = readGsdIdMarker(projectPath);
|
|
436
|
+
const markerPath = markerId ? join(base, "projects", markerId) : null;
|
|
437
|
+
const markerHasState = markerPath ? hasProjectState(markerPath) : false;
|
|
438
|
+
|
|
439
|
+
// Split-brain guard: when marker and computed identities disagree and both
|
|
440
|
+
// directories contain state, prefer the marker-backed directory. This keeps
|
|
441
|
+
// all writers anchored to a single canonical project identity even if a
|
|
442
|
+
// transient identity computation produced a stale computed hash.
|
|
443
|
+
if (
|
|
444
|
+
markerId
|
|
445
|
+
&& markerPath
|
|
446
|
+
&& markerId !== computedId
|
|
447
|
+
&& markerHasState
|
|
448
|
+
&& computedHasState
|
|
449
|
+
) {
|
|
450
|
+
return { path: markerPath, identity: markerId };
|
|
451
|
+
}
|
|
433
452
|
|
|
434
453
|
// Check if computed path already has state — fast path, no recovery needed.
|
|
435
|
-
if (
|
|
436
|
-
return computedPath;
|
|
454
|
+
if (computedHasState) {
|
|
455
|
+
return { path: computedPath, identity: computedId };
|
|
437
456
|
}
|
|
438
457
|
|
|
439
458
|
// Check for .gsd-id marker from a previous location.
|
|
440
|
-
|
|
441
|
-
if (markerId && markerId !== computedId) {
|
|
459
|
+
if (markerId && markerPath && markerId !== computedId) {
|
|
442
460
|
// The marker points to a different identity — the repo was likely moved.
|
|
443
|
-
|
|
444
|
-
const markerPath = join(base, "projects", markerId);
|
|
445
|
-
if (hasProjectState(markerPath)) {
|
|
461
|
+
if (markerHasState) {
|
|
446
462
|
// Recover: use the old state directory and update the marker to the new identity.
|
|
447
463
|
// Move the state from the old hash dir to the new one so future lookups work
|
|
448
464
|
// without the marker.
|
|
@@ -465,12 +481,12 @@ function resolveExternalPathWithRecovery(projectPath: string): string {
|
|
|
465
481
|
try { rmSync(markerPath, { recursive: true, force: true }); } catch { /* non-fatal */ }
|
|
466
482
|
} catch {
|
|
467
483
|
// If migration fails, just point at the old directory.
|
|
468
|
-
return markerPath;
|
|
484
|
+
return { path: markerPath, identity: markerId };
|
|
469
485
|
}
|
|
470
486
|
}
|
|
471
487
|
}
|
|
472
488
|
|
|
473
|
-
return computedPath;
|
|
489
|
+
return { path: computedPath, identity: computedId };
|
|
474
490
|
}
|
|
475
491
|
|
|
476
492
|
// ─── Symlink Management ─────────────────────────────────────────────────────
|
|
@@ -489,20 +505,21 @@ function resolveExternalPathWithRecovery(projectPath: string): string {
|
|
|
489
505
|
* Returns the resolved external path.
|
|
490
506
|
*/
|
|
491
507
|
export function ensureGsdSymlink(projectPath: string): string {
|
|
492
|
-
const result = ensureGsdSymlinkCore(projectPath);
|
|
508
|
+
const { path: result, identity } = ensureGsdSymlinkCore(projectPath);
|
|
493
509
|
|
|
494
510
|
// Write .gsd-id marker so future relocations can recover this state (#2750).
|
|
495
511
|
// Only write for the project root (not subdirectories or worktrees that
|
|
496
512
|
// delegate to a parent .gsd).
|
|
497
513
|
if (!isInsideWorktree(projectPath)) {
|
|
498
|
-
writeGsdIdMarker(projectPath,
|
|
514
|
+
writeGsdIdMarker(projectPath, identity);
|
|
499
515
|
}
|
|
500
516
|
|
|
501
517
|
return result;
|
|
502
518
|
}
|
|
503
519
|
|
|
504
|
-
function ensureGsdSymlinkCore(projectPath: string): string {
|
|
505
|
-
const
|
|
520
|
+
function ensureGsdSymlinkCore(projectPath: string): { path: string; identity: string } {
|
|
521
|
+
const resolved = resolveExternalPathWithRecovery(projectPath);
|
|
522
|
+
const externalPath = resolved.path;
|
|
506
523
|
const localGsd = join(projectPath, ".gsd");
|
|
507
524
|
const inWorktree = isInsideWorktree(projectPath);
|
|
508
525
|
|
|
@@ -519,7 +536,7 @@ function ensureGsdSymlinkCore(projectPath: string): string {
|
|
|
519
536
|
const localGsdNormalized = normalizeForGuard(localGsd);
|
|
520
537
|
const gsdHomeNorm = normalizeForGuard(gsdHome());
|
|
521
538
|
if (localGsdNormalized === gsdHomeNorm) {
|
|
522
|
-
return localGsd;
|
|
539
|
+
return { path: localGsd, identity: resolved.identity };
|
|
523
540
|
}
|
|
524
541
|
|
|
525
542
|
// Guard: If projectPath is a plain subdirectory (not a worktree) of a git
|
|
@@ -538,7 +555,10 @@ function ensureGsdSymlinkCore(projectPath: string): string {
|
|
|
538
555
|
try {
|
|
539
556
|
const rootStat = lstatSync(rootGsd);
|
|
540
557
|
if (rootStat.isSymbolicLink() || rootStat.isDirectory()) {
|
|
541
|
-
return
|
|
558
|
+
return {
|
|
559
|
+
path: rootStat.isSymbolicLink() ? realpathSync(rootGsd) : rootGsd,
|
|
560
|
+
identity: resolved.identity,
|
|
561
|
+
};
|
|
542
562
|
}
|
|
543
563
|
} catch {
|
|
544
564
|
// Fall through to normal logic if we can't stat root .gsd
|
|
@@ -560,12 +580,12 @@ function ensureGsdSymlinkCore(projectPath: string): string {
|
|
|
560
580
|
// Write repo metadata once so cleanup commands can identify this directory later.
|
|
561
581
|
writeRepoMeta(externalPath, getRemoteUrl(projectPath), resolveGitRoot(projectPath));
|
|
562
582
|
|
|
563
|
-
const replaceWithSymlink = (): string => {
|
|
583
|
+
const replaceWithSymlink = (): { path: string; identity: string } => {
|
|
564
584
|
rmSync(localGsd, { recursive: true, force: true });
|
|
565
585
|
// Defensive: remove any residual entry (e.g. dangling symlink) before creating.
|
|
566
586
|
try { unlinkSync(localGsd); } catch { /* already gone */ }
|
|
567
587
|
symlinkSync(externalPath, localGsd, "junction");
|
|
568
|
-
return
|
|
588
|
+
return resolved;
|
|
569
589
|
};
|
|
570
590
|
|
|
571
591
|
// Check for dangling symlinks (e.g. after relocation recovery removed the old
|
|
@@ -585,7 +605,7 @@ function ensureGsdSymlinkCore(projectPath: string): string {
|
|
|
585
605
|
// Defensive: remove any residual entry to avoid EEXIST race (#2750).
|
|
586
606
|
try { unlinkSync(localGsd); } catch { /* nothing to remove */ }
|
|
587
607
|
symlinkSync(externalPath, localGsd, "junction");
|
|
588
|
-
return
|
|
608
|
+
return resolved;
|
|
589
609
|
}
|
|
590
610
|
|
|
591
611
|
try {
|
|
@@ -595,7 +615,7 @@ function ensureGsdSymlinkCore(projectPath: string): string {
|
|
|
595
615
|
// Already a symlink — verify it points to the right place
|
|
596
616
|
const target = realpathSync(localGsd);
|
|
597
617
|
if (target === externalPath) {
|
|
598
|
-
return
|
|
618
|
+
return resolved; // correct symlink, no-op
|
|
599
619
|
}
|
|
600
620
|
// In a worktree, mismatched symlinks are always stale. Heal them so
|
|
601
621
|
// the worktree points at the same external state dir as the main repo.
|
|
@@ -620,11 +640,11 @@ function ensureGsdSymlinkCore(projectPath: string): string {
|
|
|
620
640
|
return replaceWithSymlink();
|
|
621
641
|
} catch {
|
|
622
642
|
// Migration failed — preserve old symlink
|
|
623
|
-
return target;
|
|
643
|
+
return { path: target, identity: resolved.identity };
|
|
624
644
|
}
|
|
625
645
|
}
|
|
626
646
|
// Outside worktrees, preserve custom overrides or legacy symlinks.
|
|
627
|
-
return target;
|
|
647
|
+
return { path: target, identity: resolved.identity };
|
|
628
648
|
}
|
|
629
649
|
|
|
630
650
|
if (stat.isDirectory()) {
|
|
@@ -632,13 +652,13 @@ function ensureGsdSymlinkCore(projectPath: string): string {
|
|
|
632
652
|
// In worktrees, keep the directory in place and let syncGsdStateToWorktree
|
|
633
653
|
// refresh its contents. Replacing a git-tracked .gsd directory with a
|
|
634
654
|
// symlink makes git think tracked planning files were deleted.
|
|
635
|
-
return localGsd;
|
|
655
|
+
return { path: localGsd, identity: resolved.identity };
|
|
636
656
|
}
|
|
637
657
|
} catch {
|
|
638
658
|
// lstat failed — path exists but we can't stat it
|
|
639
659
|
}
|
|
640
660
|
|
|
641
|
-
return localGsd;
|
|
661
|
+
return { path: localGsd, identity: resolved.identity };
|
|
642
662
|
}
|
|
643
663
|
|
|
644
664
|
// ─── Worktree Detection ─────────────────────────────────────────────────────
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
20
|
import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
|
|
21
21
|
import { join, dirname } from "node:path";
|
|
22
|
-
import { gsdRoot } from "./paths.js";
|
|
22
|
+
import { gsdRoot, normalizeRealPath } from "./paths.js";
|
|
23
23
|
import { atomicWriteSync } from "./atomic-write.js";
|
|
24
|
+
import { markWorkerStoppingByPid } from "./db/auto-workers.js";
|
|
24
25
|
|
|
25
26
|
const _require = createRequire(import.meta.url);
|
|
26
27
|
|
|
@@ -281,6 +282,13 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
281
282
|
// Clean up numbered lock file variants from cloud sync conflicts (#1315)
|
|
282
283
|
cleanupStrayLockFiles(basePath);
|
|
283
284
|
|
|
285
|
+
// If lock metadata points to a dead PID, mark that worker row stopping so
|
|
286
|
+
// crash diagnostics do not keep surfacing it as active.
|
|
287
|
+
const existingPreflight = readExistingLockData(lp);
|
|
288
|
+
if (existingPreflight?.pid && !isPidAlive(existingPreflight.pid)) {
|
|
289
|
+
markWorkerStoppingByPid(normalizeRealPath(basePath), existingPreflight.pid);
|
|
290
|
+
}
|
|
291
|
+
|
|
284
292
|
// Write our lock data first (the content is informational; the OS lock is the real guard)
|
|
285
293
|
const lockData: SessionLockData = {
|
|
286
294
|
pid: process.pid,
|
|
@@ -308,7 +316,9 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
308
316
|
const lockDir = lockTarget + ".lock";
|
|
309
317
|
if (existsSync(lockDir)) {
|
|
310
318
|
const existingData = readExistingLockData(lp);
|
|
311
|
-
const
|
|
319
|
+
const deadPid = existingData?.pid && !isPidAlive(existingData.pid) ? existingData.pid : null;
|
|
320
|
+
if (deadPid) markWorkerStoppingByPid(normalizeRealPath(basePath), deadPid);
|
|
321
|
+
const isOrphan = !existingData || !!deadPid;
|
|
312
322
|
if (isOrphan) {
|
|
313
323
|
try { rmSync(lockDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
314
324
|
try { if (existsSync(lp)) unlinkSync(lp); } catch { /* best-effort */ }
|
|
@@ -344,6 +354,9 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
344
354
|
// Check: if auto.lock is gone and no process is alive, the lock dir is stale.
|
|
345
355
|
const existingData = readExistingLockData(lp);
|
|
346
356
|
const existingPid = existingData?.pid;
|
|
357
|
+
if (existingPid && !isPidAlive(existingPid)) {
|
|
358
|
+
markWorkerStoppingByPid(normalizeRealPath(basePath), existingPid);
|
|
359
|
+
}
|
|
347
360
|
|
|
348
361
|
// If no lock file or no alive process, try to clean up and re-acquire (#1245)
|
|
349
362
|
if (!existingData || (existingPid && !isPidAlive(existingPid))) {
|
|
@@ -3652,6 +3652,81 @@ test("runDispatch falls back to main when dispatch guard cannot read main branch
|
|
|
3652
3652
|
assert.equal(result.action, "next");
|
|
3653
3653
|
});
|
|
3654
3654
|
|
|
3655
|
+
test("runDispatch clamps oversized stuck window before detection (#6216)", async () => {
|
|
3656
|
+
_resetPendingResolve();
|
|
3657
|
+
|
|
3658
|
+
const ctx = makeMockCtx();
|
|
3659
|
+
const pi = makeMockPi();
|
|
3660
|
+
const s = makeLoopSession();
|
|
3661
|
+
const deps = makeMockDeps({
|
|
3662
|
+
resolveDispatch: async () => {
|
|
3663
|
+
deps.callLog.push("resolveDispatch");
|
|
3664
|
+
return {
|
|
3665
|
+
action: "dispatch" as const,
|
|
3666
|
+
unitType: "complete-slice",
|
|
3667
|
+
unitId: "M006/S03",
|
|
3668
|
+
prompt: "close out slice",
|
|
3669
|
+
};
|
|
3670
|
+
},
|
|
3671
|
+
});
|
|
3672
|
+
const loopState = {
|
|
3673
|
+
recentUnits: [
|
|
3674
|
+
{ key: "complete-slice/M006/S03" },
|
|
3675
|
+
{ key: "execute-task/M006/S03/T01" },
|
|
3676
|
+
{ key: "complete-slice/M006/S03" },
|
|
3677
|
+
{ key: "execute-task/M006/S03/T01" },
|
|
3678
|
+
{ key: "execute-task/M006/S03/T02" },
|
|
3679
|
+
{ key: "execute-task/M006/S03/T03" },
|
|
3680
|
+
{ key: "execute-task/M006/S03/T04" },
|
|
3681
|
+
{ key: "execute-task/M006/S03/T05" },
|
|
3682
|
+
{ key: "execute-task/M006/S03/T06" },
|
|
3683
|
+
{ key: "execute-task/M006/S03/T07" },
|
|
3684
|
+
{ key: "execute-task/M006/S03/T08" },
|
|
3685
|
+
{ key: "execute-task/M006/S03/T09" },
|
|
3686
|
+
{ key: "execute-task/M006/S03/T10" },
|
|
3687
|
+
{ key: "execute-task/M006/S03/T11" },
|
|
3688
|
+
{ key: "execute-task/M006/S03/T12" },
|
|
3689
|
+
{ key: "complete-slice/M006/S03" },
|
|
3690
|
+
{ key: "execute-task/M006/S03/T13" },
|
|
3691
|
+
{ key: "execute-task/M006/S03/T14" },
|
|
3692
|
+
{ key: "execute-task/M006/S03/T15" },
|
|
3693
|
+
{ key: "execute-task/M006/S03/T16" },
|
|
3694
|
+
],
|
|
3695
|
+
stuckRecoveryAttempts: 0,
|
|
3696
|
+
consecutiveFinalizeTimeouts: 0,
|
|
3697
|
+
};
|
|
3698
|
+
|
|
3699
|
+
const result = await runDispatch(
|
|
3700
|
+
{
|
|
3701
|
+
ctx,
|
|
3702
|
+
pi,
|
|
3703
|
+
s,
|
|
3704
|
+
deps,
|
|
3705
|
+
prefs: undefined,
|
|
3706
|
+
iteration: 1,
|
|
3707
|
+
flowId: "test-flow",
|
|
3708
|
+
nextSeq: () => 1,
|
|
3709
|
+
},
|
|
3710
|
+
{
|
|
3711
|
+
state: {
|
|
3712
|
+
phase: "executing",
|
|
3713
|
+
activeMilestone: { id: "M001", title: "Test", status: "active" },
|
|
3714
|
+
activeSlice: { id: "S01", title: "Slice 1" },
|
|
3715
|
+
activeTask: { id: "T01" },
|
|
3716
|
+
registry: [{ id: "M001", status: "active" }],
|
|
3717
|
+
blockers: [],
|
|
3718
|
+
} as any,
|
|
3719
|
+
mid: "M001",
|
|
3720
|
+
midTitle: "Test",
|
|
3721
|
+
},
|
|
3722
|
+
loopState,
|
|
3723
|
+
);
|
|
3724
|
+
|
|
3725
|
+
assert.equal(result.action, "next");
|
|
3726
|
+
assert.equal(loopState.recentUnits.length, 6, "stuck window should be capped to the active detector size");
|
|
3727
|
+
assert.ok(!deps.callLog.includes("stopAuto"), "oversized persisted history should not trigger a false stuck stop");
|
|
3728
|
+
});
|
|
3729
|
+
|
|
3655
3730
|
test("dispatch Worktree Safety stops unknown unit types with missing Tool Contract", async (t) => {
|
|
3656
3731
|
_resetPendingResolve();
|
|
3657
3732
|
|
|
@@ -3722,6 +3797,66 @@ test("dispatch Worktree Safety stops unknown unit types with missing Tool Contra
|
|
|
3722
3797
|
);
|
|
3723
3798
|
});
|
|
3724
3799
|
|
|
3800
|
+
test("dispatch Worktree Safety accepts sidecar-prefixed known unit types", async (t) => {
|
|
3801
|
+
_resetPendingResolve();
|
|
3802
|
+
|
|
3803
|
+
const ctx = makeMockCtx();
|
|
3804
|
+
const pi = makeMockPi();
|
|
3805
|
+
|
|
3806
|
+
const projectRoot = mkdtempSync(join(tmpdir(), "gsd-wt-safety-sidecar-prefix-"));
|
|
3807
|
+
const worktreeRoot = join(projectRoot, ".gsd", "worktrees", "M001");
|
|
3808
|
+
mkdirSync(worktreeRoot, { recursive: true });
|
|
3809
|
+
t.after(() => rmSync(projectRoot, { recursive: true, force: true }));
|
|
3810
|
+
|
|
3811
|
+
const s = makeLoopSession({
|
|
3812
|
+
basePath: worktreeRoot,
|
|
3813
|
+
originalBasePath: projectRoot,
|
|
3814
|
+
canonicalProjectRoot: projectRoot,
|
|
3815
|
+
});
|
|
3816
|
+
const deps = makeMockDeps({
|
|
3817
|
+
getIsolationMode: () => "worktree",
|
|
3818
|
+
resolveDispatch: async () => ({
|
|
3819
|
+
action: "dispatch" as const,
|
|
3820
|
+
unitType: "sidecar/triage-captures",
|
|
3821
|
+
unitId: "M001/S01/triage",
|
|
3822
|
+
prompt: "triage",
|
|
3823
|
+
}),
|
|
3824
|
+
});
|
|
3825
|
+
|
|
3826
|
+
const result = await runDispatch(
|
|
3827
|
+
{
|
|
3828
|
+
ctx,
|
|
3829
|
+
pi,
|
|
3830
|
+
s,
|
|
3831
|
+
deps,
|
|
3832
|
+
prefs: undefined,
|
|
3833
|
+
iteration: 1,
|
|
3834
|
+
flowId: "test-flow",
|
|
3835
|
+
nextSeq: () => 1,
|
|
3836
|
+
},
|
|
3837
|
+
{
|
|
3838
|
+
state: {
|
|
3839
|
+
phase: "executing",
|
|
3840
|
+
activeMilestone: { id: "M001", title: "Test", status: "active" },
|
|
3841
|
+
activeSlice: { id: "S01", title: "Slice 1" },
|
|
3842
|
+
activeTask: { id: "T01" },
|
|
3843
|
+
registry: [{ id: "M001", status: "active" }],
|
|
3844
|
+
blockers: [],
|
|
3845
|
+
} as any,
|
|
3846
|
+
mid: "M001",
|
|
3847
|
+
midTitle: "Test",
|
|
3848
|
+
},
|
|
3849
|
+
{
|
|
3850
|
+
recentUnits: [],
|
|
3851
|
+
stuckRecoveryAttempts: 0,
|
|
3852
|
+
consecutiveFinalizeTimeouts: 0,
|
|
3853
|
+
},
|
|
3854
|
+
);
|
|
3855
|
+
|
|
3856
|
+
assert.equal(result.action, "next");
|
|
3857
|
+
assert.ok(!deps.callLog.includes("stopAuto"), "should not stop for sidecar-prefixed known unit types");
|
|
3858
|
+
});
|
|
3859
|
+
|
|
3725
3860
|
test("pre-dispatch skip resolves before dispatch health and stuck accounting", async () => {
|
|
3726
3861
|
_resetPendingResolve();
|
|
3727
3862
|
|
|
@@ -98,19 +98,18 @@ function makeDeps(overrides: Partial<AutoOrchestratorDeps> = {}): { deps: AutoOr
|
|
|
98
98
|
return { deps: { ...deps, ...overrides }, calls };
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
test("start()
|
|
101
|
+
test("start() enters running phase without dispatching", async () => {
|
|
102
102
|
const { deps, calls } = makeDeps();
|
|
103
103
|
const orchestrator = createAutoOrchestrator(deps);
|
|
104
104
|
|
|
105
105
|
const result = await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
|
|
106
106
|
|
|
107
|
-
assert.equal(result.kind, "
|
|
108
|
-
assert.deepEqual(result.unit, { unitType: "execute-task", unitId: "T01" });
|
|
107
|
+
assert.equal(result.kind, "started");
|
|
109
108
|
const status = orchestrator.getStatus();
|
|
110
109
|
assert.equal(status.phase, "running");
|
|
111
|
-
assert.
|
|
110
|
+
assert.equal(status.activeUnit, undefined);
|
|
112
111
|
assert.ok(calls.includes("journal:start"));
|
|
113
|
-
assert.ok(calls.includes("journal:advance"));
|
|
112
|
+
assert.ok(!calls.includes("journal:advance"));
|
|
114
113
|
});
|
|
115
114
|
|
|
116
115
|
test("advance() returns blocked when health gate denies", async () => {
|
|
@@ -413,26 +412,16 @@ test("advance() surfaces dispatch blocker reason instead of generic no remaining
|
|
|
413
412
|
assert.ok(!calls.includes("journal:advance-stopped"));
|
|
414
413
|
});
|
|
415
414
|
|
|
416
|
-
test("resume()
|
|
417
|
-
const { deps } = makeDeps(
|
|
418
|
-
dispatch: {
|
|
419
|
-
async decideNextUnit() {
|
|
420
|
-
return {
|
|
421
|
-
kind: "blocked",
|
|
422
|
-
reason: "remediation required",
|
|
423
|
-
action: "pause",
|
|
424
|
-
};
|
|
425
|
-
},
|
|
426
|
-
},
|
|
427
|
-
});
|
|
415
|
+
test("resume() enters running phase without dispatching", async () => {
|
|
416
|
+
const { deps, calls } = makeDeps();
|
|
428
417
|
const orchestrator = createAutoOrchestrator(deps);
|
|
429
418
|
|
|
430
419
|
const result = await orchestrator.resume();
|
|
431
420
|
|
|
432
|
-
assert.equal(result.kind, "
|
|
433
|
-
|
|
434
|
-
assert.
|
|
435
|
-
assert.
|
|
421
|
+
assert.equal(result.kind, "resumed");
|
|
422
|
+
assert.equal(orchestrator.getStatus().phase, "running");
|
|
423
|
+
assert.ok(!calls.includes("journal:advance"));
|
|
424
|
+
assert.ok(!calls.includes("dispatch.decide"));
|
|
436
425
|
});
|
|
437
426
|
|
|
438
427
|
test("advance() uses recovery on error", async () => {
|
|
@@ -472,13 +461,13 @@ test("advance() is idempotent for the same active unit", async () => {
|
|
|
472
461
|
assert.equal(prepareCalls, 1);
|
|
473
462
|
});
|
|
474
463
|
|
|
475
|
-
test("resume() re-enters running
|
|
464
|
+
test("resume() re-enters running phase", async () => {
|
|
476
465
|
const { deps } = makeDeps();
|
|
477
466
|
const orchestrator = createAutoOrchestrator(deps);
|
|
478
467
|
|
|
479
468
|
const result = await orchestrator.resume();
|
|
480
469
|
|
|
481
|
-
assert.equal(result.kind, "
|
|
470
|
+
assert.equal(result.kind, "resumed");
|
|
482
471
|
assert.equal(orchestrator.getStatus().phase, "running");
|
|
483
472
|
});
|
|
484
473
|
|
|
@@ -489,10 +478,12 @@ test("resume() clears idempotent lock and allows re-advance", async () => {
|
|
|
489
478
|
const first = await orchestrator.advance();
|
|
490
479
|
const blocked = await orchestrator.advance();
|
|
491
480
|
const resumed = await orchestrator.resume();
|
|
481
|
+
const next = await orchestrator.advance();
|
|
492
482
|
|
|
493
483
|
assert.equal(first.kind, "advanced");
|
|
494
484
|
assert.equal(blocked.kind, "blocked");
|
|
495
|
-
assert.equal(resumed.kind, "
|
|
485
|
+
assert.equal(resumed.kind, "resumed");
|
|
486
|
+
assert.equal(next.kind, "advanced");
|
|
496
487
|
});
|
|
497
488
|
|
|
498
489
|
test("transitionCount increases across lifecycle transitions", async () => {
|
|
@@ -607,9 +598,11 @@ test("start() clears prior idempotent lock", async () => {
|
|
|
607
598
|
await orchestrator.advance();
|
|
608
599
|
const blocked = await orchestrator.advance();
|
|
609
600
|
const restarted = await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
|
|
601
|
+
const next = await orchestrator.advance();
|
|
610
602
|
|
|
611
603
|
assert.equal(blocked.kind, "blocked");
|
|
612
|
-
assert.equal(restarted.kind, "
|
|
604
|
+
assert.equal(restarted.kind, "started");
|
|
605
|
+
assert.equal(next.kind, "advanced");
|
|
613
606
|
});
|
|
614
607
|
|
|
615
608
|
test("error path emits error notification", async () => {
|
|
@@ -798,14 +791,12 @@ test("stuck-loop: start() resets the ring so a fresh saturation cycle is require
|
|
|
798
791
|
}
|
|
799
792
|
|
|
800
793
|
const restarted = await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
|
|
801
|
-
assert.equal(restarted.kind, "
|
|
794
|
+
assert.equal(restarted.kind, "started");
|
|
802
795
|
|
|
803
|
-
// Immediately after start(), the next advance
|
|
804
|
-
//
|
|
796
|
+
// Immediately after start(), the next advance should succeed because start()
|
|
797
|
+
// no longer pre-dispatches and the ring was reset.
|
|
805
798
|
const next = await orchestrator.advance();
|
|
806
|
-
assert.equal(next.kind, "
|
|
807
|
-
assert.equal(next.reason, "idempotent advance: unit already active");
|
|
808
|
-
assert.equal(next.action, "pause");
|
|
799
|
+
assert.equal(next.kind, "advanced");
|
|
809
800
|
});
|
|
810
801
|
|
|
811
802
|
test("stuck-loop: resume() resets the ring", async () => {
|
|
@@ -817,12 +808,10 @@ test("stuck-loop: resume() resets the ring", async () => {
|
|
|
817
808
|
}
|
|
818
809
|
|
|
819
810
|
const resumed = await orchestrator.resume();
|
|
820
|
-
assert.equal(resumed.kind, "
|
|
811
|
+
assert.equal(resumed.kind, "resumed");
|
|
821
812
|
|
|
822
813
|
const next = await orchestrator.advance();
|
|
823
|
-
assert.equal(next.kind, "
|
|
824
|
-
assert.equal(next.reason, "idempotent advance: unit already active");
|
|
825
|
-
assert.equal(next.action, "pause");
|
|
814
|
+
assert.equal(next.kind, "advanced");
|
|
826
815
|
});
|
|
827
816
|
|
|
828
817
|
test("stuck-loop: stop() resets the ring", async () => {
|
|
@@ -1027,6 +1016,46 @@ test("wired DispatchAdapter prefers caller-supplied dispatch inputs over ctx-der
|
|
|
1027
1016
|
}
|
|
1028
1017
|
});
|
|
1029
1018
|
|
|
1019
|
+
test("wired DispatchAdapter forwards constructor session when advance input omits session", async () => {
|
|
1020
|
+
const stateSnapshot = makeState();
|
|
1021
|
+
const captured: DispatchContext[] = [];
|
|
1022
|
+
const captureRule: UnifiedRule = {
|
|
1023
|
+
name: "test-session-fallback",
|
|
1024
|
+
when: "dispatch",
|
|
1025
|
+
evaluation: "first-match",
|
|
1026
|
+
where: async (ctx: DispatchContext) => {
|
|
1027
|
+
captured.push(ctx);
|
|
1028
|
+
return {
|
|
1029
|
+
action: "dispatch" as const,
|
|
1030
|
+
unitType: "execute-task",
|
|
1031
|
+
unitId: "T01",
|
|
1032
|
+
prompt: "session-fallback-fixture",
|
|
1033
|
+
};
|
|
1034
|
+
},
|
|
1035
|
+
then: (r: unknown) => r,
|
|
1036
|
+
};
|
|
1037
|
+
setRegistry(new RuleRegistry([captureRule]));
|
|
1038
|
+
|
|
1039
|
+
try {
|
|
1040
|
+
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as any;
|
|
1041
|
+
const pi = { getActiveTools: () => [] } as any;
|
|
1042
|
+
const session = {
|
|
1043
|
+
basePath: "/tmp/worktree-fixture",
|
|
1044
|
+
originalBasePath: "/tmp/project-fixture",
|
|
1045
|
+
currentMilestoneId: "M001",
|
|
1046
|
+
} as any;
|
|
1047
|
+
const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/project-fixture", session);
|
|
1048
|
+
|
|
1049
|
+
const result = await adapter.decideNextUnit({ stateSnapshot });
|
|
1050
|
+
|
|
1051
|
+
assert.ok(result);
|
|
1052
|
+
assert.equal(captured.length, 1, "expected one captured dispatch context");
|
|
1053
|
+
assert.equal(captured[0].session, session);
|
|
1054
|
+
} finally {
|
|
1055
|
+
resetRegistry();
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1030
1059
|
test("wired DispatchAdapter preserves stop reason as a blocked decision", async () => {
|
|
1031
1060
|
const stateSnapshot = makeState();
|
|
1032
1061
|
const stopRule: UnifiedRule = {
|