gsd-pi 2.73.0-dev.e1c09f2 → 2.73.1-dev.06e4302

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 (166) hide show
  1. package/dist/cli-web-branch.d.ts +4 -3
  2. package/dist/cli-web-branch.js +10 -7
  3. package/dist/cli.js +99 -206
  4. package/dist/logo.d.ts +1 -1
  5. package/dist/logo.js +1 -1
  6. package/dist/onboarding.js +59 -53
  7. package/dist/resource-loader.js +2 -2
  8. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +68 -4
  9. package/dist/resources/extensions/gsd/auto/phases.js +15 -9
  10. package/dist/resources/extensions/gsd/auto-dispatch.js +11 -3
  11. package/dist/resources/extensions/gsd/auto-model-selection.js +54 -11
  12. package/dist/resources/extensions/gsd/auto-post-unit.js +41 -1
  13. package/dist/resources/extensions/gsd/auto-start.js +23 -6
  14. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +13 -0
  15. package/dist/resources/extensions/gsd/auto-verification.js +88 -3
  16. package/dist/resources/extensions/gsd/auto.js +34 -9
  17. package/dist/resources/extensions/gsd/bootstrap/crash-log.js +31 -0
  18. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +18 -7
  19. package/dist/resources/extensions/gsd/commands-handlers.js +8 -2
  20. package/dist/resources/extensions/gsd/crash-recovery.js +51 -0
  21. package/dist/resources/extensions/gsd/docs/preferences-reference.md +1 -1
  22. package/dist/resources/extensions/gsd/gsd-db.js +36 -2
  23. package/dist/resources/extensions/gsd/milestone-actions.js +19 -1
  24. package/dist/resources/extensions/gsd/notification-widget.js +2 -2
  25. package/dist/resources/extensions/gsd/preferences-models.js +43 -0
  26. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  27. package/dist/resources/extensions/gsd/preferences-validation.js +22 -0
  28. package/dist/resources/extensions/gsd/state.js +61 -14
  29. package/dist/update-check.d.ts +1 -0
  30. package/dist/update-check.js +13 -5
  31. package/dist/update-cmd.js +4 -3
  32. package/dist/web/standalone/.next/BUILD_ID +1 -1
  33. package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
  34. package/dist/web/standalone/.next/build-manifest.json +2 -2
  35. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  36. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.html +1 -1
  53. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
  60. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  61. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  62. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  63. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  64. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  65. package/package.json +1 -2
  66. package/packages/pi-ai/dist/index.d.ts +1 -0
  67. package/packages/pi-ai/dist/index.d.ts.map +1 -1
  68. package/packages/pi-ai/dist/index.js +1 -0
  69. package/packages/pi-ai/dist/index.js.map +1 -1
  70. package/packages/pi-ai/dist/utils/overflow.d.ts.map +1 -1
  71. package/packages/pi-ai/dist/utils/overflow.js +12 -0
  72. package/packages/pi-ai/dist/utils/overflow.js.map +1 -1
  73. package/packages/pi-ai/dist/utils/tests/overflow.test.d.ts +2 -0
  74. package/packages/pi-ai/dist/utils/tests/overflow.test.d.ts.map +1 -0
  75. package/packages/pi-ai/dist/utils/tests/overflow.test.js +50 -0
  76. package/packages/pi-ai/dist/utils/tests/overflow.test.js.map +1 -0
  77. package/packages/pi-ai/src/index.ts +4 -0
  78. package/packages/pi-ai/src/utils/overflow.ts +14 -1
  79. package/packages/pi-ai/src/utils/tests/overflow.test.ts +58 -0
  80. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +313 -8
  81. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  82. package/packages/pi-coding-agent/dist/core/compaction/utils.js +5 -5
  83. package/packages/pi-coding-agent/dist/core/compaction/utils.js.map +1 -1
  84. package/packages/pi-coding-agent/dist/core/compaction-utils.test.d.ts +2 -0
  85. package/packages/pi-coding-agent/dist/core/compaction-utils.test.d.ts.map +1 -0
  86. package/packages/pi-coding-agent/dist/core/compaction-utils.test.js +45 -0
  87. package/packages/pi-coding-agent/dist/core/compaction-utils.test.js.map +1 -0
  88. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +12 -2
  89. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  90. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +61 -28
  91. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  92. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +2 -1
  93. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  94. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +9 -3
  95. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  96. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts +2 -0
  97. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts.map +1 -0
  98. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js +52 -0
  99. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js.map +1 -0
  100. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  101. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +94 -16
  102. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  103. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  104. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +11 -3
  105. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  106. package/packages/pi-coding-agent/package.json +1 -1
  107. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +355 -8
  108. package/packages/pi-coding-agent/src/core/compaction/utils.ts +5 -5
  109. package/packages/pi-coding-agent/src/core/compaction-utils.test.ts +50 -0
  110. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +74 -32
  111. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.test.ts +73 -0
  112. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +9 -3
  113. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +113 -21
  114. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +11 -3
  115. package/packages/pi-tui/dist/__tests__/tui.test.js +60 -1
  116. package/packages/pi-tui/dist/__tests__/tui.test.js.map +1 -1
  117. package/packages/pi-tui/dist/tui.d.ts +8 -0
  118. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  119. package/packages/pi-tui/dist/tui.js +32 -3
  120. package/packages/pi-tui/dist/tui.js.map +1 -1
  121. package/packages/pi-tui/src/__tests__/tui.test.ts +76 -1
  122. package/packages/pi-tui/src/tui.ts +31 -3
  123. package/pkg/package.json +1 -1
  124. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +107 -5
  125. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +111 -2
  126. package/src/resources/extensions/gsd/auto/phases.ts +22 -9
  127. package/src/resources/extensions/gsd/auto-dispatch.ts +10 -4
  128. package/src/resources/extensions/gsd/auto-model-selection.ts +85 -11
  129. package/src/resources/extensions/gsd/auto-post-unit.ts +47 -1
  130. package/src/resources/extensions/gsd/auto-start.ts +30 -6
  131. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +17 -0
  132. package/src/resources/extensions/gsd/auto-verification.ts +98 -3
  133. package/src/resources/extensions/gsd/auto.ts +36 -14
  134. package/src/resources/extensions/gsd/bootstrap/crash-log.ts +32 -0
  135. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +19 -7
  136. package/src/resources/extensions/gsd/commands-handlers.ts +8 -2
  137. package/src/resources/extensions/gsd/crash-recovery.ts +59 -0
  138. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -1
  139. package/src/resources/extensions/gsd/gsd-db.ts +52 -2
  140. package/src/resources/extensions/gsd/milestone-actions.ts +19 -1
  141. package/src/resources/extensions/gsd/notification-widget.ts +2 -2
  142. package/src/resources/extensions/gsd/preferences-models.ts +41 -0
  143. package/src/resources/extensions/gsd/preferences-types.ts +12 -0
  144. package/src/resources/extensions/gsd/preferences-validation.ts +23 -0
  145. package/src/resources/extensions/gsd/state.ts +71 -15
  146. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +2 -2
  147. package/src/resources/extensions/gsd/tests/auto-post-unit-step-message.test.ts +53 -0
  148. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +51 -2
  149. package/src/resources/extensions/gsd/tests/complete-milestone-false-merge.test.ts +142 -0
  150. package/src/resources/extensions/gsd/tests/completed-at-reconcile.test.ts +42 -0
  151. package/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts +235 -0
  152. package/src/resources/extensions/gsd/tests/derive-state-crossval.test.ts +3 -2
  153. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +3 -2
  154. package/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts +68 -8
  155. package/src/resources/extensions/gsd/tests/derive-state.test.ts +3 -3
  156. package/src/resources/extensions/gsd/tests/flat-rate-routing-guard.test.ts +137 -1
  157. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +59 -1
  158. package/src/resources/extensions/gsd/tests/integration/state-machine-edge-cases.test.ts +4 -2
  159. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +91 -2
  160. package/src/resources/extensions/gsd/tests/park-milestone.test.ts +64 -0
  161. package/src/resources/extensions/gsd/tests/preferences.test.ts +47 -0
  162. package/src/resources/extensions/gsd/tests/state-machine-full-walkthrough.test.ts +5 -7
  163. package/src/resources/extensions/gsd/tests/token-profile.test.ts +1 -1
  164. package/src/resources/extensions/gsd/tests/validate-milestone-stuck-guard.test.ts +179 -0
  165. /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → RXD20AQgB9BHSQJ07MDdd}/_buildManifest.js +0 -0
  166. /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → RXD20AQgB9BHSQJ07MDdd}/_ssgManifest.js +0 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Regression tests for #3348 secondary issues — crash handler gaps surfaced after #3696
3
+ *
4
+ * 1. register-extension.ts: writeCrashLog writes to ~/.gsd/crash/ directory
5
+ * 2. register-extension.ts: _gsdRejectionGuard registered for unhandledRejection
6
+ * 3. register-extension.ts: _gsdEpipeGuard exits with code 1 for unrecoverable errors (no log-and-continue)
7
+ * 4. crash-recovery.ts: emitCrashRecoveredUnitEnd closes open unit-start journal entries
8
+ */
9
+
10
+ import { describe, test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { dirname } from 'node:path';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+
22
+ function makeTmpBase(): string {
23
+ const base = join(tmpdir(), `gsd-test-${randomUUID()}`);
24
+ mkdirSync(join(base, '.gsd'), { recursive: true });
25
+ return base;
26
+ }
27
+
28
+ // ─── register-extension source assertions ────────────────────────────────────
29
+
30
+ const registerExtSrc = readFileSync(
31
+ join(__dirname, '..', 'bootstrap', 'register-extension.ts'),
32
+ 'utf-8',
33
+ );
34
+
35
+ describe('register-extension crash handler secondary fixes (#3348)', () => {
36
+ test('writeCrashLog is exported and writes a file to the crash directory', async () => {
37
+ // Dynamic import so GSD_HOME can be pointed at a temp dir without polluting ~/.gsd
38
+ const tmpHome = join(tmpdir(), `gsd-crash-test-${randomUUID()}`);
39
+ const origHome = process.env.GSD_HOME;
40
+ process.env.GSD_HOME = tmpHome;
41
+ try {
42
+ const { writeCrashLog } = await import('../bootstrap/crash-log.ts');
43
+ const err = new Error('test crash from secondary regression test');
44
+ writeCrashLog(err, 'uncaughtException');
45
+
46
+ const crashDir = join(tmpHome, 'crash');
47
+ assert.ok(existsSync(crashDir), 'crash directory should be created');
48
+
49
+ const logs = readdirSync(crashDir).filter((f) => f.endsWith('.log'));
50
+ assert.equal(logs.length, 1, 'exactly one crash log should be written');
51
+
52
+ const content = readFileSync(join(crashDir, logs[0]), 'utf-8');
53
+ assert.ok(content.includes('test crash from secondary regression test'), 'log should contain error message');
54
+ assert.ok(content.includes('uncaughtException'), 'log should identify the source');
55
+ assert.ok(content.includes('pid:'), 'log should include process pid');
56
+ } finally {
57
+ process.env.GSD_HOME = origHome;
58
+ rmSync(tmpHome, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test('_gsdRejectionGuard is registered for unhandledRejection', () => {
63
+ assert.match(
64
+ registerExtSrc,
65
+ /_gsdRejectionGuard/,
66
+ '_gsdRejectionGuard handler should be defined',
67
+ );
68
+ assert.match(
69
+ registerExtSrc,
70
+ /unhandledRejection/,
71
+ 'installEpipeGuard should register an unhandledRejection handler',
72
+ );
73
+ });
74
+
75
+ test('_gsdEpipeGuard calls process.exit(1) for unrecoverable errors, not log-and-continue', () => {
76
+ // The original #3696 fix replaced "throw err" with a log-and-continue.
77
+ // The secondary fix replaces that with writeCrashLog + process.exit(1).
78
+ assert.ok(
79
+ !registerExtSrc.includes('process.stderr.write(`[gsd] uncaught extension error (non-fatal)'),
80
+ '_gsdEpipeGuard should NOT log errors as non-fatal and continue',
81
+ );
82
+ assert.match(
83
+ registerExtSrc,
84
+ /process\.exit\(1\)/,
85
+ '_gsdEpipeGuard should call process.exit(1) for unrecoverable errors',
86
+ );
87
+ });
88
+
89
+ test('writeCrashLog never throws even when directory is unwritable', async () => {
90
+ const { writeCrashLog } = await import('../bootstrap/crash-log.ts');
91
+ const origHome = process.env.GSD_HOME;
92
+ // Point at a path that will fail to mkdir (e.g. a file that exists as non-dir)
93
+ const tmpFile = join(tmpdir(), `gsd-not-a-dir-${randomUUID()}`);
94
+ // Don't create it — mkdirSync with bad path should be caught internally
95
+ process.env.GSD_HOME = join(tmpFile, 'nested', 'deeply');
96
+ try {
97
+ // Should not throw
98
+ assert.doesNotThrow(() => {
99
+ writeCrashLog(new Error('should not throw'), 'test');
100
+ });
101
+ } finally {
102
+ process.env.GSD_HOME = origHome;
103
+ }
104
+ });
105
+ });
106
+
107
+ // ─── emitCrashRecoveredUnitEnd ────────────────────────────────────────────────
108
+
109
+ describe('emitCrashRecoveredUnitEnd (#3348)', () => {
110
+ test('emits synthetic unit-end when unit-start has no matching unit-end', async () => {
111
+ const base = makeTmpBase();
112
+ try {
113
+ const { emitJournalEvent, queryJournal } = await import('../journal.ts');
114
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
115
+
116
+ const flowId = randomUUID();
117
+ const unitStartSeq = 5;
118
+
119
+ // Emit a unit-start with no corresponding unit-end (simulating a crash)
120
+ emitJournalEvent(base, {
121
+ ts: new Date().toISOString(),
122
+ flowId,
123
+ seq: unitStartSeq,
124
+ eventType: 'unit-start',
125
+ data: { unitType: 'execute-task', unitId: 'M001/S01/T01' },
126
+ });
127
+
128
+ const lock = {
129
+ pid: 99999,
130
+ startedAt: new Date().toISOString(),
131
+ unitType: 'execute-task',
132
+ unitId: 'M001/S01/T01',
133
+ unitStartedAt: new Date().toISOString(),
134
+ };
135
+
136
+ emitCrashRecoveredUnitEnd(base, lock);
137
+
138
+ const events = queryJournal(base);
139
+ const ends = events.filter((e) => e.eventType === 'unit-end');
140
+ assert.equal(ends.length, 1, 'should emit exactly one unit-end');
141
+ assert.equal(ends[0].data?.unitId, 'M001/S01/T01');
142
+ assert.equal(ends[0].data?.status, 'crash-recovered');
143
+ assert.equal(ends[0].causedBy?.flowId, flowId);
144
+ assert.equal(ends[0].causedBy?.seq, unitStartSeq);
145
+ assert.ok(ends[0].seq > unitStartSeq, 'unit-end seq must be higher than unit-start seq');
146
+ } finally {
147
+ rmSync(base, { recursive: true, force: true });
148
+ }
149
+ });
150
+
151
+ test('is a no-op when unit-end was already emitted (e.g. hard timeout fired)', async () => {
152
+ const base = makeTmpBase();
153
+ try {
154
+ const { emitJournalEvent, queryJournal } = await import('../journal.ts');
155
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
156
+
157
+ const flowId = randomUUID();
158
+ emitJournalEvent(base, {
159
+ ts: new Date().toISOString(),
160
+ flowId,
161
+ seq: 3,
162
+ eventType: 'unit-start',
163
+ data: { unitType: 'plan-slice', unitId: 'M001/S02' },
164
+ });
165
+ // Hard timeout already emitted a unit-end
166
+ emitJournalEvent(base, {
167
+ ts: new Date().toISOString(),
168
+ flowId,
169
+ seq: 4,
170
+ eventType: 'unit-end',
171
+ data: { unitType: 'plan-slice', unitId: 'M001/S02', status: 'cancelled' },
172
+ causedBy: { flowId, seq: 3 },
173
+ });
174
+
175
+ const lock = {
176
+ pid: 99999,
177
+ startedAt: new Date().toISOString(),
178
+ unitType: 'plan-slice',
179
+ unitId: 'M001/S02',
180
+ unitStartedAt: new Date().toISOString(),
181
+ };
182
+ emitCrashRecoveredUnitEnd(base, lock);
183
+
184
+ const ends = queryJournal(base).filter((e) => e.eventType === 'unit-end');
185
+ assert.equal(ends.length, 1, 'should not emit a duplicate unit-end');
186
+ assert.equal(ends[0].data?.status, 'cancelled', 'original unit-end should be preserved');
187
+ } finally {
188
+ rmSync(base, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ test('is a no-op for "starting" pseudo-units (bootstrap crash)', async () => {
193
+ const base = makeTmpBase();
194
+ try {
195
+ const { queryJournal } = await import('../journal.ts');
196
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
197
+
198
+ const lock = {
199
+ pid: 99999,
200
+ startedAt: new Date().toISOString(),
201
+ unitType: 'starting',
202
+ unitId: 'bootstrap',
203
+ unitStartedAt: new Date().toISOString(),
204
+ };
205
+ emitCrashRecoveredUnitEnd(base, lock);
206
+
207
+ const events = queryJournal(base);
208
+ assert.equal(events.length, 0, 'should emit nothing for starting/bootstrap pseudo-units');
209
+ } finally {
210
+ rmSync(base, { recursive: true, force: true });
211
+ }
212
+ });
213
+
214
+ test('is a no-op when no unit-start exists in the journal', async () => {
215
+ const base = makeTmpBase();
216
+ try {
217
+ const { queryJournal } = await import('../journal.ts');
218
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
219
+
220
+ const lock = {
221
+ pid: 99999,
222
+ startedAt: new Date().toISOString(),
223
+ unitType: 'execute-task',
224
+ unitId: 'M002/S01/T03',
225
+ unitStartedAt: new Date().toISOString(),
226
+ };
227
+ emitCrashRecoveredUnitEnd(base, lock);
228
+
229
+ const events = queryJournal(base);
230
+ assert.equal(events.length, 0, 'should emit nothing when there is no journal entry to close');
231
+ } finally {
232
+ rmSync(base, { recursive: true, force: true });
233
+ }
234
+ });
235
+ });
@@ -351,8 +351,9 @@ skills_used: []
351
351
  const dbState = await deriveStateFromDb(base);
352
352
 
353
353
  assertStatesEqual(dbState, fileState, 'E-blocked');
354
- assert.deepStrictEqual(dbState.phase, 'blocked', 'E-blocked: phase is blocked');
355
- assert.ok(dbState.blockers.length > 0, 'E-blocked: has blockers');
354
+ // With partial-dep fallback, circular deps no longer block — fallback picks first eligible slice
355
+ assert.deepStrictEqual(dbState.phase, 'planning', 'E-blocked: phase is planning (fallback picks a slice)');
356
+ assert.ok(dbState.activeSlice !== null, 'E-blocked: activeSlice is set via fallback');
356
357
 
357
358
  closeDatabase();
358
359
  } finally {
@@ -616,9 +616,10 @@ describe('derive-state-db', async () => {
616
616
  invalidateStateCache();
617
617
  const dbState = await deriveStateFromDb(base);
618
618
 
619
- assert.deepStrictEqual(dbState.phase, 'blocked', 'blocked-db: phase is blocked');
619
+ // With partial-dep fallback, circular deps no longer block — fallback picks first eligible slice
620
+ assert.deepStrictEqual(dbState.phase, 'planning', 'blocked-db: phase is planning (fallback picks a slice)');
620
621
  assert.deepStrictEqual(dbState.phase, fileState.phase, 'blocked-db: phase matches filesystem');
621
- assert.ok(dbState.blockers.length > 0, 'blocked-db: has blockers');
622
+ assert.ok(dbState.activeSlice !== null, 'blocked-db: activeSlice is set via fallback');
622
623
 
623
624
  closeDatabase();
624
625
  } finally {
@@ -307,27 +307,87 @@ describe('derive-state-helpers', () => {
307
307
  }
308
308
  });
309
309
 
310
- // ─── buildCompletenessSet: SUMMARY-on-disk marks complete ───────────
311
- test('buildCompletenessSet: milestone with SUMMARY on disk treated as complete', async () => {
310
+ // ─── buildCompletenessSet: DB status is authoritative ──────────────
311
+ test('buildCompletenessSet: DB status=complete marks milestone complete', async () => {
312
312
  const base = createFixtureBase();
313
313
  try {
314
- // M001 has summary on disk but DB status is still 'active'
315
314
  writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
316
315
  writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.');
317
- // M002 is the real active milestone
318
316
  writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nActive.');
319
317
 
320
318
  openDatabase(':memory:');
321
- insertMilestone({ id: 'M001', title: 'First', status: 'active' });
319
+ insertMilestone({ id: 'M001', title: 'First', status: 'complete' });
322
320
  insertMilestone({ id: 'M002', title: 'Second', status: 'active' });
323
321
 
324
322
  invalidateStateCache();
325
323
  const state = await deriveStateFromDb(base);
326
324
 
327
- // M001 should be complete (summary on disk), M002 should be active
328
325
  const m1 = state.registry.find(e => e.id === 'M001');
329
- assert.equal(m1?.status, 'complete', 'summary-disk: M001 marked complete via disk SUMMARY');
330
- assert.equal(state.activeMilestone?.id, 'M002', 'summary-disk: M002 is active');
326
+ assert.equal(m1?.status, 'complete', 'DB status=complete registry entry complete');
327
+ assert.equal(state.activeMilestone?.id, 'M002', 'M002 is the active milestone');
328
+ } finally {
329
+ closeDatabase();
330
+ cleanup(base);
331
+ }
332
+ });
333
+
334
+ // ─── Regression #4179: orphan SUMMARY must NOT flip DB-active milestone ───
335
+ // A crashed complete-milestone turn (or stale/manual SUMMARY.md) can leave
336
+ // a milestone SUMMARY on disk while the DB row still reads 'active'. The
337
+ // read-side of state derivation must NOT treat the orphan SUMMARY as a
338
+ // completion signal, or the auto-loop advances and merges work that was
339
+ // never actually finished (same failure class as #4175, read-side twin).
340
+ test('buildCompletenessSet (#4179): orphan SUMMARY on disk does not mark DB-active milestone complete', async () => {
341
+ const base = createFixtureBase();
342
+ try {
343
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
344
+ writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Orphan Summary\n\nLeft over from crashed turn.');
345
+
346
+ openDatabase(':memory:');
347
+ insertMilestone({ id: 'M001', title: 'First', status: 'active' });
348
+ // Slice still in-flight — auto should resume, not merge.
349
+ insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
350
+ insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
351
+ insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'In-flight', status: 'pending' });
352
+
353
+ invalidateStateCache();
354
+ const state = await deriveStateFromDb(base);
355
+
356
+ const m1 = state.registry.find(e => e.id === 'M001');
357
+ assert.notEqual(m1?.status, 'complete', 'orphan SUMMARY must not mark milestone complete');
358
+ assert.equal(m1?.status, 'active', 'M001 remains active — DB is authoritative');
359
+ assert.equal(state.activeMilestone?.id, 'M001', 'M001 is still the active milestone');
360
+ assert.notEqual(state.phase, 'completing-milestone', 'must not short-circuit into completion');
361
+ } finally {
362
+ closeDatabase();
363
+ cleanup(base);
364
+ }
365
+ });
366
+
367
+ // Regression #4179 (companion): DB-active milestone with all slices done +
368
+ // validation terminal + orphan SUMMARY must still flow through completing-milestone
369
+ // (re-runs complete-milestone), not be reported as already-complete.
370
+ test('buildRegistryAndFindActive (#4179): orphan SUMMARY with validation-terminal falls through to completing-milestone', async () => {
371
+ const base = createFixtureBase();
372
+ try {
373
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
374
+ writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
375
+ writeFile(base, 'milestones/M001/slices/S02/S02-PLAN.md', PLAN_CONTENT);
376
+ writeFile(base, 'milestones/M001/M001-VALIDATION.md', '---\nverdict: passed\n---\n# Validation\nAll good.');
377
+ writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Orphan Summary\n\nLeft over.');
378
+
379
+ openDatabase(':memory:');
380
+ insertMilestone({ id: 'M001', title: 'First', status: 'active' });
381
+ insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'complete', risk: 'low', depends: [] });
382
+ insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'complete', risk: 'low', depends: ['S01'] });
383
+
384
+ invalidateStateCache();
385
+ const state = await deriveStateFromDb(base);
386
+
387
+ const m1 = state.registry.find(e => e.id === 'M001');
388
+ assert.equal(m1?.status, 'active', 'M001 stays active despite orphan SUMMARY + validation-terminal');
389
+ assert.equal(state.activeMilestone?.id, 'M001', 'M001 is still the active milestone');
390
+ assert.equal(state.phase, 'completing-milestone', 'phase flows through completing-milestone (re-run)');
331
391
  } finally {
332
392
  closeDatabase();
333
393
  cleanup(base);
@@ -446,9 +446,9 @@ Continue from step 2.
446
446
 
447
447
  const state2 = await deriveState(base2);
448
448
 
449
- assert.deepStrictEqual(state2.phase, 'blocked', 'blocked-B: phase is blocked');
450
- assert.deepStrictEqual(state2.activeSlice, null, 'blocked-B: activeSlice is null');
451
- assert.ok(state2.blockers.length > 0, 'blocked-B: blockers array is non-empty');
449
+ // With partial-dep fallback, S01 is picked despite unmet dep on S99
450
+ assert.deepStrictEqual(state2.phase, 'planning', 'blocked-B: phase is planning (fallback picks S01)');
451
+ assert.deepStrictEqual(state2.activeSlice?.id, 'S01', 'blocked-B: activeSlice is S01 via fallback');
452
452
  } finally {
453
453
  cleanup(base2);
454
454
  }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { describe, test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
- import { isFlatRateProvider, resolvePreferredModelConfig } from "../auto-model-selection.ts";
9
+ import { buildFlatRateContext, isFlatRateProvider, resolvePreferredModelConfig } from "../auto-model-selection.ts";
10
10
 
11
11
  describe("flat-rate provider routing guard (#3453)", () => {
12
12
 
@@ -48,3 +48,139 @@ describe("flat-rate provider routing guard (#3453)", () => {
48
48
  assert.equal(result, undefined, "Should not create routing config for copilot");
49
49
  });
50
50
  });
51
+
52
+ describe("flat-rate provider extensibility (any/all/custom)", () => {
53
+ test("regression: built-in providers still flat-rate with no context", () => {
54
+ assert.equal(isFlatRateProvider("github-copilot"), true);
55
+ assert.equal(isFlatRateProvider("copilot"), true);
56
+ assert.equal(isFlatRateProvider("claude-code"), true);
57
+ });
58
+
59
+ test("regression: non-flat-rate API providers return false with no context", () => {
60
+ assert.equal(isFlatRateProvider("anthropic"), false);
61
+ assert.equal(isFlatRateProvider("openai"), false);
62
+ assert.equal(isFlatRateProvider("google-vertex"), false);
63
+ });
64
+
65
+ test("auto-detection: externalCli auth mode marks provider flat-rate", () => {
66
+ // Any provider registered with authMode: "externalCli" is a local
67
+ // CLI wrapper around the user's subscription — every request costs
68
+ // the same regardless of model, so dynamic routing provides no benefit.
69
+ assert.equal(
70
+ isFlatRateProvider("my-private-cli", { authMode: "externalCli" }),
71
+ true,
72
+ );
73
+ });
74
+
75
+ test("auto-detection: non-externalCli auth modes do not mark provider flat-rate", () => {
76
+ assert.equal(
77
+ isFlatRateProvider("my-http-proxy", { authMode: "apiKey" }),
78
+ false,
79
+ );
80
+ assert.equal(
81
+ isFlatRateProvider("my-http-proxy", { authMode: "oauth" }),
82
+ false,
83
+ );
84
+ assert.equal(
85
+ isFlatRateProvider("my-http-proxy", { authMode: "none" }),
86
+ false,
87
+ );
88
+ });
89
+
90
+ test("user preference: custom provider listed in userFlatRate is flat-rate", () => {
91
+ assert.equal(
92
+ isFlatRateProvider("my-ollama-proxy", { userFlatRate: ["my-ollama-proxy"] }),
93
+ true,
94
+ );
95
+ });
96
+
97
+ test("user preference: case-insensitive match against userFlatRate list", () => {
98
+ assert.equal(
99
+ isFlatRateProvider("My-Proxy", { userFlatRate: ["my-proxy"] }),
100
+ true,
101
+ );
102
+ assert.equal(
103
+ isFlatRateProvider("my-proxy", { userFlatRate: ["MY-PROXY"] }),
104
+ true,
105
+ );
106
+ });
107
+
108
+ test("user preference: provider not in userFlatRate list is not flat-rate", () => {
109
+ assert.equal(
110
+ isFlatRateProvider("other-proxy", { userFlatRate: ["my-proxy"] }),
111
+ false,
112
+ );
113
+ });
114
+
115
+ test("combined signals: built-in list wins even when context is empty", () => {
116
+ assert.equal(
117
+ isFlatRateProvider("claude-code", { authMode: "apiKey", userFlatRate: [] }),
118
+ true,
119
+ );
120
+ });
121
+
122
+ test("combined signals: externalCli auto-detection wins alongside userFlatRate miss", () => {
123
+ assert.equal(
124
+ isFlatRateProvider("my-cli", {
125
+ authMode: "externalCli",
126
+ userFlatRate: ["a-different-cli"],
127
+ }),
128
+ true,
129
+ );
130
+ });
131
+ });
132
+
133
+ describe("buildFlatRateContext()", () => {
134
+ test("builds a context from ctx.modelRegistry.getProviderAuthMode + prefs", () => {
135
+ const ctx = {
136
+ modelRegistry: {
137
+ getProviderAuthMode: (p: string) =>
138
+ p === "my-cli" ? "externalCli" : "apiKey",
139
+ },
140
+ };
141
+ const prefs = { flat_rate_providers: ["my-proxy"] };
142
+
143
+ const ctxForCli = buildFlatRateContext("my-cli", ctx, prefs);
144
+ assert.equal(ctxForCli.authMode, "externalCli");
145
+ assert.deepEqual(ctxForCli.userFlatRate, ["my-proxy"]);
146
+ assert.equal(isFlatRateProvider("my-cli", ctxForCli), true);
147
+
148
+ const ctxForProxy = buildFlatRateContext("my-proxy", ctx, prefs);
149
+ assert.equal(ctxForProxy.authMode, "apiKey");
150
+ assert.equal(isFlatRateProvider("my-proxy", ctxForProxy), true);
151
+
152
+ const ctxForOther = buildFlatRateContext("anthropic", ctx, prefs);
153
+ assert.equal(ctxForOther.authMode, "apiKey");
154
+ assert.equal(isFlatRateProvider("anthropic", ctxForOther), false);
155
+ });
156
+
157
+ test("survives missing ctx and missing prefs", () => {
158
+ const empty = buildFlatRateContext("anything");
159
+ assert.equal(empty.authMode, undefined);
160
+ assert.equal(empty.userFlatRate, undefined);
161
+ assert.equal(isFlatRateProvider("anything", empty), false);
162
+ });
163
+
164
+ test("survives a registry lookup that throws", () => {
165
+ const ctx = {
166
+ modelRegistry: {
167
+ getProviderAuthMode: () => {
168
+ throw new Error("registry boom");
169
+ },
170
+ },
171
+ };
172
+ const result = buildFlatRateContext("anything", ctx);
173
+ // Error must be swallowed — authMode left undefined, function returns.
174
+ assert.equal(result.authMode, undefined);
175
+ });
176
+
177
+ test("registry returning a non-canonical auth mode is ignored", () => {
178
+ const ctx = {
179
+ modelRegistry: {
180
+ getProviderAuthMode: () => "weird-mode",
181
+ },
182
+ };
183
+ const result = buildFlatRateContext("anything", ctx);
184
+ assert.equal(result.authMode, undefined);
185
+ });
186
+ });
@@ -15,10 +15,14 @@ import {
15
15
  getRequirementById,
16
16
  getActiveDecisions,
17
17
  getActiveRequirements,
18
- getTask,
19
18
  transaction,
20
19
  _getAdapter,
21
20
  _resetProvider,
21
+ insertMilestone,
22
+ insertSlice,
23
+ insertTask,
24
+ getTask,
25
+ getSliceTasks,
22
26
  } from '../gsd-db.ts';
23
27
 
24
28
  // ═══════════════════════════════════════════════════════════════════════════
@@ -460,6 +464,60 @@ describe('gsd-db', () => {
460
464
  assert.ok(!wasDbOpenAttempted(), 'wasDbOpenAttempted should reset after closeDatabase');
461
465
  });
462
466
 
467
+ test('gsd-db: rowToTask tolerates corrupt comma-separated task arrays', () => {
468
+ openDatabase(':memory:');
469
+ insertMilestone({ id: 'M001', status: 'active' });
470
+ insertSlice({ milestoneId: 'M001', id: 'S01', status: 'active' });
471
+ insertTask({
472
+ milestoneId: 'M001',
473
+ sliceId: 'S01',
474
+ id: 'T01',
475
+ title: 'Recover corrupt arrays',
476
+ planning: {
477
+ description: 'desc',
478
+ estimate: 'small',
479
+ files: ['src/original.ts'],
480
+ verify: 'npm test',
481
+ inputs: ['docs/original.md'],
482
+ expectedOutput: ['dist/original.md'],
483
+ observabilityImpact: '',
484
+ },
485
+ });
486
+
487
+ const adapter = _getAdapter()!;
488
+ adapter.prepare(
489
+ `UPDATE tasks
490
+ SET files = ?, inputs = ?, expected_output = ?, key_files = ?, key_decisions = ?
491
+ WHERE milestone_id = ? AND slice_id = ? AND id = ?`,
492
+ ).run(
493
+ 'src-erf/Models/foo.cs, src-erf/Models/bar.cs',
494
+ 'docs/input-a.md, docs/input-b.md',
495
+ 'dist/out-a.md, dist/out-b.md',
496
+ 'src/resources/extensions/gsd/gsd-db.ts, src/resources/extensions/gsd/state.ts',
497
+ '"decision-1"',
498
+ 'M001',
499
+ 'S01',
500
+ 'T01',
501
+ );
502
+
503
+ const task = getTask('M001', 'S01', 'T01');
504
+ assert.ok(task, 'getTask should still return the corrupt row');
505
+ assert.deepStrictEqual(task!.files, ['src-erf/Models/foo.cs', 'src-erf/Models/bar.cs']);
506
+ assert.deepStrictEqual(task!.inputs, ['docs/input-a.md', 'docs/input-b.md']);
507
+ assert.deepStrictEqual(task!.expected_output, ['dist/out-a.md', 'dist/out-b.md']);
508
+ assert.deepStrictEqual(
509
+ task!.key_files,
510
+ ['src/resources/extensions/gsd/gsd-db.ts', 'src/resources/extensions/gsd/state.ts'],
511
+ );
512
+ assert.deepStrictEqual(task!.key_decisions, ['decision-1']);
513
+
514
+ const sliceTasks = getSliceTasks('M001', 'S01');
515
+ assert.equal(sliceTasks.length, 1, 'getSliceTasks should also survive corrupt rows');
516
+ assert.deepStrictEqual(sliceTasks[0]!.files, task!.files);
517
+
518
+ closeDatabase();
519
+ });
520
+
463
521
  // ─── Final Report ──────────────────────────────────────────────────────────
464
522
 
465
523
  });
@@ -691,7 +691,7 @@ describe("transition boundary failures", () => {
691
691
  );
692
692
  });
693
693
 
694
- test("blocked state: all slices have unmet deps → blocked phase", async () => {
694
+ test("blocked state: all slices have unmet deps → fallback picks slice", async () => {
695
695
  base = makeTempDir();
696
696
  const mDir = join(base, ".gsd", "milestones", "M001");
697
697
  mkdirSync(join(mDir, "slices", "S01", "tasks"), { recursive: true });
@@ -736,7 +736,9 @@ describe("transition boundary failures", () => {
736
736
 
737
737
  invalidateAllCaches();
738
738
  const state = await deriveStateFromDb(base);
739
- assert.equal(state.phase, "blocked", "circular deps should produce blocked phase");
739
+ // With partial-dep fallback, circular deps no longer block — fallback picks first eligible slice
740
+ assert.equal(state.phase, "planning", "circular deps: fallback picks a slice instead of blocking");
741
+ assert.ok(state.activeSlice !== null, "activeSlice set via fallback");
740
742
  });
741
743
  });
742
744