@mjasnikovs/pi-task 0.39.4 → 0.39.5
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 +2 -2
- package/dist/task/auto-orchestrator.d.ts +36 -0
- package/dist/task/auto-orchestrator.js +43 -6
- package/dist/task/cancel-points.d.ts +34 -6
- package/dist/task/cancel-points.js +62 -10
- package/dist/task/child-status.js +13 -1
- package/dist/task/orchestrator.js +47 -5
- package/dist/task/plan-orchestrator.js +10 -1
- package/dist/task/research-worker.js +11 -0
- package/dist/task/run-bracket.js +13 -1
- package/dist/task/task-gates.js +34 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,10 +68,10 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
|
|
|
68
68
|
| `/task-plan <prompt>` | Plan one task with the model — it asks, you answer, ask it something back, or proceed — then run it through `/task`. |
|
|
69
69
|
| `/task-list` | Open the task list in an editor dialog. |
|
|
70
70
|
| `/task-resume [id]` | Resume the most recent (or named) unfinished task. |
|
|
71
|
-
| `/task-cancel` |
|
|
71
|
+
| `/task-cancel` | Stop the running task at the next safe checkpoint (still resumable). Mid-phase it kills the running child; during the implementation turn it lets the turn finish and stops before the gates. |
|
|
72
72
|
| `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
|
|
73
73
|
| `/task-auto-resume [--unattended]` | Resume the active `/task-auto` run at the next unfinished task. `--unattended` is the boot-hook form: in-flight runs only. |
|
|
74
|
-
| `/task-auto-cancel` | Stop the `/task-auto` loop
|
|
74
|
+
| `/task-auto-cancel` | Stop the `/task-auto` loop at the next safe checkpoint — the end of the current phase, research worker, implementation turn or gate, not the end of the task (still resumable). During planning it abandons the plan, which is not yet written. |
|
|
75
75
|
| `/task-config` | Toggle pi-task settings in an editor dialog: remote control, auto-commit, verify work, enforce guidelines, project tour, parallel research, research cache, search engine, command timeout, stuck reply retry, yolo mode, debug logs, one `watch:` toggle per live tool, and one `ext:` toggle per installed host extension. |
|
|
76
76
|
| `/remote` | Show the QR code & URLs for the web view (`/remote stop` to stop). Answer grill questions, start tasks, and watch progress from your phone. |
|
|
77
77
|
|
|
@@ -162,6 +162,42 @@ export interface CoveredPlan {
|
|
|
162
162
|
export declare function coverPlan(ctx: ExtensionCommandContext, cwd: string, deps: AutoDeps, oriented: OrientedFeature, clarifications: string, decomposed: DecomposedPlan, specDangling: DanglingRef[]): Promise<CoveredPlan>;
|
|
163
163
|
/** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
|
|
164
164
|
export declare function planAuto(ctx: ExtensionCommandContext, cwd: string, feature: string, deps: AutoDeps): Promise<string | null>;
|
|
165
|
+
/**
|
|
166
|
+
* One best-effort, HOST-GROUNDED extraction: ask a child to emit lines, drop
|
|
167
|
+
* every line the design does not literally contain, log the kept/emitted split,
|
|
168
|
+
* and append what survives to a run-level artifact.
|
|
169
|
+
*
|
|
170
|
+
* The grounding step is the reason this shape exists rather than a plain child
|
|
171
|
+
* call. A child asked for interface facts will paraphrase and occasionally invent
|
|
172
|
+
* them, and an invented fact in a run-level registry is read as
|
|
173
|
+
* authoritative by every downstream refine/compose/verify. So nothing the child
|
|
174
|
+
* says is trusted: `ground` re-checks each emitted line against the design text
|
|
175
|
+
* host-side, and only substrings survive.
|
|
176
|
+
*
|
|
177
|
+
* Best-effort by contract. These artifacts SHARPEN planning; none of them gates
|
|
178
|
+
* it, so a fault here is swallowed rather than failing a run that is otherwise
|
|
179
|
+
* fine — which is why the whole body sits in one `catch {}`.
|
|
180
|
+
*
|
|
181
|
+
* The two call sites (contracts, launch scripts) were byte-identical apart from
|
|
182
|
+
* the four values this row carries, and the contracts copy parsed its child's
|
|
183
|
+
* output twice — once for the artifact and once for the log count — because the
|
|
184
|
+
* duplication made the second parse easy to miss.
|
|
185
|
+
*/
|
|
186
|
+
export declare function runGroundedExtraction<T>(row: {
|
|
187
|
+
cwd: string;
|
|
188
|
+
runChild: (name: string, tools: string, prompt: string) => Promise<string>;
|
|
189
|
+
/** Child name — also the key AUTO_PLAN_STEPS renders in the loader. */
|
|
190
|
+
child: string;
|
|
191
|
+
/** Singular noun for the log line ("contract", "script"). */
|
|
192
|
+
noun: string;
|
|
193
|
+
/** Log prefix naming the step. */
|
|
194
|
+
label: string;
|
|
195
|
+
prompt: string;
|
|
196
|
+
parse: (raw: string) => T[];
|
|
197
|
+
/** Keep only what the design itself backs. Runs host-side, never the child. */
|
|
198
|
+
ground: (emitted: T[]) => T[];
|
|
199
|
+
append: (cwd: string, kept: T[]) => Promise<void>;
|
|
200
|
+
}): Promise<void>;
|
|
165
201
|
export declare function requestAutoCancel(): void;
|
|
166
202
|
export declare function runAutoLoop(ctx: ExtensionCommandContext, cwd: string, id: string, deps: AutoDeps): Promise<void>;
|
|
167
203
|
export declare function registerTaskAuto(pi: ExtensionAPI): void;
|
|
@@ -773,7 +773,8 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
|
|
|
773
773
|
try {
|
|
774
774
|
verdict = parseCoverageVerdict(await deps.runChild('decompose-coverage', '', DECOMPOSE_COVERAGE_PROMPT(featureForModel, clarifications, titles)));
|
|
775
775
|
}
|
|
776
|
-
catch {
|
|
776
|
+
catch (err) {
|
|
777
|
+
rethrowIfCancelled(err);
|
|
777
778
|
verdict = null;
|
|
778
779
|
}
|
|
779
780
|
const verdictMissing = verdict?.kind === 'incomplete' ? verdict.missing : [];
|
|
@@ -796,7 +797,8 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
|
|
|
796
797
|
+ `${acc.crossCutting.length} cross-cutting, ${acc.unmapped.length} unmapped; `
|
|
797
798
|
+ `${covered.size} requirement(s) title-grounded`);
|
|
798
799
|
}
|
|
799
|
-
catch {
|
|
800
|
+
catch (err) {
|
|
801
|
+
rethrowIfCancelled(err);
|
|
800
802
|
// mapping fault — Fix A accounting degrades; the grounded owned-set
|
|
801
803
|
// above still guards against drops.
|
|
802
804
|
}
|
|
@@ -1080,7 +1082,7 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
1080
1082
|
* output twice — once for the artifact and once for the log count — because the
|
|
1081
1083
|
* duplication made the second parse easy to miss.
|
|
1082
1084
|
*/
|
|
1083
|
-
async function runGroundedExtraction(row) {
|
|
1085
|
+
export async function runGroundedExtraction(row) {
|
|
1084
1086
|
try {
|
|
1085
1087
|
const emitted = row.parse(await row.runChild(row.child, '', row.prompt));
|
|
1086
1088
|
const grounded = row.ground(emitted);
|
|
@@ -1088,10 +1090,32 @@ async function runGroundedExtraction(row) {
|
|
|
1088
1090
|
+ ` from ${emitted.length} emitted`);
|
|
1089
1091
|
await row.append(row.cwd, grounded);
|
|
1090
1092
|
}
|
|
1091
|
-
catch {
|
|
1093
|
+
catch (err) {
|
|
1094
|
+
rethrowIfCancelled(err);
|
|
1092
1095
|
// best-effort artifact — never a planning blocker
|
|
1093
1096
|
}
|
|
1094
1097
|
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Let a cancel through a best-effort catch.
|
|
1100
|
+
*
|
|
1101
|
+
* Planning's degrade-quietly catches exist so one weak child cannot sink a plan,
|
|
1102
|
+
* and every one of them predates the `plan:` checkpoint. That checkpoint throws
|
|
1103
|
+
* USER_CANCELLED from inside `runPlanningChild`, so without this the throw reads
|
|
1104
|
+
* as "that extraction failed": planning carries on, writes the AUTO file, and
|
|
1105
|
+
* leaves a resumable run whose contracts silently lost the entries the cancelled
|
|
1106
|
+
* child would have grounded. Cancel means abandon the plan, so it is not a
|
|
1107
|
+
* degradable fault.
|
|
1108
|
+
*
|
|
1109
|
+
* The requirement-extraction catch already gets this right through
|
|
1110
|
+
* `isFatalChildCause`, which covers USER_CANCELLED as well as the fatal kills.
|
|
1111
|
+
* This is the narrow half of the same rule for the catches that guard only an
|
|
1112
|
+
* optional artifact, where promoting every fatal kill would be a behaviour change
|
|
1113
|
+
* this fix has no evidence for.
|
|
1114
|
+
*/
|
|
1115
|
+
function rethrowIfCancelled(err) {
|
|
1116
|
+
if (err instanceof Error && err.message === USER_CANCELLED)
|
|
1117
|
+
throw err;
|
|
1118
|
+
}
|
|
1095
1119
|
/** The two feature-level planning children, shown as steps in the loader. */
|
|
1096
1120
|
const AUTO_PLAN_STEPS = {
|
|
1097
1121
|
'auto-clarify': { step: 'clarify', stepNum: 1 },
|
|
@@ -1470,7 +1494,12 @@ async function handleTaskAuto(args, ctx) {
|
|
|
1470
1494
|
catch (err) {
|
|
1471
1495
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1472
1496
|
if (msg === USER_CANCELLED) {
|
|
1473
|
-
|
|
1497
|
+
// Say what was thrown away. Planning writes nothing until it
|
|
1498
|
+
// finishes, so there is no half-plan to resume and no
|
|
1499
|
+
// /task-auto-resume to offer — the user has to start over,
|
|
1500
|
+
// and a bare "cancelled" would leave them looking for one.
|
|
1501
|
+
announceDone(ctx, '/task-auto cancelled — the plan was discarded. Nothing was written; '
|
|
1502
|
+
+ 'run /task-auto again to re-plan.', 'warning');
|
|
1474
1503
|
return;
|
|
1475
1504
|
}
|
|
1476
1505
|
announceDone(ctx, `/task-auto planning failed: ${msg}`, 'error');
|
|
@@ -1559,8 +1588,16 @@ async function handleTaskAutoCancel(_args, ctx) {
|
|
|
1559
1588
|
* promise "after the current task": the request is now honoured at the next safe
|
|
1560
1589
|
* checkpoint (see cancel-points.ts), which mid-spec-pipeline is the end of the
|
|
1561
1590
|
* current phase, not the end of the task.
|
|
1591
|
+
*
|
|
1592
|
+
* It names the seam set rather than the one seam the run happens to be between,
|
|
1593
|
+
* because that seam is not knowable from here — the checkpoint trail says where
|
|
1594
|
+
* the run has BEEN. Naming the set is what stops "next safe checkpoint" reading
|
|
1595
|
+
* as "some time before the run ends", which is how a wait that is really one
|
|
1596
|
+
* research worker gets mistaken for a wait of the whole task.
|
|
1562
1597
|
*/
|
|
1563
|
-
const CANCEL_ACK = 'Stopping /task-auto at the next safe checkpoint
|
|
1598
|
+
const CANCEL_ACK = 'Stopping /task-auto at the next safe checkpoint — the next planning child, or the '
|
|
1599
|
+
+ 'end of the current phase, research worker, implementation turn or gate. The model '
|
|
1600
|
+
+ 'call already in flight finishes first.';
|
|
1564
1601
|
/**
|
|
1565
1602
|
* Deliver a /task-auto-cancel typed in the terminal while a run owns the main
|
|
1566
1603
|
* loop — the run bracket's `onCancel`. The armed listener watches raw stdin, so
|
|
@@ -27,18 +27,42 @@
|
|
|
27
27
|
* pre-final-gate (run-final-gate) every task is checked off and committed; the
|
|
28
28
|
* whole-repo gate has not started. A resume re-enters the same
|
|
29
29
|
* branch.
|
|
30
|
+
* plan:<child> (child-status, runPlanningChild — the one funnel BOTH
|
|
31
|
+
* /task-auto's planning and /task-plan go through) DURABLE BY
|
|
32
|
+
* DISCARD, and the one seam here that is not durable by writing.
|
|
33
|
+
* Nothing planning produces reaches disk until planAuto's final
|
|
34
|
+
* writeTaskFile, so there is no partial plan to resume and no
|
|
35
|
+
* half-state to repair — stopping abandons the whole plan. That
|
|
36
|
+
* is the point: the alternative measured at 13+ minutes of
|
|
37
|
+
* running after the user asked to stop.
|
|
38
|
+
* research:<w> (research-worker) after persistSection. The four workers run
|
|
39
|
+
* serially by default and each one's section is read back by
|
|
40
|
+
* readCached, so a resume skips every worker already on disk.
|
|
41
|
+
* Costs nothing and repeats nothing — the only seam here that is
|
|
42
|
+
* free in both directions.
|
|
43
|
+
* impl:post-turn (orchestrator) the implementation turn has ENDED and the spec
|
|
44
|
+
* sections are all on disk. The turn's edits are uncommitted, so
|
|
45
|
+
* a resume re-delivers the spec onto the partly-edited tree —
|
|
46
|
+
* identical to the shipped ESC-then-decline-steer ending.
|
|
47
|
+
* gate:post-commit (task-gates) the task is checked off and its snapshot is in
|
|
48
|
+
* HEAD. Only the enforce pass is skipped, and enforce is
|
|
49
|
+
* re-runnable.
|
|
50
|
+
* gate:pre-resolution (task-gates) at the top of the verify-resolution loop, before
|
|
51
|
+
* a round spends a lint fix, a research child or a whole
|
|
52
|
+
* implementation re-run. The task file is demoted there: it
|
|
53
|
+
* still reads `completed` from spec handoff, and the work is
|
|
54
|
+
* neither verified nor committed.
|
|
30
55
|
*
|
|
31
|
-
* DELIBERATELY NOT
|
|
32
|
-
* - mid implementation turn
|
|
56
|
+
* DELIBERATELY NOT a checkpoint — stopping here is not safe:
|
|
57
|
+
* - mid implementation turn. The turn is a host-session turn, not a child, so
|
|
58
|
+
* stopping it means abandoning a half-applied edit set with no commit behind
|
|
59
|
+
* it. `impl:post-turn` waits for the turn to end instead. The user's ESC
|
|
33
60
|
* (declined steer) path already covers "stop now, I accept a partial tree".
|
|
34
|
-
* - between the implementation turn and the gates, or inside the gates: the
|
|
35
|
-
* work is written but unverified and uncommitted; the gates are what make it
|
|
36
|
-
* durable. Cancel is observed on the far side, at loop-top.
|
|
37
61
|
*/
|
|
38
62
|
/** Every place the cancel flag is polled. A closed union so the tests enumerate
|
|
39
63
|
* the same set the loop does — and they do: cancel-points.test.ts asserts on the
|
|
40
64
|
* recorded trail rather than on the loop's own bookkeeping. */
|
|
41
|
-
export type CancelCheckpoint = 'loop-top' | 'pre-task' | 'pre-final-gate' | `phase:${string}`;
|
|
65
|
+
export type CancelCheckpoint = 'loop-top' | 'pre-task' | 'pre-final-gate' | 'impl:post-turn' | 'gate:post-commit' | 'gate:pre-resolution' | `phase:${string}` | `plan:${string}` | `research:${string}`;
|
|
42
66
|
export declare function requestCancel(): void;
|
|
43
67
|
export declare function isCancelRequested(): boolean;
|
|
44
68
|
/**
|
|
@@ -61,5 +85,9 @@ export declare function resetCheckpointTrail(): void;
|
|
|
61
85
|
* @returns true when the caller must stop here.
|
|
62
86
|
*/
|
|
63
87
|
export declare function cancelCheckpoint(where: CancelCheckpoint): boolean;
|
|
88
|
+
/** Let only the seams this answers true for fire. Tests only. */
|
|
89
|
+
export declare function onlyCheckpoints(predicate: (where: CancelCheckpoint) => boolean): void;
|
|
90
|
+
/** Back to production: every seam fires. */
|
|
91
|
+
export declare function clearCheckpointSuppression(): void;
|
|
64
92
|
/** Checkpoints crossed since the last reset. */
|
|
65
93
|
export declare function checkpointsCrossed(): readonly CancelCheckpoint[];
|
|
@@ -27,13 +27,37 @@
|
|
|
27
27
|
* pre-final-gate (run-final-gate) every task is checked off and committed; the
|
|
28
28
|
* whole-repo gate has not started. A resume re-enters the same
|
|
29
29
|
* branch.
|
|
30
|
+
* plan:<child> (child-status, runPlanningChild — the one funnel BOTH
|
|
31
|
+
* /task-auto's planning and /task-plan go through) DURABLE BY
|
|
32
|
+
* DISCARD, and the one seam here that is not durable by writing.
|
|
33
|
+
* Nothing planning produces reaches disk until planAuto's final
|
|
34
|
+
* writeTaskFile, so there is no partial plan to resume and no
|
|
35
|
+
* half-state to repair — stopping abandons the whole plan. That
|
|
36
|
+
* is the point: the alternative measured at 13+ minutes of
|
|
37
|
+
* running after the user asked to stop.
|
|
38
|
+
* research:<w> (research-worker) after persistSection. The four workers run
|
|
39
|
+
* serially by default and each one's section is read back by
|
|
40
|
+
* readCached, so a resume skips every worker already on disk.
|
|
41
|
+
* Costs nothing and repeats nothing — the only seam here that is
|
|
42
|
+
* free in both directions.
|
|
43
|
+
* impl:post-turn (orchestrator) the implementation turn has ENDED and the spec
|
|
44
|
+
* sections are all on disk. The turn's edits are uncommitted, so
|
|
45
|
+
* a resume re-delivers the spec onto the partly-edited tree —
|
|
46
|
+
* identical to the shipped ESC-then-decline-steer ending.
|
|
47
|
+
* gate:post-commit (task-gates) the task is checked off and its snapshot is in
|
|
48
|
+
* HEAD. Only the enforce pass is skipped, and enforce is
|
|
49
|
+
* re-runnable.
|
|
50
|
+
* gate:pre-resolution (task-gates) at the top of the verify-resolution loop, before
|
|
51
|
+
* a round spends a lint fix, a research child or a whole
|
|
52
|
+
* implementation re-run. The task file is demoted there: it
|
|
53
|
+
* still reads `completed` from spec handoff, and the work is
|
|
54
|
+
* neither verified nor committed.
|
|
30
55
|
*
|
|
31
|
-
* DELIBERATELY NOT
|
|
32
|
-
* - mid implementation turn
|
|
56
|
+
* DELIBERATELY NOT a checkpoint — stopping here is not safe:
|
|
57
|
+
* - mid implementation turn. The turn is a host-session turn, not a child, so
|
|
58
|
+
* stopping it means abandoning a half-applied edit set with no commit behind
|
|
59
|
+
* it. `impl:post-turn` waits for the turn to end instead. The user's ESC
|
|
33
60
|
* (declined steer) path already covers "stop now, I accept a partial tree".
|
|
34
|
-
* - between the implementation turn and the gates, or inside the gates: the
|
|
35
|
-
* work is written but unverified and uncommitted; the gates are what make it
|
|
36
|
-
* durable. Cancel is observed on the far side, at loop-top.
|
|
37
61
|
*/
|
|
38
62
|
let requested = false;
|
|
39
63
|
/** Checkpoints actually reached since the last reset, in order. Instrumentation
|
|
@@ -71,14 +95,42 @@ export function resetCheckpointTrail() {
|
|
|
71
95
|
*/
|
|
72
96
|
export function cancelCheckpoint(where) {
|
|
73
97
|
crossed.push(where);
|
|
74
|
-
|
|
75
|
-
// so the two arms differ in exactly one thing. Nothing in src/ ever sets it;
|
|
76
|
-
// the only writer in the tree is cancel-points.test.ts, which uses it to pin
|
|
77
|
-
// that the extra checkpoints — and only they — are what the flag gates.
|
|
78
|
-
if (process.env.CANCEL_AB_ARM === 'baseline' && where !== 'loop-top')
|
|
98
|
+
if (isSuppressed(where))
|
|
79
99
|
return false;
|
|
80
100
|
return requested;
|
|
81
101
|
}
|
|
102
|
+
// ─── Suppression (negative controls) ─────────────────────────────────────────
|
|
103
|
+
/**
|
|
104
|
+
* Which seams may fire. `null` is production: all of them.
|
|
105
|
+
*
|
|
106
|
+
* This is what makes the seam matrix falsifiable, and a predicate rather than a
|
|
107
|
+
* name list because three of the seams are open-ended (`phase:`, `plan:`,
|
|
108
|
+
* `research:`) and a list could not name them all.
|
|
109
|
+
*
|
|
110
|
+
* The matrix asks one seam at a time — `onlyCheckpoints(w => w === 'pre-task')`
|
|
111
|
+
* for the assertion, `onlyCheckpoints(() => false)` for its control — so the two
|
|
112
|
+
* runs differ by exactly that seam and nothing else. Without the control, "the
|
|
113
|
+
* run stopped here" also passes against a loop that stops everywhere, or one that
|
|
114
|
+
* stopped for an unrelated reason: several seams see the same raised flag, and
|
|
115
|
+
* whichever comes first is the one that stops the run.
|
|
116
|
+
*/
|
|
117
|
+
let allowed = null;
|
|
118
|
+
function isSuppressed(where) {
|
|
119
|
+
// CANCEL_AB_ARM=baseline collapses the set back to loop-top alone — the
|
|
120
|
+
// original two-arm control, kept because it is the coarse "does the extra
|
|
121
|
+
// checkpoint set do anything at all" question, which no per-seam control asks.
|
|
122
|
+
if (process.env.CANCEL_AB_ARM === 'baseline' && where !== 'loop-top')
|
|
123
|
+
return true;
|
|
124
|
+
return allowed !== null && !allowed(where);
|
|
125
|
+
}
|
|
126
|
+
/** Let only the seams this answers true for fire. Tests only. */
|
|
127
|
+
export function onlyCheckpoints(predicate) {
|
|
128
|
+
allowed = predicate;
|
|
129
|
+
}
|
|
130
|
+
/** Back to production: every seam fires. */
|
|
131
|
+
export function clearCheckpointSuppression() {
|
|
132
|
+
allowed = null;
|
|
133
|
+
}
|
|
82
134
|
/** Checkpoints crossed since the last reset. */
|
|
83
135
|
export function checkpointsCrossed() {
|
|
84
136
|
return crossed;
|
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
* gate-wide loader over a child that renders none (`frame: null`, reached when
|
|
20
20
|
* `deps.loader === false` in gate-child), so both must see the same object.
|
|
21
21
|
*/
|
|
22
|
-
import { runPhaseChild } from './child-runner.js';
|
|
22
|
+
import { runPhaseChild, USER_CANCELLED } from './child-runner.js';
|
|
23
23
|
import { resolveContextUsage } from './context-usage.js';
|
|
24
24
|
import { startAutoLoader } from './widget.js';
|
|
25
|
+
import { cancelCheckpoint } from './cancel-points.js';
|
|
25
26
|
export class ChildStatus {
|
|
26
27
|
_lastLine;
|
|
27
28
|
_contextUsage;
|
|
@@ -95,6 +96,17 @@ export class ChildStatus {
|
|
|
95
96
|
*/
|
|
96
97
|
export async function runPlanningChild(opts) {
|
|
97
98
|
const { ctx, status, phaseDeps, name, tools, prompt, loader } = opts;
|
|
99
|
+
// SAFE CHECKPOINT (planning): the one funnel every planning child goes through
|
|
100
|
+
// — orient, clarify, decompose, each coverage round, each grounded extraction,
|
|
101
|
+
// and /task-plan's children too. Safe by DISCARD rather than by writing:
|
|
102
|
+
// planning puts nothing on disk before planAuto's closing writeTaskFile, so
|
|
103
|
+
// there is no partial plan to resume and none to repair. Without it the flag
|
|
104
|
+
// was read only after planning RETURNED — measured at 13+ minutes of planning
|
|
105
|
+
// still running after the user asked to stop.
|
|
106
|
+
if (cancelCheckpoint(`plan:${name}`)) {
|
|
107
|
+
phaseDeps.logDebug?.(`cancel: abandoning the plan before ${name}`);
|
|
108
|
+
throw new Error(USER_CANCELLED);
|
|
109
|
+
}
|
|
98
110
|
const startedAt = Date.now();
|
|
99
111
|
return status.track(ctx, () => ({
|
|
100
112
|
...(loader.command === undefined ? {} : { command: loader.command }),
|
|
@@ -37,10 +37,10 @@ import { parseVerifyBlock } from './spec-validation.js';
|
|
|
37
37
|
import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phantom-imports.js';
|
|
38
38
|
import { titleForDisplay } from './parsers.js';
|
|
39
39
|
import { USER_CANCELLED } from './child-runner.js';
|
|
40
|
-
import { cancelCheckpoint } from './cancel-points.js';
|
|
40
|
+
import { cancelCheckpoint, requestCancel } from './cancel-points.js';
|
|
41
41
|
import { holdImplementation, liveModelControl } from './implementation-hold.js';
|
|
42
42
|
import { rearmCancelListener } from './cancel-input.js';
|
|
43
|
-
import { takeHeldInput } from './mid-run-input.js';
|
|
43
|
+
import { takeHeldInput, isRunActive } from './mid-run-input.js';
|
|
44
44
|
import { withRun, announceTerminal } from './run-bracket.js';
|
|
45
45
|
import { RUN_END_POLICY, runSucceeded } from './run-end.js';
|
|
46
46
|
import { formatTimings } from './timings.js';
|
|
@@ -334,6 +334,31 @@ export class TaskRunner {
|
|
|
334
334
|
await setTaskSection(cwd, id, 'phase timings', formatTimings(this._timings));
|
|
335
335
|
await setTaskSection(cwd, id, 'handoff', `handoff_at: ${new Date().toISOString()}`);
|
|
336
336
|
await this._deliverSpec(ctx);
|
|
337
|
+
// SAFE CHECKPOINT (post implementation turn): every phase section is
|
|
338
|
+
// on disk and the turn has ENDED. Its edits are uncommitted, so a
|
|
339
|
+
// resume re-delivers the spec onto the partly-edited tree — the same
|
|
340
|
+
// ending the ESC-then-decline-steer path already produces. Front
|
|
341
|
+
// matter reads `phase: done`, and PHASE_INDEX.done is past every row,
|
|
342
|
+
// so the resumed run restores all five sections and falls straight
|
|
343
|
+
// through to _deliverSpec.
|
|
344
|
+
//
|
|
345
|
+
// This is also what makes /task-cancel work here at all: the turn
|
|
346
|
+
// runs in the host session, not as a child, so aborting this runner's
|
|
347
|
+
// signal never reached it. The command raises the cooperative flag
|
|
348
|
+
// and this is where the flag is read.
|
|
349
|
+
//
|
|
350
|
+
// AWAITED ONLY. On the fire-and-forget /task path _deliverSpec returns
|
|
351
|
+
// as soon as the spec is sent, so the turn is STARTING, not finished,
|
|
352
|
+
// and firing here would write `cancelled` over a task the agent then
|
|
353
|
+
// goes on to implement in full — reported and recorded as stopped
|
|
354
|
+
// while it runs, and left in a resumable state that re-delivers the
|
|
355
|
+
// same spec on top of the finished work. Nothing is lost by staying
|
|
356
|
+
// out: that path has no gates and no loop to stop, the run just ends,
|
|
357
|
+
// and ESC is what interrupts the turn itself.
|
|
358
|
+
if (this._implAwaited && cancelCheckpoint('impl:post-turn')) {
|
|
359
|
+
this._deps.logDebug?.('cancel: stopping after the implementation turn');
|
|
360
|
+
throw new Error(USER_CANCELLED);
|
|
361
|
+
}
|
|
337
362
|
return { kind: 'completed' };
|
|
338
363
|
}
|
|
339
364
|
catch (err) {
|
|
@@ -795,12 +820,29 @@ async function handleTaskResume(args, ctx) {
|
|
|
795
820
|
}
|
|
796
821
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
797
822
|
async function handleTaskCancel(_args, ctx) {
|
|
798
|
-
|
|
823
|
+
// `activeTask` covers the spec phases and the implementation turn — it is set
|
|
824
|
+
// in TaskRunner._run and cleared in that run's `finally`. The GATES run after
|
|
825
|
+
// that, so for the whole verify/autofix/enforce stretch there is no runner to
|
|
826
|
+
// abort and the honest answer is not "No task is running."
|
|
827
|
+
const runner = activeTask;
|
|
828
|
+
if (!runner && !isRunActive()) {
|
|
799
829
|
notifyBoth(ctx, 'No task is running.', 'info');
|
|
800
830
|
return;
|
|
801
831
|
}
|
|
802
|
-
|
|
803
|
-
|
|
832
|
+
// The cooperative flag is what gives the command one meaning wherever it is
|
|
833
|
+
// typed: stop at the next point the run can be resumed from. Without it a
|
|
834
|
+
// cancel during the gates, or during the implementation turn, had nothing to
|
|
835
|
+
// observe it. The run bracket scopes the flag to the run that raised it.
|
|
836
|
+
requestCancel();
|
|
837
|
+
if (!runner) {
|
|
838
|
+
notifyBoth(ctx, 'Stopping at the next safe checkpoint…', 'warning');
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
// Abort as well: during the spec phases this kills the running child outright
|
|
842
|
+
// instead of waiting out its remaining minutes, and the phase already on disk
|
|
843
|
+
// is what a resume starts from.
|
|
844
|
+
runner.cancel();
|
|
845
|
+
notifyBoth(ctx, `Cancelling ${runner.taskId}…`, 'warning');
|
|
804
846
|
}
|
|
805
847
|
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
806
848
|
export function registerTask(pi) {
|
|
@@ -277,8 +277,17 @@ async function runPlanCommand(ctx, cwd, planId, task, commandDeps) {
|
|
|
277
277
|
}
|
|
278
278
|
catch (err) {
|
|
279
279
|
const msg = err instanceof Error ? err.message : String(err);
|
|
280
|
+
// A user stop is not a fault, and the sentinel is not a sentence. The
|
|
281
|
+
// cancel checkpoint in runPlanningChild throws USER_CANCELLED here, so
|
|
282
|
+
// without this branch a /task-cancel during planning reads as a red
|
|
283
|
+
// "PLAN_0001 stopped — __user_cancelled__".
|
|
284
|
+
if (msg === USER_CANCELLED) {
|
|
285
|
+
await updateTaskFrontMatter(cwd, planId, { state: 'cancelled' }).catch(() => { });
|
|
286
|
+
announceTerminal(ctx, `${planId} cancelled.`, 'warning', { push: false });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
280
289
|
await updateTaskFrontMatter(cwd, planId, {
|
|
281
|
-
state:
|
|
290
|
+
state: 'failed',
|
|
282
291
|
reason: msg.slice(0, 200)
|
|
283
292
|
}).catch(() => { });
|
|
284
293
|
// No push: a plan is a conversation, not a task; the run it hands off
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { classifyWorkerFailure } from '../workers/worker-failure.js';
|
|
22
22
|
import { classifyContextSilence, countBullets } from './context-silence.js';
|
|
23
|
+
import { cancelCheckpoint } from './cancel-points.js';
|
|
24
|
+
import { USER_CANCELLED } from './child-runner.js';
|
|
23
25
|
/**
|
|
24
26
|
* Task-file heading under which a research worker's validated output is cached.
|
|
25
27
|
* A resumed research phase reads these to skip workers that already succeeded,
|
|
@@ -403,5 +405,14 @@ export async function runResearchWorker(spec, run, prior = []) {
|
|
|
403
405
|
// a truncated section can still carry a laundered claim.
|
|
404
406
|
const sectionText = spec.postProcess ? spec.postProcess(rawText) : rawText;
|
|
405
407
|
await run.persistSection(cacheHeading, sectionText);
|
|
408
|
+
// SAFE CHECKPOINT: this worker's section is on disk and the cache read at the
|
|
409
|
+
// top of this function is what a resume uses to skip it, so stopping between
|
|
410
|
+
// workers repeats nothing. Research is the phase's long pole and the workers
|
|
411
|
+
// run serially, which is what makes this the one seam that costs nothing in
|
|
412
|
+
// either direction. USER_CANCELLED reuses the phase's existing cancel path.
|
|
413
|
+
if (cancelCheckpoint(`research:${spec.section}`)) {
|
|
414
|
+
run.logDebug?.(`cancel: stopping after research worker ${spec.section}`);
|
|
415
|
+
throw new Error(USER_CANCELLED);
|
|
416
|
+
}
|
|
406
417
|
return { name: spec.section, text: sectionText };
|
|
407
418
|
}
|
package/dist/task/run-bracket.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { armCancelListener, disarmCancelListener } from './cancel-input.js';
|
|
2
|
-
import { beginRun, endRun } from './mid-run-input.js';
|
|
2
|
+
import { beginRun, endRun, isRunActive } from './mid-run-input.js';
|
|
3
3
|
import { reportDroppedInput } from './dropped-input.js';
|
|
4
4
|
import { publishLifecycleNotice } from '../remote/bridge.js';
|
|
5
5
|
import { pushNotify } from '../remote/push.js';
|
|
6
|
+
import { resetCancel } from './cancel-points.js';
|
|
6
7
|
/**
|
|
7
8
|
* Run `fn` as the owner of the session: hold mid-run input and arm the terminal
|
|
8
9
|
* interception for exactly its duration, then release both — on return AND on
|
|
@@ -10,6 +11,15 @@ import { pushNotify } from '../remote/push.js';
|
|
|
10
11
|
* Nests: an inner bracket neither re-renders the surfaces nor un-arms the outer.
|
|
11
12
|
*/
|
|
12
13
|
export async function withRun(ctx, opts, fn) {
|
|
14
|
+
// A cancel is scoped to the run that was asked to stop. runAutoLoop already
|
|
15
|
+
// cleared the flag at both ends of its own loop; the bracket is what gives
|
|
16
|
+
// every OTHER entry point the same guarantee, so a /task-cancel raised on a
|
|
17
|
+
// bare /task cannot survive to stop the next run at its first phase.
|
|
18
|
+
// Outermost only: an inner bracket clearing it would drop the request the
|
|
19
|
+
// outer loop has not observed yet.
|
|
20
|
+
const outermost = !isRunActive();
|
|
21
|
+
if (outermost)
|
|
22
|
+
resetCancel();
|
|
13
23
|
beginRun();
|
|
14
24
|
armCancelListener(ctx, opts.onCancel);
|
|
15
25
|
try {
|
|
@@ -17,6 +27,8 @@ export async function withRun(ctx, opts, fn) {
|
|
|
17
27
|
}
|
|
18
28
|
finally {
|
|
19
29
|
disarmCancelListener();
|
|
30
|
+
if (outermost)
|
|
31
|
+
resetCancel();
|
|
20
32
|
reportDroppedInput(endRun(), ctx);
|
|
21
33
|
}
|
|
22
34
|
}
|
package/dist/task/task-gates.js
CHANGED
|
@@ -10,6 +10,8 @@ import { attributeEnforceFailure } from './enforce-attribution.js';
|
|
|
10
10
|
// re-check-side parser (extractDeletedDebtPath) have to move together.
|
|
11
11
|
import { crossTaskDeletionReason } from './accept-debt.js';
|
|
12
12
|
import { clampOutput } from './clamp-output.js';
|
|
13
|
+
import { cancelCheckpoint } from './cancel-points.js';
|
|
14
|
+
import { updateTaskFrontMatter } from './task-io.js';
|
|
13
15
|
/**
|
|
14
16
|
* How many times a verify FAIL may be auto-fixed UNATTENDED (the research
|
|
15
17
|
* recommended AUTOFIX, so pi re-runs the impl turn without prompting) before the
|
|
@@ -114,6 +116,24 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
|
|
|
114
116
|
// YOLO only: has the one-attempt rescue below already been spent on this task?
|
|
115
117
|
let yoloRescueUsed = false;
|
|
116
118
|
while (!verified.ok) {
|
|
119
|
+
// SAFE CHECKPOINT (before a resolution round): a round is a bounded
|
|
120
|
+
// lint fix, a research child and possibly a whole implementation
|
|
121
|
+
// re-run, so a cancel observed only INSIDE one buys all of that
|
|
122
|
+
// first. At the TOP of the loop because every later position is past
|
|
123
|
+
// something that already recorded itself — the unattended branch
|
|
124
|
+
// increments the counter, writes its `## gates` line and toasts
|
|
125
|
+
// "auto-fixing…" before it reaches any code below.
|
|
126
|
+
//
|
|
127
|
+
// The task file still reads `completed` here, written at spec handoff
|
|
128
|
+
// before any of this ran, and `completed` is not in RESUMABLE_STATES.
|
|
129
|
+
// Returning `cancelled` without this write would leave the announced
|
|
130
|
+
// "resume with /task-resume" pointing at a file /task-resume skips —
|
|
131
|
+
// the work is unverified AND uncommitted at this point, so that is the
|
|
132
|
+
// one place in the gate where a missed demotion loses it.
|
|
133
|
+
if (cancelCheckpoint('gate:pre-resolution')) {
|
|
134
|
+
await updateTaskFrontMatter(p.cwd, p.taskId, { state: 'cancelled' }).catch(() => { });
|
|
135
|
+
return { stop: { kind: 'cancelled', ctx: active } };
|
|
136
|
+
}
|
|
117
137
|
const failReason = verified.reason ?? 'did not verify';
|
|
118
138
|
// GRADUATED resolution: a repo-health FAIL (pure static findings) gets ONE
|
|
119
139
|
// bounded fix attempt before the picker — smallest tool first. Applied →
|
|
@@ -671,6 +691,20 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
671
691
|
else
|
|
672
692
|
notifyBoth(active, line, 'warning');
|
|
673
693
|
}
|
|
694
|
+
// SAFE CHECKPOINT (post task commit): the work verified, the parent entry is
|
|
695
|
+
// checked off and the snapshot is in HEAD. Only the enforce pass is skipped,
|
|
696
|
+
// and enforce is re-runnable.
|
|
697
|
+
//
|
|
698
|
+
// Returns `done`, not `cancelled`: this task IS done, and `cancelled` would
|
|
699
|
+
// announce "resume with /task-resume" over a task whose work is already
|
|
700
|
+
// committed — a resume there re-runs the whole spec pipeline to redo it. The
|
|
701
|
+
// flag stays raised, so /task-auto stops one step later at loop-top with the
|
|
702
|
+
// right wording and a ticked checkbox, and a bare /task simply ends. The trail
|
|
703
|
+
// line is what keeps the skip from being silent.
|
|
704
|
+
if (cancelCheckpoint('gate:post-commit')) {
|
|
705
|
+
await rec('enforce: skipped — cancel requested after the task snapshot committed');
|
|
706
|
+
return { kind: 'done', ctx: active };
|
|
707
|
+
}
|
|
674
708
|
await runEnforcePass(active, deps, p, rec, routeRootCause, { cleanPass, commit });
|
|
675
709
|
return { kind: 'done', ctx: active };
|
|
676
710
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.39.
|
|
3
|
+
"version": "0.39.5",
|
|
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",
|