@mjasnikovs/pi-task 0.18.41 → 0.18.43
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 -1
- package/dist/shared/child-process.js +4 -2
- package/dist/shared/stream-watchdog.d.ts +27 -8
- package/dist/shared/stream-watchdog.js +43 -13
- package/dist/task/auto-io.d.ts +16 -1
- package/dist/task/auto-io.js +21 -6
- package/dist/task/auto-orchestrator.js +25 -8
- package/dist/task/final-gate-progress.js +18 -1
- package/dist/task/resume-gap.d.ts +52 -0
- package/dist/task/resume-gap.js +67 -0
- package/dist/task/stream-watchdog.js +10 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
|
|
|
63
63
|
| `/task-resume [id]` | Resume the most recent (or named) unfinished task. |
|
|
64
64
|
| `/task-cancel` | Cancel the running task (soft-terminal — still resumable). |
|
|
65
65
|
| `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
|
|
66
|
-
| `/task-auto-resume` | Resume the active `/task-auto` run at the next unfinished task. |
|
|
66
|
+
| `/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. |
|
|
67
67
|
| `/task-auto-cancel` | Stop the `/task-auto` loop after the current task (still resumable). |
|
|
68
68
|
| `/task-config` | Toggle pi-task settings in an editor dialog: remote server, compress reasoning, auto-commit, orientation, verify work, enforce guidelines, command timeout, stream watchdog, and the extension whitelist for child sessions. |
|
|
69
69
|
| `/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. |
|
|
@@ -100,6 +100,7 @@ A real feature is usually several tasks, not one. `/task-auto` is a thin planner
|
|
|
100
100
|
- **Clarify first.** It asks the few clarifying questions whose answers change how the feature splits, then decomposes the answers into an ordered list of task titles written to `.pi-tasks/TASK_AUTO_NNNN.md`.
|
|
101
101
|
- **Sequential, blocking.** Each title runs through `/task` to a spec, the spec is implemented, and the loop waits for that to finish before starting the next title. No overlap.
|
|
102
102
|
- **Crash- and cancel-safe.** Progress is the markdown checkboxes in the AUTO file. `/task-auto-resume` (no id) automatically picks up the active run at the first unchecked title. If a title's `/task` run fails, the loop stops and leaves the run resumable.
|
|
103
|
+
- **Restart-safe, unattended.** `/task-auto-resume --unattended` is the same resume with no human in the loop — for a boot hook or a container entrypoint. It continues **in-flight** runs only: a `failed` or `cancelled` run stopped for a reason a power cycle does not clear, so it is reported and left alone rather than re-entered against the same wall. Either way the resume banner states exactly what it measured — how long since the run last wrote, and that nothing was rolled back — and attributes no cause, because a stopped host, a hung child, and a slow task look identical from here. Pair it with `restart: unless-stopped` on long-running containers and an overnight outage costs minutes instead of the whole night.
|
|
103
104
|
- **One commit per task.** When **auto-commit** is on (the default) and you're in a git repo, the working tree is snapshotted into a single commit after each title passes, so the run produces a clean per-task history. It's best-effort: outside a repo, with nothing to commit, or on any git error, the loop reports the reason and keeps going. Toggle it in `/task-config`.
|
|
104
105
|
|
|
105
106
|
## Remote — drive a task from your phone
|
|
@@ -276,11 +276,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
276
276
|
{
|
|
277
277
|
...opts,
|
|
278
278
|
onToolCall: call => {
|
|
279
|
-
|
|
279
|
+
// Keyed: a child's tool batch runs in parallel too, so the
|
|
280
|
+
// first result must not un-pause a still-running sibling.
|
|
281
|
+
streamWatch.suspend(call.toolCallId);
|
|
280
282
|
return opts.onToolCall ? opts.onToolCall(call) : null;
|
|
281
283
|
},
|
|
282
284
|
onToolResult: r => {
|
|
283
|
-
streamWatch.resume();
|
|
285
|
+
streamWatch.resume(r.toolCallId);
|
|
284
286
|
opts.onToolResult?.(r);
|
|
285
287
|
}
|
|
286
288
|
}
|
|
@@ -64,20 +64,39 @@ export declare class StreamWatchdog {
|
|
|
64
64
|
private timer;
|
|
65
65
|
private lastEvent;
|
|
66
66
|
private armedMs;
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
|
|
67
|
+
/**
|
|
68
|
+
* The tool calls currently executing. While ANY is running the model stream is
|
|
69
|
+
* legitimately idle, and that window belongs to the command watchdog, not to
|
|
70
|
+
* this one — without it a 10-minute build looks identical to a hung stream.
|
|
71
|
+
*
|
|
72
|
+
* A SET, not a boolean: pi runs a tool batch in parallel (agent-loop.js
|
|
73
|
+
* `executeToolCallsParallel` emits every tool_execution_start up front, then one
|
|
74
|
+
* end per call as each settles, and answers an immediate call inline while an
|
|
75
|
+
* earlier one is still running). A boolean would be cleared by the FIRST end and
|
|
76
|
+
* leave the still-running sibling — the long build — exposed to a false fire.
|
|
77
|
+
* Same per-toolCallId idiom the command watchdog uses.
|
|
78
|
+
*/
|
|
79
|
+
private readonly active;
|
|
80
|
+
/** Nesting depth for callers that cannot supply an id, counted so an unkeyed
|
|
81
|
+
* pair nests the same way a keyed one does. */
|
|
82
|
+
private keyless;
|
|
71
83
|
private fired;
|
|
84
|
+
private get suspended();
|
|
72
85
|
constructor(deps: StreamWatchdogDeps);
|
|
73
86
|
/** Begin watching a model request. No-op when the watchdog is off or already armed. */
|
|
74
87
|
start(): void;
|
|
75
88
|
/** Any stream event of any kind: resets the idle clock. */
|
|
76
89
|
note(): void;
|
|
77
|
-
/** A tool started executing — pause the idle clock until it
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
/** A tool started executing — pause the idle clock until it (and every sibling
|
|
91
|
+
* still running) ends. `key` is the tool call id where the caller has one. */
|
|
92
|
+
suspend(key?: string): void;
|
|
93
|
+
/**
|
|
94
|
+
* A tool finished. The clock only restarts once the LAST one does — a fast tool
|
|
95
|
+
* settling first says nothing about a sibling that is still running. Unmatched
|
|
96
|
+
* ends (a watchdog armed mid-batch never saw the start) are ignored rather than
|
|
97
|
+
* clearing the whole set.
|
|
98
|
+
*/
|
|
99
|
+
resume(key?: string): void;
|
|
81
100
|
/** Stop watching (turn/agent/session end, or child exit). Safe to call twice. */
|
|
82
101
|
stop(): void;
|
|
83
102
|
/** @internal Exposed for the poll callback and tests. */
|
|
@@ -59,11 +59,26 @@ export class StreamWatchdog {
|
|
|
59
59
|
timer;
|
|
60
60
|
lastEvent = 0;
|
|
61
61
|
armedMs = 0;
|
|
62
|
-
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The tool calls currently executing. While ANY is running the model stream is
|
|
64
|
+
* legitimately idle, and that window belongs to the command watchdog, not to
|
|
65
|
+
* this one — without it a 10-minute build looks identical to a hung stream.
|
|
66
|
+
*
|
|
67
|
+
* A SET, not a boolean: pi runs a tool batch in parallel (agent-loop.js
|
|
68
|
+
* `executeToolCallsParallel` emits every tool_execution_start up front, then one
|
|
69
|
+
* end per call as each settles, and answers an immediate call inline while an
|
|
70
|
+
* earlier one is still running). A boolean would be cleared by the FIRST end and
|
|
71
|
+
* leave the still-running sibling — the long build — exposed to a false fire.
|
|
72
|
+
* Same per-toolCallId idiom the command watchdog uses.
|
|
73
|
+
*/
|
|
74
|
+
active = new Set();
|
|
75
|
+
/** Nesting depth for callers that cannot supply an id, counted so an unkeyed
|
|
76
|
+
* pair nests the same way a keyed one does. */
|
|
77
|
+
keyless = 0;
|
|
66
78
|
fired = false;
|
|
79
|
+
get suspended() {
|
|
80
|
+
return this.active.size > 0 || this.keyless > 0;
|
|
81
|
+
}
|
|
67
82
|
constructor(deps) {
|
|
68
83
|
this.deps = deps;
|
|
69
84
|
}
|
|
@@ -80,7 +95,8 @@ export class StreamWatchdog {
|
|
|
80
95
|
return;
|
|
81
96
|
this.armedMs = ms;
|
|
82
97
|
this.fired = false;
|
|
83
|
-
this.
|
|
98
|
+
this.active.clear();
|
|
99
|
+
this.keyless = 0;
|
|
84
100
|
this.lastEvent = this.deps.now();
|
|
85
101
|
this.timer = this.deps.schedule(() => this.check(), pollIntervalMs(ms));
|
|
86
102
|
}
|
|
@@ -88,21 +104,35 @@ export class StreamWatchdog {
|
|
|
88
104
|
note() {
|
|
89
105
|
this.lastEvent = this.deps.now();
|
|
90
106
|
}
|
|
91
|
-
/** A tool started executing — pause the idle clock until it
|
|
92
|
-
|
|
93
|
-
|
|
107
|
+
/** A tool started executing — pause the idle clock until it (and every sibling
|
|
108
|
+
* still running) ends. `key` is the tool call id where the caller has one. */
|
|
109
|
+
suspend(key) {
|
|
110
|
+
if (key === undefined)
|
|
111
|
+
this.keyless++;
|
|
112
|
+
else
|
|
113
|
+
this.active.add(key);
|
|
94
114
|
}
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
/**
|
|
116
|
+
* A tool finished. The clock only restarts once the LAST one does — a fast tool
|
|
117
|
+
* settling first says nothing about a sibling that is still running. Unmatched
|
|
118
|
+
* ends (a watchdog armed mid-batch never saw the start) are ignored rather than
|
|
119
|
+
* clearing the whole set.
|
|
120
|
+
*/
|
|
121
|
+
resume(key) {
|
|
122
|
+
if (key === undefined)
|
|
123
|
+
this.keyless = Math.max(0, this.keyless - 1);
|
|
124
|
+
else
|
|
125
|
+
this.active.delete(key);
|
|
126
|
+
if (!this.suspended)
|
|
127
|
+
this.note();
|
|
99
128
|
}
|
|
100
129
|
/** Stop watching (turn/agent/session end, or child exit). Safe to call twice. */
|
|
101
130
|
stop() {
|
|
102
131
|
if (this.timer !== undefined)
|
|
103
132
|
this.deps.cancel(this.timer);
|
|
104
133
|
this.timer = undefined;
|
|
105
|
-
this.
|
|
134
|
+
this.active.clear();
|
|
135
|
+
this.keyless = 0;
|
|
106
136
|
}
|
|
107
137
|
/** @internal Exposed for the poll callback and tests. */
|
|
108
138
|
check() {
|
package/dist/task/auto-io.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TaskState } from './task-types.js';
|
|
2
|
+
import type { AutoResumeCandidate } from './resume-gap.js';
|
|
1
3
|
export interface TaskEntry {
|
|
2
4
|
index: number;
|
|
3
5
|
title: string;
|
|
@@ -58,5 +60,18 @@ export declare function stampTaskInProgress(cwd: string, id: string, index: numb
|
|
|
58
60
|
* next step by "first unchecked" rather than by a cached index.
|
|
59
61
|
*/
|
|
60
62
|
export declare function insertTaskAfter(cwd: string, id: string, afterIndex: number, title: string): Promise<boolean>;
|
|
61
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
|
|
65
|
+
* last-write time the resume banner reports (see resume-gap.ts). Null when there
|
|
66
|
+
* is nothing resumable.
|
|
67
|
+
*
|
|
68
|
+
* `states` narrows which states count as resumable, and the UNATTENDED path passes
|
|
69
|
+
* UNATTENDED_STATES so the search answers the question that path actually asks —
|
|
70
|
+
* "is there an IN-FLIGHT run to pick up?". Selecting the newest human-resumable run
|
|
71
|
+
* and only then refusing it on state let one failed run shadow an in-flight one
|
|
72
|
+
* behind it: the boot hook refused every restart and the in-flight run stayed in
|
|
73
|
+
* exactly the dead air this feature exists to end.
|
|
74
|
+
*/
|
|
75
|
+
export declare function findResumableAutoDetailed(cwd: string, states?: readonly TaskState[]): Promise<AutoResumeCandidate | null>;
|
|
76
|
+
/** Id-only form of {@link findResumableAutoDetailed}. */
|
|
62
77
|
export declare function findResumableAuto(cwd: string): Promise<string | null>;
|
package/dist/task/auto-io.js
CHANGED
|
@@ -194,8 +194,19 @@ export async function insertTaskAfter(cwd, id, afterIndex, title) {
|
|
|
194
194
|
await setTaskSection(cwd, id, 'tasks', lines.join('\n'));
|
|
195
195
|
return true;
|
|
196
196
|
}
|
|
197
|
-
/**
|
|
198
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
|
|
199
|
+
* last-write time the resume banner reports (see resume-gap.ts). Null when there
|
|
200
|
+
* is nothing resumable.
|
|
201
|
+
*
|
|
202
|
+
* `states` narrows which states count as resumable, and the UNATTENDED path passes
|
|
203
|
+
* UNATTENDED_STATES so the search answers the question that path actually asks —
|
|
204
|
+
* "is there an IN-FLIGHT run to pick up?". Selecting the newest human-resumable run
|
|
205
|
+
* and only then refusing it on state let one failed run shadow an in-flight one
|
|
206
|
+
* behind it: the boot hook refused every restart and the in-flight run stayed in
|
|
207
|
+
* exactly the dead air this feature exists to end.
|
|
208
|
+
*/
|
|
209
|
+
export async function findResumableAutoDetailed(cwd, states = RESUMABLE_STATES) {
|
|
199
210
|
await ensureTasksDir(cwd);
|
|
200
211
|
const entries = await fsp.readdir(tasksDir(cwd));
|
|
201
212
|
const candidates = [];
|
|
@@ -208,15 +219,19 @@ export async function findResumableAuto(cwd) {
|
|
|
208
219
|
const fm = parseFrontMatter(raw);
|
|
209
220
|
if (!fm)
|
|
210
221
|
continue;
|
|
211
|
-
if (!
|
|
222
|
+
if (!states.includes(fm.state))
|
|
212
223
|
continue;
|
|
213
224
|
const st = await fsp.stat(path.join(tasksDir(cwd), f));
|
|
214
|
-
candidates.push({ id: m[1],
|
|
225
|
+
candidates.push({ id: m[1], state: fm.state, lastWriteMs: st.mtimeMs });
|
|
215
226
|
}
|
|
216
227
|
catch {
|
|
217
228
|
/* skip unreadable */
|
|
218
229
|
}
|
|
219
230
|
}
|
|
220
|
-
candidates.sort((a, b) => b.
|
|
221
|
-
return candidates.length > 0 ? candidates[0]
|
|
231
|
+
candidates.sort((a, b) => b.lastWriteMs - a.lastWriteMs);
|
|
232
|
+
return candidates.length > 0 ? candidates[0] : null;
|
|
233
|
+
}
|
|
234
|
+
/** Id-only form of {@link findResumableAutoDetailed}. */
|
|
235
|
+
export async function findResumableAuto(cwd) {
|
|
236
|
+
return (await findResumableAutoDetailed(cwd))?.id ?? null;
|
|
222
237
|
}
|
|
@@ -14,7 +14,8 @@ import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js'
|
|
|
14
14
|
import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
|
|
15
15
|
import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
|
|
16
16
|
import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
|
|
17
|
-
import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter,
|
|
17
|
+
import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter, findResumableAutoDetailed } from './auto-io.js';
|
|
18
|
+
import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
|
|
18
19
|
import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
|
|
19
20
|
import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
|
|
20
21
|
import { readTextFile } from '../shared/fs-text.js';
|
|
@@ -1593,15 +1594,30 @@ async function handleTaskAuto(args, ctx) {
|
|
|
1593
1594
|
disarmCancelListener();
|
|
1594
1595
|
}
|
|
1595
1596
|
}
|
|
1596
|
-
async function handleTaskAutoResume(
|
|
1597
|
+
async function handleTaskAutoResume(args, ctx) {
|
|
1597
1598
|
await ctx.waitForIdle();
|
|
1598
1599
|
const cwd = ctx.cwd;
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1600
|
+
// `--unattended` is the boot-hook path: no human decided to continue this
|
|
1601
|
+
// run, so it resumes in-flight states only and refuses the rest by name.
|
|
1602
|
+
const unattended = /(^|\s)--unattended(\s|$)/.test(args);
|
|
1603
|
+
// Unattended asks a narrower question — "is there an IN-FLIGHT run?" — so it
|
|
1604
|
+
// searches those states directly. Picking the newest human-resumable run and
|
|
1605
|
+
// refusing it on state let a failed run shadow an in-flight one behind it.
|
|
1606
|
+
// With nothing in flight, fall back to the newest resumable run so the refusal
|
|
1607
|
+
// still names it instead of claiming there is nothing here.
|
|
1608
|
+
const eligible = unattended ?
|
|
1609
|
+
await findResumableAutoDetailed(cwd, UNATTENDED_STATES)
|
|
1610
|
+
: await findResumableAutoDetailed(cwd);
|
|
1611
|
+
const candidate = eligible ?? (unattended ? await findResumableAutoDetailed(cwd) : null);
|
|
1612
|
+
const decision = decideResume(candidate, Date.now(), unattended);
|
|
1613
|
+
ctx.ui.notify(decision.banner, decision.level);
|
|
1614
|
+
// An unattended refusal happens with nobody watching the terminal — the
|
|
1615
|
+
// remote view is the only surface that will still be there in the morning.
|
|
1616
|
+
if (unattended)
|
|
1617
|
+
publishLifecycleNotice(decision.banner, decision.level);
|
|
1618
|
+
if (!decision.resume || !candidate)
|
|
1602
1619
|
return;
|
|
1603
|
-
|
|
1604
|
-
ctx.ui.notify(`Resuming ${id}…`, 'info');
|
|
1620
|
+
const id = candidate.id;
|
|
1605
1621
|
await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
|
|
1606
1622
|
autoRunning = true;
|
|
1607
1623
|
armTerminalCancel(ctx);
|
|
@@ -1670,7 +1686,8 @@ export function registerTaskAuto(pi) {
|
|
|
1670
1686
|
handler: handleTaskAuto
|
|
1671
1687
|
});
|
|
1672
1688
|
registerBridgeCommand(pi, 'task-auto-resume', {
|
|
1673
|
-
description: 'Resume the active /task-auto run.'
|
|
1689
|
+
description: 'Resume the active /task-auto run. Usage: /task-auto-resume [--unattended] '
|
|
1690
|
+
+ '(--unattended is for boot hooks: in-flight runs only, never a failed one).',
|
|
1674
1691
|
handler: handleTaskAutoResume
|
|
1675
1692
|
});
|
|
1676
1693
|
registerBridgeCommand(pi, 'task-auto-cancel', {
|
|
@@ -51,12 +51,28 @@ const VOLATILE = [
|
|
|
51
51
|
[/\bpids?\s*[:=]?\s*\d+/g, '<pid>'],
|
|
52
52
|
[/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\]):\d+/g, '<addr>'],
|
|
53
53
|
[/\bport\s*[:=]?\s*\d{2,5}\b/g, '<port>'],
|
|
54
|
-
[/:\d{2,5}\b/g, ':<port>'],
|
|
55
54
|
// Long hex / uuid-ish ids (sha, container id, request id).
|
|
56
55
|
[/\b0x[0-9a-f]+\b/g, '<hex>'],
|
|
57
56
|
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/g, '<uuid>'],
|
|
58
57
|
[/\b[0-9a-f]{7,40}\b/g, '<hex>']
|
|
59
58
|
];
|
|
59
|
+
/**
|
|
60
|
+
* A bare `:NNNN` port, which the localhost/`port N` rules above do not reach (a
|
|
61
|
+
* failure that just says "no listener on :3000").
|
|
62
|
+
*
|
|
63
|
+
* NOT applied when the colon follows a SOURCE LOCATION — `src/db.ts:41`,
|
|
64
|
+
* `Cart.tsx:88:12`. A failure detail embeds the failing command's output tail
|
|
65
|
+
* verbatim (final-gate.ts `r.tail`), and tsc/eslint/bun-test tails are mostly
|
|
66
|
+
* file:line. Collapsing those made two DIFFERENT defects in one file — the second
|
|
67
|
+
* uncovered by fixing the first — compare equal, which reads as non-progress and
|
|
68
|
+
* demotes a genuinely fixable check to UNOBSERVED debt. A moved error is progress.
|
|
69
|
+
*/
|
|
70
|
+
const BARE_PORT = /(\S*?):(\d{2,5})\b/g;
|
|
71
|
+
/** A path (has a separator) or a filename with an extension ⇒ a source location. */
|
|
72
|
+
const SOURCE_LOCATION = /[/\\]|\.[a-z][a-z0-9]{0,4}$/;
|
|
73
|
+
function collapseBarePorts(s) {
|
|
74
|
+
return s.replace(BARE_PORT, (whole, prefix) => SOURCE_LOCATION.test(prefix) ? whole : `${prefix}:<port>`);
|
|
75
|
+
}
|
|
60
76
|
/**
|
|
61
77
|
* Comparison key for a gate failure entry: lowercased, volatile substrings erased,
|
|
62
78
|
* whitespace collapsed. Two entries with the same key are "the same failure" for
|
|
@@ -66,6 +82,7 @@ export function normalizeFailureDetail(detail) {
|
|
|
66
82
|
let s = detail.toLowerCase();
|
|
67
83
|
for (const [re, repl] of VOLATILE)
|
|
68
84
|
s = s.replace(re, repl);
|
|
85
|
+
s = collapseBarePorts(s);
|
|
69
86
|
return s.replace(/\s+/g, ' ').trim();
|
|
70
87
|
}
|
|
71
88
|
/** The ranked-first failure of a gate outcome (the list is ranked most load-bearing
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume gating & the honest resume banner.
|
|
3
|
+
*
|
|
4
|
+
* mx5 run 14 lost ~10 hours to dead air that looked like a stall: the host was
|
|
5
|
+
* powered off overnight, both containers stopped at 20:00Z, and the run picked
|
|
6
|
+
* up cleanly the moment they were restarted at 06:01Z. Nothing was wrong with
|
|
7
|
+
* the run — the only defect was that nobody could tell. Two things follow.
|
|
8
|
+
*
|
|
9
|
+
* (1) A restart-time resume can be automated (`restart: unless-stopped` on the
|
|
10
|
+
* containers plus a boot hook that runs `/task-auto-resume --unattended`), which
|
|
11
|
+
* turns that 10h of nothing into minutes.
|
|
12
|
+
*
|
|
13
|
+
* (2) An automated resume must not resume everything. `RESUMABLE_STATES`
|
|
14
|
+
* deliberately includes `failed` and `cancelled` because a HUMAN typing
|
|
15
|
+
* /task-auto-resume has decided to continue; a boot hook has decided nothing. A
|
|
16
|
+
* failed run stopped for a reason a power cycle does not clear, and re-entering
|
|
17
|
+
* it unattended burns the whole loop against the same wall. Unattended resume
|
|
18
|
+
* therefore covers in-flight states only (see UNATTENDED_STATES), and refuses
|
|
19
|
+
* the rest by name rather than silently doing nothing.
|
|
20
|
+
*
|
|
21
|
+
* The banner follows the honest-restart-hint rule (70a8497): say exactly what
|
|
22
|
+
* was observed and exactly what it does not tell you. All this process knows is
|
|
23
|
+
* when the AUTO file was last written — it cannot distinguish a stopped host
|
|
24
|
+
* from a hung child from a slow task, so it reports the gap and attributes no
|
|
25
|
+
* cause. It is equally careful about the tree: nothing is rolled back between
|
|
26
|
+
* the interruption and the resume.
|
|
27
|
+
*/
|
|
28
|
+
import type { TaskState } from './task-types.js';
|
|
29
|
+
/** States an UNATTENDED resume may continue. In-flight only — see file header. */
|
|
30
|
+
export declare const UNATTENDED_STATES: TaskState[];
|
|
31
|
+
/** The most recent resumable AUTO run, with the two facts the banner needs. */
|
|
32
|
+
export interface AutoResumeCandidate {
|
|
33
|
+
id: string;
|
|
34
|
+
state: TaskState;
|
|
35
|
+
/** AUTO-file mtime: the last moment the run demonstrably wrote progress. */
|
|
36
|
+
lastWriteMs: number;
|
|
37
|
+
}
|
|
38
|
+
export interface ResumeDecision {
|
|
39
|
+
/** Whether the caller should proceed with the resume. */
|
|
40
|
+
resume: boolean;
|
|
41
|
+
/** The line to show the user (and publish to the remote view). */
|
|
42
|
+
banner: string;
|
|
43
|
+
level: 'info' | 'warning';
|
|
44
|
+
}
|
|
45
|
+
/** Human gap, coarsest-two-units ("10h 1m", "45s", "2d 4h"). */
|
|
46
|
+
export declare function formatGap(ms: number): string;
|
|
47
|
+
/**
|
|
48
|
+
* Gate an incoming resume and phrase it. Pure: the caller supplies the candidate
|
|
49
|
+
* and the clock. `unattended` is the boot-hook path (`--unattended`), which is
|
|
50
|
+
* the only one that refuses on state.
|
|
51
|
+
*/
|
|
52
|
+
export declare function decideResume(candidate: AutoResumeCandidate | null, nowMs: number, unattended: boolean): ResumeDecision;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** States an UNATTENDED resume may continue. In-flight only — see file header. */
|
|
2
|
+
export const UNATTENDED_STATES = ['in_progress'];
|
|
3
|
+
/**
|
|
4
|
+
* Below this the gap is ordinary hand-driven latency (you looked at the failure,
|
|
5
|
+
* then typed the command) and the unaccounted-time paragraph would be noise.
|
|
6
|
+
*/
|
|
7
|
+
const GAP_NARRATION_MS = 5 * 60_000;
|
|
8
|
+
/** Human gap, coarsest-two-units ("10h 1m", "45s", "2d 4h"). */
|
|
9
|
+
export function formatGap(ms) {
|
|
10
|
+
const s = Math.floor(ms / 1000);
|
|
11
|
+
if (s < 60)
|
|
12
|
+
return `${s}s`;
|
|
13
|
+
const m = Math.floor(s / 60);
|
|
14
|
+
if (m < 60)
|
|
15
|
+
return s % 60 === 0 ? `${m}m` : `${m}m ${s % 60}s`;
|
|
16
|
+
const h = Math.floor(m / 60);
|
|
17
|
+
if (h < 24)
|
|
18
|
+
return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`;
|
|
19
|
+
const d = Math.floor(h / 24);
|
|
20
|
+
return h % 24 === 0 ? `${d}d` : `${d}d ${h % 24}h`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What the resume says about the time it was away. Attributes no cause: a gap
|
|
24
|
+
* is wall-clock silence, and this process cannot see which of the several very
|
|
25
|
+
* different explanations produced it.
|
|
26
|
+
*/
|
|
27
|
+
function gapClause(lastWriteMs, nowMs) {
|
|
28
|
+
const gap = nowMs - lastWriteMs;
|
|
29
|
+
const at = new Date(lastWriteMs).toISOString();
|
|
30
|
+
// A future mtime means the clock moved, not that the run wrote ahead of
|
|
31
|
+
// time; saying "0s ago" there would be the one thing this banner must not do.
|
|
32
|
+
if (gap < -60_000) {
|
|
33
|
+
return `its file is timestamped ${at}, which is in the future — the clock moved under it, so no gap can be measured`;
|
|
34
|
+
}
|
|
35
|
+
const shown = formatGap(Math.max(0, gap));
|
|
36
|
+
if (gap < GAP_NARRATION_MS)
|
|
37
|
+
return `last written ${shown} ago (${at})`;
|
|
38
|
+
return (`last written ${shown} ago (${at}). That gap is unaccounted-for wall time, not work: `
|
|
39
|
+
+ `this process cannot tell a stopped host from a hung child from a slow task. `
|
|
40
|
+
+ `Nothing was rolled back — the working tree is exactly as the interrupted run left it`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Gate an incoming resume and phrase it. Pure: the caller supplies the candidate
|
|
44
|
+
* and the clock. `unattended` is the boot-hook path (`--unattended`), which is
|
|
45
|
+
* the only one that refuses on state.
|
|
46
|
+
*/
|
|
47
|
+
export function decideResume(candidate, nowMs, unattended) {
|
|
48
|
+
if (!candidate) {
|
|
49
|
+
return { resume: false, banner: 'No resumable /task-auto run.', level: 'info' };
|
|
50
|
+
}
|
|
51
|
+
const { id, state, lastWriteMs } = candidate;
|
|
52
|
+
if (unattended && !UNATTENDED_STATES.includes(state)) {
|
|
53
|
+
return {
|
|
54
|
+
resume: false,
|
|
55
|
+
banner: `Not auto-resuming ${id}: state=${state}. Unattended resume continues `
|
|
56
|
+
+ `in-flight runs (${UNATTENDED_STATES.join(', ')}) only — a ${state} run stopped `
|
|
57
|
+
+ `for a reason a restart does not clear. Look at it, then resume by hand with `
|
|
58
|
+
+ `/task-auto-resume.`,
|
|
59
|
+
level: 'warning'
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
resume: true,
|
|
64
|
+
banner: `Resuming ${id} (state=${state}) — ${gapClause(lastWriteMs, nowMs)}.`,
|
|
65
|
+
level: 'info'
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -68,16 +68,22 @@ export function registerStreamWatchdog(pi) {
|
|
|
68
68
|
pi.on('message_start', (_e, ctx) => arm(ctx));
|
|
69
69
|
pi.on('message_update', (_e, ctx) => arm(ctx));
|
|
70
70
|
pi.on('message_end', (_e, ctx) => arm(ctx));
|
|
71
|
-
|
|
71
|
+
// Keyed by toolCallId: a tool BATCH runs in parallel, so the clock must stay
|
|
72
|
+
// paused until the last call settles, not the first (see StreamWatchdog.resume).
|
|
73
|
+
const callId = (e) => {
|
|
74
|
+
const id = e?.toolCallId;
|
|
75
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
76
|
+
};
|
|
77
|
+
pi.on('tool_execution_start', (e, ctx) => {
|
|
72
78
|
liveCtx = ctx;
|
|
73
|
-
watchdog.suspend();
|
|
79
|
+
watchdog.suspend(callId(e));
|
|
74
80
|
});
|
|
75
81
|
pi.on('tool_execution_update', (_e, ctx) => {
|
|
76
82
|
liveCtx = ctx;
|
|
77
83
|
});
|
|
78
|
-
pi.on('tool_execution_end', (
|
|
84
|
+
pi.on('tool_execution_end', (e, ctx) => {
|
|
79
85
|
liveCtx = ctx;
|
|
80
|
-
watchdog.resume();
|
|
86
|
+
watchdog.resume(callId(e));
|
|
81
87
|
});
|
|
82
88
|
// Nothing is streaming between agent loops; stop so no timer can fire into an
|
|
83
89
|
// idle session (which would abort nothing and post a reminder to no one).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.43",
|
|
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",
|