@mjasnikovs/pi-task 0.38.11 → 0.38.13
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 +8 -5
- package/dist/config/config.d.ts +0 -1
- package/dist/config/config.js +0 -1
- package/dist/config/register.js +0 -2
- package/dist/index.js +0 -2
- 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 +91 -114
- package/dist/task/child-runner.d.ts +39 -25
- package/dist/task/child-runner.js +59 -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 +5 -151
- package/dist/task/final-gate.js +81 -379
- 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/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 +67 -294
- 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/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- 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 —
|
|
@@ -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;
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* /task receives that a bare /task would not is the decisions block.
|
|
21
21
|
*/
|
|
22
22
|
import * as path from 'node:path';
|
|
23
|
-
import {
|
|
23
|
+
import { prependHint, USER_CANCELLED } from './child-runner.js';
|
|
24
24
|
import { PLAN_QUESTION_PROMPT, PLAN_ANSWER_PROMPT } from './plan-prompts.js';
|
|
25
25
|
import { runPlanSession, ASK_TITLE } from './plan-session.js';
|
|
26
26
|
import { allocatePlanId, buildPlanBody, buildHandoffPrompt, formatPlanDecisions } from './plan-io.js';
|
|
@@ -32,12 +32,13 @@ import { deriveTitle } from './parsers.js';
|
|
|
32
32
|
import { renderInlineMarkdown } from './inline-markdown.js';
|
|
33
33
|
import { expandFeatureMentions } from './auto-orchestrator.js';
|
|
34
34
|
import { runSingleTask, runGatedTask } from './orchestrator.js';
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
35
|
+
import { SessionUI, publishViewer, publishLifecycleNotice, registerBridgeCommand } from '../remote/bridge.js';
|
|
36
|
+
import { withRun, announceTerminal } from './run-bracket.js';
|
|
37
37
|
import { getConfig } from '../config/config.js';
|
|
38
38
|
import { isYoloMode } from './yolo.js';
|
|
39
39
|
import { gateDebugWriter } from './debug-log.js';
|
|
40
|
-
import { getParentContextWindow
|
|
40
|
+
import { getParentContextWindow } from './context-usage.js';
|
|
41
|
+
import { ChildStatus, runPlanningChild, statusCallbacks } from './child-status.js';
|
|
41
42
|
import * as fsp from 'node:fs/promises';
|
|
42
43
|
/** Loader labels for the two planning children. */
|
|
43
44
|
const PLAN_STEPS = {
|
|
@@ -52,10 +53,8 @@ const PLAN_STEPS = {
|
|
|
52
53
|
export function buildPlanDeps(ctx, cwd, planId, task, signal) {
|
|
53
54
|
const ui = new SessionUI(ctx);
|
|
54
55
|
const theme = ctx.ui.theme;
|
|
55
|
-
let lastLine;
|
|
56
56
|
let status;
|
|
57
|
-
|
|
58
|
-
const parentContextWindow = getParentContextWindow(ctx);
|
|
57
|
+
const childStatus = new ChildStatus({ parentContextWindow: getParentContextWindow(ctx) });
|
|
59
58
|
const title = deriveTitle(task);
|
|
60
59
|
const logDebug = gateDebugWriter((msg) => {
|
|
61
60
|
const line = `${new Date().toISOString()} ${msg}\n`;
|
|
@@ -65,12 +64,7 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
|
|
|
65
64
|
cwd,
|
|
66
65
|
taskId: planId,
|
|
67
66
|
signal,
|
|
68
|
-
|
|
69
|
-
lastLine = line;
|
|
70
|
-
},
|
|
71
|
-
onContextUsage: snapshot => {
|
|
72
|
-
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
73
|
-
},
|
|
67
|
+
...statusCallbacks(childStatus),
|
|
74
68
|
...(logDebug && { logDebug })
|
|
75
69
|
};
|
|
76
70
|
/**
|
|
@@ -82,25 +76,23 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
|
|
|
82
76
|
* the plan file itself is written.
|
|
83
77
|
*/
|
|
84
78
|
const child = async (name, prompt) => {
|
|
85
|
-
lastLine = undefined;
|
|
86
|
-
contextUsage = undefined;
|
|
87
|
-
const startedAt = Date.now();
|
|
88
|
-
const stopLoader = startAutoLoader(ctx, () => ({
|
|
89
|
-
command: '/task-plan',
|
|
90
|
-
title,
|
|
91
|
-
step: status ?? PLAN_STEPS[name] ?? name,
|
|
92
|
-
stepNum: 1,
|
|
93
|
-
stepTotal: 1,
|
|
94
|
-
startedAt,
|
|
95
|
-
lastLine,
|
|
96
|
-
contextUsage
|
|
97
|
-
}));
|
|
98
79
|
const before = await collectTreeChanges(cwd, signal).catch(() => null);
|
|
99
80
|
try {
|
|
100
|
-
return await
|
|
81
|
+
return await runPlanningChild({
|
|
82
|
+
ctx,
|
|
83
|
+
status: childStatus,
|
|
84
|
+
phaseDeps,
|
|
85
|
+
name,
|
|
86
|
+
tools: PLAN_TOOLS,
|
|
87
|
+
prompt,
|
|
88
|
+
loader: {
|
|
89
|
+
command: '/task-plan',
|
|
90
|
+
title,
|
|
91
|
+
step: n => ({ step: status ?? PLAN_STEPS[n] ?? n, stepNum: 1, stepTotal: 1 })
|
|
92
|
+
}
|
|
93
|
+
});
|
|
101
94
|
}
|
|
102
95
|
finally {
|
|
103
|
-
stopLoader();
|
|
104
96
|
// Outside a git repo `before` is null and there is nothing to compare
|
|
105
97
|
// against — the same degrade every other tree-reading guard here takes.
|
|
106
98
|
if (before) {
|
|
@@ -264,6 +256,16 @@ export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DE
|
|
|
264
256
|
title: deriveTitle(raw)
|
|
265
257
|
};
|
|
266
258
|
await writeTaskFile(cwd, fm, buildPlanBody(task));
|
|
259
|
+
// The plan session owns the session the way a run does: its children run
|
|
260
|
+
// with the host idle, so an unbracketed line typed then went into pi's queue
|
|
261
|
+
// (terminal) or started a competing turn (browser) — the issue #8 shape.
|
|
262
|
+
// /task-auto brackets its planning for the same reason; this one was left
|
|
263
|
+
// out when /task-plan landed. Held lines reach the handed-off /task run's
|
|
264
|
+
// first turn (the bracket nests), or are reported when the plan ends without
|
|
265
|
+
// one. No onCancel: /task-auto-cancel is not this command's to acknowledge.
|
|
266
|
+
await withRun(ctx, {}, () => runPlanCommand(ctx, cwd, planId, task, commandDeps));
|
|
267
|
+
}
|
|
268
|
+
async function runPlanCommand(ctx, cwd, planId, task, commandDeps) {
|
|
267
269
|
const abort = new AbortController();
|
|
268
270
|
let outcome;
|
|
269
271
|
try {
|
|
@@ -275,9 +277,9 @@ export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DE
|
|
|
275
277
|
state: msg === USER_CANCELLED ? 'cancelled' : 'failed',
|
|
276
278
|
reason: msg.slice(0, 200)
|
|
277
279
|
}).catch(() => { });
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
280
|
+
// No push: a plan is a conversation, not a task; the run it hands off
|
|
281
|
+
// to pushes its own ending.
|
|
282
|
+
announceTerminal(ctx, `${planId} stopped — ${msg.slice(0, 160)}`, 'error', { push: false });
|
|
281
283
|
return;
|
|
282
284
|
}
|
|
283
285
|
if (outcome.kind === 'cancelled') {
|
|
@@ -289,8 +291,7 @@ export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DE
|
|
|
289
291
|
const line = outcome.entries.length === 0 ?
|
|
290
292
|
`${planId} cancelled — nothing planned.`
|
|
291
293
|
: `${planId} cancelled — ${outcome.entries.length} entr${outcome.entries.length === 1 ? 'y' : 'ies'} kept in .pi-tasks/${planId}.md`;
|
|
292
|
-
ctx
|
|
293
|
-
publishNotify(line, 'warning');
|
|
294
|
+
announceTerminal(ctx, line, 'warning', { push: false });
|
|
294
295
|
return;
|
|
295
296
|
}
|
|
296
297
|
const prompt = buildHandoffPrompt(task, outcome.entries);
|
|
@@ -147,7 +147,7 @@ export declare function ownedRequirementsFile(cwd: string): string;
|
|
|
147
147
|
/** Persist the task-mapped requirements (host-side, plan time). Overwrites —
|
|
148
148
|
* the mapping is recomputed whole per plan round. Best-effort like the carried
|
|
149
149
|
* artifact. */
|
|
150
|
-
export declare function writeOwnedRequirements(cwd: string,
|
|
150
|
+
export declare function writeOwnedRequirements(cwd: string, entries: OwnedRequirement[]): Promise<void>;
|
|
151
151
|
export declare function readOwnedRequirements(cwd: string): Promise<OwnedRequirement[]>;
|
|
152
152
|
export declare function parseOwnedRequirements(text: string): OwnedRequirement[];
|
|
153
153
|
/** The owned entries whose plan title matches THIS task's title (normalised
|