@mjasnikovs/pi-task 0.18.28 → 0.18.29
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.
|
@@ -43,6 +43,14 @@ export interface WatchdogDeps {
|
|
|
43
43
|
/** Invoked when a command overruns: each adapter aborts/kills here. */
|
|
44
44
|
onFire: (toolCallId: string, toolName: string, timeoutMs: number) => void;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Stable substring of {@link reminderMessage}, used by the steer loop
|
|
48
|
+
* (orchestrator steerUntilDone) to recognise the watchdog's follow-up turn in
|
|
49
|
+
* the session entries — the artifact that distinguishes a watchdog abort from a
|
|
50
|
+
* human ESC. Interpolated into the message so the detector and the text cannot
|
|
51
|
+
* drift apart.
|
|
52
|
+
*/
|
|
53
|
+
export declare const WATCHDOG_CANCEL_MARKER = "was automatically cancelled \u2014 it looked stuck.";
|
|
46
54
|
/**
|
|
47
55
|
* MAIN-SESSION reminder, delivered as a follow-up turn after ctx.abort() has
|
|
48
56
|
* cancelled the offending tool call. The session is still alive and remembers
|
|
@@ -44,6 +44,14 @@ function correction() {
|
|
|
44
44
|
+ `timeout — set the bash tool's \`timeout\` parameter (in seconds) so it cannot hang `
|
|
45
45
|
+ `again — or break it into smaller steps. Do NOT simply retry the same unbounded command.`);
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Stable substring of {@link reminderMessage}, used by the steer loop
|
|
49
|
+
* (orchestrator steerUntilDone) to recognise the watchdog's follow-up turn in
|
|
50
|
+
* the session entries — the artifact that distinguishes a watchdog abort from a
|
|
51
|
+
* human ESC. Interpolated into the message so the detector and the text cannot
|
|
52
|
+
* drift apart.
|
|
53
|
+
*/
|
|
54
|
+
export const WATCHDOG_CANCEL_MARKER = 'was automatically cancelled — it looked stuck.';
|
|
47
55
|
/**
|
|
48
56
|
* MAIN-SESSION reminder, delivered as a follow-up turn after ctx.abort() has
|
|
49
57
|
* cancelled the offending tool call. The session is still alive and remembers
|
|
@@ -51,7 +59,7 @@ function correction() {
|
|
|
51
59
|
*/
|
|
52
60
|
export function reminderMessage(toolName, timeoutMs) {
|
|
53
61
|
return (`[SYSTEM] Your \`${toolName}\` call ran longer than ${minutes(timeoutMs)} `
|
|
54
|
-
+ `and
|
|
62
|
+
+ `and ${WATCHDOG_CANCEL_MARKER} `
|
|
55
63
|
+ correction());
|
|
56
64
|
}
|
|
57
65
|
/**
|
|
@@ -24,7 +24,11 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
24
24
|
* them; their equivalent guard lives in runWorker (workers/pi-worker-core.ts)
|
|
25
25
|
* and shares the same machine from shared/command-watchdog.ts.
|
|
26
26
|
*/
|
|
27
|
-
export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, type TimerHandle, type WatchdogDeps } from '../shared/command-watchdog.js';
|
|
27
|
+
export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, WATCHDOG_CANCEL_MARKER, type TimerHandle, type WatchdogDeps } from '../shared/command-watchdog.js';
|
|
28
|
+
/** @internal Set by onFire when it aborts a turn. Exported for the adapter and tests. */
|
|
29
|
+
export declare function noteWatchdogAbort(): void;
|
|
30
|
+
/** True exactly once per watchdog abort; clears the flag. */
|
|
31
|
+
export declare function consumeWatchdogAbort(): boolean;
|
|
28
32
|
/**
|
|
29
33
|
* Wire the watchdog into the main session. Only ever active in the host session
|
|
30
34
|
* (children run `--no-extensions`), which is exactly where the observed hangs
|
|
@@ -27,7 +27,31 @@ import { CommandWatchdog, realTimerDeps, reminderMessage } from '../shared/comma
|
|
|
27
27
|
*/
|
|
28
28
|
// Re-exported so existing importers (and the machine's own tests) keep their
|
|
29
29
|
// entry point while the implementation lives in shared/.
|
|
30
|
-
export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage } from '../shared/command-watchdog.js';
|
|
30
|
+
export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, WATCHDOG_CANCEL_MARKER } from '../shared/command-watchdog.js';
|
|
31
|
+
/**
|
|
32
|
+
* One-shot marker: the most recent turn abort was issued BY THE WATCHDOG, not by
|
|
33
|
+
* a human ESC. Both end the assistant turn with stopReason 'aborted' — the only
|
|
34
|
+
* signal steerUntilDone's wasInterrupted() can read — so without this flag the
|
|
35
|
+
* steer loop can win the race against the watchdog's queued follow-up turn and
|
|
36
|
+
* show a steering prompt to an empty room (wedging an unattended run).
|
|
37
|
+
*
|
|
38
|
+
* Set synchronously in onFire BEFORE ctx.abort(), so it is observable by the
|
|
39
|
+
* time any waitForIdle resolves; consumed (cleared) by the first reader. A stale
|
|
40
|
+
* flag (the aborted turn wasn't one the steer loop was watching) only costs the
|
|
41
|
+
* consumer a bounded wait before it falls back to prompting — it can never
|
|
42
|
+
* permanently suppress a human's steer prompt.
|
|
43
|
+
*/
|
|
44
|
+
let watchdogAbortPending = false;
|
|
45
|
+
/** @internal Set by onFire when it aborts a turn. Exported for the adapter and tests. */
|
|
46
|
+
export function noteWatchdogAbort() {
|
|
47
|
+
watchdogAbortPending = true;
|
|
48
|
+
}
|
|
49
|
+
/** True exactly once per watchdog abort; clears the flag. */
|
|
50
|
+
export function consumeWatchdogAbort() {
|
|
51
|
+
const was = watchdogAbortPending;
|
|
52
|
+
watchdogAbortPending = false;
|
|
53
|
+
return was;
|
|
54
|
+
}
|
|
31
55
|
/**
|
|
32
56
|
* Wire the watchdog into the main session. Only ever active in the host session
|
|
33
57
|
* (children run `--no-extensions`), which is exactly where the observed hangs
|
|
@@ -46,8 +70,12 @@ export function registerCommandWatchdog(pi) {
|
|
|
46
70
|
ctxByCall.delete(toolCallId);
|
|
47
71
|
// Cancel the stuck command (kills the tool's whole process tree via
|
|
48
72
|
// the turn's AbortSignal), then start a fresh turn telling the model
|
|
49
|
-
// to bound its next attempt.
|
|
50
|
-
|
|
73
|
+
// to bound its next attempt. The flag must precede the abort so the
|
|
74
|
+
// steer loop can never observe the 'aborted' turn before the flag.
|
|
75
|
+
if (ctx) {
|
|
76
|
+
noteWatchdogAbort();
|
|
77
|
+
ctx.abort();
|
|
78
|
+
}
|
|
51
79
|
pi.sendUserMessage(reminderMessage(toolName, timeoutMs), { deliverAs: 'followUp' });
|
|
52
80
|
}
|
|
53
81
|
});
|
|
@@ -212,6 +212,41 @@ export declare const MAX_COMPACTION_RESUMES = 20;
|
|
|
212
212
|
* of resumes performed (0 when the turn did not end on a compaction).
|
|
213
213
|
*/
|
|
214
214
|
export declare function resumeAcrossCompactions(ctx: SteerCtx): Promise<number>;
|
|
215
|
+
/**
|
|
216
|
+
* Timing knobs for the watchdog-abort guard in {@link steerUntilDone}, injectable
|
|
217
|
+
* so tests exercise the grace expiry without a 10-second wait. `graceMs` bounds
|
|
218
|
+
* how long the loop waits for the watchdog's follow-up to be DELIVERED (not to
|
|
219
|
+
* finish — its turn may legitimately run for minutes afterwards); delivery is
|
|
220
|
+
* normally near-instant, so the grace only expires on a stale flag.
|
|
221
|
+
*/
|
|
222
|
+
export interface SteerWatchdogDeps {
|
|
223
|
+
consume: () => boolean;
|
|
224
|
+
graceMs: number;
|
|
225
|
+
pollMs: number;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* After the implementation turn settles, honour a user ESC by letting them steer.
|
|
229
|
+
*
|
|
230
|
+
* `waitForIdle` resolves both on natural completion AND on an ESC (which aborts
|
|
231
|
+
* the turn → idle). When the last turn was aborted, the host's main input loop is
|
|
232
|
+
* blocked inside our command handler, so a message typed in the editor would only
|
|
233
|
+
* queue, never run (interactive-mode routes idle input through onInputCallback,
|
|
234
|
+
* which is unset while we hold the loop). We therefore solicit the steering text
|
|
235
|
+
* ourselves and feed it back as another turn via sendUserMessage — which runs to
|
|
236
|
+
* completion when the session is idle. Repeat until a turn finishes uninterrupted.
|
|
237
|
+
*
|
|
238
|
+
* A WATCHDOG abort also ends the turn with stopReason 'aborted' — indistinguishable
|
|
239
|
+
* from a human ESC by the session entries alone at that instant. The watchdog
|
|
240
|
+
* queues its own recovery follow-up, so prompting there would show a steering
|
|
241
|
+
* dialog to an empty room and wedge an unattended run on the race. The one-shot
|
|
242
|
+
* flag (set synchronously before the abort) routes that case to
|
|
243
|
+
* {@link awaitWatchdogFollowUp} instead; a stale flag degrades to a bounded wait
|
|
244
|
+
* followed by the ordinary prompt, never to a suppressed one.
|
|
245
|
+
*
|
|
246
|
+
* Returns true when the user declined to steer (empty/cancelled) and the run
|
|
247
|
+
* should pause; false when the implementation completed (steered or not).
|
|
248
|
+
*/
|
|
249
|
+
export declare function steerUntilDone(ctx: SteerCtx, promptSteer?: (ctx: ExtensionCommandContext) => Promise<string | undefined>, watchdog?: Partial<SteerWatchdogDeps>): Promise<boolean>;
|
|
215
250
|
/**
|
|
216
251
|
* Run one prompt through the full single-task pipeline in a fresh session and
|
|
217
252
|
* deliver its spec. With waitForImplementation, block until the agent finishes
|
|
@@ -28,6 +28,7 @@ import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.
|
|
|
28
28
|
import { publishViewer, publishNotify, publishLifecycleNotice, registerBridgeCommand, getBridge, SessionUI } from '../remote/bridge.js';
|
|
29
29
|
import { pushNotify } from '../remote/push.js';
|
|
30
30
|
import { getConfig } from '../config/config.js';
|
|
31
|
+
import { consumeWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
|
|
31
32
|
import { buildGateDeps } from './gate-deps.js';
|
|
32
33
|
import { runGatesForTask } from './task-gates.js';
|
|
33
34
|
import { parseVerifyBlock } from './spec-validation.js';
|
|
@@ -462,6 +463,67 @@ export async function resumeAcrossCompactions(ctx) {
|
|
|
462
463
|
}
|
|
463
464
|
return resumes;
|
|
464
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* True when the watchdog's reminder follow-up has been DELIVERED into the session
|
|
468
|
+
* after the aborted assistant turn but its own turn has not finished yet — the
|
|
469
|
+
* artifact that confirms a pending watchdog recovery. Scoped after the LAST
|
|
470
|
+
* assistant entry so an earlier fire's reminder (already answered by its own
|
|
471
|
+
* turn) never matches.
|
|
472
|
+
*/
|
|
473
|
+
function watchdogReminderDelivered(ctx) {
|
|
474
|
+
const entries = ctx.sessionManager.getEntries();
|
|
475
|
+
let lastAssistant = -1;
|
|
476
|
+
for (let i = 0; i < entries.length; i++) {
|
|
477
|
+
const e = entries[i];
|
|
478
|
+
if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
|
|
479
|
+
lastAssistant = i;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
for (let i = lastAssistant + 1; i < entries.length; i++) {
|
|
483
|
+
const e = entries[i];
|
|
484
|
+
if (!('message' in e) || !('role' in e.message) || e.message.role !== 'user')
|
|
485
|
+
continue;
|
|
486
|
+
const content = e.message.content;
|
|
487
|
+
const text = typeof content === 'string' ? content
|
|
488
|
+
: Array.isArray(content) ?
|
|
489
|
+
content
|
|
490
|
+
.map(b => b !== null && typeof b === 'object' && 'text' in b ?
|
|
491
|
+
String(b.text)
|
|
492
|
+
: '')
|
|
493
|
+
.join(' ')
|
|
494
|
+
: '';
|
|
495
|
+
if (text.includes(WATCHDOG_CANCEL_MARKER))
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
const STEER_WATCHDOG_DEFAULTS = {
|
|
501
|
+
consume: consumeWatchdogAbort,
|
|
502
|
+
graceMs: 10_000,
|
|
503
|
+
pollMs: 100
|
|
504
|
+
};
|
|
505
|
+
/**
|
|
506
|
+
* Wait for a watchdog abort's queued follow-up turn instead of prompting. The
|
|
507
|
+
* abort and the reminder follow-up are two separate steps in the watchdog's
|
|
508
|
+
* onFire, so the steer loop can observe the aborted turn before the reminder is
|
|
509
|
+
* delivered — poll (bounded) until it lands or the follow-up turn has already
|
|
510
|
+
* completed. True = recovery observed, re-check the loop; false = grace expired
|
|
511
|
+
* with no reminder (stale flag) — fall back to the human prompt.
|
|
512
|
+
*/
|
|
513
|
+
async function awaitWatchdogFollowUp(ctx, wd) {
|
|
514
|
+
const deadline = Date.now() + wd.graceMs;
|
|
515
|
+
for (;;) {
|
|
516
|
+
if (!wasInterrupted(ctx))
|
|
517
|
+
return true; // follow-up turn already completed
|
|
518
|
+
if (watchdogReminderDelivered(ctx)) {
|
|
519
|
+
await ctx.waitForIdle(); // let the follow-up turn run to completion
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
if (Date.now() >= deadline)
|
|
523
|
+
return false;
|
|
524
|
+
await new Promise(r => setTimeout(r, wd.pollMs));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
465
527
|
/**
|
|
466
528
|
* After the implementation turn settles, honour a user ESC by letting them steer.
|
|
467
529
|
*
|
|
@@ -473,10 +535,19 @@ export async function resumeAcrossCompactions(ctx) {
|
|
|
473
535
|
* ourselves and feed it back as another turn via sendUserMessage — which runs to
|
|
474
536
|
* completion when the session is idle. Repeat until a turn finishes uninterrupted.
|
|
475
537
|
*
|
|
538
|
+
* A WATCHDOG abort also ends the turn with stopReason 'aborted' — indistinguishable
|
|
539
|
+
* from a human ESC by the session entries alone at that instant. The watchdog
|
|
540
|
+
* queues its own recovery follow-up, so prompting there would show a steering
|
|
541
|
+
* dialog to an empty room and wedge an unattended run on the race. The one-shot
|
|
542
|
+
* flag (set synchronously before the abort) routes that case to
|
|
543
|
+
* {@link awaitWatchdogFollowUp} instead; a stale flag degrades to a bounded wait
|
|
544
|
+
* followed by the ordinary prompt, never to a suppressed one.
|
|
545
|
+
*
|
|
476
546
|
* Returns true when the user declined to steer (empty/cancelled) and the run
|
|
477
547
|
* should pause; false when the implementation completed (steered or not).
|
|
478
548
|
*/
|
|
479
|
-
async function steerUntilDone(ctx, promptSteer) {
|
|
549
|
+
export async function steerUntilDone(ctx, promptSteer, watchdog) {
|
|
550
|
+
const wd = { ...STEER_WATCHDOG_DEFAULTS, ...watchdog };
|
|
480
551
|
// Fan the prompt out through the bridge (local TUI input + remote browser
|
|
481
552
|
// card, first answer wins) instead of a raw ctx.ui.input: an interrupt can
|
|
482
553
|
// come from the remote Stop button just as well as a terminal ESC, and a
|
|
@@ -491,6 +562,8 @@ async function steerUntilDone(ctx, promptSteer) {
|
|
|
491
562
|
allowSkip: true
|
|
492
563
|
}));
|
|
493
564
|
while (wasInterrupted(ctx)) {
|
|
565
|
+
if (wd.consume() && (await awaitWatchdogFollowUp(ctx, wd)))
|
|
566
|
+
continue;
|
|
494
567
|
const steer = await ask(ctx);
|
|
495
568
|
if (steer === undefined || steer.trim().length === 0)
|
|
496
569
|
return true; // pause
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.29",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|