gsd-pi 2.73.0-dev.e1c09f2 → 2.73.1-dev.088d28f
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.
- package/dist/resource-loader.js +2 -2
- package/dist/resources/extensions/claude-code-cli/stream-adapter.js +9 -3
- package/dist/resources/extensions/gsd/auto/phases.js +15 -9
- package/dist/resources/extensions/gsd/auto-dispatch.js +11 -3
- package/dist/resources/extensions/gsd/auto-model-selection.js +54 -11
- package/dist/resources/extensions/gsd/auto-start.js +23 -6
- package/dist/resources/extensions/gsd/auto.js +13 -1
- package/dist/resources/extensions/gsd/bootstrap/crash-log.js +31 -0
- package/dist/resources/extensions/gsd/bootstrap/register-extension.js +18 -7
- package/dist/resources/extensions/gsd/commands-handlers.js +8 -2
- package/dist/resources/extensions/gsd/crash-recovery.js +51 -0
- package/dist/resources/extensions/gsd/docs/preferences-reference.md +1 -1
- package/dist/resources/extensions/gsd/gsd-db.js +36 -2
- package/dist/resources/extensions/gsd/milestone-actions.js +19 -1
- package/dist/resources/extensions/gsd/notification-widget.js +2 -2
- package/dist/resources/extensions/gsd/preferences-models.js +43 -0
- package/dist/resources/extensions/gsd/preferences-types.js +1 -0
- package/dist/resources/extensions/gsd/preferences-validation.js +22 -0
- package/dist/resources/extensions/gsd/state.js +36 -0
- package/dist/update-check.d.ts +1 -0
- package/dist/update-check.js +13 -5
- package/dist/update-cmd.js +4 -3
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/pi-ai/dist/index.d.ts +1 -0
- package/packages/pi-ai/dist/index.d.ts.map +1 -1
- package/packages/pi-ai/dist/index.js +1 -0
- package/packages/pi-ai/dist/index.js.map +1 -1
- package/packages/pi-ai/dist/utils/overflow.d.ts.map +1 -1
- package/packages/pi-ai/dist/utils/overflow.js +12 -0
- package/packages/pi-ai/dist/utils/overflow.js.map +1 -1
- package/packages/pi-ai/dist/utils/tests/overflow.test.d.ts +2 -0
- package/packages/pi-ai/dist/utils/tests/overflow.test.d.ts.map +1 -0
- package/packages/pi-ai/dist/utils/tests/overflow.test.js +50 -0
- package/packages/pi-ai/dist/utils/tests/overflow.test.js.map +1 -0
- package/packages/pi-ai/src/index.ts +4 -0
- package/packages/pi-ai/src/utils/overflow.ts +14 -1
- package/packages/pi-ai/src/utils/tests/overflow.test.ts +58 -0
- package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +313 -8
- package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/compaction/utils.js +5 -5
- package/packages/pi-coding-agent/dist/core/compaction/utils.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/compaction-utils.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/compaction-utils.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/compaction-utils.test.js +45 -0
- package/packages/pi-coding-agent/dist/core/compaction-utils.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +12 -2
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +51 -26
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +2 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +9 -3
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js +52 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +94 -16
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +11 -3
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +355 -8
- package/packages/pi-coding-agent/src/core/compaction/utils.ts +5 -5
- package/packages/pi-coding-agent/src/core/compaction-utils.test.ts +50 -0
- package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +62 -26
- package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.test.ts +73 -0
- package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +9 -3
- package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +113 -21
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +11 -3
- package/packages/pi-tui/dist/__tests__/tui.test.js +60 -1
- package/packages/pi-tui/dist/__tests__/tui.test.js.map +1 -1
- package/packages/pi-tui/dist/tui.d.ts +8 -0
- package/packages/pi-tui/dist/tui.d.ts.map +1 -1
- package/packages/pi-tui/dist/tui.js +32 -3
- package/packages/pi-tui/dist/tui.js.map +1 -1
- package/packages/pi-tui/src/__tests__/tui.test.ts +76 -1
- package/packages/pi-tui/src/tui.ts +31 -3
- package/pkg/package.json +1 -1
- package/src/resources/extensions/claude-code-cli/stream-adapter.ts +12 -4
- package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +23 -2
- package/src/resources/extensions/gsd/auto/phases.ts +22 -9
- package/src/resources/extensions/gsd/auto-dispatch.ts +10 -4
- package/src/resources/extensions/gsd/auto-model-selection.ts +85 -11
- package/src/resources/extensions/gsd/auto-start.ts +30 -6
- package/src/resources/extensions/gsd/auto.ts +10 -0
- package/src/resources/extensions/gsd/bootstrap/crash-log.ts +32 -0
- package/src/resources/extensions/gsd/bootstrap/register-extension.ts +19 -7
- package/src/resources/extensions/gsd/commands-handlers.ts +8 -2
- package/src/resources/extensions/gsd/crash-recovery.ts +59 -0
- package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -1
- package/src/resources/extensions/gsd/gsd-db.ts +52 -2
- package/src/resources/extensions/gsd/milestone-actions.ts +19 -1
- package/src/resources/extensions/gsd/notification-widget.ts +2 -2
- package/src/resources/extensions/gsd/preferences-models.ts +41 -0
- package/src/resources/extensions/gsd/preferences-types.ts +12 -0
- package/src/resources/extensions/gsd/preferences-validation.ts +23 -0
- package/src/resources/extensions/gsd/state.ts +46 -0
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +2 -2
- package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +51 -2
- package/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts +235 -0
- package/src/resources/extensions/gsd/tests/derive-state-crossval.test.ts +3 -2
- package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +3 -2
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +3 -3
- package/src/resources/extensions/gsd/tests/flat-rate-routing-guard.test.ts +137 -1
- package/src/resources/extensions/gsd/tests/gsd-db.test.ts +59 -1
- package/src/resources/extensions/gsd/tests/integration/state-machine-edge-cases.test.ts +4 -2
- package/src/resources/extensions/gsd/tests/model-isolation.test.ts +91 -2
- package/src/resources/extensions/gsd/tests/park-milestone.test.ts +64 -0
- package/src/resources/extensions/gsd/tests/preferences.test.ts +47 -0
- package/src/resources/extensions/gsd/tests/state-machine-full-walkthrough.test.ts +5 -7
- package/src/resources/extensions/gsd/tests/token-profile.test.ts +1 -1
- /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → nwYTvJZ1-hZIfw98d9Wfg}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → nwYTvJZ1-hZIfw98d9Wfg}/_ssgManifest.js +0 -0
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
10
12
|
import type { DynamicRoutingConfig } from "./model-router.js";
|
|
11
13
|
import { defaultRoutingConfig } from "./model-router.js";
|
|
12
14
|
import type { TokenProfile, InlineLevel } from "./types.js";
|
|
@@ -185,6 +187,45 @@ export function resolveDefaultSessionModel(
|
|
|
185
187
|
return undefined;
|
|
186
188
|
}
|
|
187
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Returns true if `provider` is defined as a custom provider in the user's
|
|
192
|
+
* `~/.gsd/agent/models.json` (Ollama, vLLM, LM Studio, OpenAI-compatible
|
|
193
|
+
* proxies, etc.).
|
|
194
|
+
*
|
|
195
|
+
* Used by auto-mode bootstrap to decide whether the session model
|
|
196
|
+
* (set via `/gsd model`) should override `PREFERENCES.md`. Custom providers
|
|
197
|
+
* are never reachable from `PREFERENCES.md` (which only knows built-in
|
|
198
|
+
* providers), so when the user has explicitly selected one, it must take
|
|
199
|
+
* priority — otherwise auto-mode tries to start the built-in provider from
|
|
200
|
+
* PREFERENCES.md and fails with "Not logged in · Please run /login" (#4122).
|
|
201
|
+
*
|
|
202
|
+
* Reads models.json directly with a lightweight JSON parse to avoid
|
|
203
|
+
* pulling in the full model-registry at this call site. Falls back to
|
|
204
|
+
* `~/.pi/agent/models.json` for parity with `resolveModelsJsonPath()`.
|
|
205
|
+
* Any read or parse error yields `false` (treat as not-custom) so a
|
|
206
|
+
* malformed models.json never breaks the session bootstrap.
|
|
207
|
+
*/
|
|
208
|
+
export function isCustomProvider(provider: string | undefined): boolean {
|
|
209
|
+
if (!provider) return false;
|
|
210
|
+
const candidates = [
|
|
211
|
+
join(homedir(), ".gsd", "agent", "models.json"),
|
|
212
|
+
join(homedir(), ".pi", "agent", "models.json"),
|
|
213
|
+
];
|
|
214
|
+
for (const path of candidates) {
|
|
215
|
+
if (!existsSync(path)) continue;
|
|
216
|
+
try {
|
|
217
|
+
const raw = readFileSync(path, "utf-8");
|
|
218
|
+
const parsed = JSON.parse(raw) as { providers?: Record<string, unknown> };
|
|
219
|
+
if (parsed?.providers && Object.prototype.hasOwnProperty.call(parsed.providers, provider)) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// Ignore — malformed models.json must not break bootstrap.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
188
229
|
/**
|
|
189
230
|
* Determines the next fallback model to try when the current model fails.
|
|
190
231
|
* If the current model is not in the configured list, returns the primary model.
|
|
@@ -113,6 +113,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|
|
113
113
|
"discuss_preparation",
|
|
114
114
|
"discuss_web_research",
|
|
115
115
|
"discuss_depth",
|
|
116
|
+
"flat_rate_providers",
|
|
116
117
|
]);
|
|
117
118
|
|
|
118
119
|
/** Canonical list of all dispatch unit types. */
|
|
@@ -359,6 +360,17 @@ export interface GSDPreferences {
|
|
|
359
360
|
* Default: "standard".
|
|
360
361
|
*/
|
|
361
362
|
discuss_depth?: "quick" | "standard" | "thorough";
|
|
363
|
+
/**
|
|
364
|
+
* Extra provider IDs to treat as flat-rate (no cost benefit from dynamic
|
|
365
|
+
* routing). Dynamic routing is suppressed for any provider listed here,
|
|
366
|
+
* in addition to the built-in list (github-copilot, copilot, claude-code)
|
|
367
|
+
* and any provider auto-detected via `authMode: "externalCli"`.
|
|
368
|
+
*
|
|
369
|
+
* Intended for private subscription-backed proxies, enterprise-gated
|
|
370
|
+
* deployments, and custom CLI wrappers where every request costs the
|
|
371
|
+
* same regardless of model. Case-insensitive.
|
|
372
|
+
*/
|
|
373
|
+
flat_rate_providers?: string[];
|
|
362
374
|
}
|
|
363
375
|
|
|
364
376
|
export interface LoadedGSDPreferences {
|
|
@@ -180,6 +180,29 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// ─── Flat-rate Providers ────────────────────────────────────────────
|
|
184
|
+
// User-declared flat-rate providers for dynamic routing suppression.
|
|
185
|
+
// Built-in providers (github-copilot, copilot, claude-code) and any
|
|
186
|
+
// externalCli provider are already auto-detected; this list layers on
|
|
187
|
+
// top for private subscription proxies and custom CLI wrappers.
|
|
188
|
+
if (preferences.flat_rate_providers !== undefined) {
|
|
189
|
+
if (Array.isArray(preferences.flat_rate_providers)) {
|
|
190
|
+
const allStrings = preferences.flat_rate_providers.every(
|
|
191
|
+
(item: unknown) => typeof item === "string",
|
|
192
|
+
);
|
|
193
|
+
if (allStrings) {
|
|
194
|
+
// Strip empty/whitespace-only entries to avoid false matches.
|
|
195
|
+
validated.flat_rate_providers = preferences.flat_rate_providers
|
|
196
|
+
.map((s: string) => s.trim())
|
|
197
|
+
.filter((s: string) => s.length > 0);
|
|
198
|
+
} else {
|
|
199
|
+
errors.push("flat_rate_providers must be an array of strings");
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
errors.push("flat_rate_providers must be an array of strings");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
183
206
|
// ─── Phase Skip Preferences ─────────────────────────────────────────
|
|
184
207
|
if (preferences.phases !== undefined) {
|
|
185
208
|
if (typeof preferences.phases === "object" && preferences.phases !== null) {
|
|
@@ -630,13 +630,39 @@ function resolveSliceDependencies(activeMilestoneSlices: SliceRow[]): { activeSl
|
|
|
630
630
|
}
|
|
631
631
|
}
|
|
632
632
|
|
|
633
|
+
// First pass: find a slice with ALL dependencies satisfied (strict)
|
|
634
|
+
let bestFallback: SliceRow | null = null;
|
|
635
|
+
let bestFallbackSatisfied = -1;
|
|
636
|
+
|
|
633
637
|
for (const s of activeMilestoneSlices) {
|
|
634
638
|
if (isStatusDone(s.status)) continue;
|
|
635
639
|
if (isDeferredStatus(s.status)) continue;
|
|
636
640
|
if (s.depends.every(dep => doneSliceIds.has(dep))) {
|
|
637
641
|
return { activeSlice: { id: s.id, title: s.title }, activeSliceRow: s };
|
|
638
642
|
}
|
|
643
|
+
// Track the slice with the most satisfied dependencies as fallback
|
|
644
|
+
const satisfied = s.depends.filter(dep => doneSliceIds.has(dep)).length;
|
|
645
|
+
if (satisfied > bestFallbackSatisfied || (satisfied === bestFallbackSatisfied && !bestFallback)) {
|
|
646
|
+
bestFallback = s;
|
|
647
|
+
bestFallbackSatisfied = satisfied;
|
|
648
|
+
}
|
|
639
649
|
}
|
|
650
|
+
|
|
651
|
+
// Fallback: if no slice has all deps met but there ARE incomplete non-deferred
|
|
652
|
+
// slices, pick the one with the most deps satisfied. This prevents hard-blocking
|
|
653
|
+
// when dependency metadata is stale (e.g. after reassessment added/removed slices)
|
|
654
|
+
// or when deps reference slices from previous milestones.
|
|
655
|
+
if (bestFallback) {
|
|
656
|
+
const unmet = bestFallback.depends.filter(dep => !doneSliceIds.has(dep));
|
|
657
|
+
logWarning("state",
|
|
658
|
+
`No slice has all deps satisfied — falling back to ${bestFallback.id} ` +
|
|
659
|
+
`(${bestFallbackSatisfied}/${bestFallback.depends.length} deps met, ` +
|
|
660
|
+
`unmet: ${unmet.join(", ")})`,
|
|
661
|
+
{ mid: activeMilestoneSlices[0]?.milestone_id, sid: bestFallback.id },
|
|
662
|
+
);
|
|
663
|
+
return { activeSlice: { id: bestFallback.id, title: bestFallback.title }, activeSliceRow: bestFallback };
|
|
664
|
+
}
|
|
665
|
+
|
|
640
666
|
return { activeSlice: null, activeSliceRow: null };
|
|
641
667
|
}
|
|
642
668
|
|
|
@@ -1431,12 +1457,32 @@ export async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|
|
1431
1457
|
};
|
|
1432
1458
|
}
|
|
1433
1459
|
} else {
|
|
1460
|
+
let bestFallbackLegacy: { id: string; title: string; depends: string[] } | null = null;
|
|
1461
|
+
let bestFallbackLegacySatisfied = -1;
|
|
1462
|
+
|
|
1434
1463
|
for (const s of activeRoadmap.slices) {
|
|
1435
1464
|
if (s.done) continue;
|
|
1436
1465
|
if (s.depends.every(dep => doneSliceIds.has(dep))) {
|
|
1437
1466
|
activeSlice = { id: s.id, title: s.title };
|
|
1438
1467
|
break;
|
|
1439
1468
|
}
|
|
1469
|
+
// Track best fallback
|
|
1470
|
+
const satisfied = s.depends.filter(dep => doneSliceIds.has(dep)).length;
|
|
1471
|
+
if (satisfied > bestFallbackLegacySatisfied) {
|
|
1472
|
+
bestFallbackLegacy = s;
|
|
1473
|
+
bestFallbackLegacySatisfied = satisfied;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Fallback: if no slice has all deps met, pick the one with the most deps satisfied
|
|
1478
|
+
if (!activeSlice && bestFallbackLegacy) {
|
|
1479
|
+
const unmet = bestFallbackLegacy.depends.filter(dep => !doneSliceIds.has(dep));
|
|
1480
|
+
logWarning("state",
|
|
1481
|
+
`No slice has all deps satisfied — falling back to ${bestFallbackLegacy.id} ` +
|
|
1482
|
+
`(${bestFallbackLegacySatisfied}/${bestFallbackLegacy.depends.length} deps met, ` +
|
|
1483
|
+
`unmet: ${unmet.join(", ")})`,
|
|
1484
|
+
);
|
|
1485
|
+
activeSlice = { id: bestFallbackLegacy.id, title: bestFallbackLegacy.title };
|
|
1440
1486
|
}
|
|
1441
1487
|
}
|
|
1442
1488
|
|
|
@@ -688,8 +688,8 @@ test("autoLoop exits on terminal blocked state", async (t) => {
|
|
|
688
688
|
|
|
689
689
|
assert.ok(deps.callLog.includes("deriveState"), "should have derived state");
|
|
690
690
|
assert.ok(
|
|
691
|
-
deps.callLog.includes("
|
|
692
|
-
"should have called
|
|
691
|
+
deps.callLog.includes("pauseAuto"),
|
|
692
|
+
"should have called pauseAuto for blocked state",
|
|
693
693
|
);
|
|
694
694
|
assert.ok(
|
|
695
695
|
!deps.callLog.includes("resolveDispatch"),
|
|
@@ -33,8 +33,12 @@ test("bootstrapAutoSession checks manual session override before preferences", (
|
|
|
33
33
|
assert.ok(manualIdx > -1, "auto-start.ts should read session model override first");
|
|
34
34
|
|
|
35
35
|
// resolveDefaultSessionModel() should still be called for fallback behavior
|
|
36
|
-
const preferredIdx = source.indexOf("const preferredModel =
|
|
37
|
-
assert.ok(preferredIdx > -1, "auto-start.ts should
|
|
36
|
+
const preferredIdx = source.indexOf("const preferredModel = ");
|
|
37
|
+
assert.ok(preferredIdx > -1, "auto-start.ts should build preferredModel");
|
|
38
|
+
assert.ok(
|
|
39
|
+
source.indexOf("resolveDefaultSessionModel(") > -1,
|
|
40
|
+
"auto-start.ts should call resolveDefaultSessionModel()",
|
|
41
|
+
);
|
|
38
42
|
|
|
39
43
|
// Session provider should be passed for bare model ID resolution
|
|
40
44
|
const withProviderIdx = source.indexOf("resolveDefaultSessionModel(ctx.model?.provider)");
|
|
@@ -47,6 +51,51 @@ test("bootstrapAutoSession checks manual session override before preferences", (
|
|
|
47
51
|
manualIdx < snapshotIdx && preferredIdx < snapshotIdx,
|
|
48
52
|
"manual override and preference fallback must be resolved before building startModelSnapshot",
|
|
49
53
|
);
|
|
54
|
+
|
|
55
|
+
// The validated preferred model must still appear as one of the snapshot
|
|
56
|
+
// sources so PREFERENCES.md continues to win over a stale settings.json
|
|
57
|
+
// default for built-in providers.
|
|
58
|
+
const snapshotBlock = source.slice(snapshotIdx, snapshotIdx + 400);
|
|
59
|
+
assert.ok(
|
|
60
|
+
snapshotBlock.includes("validatedPreferredModel") || snapshotBlock.includes("preferredModel"),
|
|
61
|
+
"startModelSnapshot must still consider preferredModel for built-in providers",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("bootstrapAutoSession prefers session model over PREFERENCES.md when provider is custom (#4122)", () => {
|
|
66
|
+
// Custom providers (Ollama, vLLM, OpenAI-compatible proxies) live in
|
|
67
|
+
// ~/.gsd/agent/models.json, not PREFERENCES.md. When the user picks one
|
|
68
|
+
// via /gsd model, that selection must win over any preferredModel from
|
|
69
|
+
// PREFERENCES.md, otherwise auto-mode tries to start a built-in provider
|
|
70
|
+
// the user is not logged into and pauses with "Not logged in".
|
|
71
|
+
const customCheckIdx = source.indexOf("isCustomProvider(ctx.model?.provider)");
|
|
72
|
+
assert.ok(
|
|
73
|
+
customCheckIdx > -1,
|
|
74
|
+
"auto-start.ts should call isCustomProvider() to detect custom-model sessions",
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// sessionProviderIsCustom must gate preferredModel resolution so that when the
|
|
78
|
+
// session provider is custom, preferredModel is null and PREFERENCES.md is
|
|
79
|
+
// skipped entirely — the snapshot then falls through to ctx.model.
|
|
80
|
+
const gateIdx = source.indexOf("sessionProviderIsCustom");
|
|
81
|
+
assert.ok(gateIdx > -1, "auto-start.ts should bind sessionProviderIsCustom");
|
|
82
|
+
|
|
83
|
+
const preferredIdx = source.indexOf("const preferredModel = ");
|
|
84
|
+
assert.ok(preferredIdx > -1, "auto-start.ts should build preferredModel");
|
|
85
|
+
|
|
86
|
+
const preferredBlock = source.slice(preferredIdx, preferredIdx + 200);
|
|
87
|
+
assert.ok(
|
|
88
|
+
preferredBlock.includes("sessionProviderIsCustom"),
|
|
89
|
+
"preferredModel must be gated on sessionProviderIsCustom so PREFERENCES.md is skipped for custom providers",
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const snapshotIdx = source.indexOf("const startModelSnapshot = ");
|
|
93
|
+
assert.ok(snapshotIdx > -1, "auto-start.ts should build startModelSnapshot");
|
|
94
|
+
|
|
95
|
+
assert.ok(
|
|
96
|
+
customCheckIdx < preferredIdx && preferredIdx < snapshotIdx,
|
|
97
|
+
"isCustomProvider() must be evaluated before preferredModel, which must be resolved before startModelSnapshot",
|
|
98
|
+
);
|
|
50
99
|
});
|
|
51
100
|
|
|
52
101
|
test("bootstrapAutoSession validates preferred model against live registry auth (#unconfigured-models)", () => {
|
|
@@ -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
|
-
|
|
355
|
-
assert.
|
|
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
|
-
|
|
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.
|
|
622
|
+
assert.ok(dbState.activeSlice !== null, 'blocked-db: activeSlice is set via fallback');
|
|
622
623
|
|
|
623
624
|
closeDatabase();
|
|
624
625
|
} finally {
|
|
@@ -446,9 +446,9 @@ Continue from step 2.
|
|
|
446
446
|
|
|
447
447
|
const state2 = await deriveState(base2);
|
|
448
448
|
|
|
449
|
-
|
|
450
|
-
assert.deepStrictEqual(state2.
|
|
451
|
-
assert.
|
|
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
|
}
|