@mjasnikovs/pi-task 0.18.32 → 0.18.34
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/dist/task/auto-orchestrator.js +111 -45
- package/dist/task/cancel-input.d.ts +61 -0
- package/dist/task/cancel-input.js +113 -0
- package/dist/task/cancel-points.d.ts +58 -0
- package/dist/task/cancel-points.js +77 -0
- package/dist/task/orchestrator.js +20 -0
- package/dist/workers/docs-retrieve.js +16 -4
- package/package.json +1 -1
|
@@ -19,6 +19,8 @@ import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasks
|
|
|
19
19
|
import { readTextFile } from '../shared/fs-text.js';
|
|
20
20
|
import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
|
|
21
21
|
import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
22
|
+
import { requestCancel, resetCancel, isCancelRequested, cancelCheckpoint } from './cancel-points.js';
|
|
23
|
+
import { armCancelListener, disarmCancelListener } from './cancel-input.js';
|
|
22
24
|
import { refineExistingFilesBlock } from './phases.js';
|
|
23
25
|
import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../remote/bridge.js';
|
|
24
26
|
import { pushNotify } from '../remote/push.js';
|
|
@@ -915,10 +917,9 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
915
917
|
};
|
|
916
918
|
}
|
|
917
919
|
// ─── Loop ────────────────────────────────────────────────────────────────────
|
|
918
|
-
let cancelRequested = false;
|
|
919
920
|
let autoRunning = false;
|
|
920
921
|
export function requestAutoCancel() {
|
|
921
|
-
|
|
922
|
+
requestCancel();
|
|
922
923
|
}
|
|
923
924
|
/**
|
|
924
925
|
* Announce a terminal /task-auto-overall outcome both in the terminal and to
|
|
@@ -936,7 +937,7 @@ function announceDone(ctx, msg, level) {
|
|
|
936
937
|
void pushNotify('Task finished', msg, 'pi-end').catch(() => { });
|
|
937
938
|
}
|
|
938
939
|
export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
939
|
-
|
|
940
|
+
resetCancel();
|
|
940
941
|
// Each task runs in its own fresh session (deps.runTask → ctx.newSession),
|
|
941
942
|
// which tears down the current session and leaves the ctx we passed in stale.
|
|
942
943
|
// Adopt the replacement ctx the runner hands back and use it for all further
|
|
@@ -944,7 +945,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
944
945
|
let active = ctx;
|
|
945
946
|
try {
|
|
946
947
|
for (;;) {
|
|
947
|
-
if (
|
|
948
|
+
if (cancelCheckpoint('loop-top')) {
|
|
948
949
|
announceDone(active, `${id} cancelled — resume with /task-auto-resume.`, 'warning');
|
|
949
950
|
return;
|
|
950
951
|
}
|
|
@@ -959,6 +960,14 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
959
960
|
// complete. On a FAIL the user decides: accept (complete anyway) or
|
|
960
961
|
// leave the run failed — a resume re-enters this branch and re-runs
|
|
961
962
|
// the gate, so fixing and resuming converges.
|
|
963
|
+
// SAFE CHECKPOINT (pre-final-gate): every task is checked off and
|
|
964
|
+
// committed and the whole-repo gate has not started. A resume
|
|
965
|
+
// re-enters this same branch and runs the gate then, so the run is
|
|
966
|
+
// left exactly where it was — not silently declared complete.
|
|
967
|
+
if (cancelCheckpoint('pre-final-gate')) {
|
|
968
|
+
announceDone(active, `${id} cancelled before the final integration gate — resume with /task-auto-resume.`, 'warning');
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
962
971
|
if (deps.finalGate) {
|
|
963
972
|
active.ui.notify(`${id}: running final integration gate…`, 'info');
|
|
964
973
|
// Run-level gate trail on the parent task file — same durable
|
|
@@ -1229,6 +1238,14 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1229
1238
|
// during the task (impl model or any child) and left behind is called
|
|
1230
1239
|
// out instead of silently waiting to detonate in a later task.
|
|
1231
1240
|
const stashBefore = deps.stashRef ? await deps.stashRef(cwd) : undefined;
|
|
1241
|
+
// SAFE CHECKPOINT (pre-task): the tree is committed and no inner task
|
|
1242
|
+
// is stamped yet, so stopping here just leaves this entry unchecked —
|
|
1243
|
+
// a resume restarts it from scratch. Cheapest possible stop, and the
|
|
1244
|
+
// last one before we commit to a ~30-minute task.
|
|
1245
|
+
if (cancelCheckpoint('pre-task')) {
|
|
1246
|
+
announceDone(active, `${id} cancelled before "${next.title}" — resume with /task-auto-resume.`, 'warning');
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1232
1249
|
const res = await deps.runTask(active, cwd, next.title, {
|
|
1233
1250
|
resumeId,
|
|
1234
1251
|
// Fence this step against re-expanding the whole referenced spec:
|
|
@@ -1255,6 +1272,16 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1255
1272
|
announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
|
|
1256
1273
|
return;
|
|
1257
1274
|
}
|
|
1275
|
+
// A phase-boundary cancel surfaces here as a plain !res.ok: the runner
|
|
1276
|
+
// caught its own USER_CANCELLED and wrote state 'cancelled' (resumable)
|
|
1277
|
+
// to the inner file. Claim it BEFORE the failure branch, or a
|
|
1278
|
+
// user-requested stop is announced in red as "stopped … fix and
|
|
1279
|
+
// resume". The inner file is already resumable and the parent stays
|
|
1280
|
+
// in_progress, matching the loop-top cancel.
|
|
1281
|
+
if (!res.ok && isCancelRequested()) {
|
|
1282
|
+
announceDone(active, `${id} cancelled during "${next.title}" — resume with /task-auto-resume.`, 'warning');
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1258
1285
|
if (!res.ok) {
|
|
1259
1286
|
// Demote the INNER task file too: it reads `completed` from
|
|
1260
1287
|
// spec-handoff, and leaving it that way is how a failed run's task
|
|
@@ -1336,7 +1363,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1336
1363
|
announceDone(active, `${id} stopped: ${msg} — fix and run /task-auto-resume.`, 'error');
|
|
1337
1364
|
}
|
|
1338
1365
|
finally {
|
|
1339
|
-
|
|
1366
|
+
resetCancel();
|
|
1340
1367
|
}
|
|
1341
1368
|
}
|
|
1342
1369
|
// ─── Command handlers ────────────────────────────────────────────────────────
|
|
@@ -1350,40 +1377,45 @@ async function handleTaskAuto(args, ctx) {
|
|
|
1350
1377
|
return;
|
|
1351
1378
|
}
|
|
1352
1379
|
autoRunning = true;
|
|
1353
|
-
//
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1356
|
-
|
|
1357
|
-
const abort = new AbortController();
|
|
1358
|
-
const deps = defaultDeps(ctx, cwd, abort.signal, deriveTitle(raw));
|
|
1359
|
-
let id;
|
|
1380
|
+
// Take delivery of a typed /task-auto-cancel for the WHOLE run, planning
|
|
1381
|
+
// included — planning is children too, so the host is not streaming and the
|
|
1382
|
+
// ordinary command path cannot reach us.
|
|
1383
|
+
armTerminalCancel(ctx);
|
|
1360
1384
|
try {
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
const
|
|
1366
|
-
|
|
1385
|
+
// Stamp a fresh per-run research-cache id (F10) BEFORE planning so enrichment and
|
|
1386
|
+
// every task's research phase share one run's cache; disabled ⇒ clears any token a
|
|
1387
|
+
// prior run left, so nothing is cached.
|
|
1388
|
+
configureResearchRun(getConfig().researchCache);
|
|
1389
|
+
const abort = new AbortController();
|
|
1390
|
+
const deps = defaultDeps(ctx, cwd, abort.signal, deriveTitle(raw));
|
|
1391
|
+
let id;
|
|
1392
|
+
try {
|
|
1393
|
+
id = await planAuto(ctx, cwd, raw, deps);
|
|
1394
|
+
}
|
|
1395
|
+
catch (err) {
|
|
1396
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1397
|
+
if (msg === USER_CANCELLED) {
|
|
1398
|
+
announceDone(ctx, '/task-auto cancelled.', 'warning');
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
announceDone(ctx, `/task-auto planning failed: ${msg}`, 'error');
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
if (!id)
|
|
1405
|
+
return;
|
|
1406
|
+
// Check for a cancel that was requested during the planning phase before the
|
|
1407
|
+
// loop resets the flag.
|
|
1408
|
+
if (isCancelRequested()) {
|
|
1409
|
+
resetCancel();
|
|
1367
1410
|
announceDone(ctx, '/task-auto cancelled.', 'warning');
|
|
1368
1411
|
return;
|
|
1369
1412
|
}
|
|
1370
|
-
|
|
1371
|
-
return;
|
|
1413
|
+
await runAutoLoop(ctx, cwd, id, deps);
|
|
1372
1414
|
}
|
|
1373
|
-
|
|
1374
|
-
autoRunning = false;
|
|
1375
|
-
return;
|
|
1376
|
-
}
|
|
1377
|
-
// Check for a cancel that was requested during the planning phase before the
|
|
1378
|
-
// loop resets the flag.
|
|
1379
|
-
if (cancelRequested) {
|
|
1380
|
-
cancelRequested = false;
|
|
1415
|
+
finally {
|
|
1381
1416
|
autoRunning = false;
|
|
1382
|
-
|
|
1383
|
-
return;
|
|
1417
|
+
disarmCancelListener();
|
|
1384
1418
|
}
|
|
1385
|
-
await runAutoLoop(ctx, cwd, id, deps);
|
|
1386
|
-
autoRunning = false;
|
|
1387
1419
|
}
|
|
1388
1420
|
async function handleTaskAutoResume(_args, ctx) {
|
|
1389
1421
|
await ctx.waitForIdle();
|
|
@@ -1396,19 +1428,25 @@ async function handleTaskAutoResume(_args, ctx) {
|
|
|
1396
1428
|
ctx.ui.notify(`Resuming ${id}…`, 'info');
|
|
1397
1429
|
await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
|
|
1398
1430
|
autoRunning = true;
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1431
|
+
armTerminalCancel(ctx);
|
|
1432
|
+
try {
|
|
1433
|
+
// Reuse the interrupted run's research-cache id when the cache proves it still
|
|
1434
|
+
// describes the same dependency surface (F10). mx5 run 13 resumed three times and
|
|
1435
|
+
// each resume's fresh id discarded a working 201-entry cache; anything inconclusive
|
|
1436
|
+
// still falls back to a fresh id and a re-fetch. See resumeResearchRun.
|
|
1437
|
+
const research = await resumeResearchRun(cwd, getConfig().researchCache);
|
|
1438
|
+
if (research.reused) {
|
|
1439
|
+
logPlanDebug(cwd, `research cache: resume reused ${research.entries} entr(ies)`);
|
|
1440
|
+
}
|
|
1441
|
+
const abort = new AbortController();
|
|
1442
|
+
// Resume only runs the loop (runTask); no planning children, so the loader
|
|
1443
|
+
// title is unused here — pass the id for clarity if that ever changes.
|
|
1444
|
+
await runAutoLoop(ctx, cwd, id, defaultDeps(ctx, cwd, abort.signal, id));
|
|
1445
|
+
}
|
|
1446
|
+
finally {
|
|
1447
|
+
autoRunning = false;
|
|
1448
|
+
disarmCancelListener();
|
|
1406
1449
|
}
|
|
1407
|
-
const abort = new AbortController();
|
|
1408
|
-
// Resume only runs the loop (runTask); no planning children, so the loader
|
|
1409
|
-
// title is unused here — pass the id for clarity if that ever changes.
|
|
1410
|
-
await runAutoLoop(ctx, cwd, id, defaultDeps(ctx, cwd, abort.signal, id));
|
|
1411
|
-
autoRunning = false;
|
|
1412
1450
|
}
|
|
1413
1451
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
1414
1452
|
async function handleTaskAutoCancel(_args, ctx) {
|
|
@@ -1417,7 +1455,35 @@ async function handleTaskAutoCancel(_args, ctx) {
|
|
|
1417
1455
|
return;
|
|
1418
1456
|
}
|
|
1419
1457
|
requestAutoCancel();
|
|
1420
|
-
ctx.ui.notify(
|
|
1458
|
+
ctx.ui.notify(CANCEL_ACK, 'warning');
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* What the user is told the moment the request lands. Deliberately does NOT
|
|
1462
|
+
* promise "after the current task": the request is now honoured at the next safe
|
|
1463
|
+
* checkpoint (see cancel-points.ts), which mid-spec-pipeline is the end of the
|
|
1464
|
+
* current phase, not the end of the task.
|
|
1465
|
+
*/
|
|
1466
|
+
const CANCEL_ACK = 'Stopping /task-auto at the next safe checkpoint…';
|
|
1467
|
+
/**
|
|
1468
|
+
* Deliver a /task-auto-cancel typed in the terminal while a run owns the main
|
|
1469
|
+
* loop. `armCancelListener` watches raw stdin, so it works during the spec
|
|
1470
|
+
* phases and the gates — the windows where pi would otherwise queue the line
|
|
1471
|
+
* until after the run (see cancel-input.ts). The remote path is unaffected:
|
|
1472
|
+
* dispatchRemoteLine invokes the handler directly.
|
|
1473
|
+
*/
|
|
1474
|
+
function armTerminalCancel(ctx) {
|
|
1475
|
+
armCancelListener(ctx, live => {
|
|
1476
|
+
requestAutoCancel();
|
|
1477
|
+
// `live` is the ctx the listener is currently installed on — the captured
|
|
1478
|
+
// one is stale the moment a task replaces the session.
|
|
1479
|
+
try {
|
|
1480
|
+
live.ui.notify(CANCEL_ACK, 'warning');
|
|
1481
|
+
}
|
|
1482
|
+
catch {
|
|
1483
|
+
/* the acknowledgement must never break the cancel itself */
|
|
1484
|
+
}
|
|
1485
|
+
publishLifecycleNotice(CANCEL_ACK, 'warning');
|
|
1486
|
+
});
|
|
1421
1487
|
}
|
|
1422
1488
|
// ─── Registration ────────────────────────────────────────────────────────────
|
|
1423
1489
|
export function registerTaskAuto(pi) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw-terminal delivery for /task-auto-cancel.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM. While a /task-auto run is in flight, the host's interactive main
|
|
5
|
+
* loop is parked at `await session.prompt("/task-auto …")` and never loops back
|
|
6
|
+
* to read input. pi's editor submit handler (interactive-mode.js) has two paths:
|
|
7
|
+
*
|
|
8
|
+
* - streaming → session.prompt(text, {streamingBehavior:"steer"}), and
|
|
9
|
+
* agent-session.prompt() runs extension commands immediately.
|
|
10
|
+
* - not streaming → pendingUserInputs.push(text) — a queue only the parked
|
|
11
|
+
* main loop drains.
|
|
12
|
+
*
|
|
13
|
+
* The host session is NOT streaming for most of a run: the spec phases and every
|
|
14
|
+
* gate are child `pi` processes, not host turns. So a /task-auto-cancel typed
|
|
15
|
+
* during them was pushed onto that queue and executed only after the run had
|
|
16
|
+
* already finished — by which point `autoRunning` is false and it answers "No
|
|
17
|
+
* /task-auto loop is running." The command was, for most of a run, inert.
|
|
18
|
+
*
|
|
19
|
+
* THE FIX. `ctx.ui.onTerminalInput` (→ TUI.addInputListener) is a raw stdin
|
|
20
|
+
* listener that the TUI dispatches BEFORE the focused component sees the bytes,
|
|
21
|
+
* independently of the parked main loop. We watch for the submit key, read what
|
|
22
|
+
* the editor is holding, and if it is the cancel command we raise the request
|
|
23
|
+
* ourselves and `consume` the keystroke so the line is never queued for a
|
|
24
|
+
* post-run replay of the confusing "no loop is running" message.
|
|
25
|
+
*
|
|
26
|
+
* Nothing else is intercepted: every other keystroke is passed straight through,
|
|
27
|
+
* so typing, history, and the ESC/steer path are untouched. Deliberately no
|
|
28
|
+
* bare-key shortcut — ESC already means "interrupt the turn" during the
|
|
29
|
+
* implementation turn, and hijacking it would break steerUntilDone.
|
|
30
|
+
*/
|
|
31
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
32
|
+
/**
|
|
33
|
+
* Would this keystroke, against this editor content, submit /task-auto-cancel?
|
|
34
|
+
* Pure so the decision is testable without a TUI.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isCancelSubmission(data: string, editorText: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Watch raw terminal input for a submitted /task-auto-cancel and call `onCancel`
|
|
39
|
+
* the moment it is typed, however deep in a run we are.
|
|
40
|
+
*
|
|
41
|
+
* Returns an unsubscribe function. A host without the raw input hook or the
|
|
42
|
+
* editor accessor (the shimmed remote ctx — which does not need this, since
|
|
43
|
+
* dispatchRemoteLine calls handlers directly) gets a no-op.
|
|
44
|
+
*
|
|
45
|
+
* Prefer the arm/rearm/disarm trio below over calling this directly: the
|
|
46
|
+
* listener does not survive a session replacement.
|
|
47
|
+
*/
|
|
48
|
+
export declare function installCancelListener(ctx: ExtensionCommandContext, onCancel: (live: ExtensionCommandContext) => void): () => void;
|
|
49
|
+
/** Begin listening for a typed /task-auto-cancel. Replaces any previous arm. */
|
|
50
|
+
export declare function armCancelListener(ctx: ExtensionCommandContext, onCancel: (live: ExtensionCommandContext) => void): void;
|
|
51
|
+
/**
|
|
52
|
+
* Re-point the armed listener at a replacement ctx. A no-op when nothing is
|
|
53
|
+
* armed, so the runner can call it unconditionally on every session swap
|
|
54
|
+
* (including for a plain /task, which has no cancel listener of its own).
|
|
55
|
+
*/
|
|
56
|
+
export declare function rearmCancelListener(ctx: ExtensionCommandContext): void;
|
|
57
|
+
/** Stop listening, so a cancel typed after the run goes back through the
|
|
58
|
+
* ordinary command path (which then reports there is no loop running). */
|
|
59
|
+
export declare function disarmCancelListener(): void;
|
|
60
|
+
/** Whether a listener is currently armed (tests). */
|
|
61
|
+
export declare function isCancelListenerArmed(): boolean;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** The command this listener delivers. Accepts a leading slash only — matching
|
|
2
|
+
* bare "task-auto-cancel" would fire on prose about the command. */
|
|
3
|
+
const CANCEL_RE = /^\/task-auto-cancel\s*$/;
|
|
4
|
+
/** Enter, in the encodings a terminal actually sends. A bare "\n" is what many
|
|
5
|
+
* terminals emit in raw mode; "\r" is the usual CR. */
|
|
6
|
+
function isSubmitKey(data) {
|
|
7
|
+
return data === '\r' || data === '\n' || data === '\r\n';
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Would this keystroke, against this editor content, submit /task-auto-cancel?
|
|
11
|
+
* Pure so the decision is testable without a TUI.
|
|
12
|
+
*/
|
|
13
|
+
export function isCancelSubmission(data, editorText) {
|
|
14
|
+
return isSubmitKey(data) && CANCEL_RE.test(editorText.trim());
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Watch raw terminal input for a submitted /task-auto-cancel and call `onCancel`
|
|
18
|
+
* the moment it is typed, however deep in a run we are.
|
|
19
|
+
*
|
|
20
|
+
* Returns an unsubscribe function. A host without the raw input hook or the
|
|
21
|
+
* editor accessor (the shimmed remote ctx — which does not need this, since
|
|
22
|
+
* dispatchRemoteLine calls handlers directly) gets a no-op.
|
|
23
|
+
*
|
|
24
|
+
* Prefer the arm/rearm/disarm trio below over calling this directly: the
|
|
25
|
+
* listener does not survive a session replacement.
|
|
26
|
+
*/
|
|
27
|
+
export function installCancelListener(ctx, onCancel) {
|
|
28
|
+
const ui = ctx.ui;
|
|
29
|
+
if (typeof ui.onTerminalInput !== 'function' || typeof ui.getEditorText !== 'function') {
|
|
30
|
+
return () => { };
|
|
31
|
+
}
|
|
32
|
+
const getText = ui.getEditorText.bind(ui);
|
|
33
|
+
const setText = typeof ui.setEditorText === 'function' ? ui.setEditorText.bind(ui) : undefined;
|
|
34
|
+
let unsubscribe = () => { };
|
|
35
|
+
try {
|
|
36
|
+
unsubscribe = ui.onTerminalInput(data => {
|
|
37
|
+
let text;
|
|
38
|
+
try {
|
|
39
|
+
text = getText();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// A torn-down editor (session replacement mid-run) must never
|
|
43
|
+
// take the run down with it — fall through to normal handling.
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
if (!isCancelSubmission(data, text))
|
|
47
|
+
return undefined;
|
|
48
|
+
// Clear what we swallowed, so the editor does not sit there holding a
|
|
49
|
+
// command the user believes they submitted.
|
|
50
|
+
try {
|
|
51
|
+
setText?.('');
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* cosmetic only */
|
|
55
|
+
}
|
|
56
|
+
// Hand back the ctx this listener is installed on: after a session
|
|
57
|
+
// replacement the original is stale and using it throws.
|
|
58
|
+
onCancel(ctx);
|
|
59
|
+
return { consume: true };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return () => { };
|
|
64
|
+
}
|
|
65
|
+
return () => {
|
|
66
|
+
try {
|
|
67
|
+
unsubscribe();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* best-effort */
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// ─── Armed listener (survives session replacement) ────────────────────────────
|
|
75
|
+
/**
|
|
76
|
+
* The listener does NOT survive `ctx.newSession()`. pi's InteractiveMode
|
|
77
|
+
* registers `setBeforeSessionInvalidate(() => this.resetExtensionUI())`, and
|
|
78
|
+
* `resetExtensionUI` calls `clearExtensionTerminalInputListeners()` — so every
|
|
79
|
+
* per-task session replacement silently drops it. That replacement happens at
|
|
80
|
+
* the START of each task, which is exactly the window this listener exists to
|
|
81
|
+
* cover, so the run must re-arm against the fresh ctx as soon as one appears
|
|
82
|
+
* (runSingleTask, where the new ctx is also handed to the remote bridge).
|
|
83
|
+
*
|
|
84
|
+
* Module-level rather than threaded through the deps: the re-arm point is deep
|
|
85
|
+
* inside the runner, which must not learn about /task-auto's cancel plumbing.
|
|
86
|
+
*/
|
|
87
|
+
let armed = null;
|
|
88
|
+
/** Begin listening for a typed /task-auto-cancel. Replaces any previous arm. */
|
|
89
|
+
export function armCancelListener(ctx, onCancel) {
|
|
90
|
+
disarmCancelListener();
|
|
91
|
+
armed = { dispose: installCancelListener(ctx, onCancel), onCancel };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Re-point the armed listener at a replacement ctx. A no-op when nothing is
|
|
95
|
+
* armed, so the runner can call it unconditionally on every session swap
|
|
96
|
+
* (including for a plain /task, which has no cancel listener of its own).
|
|
97
|
+
*/
|
|
98
|
+
export function rearmCancelListener(ctx) {
|
|
99
|
+
if (!armed)
|
|
100
|
+
return;
|
|
101
|
+
armed.dispose();
|
|
102
|
+
armed = { dispose: installCancelListener(ctx, armed.onCancel), onCancel: armed.onCancel };
|
|
103
|
+
}
|
|
104
|
+
/** Stop listening, so a cancel typed after the run goes back through the
|
|
105
|
+
* ordinary command path (which then reports there is no loop running). */
|
|
106
|
+
export function disarmCancelListener() {
|
|
107
|
+
armed?.dispose();
|
|
108
|
+
armed = null;
|
|
109
|
+
}
|
|
110
|
+
/** Whether a listener is currently armed (tests). */
|
|
111
|
+
export function isCancelListenerArmed() {
|
|
112
|
+
return armed !== null;
|
|
113
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /task-auto-cancel — the cancel request and the SAFE CHECKPOINTS that observe it.
|
|
3
|
+
*
|
|
4
|
+
* A cancel is a *request*, never a kill. It is honoured only where the on-disk
|
|
5
|
+
* state is durable and /task-auto-resume provably continues from it. Killing a
|
|
6
|
+
* child mid-phase would also stop the run (the abort signal already reaches
|
|
7
|
+
* every child's process group) but throws away that phase's work and can leave
|
|
8
|
+
* a half-written tree — so the flag is polled at boundaries instead.
|
|
9
|
+
*
|
|
10
|
+
* The checkpoint set, and why each one is safe:
|
|
11
|
+
*
|
|
12
|
+
* loop-top the previous task is checked off + committed and the next one
|
|
13
|
+
* has not started. The original (and until now the ONLY) point.
|
|
14
|
+
* pre-task after the pre-task checkpoint commit, before the runner is
|
|
15
|
+
* constructed: tree committed, no inner id stamped yet, so the
|
|
16
|
+
* entry is simply still unchecked and a resume restarts it.
|
|
17
|
+
* phase:<name> inside the runner's phase loop, after setTaskSection +
|
|
18
|
+
* postCommitPhase have persisted the phase output and
|
|
19
|
+
* front-matter `phase` names the NEXT phase. This is exactly
|
|
20
|
+
* the state /task-resume already resumes from (PHASE_INDEX
|
|
21
|
+
* skip-forward), so a cancel here costs nothing.
|
|
22
|
+
* pre-final-gate every task is checked off and committed; the whole-repo gate
|
|
23
|
+
* has not started. A resume re-enters the same branch.
|
|
24
|
+
*
|
|
25
|
+
* DELIBERATELY NOT checkpoints — stopping here is not safe:
|
|
26
|
+
* - mid implementation turn: uncommitted, half-applied edits. The user's ESC
|
|
27
|
+
* (declined steer) path already covers "stop now, I accept a partial tree".
|
|
28
|
+
* - between the implementation turn and the gates, or inside the gates: the
|
|
29
|
+
* work is written but unverified and uncommitted; the gates are what make it
|
|
30
|
+
* durable. Cancel is observed on the far side, at loop-top.
|
|
31
|
+
*/
|
|
32
|
+
/** Every place the cancel flag is polled. Kept as a closed union so the A/B
|
|
33
|
+
* harness and the tests enumerate the same set the loop does. */
|
|
34
|
+
export type CancelCheckpoint = 'loop-top' | 'pre-task' | 'pre-final-gate' | `phase:${string}`;
|
|
35
|
+
export declare function requestCancel(): void;
|
|
36
|
+
export declare function isCancelRequested(): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Clear the request so the next run does not inherit it. Called when a run
|
|
39
|
+
* starts and in its `finally`.
|
|
40
|
+
*
|
|
41
|
+
* The checkpoint trail is deliberately NOT cleared here: the run's `finally`
|
|
42
|
+
* calls this, and a harness or test that inspects where the run stopped has to
|
|
43
|
+
* read the trail AFTER the loop returns. Use `resetCheckpointTrail` at the start
|
|
44
|
+
* of a trial to get a clean slate.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resetCancel(): void;
|
|
47
|
+
/** Drop the recorded checkpoint trail (A/B + tests). */
|
|
48
|
+
export declare function resetCheckpointTrail(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Poll the cancel flag at a safe checkpoint. Records the visit either way, so a
|
|
51
|
+
* test can prove a checkpoint is reached at all (a checkpoint that never runs is
|
|
52
|
+
* indistinguishable from one that never fires).
|
|
53
|
+
*
|
|
54
|
+
* @returns true when the caller must stop here.
|
|
55
|
+
*/
|
|
56
|
+
export declare function cancelCheckpoint(where: CancelCheckpoint): boolean;
|
|
57
|
+
/** Checkpoints crossed since the last reset (A/B + tests). */
|
|
58
|
+
export declare function checkpointsCrossed(): readonly CancelCheckpoint[];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /task-auto-cancel — the cancel request and the SAFE CHECKPOINTS that observe it.
|
|
3
|
+
*
|
|
4
|
+
* A cancel is a *request*, never a kill. It is honoured only where the on-disk
|
|
5
|
+
* state is durable and /task-auto-resume provably continues from it. Killing a
|
|
6
|
+
* child mid-phase would also stop the run (the abort signal already reaches
|
|
7
|
+
* every child's process group) but throws away that phase's work and can leave
|
|
8
|
+
* a half-written tree — so the flag is polled at boundaries instead.
|
|
9
|
+
*
|
|
10
|
+
* The checkpoint set, and why each one is safe:
|
|
11
|
+
*
|
|
12
|
+
* loop-top the previous task is checked off + committed and the next one
|
|
13
|
+
* has not started. The original (and until now the ONLY) point.
|
|
14
|
+
* pre-task after the pre-task checkpoint commit, before the runner is
|
|
15
|
+
* constructed: tree committed, no inner id stamped yet, so the
|
|
16
|
+
* entry is simply still unchecked and a resume restarts it.
|
|
17
|
+
* phase:<name> inside the runner's phase loop, after setTaskSection +
|
|
18
|
+
* postCommitPhase have persisted the phase output and
|
|
19
|
+
* front-matter `phase` names the NEXT phase. This is exactly
|
|
20
|
+
* the state /task-resume already resumes from (PHASE_INDEX
|
|
21
|
+
* skip-forward), so a cancel here costs nothing.
|
|
22
|
+
* pre-final-gate every task is checked off and committed; the whole-repo gate
|
|
23
|
+
* has not started. A resume re-enters the same branch.
|
|
24
|
+
*
|
|
25
|
+
* DELIBERATELY NOT checkpoints — stopping here is not safe:
|
|
26
|
+
* - mid implementation turn: uncommitted, half-applied edits. The user's ESC
|
|
27
|
+
* (declined steer) path already covers "stop now, I accept a partial tree".
|
|
28
|
+
* - between the implementation turn and the gates, or inside the gates: the
|
|
29
|
+
* work is written but unverified and uncommitted; the gates are what make it
|
|
30
|
+
* durable. Cancel is observed on the far side, at loop-top.
|
|
31
|
+
*/
|
|
32
|
+
let requested = false;
|
|
33
|
+
/** Checkpoints actually reached since the last reset, in order. Instrumentation
|
|
34
|
+
* for the A/B harness and the unit tests — never read by the loop itself. */
|
|
35
|
+
const crossed = [];
|
|
36
|
+
export function requestCancel() {
|
|
37
|
+
requested = true;
|
|
38
|
+
}
|
|
39
|
+
export function isCancelRequested() {
|
|
40
|
+
return requested;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Clear the request so the next run does not inherit it. Called when a run
|
|
44
|
+
* starts and in its `finally`.
|
|
45
|
+
*
|
|
46
|
+
* The checkpoint trail is deliberately NOT cleared here: the run's `finally`
|
|
47
|
+
* calls this, and a harness or test that inspects where the run stopped has to
|
|
48
|
+
* read the trail AFTER the loop returns. Use `resetCheckpointTrail` at the start
|
|
49
|
+
* of a trial to get a clean slate.
|
|
50
|
+
*/
|
|
51
|
+
export function resetCancel() {
|
|
52
|
+
requested = false;
|
|
53
|
+
}
|
|
54
|
+
/** Drop the recorded checkpoint trail (A/B + tests). */
|
|
55
|
+
export function resetCheckpointTrail() {
|
|
56
|
+
crossed.length = 0;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Poll the cancel flag at a safe checkpoint. Records the visit either way, so a
|
|
60
|
+
* test can prove a checkpoint is reached at all (a checkpoint that never runs is
|
|
61
|
+
* indistinguishable from one that never fires).
|
|
62
|
+
*
|
|
63
|
+
* @returns true when the caller must stop here.
|
|
64
|
+
*/
|
|
65
|
+
export function cancelCheckpoint(where) {
|
|
66
|
+
crossed.push(where);
|
|
67
|
+
// A/B ONLY (scripts/cancel-latency-ab.ts): reproduce the pre-checkpoint
|
|
68
|
+
// behaviour — the flag was observed at the loop top and nowhere else — so the
|
|
69
|
+
// two arms differ in exactly the thing under test and share every other path.
|
|
70
|
+
if (process.env.CANCEL_AB_ARM === 'baseline' && where !== 'loop-top')
|
|
71
|
+
return false;
|
|
72
|
+
return requested;
|
|
73
|
+
}
|
|
74
|
+
/** Checkpoints crossed since the last reset (A/B + tests). */
|
|
75
|
+
export function checkpointsCrossed() {
|
|
76
|
+
return crossed;
|
|
77
|
+
}
|
|
@@ -34,6 +34,9 @@ import { runGatesForTask } from './task-gates.js';
|
|
|
34
34
|
import { parseVerifyBlock } from './spec-validation.js';
|
|
35
35
|
import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phantom-imports.js';
|
|
36
36
|
import { titleForDisplay } from './parsers.js';
|
|
37
|
+
import { USER_CANCELLED } from './child-runner.js';
|
|
38
|
+
import { cancelCheckpoint } from './cancel-points.js';
|
|
39
|
+
import { rearmCancelListener } from './cancel-input.js';
|
|
37
40
|
import { formatTimings } from './timings.js';
|
|
38
41
|
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
39
42
|
// ─── Module-level state ──────────────────────────────────────────────────────
|
|
@@ -237,6 +240,18 @@ export class TaskRunner {
|
|
|
237
240
|
await setTaskSection(cwd, id, phase.section, out);
|
|
238
241
|
this._pc[phase.field] = out;
|
|
239
242
|
await postCommitPhase(phase, this._deps, this._pc, out);
|
|
243
|
+
// SAFE CHECKPOINT (phase boundary): this phase's output is on disk
|
|
244
|
+
// and the next `advance()` has not moved front-matter forward, so a
|
|
245
|
+
// resume re-enters at exactly this phase. Without this the whole
|
|
246
|
+
// spec pipeline (refine→critique, ~4.5 min on the mx5 run, research
|
|
247
|
+
// alone ~3 min) runs to completion after a cancel is requested.
|
|
248
|
+
// Throwing USER_CANCELLED reuses the existing cancellation path:
|
|
249
|
+
// handleFailure leaves the task resumable and /task-auto's catch
|
|
250
|
+
// announces the resume hint.
|
|
251
|
+
if (cancelCheckpoint(`phase:${phase.name}`)) {
|
|
252
|
+
this._deps.logDebug?.(`cancel: stopping after phase ${phase.name}`);
|
|
253
|
+
throw new Error(USER_CANCELLED);
|
|
254
|
+
}
|
|
240
255
|
}
|
|
241
256
|
// All phases done — hand off the spec.
|
|
242
257
|
await advance('done');
|
|
@@ -594,6 +609,11 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
594
609
|
withSession: async (newCtx) => {
|
|
595
610
|
freshCtx = newCtx;
|
|
596
611
|
getBridge().currentCtx = newCtx; // keep remote dispatch ctx fresh across session replacement
|
|
612
|
+
// Same reason, for the terminal: pi clears every extension terminal
|
|
613
|
+
// input listener when the old session is invalidated, and that
|
|
614
|
+
// happens at the START of each task — precisely the window a typed
|
|
615
|
+
// /task-auto-cancel has to survive. No-op unless a run armed one.
|
|
616
|
+
rearmCancelListener(newCtx);
|
|
597
617
|
const runner = new TaskRunner(newCtx, cwd, rawPrompt, opts.resumeId, async (spec) => {
|
|
598
618
|
await newCtx.sendUserMessage(spec);
|
|
599
619
|
if (opts.waitForImplementation) {
|
|
@@ -3,11 +3,23 @@ const DEFAULT_BUDGET = 24_000;
|
|
|
3
3
|
const MIN_TOKEN_LEN = 2;
|
|
4
4
|
const FALLBACK_DTS_CHARS = 12_000;
|
|
5
5
|
const FALLBACK_README_CHARS = 4_000;
|
|
6
|
+
/**
|
|
7
|
+
* Split on any run of non-identifier characters — NOT on whitespace alone.
|
|
8
|
+
*
|
|
9
|
+
* Splitting on /\s+/ and then stripping punctuation INSIDE each token silently welds
|
|
10
|
+
* multi-part identifiers into one string that occurs nowhere in the corpus:
|
|
11
|
+
* "src/server/routes/auth.ts" -> "srcserverroutesauthts"
|
|
12
|
+
* "Bun.password.hash" -> "Bunpasswordhash"
|
|
13
|
+
* Because buildFtsQuery ORs the tokens, such a token contributes no MATCH at all, so the
|
|
14
|
+
* single most informative term in the query — the path, or the dotted API symbol — was
|
|
15
|
+
* dropped and ranking fell to the surrounding prose. Measured on the 141 real project
|
|
16
|
+
* queries of mx5 run 13, that cost 18% of them any chunk from the file they named
|
|
17
|
+
* (82% -> 99% retrieved once split on punctuation); on the 44 gradable npm queries,
|
|
18
|
+
* discriminative-symbol recall 89.5% -> 96.4%.
|
|
19
|
+
* See scripts/live-project-docs-retrieval-ab.ts and scripts/live-npm-tokenizer-regression.ts.
|
|
20
|
+
*/
|
|
6
21
|
function tokenize(query) {
|
|
7
|
-
return query
|
|
8
|
-
.split(/\s+/)
|
|
9
|
-
.map(t => t.replace(/[^a-zA-Z0-9_]/g, ''))
|
|
10
|
-
.filter(t => t.length >= MIN_TOKEN_LEN);
|
|
22
|
+
return query.split(/[^a-zA-Z0-9_]+/).filter(t => t.length >= MIN_TOKEN_LEN);
|
|
11
23
|
}
|
|
12
24
|
function buildFtsQuery(tokens) {
|
|
13
25
|
return tokens.map(t => `"${t}"`).join(' OR ');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.34",
|
|
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",
|