gsd-pi 2.73.0-dev.1cfd50c → 2.73.0-dev.27730dc

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 (71) hide show
  1. package/dist/cli.js +0 -47
  2. package/dist/help-text.js +1 -1
  3. package/dist/resources/extensions/gsd/auto-dispatch.js +5 -3
  4. package/dist/resources/extensions/gsd/auto-prompts.js +9 -6
  5. package/dist/resources/extensions/gsd/auto.js +5 -1
  6. package/dist/resources/extensions/gsd/bootstrap/crash-log.js +31 -0
  7. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +18 -7
  8. package/dist/resources/extensions/gsd/bootstrap/system-context.js +6 -1
  9. package/dist/resources/extensions/gsd/crash-recovery.js +51 -0
  10. package/dist/resources/extensions/gsd/gsd-db.js +36 -2
  11. package/dist/resources/extensions/gsd/milestone-actions.js +19 -1
  12. package/dist/startup-model-validation.js +8 -5
  13. package/dist/web/standalone/.next/BUILD_ID +1 -1
  14. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  15. package/dist/web/standalone/.next/build-manifest.json +2 -2
  16. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  17. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.html +1 -1
  34. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  41. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  42. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  43. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  44. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  45. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  46. package/package.json +1 -1
  47. package/packages/pi-coding-agent/dist/core/auth-storage.js +1 -1
  48. package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/core/auth-storage.test.js +27 -0
  50. package/packages/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -1
  51. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/core/model-resolver.js +25 -68
  53. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  54. package/packages/pi-coding-agent/src/core/auth-storage.test.ts +38 -0
  55. package/packages/pi-coding-agent/src/core/auth-storage.ts +1 -1
  56. package/packages/pi-coding-agent/src/core/model-resolver.ts +26 -70
  57. package/src/resources/extensions/gsd/auto-dispatch.ts +5 -0
  58. package/src/resources/extensions/gsd/auto-prompts.ts +9 -3
  59. package/src/resources/extensions/gsd/auto.ts +5 -0
  60. package/src/resources/extensions/gsd/bootstrap/crash-log.ts +32 -0
  61. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +19 -7
  62. package/src/resources/extensions/gsd/bootstrap/system-context.ts +8 -1
  63. package/src/resources/extensions/gsd/crash-recovery.ts +59 -0
  64. package/src/resources/extensions/gsd/gsd-db.ts +52 -2
  65. package/src/resources/extensions/gsd/milestone-actions.ts +19 -1
  66. package/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts +235 -0
  67. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +59 -1
  68. package/src/resources/extensions/gsd/tests/park-milestone.test.ts +64 -0
  69. package/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts +267 -0
  70. /package/dist/web/standalone/.next/static/{uNGVqSkAnszMl0okA4nnp → jNiH700EcljeLnbQ2_RCv}/_buildManifest.js +0 -0
  71. /package/dist/web/standalone/.next/static/{uNGVqSkAnszMl0okA4nnp → jNiH700EcljeLnbQ2_RCv}/_ssgManifest.js +0 -0
@@ -3,45 +3,13 @@
3
3
  */
4
4
 
5
5
  import type { ThinkingLevel } from "@gsd/pi-agent-core";
6
- import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@gsd/pi-ai";
6
+ import { type Api, type Model, modelsAreEqual } from "@gsd/pi-ai";
7
7
  import chalk from "chalk";
8
8
  import { minimatch } from "minimatch";
9
9
  import { isValidThinkingLevel } from "../cli/args.js";
10
10
  import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
11
11
  import type { ModelRegistry } from "./model-registry.js";
12
12
 
13
- /** Default model IDs for each known provider */
14
- const defaultModelPerProvider: Record<KnownProvider, string> = {
15
- "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
16
- anthropic: "claude-opus-4-6",
17
- "anthropic-vertex": "claude-sonnet-4-6",
18
- openai: "gpt-5.4",
19
- "azure-openai-responses": "gpt-5.2",
20
- "openai-codex": "gpt-5.4",
21
- google: "gemini-2.5-pro",
22
- "google-gemini-cli": "gemini-2.5-pro",
23
- "google-antigravity": "gemini-3.1-pro-high",
24
- "google-vertex": "gemini-3-pro-preview",
25
- "github-copilot": "gpt-4o",
26
- openrouter: "openai/gpt-5.1-codex",
27
- "vercel-ai-gateway": "anthropic/claude-opus-4-6",
28
- xai: "grok-4-fast-non-reasoning",
29
- groq: "openai/gpt-oss-120b",
30
- cerebras: "zai-glm-4.6",
31
- zai: "glm-4.6",
32
- mistral: "devstral-medium-latest",
33
- minimax: "MiniMax-M2.1",
34
- "minimax-cn": "MiniMax-M2.1",
35
- huggingface: "moonshotai/Kimi-K2.5",
36
- opencode: "claude-opus-4-6",
37
- "opencode-go": "kimi-k2.5",
38
- "kimi-coding": "kimi-k2-thinking",
39
- "alibaba-coding-plan": "qwen3.5-plus",
40
- "alibaba-dashscope": "qwen3.5-plus",
41
- ollama: "llama3.1:8b",
42
- "ollama-cloud": "qwen3:32b",
43
- };
44
-
45
13
  export interface ScopedModel {
46
14
  model: Model<Api>;
47
15
  /** Thinking level if explicitly specified in pattern (e.g., "model:high"), undefined otherwise */
@@ -123,10 +91,11 @@ function buildFallbackModel(provider: string, modelId: string, availableModels:
123
91
  const providerModels = availableModels.filter((m) => m.provider === provider);
124
92
  if (providerModels.length === 0) return undefined;
125
93
 
126
- const defaultId = defaultModelPerProvider[provider as KnownProvider];
127
- const baseModel = defaultId
128
- ? (providerModels.find((m) => m.id === defaultId) ?? providerModels[0])
129
- : providerModels[0];
94
+ // Use the first available model from this provider as a template for
95
+ // capabilities (context window, reasoning support, etc.). The user is
96
+ // explicitly providing a custom model id, so we just need any shape of
97
+ // model from the same provider to inherit from.
98
+ const baseModel = providerModels[0];
130
99
 
131
100
  return {
132
101
  ...baseModel,
@@ -503,33 +472,19 @@ export async function findInitialModel(options: {
503
472
  };
504
473
  }
505
474
 
506
- // 3. Try saved default from settings
507
- if (defaultProvider && defaultModelId) {
508
- // Guard against stale settings defaults: only use the saved provider/model
509
- // if the provider is actually request-ready (auth/OAuth/CLI ready).
510
- if (modelRegistry.isProviderRequestReady(defaultProvider)) {
511
- const found = modelRegistry.find(defaultProvider, defaultModelId);
512
- if (found) {
513
- // Check if the provider's recommended default is a higher-capability variant
514
- // of the saved model (e.g. saved "claude-opus-4-6" vs recommended "claude-opus-4-6-extended").
515
- // If so, prefer the recommended variant to avoid using a smaller context window (#1125).
516
- const recommendedId = defaultModelPerProvider[defaultProvider as KnownProvider];
517
- if (recommendedId && recommendedId !== defaultModelId && recommendedId.startsWith(defaultModelId)) {
518
- const recommended = modelRegistry.find(defaultProvider, recommendedId);
519
- if (recommended) {
520
- model = recommended;
521
- if (defaultThinkingLevel) {
522
- thinkingLevel = defaultThinkingLevel;
523
- }
524
- return { model, thinkingLevel, fallbackMessage: undefined };
525
- }
526
- }
527
- model = found;
528
- if (defaultThinkingLevel) {
529
- thinkingLevel = defaultThinkingLevel;
530
- }
531
- return { model, thinkingLevel, fallbackMessage: undefined };
475
+ // 3. Try saved default from settings — use it exactly as configured.
476
+ // Whatever the user chose is what gets used; no silent substitution.
477
+ // Skip the saved default if its provider is not request-ready (no auth
478
+ // available) so we fall through to an actually-usable model instead of
479
+ // returning a stale selection every selector surface would display.
480
+ if (defaultProvider && defaultModelId && modelRegistry.isProviderRequestReady(defaultProvider)) {
481
+ const found = modelRegistry.find(defaultProvider, defaultModelId);
482
+ if (found) {
483
+ model = found;
484
+ if (defaultThinkingLevel) {
485
+ thinkingLevel = defaultThinkingLevel;
532
486
  }
487
+ return { model, thinkingLevel, fallbackMessage: undefined };
533
488
  }
534
489
  }
535
490
 
@@ -537,16 +492,17 @@ export async function findInitialModel(options: {
537
492
  const availableModels = await modelRegistry.getAvailable();
538
493
 
539
494
  if (availableModels.length > 0) {
540
- // Try to find a default model from known providers
541
- for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
542
- const defaultId = defaultModelPerProvider[provider];
543
- const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
544
- if (match) {
545
- return { model: match, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
495
+ // Prefer a model from the user's saved provider if any is still available —
496
+ // provider stickiness, not a hard-coded Anthropic/OpenAI preference.
497
+ if (defaultProvider) {
498
+ const sameProvider = availableModels.find((m) => m.provider === defaultProvider);
499
+ if (sameProvider) {
500
+ return { model: sameProvider, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
546
501
  }
547
502
  }
548
503
 
549
- // If no default found, use first available
504
+ // Otherwise use the first available registry order reflects models.json
505
+ // order, which the user controls.
550
506
  return { model: availableModels[0], thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
551
507
  }
552
508
 
@@ -52,6 +52,7 @@ import {
52
52
  checkNeedsReassessment,
53
53
  checkNeedsRunUat,
54
54
  } from "./auto-prompts.js";
55
+ import { resolveModelWithFallbacksForUnit } from "./preferences-models.js";
55
56
 
56
57
  // ─── Types ────────────────────────────────────────────────────────────────
57
58
 
@@ -423,6 +424,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
423
424
  midTitle,
424
425
  researchReadySlices,
425
426
  basePath,
427
+ resolveModelWithFallbacksForUnit("subagent")?.primary,
426
428
  ),
427
429
  };
428
430
  },
@@ -510,6 +512,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
510
512
  sid,
511
513
  sTitle,
512
514
  basePath,
515
+ resolveModelWithFallbacksForUnit("subagent")?.primary,
513
516
  ),
514
517
  };
515
518
  },
@@ -548,6 +551,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
548
551
  const sid = state.activeSlice.id;
549
552
  const sTitle = state.activeSlice.title;
550
553
  const maxParallel = reactiveConfig.max_parallel ?? 2;
554
+ const subagentModel = reactiveConfig.subagent_model ?? resolveModelWithFallbacksForUnit("subagent")?.primary;
551
555
 
552
556
  // Dry-run mode: max_parallel=1 means graph is derived and logged but
553
557
  // execution remains sequential
@@ -618,6 +622,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
618
622
  sTitle,
619
623
  selected,
620
624
  basePath,
625
+ subagentModel,
621
626
  ),
622
627
  };
623
628
  } catch (err) {
@@ -1926,6 +1926,7 @@ export async function buildReassessRoadmapPrompt(
1926
1926
  export async function buildReactiveExecutePrompt(
1927
1927
  mid: string, midTitle: string, sid: string, sTitle: string,
1928
1928
  readyTaskIds: string[], base: string,
1929
+ subagentModel?: string,
1929
1930
  ): Promise<string> {
1930
1931
  const { loadSliceTaskIO, deriveTaskGraph, graphMetrics } = await import("./reactive-graph.js");
1931
1932
 
@@ -1970,10 +1971,11 @@ export async function buildReactiveExecutePrompt(
1970
1971
  { carryForwardPaths: depPaths },
1971
1972
  );
1972
1973
 
1974
+ const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : "";
1973
1975
  subagentSections.push([
1974
1976
  `### ${tid}: ${tTitle}`,
1975
1977
  "",
1976
- "Use this as the prompt for a `subagent` call:",
1978
+ `Use this as the prompt for a \`subagent\` call${modelSuffix}:`,
1977
1979
  "",
1978
1980
  "```",
1979
1981
  taskPrompt,
@@ -2049,15 +2051,17 @@ export async function buildParallelResearchSlicesPrompt(
2049
2051
  midTitle: string,
2050
2052
  slices: Array<{ id: string; title: string }>,
2051
2053
  basePath: string,
2054
+ subagentModel?: string,
2052
2055
  ): Promise<string> {
2053
2056
  // Build individual research-slice prompts for each slice
2054
2057
  const subagentSections: string[] = [];
2058
+ const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : "";
2055
2059
  for (const slice of slices) {
2056
2060
  const slicePrompt = await buildResearchSlicePrompt(mid, midTitle, slice.id, slice.title, basePath);
2057
2061
  subagentSections.push([
2058
2062
  `### ${slice.id}: ${slice.title}`,
2059
2063
  "",
2060
- "Use this as the prompt for a `subagent` call (agent: `gsd-executor` or the default agent):",
2064
+ `Use this as the prompt for a \`subagent\` call${modelSuffix} (agent: \`gsd-executor\` or the default agent):`,
2061
2065
  "",
2062
2066
  "```",
2063
2067
  slicePrompt,
@@ -2077,6 +2081,7 @@ export async function buildParallelResearchSlicesPrompt(
2077
2081
  export async function buildGateEvaluatePrompt(
2078
2082
  mid: string, midTitle: string, sid: string, sTitle: string,
2079
2083
  base: string,
2084
+ subagentModel?: string,
2080
2085
  ): Promise<string> {
2081
2086
  // Pull only the gates this turn actually owns (Q3/Q4). Filter via the
2082
2087
  // registry so that scope:"slice" gates owned by other turns (Q8) can't
@@ -2128,10 +2133,11 @@ export async function buildGateEvaluatePrompt(
2128
2133
  "- `findings`: detailed markdown findings (or empty if omitted)",
2129
2134
  ].join("\n");
2130
2135
 
2136
+ const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : "";
2131
2137
  subagentSections.push([
2132
2138
  `### ${def.id}: ${def.question}`,
2133
2139
  "",
2134
- "Use this as the prompt for a `subagent` call:",
2140
+ `Use this as the prompt for a \`subagent\` call${modelSuffix}:`,
2135
2141
  "",
2136
2142
  "```",
2137
2143
  subPrompt,
@@ -52,6 +52,7 @@ import {
52
52
  readCrashLock,
53
53
  isLockProcessAlive,
54
54
  formatCrashInfo,
55
+ emitCrashRecoveredUnitEnd,
55
56
  } from "./crash-recovery.js";
56
57
  import {
57
58
  acquireSessionLock,
@@ -1332,6 +1333,10 @@ export async function startAuto(
1332
1333
  }
1333
1334
 
1334
1335
  if (freshStartAssessment.lock) {
1336
+ // Emit a synthetic unit-end for any unit-start that has no closing event.
1337
+ // This closes the journal gap reported in #3348 where the worker wrote side
1338
+ // effects (SUMMARY.md, DB updates) but died before emitting unit-end.
1339
+ emitCrashRecoveredUnitEnd(base, freshStartAssessment.lock);
1335
1340
  clearLock(base);
1336
1341
  }
1337
1342
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * crash-log.ts — Write crash diagnostics to ~/.gsd/crash/<timestamp>.log
3
+ *
4
+ * Zero cross-dependencies: only uses Node.js built-ins so it can be imported
5
+ * safely from uncaughtException / unhandledRejection handlers and from tests
6
+ * without pulling in the full extension dependency tree.
7
+ */
8
+
9
+ import { appendFileSync, mkdirSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ /**
14
+ * Write a crash log to ~/.gsd/crash/<timestamp>.log (or $GSD_HOME/crash/).
15
+ * Never throws — must be safe to call from any error handler.
16
+ */
17
+ export function writeCrashLog(err: Error, source: string): void {
18
+ try {
19
+ const crashDir = join(process.env.GSD_HOME ?? join(homedir(), ".gsd"), "crash");
20
+ mkdirSync(crashDir, { recursive: true });
21
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
22
+ const logPath = join(crashDir, `${ts}.log`);
23
+ const lines = [
24
+ `[gsd] ${source}: ${err.message}`,
25
+ `timestamp: ${new Date().toISOString()}`,
26
+ `pid: ${process.pid}`,
27
+ err.stack ?? "(no stack trace available)",
28
+ "",
29
+ ];
30
+ appendFileSync(logPath, lines.join("\n"));
31
+ } catch { /* never throw from crash handler */ }
32
+ }
@@ -11,6 +11,9 @@ import { registerJournalTools } from "./journal-tools.js";
11
11
  import { registerQueryTools } from "./query-tools.js";
12
12
  import { registerHooks } from "./register-hooks.js";
13
13
  import { registerShortcuts } from "./register-shortcuts.js";
14
+ import { writeCrashLog } from "./crash-log.js";
15
+
16
+ export { writeCrashLog } from "./crash-log.js";
14
17
 
15
18
  export function handleRecoverableExtensionProcessError(err: Error): boolean {
16
19
  if ((err as NodeJS.ErrnoException).code === "EPIPE") {
@@ -33,16 +36,25 @@ export function handleRecoverableExtensionProcessError(err: Error): boolean {
33
36
  function installEpipeGuard(): void {
34
37
  if (!process.listeners("uncaughtException").some((listener) => listener.name === "_gsdEpipeGuard")) {
35
38
  const _gsdEpipeGuard = (err: Error): void => {
36
- if (handleRecoverableExtensionProcessError(err)) {
37
- return;
38
- }
39
- // Log unhandled errors instead of re-throwing throwing inside an
40
- // uncaughtException handler is a fatal double-fault in Node.js (#3163).
41
- process.stderr.write(`[gsd] uncaught extension error (non-fatal): ${err.message}\n`);
42
- if (err.stack) process.stderr.write(`${err.stack}\n`);
39
+ if (handleRecoverableExtensionProcessError(err)) return;
40
+ // Write crash log and exit cleanly for unrecoverable errors.
41
+ // Logging and continuing was the original double-fault fix (#3163), but
42
+ // continuing in an indeterminate state is worse than a clean exit (#3348).
43
+ writeCrashLog(err, "uncaughtException");
44
+ process.exit(1);
43
45
  };
44
46
  process.on("uncaughtException", _gsdEpipeGuard);
45
47
  }
48
+
49
+ if (!process.listeners("unhandledRejection").some((listener) => listener.name === "_gsdRejectionGuard")) {
50
+ const _gsdRejectionGuard = (reason: unknown, _promise: Promise<unknown>): void => {
51
+ const err = reason instanceof Error ? reason : new Error(String(reason));
52
+ if (handleRecoverableExtensionProcessError(err)) return;
53
+ writeCrashLog(err, "unhandledRejection");
54
+ process.exit(1);
55
+ };
56
+ process.on("unhandledRejection", _gsdRejectionGuard);
57
+ }
46
58
  }
47
59
 
48
60
  export function registerGsdExtension(pi: ExtensionAPI): void {
@@ -9,6 +9,7 @@ import { debugTime } from "../debug-logger.js";
9
9
  import { loadPrompt, getTemplatesDir } from "../prompt-loader.js";
10
10
  import { readForensicsMarker } from "../forensics.js";
11
11
  import { resolveAllSkillReferences, renderPreferencesForSystemPrompt, loadEffectiveGSDPreferences } from "../preferences.js";
12
+ import { resolveModelWithFallbacksForUnit } from "../preferences-models.js";
12
13
  import { resolveSkillReference } from "../preferences-skills.js";
13
14
  import { resolveGsdRootFile, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, relSliceFile, relSlicePath, relTaskFile } from "../paths.js";
14
15
  import { ensureCodebaseMapFresh, readCodebaseMap } from "../codebase-generator.js";
@@ -175,7 +176,13 @@ export async function buildBeforeAgentStartResult(
175
176
  const forensicsInjection = !injection ? buildForensicsContextInjection(process.cwd(), event.prompt) : null;
176
177
 
177
178
  const worktreeBlock = buildWorktreeContextBlock();
178
- const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${codebaseBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`;
179
+
180
+ const subagentModelConfig = resolveModelWithFallbacksForUnit("subagent");
181
+ const subagentModelBlock = subagentModelConfig
182
+ ? `\n\n## Subagent Model\n\nWhen spawning subagents via the \`subagent\` tool, always pass \`model: "${subagentModelConfig.primary}"\` in the tool call parameters. Never omit this — always specify it explicitly.`
183
+ : "";
184
+
185
+ const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${codebaseBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}${subagentModelBlock}`;
179
186
 
180
187
  stopContextTimer({
181
188
  systemPromptSize: fullSystem.length,
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  import { gsdRoot } from "./paths.js";
16
16
  import { atomicWriteSync } from "./atomic-write.js";
17
17
  import { effectiveLockFile } from "./session-lock.js";
18
+ import { emitJournalEvent, queryJournal } from "./journal.js";
18
19
 
19
20
  export interface LockData {
20
21
  pid: number;
@@ -118,3 +119,61 @@ export function formatCrashInfo(lock: LockData): string {
118
119
 
119
120
  return lines.join("\n");
120
121
  }
122
+
123
+ /**
124
+ * Emit a synthetic unit-end event for a unit that crashed without emitting its own.
125
+ *
126
+ * Queries the journal to find the most recent unit-start for the crashed unit.
127
+ * If a matching unit-end already exists (e.g. the hard timeout fired), this is a
128
+ * no-op. Called during crash recovery, before clearing the stale lock.
129
+ *
130
+ * Addresses the gap reported in #3348 where `unit-start` was emitted but no
131
+ * `unit-end` followed — side effects landed but the worker died before closeout.
132
+ */
133
+ export function emitCrashRecoveredUnitEnd(basePath: string, lock: LockData): void {
134
+ // Skip bootstrap / starting pseudo-units — they have no meaningful unit-start event.
135
+ if (!lock.unitType || !lock.unitId || lock.unitType === "starting") return;
136
+
137
+ try {
138
+ const all = queryJournal(basePath);
139
+
140
+ // Find the most recent unit-start for this unitId
141
+ const starts = all.filter(
142
+ (e) => e.eventType === "unit-start" && e.data?.unitId === lock.unitId,
143
+ );
144
+ if (starts.length === 0) return;
145
+
146
+ const lastStart = starts[starts.length - 1];
147
+
148
+ // Check if a unit-end was already emitted (e.g. hard timeout fired after the crash)
149
+ const alreadyClosed = all.some(
150
+ (e) =>
151
+ e.eventType === "unit-end" &&
152
+ e.data?.unitId === lock.unitId &&
153
+ e.causedBy?.flowId === lastStart.flowId &&
154
+ e.causedBy?.seq === lastStart.seq,
155
+ );
156
+ if (alreadyClosed) return;
157
+
158
+ // Find the highest seq in this flow for monotonic ordering
159
+ const maxSeq = all
160
+ .filter((e) => e.flowId === lastStart.flowId)
161
+ .reduce((max, e) => Math.max(max, e.seq), lastStart.seq);
162
+
163
+ emitJournalEvent(basePath, {
164
+ ts: new Date().toISOString(),
165
+ flowId: lastStart.flowId,
166
+ seq: maxSeq + 1,
167
+ eventType: "unit-end",
168
+ data: {
169
+ unitType: lock.unitType,
170
+ unitId: lock.unitId,
171
+ status: "crash-recovered",
172
+ artifactVerified: false,
173
+ },
174
+ causedBy: { flowId: lastStart.flowId, seq: lastStart.seq },
175
+ });
176
+ } catch {
177
+ // Never throw from crash recovery path — journal failure must not block recovery
178
+ }
179
+ }
@@ -1564,6 +1564,23 @@ export interface TaskRow {
1564
1564
  sequence: number;
1565
1565
  }
1566
1566
 
1567
+ function parseTaskArrayColumn(raw: unknown): string[] {
1568
+ if (typeof raw !== "string" || raw.trim() === "") return [];
1569
+
1570
+ try {
1571
+ const parsed = JSON.parse(raw);
1572
+ if (Array.isArray(parsed)) return parsed.map((value) => String(value));
1573
+ if (parsed === null || parsed === undefined || parsed === "") return [];
1574
+ return [String(parsed)];
1575
+ } catch {
1576
+ // Older/corrupt rows may contain comma-separated strings instead of JSON.
1577
+ return raw
1578
+ .split(",")
1579
+ .map((value) => value.trim())
1580
+ .filter(Boolean);
1581
+ }
1582
+ }
1583
+
1567
1584
  function rowToTask(row: Record<string, unknown>): TaskRow {
1568
1585
  const parseTaskArray = (value: unknown): string[] => {
1569
1586
  if (Array.isArray(value)) {
@@ -1603,8 +1620,8 @@ function rowToTask(row: Record<string, unknown>): TaskRow {
1603
1620
  blocker_discovered: (row["blocker_discovered"] as number) === 1,
1604
1621
  deviations: row["deviations"] as string,
1605
1622
  known_issues: row["known_issues"] as string,
1606
- key_files: JSON.parse((row["key_files"] as string) || "[]"),
1607
- key_decisions: JSON.parse((row["key_decisions"] as string) || "[]"),
1623
+ key_files: parseTaskArrayColumn(row["key_files"]),
1624
+ key_decisions: parseTaskArrayColumn(row["key_decisions"]),
1608
1625
  full_summary_md: row["full_summary_md"] as string,
1609
1626
  description: (row["description"] as string) ?? "",
1610
1627
  estimate: (row["estimate"] as string) ?? "",
@@ -2200,6 +2217,39 @@ export function deleteSlice(milestoneId: string, sliceId: string): void {
2200
2217
  });
2201
2218
  }
2202
2219
 
2220
+ export function deleteMilestone(milestoneId: string): void {
2221
+ if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
2222
+ transaction(() => {
2223
+ currentDb!.prepare(
2224
+ `DELETE FROM verification_evidence WHERE milestone_id = :mid`,
2225
+ ).run({ ":mid": milestoneId });
2226
+ currentDb!.prepare(
2227
+ `DELETE FROM quality_gates WHERE milestone_id = :mid`,
2228
+ ).run({ ":mid": milestoneId });
2229
+ currentDb!.prepare(
2230
+ `DELETE FROM tasks WHERE milestone_id = :mid`,
2231
+ ).run({ ":mid": milestoneId });
2232
+ currentDb!.prepare(
2233
+ `DELETE FROM slice_dependencies WHERE milestone_id = :mid`,
2234
+ ).run({ ":mid": milestoneId });
2235
+ currentDb!.prepare(
2236
+ `DELETE FROM slices WHERE milestone_id = :mid`,
2237
+ ).run({ ":mid": milestoneId });
2238
+ currentDb!.prepare(
2239
+ `DELETE FROM replan_history WHERE milestone_id = :mid`,
2240
+ ).run({ ":mid": milestoneId });
2241
+ currentDb!.prepare(
2242
+ `DELETE FROM assessments WHERE milestone_id = :mid`,
2243
+ ).run({ ":mid": milestoneId });
2244
+ currentDb!.prepare(
2245
+ `DELETE FROM artifacts WHERE milestone_id = :mid`,
2246
+ ).run({ ":mid": milestoneId });
2247
+ currentDb!.prepare(
2248
+ `DELETE FROM milestones WHERE id = :mid`,
2249
+ ).run({ ":mid": milestoneId });
2250
+ });
2251
+ }
2252
+
2203
2253
  export function updateSliceFields(milestoneId: string, sliceId: string, fields: {
2204
2254
  title?: string;
2205
2255
  risk?: string;
@@ -20,7 +20,8 @@ import {
20
20
  } from "./paths.js";
21
21
  import { invalidateAllCaches } from "./cache.js";
22
22
  import { loadQueueOrder, saveQueueOrder } from "./queue-order.js";
23
- import { getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
23
+ import { deleteMilestone, getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
24
+ import { removeWorktree } from "./worktree-manager.js";
24
25
  import { logWarning } from "./workflow-logger.js";
25
26
 
26
27
  // ─── Park ──────────────────────────────────────────────────────────────────
@@ -110,6 +111,15 @@ export function discardMilestone(basePath: string, milestoneId: string): boolean
110
111
  const mDir = resolveMilestonePath(basePath, milestoneId);
111
112
  if (!mDir || !existsSync(mDir)) return false;
112
113
 
114
+ try {
115
+ removeWorktree(basePath, milestoneId, {
116
+ branch: `milestone/${milestoneId}`,
117
+ deleteBranch: true,
118
+ });
119
+ } catch (err) {
120
+ logWarning("engine", `discardMilestone worktree cleanup failed for ${milestoneId}: ${(err as Error).message}`);
121
+ }
122
+
113
123
  rmSync(mDir, { recursive: true, force: true });
114
124
 
115
125
  // Prune from queue order if present
@@ -118,6 +128,14 @@ export function discardMilestone(basePath: string, milestoneId: string): boolean
118
128
  saveQueueOrder(basePath, order.filter(id => id !== milestoneId));
119
129
  }
120
130
 
131
+ if (isDbAvailable()) {
132
+ try {
133
+ deleteMilestone(milestoneId);
134
+ } catch (err) {
135
+ logWarning("engine", `discardMilestone DB cleanup failed for ${milestoneId}: ${(err as Error).message}`);
136
+ }
137
+ }
138
+
121
139
  invalidateAllCaches();
122
140
  return true;
123
141
  }