specrails-desktop 2.43.0 → 2.43.1

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.
@@ -314,3 +314,56 @@ delivery row — stranded uncommitted work. Loops disabled ⇒ the legacy QueueM
314
314
  unchanged. Relatedly, the agent-chat operator prompt gained the ask-confirmation-once
315
315
  turn-discipline rule (`server/agent-operator-prompt.ts`): a confirmation question is asked
316
316
  exactly once and ends the reply — the answer arrives as the next user message.
317
+
318
+ ## Decider starved by its own tool budget (2026-09-07)
319
+
320
+ A two-repo `factory:implement` run (pipeline run `47b9a423`) finished its work, and the
321
+ verify step answered `VERIFICATION: PASS` with a freshly re-run receipt across both
322
+ repositories. The loop continued anyway. The Decider step's log line was the tell:
323
+
324
+ ```
325
+ Decision: continue — error_max_turns: Reached maximum number of turns (1)
326
+ ```
327
+
328
+ That is not a verdict. The Decider never emitted one — it died before answering, and the
329
+ executor's fail-open default did the rest.
330
+
331
+ - **The starvation.** `runDecider` spawned with `maxTurns: 1` *and* `toolPolicy:
332
+ 'read-only'`, which on claude is `--tools Read,Grep,Glob` **plus `--permission-mode plan
333
+ --safe-mode`**. One tool call — a repo read, or plan mode's own exit call — consumes the
334
+ single allowed turn, so the run ends `error_max_turns` **before** the JSON verdict is
335
+ produced. Nothing about it is intermittent: a Decider that touches any tool can never
336
+ answer. The step burned 2 turns and ~$0.09 to say nothing.
337
+ - **Reproduced across loops and tiers.** A `SDD Quick (OpenSpec)` run on claude/**sonnet**
338
+ showed the identical signature — every Decider step `2 turns`, ~7 s, `Decision: continue
339
+ — error_max_turns`. Its verify step reported `VERIFICATION: PASS` twice, and the loop
340
+ still cycled `opsx:ff → opsx:apply → opsx:verify` for two full iterations (the fix steps
341
+ correctly finding nothing to do and saying so) until the user cancelled it. So the bug is
342
+ not model-, tier- or loop-specific: it is every Decider on claude.
343
+ - **Why it became a `continue`.** A failed Decider invocation is deliberately forced to
344
+ `{ continue: true, parsed: false }` — a streamed verdict from an interrupted run is not
345
+ an authoritative completion gate, and `maxIterations`/`timeout` are the hard stop. Sound
346
+ in isolation, but combined with the starvation above it meant *every* such run silently
347
+ discarded a real STOP and paid for another iteration: here a fix step that invented work
348
+ (tautological cascade tests, a mutation probe, a wrong conclusion, then a full revert)
349
+ plus a second full re-verification, for no change to the delivered candidate.
350
+ - **The fix.** The Decider judges from the prompt it is GIVEN (goal + spec + iteration
351
+ history) and must answer with one JSON object, so it needs no tools at all.
352
+ `runDecider` now picks the tightest boundary the CLI enforces via
353
+ `pureOutputToolPolicy(adapter)`: `'none'` on claude (`--tools __none__`, no approval
354
+ bypass needed), `'read-only'` on codex/gemini — byte-identical there, since neither has
355
+ a native no-tools mode. The null case is unreachable: `LoopRunManager.run` already
356
+ rejects a decider-bearing graph whose provider cannot enforce `read-only` natively
357
+ (kimi), before allocating or persisting the run. `'none'` also drops the plan-mode flags
358
+ (the adapter attaches them only to `read-only`), removing the second way the single turn
359
+ could be consumed. This aligns the Decider with the repo's other single-turn spawns —
360
+ `file-summary-generator.ts` and both `contract-refine-runner.ts` invocations already
361
+ resolve a pure-output policy rather than hardcoding `read-only`; the Decider was the last
362
+ one left with tools it could not afford to use.
363
+ - **Deliberately NOT changed:** the fail-open `continue` on a genuinely failed Decider
364
+ (timeout, spawn failure, non-zero exit, provider limit). The starvation was the bug; the
365
+ default is the safety net, and with tools gone it stops firing on every healthy run.
366
+ - **Adjacent, still open:** the verify step re-ran the full cross-repository suite each
367
+ iteration because the Core receipt is invalidated per session (*"Verification environment
368
+ changed: ./mvnw / npm"*) even when the candidate hash is identical. That is a receipt
369
+ env-hash question in specrails-core, not a desktop one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.43.0",
3
+ "version": "2.43.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -488,7 +488,20 @@ function createLoopExecutors(opts = {}) {
488
488
  const decEnv = aiStepEnv(baseEnv, repoDir, executionManifest);
489
489
  // spec-gen is a one-shot, system-prompted invocation (workspace-write on
490
490
  // codex, not full-access) — appropriate for a read-only judgment.
491
- const buildOpts = { prompt: userPrompt, systemPrompt, model, maxTurns: 1, reasoning_effort: effort, toolPolicy: 'read-only', ...(executionManifest ? { extraArgs: aiStepExtraArgs(adapter, cwd, repoDir, executionManifest) } : {}) };
491
+ //
492
+ // The Decider judges the goal from the prompt it is GIVEN (goal + spec +
493
+ // iteration history) and must answer with a single JSON object, so it
494
+ // needs no tools. Granting them was actively harmful on claude: with
495
+ // `--max-turns 1`, one Read/Grep call consumes the whole turn budget and
496
+ // the run ends `error_max_turns` BEFORE the verdict is emitted. A failed
497
+ // Decider invocation is forced to `continue` below, so every such run
498
+ // silently discarded a real STOP verdict and burned another iteration.
499
+ // `pureOutputToolPolicy` picks the tightest boundary the CLI enforces:
500
+ // 'none' on claude, 'read-only' on codex/gemini (byte-identical to the
501
+ // previous behaviour there). The null case is unreachable — `run()`
502
+ // rejects a decider graph whose provider cannot enforce 'read-only'.
503
+ const toolPolicy = (0, runtime_1.pureOutputToolPolicy)(adapter) ?? 'read-only';
504
+ const buildOpts = { prompt: userPrompt, systemPrompt, model, maxTurns: 1, reasoning_effort: effort, toolPolicy, ...(executionManifest ? { extraArgs: aiStepExtraArgs(adapter, cwd, repoDir, executionManifest) } : {}) };
492
505
  const wallStartedAt = Date.now();
493
506
  const res = await (0, spawn_lifecycle_1.runAiCliInvocation)({
494
507
  adapter,