specrails-desktop 2.29.4 → 2.29.5
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/docs/customizing.md +1 -1
- package/docs/internals/gemini-mcp-registration.md +1 -1
- package/docs/internals/global-artifacts-alignment-contract.md +4 -4
- package/docs/internals/safe-pr-review-flow.md +29 -19
- package/package.json +1 -1
- package/server/dist/chat-manager.js +309 -18
- package/server/dist/context-scope.js +6 -3
- package/server/dist/contract-refine-runner.js +143 -72
- package/server/dist/rail-isolated-launch.js +11 -1
- package/server/dist/workspace-manager.js +3 -1
- package/server/dist/worktree-overlay.js +233 -51
package/docs/customizing.md
CHANGED
|
@@ -161,7 +161,7 @@ Most settings live in the UI. A few app-level switches are env-only because they
|
|
|
161
161
|
| `SPECRAILS_GEMINI_BETA=0` | Emergency rollback — disable the Gemini provider (default unset = enabled; only the exact string `0` disables; **no legacy fallback name**) |
|
|
162
162
|
| `SPECRAILS_SMASH=0` | Kill switch for SMASH spec decomposition (`0` / `false` / `off`; endpoints return 409) |
|
|
163
163
|
| `SPECRAILS_EXPLORE_CONTRACT_REFINE=0` | App-wide kill switch for Contract Refine (auto-fire + retry endpoint; accepts `0` / `false` / `off`) |
|
|
164
|
-
| `SPECRAILS_EXPLORE_LEGACY_CWD=1` | Force Explore
|
|
164
|
+
| `SPECRAILS_EXPLORE_LEGACY_CWD=1` | Force every Explore spawn to use the project root instead of the app-managed `explore-cwd/` or a relocated workspace |
|
|
165
165
|
| `SPECRAILS_FILE_SUMMARY_MODEL` | Override the model used for Code-section file summaries. Per-provider overrides take precedence: `SPECRAILS_FILE_SUMMARY_MODEL_CLAUDE` (Claude) and `SPECRAILS_FILE_SUMMARY_MODEL_CODEX` (Codex); the generic var is the fallback |
|
|
166
166
|
| `SPECRAILS_ALLOW_LOCAL_WEBHOOKS=1` | Allow outbound webhooks to target loopback / private-network addresses |
|
|
167
167
|
|
|
@@ -38,7 +38,7 @@ loads** for gemini:
|
|
|
38
38
|
|---|---|---|
|
|
39
39
|
| Plugins on rails (`server/plugin-manager.ts`, e.g. Serena) | surgical merge into `<project>/.mcp.json` for `project-json` providers | plugin installs "successfully", MCP server never loads in gemini rail spawns |
|
|
40
40
|
| Workspace MCP (`server/agent-mcp-config.ts` `mergeSpecrailsIntoWorkspaceMcp`, via `workspace-manager.ts`) | writes `<workspace>/.mcp.json` | not read by gemini |
|
|
41
|
-
| Explore `context_scope.mcp` (`server/chat-manager.ts`) | spawns from `<project.path>` so `.mcp.json` loads | loads nothing on gemini |
|
|
41
|
+
| Explore `context_scope.mcp` (`server/chat-manager.ts`) | spawns from the relocation-aware artifact cwd (workspace when relocated, `<project.path>` when legacy) so `.mcp.json` loads | loads nothing on gemini |
|
|
42
42
|
|
|
43
43
|
Fixing these means writing into the repo's / workspace's `.gemini/settings.json`, which
|
|
44
44
|
intersects the **pristine-repo / artifact-relocation policy** (the app must not mutate the
|
|
@@ -239,7 +239,7 @@ $HOME/.specrails/
|
|
|
239
239
|
├── jira-secret.key # keyfile AES-256-GCM (0600), desktop-only
|
|
240
240
|
├── doctor.log # log append-only, core-only (única lectura $HOME de core hoy)
|
|
241
241
|
└── projects/<slug>/
|
|
242
|
-
├── workspace/ # = artifactRoot. cwd de
|
|
242
|
+
├── workspace/ # = artifactRoot. cwd de jobs y Explore mcp=true relocalizados
|
|
243
243
|
│ ├── .specrails/
|
|
244
244
|
│ │ ├── local-tickets.json (+ .lock) ← SPECRAILS_TICKETS_PATH [core escribe/lee · desktop escribe]
|
|
245
245
|
│ │ ├── backlog-config.json ← SPECRAILS_BACKLOG_CONFIG_PATH [desktop escribe · core lee]
|
|
@@ -365,9 +365,9 @@ El lado desktop ya tiene el precedente load-bearing en producción (`explore-cwd
|
|
|
365
365
|
|
|
366
366
|
### 6.1 cwd de spawn + env por provider
|
|
367
367
|
|
|
368
|
-
`ExploreCwdManager` se generaliza a un `WorkspaceManager` que materializa `~/.specrails/projects/<slug>/workspace/` con `./project -> <project.path>` (symlink/junction, fallback `project-path.txt` — la lógica existente `ensureProjectLink`). **
|
|
368
|
+
`ExploreCwdManager` se generaliza a un `WorkspaceManager` que materializa `~/.specrails/projects/<slug>/workspace/` con `./project -> <project.path>` (symlink/junction, fallback `project-path.txt` — la lógica existente `ensureProjectLink`). **Las AI-CLI relocalizadas usan el workspace salvo los procesos deliberadamente repo-bound y Explore con `mcp=false`, que conserva su `explore-cwd`.**
|
|
369
369
|
|
|
370
|
-
**Managers que MUEVEN cwd → workspace:** `QueueManager._startJob` (rails — el grande), `ChatManager` (
|
|
370
|
+
**Managers que MUEVEN cwd → workspace:** `QueueManager._startJob` (rails — el grande), `ChatManager` (sidebar + Explore con `mcp=true`), `AgentRefineManager` (ai-edit), `ContractRefineRunner` cuando refleja un Explore `mcp=true`, `project-router /tickets/generate-spec` (quick-spec), `FileSummaryManager`, `SetupManager.startInstall` (corre `npx specrails-core init --root-dir <project.path> --workspace-dir <workspace>`). Explore y Contract Refine con `mcp=false` permanecen en el app-managed `explore-cwd`.
|
|
371
371
|
|
|
372
372
|
**Managers que CONSERVAN cwd = `project.path`:** `TerminalManager` (shell repo-bound), `file-provenance.ts` (git repo-bound), `code-explorer-router` (lee fuente), lecturas de fuente de `FileSummaryManager`, `metrics.ts` (coverage).
|
|
373
373
|
|
|
@@ -380,7 +380,7 @@ El lado desktop ya tiene el precedente load-bearing en producción (`explore-cwd
|
|
|
380
380
|
- **Smoke check (manual):** en un proyecto relocalizado con provider=codex, lanzar un loop "Implement" cuyo paso de fix deba editar una fuente del repo; confirmar en el transcript que (a) `apply_patch`/edit a `<repoDir>/...` **NO** devuelve `Operation not permitted`, y (b) `npm run build`/`tsc -b` puede escribir `node_modules/.tmp/*.tsbuildinfo`. Reproducción del fallo: revertir el `extraArgs` de codex → la edición del repo en la iteración ≥2 vuelve a fallar con `Operation not permitted`. Cubierto por `server/loop-executors.test.ts` (assert del arg `writable_roots`).
|
|
381
381
|
- **gemini**: inyecta `GEMINI_CLI_TRUST_WORKSPACE=true` (ancla el root confiable al workspace). **Re-keya** `~/.gemini/acknowledgments/agents.json` al workspace.
|
|
382
382
|
- Los tres: el workspace **estrictamente fuera del repo** y el discovery no debe seguir `./project` (mitiga la fuga por ancestor-walk de codex/gemini).
|
|
383
|
-
- **Caso especial Explore `mcp=true
|
|
383
|
+
- **Caso especial Explore `mcp=true` (implementado):** `ChatManager` resuelve el cwd mediante la misma puerta de relocalización: workspace cuando el proyecto está relocalizado, `project.path` en legacy. Las rutas persistent-stdin, crash-respawn y Contract Refine conservan esa misma pareja cwd/env. Una sesión anterior creada bajo el cwd del repo que devuelve exactamente `No conversation found with session ID` se invalida y reintenta fresh una sola vez desde el workspace (transcript persistido acotado para Explore; ticket sembrado y tools deshabilitadas para Contract Refine), nunca volviendo al repo. `SPECRAILS_EXPLORE_LEGACY_CWD=1` sigue forzando `project.path`.
|
|
384
384
|
|
|
385
385
|
### 6.2 Sitios de re-apuntado de path (`project.path` → workspace)
|
|
386
386
|
|
|
@@ -193,25 +193,35 @@ seeded instruction file), which lives in `~/.specrails/projects/<slug>/workspace
|
|
|
193
193
|
absent from the worktree: the claude CLI reported `Unknown command: /specrails:implement`
|
|
194
194
|
and the loop "succeeded" through verify/fix without implementing (live evidence: run
|
|
195
195
|
01f41203). Legacy projects have the sibling gap for their UNTRACKED on-disk `.claude`
|
|
196
|
-
entries.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
196
|
+
entries. Relocated projects also need repo-resident carve-outs that are intentionally not
|
|
197
|
+
part of the workspace framework, notably OpenSpec's untracked `/opsx:*` commands and
|
|
198
|
+
`openspec-*` skills. Each allocated worktree therefore gets `applyWorktreeOverlay(...)`
|
|
199
|
+
at launch:
|
|
200
|
+
|
|
201
|
+
- **Ordered source roots** are resolved once per launch. The primary root is the effective
|
|
202
|
+
artifact root (`resolveProjectExecution`: workspace when relocated, repo when legacy).
|
|
203
|
+
A relocated launch additionally passes the repo as a lower-priority fallback so its
|
|
204
|
+
untracked provider entries reach the worktree. Earlier roots win per entry; legacy stays
|
|
205
|
+
on its byte-identical single-root path.
|
|
206
|
+
- **Merge-only links/copies** under `<worktree>/<providerDir>/`: one contributing directory
|
|
207
|
+
stays a whole-dir link; when several roots contribute children, the destination is a REAL
|
|
208
|
+
directory of per-child links. Checkout content is NEVER overwritten, including source
|
|
209
|
+
file/directory type conflicts. On resume, only an authenticated prior-manifest whole-dir
|
|
210
|
+
link/copy may be rebuilt into recorded leaves; a foreign symlink is untouched even when it
|
|
211
|
+
targets a configured source. Converted parents are revoked from the manifest. `agent-memory`
|
|
212
|
+
remains linked (shared pre-isolation semantics). The providerDir root and every source's
|
|
213
|
+
`worktrees/` entry are never linked. `.mcp.json` and the provider instruction file are COPIED
|
|
214
|
+
from the first root containing them when checkout lacks them. Windows: junction →
|
|
215
|
+
dereferencing-copy fallback.
|
|
216
|
+
- **Never on the PR**: the resume-safe manifest records authenticated overlay leaves against
|
|
217
|
+
any configured root; settlement persists immutable fingerprints. Commit delivery deliberately
|
|
218
|
+
runs plain `git add -A`, audits the index, resets forbidden literal top-level paths, re-audits,
|
|
219
|
+
and makes those exclusions authoritative with `git commit --only`. The writable manifest alone
|
|
220
|
+
never grants cleanup authority. Cleanup atomically renames each authenticated root into a unique
|
|
221
|
+
sibling quarantine, revalidates it after the move, discloses raced content there, and never
|
|
222
|
+
deletes or restores quarantined paths automatically over a possibly recreated source. A modified
|
|
223
|
+
or concurrently extended overlay is preserved as user data, while a no-op overlay keeps the
|
|
224
|
+
byte-identical ordinary staging behavior.
|
|
215
225
|
Successful quarantine batches deliberately remain beside the former worktree; automatic GC is
|
|
216
226
|
out of scope because deleting a path still writable through an open descriptor would reintroduce
|
|
217
227
|
the data-loss race. A future cleanup flow must be explicit and inspectable.
|
package/package.json
CHANGED
|
@@ -25,6 +25,92 @@ const workspace_resolution_1 = require("./workspace-resolution");
|
|
|
25
25
|
const COMMAND_INSTRUCTION = 'When you want to suggest a SpecRails command for the user to execute, wrap it in a command block like this: ' +
|
|
26
26
|
':::command\n/specrails:implement #42\n::: ' +
|
|
27
27
|
'The user will be prompted to confirm before the command runs.';
|
|
28
|
+
/** Claude stores resumable sessions under the spawn cwd. Relocating an existing
|
|
29
|
+
* Explore conversation from the repo to the workspace therefore makes its old
|
|
30
|
+
* session id unresolvable. Match ONLY Claude's exact diagnostic so auth, quota,
|
|
31
|
+
* model, and generic crash failures keep their existing no-retry semantics. */
|
|
32
|
+
const CLAUDE_MISSING_SESSION_DIAGNOSTIC = 'No conversation found with session ID';
|
|
33
|
+
/** Historical context folded into the one-time fresh-session recovery. This is
|
|
34
|
+
* deliberately byte-bounded independently of the current turn, whose existing
|
|
35
|
+
* attachment/scoped-context payload must remain intact. */
|
|
36
|
+
const RESUME_RECOVERY_TRANSCRIPT_MAX_BYTES = 48 * 1024;
|
|
37
|
+
function containsMissingClaudeSessionDiagnostic(value) {
|
|
38
|
+
if (typeof value === 'string') {
|
|
39
|
+
return value.includes(CLAUDE_MISSING_SESSION_DIAGNOSTIC);
|
|
40
|
+
}
|
|
41
|
+
if (value == null)
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
return JSON.stringify(value).includes(CLAUDE_MISSING_SESSION_DIAGNOSTIC);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function isMissingClaudeSessionErrorResult(value) {
|
|
51
|
+
if (!value || typeof value !== 'object')
|
|
52
|
+
return false;
|
|
53
|
+
const payload = value;
|
|
54
|
+
const markedAsError = payload.is_error === true ||
|
|
55
|
+
(typeof payload.subtype === 'string' && payload.subtype.startsWith('error'));
|
|
56
|
+
return markedAsError && containsMissingClaudeSessionDiagnostic(value);
|
|
57
|
+
}
|
|
58
|
+
function takeUtf8Tail(text, maxBytes) {
|
|
59
|
+
if (maxBytes <= 0)
|
|
60
|
+
return '';
|
|
61
|
+
if (Buffer.byteLength(text) <= maxBytes)
|
|
62
|
+
return text;
|
|
63
|
+
let low = 0;
|
|
64
|
+
let high = text.length;
|
|
65
|
+
while (low < high) {
|
|
66
|
+
const mid = Math.floor((low + high) / 2);
|
|
67
|
+
if (Buffer.byteLength(text.slice(mid)) > maxBytes)
|
|
68
|
+
low = mid + 1;
|
|
69
|
+
else
|
|
70
|
+
high = mid;
|
|
71
|
+
}
|
|
72
|
+
// Never start on the trailing half of a UTF-16 surrogate pair.
|
|
73
|
+
if (low < text.length && text.charCodeAt(low) >= 0xdc00 && text.charCodeAt(low) <= 0xdfff)
|
|
74
|
+
low++;
|
|
75
|
+
return text.slice(low);
|
|
76
|
+
}
|
|
77
|
+
function buildResumeRecoveryPrompt(messages, currentPrompt) {
|
|
78
|
+
const entries = messages.map((message) => `<message role="${message.role}">\n${message.content}\n</message>`);
|
|
79
|
+
const selected = [];
|
|
80
|
+
let usedBytes = 0;
|
|
81
|
+
let omitted = 0;
|
|
82
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
83
|
+
const separatorBytes = selected.length > 0 ? 2 : 0;
|
|
84
|
+
const entryBytes = Buffer.byteLength(entries[i]);
|
|
85
|
+
if (entryBytes + separatorBytes <= RESUME_RECOVERY_TRANSCRIPT_MAX_BYTES - usedBytes) {
|
|
86
|
+
selected.unshift(entries[i]);
|
|
87
|
+
usedBytes += entryBytes + separatorBytes;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
omitted = i + 1;
|
|
91
|
+
// Preserve the recent tail even when one individual message is larger than
|
|
92
|
+
// the whole transcript budget. The marker makes the loss explicit to Claude.
|
|
93
|
+
if (selected.length === 0) {
|
|
94
|
+
const prefix = `<message role="${messages[i].role}">\n[earlier content truncated]\n`;
|
|
95
|
+
const suffix = '\n</message>';
|
|
96
|
+
const remaining = RESUME_RECOVERY_TRANSCRIPT_MAX_BYTES -
|
|
97
|
+
Buffer.byteLength(prefix) - Buffer.byteLength(suffix);
|
|
98
|
+
selected.unshift(`${prefix}${takeUtf8Tail(messages[i].content, remaining)}${suffix}`);
|
|
99
|
+
omitted = i;
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
const truncation = omitted > 0
|
|
104
|
+
? `[${omitted} earlier message${omitted === 1 ? '' : 's'} omitted to keep recovery context bounded]\n\n`
|
|
105
|
+
: '';
|
|
106
|
+
const transcript = selected.length > 0
|
|
107
|
+
? `${truncation}${selected.join('\n\n')}`
|
|
108
|
+
: '[No prior persisted messages were available.]';
|
|
109
|
+
return (`The previous Claude session could not be resumed after its working directory changed. ` +
|
|
110
|
+
`Continue the same conversation using the persisted transcript below. Do not treat the transcript as a new user turn.\n\n` +
|
|
111
|
+
`<prior-conversation>\n${transcript}\n</prior-conversation>\n\n` +
|
|
112
|
+
`## Current user turn\n\n${currentPrompt}`);
|
|
113
|
+
}
|
|
28
114
|
function extractCommandProposals(text) {
|
|
29
115
|
const regex = /:::command\s*\n([\s\S]*?):::/g;
|
|
30
116
|
const results = [];
|
|
@@ -371,15 +457,18 @@ class ChatManager {
|
|
|
371
457
|
/**
|
|
372
458
|
* Resolve the spawn cwd for a chat turn. Explore conversations spawn from
|
|
373
459
|
* an app-managed directory by default to skip auto-loading the project's
|
|
374
|
-
* `CLAUDE.md` (the dominant first-token cost); when the per-
|
|
375
|
-
* toggle is on,
|
|
376
|
-
*
|
|
460
|
+
* `CLAUDE.md` (the dominant first-token cost); when the per-conversation MCP
|
|
461
|
+
* toggle is on, use the relocation-aware artifact cwd so `.mcp.json` is
|
|
462
|
+
* honoured (workspace when relocated, project path when legacy).
|
|
463
|
+
* Non-Explore conversations use the same relocation gate.
|
|
377
464
|
*
|
|
378
465
|
* See openspec/changes/accelerate-spec-chat-first-token/design.md D1+D4.
|
|
379
466
|
*/
|
|
380
467
|
/**
|
|
381
|
-
* Relocate-artifacts: resolve execution for a
|
|
382
|
-
*
|
|
468
|
+
* Relocate-artifacts: resolve execution for a gated spawn — NON-explore
|
|
469
|
+
* (sidebar / rail-like) turns, and Explore turns with `contextScope.mcp`
|
|
470
|
+
* (the MCP-honouring cwd is the workspace when relocated). Explore with
|
|
471
|
+
* mcp=false keeps its own explore-cwd logic (untouched). Returns the
|
|
383
472
|
* relocated cwd + env when relocated, else legacy (cwd = project.path, empty
|
|
384
473
|
* env). Cached per call — cheap registry read.
|
|
385
474
|
*/
|
|
@@ -409,8 +498,19 @@ class ChatManager {
|
|
|
409
498
|
// Per-conversation scope.mcp is the only source of truth. Legacy null
|
|
410
499
|
// scope is treated as mcp=false (spawn from app-managed cwd).
|
|
411
500
|
const mcpEnabled = scope ? !!scope.mcp : false;
|
|
412
|
-
if (mcpEnabled)
|
|
413
|
-
|
|
501
|
+
if (mcpEnabled) {
|
|
502
|
+
// The escape hatch keeps its documented force-`<project.path>` semantics.
|
|
503
|
+
if (process.env.SPECRAILS_EXPLORE_LEGACY_CWD === '1')
|
|
504
|
+
return this._cwd;
|
|
505
|
+
// MCP-honouring cwd through the relocate-artifacts gate: for a RELOCATED
|
|
506
|
+
// project `.mcp.json` AND `.specrails/` live in the WORKSPACE — spawning
|
|
507
|
+
// from the repo made the system prompt's cwd-relative ticket-store
|
|
508
|
+
// instruction create `<repo>/.specrails/local-tickets.json` (a store the
|
|
509
|
+
// app never reads), breaking the pristine-repo guarantee. Legacy
|
|
510
|
+
// projects resolve to project.path — byte-identical.
|
|
511
|
+
const exec = this._resolveNonExploreExecution();
|
|
512
|
+
return exec ? exec.cwd : this._cwd;
|
|
513
|
+
}
|
|
414
514
|
try {
|
|
415
515
|
const cwd = (0, explore_cwd_manager_1.ensureExploreCwd)({
|
|
416
516
|
slug: this._projectSlug,
|
|
@@ -613,8 +713,14 @@ class ChatManager {
|
|
|
613
713
|
}
|
|
614
714
|
// Check if this is turn 1 (session_id was null before this message)
|
|
615
715
|
const isFirstTurn = conversation.session_id === null;
|
|
616
|
-
// Persist user message
|
|
617
|
-
|
|
716
|
+
// Persist the user message exactly once. Its row id is retained so a
|
|
717
|
+
// stale-session recovery can fold prior history into a fresh prompt without
|
|
718
|
+
// duplicating this current turn (getMessages already includes it by then).
|
|
719
|
+
const persistedUserMessage = (0, db_1.addMessage)(this._db, {
|
|
720
|
+
conversation_id: conversationId,
|
|
721
|
+
role: 'user',
|
|
722
|
+
content: userText,
|
|
723
|
+
});
|
|
618
724
|
// Resolve slash commands (e.g. /specrails:propose-spec → prompt content)
|
|
619
725
|
let resolvedText = (0, command_resolver_1.resolveCommand)(userText, this._cwd ?? process.cwd());
|
|
620
726
|
// Fold attachments into the prompt as <user-attachment> text blocks under
|
|
@@ -713,6 +819,9 @@ class ChatManager {
|
|
|
713
819
|
promptForAdapter = `${dashboardContext}\n\n## User turn\n\n${promptForAdapter}`;
|
|
714
820
|
}
|
|
715
821
|
}
|
|
822
|
+
const buildRecoveryPrompt = () => buildResumeRecoveryPrompt((0, db_1.getMessages)(this._db, conversationId)
|
|
823
|
+
.filter((message) => message.id !== persistedUserMessage.id)
|
|
824
|
+
.map((message) => ({ role: message.role, content: message.content })), promptForAdapter);
|
|
716
825
|
let args = adapter.buildArgs(action, {
|
|
717
826
|
prompt: promptForAdapter,
|
|
718
827
|
systemPrompt,
|
|
@@ -732,11 +841,18 @@ class ChatManager {
|
|
|
732
841
|
// not pipeline jobs. Telemetry is scoped to QueueManager pipeline runs only.
|
|
733
842
|
// spawnAiCli reroutes multi-line argv values through stdin on Windows.
|
|
734
843
|
const spawnCwd = this._resolveSpawnCwd(conversation.kind, conversationScope, adapter.id);
|
|
735
|
-
// Relocate-artifacts env for NON-explore (sidebar)
|
|
736
|
-
//
|
|
737
|
-
//
|
|
844
|
+
// Relocate-artifacts env for gated spawns: NON-explore (sidebar) turns, and
|
|
845
|
+
// Explore turns with `contextScope.mcp` (they spawn from the workspace when
|
|
846
|
+
// relocated — see `_resolveSpawnCwd`; the env makes the workspace's
|
|
847
|
+
// `${SPECRAILS_REPO_DIR:-.}` indirection resolve to the repo). Explore with
|
|
848
|
+
// mcp=false keeps its explore-cwd path (no relocation env — it reaches the
|
|
849
|
+
// repo via the explore-cwd `./project` link). Legacy ⇒ process.env
|
|
850
|
+
// (byte-identical).
|
|
738
851
|
let spawnEnv = process.env;
|
|
739
|
-
|
|
852
|
+
const exploreMcp = conversation.kind === 'explore' &&
|
|
853
|
+
!!conversationScope?.mcp &&
|
|
854
|
+
process.env.SPECRAILS_EXPLORE_LEGACY_CWD !== '1';
|
|
855
|
+
if (conversation.kind !== 'explore' || exploreMcp) {
|
|
740
856
|
const exec = this._resolveNonExploreExecution();
|
|
741
857
|
if (exec?.relocated) {
|
|
742
858
|
spawnEnv = { ...process.env, ...exec.env };
|
|
@@ -744,6 +860,11 @@ class ChatManager {
|
|
|
744
860
|
spawnEnv = { ...spawnEnv, GEMINI_CLI_TRUST_WORKSPACE: 'true' };
|
|
745
861
|
}
|
|
746
862
|
}
|
|
863
|
+
const allowMissingSessionRecovery = adapter.id === 'claude' &&
|
|
864
|
+
conversation.kind === 'explore' &&
|
|
865
|
+
exploreMcp &&
|
|
866
|
+
spawnCwd !== this._cwd &&
|
|
867
|
+
spawnEnv.SPECRAILS_WORKSPACE_DIR === spawnCwd;
|
|
747
868
|
// Big bet #3 fast-path: persistent-stdin multi-turn for Explore (claude
|
|
748
869
|
// only, flag-gated default OFF). Reuses a single long-lived child across
|
|
749
870
|
// turns so turns 2+ skip spawn + session rehydration. Full fallback to the
|
|
@@ -753,8 +874,9 @@ class ChatManager {
|
|
|
753
874
|
adapter.capabilities.persistentStdin === true) {
|
|
754
875
|
return await this._streamPersistentExploreTurn({
|
|
755
876
|
conversationId, conversation, adapter, binary, model, systemPrompt,
|
|
756
|
-
scopeFlags, spawnCwd, promptForAdapter, isFirstTurn, userText,
|
|
757
|
-
lightweight, conversationScope,
|
|
877
|
+
scopeFlags, spawnCwd, spawnEnv, promptForAdapter, isFirstTurn, userText,
|
|
878
|
+
lightweight, conversationScope, buildRecoveryPrompt,
|
|
879
|
+
allowMissingSessionRecovery,
|
|
758
880
|
});
|
|
759
881
|
}
|
|
760
882
|
const child = this._spawnOwned(binary, args, {
|
|
@@ -802,6 +924,7 @@ class ChatManager {
|
|
|
802
924
|
* `error`). When set, the turn failed for a concrete reason (usage limit,
|
|
803
925
|
* auth, model) that a respawn cannot fix — surface it instead of retrying. */
|
|
804
926
|
let capturedErrorMessage = null;
|
|
927
|
+
let resumeRecoveryAttempted = false;
|
|
805
928
|
const turnStartedAt = new Date().toISOString();
|
|
806
929
|
const stdoutReader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
|
|
807
930
|
const emitDelta = (newText) => {
|
|
@@ -924,6 +1047,124 @@ class ChatManager {
|
|
|
924
1047
|
console.log(`[chat-manager] ${adapter.id} exited code=${code} conv=${conversationId}`);
|
|
925
1048
|
const fullText = this._buffers.get(conversationId) ?? '';
|
|
926
1049
|
const wasAborting = this._abortingConversations.has(conversationId);
|
|
1050
|
+
// A session created before relocated MCP Explore moved from repo cwd to
|
|
1051
|
+
// workspace cwd cannot be found in Claude's per-cwd session store. This
|
|
1052
|
+
// is not a generic crash retry: recover ONLY the exact provider
|
|
1053
|
+
// diagnostic, only before any text/tool side effect, and only once.
|
|
1054
|
+
const missingPriorSession = allowMissingSessionRecovery &&
|
|
1055
|
+
action === 'chat-resume' &&
|
|
1056
|
+
!resumeRecoveryAttempted &&
|
|
1057
|
+
!wasAborting &&
|
|
1058
|
+
fullText.length === 0 &&
|
|
1059
|
+
!adapterEvents.some((event) => event.kind === 'tool-use') &&
|
|
1060
|
+
(containsMissingClaudeSessionDiagnostic(capturedErrorMessage) ||
|
|
1061
|
+
containsMissingClaudeSessionDiagnostic(stderrBuf) ||
|
|
1062
|
+
adapterEvents.some((event) => event.kind === 'result' &&
|
|
1063
|
+
isMissingClaudeSessionErrorResult(event.payload)));
|
|
1064
|
+
if (missingPriorSession) {
|
|
1065
|
+
resumeRecoveryAttempted = true;
|
|
1066
|
+
// The old id is provably unusable from the corrected cwd. Clearing it
|
|
1067
|
+
// before the fresh attempt prevents every later turn from paying the
|
|
1068
|
+
// same failed lookup if this recovery spawn itself cannot complete.
|
|
1069
|
+
(0, db_1.updateConversation)(this._db, conversationId, { session_id: null });
|
|
1070
|
+
const recoveryPrompt = buildRecoveryPrompt();
|
|
1071
|
+
const recoveryArgs = adapter.buildArgs('chat-turn', {
|
|
1072
|
+
prompt: recoveryPrompt,
|
|
1073
|
+
systemPrompt,
|
|
1074
|
+
model,
|
|
1075
|
+
maxTurns: options?.maxTurns,
|
|
1076
|
+
extraArgs: scopeFlags,
|
|
1077
|
+
loadUserEnv: adapter.id === 'claude' && !!conversationScope?.userMcp,
|
|
1078
|
+
});
|
|
1079
|
+
console.warn(`[chat-manager] stale Explore session; retrying fresh for ${conversationId}`);
|
|
1080
|
+
try {
|
|
1081
|
+
const newChild = this._spawnOwned(binary, recoveryArgs, {
|
|
1082
|
+
env: spawnEnv,
|
|
1083
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1084
|
+
cwd: spawnCwd,
|
|
1085
|
+
});
|
|
1086
|
+
currentChild = newChild;
|
|
1087
|
+
closeChildren.add(newChild);
|
|
1088
|
+
args = recoveryArgs;
|
|
1089
|
+
// The failed lookup reached a terminal result but burned no model
|
|
1090
|
+
// turn. Keep it visible in analytics, then isolate the fresh turn's
|
|
1091
|
+
// canonical result/session id from the stale frame.
|
|
1092
|
+
this._recordChatInvocation({
|
|
1093
|
+
conversationId,
|
|
1094
|
+
kind: conversation.kind,
|
|
1095
|
+
adapter,
|
|
1096
|
+
events: adapterEvents,
|
|
1097
|
+
model,
|
|
1098
|
+
status: 'failed',
|
|
1099
|
+
startedAt: turnStartedAt,
|
|
1100
|
+
});
|
|
1101
|
+
this._buffers.set(conversationId, '');
|
|
1102
|
+
this._streamFilters.set(conversationId, { inBlock: false, pendingTail: '' });
|
|
1103
|
+
adapterEvents.length = 0;
|
|
1104
|
+
capturedSessionId = null;
|
|
1105
|
+
capturedErrorMessage = null;
|
|
1106
|
+
sawResult = false;
|
|
1107
|
+
stderrBuf = '';
|
|
1108
|
+
// Consume the normal Explore crash budget so a failed fresh retry is
|
|
1109
|
+
// surfaced rather than replaying the same user turn a third time.
|
|
1110
|
+
const life = this._exploreLifecycle.get(conversationId);
|
|
1111
|
+
if (life)
|
|
1112
|
+
life.crashCount = 1;
|
|
1113
|
+
this._activeProcesses.set(conversationId, newChild);
|
|
1114
|
+
newChild.stderr?.on('data', (chunk) => {
|
|
1115
|
+
if (this._disposed)
|
|
1116
|
+
return;
|
|
1117
|
+
const text = chunk.toString();
|
|
1118
|
+
stderrBuf += text;
|
|
1119
|
+
console.error(`[chat-manager] ${binary} stderr (${conversationId}):`, text.trim());
|
|
1120
|
+
});
|
|
1121
|
+
newChild.on('error', (err) => {
|
|
1122
|
+
if (this._disposed) {
|
|
1123
|
+
cancelForShutdown();
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
console.error(`[chat-manager] stale-session recovery spawn failed for ${conversationId}: ${err.message}`);
|
|
1127
|
+
this._recordChatInvocation({
|
|
1128
|
+
conversationId,
|
|
1129
|
+
kind: conversation.kind,
|
|
1130
|
+
adapter,
|
|
1131
|
+
events: adapterEvents,
|
|
1132
|
+
model,
|
|
1133
|
+
status: 'failed',
|
|
1134
|
+
startedAt: turnStartedAt,
|
|
1135
|
+
});
|
|
1136
|
+
this._activeProcesses.delete(conversationId);
|
|
1137
|
+
this._buffers.delete(conversationId);
|
|
1138
|
+
this._emittedProposals.delete(conversationId);
|
|
1139
|
+
this._abortingConversations.delete(conversationId);
|
|
1140
|
+
this._streamFilters.delete(conversationId);
|
|
1141
|
+
const activeLife = this._exploreLifecycle.get(conversationId);
|
|
1142
|
+
if (activeLife) {
|
|
1143
|
+
activeLife.isStreaming = false;
|
|
1144
|
+
activeLife.lastActivityAt = Date.now();
|
|
1145
|
+
if (activeLife.isMinimized)
|
|
1146
|
+
this._startIdleTimer(conversationId);
|
|
1147
|
+
}
|
|
1148
|
+
this._drainExploreQueue();
|
|
1149
|
+
this._broadcast({
|
|
1150
|
+
type: 'chat_error',
|
|
1151
|
+
conversationId,
|
|
1152
|
+
error: `Failed to launch ${binary}: ${err.message}`,
|
|
1153
|
+
timestamp: new Date().toISOString(),
|
|
1154
|
+
});
|
|
1155
|
+
complete();
|
|
1156
|
+
});
|
|
1157
|
+
const newReader = (0, readline_1.createInterface)({ input: newChild.stdout, crlfDelay: Infinity });
|
|
1158
|
+
readers.add(newReader);
|
|
1159
|
+
newReader.on('line', readerHandler);
|
|
1160
|
+
newChild.on('close', onClose);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
catch (err) {
|
|
1164
|
+
console.error('[chat-manager] stale-session recovery spawn failed:', err);
|
|
1165
|
+
// Fall through and surface the original missing-session error.
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
927
1168
|
// Crash auto-respawn for Explore: if the child exited non-zero before
|
|
928
1169
|
// emitting a `result` event, the user did not explicitly abort, and
|
|
929
1170
|
// we have not yet retried, respawn the same turn once via chat-resume
|
|
@@ -931,6 +1172,7 @@ class ChatManager {
|
|
|
931
1172
|
// See design.md D7.
|
|
932
1173
|
if (conversation.kind === 'explore' &&
|
|
933
1174
|
!wasAborting &&
|
|
1175
|
+
!resumeRecoveryAttempted &&
|
|
934
1176
|
code !== 0 &&
|
|
935
1177
|
!sawResult &&
|
|
936
1178
|
// A provider-reported failure (usage limit, auth, unsupported model)
|
|
@@ -958,7 +1200,7 @@ class ChatManager {
|
|
|
958
1200
|
console.warn(`[chat-manager] explore crash respawn for ${conversationId}`);
|
|
959
1201
|
try {
|
|
960
1202
|
const newChild = this._spawnOwned(binary, respawnArgs, {
|
|
961
|
-
env:
|
|
1203
|
+
env: spawnEnv,
|
|
962
1204
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
963
1205
|
cwd: spawnCwd,
|
|
964
1206
|
});
|
|
@@ -1180,7 +1422,7 @@ class ChatManager {
|
|
|
1180
1422
|
async _streamPersistentExploreTurn(p) {
|
|
1181
1423
|
if (this._disposed)
|
|
1182
1424
|
return;
|
|
1183
|
-
const { conversationId, conversation, adapter, binary, model, systemPrompt, scopeFlags, spawnCwd, promptForAdapter, isFirstTurn, userText, lightweight, conversationScope, } = p;
|
|
1425
|
+
const { conversationId, conversation, adapter, binary, model, systemPrompt, scopeFlags, spawnCwd, spawnEnv, promptForAdapter, isFirstTurn, userText, lightweight, conversationScope, buildRecoveryPrompt, } = p;
|
|
1184
1426
|
const sessionArgs = adapter.buildArgs('chat-stream', {
|
|
1185
1427
|
prompt: '',
|
|
1186
1428
|
systemPrompt,
|
|
@@ -1190,7 +1432,7 @@ class ChatManager {
|
|
|
1190
1432
|
loadUserEnv: adapter.id === 'claude' && !!conversationScope?.userMcp,
|
|
1191
1433
|
});
|
|
1192
1434
|
const { child, isNew } = this._stdinSessions.getOrSpawn(conversationId, {
|
|
1193
|
-
binary, args: sessionArgs, cwd: spawnCwd, env:
|
|
1435
|
+
binary, args: sessionArgs, cwd: spawnCwd, env: spawnEnv, spawn: this._spawnOwned.bind(this),
|
|
1194
1436
|
});
|
|
1195
1437
|
this._trackPersistentProcess(conversationId, child);
|
|
1196
1438
|
// MED-1: a fresh child restarts claude's session-cumulative counters at 0,
|
|
@@ -1344,6 +1586,51 @@ class ChatManager {
|
|
|
1344
1586
|
// turn; flip to 'failed' (the cost is still kept — the result carries real
|
|
1345
1587
|
// usage). Set at result time, read by finishTurn.
|
|
1346
1588
|
let resultIsError = false;
|
|
1589
|
+
const recoverMissingSession = (diagnostic) => {
|
|
1590
|
+
if (settled ||
|
|
1591
|
+
!p.allowMissingSessionRecovery ||
|
|
1592
|
+
!conversation.session_id ||
|
|
1593
|
+
p.resumeRecoveryAttempted ||
|
|
1594
|
+
this._abortingConversations.has(conversationId) ||
|
|
1595
|
+
(this._buffers.get(conversationId) ?? '').length > 0 ||
|
|
1596
|
+
adapterEvents.some((event) => event.kind === 'tool-use') ||
|
|
1597
|
+
!(containsMissingClaudeSessionDiagnostic(diagnostic) ||
|
|
1598
|
+
containsMissingClaudeSessionDiagnostic(stderrBuf))) {
|
|
1599
|
+
return false;
|
|
1600
|
+
}
|
|
1601
|
+
// The persistent child was launched with the stale --resume before its
|
|
1602
|
+
// first stdin turn. Evict it, but keep the Explore lifecycle slot held
|
|
1603
|
+
// while a brand-new stream child receives the same turn exactly once.
|
|
1604
|
+
settled = true;
|
|
1605
|
+
// Persist the invalidation before replacing the old stream child. A
|
|
1606
|
+
// failed fresh attempt must not leave the known-bad repo-cwd id behind.
|
|
1607
|
+
(0, db_1.updateConversation)(this._db, conversationId, { session_id: null });
|
|
1608
|
+
this._stdinSessions.clearHandlers(conversationId);
|
|
1609
|
+
cleanupTurnState();
|
|
1610
|
+
recordInv('failed');
|
|
1611
|
+
this._stdinSessions.kill(conversationId);
|
|
1612
|
+
console.warn(`[chat-manager] stale persistent Explore session; retrying fresh for ${conversationId}`);
|
|
1613
|
+
void this._streamPersistentExploreTurn({
|
|
1614
|
+
...p,
|
|
1615
|
+
conversation: { ...conversation, session_id: null },
|
|
1616
|
+
promptForAdapter: buildRecoveryPrompt(),
|
|
1617
|
+
isFirstTurn: false,
|
|
1618
|
+
resumeRecoveryAttempted: true,
|
|
1619
|
+
}).then(complete).catch((err) => {
|
|
1620
|
+
if (!this._disposed) {
|
|
1621
|
+
console.error(`[chat-manager] persistent stale-session recovery failed for ${conversationId}:`, err);
|
|
1622
|
+
markStreamingEnded(false);
|
|
1623
|
+
this._broadcast({
|
|
1624
|
+
type: 'chat_error',
|
|
1625
|
+
conversationId,
|
|
1626
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1627
|
+
timestamp: new Date().toISOString(),
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
complete();
|
|
1631
|
+
});
|
|
1632
|
+
return true;
|
|
1633
|
+
};
|
|
1347
1634
|
const finishTurn = () => {
|
|
1348
1635
|
if (settled)
|
|
1349
1636
|
return;
|
|
@@ -1410,6 +1697,8 @@ class ChatManager {
|
|
|
1410
1697
|
cancelForShutdown();
|
|
1411
1698
|
return;
|
|
1412
1699
|
}
|
|
1700
|
+
if (recoverMissingSession(stderrBuf))
|
|
1701
|
+
return;
|
|
1413
1702
|
settled = true;
|
|
1414
1703
|
this._stdinSessions.clearHandlers(conversationId);
|
|
1415
1704
|
const wasAborting = this._abortingConversations.has(conversationId);
|
|
@@ -1449,6 +1738,8 @@ class ChatManager {
|
|
|
1449
1738
|
break;
|
|
1450
1739
|
case 'result': {
|
|
1451
1740
|
const payload = ev.payload;
|
|
1741
|
+
if (recoverMissingSession(isMissingClaudeSessionErrorResult(payload) ? payload : null))
|
|
1742
|
+
break;
|
|
1452
1743
|
if (payload.session_id)
|
|
1453
1744
|
capturedSessionId = payload.session_id;
|
|
1454
1745
|
// LOW-7: detect a failed turn reported through the result frame.
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
// Context Scope — per-spec configuration that controls what context is fed
|
|
3
3
|
// into the Add Spec flow (Quick `generate-spec` and Explore chat turns).
|
|
4
4
|
//
|
|
5
|
-
//
|
|
5
|
+
// Six orthogonal toggles:
|
|
6
6
|
// - specrails: concat <project>/.specrails/local-tickets.json (all tickets)
|
|
7
7
|
// into the system prompt
|
|
8
8
|
// - openspec: concat <project>/openspec/specs/**/spec.md into the system prompt
|
|
9
|
-
// - full:
|
|
10
|
-
//
|
|
9
|
+
// - full: enable source inspection; with an MCP toggle the high tier
|
|
10
|
+
// retains Bash/MCP tools while denying GUI file-writer tools
|
|
11
|
+
// - mcp: Explore-only — spawn from the relocation-aware artifact cwd so
|
|
12
|
+
// .mcp.json is honored (workspace when relocated, repo when legacy)
|
|
13
|
+
// - contractRefine: run the post-commit structural refinement turn
|
|
11
14
|
// - userMcp: Explore-only — inject the user's own already-approved MCP
|
|
12
15
|
// servers (claude: ~/.claude.json user+local scope, fed via
|
|
13
16
|
// `--mcp-config`; codex: ~/.codex is read natively). Independent
|