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.
Files changed (87) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/loop.js +2 -3
  3. package/dist/resources/extensions/gsd/auto/orchestrator.js +2 -2
  4. package/dist/resources/extensions/gsd/auto/phases.js +12 -4
  5. package/dist/resources/extensions/gsd/auto-dispatch.js +34 -4
  6. package/dist/resources/extensions/gsd/auto-recovery.js +1 -0
  7. package/dist/resources/extensions/gsd/auto.js +27 -11
  8. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +35 -4
  9. package/dist/resources/extensions/gsd/crash-recovery.js +4 -1
  10. package/dist/resources/extensions/gsd/db/auto-workers.js +21 -0
  11. package/dist/resources/extensions/gsd/preferences.js +4 -0
  12. package/dist/resources/extensions/gsd/repo-identity.js +39 -22
  13. package/dist/resources/extensions/gsd/session-lock.js +15 -2
  14. package/dist/resources/extensions/gsd/tools/complete-milestone.js +9 -1
  15. package/dist/resources/extensions/gsd/tools/complete-slice.js +50 -2
  16. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +66 -40
  17. package/dist/resources/extensions/gsd/worktree-safety.js +10 -3
  18. package/dist/resources/extensions/shared/next-action-ui.js +13 -5
  19. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  20. package/dist/web/standalone/.next/BUILD_ID +1 -1
  21. package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
  22. package/dist/web/standalone/.next/build-manifest.json +2 -2
  23. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  24. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.html +1 -1
  41. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
  48. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  49. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  50. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  51. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  52. package/package.json +1 -1
  53. package/src/resources/extensions/gsd/auto/contracts.ts +2 -0
  54. package/src/resources/extensions/gsd/auto/loop.ts +2 -2
  55. package/src/resources/extensions/gsd/auto/orchestrator.ts +2 -2
  56. package/src/resources/extensions/gsd/auto/phases.ts +14 -4
  57. package/src/resources/extensions/gsd/auto-dispatch.ts +52 -3
  58. package/src/resources/extensions/gsd/auto-recovery.ts +1 -0
  59. package/src/resources/extensions/gsd/auto.ts +63 -18
  60. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +25 -4
  61. package/src/resources/extensions/gsd/crash-recovery.ts +3 -0
  62. package/src/resources/extensions/gsd/db/auto-workers.ts +25 -0
  63. package/src/resources/extensions/gsd/preferences.ts +4 -0
  64. package/src/resources/extensions/gsd/repo-identity.ts +45 -25
  65. package/src/resources/extensions/gsd/session-lock.ts +15 -2
  66. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +135 -0
  67. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +64 -35
  68. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +17 -15
  69. package/src/resources/extensions/gsd/tests/auto-workers.test.ts +13 -0
  70. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +51 -1
  71. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +55 -0
  72. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +111 -1
  73. package/src/resources/extensions/gsd/tests/integration/state-machine-live-validation.test.ts +15 -0
  74. package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +38 -0
  75. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +28 -1
  76. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +35 -0
  77. package/src/resources/extensions/gsd/tests/session-switch-abort-misclassification.test.ts +38 -0
  78. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +120 -0
  79. package/src/resources/extensions/gsd/tests/worktree-safety.test.ts +44 -0
  80. package/src/resources/extensions/gsd/tools/complete-milestone.ts +10 -0
  81. package/src/resources/extensions/gsd/tools/complete-slice.ts +51 -2
  82. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +31 -17
  83. package/src/resources/extensions/gsd/worktree-safety.ts +12 -4
  84. package/src/resources/extensions/shared/next-action-ui.ts +11 -5
  85. package/src/resources/extensions/shared/tests/next-action-ui-hasui.test.ts +32 -0
  86. /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_buildManifest.js +0 -0
  87. /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
- return content == null || (Array.isArray(content) && content.length === 0);
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
- // The Claude Code adapter can emit this placeholder after a prior turn has
320
- // already completed and the next unit is active. It has no user/provider
321
- // diagnostic value and must not cancel the newly-dispatched unit.
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 computedPath = externalGsdRoot(projectPath);
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 (hasProjectState(computedPath)) {
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
- const markerId = readGsdIdMarker(projectPath);
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
- const base = process.env.GSD_STATE_DIR || gsdHome();
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, repoIdentity(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 externalPath = resolveExternalPathWithRecovery(projectPath);
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 rootStat.isSymbolicLink() ? realpathSync(rootGsd) : rootGsd;
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 externalPath;
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 externalPath;
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 externalPath; // correct symlink, no-op
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 isOrphan = !existingData || (existingData.pid && !isPidAlive(existingData.pid));
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() advances and records active unit", async () => {
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, "advanced");
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.deepEqual(status.activeUnit, { unitType: "execute-task", unitId: "T01" });
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() returns blocked when advance detects a dispatch blocker", async () => {
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, "blocked");
433
- if (result.kind !== "blocked") return;
434
- assert.equal(result.reason, "remediation required");
435
- assert.equal(result.action, "pause");
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 flow via advance", async () => {
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, "advanced");
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, "advanced");
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, "advanced");
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, "advanced");
794
+ assert.equal(restarted.kind, "started");
802
795
 
803
- // Immediately after start(), the next advance is idempotent (one element in
804
- // ring), not stuck-loop, confirming the ring was reset.
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, "blocked");
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, "advanced");
811
+ assert.equal(resumed.kind, "resumed");
821
812
 
822
813
  const next = await orchestrator.advance();
823
- assert.equal(next.kind, "blocked");
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 = {