@mjasnikovs/pi-task 0.38.15 → 0.38.17

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 (86) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  3. package/dist/shared/child-process.js +9 -16
  4. package/dist/task/accept-debt.d.ts +7 -5
  5. package/dist/task/accept-debt.js +18 -14
  6. package/dist/task/artifact-closure.js +18 -63
  7. package/dist/task/auto-orchestrator.js +211 -218
  8. package/dist/task/autofix-ledger.d.ts +113 -0
  9. package/dist/task/autofix-ledger.js +152 -0
  10. package/dist/task/boot-probe.d.ts +109 -1
  11. package/dist/task/boot-probe.js +139 -23
  12. package/dist/task/child-runner.d.ts +50 -6
  13. package/dist/task/child-runner.js +48 -69
  14. package/dist/task/command-run.d.ts +49 -6
  15. package/dist/task/command-run.js +154 -18
  16. package/dist/task/coverage-loop.d.ts +11 -0
  17. package/dist/task/coverage-loop.js +16 -0
  18. package/dist/task/external-context.d.ts +9 -12
  19. package/dist/task/external-context.js +5 -5
  20. package/dist/task/failure-classifier.d.ts +9 -1
  21. package/dist/task/failure-classifier.js +9 -0
  22. package/dist/task/final-gate-fix.d.ts +22 -26
  23. package/dist/task/final-gate-fix.js +16 -31
  24. package/dist/task/final-gate.d.ts +10 -2
  25. package/dist/task/final-gate.js +55 -89
  26. package/dist/task/fix-child.d.ts +64 -0
  27. package/dist/task/fix-child.js +66 -0
  28. package/dist/task/gate-deps.js +20 -13
  29. package/dist/task/lint-fix.d.ts +7 -0
  30. package/dist/task/lint-fix.js +45 -9
  31. package/dist/task/orchestrator.d.ts +33 -24
  32. package/dist/task/orchestrator.js +75 -46
  33. package/dist/task/phases.d.ts +120 -34
  34. package/dist/task/phases.js +221 -134
  35. package/dist/task/plan-orchestrator.js +2 -2
  36. package/dist/task/plan-rounds.d.ts +86 -0
  37. package/dist/task/plan-rounds.js +105 -0
  38. package/dist/task/plan-session.d.ts +31 -21
  39. package/dist/task/plan-session.js +97 -120
  40. package/dist/task/qa-transcript.d.ts +100 -0
  41. package/dist/task/qa-transcript.js +99 -0
  42. package/dist/task/question-source.d.ts +117 -0
  43. package/dist/task/question-source.js +174 -0
  44. package/dist/task/repo-health-check.d.ts +21 -21
  45. package/dist/task/repo-health-check.js +43 -112
  46. package/dist/task/run-end.d.ts +77 -0
  47. package/dist/task/run-end.js +37 -0
  48. package/dist/task/run-final-gate.js +71 -79
  49. package/dist/task/serve-entry.js +6 -57
  50. package/dist/task/shipped-source.d.ts +67 -0
  51. package/dist/task/shipped-source.js +144 -0
  52. package/dist/task/task-gates.d.ts +9 -1
  53. package/dist/task/task-gates.js +27 -6
  54. package/dist/task/terminal-outcome.d.ts +1 -1
  55. package/dist/task/terminal-outcome.js +12 -0
  56. package/dist/task/verify-work.d.ts +46 -0
  57. package/dist/task/verify-work.js +51 -3
  58. package/dist/workers/brave-search.d.ts +7 -0
  59. package/dist/workers/brave-search.js +36 -55
  60. package/dist/workers/ddg-search.d.ts +1 -1
  61. package/dist/workers/ddg-search.js +27 -47
  62. package/dist/workers/docs-core.d.ts +71 -1
  63. package/dist/workers/docs-core.js +131 -71
  64. package/dist/workers/exa-search.d.ts +2 -2
  65. package/dist/workers/exa-search.js +53 -68
  66. package/dist/workers/html-clean.js +67 -88
  67. package/dist/workers/http-request.d.ts +74 -0
  68. package/dist/workers/http-request.js +103 -0
  69. package/dist/workers/npm-version.js +37 -42
  70. package/dist/workers/pi-worker-core.d.ts +13 -2
  71. package/dist/workers/pi-worker-core.js +35 -25
  72. package/dist/workers/pi-worker-docs.d.ts +1 -1
  73. package/dist/workers/pi-worker-docs.js +49 -68
  74. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  75. package/dist/workers/pi-worker-fetch.js +20 -21
  76. package/dist/workers/pi-worker-search.js +6 -4
  77. package/dist/workers/pi-worker.js +5 -4
  78. package/dist/workers/search-core.d.ts +1 -1
  79. package/dist/workers/search-core.js +36 -42
  80. package/dist/workers/search-types.d.ts +13 -0
  81. package/dist/workers/search-types.js +27 -0
  82. package/dist/workers/shared.d.ts +51 -11
  83. package/dist/workers/shared.js +0 -0
  84. package/dist/workers/worker-channels.d.ts +60 -0
  85. package/dist/workers/worker-channels.js +98 -0
  86. package/package.json +1 -1
@@ -24,7 +24,7 @@ import { readEnvNotes, appendEnvNotes } from './env-notes.js';
24
24
  import { readContracts } from './contracts.js';
25
25
  import { recordDebt } from './accept-debt.js';
26
26
  import { recordRepairCandidate } from './root-cause-repair.js';
27
- import { runRepoHealthCheck, runRepoHealthCheckAsync } from './repo-health-check.js';
27
+ import { runRepoHealthCheck } from './repo-health-check.js';
28
28
  import { runFinalIntegrationGate, discoverGateCommandLabels, discoverGateCommandBodies } from './final-gate.js';
29
29
  import { runFinalGateAutofix } from './final-gate-fix.js';
30
30
  import { researchResolution } from './verify-resolution.js';
@@ -755,14 +755,19 @@ export function buildGateDeps(params) {
755
755
  // own lint/typecheck and fails on a real non-zero exit, independent of
756
756
  // the model-authored VERIFY block (which may not lint at all). ASYNC:
757
757
  // the sync runner froze the event loop for the whole lint (see above).
758
- repoHealth: () => deadAirBaseline ?
759
- Promise.resolve(runRepoHealthCheck(cwd2))
760
- : runRepoHealthCheckAsync(cwd2, {
761
- signal,
762
- onCommand: c => {
763
- stageLine = `repo health · ${c}`;
764
- }
765
- }),
758
+ // ONE call, both arms. The baseline arm used to call this
759
+ // without the signal or the progress hook, because
760
+ // `runRepoHealthCheck` was SYNCHRONOUS and blocking the event
761
+ // loop was the thing being measured. It is async now, so that
762
+ // branch measured treatment against treatment — and it also
763
+ // made `DEADAIR_AB_ARM=baseline` silently uncancellable and
764
+ // mute. The arm's real difference is the LOADER, above.
765
+ repoHealth: () => runRepoHealthCheck(cwd2, {
766
+ signal,
767
+ onCommand: c => {
768
+ stageLine = `repo health · ${c}`;
769
+ }
770
+ }),
766
771
  // The deterministic probes, bound in one place (buildVerifyProbes
767
772
  // above); the PROBE_ADAPTERS table in verify-work.ts runs them.
768
773
  probes: buildVerifyProbes({
@@ -814,7 +819,7 @@ export function buildGateDeps(params) {
814
819
  signal,
815
820
  failReason,
816
821
  runChild: gateChild(fixCtx, cwd2, taskTitle, 'lint-fix', 'verify-debug.log'),
817
- repoHealth: () => runRepoHealthCheckAsync(cwd2, { signal }),
822
+ repoHealth: () => runRepoHealthCheck(cwd2, { signal }),
818
823
  git: async (args) => {
819
824
  const r = await git(cwd2, args, signal);
820
825
  return { exitCode: r.exitCode, stdout: r.stdout };
@@ -846,7 +851,7 @@ export function buildGateDeps(params) {
846
851
  startedAt,
847
852
  lastLine: running ? `repo health · ${running}` : 'repo health'
848
853
  }));
849
- return runRepoHealthCheckAsync(cwd2, {
854
+ return runRepoHealthCheck(cwd2, {
850
855
  signal,
851
856
  onCommand: c => {
852
857
  running = c;
@@ -865,7 +870,9 @@ export function buildGateDeps(params) {
865
870
  runChild: gateChild(fixCtx, cwd2, 'final integration gate', 'final-fix', 'final-gate-debug.log'),
866
871
  // The gate re-run is the only arbiter of convergence, and the
867
872
  // shrink guard's discovery is the gate's own (see final-gate.ts).
868
- gate: c => runFinalIntegrationGate(c),
873
+ // The run's cancel reaches the re-run too. Without it the whole
874
+ // `FinalGateOptions.signal` path is inert in the shipped code.
875
+ gate: c => runFinalIntegrationGate(c, { signal }),
869
876
  discoverLabels: discoverGateCommandLabels,
870
877
  discoverBodies: discoverGateCommandBodies,
871
878
  discard: discardTreeEdits,
@@ -893,7 +900,7 @@ export function buildGateDeps(params) {
893
900
  // depend on it.
894
901
  ignoredSnapshot: () => collectIgnoredSnapshot(cwd2, signal),
895
902
  ...(ignoredKnown && ignoredKnown.length > 0 ? { ignoredKnown } : {}),
896
- gateWithoutIgnored: paths => gatePassesWithoutIgnored(cwd2, paths, c => runFinalIntegrationGate(c), makeDebugAppender(path.join(tasksDir(cwd2), 'final-gate-debug.log'))),
903
+ gateWithoutIgnored: paths => gatePassesWithoutIgnored(cwd2, paths, c => runFinalIntegrationGate(c, { signal }), makeDebugAppender(path.join(tasksDir(cwd2), 'final-gate-debug.log'))),
897
904
  log: makeDebugAppender(path.join(tasksDir(cwd2), 'final-gate-debug.log'))
898
905
  }),
899
906
  recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
@@ -42,6 +42,13 @@ export interface LintFixDeps {
42
42
  * deletion guard is disarmed (prior behavior).
43
43
  */
44
44
  introducedBy?: (rel: string) => Promise<string | null>;
45
+ /**
46
+ * Debug-log sink. This dep did not exist, so all four of this pass's guard
47
+ * trips were invisible in the trail while the twin (`FinalFixDeps.log`) logged
48
+ * three of its own — and a guard whose firing leaves no record cannot be
49
+ * distinguished from one that never armed.
50
+ */
51
+ log?: (msg: string) => void;
45
52
  }
46
53
  /** The fix child edits and runs the checker; bash exists to RUN the check, not git. */
47
54
  export declare const LINT_FIX_TOOLS = "read,edit,bash";
@@ -58,6 +58,7 @@
58
58
  * `currentTaskId` and `introducedBy` are wired.
59
59
  */
60
60
  import { parseChangedFrozenFiles, pathNamedIn, revertFrozenPaths } from './frozen-path-guard.js';
61
+ import { runFixChild } from './fix-child.js';
61
62
  import { parseTreeChanges } from './write-guard.js';
62
63
  import { findCrossTaskDeletions } from './task-provenance.js';
63
64
  /** The fix child edits and runs the checker; bash exists to RUN the check, not git. */
@@ -204,16 +205,27 @@ export async function runBoundedLintFix(deps) {
204
205
  // guard for this run (inconclusive ≠ evidence).
205
206
  const deletionGuardArmed = Boolean(deps.currentTaskId && deps.introducedBy);
206
207
  const preChanges = deletionGuardArmed ? await treeChanges(deps) : null;
207
- try {
208
- await deps.runChild(LINT_FIX_TOOLS, buildLintFixPrompt(deps.failReason, frozen), deps.signal);
209
- }
210
- catch (err) {
211
- const msg = err instanceof Error ? err.message : String(err);
212
- // A cancel must propagate so the caller's USER_CANCELLED path runs.
213
- if (msg === '__user_cancelled__')
214
- throw err;
215
- return { ok: false, reason: `fix child failed: ${msg}` };
208
+ // The four-rung ladder lives in task/fix-child.ts. A cancel still propagates
209
+ // (it THROWS, so the caller's USER_CANCELLED path is unchanged); what is new
210
+ // here is the BLOCKED rung, which this pass instructed in its prompt and then
211
+ // discarded the reply of — so a child that reported BLOCKED still paid the
212
+ // whole guard stack and a repo-health re-run (15–69s measured) to be told
213
+ // `did not converge`, instead of its own stated reason.
214
+ const end = await runFixChild({
215
+ runChild: deps.runChild,
216
+ tools: LINT_FIX_TOOLS,
217
+ prompt: buildLintFixPrompt(deps.failReason, frozen),
218
+ signal: deps.signal,
219
+ marker: 'LINT-FIX'
220
+ });
221
+ if (end.kind === 'error') {
222
+ deps.log?.(`lint-fix child failed — ${end.msg}`);
223
+ return { ok: false, reason: `fix child failed: ${end.msg}` };
216
224
  }
225
+ // A BLOCKED child is NOT an early return from here: the guards below exist to
226
+ // catch a child that discarded work, and a child can discard work and then
227
+ // block. The marker is consulted after them, in place of the re-run — which is
228
+ // where the twin consults its own.
217
229
  // REVERT-GUARD: every pre-existing work file must still differ from HEAD, and
218
230
  // every pre-existing untracked file must still exist. Trip → restore snapshot.
219
231
  // Every comparison requires git to have actually SUCCEEDED: a git error after
@@ -250,6 +262,7 @@ export async function runBoundedLintFix(deps) {
250
262
  await deps.git(['checkout', snapshot, '--', '.', EXCLUDE_TASKS_DIR]);
251
263
  await deps.git(['reset']);
252
264
  }
265
+ deps.log?.(`lint-fix REVERT-GUARD — discarded ${violations.length} work file(s)`);
253
266
  return {
254
267
  ok: false,
255
268
  reason: `revert-guard: fix pass discarded work (${violations.slice(0, 3).join(', ')}`
@@ -277,6 +290,7 @@ export async function runBoundedLintFix(deps) {
277
290
  if (crossDeletions.length > 0) {
278
291
  await deps.git(['checkout', '-f', 'HEAD', '--', ...crossDeletions.map(d => d.path)]);
279
292
  const named = crossDeletions.map(d => `${d.path} — ${d.owner}'s deliverable`);
293
+ deps.log?.(`lint-fix CROSS-TASK-DELETION GUARD — ${named[0]}`);
280
294
  return {
281
295
  ok: false,
282
296
  reason: `cross-task-deletion: fix child DELETED sibling task deliverable(s) `
@@ -300,6 +314,7 @@ export async function runBoundedLintFix(deps) {
300
314
  const frozenViolations = [...postFrozenDirty].filter(f => !preFrozenDirty.has(f));
301
315
  if (frozenViolations.length > 0) {
302
316
  const reverted = await revertFrozenPaths(frozenViolations, deps.git);
317
+ deps.log?.(`lint-fix FROZEN-PATH GUARD — ${frozenViolations.slice(0, 3).join(', ')}`);
303
318
  return {
304
319
  ok: false,
305
320
  reason: `frozen-path: fix child modified spec-frozen path(s) `
@@ -311,8 +326,28 @@ export async function runBoundedLintFix(deps) {
311
326
  }
312
327
  }
313
328
  }
329
+ if (end.kind === 'blocked')
330
+ deps.log?.(`lint-fix BLOCKED — ${end.note}`);
331
+ // The CHECK is the arbiter, including after a BLOCKED marker.
332
+ //
333
+ // The marker is scraped last-match-wins out of arbitrary child output, and
334
+ // LINT_FIX_TOOLS carries bash — so the child's own command output is in that
335
+ // text. A child can also genuinely converge and THEN block: `eslint --fix`
336
+ // silently clears the last finding, and the model still reports
337
+ // `LINT-FIX: BLOCKED the generated file is frozen`. Returning not-applied on
338
+ // the marker alone would send the gate to `deps.recommend(...)` with a
339
+ // failReason that no longer describes the tree, skipping the re-verify and
340
+ // burning a full implementation re-run on findings that are already gone —
341
+ // while the child's edits sit in the working tree.
342
+ //
343
+ // So BLOCKED does not decide; it only supplies a better REASON when the check
344
+ // agrees nothing converged. That was the durable half of the win. Skipping the
345
+ // re-run was the other half, and it is not worth this.
314
346
  const health = await deps.repoHealth();
315
347
  if (!health.ok) {
348
+ if (end.kind === 'blocked') {
349
+ return { ok: false, reason: `fix child blocked: ${end.note}` };
350
+ }
316
351
  // FROZEN-PATH TRACE on non-convergence (PROMPT 1 layer B): when the child
317
352
  // was honest — it did NOT touch the frozen path, so the guard above never
318
353
  // tripped — but the check is still red and its own output NAMES a frozen
@@ -324,6 +359,7 @@ export async function runBoundedLintFix(deps) {
324
359
  // rounds an impl re-run under the same freeze cannot converge out of.
325
360
  const implicated = frozen.filter(p => pathNamedIn(`${health.reason}\n${health.output ?? ''}`, p));
326
361
  if (implicated.length > 0) {
362
+ deps.log?.(`lint-fix FROZEN-PATH TRACE — ${implicated.slice(0, 3).join(', ')}`);
327
363
  return {
328
364
  ok: false,
329
365
  reason: `frozen-path: static findings implicate spec-frozen path(s) `
@@ -20,6 +20,7 @@ import { type WidgetState } from './widget.js';
20
20
  import { type RunTaskFn } from './gate-deps.js';
21
21
  import { type GateDeps } from './task-gates.js';
22
22
  import { type PhaseDeps } from './child-runner.js';
23
+ import { type RunEnd } from './run-end.js';
23
24
  import type { SpawnFn } from '../shared/child-process.js';
24
25
  import { type SuperviseOptions } from './implementation-turn.js';
25
26
  /**
@@ -45,6 +46,16 @@ export interface TaskRunnerOptions {
45
46
  * the child is a premise of the test, not its subject.
46
47
  */
47
48
  runChild?: PhaseDeps['runChild'];
49
+ /**
50
+ * Test seam: `PhaseDeps.runWorker(label, input)`. Present → every research
51
+ * worker is answered BY NAME, so the three Research retry gates and the
52
+ * fatal/runaway/empty classification are reachable from a runner-driven test
53
+ * without matching a marker sentence inside the prompt.
54
+ */
55
+ runWorker?: PhaseDeps['runWorker'];
56
+ /** Test seam: the four EXTERNAL CONTEXT lookups plus the file inventory. Each
57
+ * defaults to the real implementation when absent. */
58
+ lookups?: Pick<PhaseDeps, 'getFileInventory' | 'docsRaw' | 'fetchRaw' | 'npmVersionLookup' | 'docsFocused' | 'fetchFocused' | 'searchFn'>;
48
59
  /** Called with the resolved task id once its file exists, before any phase
49
60
  * work. Lets callers record the id (e.g. stamp the /task-auto entry) so an
50
61
  * interrupted run can be resumed instead of restarted. */
@@ -110,7 +121,13 @@ export declare class TaskRunner {
110
121
  * armed for the same window (`withRun`); nested inside `runGatedTask` or
111
122
  * the `/task-auto` loop the bracket refcounts, so this changes nothing there
112
123
  * and covers the fire-and-forget `runSingleTask` path on its own. */
113
- run(): Promise<void>;
124
+ /**
125
+ * Run the task and NAME how it ended.
126
+ *
127
+ * This returned `void`, so the caller re-read the task file to learn what had
128
+ * just happened here — see run-end.ts for the report that got wrong.
129
+ */
130
+ run(): Promise<RunEnd>;
114
131
  private _run;
115
132
  /** Stop the phase widget — clearing both the terminal and remote surfaces —
116
133
  * exactly once. Nulling the disposer makes repeat calls no-ops, so the
@@ -129,7 +146,7 @@ export declare class TaskRunner {
129
146
  */
130
147
  private _specForDelivery;
131
148
  }
132
- export interface RunSingleTaskOptions extends Pick<TaskRunnerOptions, 'resumeId' | 'spawnFn' | 'runChild' | 'onStart' | 'planContext' | 'fixInstruction'> {
149
+ export interface RunSingleTaskOptions extends Pick<TaskRunnerOptions, 'resumeId' | 'spawnFn' | 'runChild' | 'runWorker' | 'lookups' | 'onStart' | 'planContext' | 'fixInstruction'> {
133
150
  /** Await the session going idle after the spec is delivered, so the caller
134
151
  * blocks until the agent has implemented it. Default false. */
135
152
  waitForImplementation?: boolean;
@@ -152,8 +169,15 @@ export interface RunSingleTaskOptions extends Pick<TaskRunnerOptions, 'resumeId'
152
169
  }
153
170
  export interface RunSingleTaskResult {
154
171
  taskId: string;
155
- ok: boolean;
156
- sessionCancelled: boolean;
172
+ /**
173
+ * How the run ended, named by the runner rather than re-derived from disk.
174
+ *
175
+ * This was `ok: boolean` plus `sessionCancelled`, `interrupted` and `reason`
176
+ * — four fields for one fact, three of them smuggled out of the `withSession`
177
+ * closure through mutable captures. `/task-cancel` fell into the `!ok` arm
178
+ * and was reported as a failure; see run-end.ts.
179
+ */
180
+ end: RunEnd;
157
181
  /**
158
182
  * The session context the caller must use for any work after this call. A
159
183
  * successful run replaces the session via ctx.newSession(), which leaves the
@@ -163,30 +187,15 @@ export interface RunSingleTaskResult {
163
187
  * only so test fakes that don't model session replacement can omit it.
164
188
  */
165
189
  ctx?: ExtensionCommandContext;
166
- /**
167
- * Set when the user interrupted the implementation (ESC) and then declined to
168
- * steer (submitted an empty steer prompt) — i.e. they want the run to pause
169
- * rather than continue. Only meaningful with waitForImplementation. The
170
- * /task-auto loop reads this to pause (resumable) instead of checking the task
171
- * off and advancing. A plain ESC that the user follows with steering text does
172
- * NOT set this — that case loops on the same task until a turn finishes
173
- * uninterrupted.
174
- */
175
- interrupted?: boolean;
176
- /**
177
- * Why the run is not ok, when known. Set when a waitForImplementation turn
178
- * ended with stopReason "error" (the model/provider died mid-implementation —
179
- * e.g. a context-overflow 400 — after the task file was already marked
180
- * `completed` at spec-handoff). The /task-auto loop surfaces this in its
181
- * "stopped at …" message so the real cause isn't lost. Undefined otherwise.
182
- */
183
- reason?: string;
184
190
  }
185
191
  /**
186
192
  * Run one prompt through the full single-task pipeline in a fresh session and
187
193
  * deliver its spec. With waitForImplementation, block until the agent finishes
188
- * implementing the delivered spec. Success is read off the produced task file's
189
- * front-matter state (TaskRunner.run never throws).
194
+ * implementing the delivered spec.
195
+ *
196
+ * The ending comes from `TaskRunner.run`. It used to be read back off the task
197
+ * file's front matter — a disk round-trip this process made to learn what it had
198
+ * just done, and one that could not tell a cancel from a failure.
190
199
  */
191
200
  export declare function runSingleTask(ctx: ExtensionCommandContext, cwd: string, rawPrompt: string, opts?: RunSingleTaskOptions): Promise<RunSingleTaskResult>;
192
201
  /**
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import * as fsp from 'node:fs/promises';
19
19
  import * as path from 'node:path';
20
- import { PHASES, postCommitPhase } from './phases.js';
20
+ import { PHASES, postCommitPhase, replayPhaseCarry, runPhaseRow } from './phases.js';
21
21
  import { handleFailure } from './failure-classifier.js';
22
22
  import { PHASE_INDEX, PHASE_ORDER, RESUMABLE_STATES } from './task-types.js';
23
23
  import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parsers.js';
@@ -39,6 +39,7 @@ import { cancelCheckpoint } from './cancel-points.js';
39
39
  import { rearmCancelListener } from './cancel-input.js';
40
40
  import { takeHeldInput } from './mid-run-input.js';
41
41
  import { withRun, announceTerminal } from './run-bracket.js';
42
+ import { RUN_END_POLICY, runSucceeded } from './run-end.js';
42
43
  import { formatTimings } from './timings.js';
43
44
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
44
45
  import { superviseImplementation } from './implementation-turn.js';
@@ -111,6 +112,8 @@ export class TaskRunner {
111
112
  signal: this._abort.signal,
112
113
  spawn: opts.spawnFn,
113
114
  runChild: opts.runChild,
115
+ runWorker: opts.runWorker,
116
+ ...opts.lookups,
114
117
  // Deliberately NOT a ChildStatus (child-status.ts): the phase widget's
115
118
  // state is the whole-run WidgetState — task id, phase, label — shared by
116
119
  // reference with PhaseContext and written by the phases themselves
@@ -160,6 +163,12 @@ export class TaskRunner {
160
163
  * armed for the same window (`withRun`); nested inside `runGatedTask` or
161
164
  * the `/task-auto` loop the bracket refcounts, so this changes nothing there
162
165
  * and covers the fire-and-forget `runSingleTask` path on its own. */
166
+ /**
167
+ * Run the task and NAME how it ended.
168
+ *
169
+ * This returned `void`, so the caller re-read the task file to learn what had
170
+ * just happened here — see run-end.ts for the report that got wrong.
171
+ */
163
172
  async run() {
164
173
  return withRun(this._ctx, {}, () => this._run());
165
174
  }
@@ -237,6 +246,13 @@ export class TaskRunner {
237
246
  const idx = PHASE_INDEX[phase.name];
238
247
  if (idx < resumeIdx) {
239
248
  this._pc[phase.field] = (await readSection(cwd, id, phase.section)) ?? '';
249
+ // A row's `section` restores exactly ONE field. A phase that also
250
+ // settles another one declares that as its `carry`, and the replay
251
+ // is the only thing standing between a resume and losing it — the
252
+ // task file deliberately stores the PRE-carry text for the field
253
+ // compose rewrites. Trail lines are discarded: the live run that
254
+ // wrote this section already recorded them.
255
+ await replayPhaseCarry(phase, this._deps, this._pc);
240
256
  continue;
241
257
  }
242
258
  await advance(phase.name);
@@ -246,7 +262,7 @@ export class TaskRunner {
246
262
  const phaseStart = Date.now();
247
263
  let out;
248
264
  try {
249
- out = await phase.run(this._deps, this._pc);
265
+ out = await runPhaseRow(phase, this._deps, this._pc);
250
266
  }
251
267
  finally {
252
268
  const phaseMs = Date.now() - phaseStart;
@@ -279,6 +295,7 @@ export class TaskRunner {
279
295
  await setTaskSection(cwd, id, 'phase timings', formatTimings(this._timings));
280
296
  await setTaskSection(cwd, id, 'handoff', `handoff_at: ${new Date().toISOString()}`);
281
297
  await this._deliverSpec(ctx);
298
+ return { kind: 'completed' };
282
299
  }
283
300
  catch (err) {
284
301
  this._disposeWidget();
@@ -292,7 +309,12 @@ export class TaskRunner {
292
309
  /* ignore — preserve original failure */
293
310
  }
294
311
  }
295
- await handleFailure(err, ctx, cwd, id, this._abort.signal.aborted);
312
+ // `classifyFailure` already decided whether this was a cancel or a
313
+ // fault; the value is the answer, not a side effect of writing a file.
314
+ const c = await handleFailure(err, ctx, cwd, id, this._abort.signal.aborted);
315
+ return c.state === 'cancelled' ?
316
+ { kind: 'cancelled' }
317
+ : { kind: 'failed', ...(c.reason === undefined ? {} : { reason: c.reason }) };
296
318
  }
297
319
  finally {
298
320
  this._disposeWidget();
@@ -374,11 +396,17 @@ export class TaskRunner {
374
396
  /**
375
397
  * Run one prompt through the full single-task pipeline in a fresh session and
376
398
  * deliver its spec. With waitForImplementation, block until the agent finishes
377
- * implementing the delivered spec. Success is read off the produced task file's
378
- * front-matter state (TaskRunner.run never throws).
399
+ * implementing the delivered spec.
400
+ *
401
+ * The ending comes from `TaskRunner.run`. It used to be read back off the task
402
+ * file's front matter — a disk round-trip this process made to learn what it had
403
+ * just done, and one that could not tell a cancel from a failure.
379
404
  */
380
405
  export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
381
406
  let taskId = '';
407
+ // How the runner said it ended. `no-session` until it has run at all — the
408
+ // withSession callback below may never be entered.
409
+ let runEnd = { kind: 'no-session' };
382
410
  // The newSession replacement ctx, captured so the caller can keep driving the
383
411
  // UI after the original ctx is torn down. Defaults to the original for the
384
412
  // cancellation path (where no replacement occurs).
@@ -422,12 +450,14 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
422
450
  },
423
451
  spawnFn: opts.spawnFn,
424
452
  runChild: opts.runChild,
453
+ runWorker: opts.runWorker,
454
+ lookups: opts.lookups,
425
455
  onStart: opts.onStart,
426
456
  planContext: opts.planContext,
427
457
  fixInstruction: opts.fixInstruction,
428
458
  implAwaited: opts.waitForImplementation
429
459
  });
430
- await runner.run();
460
+ runEnd = await runner.run();
431
461
  taskId = runner.taskId;
432
462
  }
433
463
  });
@@ -436,32 +466,29 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
436
466
  if (opts.notifyFinish) {
437
467
  void pushNotify('Task finished', `${taskId || 'Task'} cancelled — could not start a session.`, 'pi-end').catch(() => { });
438
468
  }
439
- return { taskId, ok: false, sessionCancelled: true, ctx };
440
- }
441
- let ok = false;
442
- let state;
443
- if (taskId) {
444
- try {
445
- const { frontMatter } = await readTaskFile(cwd, taskId);
446
- state = frontMatter.state;
447
- ok = state === 'completed';
448
- }
449
- catch {
450
- ok = false;
451
- }
469
+ return { taskId, end: { kind: 'no-session' }, ctx };
452
470
  }
453
- // The file reads `completed` from spec-handoff even when the implementation
454
- // turn then died. Demote to a failure so /task-auto stops here (leaving the
455
- // entry unchecked and resumable) instead of committing and advancing.
456
- if (ok && implError)
457
- ok = false;
471
+ // The runner already named the ending. What the SUPERVISION adds is the two
472
+ // endings the runner cannot see, because they happen after the spec is
473
+ // delivered and the task file already reads `completed`:
474
+ //
475
+ // • the user interrupted and declined to steer — a pause, not a fault;
476
+ // • the implementation turn died with stopReason "error" (a context-overflow
477
+ // 400, say), which must stop /task-auto here rather than let it commit and
478
+ // advance on a file that says `completed`.
479
+ //
480
+ // Both used to reach the caller as extra fields beside a boolean, and both are
481
+ // endings.
482
+ let end = runEnd;
483
+ if (end.kind === 'completed' && interrupted)
484
+ end = { kind: 'interrupted' };
485
+ else if (end.kind === 'completed' && implError)
486
+ end = { kind: 'failed', reason: implError };
458
487
  if (opts.notifyFinish) {
459
- // One push per top-level /task or /task-resume, on any terminal end. The
460
- // file state is the source of truth: 'completed' on success, 'failed' /
461
- // 'cancelled' otherwise; an unreadable/absent file falls back to 'ended'.
462
- void pushNotify('Task finished', `${taskId || 'Task'} ${state ?? 'ended'}.`, 'pi-end').catch(() => { });
488
+ // One push per top-level /task or /task-resume, on any terminal end.
489
+ void pushNotify('Task finished', `${taskId || 'Task'} ${end.kind}.`, 'pi-end').catch(() => { });
463
490
  }
464
- return { taskId, ok, sessionCancelled: false, ctx: freshCtx, interrupted, reason: implError };
491
+ return { taskId, end, ctx: freshCtx };
465
492
  }
466
493
  // ─── Gated single-task flow ────────────────────────────────────────────────────
467
494
  /**
@@ -533,19 +560,21 @@ async function runGatedTaskInner(ctx, cwd, raw, opts = {}) {
533
560
  const res = await deps.runTask(active, cwd, raw, { resumeId: opts.resumeId });
534
561
  active = res.ctx ?? active;
535
562
  const tag = res.taskId || 'Task';
536
- if (res.sessionCancelled) {
537
- announce(`${tag} could not start a fresh session for /task.`, 'warning');
538
- return;
539
- }
540
- if (res.interrupted) {
541
- await markResumable(cwd, res.taskId);
542
- announce(`${tag} paused resume with /task-resume.`, 'warning');
543
- return;
544
- }
545
- if (!res.ok) {
546
- await markResumable(cwd, res.taskId);
547
- const why = res.reason ? ` ${res.reason.slice(0, 160)}` : '';
548
- announce(`${tag} stopped${why}fix and run /task-resume.`, 'error');
563
+ // One dispatch over the named ending. The four-branch ladder this replaces put
564
+ // a CANCELLED run into the `!ok` arm, so `/task-cancel` overwrote the file's
565
+ // `cancelled` with `failed` and told the user their task had stopped and needed
566
+ // fixing. Resumability comes from RUN_END_POLICY; the wording stays here,
567
+ // because `/task-auto` says `/task-auto-resume` where this says `/task-resume`.
568
+ if (!runSucceeded(res.end)) {
569
+ const policy = RUN_END_POLICY[res.end.kind];
570
+ if (policy.resumable)
571
+ await markResumable(cwd, res.taskId);
572
+ const why = res.end.kind === 'failed' && res.end.reason ? ` — ${res.end.reason.slice(0, 160)}` : '';
573
+ const msg = res.end.kind === 'no-session' ? `${tag} — could not start a fresh session for /task.`
574
+ : res.end.kind === 'cancelled' ? `${tag} cancelled.`
575
+ : res.end.kind === 'interrupted' ? `${tag} pausedresume with /task-resume.`
576
+ : `${tag} stopped${why} — fix and run /task-resume.`;
577
+ announce(msg, policy.level);
549
578
  return;
550
579
  }
551
580
  // The composed task's own front-matter title — used in commit messages and
@@ -598,8 +627,8 @@ async function handleTask(args, ctx) {
598
627
  await runGatedTask(ctx, cwd, raw);
599
628
  return;
600
629
  }
601
- const { sessionCancelled } = await runSingleTask(ctx, cwd, raw, { notifyFinish: true });
602
- if (sessionCancelled) {
630
+ const { end } = await runSingleTask(ctx, cwd, raw, { notifyFinish: true });
631
+ if (end.kind === 'no-session') {
603
632
  ctx.ui.notify('Could not start a fresh session for /task.', 'warning');
604
633
  }
605
634
  }
@@ -687,8 +716,8 @@ async function handleTaskResume(args, ctx) {
687
716
  await runGatedTask(ctx, cwd, '', { resumeId: id });
688
717
  return;
689
718
  }
690
- const { sessionCancelled } = await runSingleTask(ctx, cwd, '', { resumeId: id, notifyFinish: true });
691
- if (sessionCancelled) {
719
+ const { end } = await runSingleTask(ctx, cwd, '', { resumeId: id, notifyFinish: true });
720
+ if (end.kind === 'no-session') {
692
721
  ctx.ui.notify('Could not start a fresh session for /task-resume.', 'warning');
693
722
  }
694
723
  }