@mjasnikovs/pi-task 0.42.0 → 0.42.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -0
- package/dist/task/gate-child.js +10 -0
- package/dist/task/implementation-scope.d.ts +8 -6
- package/dist/task/implementation-scope.js +15 -10
- package/dist/task/orchestrator.d.ts +1 -0
- package/dist/task/orchestrator.js +16 -5
- package/dist/task/task-dir-custody.d.ts +24 -0
- package/dist/task/task-dir-custody.js +100 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { registerRemote } from './remote/register.js';
|
|
|
7
7
|
import { registerCommandWatchdog } from './task/command-watchdog.js';
|
|
8
8
|
import { registerStreamWatchdog } from './task/stream-watchdog.js';
|
|
9
9
|
import { registerImplementationGuards } from './task/implementation-guards.js';
|
|
10
|
+
import { registerTaskDirCustody } from './task/task-dir-custody.js';
|
|
10
11
|
import { registerModelHoldRestore } from './task/model-hold-stash.js';
|
|
11
12
|
export default function (pi) {
|
|
12
13
|
registerConfig(pi);
|
|
@@ -18,5 +19,6 @@ export default function (pi) {
|
|
|
18
19
|
registerCommandWatchdog(pi);
|
|
19
20
|
registerStreamWatchdog(pi);
|
|
20
21
|
registerImplementationGuards(pi);
|
|
22
|
+
registerTaskDirCustody(pi);
|
|
21
23
|
registerModelHoldRestore(pi);
|
|
22
24
|
}
|
package/dist/task/gate-child.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { formatLoopHint } from './loop-detector.js';
|
|
28
28
|
import { classifyEnforceChildFailure } from './enforce-guidelines.js';
|
|
29
29
|
import { notifyRun } from '../remote/bridge.js';
|
|
30
|
+
import { restoreTaskDir, snapshotTaskDir, taskDirRestoredNotice } from './task-dir-custody.js';
|
|
30
31
|
/**
|
|
31
32
|
* What each kind may do. Adding a child is a row; it cannot be added without
|
|
32
33
|
* deciding all four questions, which is the point.
|
|
@@ -69,6 +70,9 @@ export function makeGateChild(deps) {
|
|
|
69
70
|
const log = deps.makeDebugAppender(deps.logPath);
|
|
70
71
|
log(`=== ${deps.kind} start: ${deps.taskTitle} ===`);
|
|
71
72
|
const guardSnapshot = row.guarded ? await deps.captureGitState(deps.cwd, sig) : null;
|
|
73
|
+
// Every kind, whatever its tools: the git-state guard and the discard both
|
|
74
|
+
// leave `.pi-tasks` alone.
|
|
75
|
+
const taskDir = await snapshotTaskDir(deps.cwd);
|
|
72
76
|
const frame = deps.loader === false ?
|
|
73
77
|
null
|
|
74
78
|
: () => ({
|
|
@@ -132,6 +136,12 @@ export function makeGateChild(deps) {
|
|
|
132
136
|
finally {
|
|
133
137
|
// Restore whatever the child moved BEFORE any verdict or failure is
|
|
134
138
|
// acted on — a crashed child must not skip the restore either.
|
|
139
|
+
const restored = await restoreTaskDir(deps.cwd, taskDir);
|
|
140
|
+
if (restored.length > 0) {
|
|
141
|
+
log(`=== ${deps.kind} TASK-DIR CUSTODY — restored: ${restored.join(', ')} ===`);
|
|
142
|
+
notifyRun(deps.ctx, `${deps.taskTitle}: `
|
|
143
|
+
+ taskDirRestoredNotice(`the ${deps.kind} child`, restored), 'warning');
|
|
144
|
+
}
|
|
135
145
|
if (guardSnapshot) {
|
|
136
146
|
const rec = await deps.reconcileGitState(deps.cwd, guardSnapshot, sig);
|
|
137
147
|
deps.onReconcile?.(rec);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The implementation-turn bracket: one entry arms
|
|
3
|
-
* runaway guard, one `leave`
|
|
2
|
+
* The implementation-turn bracket: one entry arms the status widget and the
|
|
3
|
+
* runaway guard and takes custody of the task dir, one `leave` ends all three.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Each module keeps its own lifecycle handlers, and the first two draw the turn
|
|
6
6
|
* boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
|
|
7
7
|
* over, the screen should say so), the guard survives until `agent_settled`
|
|
8
8
|
* (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
import { type ImplWidgetMeta } from './impl-widget.js';
|
|
14
14
|
/**
|
|
15
15
|
* `oneShot` true (fire-and-forget /task) lets each module's own settle handler
|
|
16
|
-
*
|
|
16
|
+
* end it after the single turn; false (awaited /task-auto) keeps all three
|
|
17
17
|
* across resume and steer turns until `leave` is called.
|
|
18
18
|
*
|
|
19
19
|
* `leave` is idempotent: a second call is a no-op, so a caller can put it in a
|
|
20
|
-
* `finally` and a `catch` without
|
|
20
|
+
* `finally` and a `catch` without ending a bracket entered since. It resolves to
|
|
21
|
+
* the task files it restored.
|
|
21
22
|
*/
|
|
22
23
|
export declare function enterImplementationTurn(meta: ImplWidgetMeta, opts: {
|
|
23
24
|
oneShot: boolean;
|
|
24
|
-
|
|
25
|
+
cwd: string;
|
|
26
|
+
}): Promise<() => Promise<string[]>>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The implementation-turn bracket: one entry arms
|
|
3
|
-
* runaway guard, one `leave`
|
|
2
|
+
* The implementation-turn bracket: one entry arms the status widget and the
|
|
3
|
+
* runaway guard and takes custody of the task dir, one `leave` ends all three.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Each module keeps its own lifecycle handlers, and the first two draw the turn
|
|
6
6
|
* boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
|
|
7
7
|
* over, the screen should say so), the guard survives until `agent_settled`
|
|
8
8
|
* (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
|
|
@@ -12,23 +12,28 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { armImplWidget, disarmImplWidget } from './impl-widget.js';
|
|
14
14
|
import { armImplementationGuard, disarmImplementationGuard } from './implementation-guards.js';
|
|
15
|
+
import { takeTaskDirCustody } from './task-dir-custody.js';
|
|
15
16
|
/**
|
|
16
17
|
* `oneShot` true (fire-and-forget /task) lets each module's own settle handler
|
|
17
|
-
*
|
|
18
|
+
* end it after the single turn; false (awaited /task-auto) keeps all three
|
|
18
19
|
* across resume and steer turns until `leave` is called.
|
|
19
20
|
*
|
|
20
21
|
* `leave` is idempotent: a second call is a no-op, so a caller can put it in a
|
|
21
|
-
* `finally` and a `catch` without
|
|
22
|
+
* `finally` and a `catch` without ending a bracket entered since. It resolves to
|
|
23
|
+
* the task files it restored.
|
|
22
24
|
*/
|
|
23
|
-
export function enterImplementationTurn(meta, opts) {
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
export async function enterImplementationTurn(meta, opts) {
|
|
26
|
+
const { oneShot } = opts;
|
|
27
|
+
const releaseTaskDir = await takeTaskDirCustody(opts.cwd, { oneShot });
|
|
28
|
+
armImplWidget(meta, { oneShot });
|
|
29
|
+
armImplementationGuard({ oneShot });
|
|
26
30
|
let left = false;
|
|
27
|
-
return () => {
|
|
31
|
+
return async () => {
|
|
28
32
|
if (left)
|
|
29
|
-
return;
|
|
33
|
+
return [];
|
|
30
34
|
left = true;
|
|
31
35
|
disarmImplWidget();
|
|
32
36
|
disarmImplementationGuard();
|
|
37
|
+
return releaseTaskDir();
|
|
33
38
|
};
|
|
34
39
|
}
|
|
@@ -178,6 +178,7 @@ export declare class TaskRunner {
|
|
|
178
178
|
*/
|
|
179
179
|
private _recordHandoff;
|
|
180
180
|
private _deliverSpec;
|
|
181
|
+
private _reportRestored;
|
|
181
182
|
/**
|
|
182
183
|
* The spec as the implementer should receive it (Layer B). Layer A strips phantom
|
|
183
184
|
* specifiers from the upstream pipeline text, but a residual affirmative can survive
|
|
@@ -27,7 +27,8 @@ import { allocateTaskId, ensureTasksDir, mergeTaskSection, readSection, readTask
|
|
|
27
27
|
import { startWidget } from './widget.js';
|
|
28
28
|
import { setupImplWidget } from './impl-widget.js';
|
|
29
29
|
import { enterImplementationTurn } from './implementation-scope.js';
|
|
30
|
-
import {
|
|
30
|
+
import { taskDirRestoredNotice } from './task-dir-custody.js';
|
|
31
|
+
import { SessionUI, publishNotify, registerBridgeCommand, getBridge, notifyBoth, notifyRun, isRemoteOrigin } from '../remote/bridge.js';
|
|
31
32
|
import { pushNotify } from '../remote/push.js';
|
|
32
33
|
import { getConfig } from '../config/config.js';
|
|
33
34
|
import { appendDebugLine, gateDebugWriter } from './debug-log.js';
|
|
@@ -473,7 +474,10 @@ export class TaskRunner {
|
|
|
473
474
|
label: this._widgetState.label
|
|
474
475
|
};
|
|
475
476
|
if (this._sendSpec) {
|
|
476
|
-
const leave = enterImplementationTurn(meta, {
|
|
477
|
+
const leave = await enterImplementationTurn(meta, {
|
|
478
|
+
oneShot: !this._implAwaited,
|
|
479
|
+
cwd: this._cwd
|
|
480
|
+
});
|
|
477
481
|
let delivered = false;
|
|
478
482
|
try {
|
|
479
483
|
await this._sendSpec(spec);
|
|
@@ -486,14 +490,14 @@ export class TaskRunner {
|
|
|
486
490
|
// missing model, or a failed auth. The next unrelated turn would
|
|
487
491
|
// inherit it, and this guard can end a turn outright.
|
|
488
492
|
if (this._implAwaited || !delivered)
|
|
489
|
-
leave();
|
|
493
|
+
this._reportRestored(await leave());
|
|
490
494
|
}
|
|
491
495
|
return;
|
|
492
496
|
}
|
|
493
497
|
if (!piApi) {
|
|
494
498
|
throw new Error('extension not initialised (no ExtensionAPI captured)');
|
|
495
499
|
}
|
|
496
|
-
const leave = enterImplementationTurn(meta, { oneShot: true });
|
|
500
|
+
const leave = await enterImplementationTurn(meta, { oneShot: true, cwd: this._cwd });
|
|
497
501
|
// Same reason as the awaited path's `delivered` flag: this send can throw
|
|
498
502
|
// SYNCHRONOUSLY — the loader gates every ExtensionAPI action behind
|
|
499
503
|
// `assertActive()` — and a guard left armed over a turn that never starts
|
|
@@ -507,10 +511,17 @@ export class TaskRunner {
|
|
|
507
511
|
piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
|
|
508
512
|
}
|
|
509
513
|
catch (e) {
|
|
510
|
-
leave();
|
|
514
|
+
await leave();
|
|
511
515
|
throw e;
|
|
512
516
|
}
|
|
513
517
|
}
|
|
518
|
+
_reportRestored(names) {
|
|
519
|
+
if (names.length === 0)
|
|
520
|
+
return;
|
|
521
|
+
const notice = taskDirRestoredNotice('The implementation turn', names);
|
|
522
|
+
this._deps.logDebug?.(notice);
|
|
523
|
+
notifyRun(this._ctx, notice, 'warning');
|
|
524
|
+
}
|
|
514
525
|
/**
|
|
515
526
|
* The spec as the implementer should receive it (Layer B). Layer A strips phantom
|
|
516
527
|
* specifiers from the upstream pipeline text, but a residual affirmative can survive
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
/** File name → bytes, for the held files only. */
|
|
3
|
+
export type TaskDirSnapshot = ReadonlyMap<string, Buffer>;
|
|
4
|
+
export declare function snapshotTaskDir(cwd: string): Promise<TaskDirSnapshot>;
|
|
5
|
+
/** Rewrite every snapshotted file that changed or is gone. Never throws.
|
|
6
|
+
* Returns the names it put back, sorted. */
|
|
7
|
+
export declare function restoreTaskDir(cwd: string, snapshot: TaskDirSnapshot): Promise<string[]>;
|
|
8
|
+
export declare function taskDirRestoredNotice(who: string, names: readonly string[]): string;
|
|
9
|
+
/**
|
|
10
|
+
* Snapshot before the turn can start, replacing any custody still held.
|
|
11
|
+
* `oneShot` means the settle ends it; otherwise the returned release does, and
|
|
12
|
+
* it never releases a custody taken since.
|
|
13
|
+
*/
|
|
14
|
+
export declare function takeTaskDirCustody(cwd: string, opts: {
|
|
15
|
+
oneShot: boolean;
|
|
16
|
+
}): Promise<() => Promise<string[]>>;
|
|
17
|
+
export declare function releaseTaskDirCustody(): Promise<string[]>;
|
|
18
|
+
/** @internal Test seam: is a turn's task dir held? */
|
|
19
|
+
export declare function taskDirCustodyHeld(): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* pi awaits these handlers before it wakes a command waiting for idle, so a
|
|
22
|
+
* one-shot restore finishes before the next run writes anything.
|
|
23
|
+
*/
|
|
24
|
+
export declare function registerTaskDirCustody(pi: ExtensionAPI): void;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custody of the task dir while a model can write to it.
|
|
3
|
+
*
|
|
4
|
+
* Every markdown file in `.pi-tasks/` is host state. A model reaches it through
|
|
5
|
+
* edit, write or bash, and pi rewrites a path before it writes (`@x`, `file://`),
|
|
6
|
+
* so a rule on the tool call sees only some of the writers. The host snapshots
|
|
7
|
+
* the files before the model runs and puts back whatever changed. MEASURED: an
|
|
8
|
+
* mx5 implementer wrote its report over TASK_AUTO_0001.md, and the run died on
|
|
9
|
+
* its front matter.
|
|
10
|
+
*
|
|
11
|
+
* Only markdown is held: pi-task's own tools write research-cache.json mid-turn.
|
|
12
|
+
* A file created in the window is kept, because a run started meanwhile
|
|
13
|
+
* allocates its own.
|
|
14
|
+
*/
|
|
15
|
+
import * as fsp from 'node:fs/promises';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import { notifyRun } from '../remote/bridge.js';
|
|
18
|
+
import { tasksDir } from './task-io.js';
|
|
19
|
+
import { TASKS_DIR_NAME } from './task-types.js';
|
|
20
|
+
export async function snapshotTaskDir(cwd) {
|
|
21
|
+
const dir = tasksDir(cwd);
|
|
22
|
+
const out = new Map();
|
|
23
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
if (!e.isFile() || !e.name.endsWith('.md'))
|
|
26
|
+
continue;
|
|
27
|
+
const bytes = await fsp.readFile(path.join(dir, e.name)).catch(() => null);
|
|
28
|
+
if (bytes)
|
|
29
|
+
out.set(e.name, bytes);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** Rewrite every snapshotted file that changed or is gone. Never throws.
|
|
34
|
+
* Returns the names it put back, sorted. */
|
|
35
|
+
export async function restoreTaskDir(cwd, snapshot) {
|
|
36
|
+
const dir = tasksDir(cwd);
|
|
37
|
+
const restored = [];
|
|
38
|
+
for (const [name, bytes] of snapshot) {
|
|
39
|
+
const file = path.join(dir, name);
|
|
40
|
+
const now = await fsp.readFile(file).catch(() => null);
|
|
41
|
+
if (now?.equals(bytes))
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
45
|
+
await fsp.writeFile(file, bytes);
|
|
46
|
+
restored.push(name);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// The host's next read of this file reports what is still wrong.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return restored.sort();
|
|
53
|
+
}
|
|
54
|
+
export function taskDirRestoredNotice(who, names) {
|
|
55
|
+
const list = names.map(n => `${TASKS_DIR_NAME}/${n}`).join(', ');
|
|
56
|
+
return `${who} changed ${list}, which only pi-task writes. Restored.`;
|
|
57
|
+
}
|
|
58
|
+
/** The implementation turn's custody. One slot: one task runs at a time. */
|
|
59
|
+
let held = null;
|
|
60
|
+
async function release(custody) {
|
|
61
|
+
if (held !== custody)
|
|
62
|
+
return [];
|
|
63
|
+
held = null;
|
|
64
|
+
return restoreTaskDir(custody.cwd, custody.snapshot);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Snapshot before the turn can start, replacing any custody still held.
|
|
68
|
+
* `oneShot` means the settle ends it; otherwise the returned release does, and
|
|
69
|
+
* it never releases a custody taken since.
|
|
70
|
+
*/
|
|
71
|
+
export async function takeTaskDirCustody(cwd, opts) {
|
|
72
|
+
const custody = { cwd, snapshot: await snapshotTaskDir(cwd), oneShot: opts.oneShot };
|
|
73
|
+
held = custody;
|
|
74
|
+
return () => release(custody);
|
|
75
|
+
}
|
|
76
|
+
export function releaseTaskDirCustody() {
|
|
77
|
+
return held ? release(held) : Promise.resolve([]);
|
|
78
|
+
}
|
|
79
|
+
/** @internal Test seam: is a turn's task dir held? */
|
|
80
|
+
export function taskDirCustodyHeld() {
|
|
81
|
+
return held !== null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* pi awaits these handlers before it wakes a command waiting for idle, so a
|
|
85
|
+
* one-shot restore finishes before the next run writes anything.
|
|
86
|
+
*/
|
|
87
|
+
export function registerTaskDirCustody(pi) {
|
|
88
|
+
pi.on('agent_settled', async (_event, ctx) => {
|
|
89
|
+
if (!held?.oneShot)
|
|
90
|
+
return;
|
|
91
|
+
const restored = await releaseTaskDirCustody();
|
|
92
|
+
if (restored.length === 0)
|
|
93
|
+
return;
|
|
94
|
+
notifyRun(ctx, taskDirRestoredNotice('The implementation turn', restored), 'warning');
|
|
95
|
+
});
|
|
96
|
+
// Dropped, not restored: a snapshot this old would revert the next run's writes.
|
|
97
|
+
pi.on('session_shutdown', () => {
|
|
98
|
+
held = null;
|
|
99
|
+
});
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.2",
|
|
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",
|