@mjasnikovs/pi-task 0.38.10 → 0.38.12
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/README.md +7 -3
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/command-watchdog.d.ts +1 -1
- package/dist/shared/command-watchdog.js +1 -1
- package/dist/task/accept-debt.d.ts +47 -0
- package/dist/task/accept-debt.js +127 -28
- package/dist/task/auto-orchestrator.js +93 -116
- package/dist/task/boot-probe.d.ts +298 -0
- package/dist/task/boot-probe.js +806 -0
- package/dist/task/child-runner.d.ts +56 -25
- package/dist/task/child-runner.js +65 -31
- package/dist/task/child-status.d.ts +95 -0
- package/dist/task/child-status.js +99 -0
- package/dist/task/command-run.d.ts +36 -0
- package/dist/task/command-run.js +48 -1
- package/dist/task/command-watchdog.js +1 -1
- package/dist/task/context-usage.d.ts +4 -3
- package/dist/task/context-usage.js +4 -3
- package/dist/task/contracts.js +18 -35
- package/dist/task/deep-render-check.d.ts +47 -0
- package/dist/task/deep-render-check.js +110 -65
- package/dist/task/env-notes.d.ts +3 -3
- package/dist/task/env-notes.js +24 -35
- package/dist/task/final-gate-fix.d.ts +1 -1
- package/dist/task/final-gate-fix.js +1 -1
- package/dist/task/final-gate.d.ts +38 -432
- package/dist/task/final-gate.js +105 -1213
- package/dist/task/gate-child.d.ts +8 -10
- package/dist/task/gate-child.js +15 -19
- package/dist/task/gate-deps.d.ts +29 -0
- package/dist/task/gate-deps.js +192 -206
- package/dist/task/gate-tally.d.ts +189 -0
- package/dist/task/gate-tally.js +249 -0
- package/dist/task/implementation-turn.d.ts +201 -0
- package/dist/task/implementation-turn.js +263 -0
- package/dist/task/launch-contract.js +27 -43
- package/dist/task/launch-manifest.d.ts +5 -0
- package/dist/task/launch-manifest.js +21 -0
- package/dist/task/ledger.d.ts +38 -0
- package/dist/task/ledger.js +83 -0
- package/dist/task/loop-detector.d.ts +14 -8
- package/dist/task/loop-detector.js +36 -12
- package/dist/task/orchestrator.d.ts +61 -126
- package/dist/task/orchestrator.js +70 -297
- package/dist/task/phases.d.ts +18 -0
- package/dist/task/phases.js +4 -3
- package/dist/task/plan-orchestrator.js +34 -33
- package/dist/task/requirements.d.ts +1 -1
- package/dist/task/requirements.js +50 -66
- package/dist/task/root-cause-repair.js +20 -32
- package/dist/task/run-bracket.d.ts +75 -0
- package/dist/task/run-bracket.js +41 -0
- package/dist/task/stall-detector.d.ts +110 -0
- package/dist/task/stall-detector.js +159 -0
- package/dist/task/task-gates.d.ts +69 -0
- package/dist/task/task-gates.js +114 -90
- package/dist/task/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- package/dist/workers/docs-core.d.ts +0 -4
- package/dist/workers/docs-core.js +10 -34
- package/dist/workers/docs-project.js +3 -3
- package/dist/workers/docs-resolve.d.ts +18 -0
- package/dist/workers/docs-resolve.js +39 -0
- package/dist/workers/docs-retrieve.d.ts +13 -0
- package/dist/workers/docs-retrieve.js +17 -2
- package/dist/workers/fetch-core.d.ts +0 -4
- package/dist/workers/fetch-core.js +2 -5
- package/dist/workers/phantom-imports.d.ts +3 -3
- package/dist/workers/phantom-imports.js +16 -29
- package/dist/workers/pi-worker-docs.d.ts +49 -0
- package/dist/workers/pi-worker-docs.js +33 -9
- package/dist/workers/pi-worker-fetch.d.ts +18 -0
- package/dist/workers/pi-worker-fetch.js +19 -4
- package/dist/workers/single-read-extension.d.ts +1 -1
- package/dist/workers/single-read-extension.js +5 -4
- package/dist/workers/single-read-guard.d.ts +32 -10
- package/dist/workers/single-read-guard.js +67 -16
- package/package.json +1 -1
|
@@ -25,11 +25,10 @@ import { readTextFile } from '../shared/fs-text.js';
|
|
|
25
25
|
import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
|
|
26
26
|
import { startWidget } from './widget.js';
|
|
27
27
|
import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.js';
|
|
28
|
-
import { publishViewer, publishNotify,
|
|
28
|
+
import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
|
|
29
29
|
import { pushNotify } from '../remote/push.js';
|
|
30
30
|
import { getConfig } from '../config/config.js';
|
|
31
31
|
import { gateDebugWriter } from './debug-log.js';
|
|
32
|
-
import { consumeWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
|
|
33
32
|
import { buildGateDeps } from './gate-deps.js';
|
|
34
33
|
import { runGatesForTask } from './task-gates.js';
|
|
35
34
|
import { parseVerifyBlock } from './spec-validation.js';
|
|
@@ -37,11 +36,12 @@ import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phanto
|
|
|
37
36
|
import { titleForDisplay } from './parsers.js';
|
|
38
37
|
import { USER_CANCELLED } from './child-runner.js';
|
|
39
38
|
import { cancelCheckpoint } from './cancel-points.js';
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
39
|
+
import { rearmCancelListener } from './cancel-input.js';
|
|
40
|
+
import { takeHeldInput } from './mid-run-input.js';
|
|
41
|
+
import { withRun, announceTerminal } from './run-bracket.js';
|
|
43
42
|
import { formatTimings } from './timings.js';
|
|
44
43
|
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
44
|
+
import { superviseImplementation } from './implementation-turn.js';
|
|
45
45
|
import { TERMINAL_OUTCOMES, formatAt, formatWhy } from './terminal-outcome.js';
|
|
46
46
|
// ─── Module-level state ──────────────────────────────────────────────────────
|
|
47
47
|
let activeTask = null;
|
|
@@ -67,10 +67,7 @@ export class TaskRunner {
|
|
|
67
67
|
_onStart;
|
|
68
68
|
_planContext;
|
|
69
69
|
_fixInstruction;
|
|
70
|
-
/**
|
|
71
|
-
* the impl widget stays armed across the whole impl phase (incl. compaction /
|
|
72
|
-
* steer turns) and is disarmed here. False for fire-and-forget /task, where the
|
|
73
|
-
* widget is armed one-shot and its own agent_end disarms it. */
|
|
70
|
+
/** See {@link TaskRunnerOptions.implAwaited}. */
|
|
74
71
|
_implAwaited;
|
|
75
72
|
_abort = new AbortController();
|
|
76
73
|
_startedAt;
|
|
@@ -87,16 +84,17 @@ export class TaskRunner {
|
|
|
87
84
|
*/
|
|
88
85
|
_timings = [];
|
|
89
86
|
_currentPhaseChildren = null;
|
|
90
|
-
constructor(
|
|
87
|
+
constructor(opts) {
|
|
88
|
+
const { ctx, cwd, rawPrompt } = opts;
|
|
91
89
|
this._ctx = ctx;
|
|
92
90
|
this._cwd = cwd;
|
|
93
91
|
this._rawPrompt = rawPrompt;
|
|
94
|
-
this._resumeId = resumeId;
|
|
95
|
-
this._sendSpec = sendSpec;
|
|
96
|
-
this._onStart = onStart;
|
|
97
|
-
this._planContext = planContext;
|
|
98
|
-
this._fixInstruction = fixInstruction;
|
|
99
|
-
this._implAwaited = implAwaited ?? false;
|
|
92
|
+
this._resumeId = opts.resumeId;
|
|
93
|
+
this._sendSpec = opts.sendSpec;
|
|
94
|
+
this._onStart = opts.onStart;
|
|
95
|
+
this._planContext = opts.planContext;
|
|
96
|
+
this._fixInstruction = opts.fixInstruction;
|
|
97
|
+
this._implAwaited = opts.implAwaited ?? false;
|
|
100
98
|
this._startedAt = Date.now();
|
|
101
99
|
// We'll populate id/title/phase lazily in run().
|
|
102
100
|
// Placeholder — real values set in run().
|
|
@@ -111,7 +109,13 @@ export class TaskRunner {
|
|
|
111
109
|
cwd,
|
|
112
110
|
taskId: '',
|
|
113
111
|
signal: this._abort.signal,
|
|
114
|
-
spawn: spawnFn,
|
|
112
|
+
spawn: opts.spawnFn,
|
|
113
|
+
runChild: opts.runChild,
|
|
114
|
+
// Deliberately NOT a ChildStatus (child-status.ts): the phase widget's
|
|
115
|
+
// state is the whole-run WidgetState — task id, phase, label — shared by
|
|
116
|
+
// reference with PhaseContext and written by the phases themselves
|
|
117
|
+
// (grill sets `lastLine`, compose sets the title). Only these two
|
|
118
|
+
// callbacks overlap, and they call the same resolveContextUsage.
|
|
115
119
|
onChildOutput: (line) => {
|
|
116
120
|
this._widgetState.lastLine = line;
|
|
117
121
|
},
|
|
@@ -151,14 +155,17 @@ export class TaskRunner {
|
|
|
151
155
|
cancel() {
|
|
152
156
|
this._abort.abort();
|
|
153
157
|
}
|
|
154
|
-
/** Execute the full task lifecycle.
|
|
158
|
+
/** Execute the full task lifecycle. Mid-run input holds instead of starting
|
|
159
|
+
* a competing turn for the whole of it, and the terminal interception is
|
|
160
|
+
* armed for the same window (`withRun`); nested inside `runGatedTask` or
|
|
161
|
+
* the `/task-auto` loop the bracket refcounts, so this changes nothing there
|
|
162
|
+
* and covers the fire-and-forget `runSingleTask` path on its own. */
|
|
155
163
|
async run() {
|
|
164
|
+
return withRun(this._ctx, {}, () => this._run());
|
|
165
|
+
}
|
|
166
|
+
async _run() {
|
|
156
167
|
const cwd = this._cwd;
|
|
157
168
|
const ctx = this._ctx;
|
|
158
|
-
// Mid-run input holds instead of starting a competing turn from here on,
|
|
159
|
-
// and the terminal interception is armed for the same window.
|
|
160
|
-
beginRun();
|
|
161
|
-
armCancelListener(ctx);
|
|
162
169
|
// Initialise or resume the TASK file.
|
|
163
170
|
let id;
|
|
164
171
|
let title;
|
|
@@ -290,8 +297,6 @@ export class TaskRunner {
|
|
|
290
297
|
finally {
|
|
291
298
|
this._disposeWidget();
|
|
292
299
|
clearActiveTask(this);
|
|
293
|
-
disarmCancelListener();
|
|
294
|
-
reportDroppedInput(endRun(), ctx);
|
|
295
300
|
}
|
|
296
301
|
}
|
|
297
302
|
/** Stop the phase widget — clearing both the terminal and remote surfaces —
|
|
@@ -303,7 +308,7 @@ export class TaskRunner {
|
|
|
303
308
|
this._stopWidget = null;
|
|
304
309
|
}
|
|
305
310
|
async _deliverSpec(_ctx) {
|
|
306
|
-
const spec = this._specForDelivery();
|
|
311
|
+
const spec = await this._specForDelivery();
|
|
307
312
|
// Keep the rich status block alive across the implementation turn (the phase
|
|
308
313
|
// widget was disposed at handoff). Awaited (/task-auto) stays armed across all
|
|
309
314
|
// sub-turns and is disarmed here; fire-and-forget (/task) arms one-shot and its
|
|
@@ -343,8 +348,8 @@ export class TaskRunner {
|
|
|
343
348
|
* deterministic check proves does not exist. No-op (returns the spec unchanged) when
|
|
344
349
|
* nothing is flagged or the runtime's types aren't installed.
|
|
345
350
|
*/
|
|
346
|
-
_specForDelivery() {
|
|
347
|
-
const phantoms = findDeliveryPhantoms(this._pc.spec, this._cwd);
|
|
351
|
+
async _specForDelivery() {
|
|
352
|
+
const phantoms = await findDeliveryPhantoms(this._pc.spec, this._cwd);
|
|
348
353
|
const apiBanner = formatApiOverrideBanner(phantoms);
|
|
349
354
|
if (apiBanner) {
|
|
350
355
|
this._deps.logDebug?.(`impl-handoff API override banner prepended for: ${phantoms.map(p => p.spec).join(', ')}`);
|
|
@@ -366,238 +371,6 @@ export class TaskRunner {
|
|
|
366
371
|
return banners ? `${banners}\n\n${this._pc.spec}` : this._pc.spec;
|
|
367
372
|
}
|
|
368
373
|
}
|
|
369
|
-
/** Dialog copy for the post-interrupt steering prompt. */
|
|
370
|
-
const STEER_TITLE = 'Paused — steer the model';
|
|
371
|
-
const STEER_PLACEHOLDER = 'Type guidance to continue this task, or leave empty to pause';
|
|
372
|
-
/** Remote-card copy for the same prompt. The browser has no placeholder ghost
|
|
373
|
-
* text, so the pause affordance must be spelled out in the question itself
|
|
374
|
-
* (Skip = empty answer = pause, same as an empty local submit). */
|
|
375
|
-
const STEER_QUESTION = 'Paused — the implementation was interrupted.\n'
|
|
376
|
-
+ 'Type guidance to continue this task, or Skip to pause the run.';
|
|
377
|
-
/**
|
|
378
|
-
* True when the most recent assistant turn ended because the user interrupted it
|
|
379
|
-
* (pressed ESC). pi records a user abort as stopReason "aborted" on the assistant
|
|
380
|
-
* message, distinct from a natural "stop". Read after the implementation wait so
|
|
381
|
-
* the /task-auto loop can tell "user wants to steer" apart from "task finished".
|
|
382
|
-
*/
|
|
383
|
-
function wasInterrupted(ctx) {
|
|
384
|
-
const entries = ctx.sessionManager.getEntries();
|
|
385
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
386
|
-
const e = entries[i];
|
|
387
|
-
if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
|
|
388
|
-
return e.message.stopReason === 'aborted';
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
return false;
|
|
392
|
-
}
|
|
393
|
-
/**
|
|
394
|
-
* The error cause when the most recent assistant turn ended with stopReason
|
|
395
|
-
* "error" — the model/provider failed mid-implementation (context overflow,
|
|
396
|
-
* disconnect, provider 5xx) after pi exhausted its own retries. Returns the
|
|
397
|
-
* provider's errorMessage, or undefined if the turn ended cleanly ("stop") or
|
|
398
|
-
* was user-aborted ("aborted", handled by wasInterrupted).
|
|
399
|
-
*
|
|
400
|
-
* The /task-auto loop reads this AFTER the implementation wait: a task's file is
|
|
401
|
-
* marked `completed` at spec-handoff (before implementation), so without this
|
|
402
|
-
* check an implementation turn that died — e.g. "400 ... exceeds the available
|
|
403
|
-
* context size" — would still read as ok and get checked off and committed.
|
|
404
|
-
*/
|
|
405
|
-
function implementationError(ctx) {
|
|
406
|
-
const entries = ctx.sessionManager.getEntries();
|
|
407
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
408
|
-
const e = entries[i];
|
|
409
|
-
if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
|
|
410
|
-
const m = e.message;
|
|
411
|
-
return m.stopReason === 'error' ? (m.errorMessage ?? 'model error') : undefined;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
return undefined;
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* True when the implementation turn went idle right after a context compaction —
|
|
418
|
-
* the most recent entry in the branch is a `compaction` boundary sitting after the
|
|
419
|
-
* last assistant message.
|
|
420
|
-
*
|
|
421
|
-
* A *threshold* auto-compaction (the runtime's "context is getting large" path)
|
|
422
|
-
* compacts and then deliberately does NOT auto-continue: it returns to idle and
|
|
423
|
-
* expects a manual continue (`_runAutoCompaction("threshold", false)` →
|
|
424
|
-
* `hasQueuedMessages()` is false → the agent loop stops). Our implementation wait
|
|
425
|
-
* resolves at exactly that idle. Without this check it reads as "the model
|
|
426
|
-
* finished" (the last assistant message is a normal `stop`, not `aborted`/`error`),
|
|
427
|
-
* so the run jumps straight to the verify gate and abandons a half-done task at the
|
|
428
|
-
* compaction boundary — the failure this detector closes.
|
|
429
|
-
*
|
|
430
|
-
* Position-based, not timestamp-based: the runtime APPENDS the compaction entry to
|
|
431
|
-
* the tail of the branch after the assistant message that triggered it
|
|
432
|
-
* (`appendCompaction` → `_appendEntry` push), so a `compaction` after the last
|
|
433
|
-
* assistant message means we are parked on a compaction with no continuation. A
|
|
434
|
-
* genuinely finished turn ends on an assistant message with no trailing compaction;
|
|
435
|
-
* an *overflow* compaction self-retries, so it never leaves us idle here.
|
|
436
|
-
*/
|
|
437
|
-
export function endedAtCompactionBoundary(ctx) {
|
|
438
|
-
const entries = ctx.sessionManager.getEntries();
|
|
439
|
-
let lastAssistant = -1;
|
|
440
|
-
let lastCompaction = -1;
|
|
441
|
-
for (let i = 0; i < entries.length; i++) {
|
|
442
|
-
const e = entries[i];
|
|
443
|
-
if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
|
|
444
|
-
lastAssistant = i;
|
|
445
|
-
}
|
|
446
|
-
else if (e.type === 'compaction') {
|
|
447
|
-
lastCompaction = i;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
return lastCompaction > lastAssistant;
|
|
451
|
-
}
|
|
452
|
-
/**
|
|
453
|
-
* Nudge that resumes an implementation turn the runtime parked at a compaction
|
|
454
|
-
* boundary. It must let a turn that was genuinely finished (then tipped over the
|
|
455
|
-
* threshold by its own final message) confirm completion without inventing busywork
|
|
456
|
-
* — we cannot tell "paused mid-task by compaction" from "finished, then compacted"
|
|
457
|
-
* from the boundary alone, so the wording lets a done turn end in one line.
|
|
458
|
-
*/
|
|
459
|
-
export const CONTINUE_AFTER_COMPACTION = 'Your context was automatically compacted. Continue implementing this task from '
|
|
460
|
-
+ 'exactly where you left off, and keep going until it is fully done. If the '
|
|
461
|
-
+ 'implementation is already complete, say so in one line and stop — do not invent '
|
|
462
|
-
+ 'extra work or restart the task.';
|
|
463
|
-
/**
|
|
464
|
-
* Safety cap on compaction-driven resumes for a single implementation turn. Each
|
|
465
|
-
* resume follows a real compaction (which only fires after the model produced a
|
|
466
|
-
* turn large enough to cross the threshold), so a legitimately large task may
|
|
467
|
-
* resume a handful of times; the cap exists only to stop a pathological loop from
|
|
468
|
-
* auto-sending forever with no user in the loop. Hitting it stops resuming and lets
|
|
469
|
-
* the verify gate / `/task-auto-resume` catch any leftover incompleteness.
|
|
470
|
-
*/
|
|
471
|
-
export const MAX_COMPACTION_RESUMES = 20;
|
|
472
|
-
/**
|
|
473
|
-
* Resume an implementation turn that went idle at a threshold-compaction boundary.
|
|
474
|
-
* The runtime compacts and parks at idle without auto-continuing; we send a
|
|
475
|
-
* continue and wait again, repeating across successive compactions until the turn
|
|
476
|
-
* ends on a real assistant message (genuine completion). A user ESC takes priority
|
|
477
|
-
* (it is not a compaction boundary, and `wasInterrupted` guards the loop so the
|
|
478
|
-
* steer loop handles it), and the safety cap bounds a runaway. Returns the number
|
|
479
|
-
* of resumes performed (0 when the turn did not end on a compaction).
|
|
480
|
-
*/
|
|
481
|
-
export async function resumeAcrossCompactions(ctx) {
|
|
482
|
-
let resumes = 0;
|
|
483
|
-
while (resumes < MAX_COMPACTION_RESUMES
|
|
484
|
-
&& !wasInterrupted(ctx)
|
|
485
|
-
&& endedAtCompactionBoundary(ctx)) {
|
|
486
|
-
await ctx.sendUserMessage(CONTINUE_AFTER_COMPACTION, { deliverAs: 'followUp' });
|
|
487
|
-
await ctx.waitForIdle();
|
|
488
|
-
resumes++;
|
|
489
|
-
}
|
|
490
|
-
return resumes;
|
|
491
|
-
}
|
|
492
|
-
/**
|
|
493
|
-
* True when the watchdog's reminder follow-up has been DELIVERED into the session
|
|
494
|
-
* after the aborted assistant turn but its own turn has not finished yet — the
|
|
495
|
-
* artifact that confirms a pending watchdog recovery. Scoped after the LAST
|
|
496
|
-
* assistant entry so an earlier fire's reminder (already answered by its own
|
|
497
|
-
* turn) never matches.
|
|
498
|
-
*/
|
|
499
|
-
function watchdogReminderDelivered(ctx) {
|
|
500
|
-
const entries = ctx.sessionManager.getEntries();
|
|
501
|
-
let lastAssistant = -1;
|
|
502
|
-
for (let i = 0; i < entries.length; i++) {
|
|
503
|
-
const e = entries[i];
|
|
504
|
-
if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
|
|
505
|
-
lastAssistant = i;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
for (let i = lastAssistant + 1; i < entries.length; i++) {
|
|
509
|
-
const e = entries[i];
|
|
510
|
-
if (!('message' in e) || !('role' in e.message) || e.message.role !== 'user')
|
|
511
|
-
continue;
|
|
512
|
-
const content = e.message.content;
|
|
513
|
-
const text = typeof content === 'string' ? content
|
|
514
|
-
: Array.isArray(content) ?
|
|
515
|
-
content
|
|
516
|
-
.map(b => b !== null && typeof b === 'object' && 'text' in b ?
|
|
517
|
-
String(b.text)
|
|
518
|
-
: '')
|
|
519
|
-
.join(' ')
|
|
520
|
-
: '';
|
|
521
|
-
if (text.includes(WATCHDOG_CANCEL_MARKER))
|
|
522
|
-
return true;
|
|
523
|
-
}
|
|
524
|
-
return false;
|
|
525
|
-
}
|
|
526
|
-
const STEER_WATCHDOG_DEFAULTS = {
|
|
527
|
-
consume: consumeWatchdogAbort,
|
|
528
|
-
graceMs: 10_000,
|
|
529
|
-
pollMs: 100
|
|
530
|
-
};
|
|
531
|
-
/**
|
|
532
|
-
* Wait for a watchdog abort's queued follow-up turn instead of prompting. The
|
|
533
|
-
* abort and the reminder follow-up are two separate steps in the watchdog's
|
|
534
|
-
* onFire, so the steer loop can observe the aborted turn before the reminder is
|
|
535
|
-
* delivered — poll (bounded) until it lands or the follow-up turn has already
|
|
536
|
-
* completed. True = recovery observed, re-check the loop; false = grace expired
|
|
537
|
-
* with no reminder (stale flag) — fall back to the human prompt.
|
|
538
|
-
*/
|
|
539
|
-
async function awaitWatchdogFollowUp(ctx, wd) {
|
|
540
|
-
const deadline = Date.now() + wd.graceMs;
|
|
541
|
-
for (;;) {
|
|
542
|
-
if (!wasInterrupted(ctx))
|
|
543
|
-
return true; // follow-up turn already completed
|
|
544
|
-
if (watchdogReminderDelivered(ctx)) {
|
|
545
|
-
await ctx.waitForIdle(); // let the follow-up turn run to completion
|
|
546
|
-
return true;
|
|
547
|
-
}
|
|
548
|
-
if (Date.now() >= deadline)
|
|
549
|
-
return false;
|
|
550
|
-
await new Promise(r => setTimeout(r, wd.pollMs));
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
/**
|
|
554
|
-
* After the implementation turn settles, honour a user ESC by letting them steer.
|
|
555
|
-
*
|
|
556
|
-
* `waitForIdle` resolves both on natural completion AND on an ESC (which aborts
|
|
557
|
-
* the turn → idle). When the last turn was aborted, the host's main input loop is
|
|
558
|
-
* blocked inside our command handler, so a message typed in the editor would only
|
|
559
|
-
* queue, never run (interactive-mode routes idle input through onInputCallback,
|
|
560
|
-
* which is unset while we hold the loop). We therefore solicit the steering text
|
|
561
|
-
* ourselves and feed it back as another turn via sendUserMessage — which runs to
|
|
562
|
-
* completion when the session is idle. Repeat until a turn finishes uninterrupted.
|
|
563
|
-
*
|
|
564
|
-
* A WATCHDOG abort also ends the turn with stopReason 'aborted' — indistinguishable
|
|
565
|
-
* from a human ESC by the session entries alone at that instant. The watchdog
|
|
566
|
-
* queues its own recovery follow-up, so prompting there would show a steering
|
|
567
|
-
* dialog to an empty room and wedge an unattended run on the race. The one-shot
|
|
568
|
-
* flag (set synchronously before the abort) routes that case to
|
|
569
|
-
* {@link awaitWatchdogFollowUp} instead; a stale flag degrades to a bounded wait
|
|
570
|
-
* followed by the ordinary prompt, never to a suppressed one.
|
|
571
|
-
*
|
|
572
|
-
* Returns true when the user declined to steer (empty/cancelled) and the run
|
|
573
|
-
* should pause; false when the implementation completed (steered or not).
|
|
574
|
-
*/
|
|
575
|
-
export async function steerUntilDone(ctx, promptSteer, watchdog) {
|
|
576
|
-
const wd = { ...STEER_WATCHDOG_DEFAULTS, ...watchdog };
|
|
577
|
-
// Fan the prompt out through the bridge (local TUI input + remote browser
|
|
578
|
-
// card, first answer wins) instead of a raw ctx.ui.input: an interrupt can
|
|
579
|
-
// come from the remote Stop button just as well as a terminal ESC, and a
|
|
580
|
-
// terminal-only dialog leaves the remote viewer staring at a silently
|
|
581
|
-
// paused run. Remote Skip returns '' → same pause path as an empty local
|
|
582
|
-
// submit.
|
|
583
|
-
const ask = promptSteer
|
|
584
|
-
?? (c => new SessionUI(c).ask({
|
|
585
|
-
localTitle: STEER_TITLE,
|
|
586
|
-
localPlaceholder: STEER_PLACEHOLDER,
|
|
587
|
-
question: STEER_QUESTION,
|
|
588
|
-
allowSkip: true
|
|
589
|
-
}));
|
|
590
|
-
while (wasInterrupted(ctx)) {
|
|
591
|
-
if (wd.consume() && (await awaitWatchdogFollowUp(ctx, wd)))
|
|
592
|
-
continue;
|
|
593
|
-
const steer = await ask(ctx);
|
|
594
|
-
if (steer === undefined || steer.trim().length === 0)
|
|
595
|
-
return true; // pause
|
|
596
|
-
await ctx.sendUserMessage(steer, { deliverAs: 'followUp' });
|
|
597
|
-
await ctx.waitForIdle();
|
|
598
|
-
}
|
|
599
|
-
return false;
|
|
600
|
-
}
|
|
601
374
|
/**
|
|
602
375
|
* Run one prompt through the full single-task pipeline in a fresh session and
|
|
603
376
|
* deliver its spec. With waitForImplementation, block until the agent finishes
|
|
@@ -625,26 +398,35 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
625
398
|
// happens at the START of each task — precisely the window a typed
|
|
626
399
|
// /task-auto-cancel has to survive. No-op unless a run armed one.
|
|
627
400
|
rearmCancelListener(newCtx);
|
|
628
|
-
const runner = new TaskRunner(
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
401
|
+
const runner = new TaskRunner({
|
|
402
|
+
ctx: newCtx,
|
|
403
|
+
cwd,
|
|
404
|
+
rawPrompt,
|
|
405
|
+
resumeId: opts.resumeId,
|
|
406
|
+
sendSpec: async (spec) => {
|
|
407
|
+
// Queue-or-run: never throws, whatever else is on the session (issue #8).
|
|
408
|
+
await newCtx.sendUserMessage(spec, { deliverAs: 'followUp' });
|
|
409
|
+
if (opts.waitForImplementation) {
|
|
410
|
+
await newCtx.waitForIdle();
|
|
411
|
+
// A threshold auto-compaction parks the turn at idle WITHOUT
|
|
412
|
+
// auto-continuing, and a user ESC ends it "aborted": the
|
|
413
|
+
// first idle is not the turn's real end. superviseImplementation
|
|
414
|
+
// resumes across compactions, steers across interrupts, and
|
|
415
|
+
// reads how the turn ACTUALLY ended.
|
|
416
|
+
const outcome = await superviseImplementation(newCtx, {
|
|
417
|
+
promptSteer: opts.promptSteer
|
|
418
|
+
});
|
|
419
|
+
interrupted = outcome.interrupted;
|
|
420
|
+
implError = outcome.error;
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
spawnFn: opts.spawnFn,
|
|
424
|
+
runChild: opts.runChild,
|
|
425
|
+
onStart: opts.onStart,
|
|
426
|
+
planContext: opts.planContext,
|
|
427
|
+
fixInstruction: opts.fixInstruction,
|
|
428
|
+
implAwaited: opts.waitForImplementation
|
|
429
|
+
});
|
|
648
430
|
await runner.run();
|
|
649
431
|
taskId = runner.taskId;
|
|
650
432
|
}
|
|
@@ -726,19 +508,13 @@ export async function runGatedTask(ctx, cwd, raw, opts = {}) {
|
|
|
726
508
|
// TaskRunner would leave verify/enforce looking like "no run", which a live
|
|
727
509
|
// run on pi 0.82.1 showed as runActive=false while the widget still read
|
|
728
510
|
// "verifying work" (issue #8). The body has many early returns, so the
|
|
729
|
-
// bracket lives in this wrapper rather than in a dozen places.
|
|
730
|
-
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
await runGatedTaskInner(ctx, cwd, raw, opts);
|
|
737
|
-
}
|
|
738
|
-
finally {
|
|
739
|
-
disarmCancelListener();
|
|
740
|
-
reportDroppedInput(endRun(), ctx);
|
|
741
|
-
}
|
|
511
|
+
// bracket lives in this wrapper rather than in a dozen places. The same
|
|
512
|
+
// bracket arms the raw-stdin interception for the WHOLE run: without it a
|
|
513
|
+
// plain /task had no terminal path at all — only /task-auto armed one — so a
|
|
514
|
+
// line typed during it went into pi's queue and fired after the run (seen
|
|
515
|
+
// live). No onCancel: a typed /task-auto-cancel goes through the generic
|
|
516
|
+
// bridge dispatch here.
|
|
517
|
+
await withRun(ctx, {}, () => runGatedTaskInner(ctx, cwd, raw, opts));
|
|
742
518
|
}
|
|
743
519
|
async function runGatedTaskInner(ctx, cwd, raw, opts = {}) {
|
|
744
520
|
const abort = new AbortController();
|
|
@@ -751,11 +527,8 @@ async function runGatedTaskInner(ctx, cwd, raw, opts = {}) {
|
|
|
751
527
|
let active = ctx;
|
|
752
528
|
// One push + remote bubble on the terminal outcome (parity with the
|
|
753
529
|
// notifyFinish push the fire-and-forget path emits via runSingleTask).
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
publishLifecycleNotice(msg, level);
|
|
757
|
-
void pushNotify('Task finished', msg, 'pi-end').catch(() => { });
|
|
758
|
-
};
|
|
530
|
+
// Bound late to `active`: a gate autofix can replace the live session.
|
|
531
|
+
const announce = (msg, level) => announceTerminal(active, msg, level);
|
|
759
532
|
// First implementation run (blocking).
|
|
760
533
|
const res = await deps.runTask(active, cwd, raw, { resumeId: opts.resumeId });
|
|
761
534
|
active = res.ctx ?? active;
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
6
6
|
import { docsFocused } from '../workers/docs-core.js';
|
|
7
7
|
import { fetchFocused } from '../workers/fetch-core.js';
|
|
8
|
+
import { type RunWorkerInput, type RunWorkerResult } from '../workers/pi-worker-core.js';
|
|
8
9
|
import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
|
|
9
10
|
import type { SearchProvider } from '../workers/search-types.js';
|
|
10
11
|
import { type ExternalContextDeps } from './external-context.js';
|
|
@@ -102,6 +103,23 @@ export declare const phaseRefine: (deps: PhaseDeps, raw: string, planContext?: s
|
|
|
102
103
|
export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): Promise<string>;
|
|
103
104
|
export interface PhaseResearchDeps extends ExternalContextDeps {
|
|
104
105
|
getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
106
|
+
/**
|
|
107
|
+
* Run ONE research worker. Absent (production) → the real `runWorker`.
|
|
108
|
+
*
|
|
109
|
+
* The seam exists for the same reason `getFileInventory` does: every decision
|
|
110
|
+
* `runSpec` makes — the three Research retry gates, the fatal/runaway/empty
|
|
111
|
+
* classification, the marker choice, `postProcess` — is a pure function of the
|
|
112
|
+
* returned `RunWorkerResult`, but reaching any of them used to require driving
|
|
113
|
+
* a fake process that emits JSON events. Substituting the result lets a gate be
|
|
114
|
+
* tested by the fields it actually reads.
|
|
115
|
+
*
|
|
116
|
+
* `label` is the worker's name — the same one `recordWorker` trails. It is
|
|
117
|
+
* passed because a substitute must answer differently per worker, and the only
|
|
118
|
+
* alternative is matching a marker sentence inside the prompt, which makes
|
|
119
|
+
* prompt copy load-bearing test infrastructure. Same reason
|
|
120
|
+
* `PhaseDeps.runChild` takes a name.
|
|
121
|
+
*/
|
|
122
|
+
runWorker?: (label: string, input: RunWorkerInput) => Promise<RunWorkerResult>;
|
|
105
123
|
}
|
|
106
124
|
/**
|
|
107
125
|
* Is live web search configured for this process? The keyless providers (exa,
|
package/dist/task/phases.js
CHANGED
|
@@ -606,6 +606,7 @@ const CONTEXT_SILENT_RETRY_PREAMBLE = 'STOP. Your previous attempt at this task
|
|
|
606
606
|
+ 'question. One claim per bullet. Better to emit three sharp sourced bullets than to say nothing.';
|
|
607
607
|
export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
608
608
|
const fileInventoryFn = researchDeps.getFileInventory ?? getFileInventory;
|
|
609
|
+
const runWorkerFn = researchDeps.runWorker ?? ((_label, input) => runWorker(input));
|
|
609
610
|
const externalContext = await gatherExternalContext(refined, deps, researchDeps);
|
|
610
611
|
// Pre-compute the project file inventory once and hand it to every worker.
|
|
611
612
|
// Workers can then jump straight to targeted read/grep on known paths
|
|
@@ -859,7 +860,7 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
859
860
|
}
|
|
860
861
|
deps.logDebug?.(`${spec.label}: start`);
|
|
861
862
|
const basePrompt = typeof spec.prompt === 'function' ? spec.prompt(prior) : spec.prompt;
|
|
862
|
-
const runOnce = (extraPreamble) => recordWorker(spec.label,
|
|
863
|
+
const runOnce = (extraPreamble) => recordWorker(spec.label, runWorkerFn(spec.label, {
|
|
863
864
|
prompt: extraPreamble ? `${extraPreamble}\n\n${basePrompt}` : basePrompt,
|
|
864
865
|
cwd: deps.cwd,
|
|
865
866
|
signal: deps.signal,
|
|
@@ -1510,7 +1511,7 @@ export const PHASES = [
|
|
|
1510
1511
|
// (proven: compose re-leaks it 4/4). Rewriting the source so compose has
|
|
1511
1512
|
// nothing to contradict is the fix. Silent + no-op when nothing is wrong
|
|
1512
1513
|
// or the runtime's types aren't installed.
|
|
1513
|
-
const phantoms = findPhantomImports(refined, d.cwd);
|
|
1514
|
+
const phantoms = await findPhantomImports(refined, d.cwd);
|
|
1514
1515
|
if (phantoms.length === 0)
|
|
1515
1516
|
return refined;
|
|
1516
1517
|
d.logDebug?.(`phantom specifiers rewritten in refined: ${phantoms.map(x => x.spec).join(', ')}`);
|
|
@@ -1534,7 +1535,7 @@ export const PHASES = [
|
|
|
1534
1535
|
// through every phase and the implementer fabricates a `declare module`
|
|
1535
1536
|
// shim to compile it. Append the corrections so compose folds them into
|
|
1536
1537
|
// CONSTRAINTS. No LLM cost and silent when nothing is wrong.
|
|
1537
|
-
const corrections = formatApiCorrections(findPhantomImports(p.refined, d.cwd));
|
|
1538
|
+
const corrections = formatApiCorrections(await findPhantomImports(p.refined, d.cwd));
|
|
1538
1539
|
if (corrections) {
|
|
1539
1540
|
d.logDebug?.(`phantom imports flagged:\n${corrections}`);
|
|
1540
1541
|
return `${out}\n\n${corrections}`;
|