@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.
@@ -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
- cancelRequested = true;
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
- cancelRequested = false;
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 (cancelRequested) {
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
- cancelRequested = false;
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
- // Stamp a fresh per-run research-cache id (F10) BEFORE planning so enrichment and
1354
- // every task's research phase share one run's cache; disabled ⇒ clears any token a
1355
- // prior run left, so nothing is cached.
1356
- configureResearchRun(getConfig().researchCache);
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
- id = await planAuto(ctx, cwd, raw, deps);
1362
- }
1363
- catch (err) {
1364
- autoRunning = false;
1365
- const msg = err instanceof Error ? err.message : String(err);
1366
- if (msg === USER_CANCELLED) {
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
- announceDone(ctx, `/task-auto planning failed: ${msg}`, 'error');
1371
- return;
1413
+ await runAutoLoop(ctx, cwd, id, deps);
1372
1414
  }
1373
- if (!id) {
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
- announceDone(ctx, '/task-auto cancelled.', 'warning');
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
- // Reuse the interrupted run's research-cache id when the cache proves it still
1400
- // describes the same dependency surface (F10). mx5 run 13 resumed three times and
1401
- // each resume's fresh id discarded a working 201-entry cache; anything inconclusive
1402
- // still falls back to a fresh id and a re-fetch. See resumeResearchRun.
1403
- const research = await resumeResearchRun(cwd, getConfig().researchCache);
1404
- if (research.reused) {
1405
- logPlanDebug(cwd, `research cache: resume reused ${research.entries} entr(ies)`);
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('Stopping /task-auto after the current task…', 'warning');
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.32",
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",