gsd-pi 2.80.0-dev.2be7caf18 → 2.80.0-dev.42bd34838

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 (112) hide show
  1. package/README.md +1 -0
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/auto/phases.js +29 -15
  4. package/dist/resources/extensions/gsd/auto/resolve.js +17 -0
  5. package/dist/resources/extensions/gsd/auto/run-unit.js +13 -1
  6. package/dist/resources/extensions/gsd/auto-recovery.js +43 -1
  7. package/dist/resources/extensions/gsd/auto-supervisor.js +8 -1
  8. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +2 -2
  9. package/dist/resources/extensions/gsd/auto.js +61 -2
  10. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +21 -2
  11. package/dist/resources/extensions/gsd/context-budget.js +37 -2
  12. package/dist/resources/extensions/gsd/db/unit-dispatches.js +39 -0
  13. package/dist/resources/extensions/gsd/db-base-schema.js +4 -2
  14. package/dist/resources/extensions/gsd/db-migration-steps.js +6 -0
  15. package/dist/resources/extensions/gsd/git-service.js +36 -4
  16. package/dist/resources/extensions/gsd/gsd-db.js +46 -13
  17. package/dist/resources/extensions/gsd/guided-flow.js +19 -4
  18. package/dist/resources/extensions/gsd/memory-store.js +69 -12
  19. package/dist/resources/extensions/gsd/prompt-loader.js +28 -2
  20. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +14 -13
  21. package/dist/resources/extensions/gsd/tools/memory-tools.js +1 -0
  22. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +1 -1
  23. package/dist/resources/extensions/gsd/unit-runtime.js +11 -0
  24. package/dist/resources/extensions/gsd/worktree-resolver.js +33 -17
  25. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  26. package/dist/web/standalone/.next/BUILD_ID +1 -1
  27. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  28. package/dist/web/standalone/.next/build-manifest.json +2 -2
  29. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  30. package/dist/web/standalone/.next/required-server-files.json +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.html +1 -1
  48. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  55. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  56. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  57. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  58. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  59. package/dist/web/standalone/server.js +1 -1
  60. package/package.json +3 -3
  61. package/packages/mcp-server/src/workflow-tools.test.ts +9 -2
  62. package/packages/native/tsconfig.tsbuildinfo +1 -1
  63. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +32 -0
  64. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
  65. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/core/agent-session.js +5 -0
  67. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  68. package/packages/pi-coding-agent/src/core/agent-session-abort-order.test.ts +36 -0
  69. package/packages/pi-coding-agent/src/core/agent-session.ts +5 -0
  70. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  71. package/src/resources/extensions/gsd/auto/phases.ts +35 -20
  72. package/src/resources/extensions/gsd/auto/resolve.ts +23 -1
  73. package/src/resources/extensions/gsd/auto/run-unit.ts +18 -1
  74. package/src/resources/extensions/gsd/auto-recovery.ts +54 -0
  75. package/src/resources/extensions/gsd/auto-supervisor.ts +7 -0
  76. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -2
  77. package/src/resources/extensions/gsd/auto.ts +69 -1
  78. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +21 -1
  79. package/src/resources/extensions/gsd/context-budget.ts +44 -2
  80. package/src/resources/extensions/gsd/db/unit-dispatches.ts +41 -0
  81. package/src/resources/extensions/gsd/db-base-schema.ts +4 -2
  82. package/src/resources/extensions/gsd/db-migration-steps.ts +8 -0
  83. package/src/resources/extensions/gsd/git-service.ts +46 -8
  84. package/src/resources/extensions/gsd/gsd-db.ts +50 -13
  85. package/src/resources/extensions/gsd/guided-flow.ts +32 -4
  86. package/src/resources/extensions/gsd/memory-store.ts +77 -12
  87. package/src/resources/extensions/gsd/prompt-loader.ts +27 -2
  88. package/src/resources/extensions/gsd/prompts/complete-milestone.md +14 -13
  89. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +71 -0
  90. package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +56 -13
  91. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +14 -1
  92. package/src/resources/extensions/gsd/tests/compaction-snapshot.test.ts +3 -1
  93. package/src/resources/extensions/gsd/tests/context-budget.test.ts +10 -1
  94. package/src/resources/extensions/gsd/tests/dispatch-rule-coverage.test.ts +313 -0
  95. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +54 -0
  96. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +234 -0
  97. package/src/resources/extensions/gsd/tests/memory-decay-factor.test.ts +90 -0
  98. package/src/resources/extensions/gsd/tests/prompt-step-ordering.test.ts +19 -0
  99. package/src/resources/extensions/gsd/tests/schema-v27-v28-sequence.test.ts +156 -0
  100. package/src/resources/extensions/gsd/tests/signal-handlers.test.ts +27 -0
  101. package/src/resources/extensions/gsd/tests/stalled-tool-recovery.test.ts +49 -1
  102. package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +38 -0
  103. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +30 -0
  104. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +30 -0
  105. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +3 -0
  106. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +63 -1
  107. package/src/resources/extensions/gsd/tools/memory-tools.ts +1 -0
  108. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +1 -1
  109. package/src/resources/extensions/gsd/unit-runtime.ts +11 -0
  110. package/src/resources/extensions/gsd/worktree-resolver.ts +36 -15
  111. /package/dist/web/standalone/.next/static/{3EDDd9ULaybSupPoA_vf- → tht0ltP8EIjsohPOA_WZf}/_buildManifest.js +0 -0
  112. /package/dist/web/standalone/.next/static/{3EDDd9ULaybSupPoA_vf- → tht0ltP8EIjsohPOA_WZf}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -259,6 +259,7 @@ Full documentation is in the [`docs/`](./docs/) directory:
259
259
 
260
260
  - **[Architecture](./docs/dev/architecture.md)** — system design and dispatch pipeline
261
261
  - **[CI/CD Pipeline](./docs/dev/ci-cd-pipeline.md)** — three-stage promotion pipeline (Dev → Test → Prod)
262
+ - **[E2E Testing](./tests/e2e/README.md)** — real-process CLI/runtime coverage and CI runner expectations
262
263
  - **[Pipeline Simplification (ADR-003)](./docs/dev/ADR-003-pipeline-simplification.md)** — merged research into planning, mechanical completion
263
264
  - **[VS Code Extension](./vscode-extension/README.md)** — chat participant, sidebar dashboard, RPC integration
264
265
 
@@ -1 +1 @@
1
- 8e6c04d763f9ba00
1
+ 03e60cff7c29604b
@@ -23,12 +23,12 @@ import { existsSync, cpSync } from "node:fs";
23
23
  import { logWarning, logError, _resetLogs, drainLogs, drainAndSummarize, formatForNotification, hasAnyIssues, } from "../workflow-logger.js";
24
24
  import { gsdRoot } from "../paths.js";
25
25
  import { atomicWriteSync } from "../atomic-write.js";
26
- import { verifyExpectedArtifact, diagnoseExpectedArtifact, buildLoopRemediationSteps } from "../auto-recovery.js";
26
+ import { verifyExpectedArtifact, diagnoseExpectedArtifact, buildLoopRemediationSteps, refreshRecoveryDbForArtifact } from "../auto-recovery.js";
27
27
  import { writeUnitRuntimeRecord } from "../unit-runtime.js";
28
28
  import { withTimeout, FINALIZE_PRE_TIMEOUT_MS, FINALIZE_POST_TIMEOUT_MS } from "./finalize-timeout.js";
29
29
  import { getEligibleSlices } from "../slice-parallel-eligibility.js";
30
30
  import { startSliceParallel } from "../slice-parallel-orchestrator.js";
31
- import { isDbAvailable, getMilestoneSlices, refreshOpenDatabaseFromDisk } from "../gsd-db.js";
31
+ import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js";
32
32
  import { ensurePlanV2Graph, isEmptyPlanV2GraphResult, isMissingFinalizedContextResult } from "../uok/plan-v2.js";
33
33
  import { resolveUokFlags } from "../uok/flags.js";
34
34
  import { UokGateRunner } from "../uok/gate-runner.js";
@@ -42,13 +42,6 @@ import { getWorkflowTransportSupportError, getRequiredWorkflowToolsForAutoUnit,
42
42
  function isSamePathLocal(a, b) {
43
43
  return normalizeWorktreePathForCompare(a) === normalizeWorktreePathForCompare(b);
44
44
  }
45
- function refreshPlanSliceRecoveryDbIfNeeded(unitType) {
46
- if (unitType !== "plan-slice")
47
- return true;
48
- if (!isDbAvailable())
49
- return true;
50
- return refreshOpenDatabaseFromDisk();
51
- }
52
45
  // ─── Session timeout auto-resume state ────────────────────────────────────────
53
46
  let consecutiveSessionTimeouts = 0;
54
47
  const MAX_SESSION_TIMEOUT_AUTO_RESUMES = 3;
@@ -795,11 +788,18 @@ export async function runDispatch(ic, preData, loopState) {
795
788
  level: 1,
796
789
  action: "artifact-found",
797
790
  });
798
- ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
799
- if (!refreshPlanSliceRecoveryDbIfNeeded(unitType)) {
800
- ctx.ui.notify(`Stuck recovery found ${unitType} ${unitId} artifacts, but the DB refresh failed. Keeping stuck state for retry.`, "warning");
791
+ const recoveryDb = refreshRecoveryDbForArtifact(unitType, unitId);
792
+ if (!recoveryDb.ok) {
793
+ ctx.ui.notify(recoveryDb.fatal
794
+ ? `${recoveryDb.message} Pausing auto-mode for manual recovery.`
795
+ : `${recoveryDb.message} Keeping stuck state for retry.`, "warning");
796
+ if (recoveryDb.fatal) {
797
+ await deps.pauseAuto(ctx, pi);
798
+ return { action: "break", reason: recoveryDb.reason };
799
+ }
801
800
  return { action: "continue" };
802
801
  }
802
+ ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
803
803
  deps.invalidateAllCaches();
804
804
  loopState.recentUnits.length = 0;
805
805
  loopState.stuckRecoveryAttempts = 0;
@@ -818,13 +818,20 @@ export async function runDispatch(ic, preData, loopState) {
818
818
  level: 2,
819
819
  action: "artifact-found",
820
820
  });
821
- ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk after cache invalidation. Continuing.`, "info");
822
- if (refreshPlanSliceRecoveryDbIfNeeded(unitType)) {
821
+ const recoveryDb = refreshRecoveryDbForArtifact(unitType, unitId);
822
+ if (recoveryDb.ok) {
823
+ ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk after cache invalidation. Continuing.`, "info");
823
824
  loopState.recentUnits.length = 0;
824
825
  loopState.stuckRecoveryAttempts = 0;
825
826
  return { action: "continue" };
826
827
  }
827
- ctx.ui.notify(`Stuck recovery found ${unitType} ${unitId} artifacts, but the DB refresh failed. Stopping for manual recovery.`, "warning");
828
+ ctx.ui.notify(recoveryDb.fatal
829
+ ? `${recoveryDb.message} Pausing auto-mode for manual recovery.`
830
+ : `${recoveryDb.message} Stopping for manual recovery.`, "warning");
831
+ if (recoveryDb.fatal) {
832
+ await deps.pauseAuto(ctx, pi);
833
+ return { action: "break", reason: recoveryDb.reason };
834
+ }
828
835
  }
829
836
  debugLog("autoLoop", {
830
837
  phase: "stuck-detected",
@@ -1672,6 +1679,13 @@ export async function runFinalize(ic, iterData, loopState, sidecarItem) {
1672
1679
  }
1673
1680
  // Both pre and post verification completed without timeout — reset counter
1674
1681
  loopState.consecutiveFinalizeTimeouts = 0;
1682
+ if (preUnitSnapshot) {
1683
+ writeUnitRuntimeRecord(s.basePath, preUnitSnapshot.type, preUnitSnapshot.id, preUnitSnapshot.startedAt, {
1684
+ phase: "finalized",
1685
+ lastProgressAt: Date.now(),
1686
+ lastProgressKind: "finalize-success",
1687
+ });
1688
+ }
1675
1689
  s.currentUnit = null;
1676
1690
  clearCurrentPhase();
1677
1691
  // Surface accumulated workflow-logger issues for this unit to the user.
@@ -17,6 +17,7 @@ import { bumpTurnGeneration } from "./turn-epoch.js";
17
17
  // scoped pendingResolve + pendingAgentEndQueue pattern.
18
18
  let _currentResolve = null;
19
19
  let _sessionSwitchInFlight = false;
20
+ let _pendingSwitchCancellation = null;
20
21
  // ─── Setters (needed for cross-module mutation) ─────────────────────────────
21
22
  export function _setCurrentResolve(fn) {
22
23
  _currentResolve = fn;
@@ -27,6 +28,11 @@ export function _setSessionSwitchInFlight(v) {
27
28
  export function _clearCurrentResolve() {
28
29
  _currentResolve = null;
29
30
  }
31
+ export function _consumePendingSwitchCancellation() {
32
+ const pending = _pendingSwitchCancellation;
33
+ _pendingSwitchCancellation = null;
34
+ return pending;
35
+ }
30
36
  // ─── resolveAgentEnd ─────────────────────────────────────────────────────────
31
37
  /**
32
38
  * Called from the agent_end event handler in index.ts to resolve the
@@ -90,8 +96,18 @@ export function resolveAgentEndCancelled(errorContext) {
90
96
  debugLog("resolveAgentEndCancelled", { status: "resolving-cancelled" });
91
97
  const r = _currentResolve;
92
98
  _currentResolve = null;
99
+ _pendingSwitchCancellation = null;
93
100
  r({ status: "cancelled", ...(errorContext ? { errorContext } : {}) });
101
+ return true;
102
+ }
103
+ if (_sessionSwitchInFlight) {
104
+ bumpTurnGeneration(`cancelled-during-switch:${errorContext?.category ?? "unknown"}`);
105
+ _pendingSwitchCancellation = errorContext ? { errorContext } : {};
106
+ debugLog("resolveAgentEndCancelled", { status: "queued-during-switch" });
107
+ return false;
94
108
  }
109
+ debugLog("resolveAgentEndCancelled", { status: "no-pending-resolve" });
110
+ return false;
95
111
  }
96
112
  // ─── resetPendingResolve (test helper) ───────────────────────────────────────
97
113
  /**
@@ -101,6 +117,7 @@ export function resolveAgentEndCancelled(errorContext) {
101
117
  export function _resetPendingResolve() {
102
118
  _currentResolve = null;
103
119
  _sessionSwitchInFlight = false;
120
+ _pendingSwitchCancellation = null;
104
121
  }
105
122
  export function _hasPendingResolveForTest() {
106
123
  return _currentResolve !== null;
@@ -1,6 +1,6 @@
1
1
  // GSD-2 + src/resources/extensions/gsd/auto/run-unit.ts - Runs one GSD auto-mode unit from session creation through agent completion.
2
2
  import { NEW_SESSION_TIMEOUT_MS } from "./session.js";
3
- import { _clearCurrentResolve, _setCurrentResolve, _setSessionSwitchInFlight } from "./resolve.js";
3
+ import { _clearCurrentResolve, _consumePendingSwitchCancellation, _setCurrentResolve, _setSessionSwitchInFlight, } from "./resolve.js";
4
4
  import { getCurrentTurnGeneration, runWithTurnGeneration, } from "./turn-epoch.js";
5
5
  import { debugLog } from "../debug-logger.js";
6
6
  import { logWarning } from "../workflow-logger.js";
@@ -71,6 +71,7 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
71
71
  catch (sessionErr) {
72
72
  if (sessionTimeoutHandle)
73
73
  clearTimeout(sessionTimeoutHandle);
74
+ _consumePendingSwitchCancellation();
74
75
  const msg = sessionErr instanceof Error ? sessionErr.message : String(sessionErr);
75
76
  debugLog("runUnit", {
76
77
  phase: "session-error",
@@ -83,15 +84,18 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
83
84
  if (sessionTimeoutHandle)
84
85
  clearTimeout(sessionTimeoutHandle);
85
86
  if (sessionResult.cancelled) {
87
+ _consumePendingSwitchCancellation();
86
88
  debugLog("runUnit-session-timeout", { unitType, unitId });
87
89
  return { status: "cancelled", errorContext: { message: "Session creation timed out", category: "timeout", isTransient: true } };
88
90
  }
89
91
  if (!s.active) {
92
+ _consumePendingSwitchCancellation();
90
93
  return { status: "cancelled" };
91
94
  }
92
95
  if (s.currentUnitModel && typeof pi.setModel === "function") {
93
96
  const restored = await pi.setModel(s.currentUnitModel, { persist: false });
94
97
  if (!restored) {
98
+ _consumePendingSwitchCancellation();
95
99
  const message = `Failed to restore configured model ${s.currentUnitModel.provider}/${s.currentUnitModel.id} after session creation`;
96
100
  ctx.ui.notify(`${message}. Cancelling unit before dispatch.`, "warning");
97
101
  return {
@@ -111,6 +115,14 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
111
115
  const unitPromise = new Promise((resolve) => {
112
116
  _setCurrentResolve(resolve);
113
117
  });
118
+ const pendingSwitchCancellation = _consumePendingSwitchCancellation();
119
+ if (pendingSwitchCancellation) {
120
+ _clearCurrentResolve();
121
+ return {
122
+ status: "cancelled",
123
+ ...(pendingSwitchCancellation.errorContext ? { errorContext: pendingSwitchCancellation.errorContext } : {}),
124
+ };
125
+ }
114
126
  // ── Provider request-readiness pre-check (#4555) ──
115
127
  // Verify the provider can accept requests before dispatching. If the token
116
128
  // has expired since bootstrap, return cancelled immediately so the unit is
@@ -30,7 +30,49 @@ import { getProjectResearchStatus } from "./project-research-policy.js";
30
30
  // Re-export so existing consumers of auto-recovery.ts keep working.
31
31
  export { resolveExpectedArtifactPath, diagnoseExpectedArtifact };
32
32
  export { classifyMilestoneSummaryContent, } from "./milestone-summary-classifier.js";
33
- // ─── Artifact Resolution & Verification ───────────────────────────────────────
33
+ export function refreshRecoveryDbForArtifact(unitType, unitId) {
34
+ if (unitType !== "plan-slice" && unitType !== "execute-task")
35
+ return { ok: true };
36
+ if (!isDbAvailable())
37
+ return { ok: true };
38
+ if (!refreshOpenDatabaseFromDisk()) {
39
+ return {
40
+ ok: false,
41
+ fatal: unitType === "execute-task",
42
+ reason: `${unitType}-db-refresh-failed`,
43
+ message: `Stuck recovery found ${unitType} ${unitId} artifacts, but the DB refresh failed.`,
44
+ };
45
+ }
46
+ if (unitType !== "execute-task")
47
+ return { ok: true };
48
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
49
+ if (!mid || !sid || !tid) {
50
+ return {
51
+ ok: false,
52
+ fatal: true,
53
+ reason: "execute-task-invalid-unit-id",
54
+ message: `Stuck recovery found execute-task ${unitId} artifacts, but the unit id could not be parsed for DB verification.`,
55
+ };
56
+ }
57
+ const task = getTask(mid, sid, tid);
58
+ if (!task) {
59
+ return {
60
+ ok: false,
61
+ fatal: true,
62
+ reason: "execute-task-artifact-db-missing",
63
+ message: `Stuck recovery found execute-task ${unitId} artifacts, but no matching DB task row exists after refresh.`,
64
+ };
65
+ }
66
+ if (!isClosedStatus(task.status)) {
67
+ return {
68
+ ok: false,
69
+ fatal: true,
70
+ reason: "execute-task-artifact-db-mismatch",
71
+ message: `Stuck recovery found execute-task ${unitId} artifacts, but the DB task status is still '${task.status}' after refresh.`,
72
+ };
73
+ }
74
+ return { ok: true };
75
+ }
34
76
  function hasCapturedWorkflowPrefs(base) {
35
77
  const prefsPath = resolveExpectedArtifactPath("workflow-preferences", "WORKFLOW-PREFS", base);
36
78
  if (!prefsPath || !existsSync(prefsPath))
@@ -24,7 +24,7 @@ let _currentSigtermHandler = null;
24
24
  *
25
25
  * Returns the new handler so the caller can store and deregister it later.
26
26
  */
27
- export function registerSigtermHandler(currentBasePath, previousHandler) {
27
+ export function registerSigtermHandler(currentBasePath, previousHandler, onSignalCleanup) {
28
28
  // Remove the explicitly-passed previous handler
29
29
  if (previousHandler) {
30
30
  for (const sig of CLEANUP_SIGNALS)
@@ -37,6 +37,13 @@ export function registerSigtermHandler(currentBasePath, previousHandler) {
37
37
  process.off(sig, _currentSigtermHandler);
38
38
  }
39
39
  const handler = () => {
40
+ try {
41
+ onSignalCleanup?.();
42
+ }
43
+ catch {
44
+ void 0;
45
+ // Signal cleanup is best-effort; lock cleanup and process exit still run.
46
+ }
40
47
  clearLock(currentBasePath);
41
48
  releaseSessionLock(currentBasePath);
42
49
  process.exit(0);
@@ -35,13 +35,13 @@ export async function recoverTimedOutUnit(ctx, pi, unitType, unitId, reason, rct
35
35
  writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
36
36
  recovery: status,
37
37
  });
38
- const durableComplete = status.summaryExists && status.taskChecked && status.nextActionAdvanced;
38
+ const durableComplete = status.dbComplete || (status.summaryExists && status.taskChecked && status.nextActionAdvanced);
39
39
  if (durableComplete) {
40
40
  writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
41
41
  phase: "finalized",
42
42
  recovery: status,
43
43
  });
44
- ctx.ui.notify(`${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode. (attempt ${attemptNumber})`, "info");
44
+ ctx.ui.notify(`${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed. Continuing auto-mode. (attempt ${attemptNumber})`, "info");
45
45
  unitRecoveryCount.delete(recoveryKey);
46
46
  bumpAndResolveSynthetic(`timeout-recovery:${reason}:${unitType}/${unitId}`);
47
47
  return "recovered";
@@ -20,7 +20,7 @@ import { gsdRoot, resolveMilestoneFile, resolveMilestonePath, resolveDir, milest
20
20
  import { invalidateAllCaches } from "./cache.js";
21
21
  import { clearActivityLogState } from "./activity-log.js";
22
22
  import { synthesizeCrashRecovery, getDeepDiagnostic, readActiveMilestoneId, } from "./session-forensics.js";
23
- import { writeLock, clearLock, readCrashLock, isLockProcessAlive, formatCrashInfo, emitCrashRecoveredUnitEnd, } from "./crash-recovery.js";
23
+ import { writeLock, clearLock, readCrashLock, isLockProcessAlive, formatCrashInfo, emitCrashRecoveredUnitEnd, emitOpenUnitEndForUnit, } from "./crash-recovery.js";
24
24
  import { acquireSessionLock, getSessionLockStatus, releaseSessionLock, updateSessionLock, } from "./session-lock.js";
25
25
  import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences, getIsolationMode, } from "./preferences.js";
26
26
  import { sendDesktopNotification } from "./notifications.js";
@@ -61,6 +61,8 @@ import { isClosedStatus } from "./status-guards.js";
61
61
  import { updateProgressWidget as _updateProgressWidget, updateSliceProgressCache, clearSliceProgressCache, } from "./auto-dashboard.js";
62
62
  import { registerSigtermHandler as _registerSigtermHandler, deregisterSigtermHandler as _deregisterSigtermHandler, } from "./auto-supervisor.js";
63
63
  import { isDbAvailable, getMilestone } from "./gsd-db.js";
64
+ import { markLatestActiveForWorkerCanceled } from "./db/unit-dispatches.js";
65
+ import { writeUnitRuntimeRecord } from "./unit-runtime.js";
64
66
  import { countPendingCaptures } from "./captures.js";
65
67
  import { CMUX_CHANNELS } from "../shared/cmux-events.js";
66
68
  import { ensureDbOpen } from "./bootstrap/dynamic-tools.js";
@@ -85,6 +87,16 @@ import { validateDirectory } from "./validate-directory.js";
85
87
  import { createAutoOrchestrator } from "./auto/orchestrator.js";
86
88
  import { WorktreeResolver, } from "./worktree-resolver.js";
87
89
  import { reorderForCaching } from "./prompt-ordering.js";
90
+ import { initTokenCounter } from "./token-counter.js";
91
+ // Warm the tiktoken encoder at extension startup so context-budget computations
92
+ // can use accurate token counts via countTokensSync without paying the load
93
+ // cost mid-prompt-build. Fire-and-forget — failure falls back to the
94
+ // provider-aware char-ratio estimator already used by getCharsPerToken().
95
+ // Catch rejections explicitly: an unhandled rejection at module-import time
96
+ // can destabilize startup before the engine logger is configured.
97
+ void initTokenCounter().catch((err) => {
98
+ logWarning("engine", `token counter warm-up failed: ${err instanceof Error ? err.message : String(err)}`);
99
+ });
88
100
  export { STUB_RECOVERY_THRESHOLD, NEW_SESSION_TIMEOUT_MS, } from "./auto/session.js";
89
101
  import { autoSession as s } from "./auto-runtime-state.js";
90
102
  import { gsdHome } from "./gsd-home.js";
@@ -273,9 +285,50 @@ export function shouldUseWorktreeIsolation(basePath) {
273
285
  */
274
286
  // Re-export budget utilities for external consumers
275
287
  export { getBudgetAlertLevel, getNewBudgetAlertLevel, getBudgetEnforcementAction, } from "./auto-budget.js";
288
+ function closeOutSignalInterruptedUnit(currentBasePath) {
289
+ const currentUnit = s.currentUnit;
290
+ if (!currentUnit)
291
+ return;
292
+ const reason = "Auto-mode process received a termination signal";
293
+ const errorContext = {
294
+ message: reason,
295
+ category: "aborted",
296
+ isTransient: false,
297
+ };
298
+ const basePath = s.basePath || currentBasePath;
299
+ try {
300
+ emitOpenUnitEndForUnit(basePath, currentUnit.type, currentUnit.id, "cancelled", errorContext);
301
+ }
302
+ catch (err) {
303
+ logWarning("engine", `signal unit-end cleanup failed: ${getErrorMessage(err)}`, { file: "auto.ts" });
304
+ }
305
+ try {
306
+ writeUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, {
307
+ phase: "crashed",
308
+ lastProgressAt: Date.now(),
309
+ lastProgressKind: "signal",
310
+ });
311
+ }
312
+ catch (err) {
313
+ logWarning("engine", `signal runtime cleanup failed: ${getErrorMessage(err)}`, { file: "auto.ts" });
314
+ }
315
+ try {
316
+ if (s.workerId)
317
+ markLatestActiveForWorkerCanceled(s.workerId, "signal-exit");
318
+ }
319
+ catch (err) {
320
+ logWarning("engine", `signal dispatch cleanup failed: ${getErrorMessage(err)}`, { file: "auto.ts" });
321
+ }
322
+ try {
323
+ resolveAgentEndCancelled(errorContext);
324
+ }
325
+ catch (err) {
326
+ logWarning("engine", `signal resolve cleanup failed: ${getErrorMessage(err)}`, { file: "auto.ts" });
327
+ }
328
+ }
276
329
  /** Wrapper: register SIGTERM handler and store reference. */
277
330
  function registerSigtermHandler(currentBasePath) {
278
- s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler);
331
+ s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler, () => closeOutSignalInterruptedUnit(currentBasePath));
279
332
  }
280
333
  /** Wrapper: deregister SIGTERM handler and clear reference. */
281
334
  function deregisterSigtermHandler() {
@@ -667,6 +720,8 @@ export async function stopAuto(ctx, pi, reason) {
667
720
  if (s.workerId) {
668
721
  markWorkerStopping(s.workerId);
669
722
  }
723
+ s.workerId = null;
724
+ s.milestoneLeaseToken = null;
670
725
  }
671
726
  catch (e) {
672
727
  debugLog("stop-cleanup-coordination", { error: e instanceof Error ? e.message : String(e) });
@@ -1567,6 +1622,10 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
1567
1622
  : new URL("../../../resource-loader.js", import.meta.url).href;
1568
1623
  const { initResources } = await import(resourceLoaderPath);
1569
1624
  initResources(agentDir);
1625
+ // initResources() uses synchronous fs APIs, so the prompt-template cache
1626
+ // can be primed immediately — no need for the legacy 1s setTimeout deferral.
1627
+ const { primeCache } = await import("./prompt-loader.js");
1628
+ primeCache();
1570
1629
  // Open the project DB before rebuild/derive so resume uses DB-backed
1571
1630
  // state instead of falling back to stale markdown parsing (#2940).
1572
1631
  await openProjectDbIfPresent(s.basePath);
@@ -118,9 +118,28 @@ export async function handleAgentEnd(pi, event, ctx) {
118
118
  return;
119
119
  if (!isAutoActive())
120
120
  return;
121
- if (isSessionSwitchInFlight())
122
- return;
123
121
  const lastMsg = event.messages[event.messages.length - 1];
122
+ if (isSessionSwitchInFlight()) {
123
+ if (lastMsg && "stopReason" in lastMsg && lastMsg.stopReason === "error") {
124
+ const rawErrorMsg = ("errorMessage" in lastMsg && lastMsg.errorMessage) ? String(lastMsg.errorMessage) : "";
125
+ if (isUserInitiatedAbortMessage(rawErrorMsg)) {
126
+ resolveAgentEndCancelled({
127
+ message: rawErrorMsg,
128
+ category: "aborted",
129
+ isTransient: false,
130
+ });
131
+ }
132
+ }
133
+ else if (lastMsg && "stopReason" in lastMsg && lastMsg.stopReason === "aborted") {
134
+ const content = "content" in lastMsg ? lastMsg.content : undefined;
135
+ const hasEmptyContent = Array.isArray(content) && content.length === 0;
136
+ const hasErrorMessage = "errorMessage" in lastMsg && !!lastMsg.errorMessage;
137
+ if (!hasEmptyContent || hasErrorMessage) {
138
+ resolveAgentEndCancelled(_buildAbortedPauseContext(lastMsg));
139
+ }
140
+ }
141
+ return;
142
+ }
124
143
  if (lastMsg && "stopReason" in lastMsg && lastMsg.stopReason === "aborted") {
125
144
  // Empty content with aborted stopReason is a non-fatal agent stop (the LLM
126
145
  // chose to end without producing output). Only pause on genuine fatal aborts
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @see D001 (module location), D002 (200K fallback), D003 (section-boundary truncation)
9
9
  */
10
- import { getCharsPerToken } from "./token-counter.js";
10
+ import { getCharsPerToken, isAccurateCountingAvailable, countTokensSync, } from "./token-counter.js";
11
11
  // ─── Budget ratio constants ──────────────────────────────────────────────────
12
12
  // Percentages of total context window allocated to each budget category.
13
13
  // These are applied after tokens→chars conversion.
@@ -23,6 +23,22 @@ const CHARS_PER_TOKEN = 4;
23
23
  const DEFAULT_CONTEXT_WINDOW = 200_000;
24
24
  /** Conservative effective context for Claude Code subscription routing (#4676) */
25
25
  const CLAUDE_CODE_EFFECTIVE_CONTEXT_WINDOW = 200_000;
26
+ /**
27
+ * Cached empirical chars-per-token from a tiktoken probe, keyed by provider.
28
+ * countTokensSync's fallback path is provider-aware, so we cache per-provider
29
+ * to preserve that distinction once the encoder warms. The cl100k_base encoder
30
+ * itself gives a stable ratio for ASCII English so a single probe per provider
31
+ * key is sufficient. Empty map means "not yet probed" or "encoder unavailable".
32
+ */
33
+ const _empiricalCharsPerTokenByProvider = new Map();
34
+ /**
35
+ * Test hook — clears the empirical chars-per-token cache so test cases that
36
+ * assert against the static char-ratio fallback aren't polluted by a prior
37
+ * tiktoken-warmed run in the same process. Production code must not call this.
38
+ */
39
+ export function _resetEmpiricalCacheForTest() {
40
+ _empiricalCharsPerTokenByProvider.clear();
41
+ }
26
42
  /** Percentage of context consumed before suggesting a continue-here checkpoint */
27
43
  const CONTINUE_THRESHOLD_PERCENT = 70;
28
44
  // ─── Task count bounds ───────────────────────────────────────────────────────
@@ -46,7 +62,26 @@ const TASK_COUNT_TIERS = [
46
62
  export function computeBudgets(contextWindow, provider) {
47
63
  const effectiveWindow = contextWindow > 0 ? contextWindow : DEFAULT_CONTEXT_WINDOW;
48
64
  const charsPerToken = provider ? getCharsPerToken(provider) : CHARS_PER_TOKEN;
49
- const totalChars = effectiveWindow * charsPerToken;
65
+ // Prefer the tiktoken encoder for total-char estimation when it has been
66
+ // warmed (initTokenCounter resolved). The cl100k_base ratio is stable for
67
+ // ASCII English, so probe once per provider and cache — computeBudgets is
68
+ // called multiple times per prompt build and the probe encode is otherwise
69
+ // wasted work.
70
+ let totalChars;
71
+ if (isAccurateCountingAvailable()) {
72
+ const providerKey = provider ?? "__default__";
73
+ let empirical = _empiricalCharsPerTokenByProvider.get(providerKey);
74
+ if (empirical === undefined) {
75
+ const probe = "the quick brown fox jumps over the lazy dog ".repeat(64);
76
+ const probeTokens = countTokensSync(probe, provider);
77
+ empirical = probeTokens > 0 ? probe.length / probeTokens : charsPerToken;
78
+ _empiricalCharsPerTokenByProvider.set(providerKey, empirical);
79
+ }
80
+ totalChars = effectiveWindow * empirical;
81
+ }
82
+ else {
83
+ totalChars = effectiveWindow * charsPerToken;
84
+ }
50
85
  return {
51
86
  summaryBudgetChars: Math.floor(totalChars * SUMMARY_RATIO),
52
87
  inlineContextBudgetChars: Math.floor(totalChars * INLINE_CONTEXT_RATIO),
@@ -253,6 +253,45 @@ export function markCanceled(dispatchId, reason) {
253
253
  SET status = 'canceled', ended_at = :ended_at, exit_reason = :reason
254
254
  WHERE id = :id AND status IN ('pending','claimed','running')`).run({ ":id": dispatchId, ":ended_at": now, ":reason": reason });
255
255
  }
256
+ /**
257
+ * Best-effort signal/crash cleanup: cancel the latest active dispatch owned by
258
+ * a worker when the process is exiting before the normal loop can settle it.
259
+ */
260
+ export function markLatestActiveForWorkerCanceled(workerId, reason) {
261
+ if (!isDbAvailable())
262
+ return false;
263
+ const now = new Date().toISOString();
264
+ const db = _getAdapter();
265
+ const result = transaction(() => {
266
+ return db.prepare(`UPDATE unit_dispatches
267
+ SET status = 'canceled', ended_at = :ended_at, exit_reason = :reason
268
+ WHERE id = (
269
+ SELECT id FROM unit_dispatches
270
+ WHERE worker_id = :worker_id
271
+ AND status IN ('pending','claimed','running')
272
+ ORDER BY id DESC
273
+ LIMIT 1
274
+ )`).run({
275
+ ":ended_at": now,
276
+ ":reason": reason,
277
+ ":worker_id": workerId,
278
+ });
279
+ });
280
+ const changes = typeof result.changes === "number"
281
+ ? result.changes
282
+ : 0;
283
+ if (changes <= 0)
284
+ return false;
285
+ insertAuditEvent({
286
+ eventId: randomUUID(),
287
+ traceId: workerId,
288
+ category: "orchestration",
289
+ type: "dispatch-canceled",
290
+ ts: now,
291
+ payload: { workerId, reason },
292
+ });
293
+ return true;
294
+ }
256
295
  /**
257
296
  * Fetch the most recent N dispatches for a unit. Used by recordDispatchClaim
258
297
  * callers to compute attempt_n and by detect-stuck.ts (B3) to consult
@@ -46,7 +46,8 @@ export function createBaseSchemaObjects(db, hooks) {
46
46
  slice_id TEXT DEFAULT NULL,
47
47
  task_id TEXT DEFAULT NULL,
48
48
  full_content TEXT NOT NULL DEFAULT '',
49
- imported_at TEXT NOT NULL DEFAULT ''
49
+ imported_at TEXT NOT NULL DEFAULT '',
50
+ content_hash TEXT DEFAULT NULL
50
51
  )
51
52
  `);
52
53
  db.exec(`
@@ -64,7 +65,8 @@ export function createBaseSchemaObjects(db, hooks) {
64
65
  hit_count INTEGER NOT NULL DEFAULT 0,
65
66
  scope TEXT NOT NULL DEFAULT 'project',
66
67
  tags TEXT NOT NULL DEFAULT '[]',
67
- structured_fields TEXT DEFAULT NULL
68
+ structured_fields TEXT DEFAULT NULL,
69
+ last_hit_at TEXT DEFAULT NULL
68
70
  )
69
71
  `);
70
72
  db.exec(`
@@ -379,6 +379,12 @@ export function applyMigrationV26MilestoneCommitAttributions(db) {
379
379
  `);
380
380
  db.exec("CREATE INDEX IF NOT EXISTS idx_milestone_commit_attr_milestone ON milestone_commit_attributions(milestone_id)");
381
381
  }
382
+ export function applyMigrationV27ArtifactHash(db) {
383
+ ensureColumn(db, "artifacts", "content_hash", "ALTER TABLE artifacts ADD COLUMN content_hash TEXT DEFAULT NULL");
384
+ }
385
+ export function applyMigrationV28MemoryLastHitAt(db) {
386
+ ensureColumn(db, "memories", "last_hit_at", "ALTER TABLE memories ADD COLUMN last_hit_at TEXT DEFAULT NULL");
387
+ }
382
388
  export function applyMigrationV22QualityGateRepair(db, hooks) {
383
389
  const qgInfo = db.prepare("PRAGMA table_info(quality_gates)").all();
384
390
  const taskIdCol = qgInfo.find((r) => r["name"] === "task_id");
@@ -13,6 +13,7 @@ import { isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
13
13
  import { gsdRoot } from "./paths.js";
14
14
  import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
15
15
  import { loadEffectiveGSDPreferences } from "./preferences.js";
16
+ import { logWarning } from "./workflow-logger.js";
16
17
  import { detectWorktreeName, } from "./worktree.js";
17
18
  import { SLICE_BRANCH_RE, QUICK_BRANCH_RE, WORKFLOW_BRANCH_RE } from "./branch-patterns.js";
18
19
  import { nativeGetCurrentBranch, nativeDetectMainBranch, nativeBranchExists, nativeHasChanges, nativeAddAllWithExclusions, nativeHasStagedChanges, nativeCommit, nativeRmCached, nativeUpdateRef, nativeAddPaths, nativeResetSoft, nativeCommitSubject, _resetHasChangesCache, } from "./native-git-bridge.js";
@@ -535,14 +536,45 @@ export class GitServiceImpl {
535
536
  if (keyFiles.length === 0)
536
537
  return false;
537
538
  const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
538
- const paths = Array.from(new Set(keyFiles
539
+ const normalized = keyFiles
539
540
  .map(file => normalizeRepoRelativePath(this.basePath, file))
540
541
  .filter((file) => file !== null)
541
- .filter(file => !isExcludedScopedPath(file, allExclusions))));
542
+ .filter(file => !isExcludedScopedPath(file, allExclusions));
543
+ // Drop entries that don't exist on disk. The LLM occasionally lists files
544
+ // it intended to write but didn't (or names them with wrong casing/path).
545
+ // Pre-`b304f738b` `git add -A` swallowed these silently; the scoped
546
+ // pathspec form passes each path explicitly, so a single bad entry made
547
+ // the whole commit fail (see #5500). Filter so valid paths still commit.
548
+ const missing = [];
549
+ const existing = [];
550
+ for (const path of normalized) {
551
+ if (existsSync(join(this.basePath, path))) {
552
+ existing.push(path);
553
+ }
554
+ else {
555
+ missing.push(path);
556
+ }
557
+ }
558
+ if (missing.length > 0) {
559
+ logWarning("engine", `scoped stage: dropping ${missing.length} non-existent keyFile(s) from task commit: ${missing.join(", ")}`, { file: "git-service.ts" });
560
+ }
561
+ const paths = Array.from(new Set(existing));
542
562
  if (paths.length === 0)
543
563
  return false;
544
- nativeAddPaths(this.basePath, paths);
545
- return true;
564
+ try {
565
+ nativeAddPaths(this.basePath, paths);
566
+ return true;
567
+ }
568
+ catch (err) {
569
+ // Defense-in-depth: even after existence filtering, libgit2/git can
570
+ // still reject paths (gitignore matches, case-only differences on
571
+ // case-insensitive FS, submodule boundaries). Returning false lets
572
+ // autoCommit fall through to smartStage so the commit still goes out
573
+ // — restoring the resilience the unscoped path used to provide.
574
+ const msg = err instanceof Error ? err.message : String(err);
575
+ logWarning("engine", `scoped stage failed (${msg}); falling back to smartStage`, { file: "git-service.ts" });
576
+ return false;
577
+ }
546
578
  }
547
579
  /** Tracks whether runtime file cleanup has run this session. */
548
580
  _runtimeFilesCleanedUp = false;