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
@@ -50,12 +50,12 @@ import {
50
50
  } from "../workflow-logger.js";
51
51
  import { gsdRoot } from "../paths.js";
52
52
  import { atomicWriteSync } from "../atomic-write.js";
53
- import { verifyExpectedArtifact, diagnoseExpectedArtifact, buildLoopRemediationSteps } from "../auto-recovery.js";
53
+ import { verifyExpectedArtifact, diagnoseExpectedArtifact, buildLoopRemediationSteps, refreshRecoveryDbForArtifact } from "../auto-recovery.js";
54
54
  import { writeUnitRuntimeRecord } from "../unit-runtime.js";
55
55
  import { withTimeout, FINALIZE_PRE_TIMEOUT_MS, FINALIZE_POST_TIMEOUT_MS } from "./finalize-timeout.js";
56
56
  import { getEligibleSlices } from "../slice-parallel-eligibility.js";
57
57
  import { startSliceParallel } from "../slice-parallel-orchestrator.js";
58
- import { isDbAvailable, getMilestoneSlices, refreshOpenDatabaseFromDisk } from "../gsd-db.js";
58
+ import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js";
59
59
  import type { MinimalModelRegistry } from "../context-budget.js";
60
60
  import { ensurePlanV2Graph, isEmptyPlanV2GraphResult, isMissingFinalizedContextResult } from "../uok/plan-v2.js";
61
61
  import { resolveUokFlags } from "../uok/flags.js";
@@ -76,12 +76,6 @@ function isSamePathLocal(a: string, b: string): boolean {
76
76
  return normalizeWorktreePathForCompare(a) === normalizeWorktreePathForCompare(b);
77
77
  }
78
78
 
79
- function refreshPlanSliceRecoveryDbIfNeeded(unitType: string): boolean {
80
- if (unitType !== "plan-slice") return true;
81
- if (!isDbAvailable()) return true;
82
- return refreshOpenDatabaseFromDisk();
83
- }
84
-
85
79
  // ─── Session timeout auto-resume state ────────────────────────────────────────
86
80
 
87
81
  let consecutiveSessionTimeouts = 0;
@@ -1100,17 +1094,24 @@ export async function runDispatch(
1100
1094
  level: 1,
1101
1095
  action: "artifact-found",
1102
1096
  });
1103
- ctx.ui.notify(
1104
- `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1105
- "info",
1106
- );
1107
- if (!refreshPlanSliceRecoveryDbIfNeeded(unitType)) {
1097
+ const recoveryDb = refreshRecoveryDbForArtifact(unitType, unitId);
1098
+ if (!recoveryDb.ok) {
1108
1099
  ctx.ui.notify(
1109
- `Stuck recovery found ${unitType} ${unitId} artifacts, but the DB refresh failed. Keeping stuck state for retry.`,
1100
+ recoveryDb.fatal
1101
+ ? `${recoveryDb.message} Pausing auto-mode for manual recovery.`
1102
+ : `${recoveryDb.message} Keeping stuck state for retry.`,
1110
1103
  "warning",
1111
1104
  );
1105
+ if (recoveryDb.fatal) {
1106
+ await deps.pauseAuto(ctx, pi);
1107
+ return { action: "break", reason: recoveryDb.reason };
1108
+ }
1112
1109
  return { action: "continue" };
1113
1110
  }
1111
+ ctx.ui.notify(
1112
+ `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1113
+ "info",
1114
+ );
1114
1115
  deps.invalidateAllCaches();
1115
1116
  loopState.recentUnits.length = 0;
1116
1117
  loopState.stuckRecoveryAttempts = 0;
@@ -1135,19 +1136,26 @@ export async function runDispatch(
1135
1136
  level: 2,
1136
1137
  action: "artifact-found",
1137
1138
  });
1138
- ctx.ui.notify(
1139
- `Stuck recovery: artifact for ${unitType} ${unitId} found on disk after cache invalidation. Continuing.`,
1140
- "info",
1141
- );
1142
- if (refreshPlanSliceRecoveryDbIfNeeded(unitType)) {
1139
+ const recoveryDb = refreshRecoveryDbForArtifact(unitType, unitId);
1140
+ if (recoveryDb.ok) {
1141
+ ctx.ui.notify(
1142
+ `Stuck recovery: artifact for ${unitType} ${unitId} found on disk after cache invalidation. Continuing.`,
1143
+ "info",
1144
+ );
1143
1145
  loopState.recentUnits.length = 0;
1144
1146
  loopState.stuckRecoveryAttempts = 0;
1145
1147
  return { action: "continue" };
1146
1148
  }
1147
1149
  ctx.ui.notify(
1148
- `Stuck recovery found ${unitType} ${unitId} artifacts, but the DB refresh failed. Stopping for manual recovery.`,
1150
+ recoveryDb.fatal
1151
+ ? `${recoveryDb.message} Pausing auto-mode for manual recovery.`
1152
+ : `${recoveryDb.message} Stopping for manual recovery.`,
1149
1153
  "warning",
1150
1154
  );
1155
+ if (recoveryDb.fatal) {
1156
+ await deps.pauseAuto(ctx, pi);
1157
+ return { action: "break", reason: recoveryDb.reason };
1158
+ }
1151
1159
  }
1152
1160
  debugLog("autoLoop", {
1153
1161
  phase: "stuck-detected",
@@ -2265,6 +2273,13 @@ export async function runFinalize(
2265
2273
 
2266
2274
  // Both pre and post verification completed without timeout — reset counter
2267
2275
  loopState.consecutiveFinalizeTimeouts = 0;
2276
+ if (preUnitSnapshot) {
2277
+ writeUnitRuntimeRecord(s.basePath, preUnitSnapshot.type, preUnitSnapshot.id, preUnitSnapshot.startedAt, {
2278
+ phase: "finalized",
2279
+ lastProgressAt: Date.now(),
2280
+ lastProgressKind: "finalize-success",
2281
+ });
2282
+ }
2268
2283
  s.currentUnit = null;
2269
2284
  clearCurrentPhase();
2270
2285
 
@@ -22,6 +22,7 @@ import { bumpTurnGeneration } from "./turn-epoch.js";
22
22
 
23
23
  let _currentResolve: ((result: UnitResult) => void) | null = null;
24
24
  let _sessionSwitchInFlight = false;
25
+ let _pendingSwitchCancellation: { errorContext?: ErrorContext } | null = null;
25
26
 
26
27
  // ─── Setters (needed for cross-module mutation) ─────────────────────────────
27
28
 
@@ -37,6 +38,12 @@ export function _clearCurrentResolve(): void {
37
38
  _currentResolve = null;
38
39
  }
39
40
 
41
+ export function _consumePendingSwitchCancellation(): { errorContext?: ErrorContext } | null {
42
+ const pending = _pendingSwitchCancellation;
43
+ _pendingSwitchCancellation = null;
44
+ return pending;
45
+ }
46
+
40
47
  // ─── resolveAgentEnd ─────────────────────────────────────────────────────────
41
48
 
42
49
  /**
@@ -96,7 +103,7 @@ export function bumpAndResolveSynthetic(reason: string): void {
96
103
  * blocks to ensure the autoLoop is never stuck awaiting a promise that
97
104
  * will never resolve. Safe to call when no resolver is pending (no-op).
98
105
  */
99
- export function resolveAgentEndCancelled(errorContext?: ErrorContext): void {
106
+ export function resolveAgentEndCancelled(errorContext?: ErrorContext): boolean {
100
107
  if (_currentResolve) {
101
108
  // Cancellation supersedes the in-flight turn the same way timeout
102
109
  // recovery does — bump the turn epoch so any lingering writes from the
@@ -107,8 +114,22 @@ export function resolveAgentEndCancelled(errorContext?: ErrorContext): void {
107
114
  debugLog("resolveAgentEndCancelled", { status: "resolving-cancelled" });
108
115
  const r = _currentResolve;
109
116
  _currentResolve = null;
117
+ _pendingSwitchCancellation = null;
110
118
  r({ status: "cancelled", ...(errorContext ? { errorContext } : {}) });
119
+ return true;
120
+ }
121
+
122
+ if (_sessionSwitchInFlight) {
123
+ bumpTurnGeneration(
124
+ `cancelled-during-switch:${errorContext?.category ?? "unknown"}`,
125
+ );
126
+ _pendingSwitchCancellation = errorContext ? { errorContext } : {};
127
+ debugLog("resolveAgentEndCancelled", { status: "queued-during-switch" });
128
+ return false;
111
129
  }
130
+
131
+ debugLog("resolveAgentEndCancelled", { status: "no-pending-resolve" });
132
+ return false;
112
133
  }
113
134
 
114
135
  // ─── resetPendingResolve (test helper) ───────────────────────────────────────
@@ -120,6 +141,7 @@ export function resolveAgentEndCancelled(errorContext?: ErrorContext): void {
120
141
  export function _resetPendingResolve(): void {
121
142
  _currentResolve = null;
122
143
  _sessionSwitchInFlight = false;
144
+ _pendingSwitchCancellation = null;
123
145
  }
124
146
 
125
147
  export function _hasPendingResolveForTest(): boolean {
@@ -11,7 +11,12 @@ import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
11
11
  import type { AutoSession } from "./session.js";
12
12
  import { NEW_SESSION_TIMEOUT_MS } from "./session.js";
13
13
  import type { UnitResult } from "./types.js";
14
- import { _clearCurrentResolve, _setCurrentResolve, _setSessionSwitchInFlight } from "./resolve.js";
14
+ import {
15
+ _clearCurrentResolve,
16
+ _consumePendingSwitchCancellation,
17
+ _setCurrentResolve,
18
+ _setSessionSwitchInFlight,
19
+ } from "./resolve.js";
15
20
  import {
16
21
  getCurrentTurnGeneration,
17
22
  runWithTurnGeneration,
@@ -97,6 +102,7 @@ export async function runUnit(
97
102
  sessionResult = await Promise.race([sessionPromise, timeoutPromise]);
98
103
  } catch (sessionErr) {
99
104
  if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle);
105
+ _consumePendingSwitchCancellation();
100
106
  const msg =
101
107
  sessionErr instanceof Error ? sessionErr.message : String(sessionErr);
102
108
  debugLog("runUnit", {
@@ -110,17 +116,20 @@ export async function runUnit(
110
116
  if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle);
111
117
 
112
118
  if (sessionResult.cancelled) {
119
+ _consumePendingSwitchCancellation();
113
120
  debugLog("runUnit-session-timeout", { unitType, unitId });
114
121
  return { status: "cancelled", errorContext: { message: "Session creation timed out", category: "timeout", isTransient: true } };
115
122
  }
116
123
 
117
124
  if (!s.active) {
125
+ _consumePendingSwitchCancellation();
118
126
  return { status: "cancelled" };
119
127
  }
120
128
 
121
129
  if (s.currentUnitModel && typeof pi.setModel === "function") {
122
130
  const restored = await pi.setModel(s.currentUnitModel, { persist: false });
123
131
  if (!restored) {
132
+ _consumePendingSwitchCancellation();
124
133
  const message =
125
134
  `Failed to restore configured model ${s.currentUnitModel.provider}/${s.currentUnitModel.id} after session creation`;
126
135
  ctx.ui.notify(
@@ -145,6 +154,14 @@ export async function runUnit(
145
154
  const unitPromise = new Promise<UnitResult>((resolve) => {
146
155
  _setCurrentResolve(resolve);
147
156
  });
157
+ const pendingSwitchCancellation = _consumePendingSwitchCancellation();
158
+ if (pendingSwitchCancellation) {
159
+ _clearCurrentResolve();
160
+ return {
161
+ status: "cancelled",
162
+ ...(pendingSwitchCancellation.errorContext ? { errorContext: pendingSwitchCancellation.errorContext } : {}),
163
+ };
164
+ }
148
165
 
149
166
  // ── Provider request-readiness pre-check (#4555) ──
150
167
  // Verify the provider can accept requests before dispatching. If the token
@@ -67,6 +67,60 @@ export {
67
67
 
68
68
  // ─── Artifact Resolution & Verification ───────────────────────────────────────
69
69
 
70
+ export type ArtifactRecoveryDbRefreshResult =
71
+ | { ok: true }
72
+ | { ok: false; fatal: boolean; message: string; reason: string };
73
+
74
+ export function refreshRecoveryDbForArtifact(
75
+ unitType: string,
76
+ unitId: string,
77
+ ): ArtifactRecoveryDbRefreshResult {
78
+ if (unitType !== "plan-slice" && unitType !== "execute-task") return { ok: true };
79
+ if (!isDbAvailable()) return { ok: true };
80
+
81
+ if (!refreshOpenDatabaseFromDisk()) {
82
+ return {
83
+ ok: false,
84
+ fatal: unitType === "execute-task",
85
+ reason: `${unitType}-db-refresh-failed`,
86
+ message: `Stuck recovery found ${unitType} ${unitId} artifacts, but the DB refresh failed.`,
87
+ };
88
+ }
89
+
90
+ if (unitType !== "execute-task") return { ok: true };
91
+
92
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
93
+ if (!mid || !sid || !tid) {
94
+ return {
95
+ ok: false,
96
+ fatal: true,
97
+ reason: "execute-task-invalid-unit-id",
98
+ message: `Stuck recovery found execute-task ${unitId} artifacts, but the unit id could not be parsed for DB verification.`,
99
+ };
100
+ }
101
+
102
+ const task = getTask(mid, sid, tid);
103
+ if (!task) {
104
+ return {
105
+ ok: false,
106
+ fatal: true,
107
+ reason: "execute-task-artifact-db-missing",
108
+ message: `Stuck recovery found execute-task ${unitId} artifacts, but no matching DB task row exists after refresh.`,
109
+ };
110
+ }
111
+
112
+ if (!isClosedStatus(task.status)) {
113
+ return {
114
+ ok: false,
115
+ fatal: true,
116
+ reason: "execute-task-artifact-db-mismatch",
117
+ message: `Stuck recovery found execute-task ${unitId} artifacts, but the DB task status is still '${task.status}' after refresh.`,
118
+ };
119
+ }
120
+
121
+ return { ok: true };
122
+ }
123
+
70
124
  function hasCapturedWorkflowPrefs(base: string): boolean {
71
125
  const prefsPath = resolveExpectedArtifactPath("workflow-preferences", "WORKFLOW-PREFS", base);
72
126
  if (!prefsPath || !existsSync(prefsPath)) return false;
@@ -32,6 +32,7 @@ let _currentSigtermHandler: (() => void) | null = null;
32
32
  export function registerSigtermHandler(
33
33
  currentBasePath: string,
34
34
  previousHandler: (() => void) | null,
35
+ onSignalCleanup?: () => void,
35
36
  ): () => void {
36
37
  // Remove the explicitly-passed previous handler
37
38
  if (previousHandler) {
@@ -43,6 +44,12 @@ export function registerSigtermHandler(
43
44
  for (const sig of CLEANUP_SIGNALS) process.off(sig, _currentSigtermHandler);
44
45
  }
45
46
  const handler = () => {
47
+ try {
48
+ onSignalCleanup?.();
49
+ } catch {
50
+ void 0;
51
+ // Signal cleanup is best-effort; lock cleanup and process exit still run.
52
+ }
46
53
  clearLock(currentBasePath);
47
54
  releaseSessionLock(currentBasePath);
48
55
  process.exit(0);
@@ -71,14 +71,14 @@ export async function recoverTimedOutUnit(
71
71
  recovery: status,
72
72
  });
73
73
 
74
- const durableComplete = status.summaryExists && status.taskChecked && status.nextActionAdvanced;
74
+ const durableComplete = status.dbComplete || (status.summaryExists && status.taskChecked && status.nextActionAdvanced);
75
75
  if (durableComplete) {
76
76
  writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
77
77
  phase: "finalized",
78
78
  recovery: status,
79
79
  });
80
80
  ctx.ui.notify(
81
- `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode. (attempt ${attemptNumber})`,
81
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed. Continuing auto-mode. (attempt ${attemptNumber})`,
82
82
  "info",
83
83
  );
84
84
  unitRecoveryCount.delete(recoveryKey);
@@ -59,6 +59,7 @@ import {
59
59
  isLockProcessAlive,
60
60
  formatCrashInfo,
61
61
  emitCrashRecoveredUnitEnd,
62
+ emitOpenUnitEndForUnit,
62
63
  } from "./crash-recovery.js";
63
64
  import {
64
65
  acquireSessionLock,
@@ -200,6 +201,8 @@ import {
200
201
  detectWorkingTreeActivity,
201
202
  } from "./auto-supervisor.js";
202
203
  import { isDbAvailable, getMilestone } from "./gsd-db.js";
204
+ import { markLatestActiveForWorkerCanceled } from "./db/unit-dispatches.js";
205
+ import { writeUnitRuntimeRecord } from "./unit-runtime.js";
203
206
  import { countPendingCaptures } from "./captures.js";
204
207
  import { CMUX_CHANNELS, type CmuxLogLevel } from "../shared/cmux-events.js";
205
208
  import { ensureDbOpen } from "./bootstrap/dynamic-tools.js";
@@ -242,6 +245,20 @@ import {
242
245
  type WorktreeResolverDeps,
243
246
  } from "./worktree-resolver.js";
244
247
  import { reorderForCaching } from "./prompt-ordering.js";
248
+ import { initTokenCounter } from "./token-counter.js";
249
+
250
+ // Warm the tiktoken encoder at extension startup so context-budget computations
251
+ // can use accurate token counts via countTokensSync without paying the load
252
+ // cost mid-prompt-build. Fire-and-forget — failure falls back to the
253
+ // provider-aware char-ratio estimator already used by getCharsPerToken().
254
+ // Catch rejections explicitly: an unhandled rejection at module-import time
255
+ // can destabilize startup before the engine logger is configured.
256
+ void initTokenCounter().catch((err) => {
257
+ logWarning(
258
+ "engine",
259
+ `token counter warm-up failed: ${err instanceof Error ? err.message : String(err)}`,
260
+ );
261
+ });
245
262
 
246
263
  // ─── Session State ─────────────────────────────────────────────────────────
247
264
 
@@ -505,9 +522,54 @@ export {
505
522
  getBudgetEnforcementAction,
506
523
  } from "./auto-budget.js";
507
524
 
525
+ function closeOutSignalInterruptedUnit(currentBasePath: string): void {
526
+ const currentUnit = s.currentUnit;
527
+ if (!currentUnit) return;
528
+
529
+ const reason = "Auto-mode process received a termination signal";
530
+ const errorContext: ErrorContext = {
531
+ message: reason,
532
+ category: "aborted",
533
+ isTransient: false,
534
+ };
535
+ const basePath = s.basePath || currentBasePath;
536
+
537
+ try {
538
+ emitOpenUnitEndForUnit(basePath, currentUnit.type, currentUnit.id, "cancelled", errorContext);
539
+ } catch (err) {
540
+ logWarning("engine", `signal unit-end cleanup failed: ${getErrorMessage(err)}`, { file: "auto.ts" });
541
+ }
542
+
543
+ try {
544
+ writeUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, {
545
+ phase: "crashed",
546
+ lastProgressAt: Date.now(),
547
+ lastProgressKind: "signal",
548
+ });
549
+ } catch (err) {
550
+ logWarning("engine", `signal runtime cleanup failed: ${getErrorMessage(err)}`, { file: "auto.ts" });
551
+ }
552
+
553
+ try {
554
+ if (s.workerId) markLatestActiveForWorkerCanceled(s.workerId, "signal-exit");
555
+ } catch (err) {
556
+ logWarning("engine", `signal dispatch cleanup failed: ${getErrorMessage(err)}`, { file: "auto.ts" });
557
+ }
558
+
559
+ try {
560
+ resolveAgentEndCancelled(errorContext);
561
+ } catch (err) {
562
+ logWarning("engine", `signal resolve cleanup failed: ${getErrorMessage(err)}`, { file: "auto.ts" });
563
+ }
564
+ }
565
+
508
566
  /** Wrapper: register SIGTERM handler and store reference. */
509
567
  function registerSigtermHandler(currentBasePath: string): void {
510
- s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler);
568
+ s.sigtermHandler = _registerSigtermHandler(
569
+ currentBasePath,
570
+ s.sigtermHandler,
571
+ () => closeOutSignalInterruptedUnit(currentBasePath),
572
+ );
511
573
  }
512
574
 
513
575
  /** Wrapper: deregister SIGTERM handler and clear reference. */
@@ -972,6 +1034,8 @@ export async function stopAuto(
972
1034
  if (s.workerId) {
973
1035
  markWorkerStopping(s.workerId);
974
1036
  }
1037
+ s.workerId = null;
1038
+ s.milestoneLeaseToken = null;
975
1039
  } catch (e) {
976
1040
  debugLog("stop-cleanup-coordination", { error: e instanceof Error ? e.message : String(e) });
977
1041
  }
@@ -1975,6 +2039,10 @@ export async function startAuto(
1975
2039
  : new URL("../../../resource-loader.js", import.meta.url).href;
1976
2040
  const { initResources } = await import(resourceLoaderPath);
1977
2041
  initResources(agentDir);
2042
+ // initResources() uses synchronous fs APIs, so the prompt-template cache
2043
+ // can be primed immediately — no need for the legacy 1s setTimeout deferral.
2044
+ const { primeCache } = await import("./prompt-loader.js");
2045
+ primeCache();
1978
2046
  // Open the project DB before rebuild/derive so resume uses DB-backed
1979
2047
  // state instead of falling back to stale markdown parsing (#2940).
1980
2048
  await openProjectDbIfPresent(s.basePath);
@@ -153,9 +153,29 @@ export async function handleAgentEnd(
153
153
  if (maybeHandleEmptyIntentTurn(event, isAutoActive())) return;
154
154
 
155
155
  if (!isAutoActive()) return;
156
- if (isSessionSwitchInFlight()) return;
157
156
 
158
157
  const lastMsg = event.messages[event.messages.length - 1];
158
+ if (isSessionSwitchInFlight()) {
159
+ if (lastMsg && "stopReason" in lastMsg && lastMsg.stopReason === "error") {
160
+ const rawErrorMsg = ("errorMessage" in lastMsg && lastMsg.errorMessage) ? String(lastMsg.errorMessage) : "";
161
+ if (isUserInitiatedAbortMessage(rawErrorMsg)) {
162
+ resolveAgentEndCancelled({
163
+ message: rawErrorMsg,
164
+ category: "aborted",
165
+ isTransient: false,
166
+ });
167
+ }
168
+ } else if (lastMsg && "stopReason" in lastMsg && lastMsg.stopReason === "aborted") {
169
+ const content = "content" in lastMsg ? lastMsg.content : undefined;
170
+ const hasEmptyContent = Array.isArray(content) && content.length === 0;
171
+ const hasErrorMessage = "errorMessage" in lastMsg && !!lastMsg.errorMessage;
172
+ if (!hasEmptyContent || hasErrorMessage) {
173
+ resolveAgentEndCancelled(_buildAbortedPauseContext(lastMsg as { errorMessage?: unknown }));
174
+ }
175
+ }
176
+ return;
177
+ }
178
+
159
179
  if (lastMsg && "stopReason" in lastMsg && lastMsg.stopReason === "aborted") {
160
180
  // Empty content with aborted stopReason is a non-fatal agent stop (the LLM
161
181
  // chose to end without producing output). Only pause on genuine fatal aborts
@@ -8,7 +8,12 @@
8
8
  * @see D001 (module location), D002 (200K fallback), D003 (section-boundary truncation)
9
9
  */
10
10
 
11
- import { type TokenProvider, getCharsPerToken } from "./token-counter.js";
11
+ import {
12
+ type TokenProvider,
13
+ getCharsPerToken,
14
+ isAccurateCountingAvailable,
15
+ countTokensSync,
16
+ } from "./token-counter.js";
12
17
 
13
18
  // ─── Budget ratio constants ──────────────────────────────────────────────────
14
19
  // Percentages of total context window allocated to each budget category.
@@ -32,6 +37,24 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
32
37
  /** Conservative effective context for Claude Code subscription routing (#4676) */
33
38
  const CLAUDE_CODE_EFFECTIVE_CONTEXT_WINDOW = 200_000;
34
39
 
40
+ /**
41
+ * Cached empirical chars-per-token from a tiktoken probe, keyed by provider.
42
+ * countTokensSync's fallback path is provider-aware, so we cache per-provider
43
+ * to preserve that distinction once the encoder warms. The cl100k_base encoder
44
+ * itself gives a stable ratio for ASCII English so a single probe per provider
45
+ * key is sufficient. Empty map means "not yet probed" or "encoder unavailable".
46
+ */
47
+ const _empiricalCharsPerTokenByProvider = new Map<string, number>();
48
+
49
+ /**
50
+ * Test hook — clears the empirical chars-per-token cache so test cases that
51
+ * assert against the static char-ratio fallback aren't polluted by a prior
52
+ * tiktoken-warmed run in the same process. Production code must not call this.
53
+ */
54
+ export function _resetEmpiricalCacheForTest(): void {
55
+ _empiricalCharsPerTokenByProvider.clear();
56
+ }
57
+
35
58
  /** Percentage of context consumed before suggesting a continue-here checkpoint */
36
59
  const CONTINUE_THRESHOLD_PERCENT = 70;
37
60
 
@@ -101,7 +124,26 @@ export interface MinimalPreferences {
101
124
  export function computeBudgets(contextWindow: number, provider?: TokenProvider): BudgetAllocation {
102
125
  const effectiveWindow = contextWindow > 0 ? contextWindow : DEFAULT_CONTEXT_WINDOW;
103
126
  const charsPerToken = provider ? getCharsPerToken(provider) : CHARS_PER_TOKEN;
104
- const totalChars = effectiveWindow * charsPerToken;
127
+
128
+ // Prefer the tiktoken encoder for total-char estimation when it has been
129
+ // warmed (initTokenCounter resolved). The cl100k_base ratio is stable for
130
+ // ASCII English, so probe once per provider and cache — computeBudgets is
131
+ // called multiple times per prompt build and the probe encode is otherwise
132
+ // wasted work.
133
+ let totalChars: number;
134
+ if (isAccurateCountingAvailable()) {
135
+ const providerKey = provider ?? "__default__";
136
+ let empirical = _empiricalCharsPerTokenByProvider.get(providerKey);
137
+ if (empirical === undefined) {
138
+ const probe = "the quick brown fox jumps over the lazy dog ".repeat(64);
139
+ const probeTokens = countTokensSync(probe, provider);
140
+ empirical = probeTokens > 0 ? probe.length / probeTokens : charsPerToken;
141
+ _empiricalCharsPerTokenByProvider.set(providerKey, empirical);
142
+ }
143
+ totalChars = effectiveWindow * empirical;
144
+ } else {
145
+ totalChars = effectiveWindow * charsPerToken;
146
+ }
105
147
 
106
148
  return {
107
149
  summaryBudgetChars: Math.floor(totalChars * SUMMARY_RATIO),
@@ -359,6 +359,47 @@ export function markCanceled(dispatchId: number, reason: string): void {
359
359
  ).run({ ":id": dispatchId, ":ended_at": now, ":reason": reason });
360
360
  }
361
361
 
362
+ /**
363
+ * Best-effort signal/crash cleanup: cancel the latest active dispatch owned by
364
+ * a worker when the process is exiting before the normal loop can settle it.
365
+ */
366
+ export function markLatestActiveForWorkerCanceled(workerId: string, reason: string): boolean {
367
+ if (!isDbAvailable()) return false;
368
+ const now = new Date().toISOString();
369
+ const db = _getAdapter()!;
370
+ const result = transaction(() => {
371
+ return db.prepare(
372
+ `UPDATE unit_dispatches
373
+ SET status = 'canceled', ended_at = :ended_at, exit_reason = :reason
374
+ WHERE id = (
375
+ SELECT id FROM unit_dispatches
376
+ WHERE worker_id = :worker_id
377
+ AND status IN ('pending','claimed','running')
378
+ ORDER BY id DESC
379
+ LIMIT 1
380
+ )`,
381
+ ).run({
382
+ ":ended_at": now,
383
+ ":reason": reason,
384
+ ":worker_id": workerId,
385
+ });
386
+ });
387
+ const changes =
388
+ typeof (result as { changes?: unknown }).changes === "number"
389
+ ? (result as { changes: number }).changes
390
+ : 0;
391
+ if (changes <= 0) return false;
392
+ insertAuditEvent({
393
+ eventId: randomUUID(),
394
+ traceId: workerId,
395
+ category: "orchestration",
396
+ type: "dispatch-canceled",
397
+ ts: now,
398
+ payload: { workerId, reason },
399
+ });
400
+ return true;
401
+ }
402
+
362
403
  /**
363
404
  * Fetch the most recent N dispatches for a unit. Used by recordDispatchClaim
364
405
  * callers to compute attempt_n and by detect-stuck.ts (B3) to consult
@@ -57,7 +57,8 @@ export function createBaseSchemaObjects(db: DbAdapter, hooks: BaseSchemaHooks):
57
57
  slice_id TEXT DEFAULT NULL,
58
58
  task_id TEXT DEFAULT NULL,
59
59
  full_content TEXT NOT NULL DEFAULT '',
60
- imported_at TEXT NOT NULL DEFAULT ''
60
+ imported_at TEXT NOT NULL DEFAULT '',
61
+ content_hash TEXT DEFAULT NULL
61
62
  )
62
63
  `);
63
64
 
@@ -76,7 +77,8 @@ export function createBaseSchemaObjects(db: DbAdapter, hooks: BaseSchemaHooks):
76
77
  hit_count INTEGER NOT NULL DEFAULT 0,
77
78
  scope TEXT NOT NULL DEFAULT 'project',
78
79
  tags TEXT NOT NULL DEFAULT '[]',
79
- structured_fields TEXT DEFAULT NULL
80
+ structured_fields TEXT DEFAULT NULL,
81
+ last_hit_at TEXT DEFAULT NULL
80
82
  )
81
83
  `);
82
84
 
@@ -416,6 +416,14 @@ export function applyMigrationV26MilestoneCommitAttributions(db: DbAdapter): voi
416
416
  db.exec("CREATE INDEX IF NOT EXISTS idx_milestone_commit_attr_milestone ON milestone_commit_attributions(milestone_id)");
417
417
  }
418
418
 
419
+ export function applyMigrationV27ArtifactHash(db: DbAdapter): void {
420
+ ensureColumn(db, "artifacts", "content_hash", "ALTER TABLE artifacts ADD COLUMN content_hash TEXT DEFAULT NULL");
421
+ }
422
+
423
+ export function applyMigrationV28MemoryLastHitAt(db: DbAdapter): void {
424
+ ensureColumn(db, "memories", "last_hit_at", "ALTER TABLE memories ADD COLUMN last_hit_at TEXT DEFAULT NULL");
425
+ }
426
+
419
427
  export interface MigrationV22Hooks {
420
428
  copyQualityGateRowsToRepairedTable(db: DbAdapter): void;
421
429
  }