@mjasnikovs/pi-task 0.39.0 → 0.39.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/README.md +2 -2
- package/dist/config/group-args.d.ts +24 -9
- package/dist/config/group-args.js +38 -28
- package/dist/config/register.d.ts +0 -9
- package/dist/config/register.js +17 -62
- package/dist/shared/child-process.d.ts +34 -32
- package/dist/shared/child-process.js +44 -58
- package/dist/shared/command-watchdog.d.ts +12 -4
- package/dist/shared/command-watchdog.js +6 -7
- package/dist/shared/connection-error.d.ts +7 -0
- package/dist/shared/connection-error.js +65 -0
- package/dist/shared/model-endpoint.d.ts +12 -24
- package/dist/shared/model-endpoint.js +32 -82
- package/dist/shared/model-resolve.d.ts +105 -0
- package/dist/shared/model-resolve.js +97 -0
- package/dist/shared/reasoning-capability.d.ts +20 -0
- package/dist/shared/reasoning-capability.js +32 -1
- package/dist/shared/stall-probe.d.ts +51 -0
- package/dist/shared/stall-probe.js +79 -0
- package/dist/task/child-runner.d.ts +76 -278
- package/dist/task/child-runner.js +186 -722
- package/dist/task/context-usage.js +2 -7
- package/dist/task/failure-classifier.js +53 -81
- package/dist/task/gate-child.js +1 -1
- package/dist/task/impl-widget.d.ts +2 -0
- package/dist/task/impl-widget.js +4 -0
- package/dist/task/implementation-hold.d.ts +11 -0
- package/dist/task/implementation-hold.js +20 -0
- package/dist/task/implementation-scope.d.ts +24 -0
- package/dist/task/implementation-scope.js +34 -0
- package/dist/task/loop-detector.d.ts +13 -5
- package/dist/task/loop-detector.js +11 -5
- package/dist/task/model-hold-stash.js +4 -14
- package/dist/task/orchestrator.d.ts +1 -8
- package/dist/task/orchestrator.js +11 -34
- package/dist/task/phases.js +2 -2
- package/dist/task/stall-detector.d.ts +1 -1
- package/dist/task/stall-detector.js +1 -1
- package/dist/workers/model-warning.d.ts +4 -16
- package/dist/workers/model-warning.js +14 -70
- package/dist/workers/pi-worker-core.d.ts +65 -20
- package/dist/workers/pi-worker-core.js +109 -50
- package/dist/workers/reasoning-warning.js +2 -24
- package/dist/workers/worker-failure.d.ts +2 -0
- package/dist/workers/worker-failure.js +2 -1
- package/dist/workers/worker-kill.d.ts +30 -11
- package/dist/workers/worker-kill.js +68 -20
- package/dist/workers/worker-profiles.d.ts +20 -0
- package/dist/workers/worker-profiles.js +22 -9
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* directly, because its state is the whole-run `WidgetState`, not one child's.
|
|
6
6
|
*/
|
|
7
7
|
import { getConfig } from '../config/config.js';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveModel } from '../shared/model-resolve.js';
|
|
9
9
|
/**
|
|
10
10
|
* The parent session's context window, or 0 when the model doesn't expose it.
|
|
11
11
|
*
|
|
@@ -53,12 +53,7 @@ export function contextWindowForGroup(ctx, group, cfg = getConfig()) {
|
|
|
53
53
|
* from the one it just checked.
|
|
54
54
|
*/
|
|
55
55
|
export function contextWindowForSpec(ctx, spec) {
|
|
56
|
-
|
|
57
|
-
return getParentContextWindow(ctx);
|
|
58
|
-
const parts = splitSpec(spec);
|
|
59
|
-
const found = parts && ctx.modelRegistry?.find(parts.provider, parts.id);
|
|
60
|
-
const window = found?.contextWindow ?? 0;
|
|
61
|
-
return window > 0 ? window : getParentContextWindow(ctx);
|
|
56
|
+
return resolveModel(ctx, spec)?.contextWindow || getParentContextWindow(ctx);
|
|
62
57
|
}
|
|
63
58
|
/**
|
|
64
59
|
* Fold a raw context snapshot into a display snapshot: prefer the child's own
|
|
@@ -5,97 +5,69 @@
|
|
|
5
5
|
import { updateTaskFrontMatter } from './task-io.js';
|
|
6
6
|
import { flashTerminalWidget } from './widget.js';
|
|
7
7
|
import { publishLifecycleNotice } from '../remote/bridge.js';
|
|
8
|
-
import {
|
|
8
|
+
import { ChildFailureError, USER_CANCELLED } from './child-runner.js';
|
|
9
|
+
import { streamStallCause } from '../shared/stream-watchdog.js';
|
|
9
10
|
// ─── Classifier ──────────────────────────────────────────────────────────────
|
|
11
|
+
const failed = (reason, flash, notify) => ({
|
|
12
|
+
state: 'failed',
|
|
13
|
+
reason,
|
|
14
|
+
flash,
|
|
15
|
+
notify,
|
|
16
|
+
level: 'error'
|
|
17
|
+
});
|
|
18
|
+
/**
|
|
19
|
+
* What one phase child failure says to the user. A switch over the cause, so a
|
|
20
|
+
* new arm cannot be added to `ChildFailure` without a notice.
|
|
21
|
+
*/
|
|
22
|
+
function classifyChildFailure(e) {
|
|
23
|
+
const f = e.failure;
|
|
24
|
+
switch (f.kind) {
|
|
25
|
+
case 'stalled':
|
|
26
|
+
return failed(`model_unreachable: ${e.message}`, 'model_unreachable', 'failed: model unreachable — restart the model, then resume.');
|
|
27
|
+
// The fix is in the SPEC, not the model, so the notify says which command.
|
|
28
|
+
case 'command-timeout':
|
|
29
|
+
return failed(e.message.slice(0, 200), 'command_timeout', `failed: \`${f.toolName}\` never returned on any attempt. Resume to bound it in VERIFY.`);
|
|
30
|
+
case 'loop':
|
|
31
|
+
return failed(`loop detected ${f.strikes}× in ${e.phase}`, 'loop_detected', `failed: ${e.phase} loop detected ${f.strikes}×. Resume to retry.`);
|
|
32
|
+
case 'leaked-tool-call':
|
|
33
|
+
return failed(`leaked tool call in ${e.phase}: ${f.text.trim()}`, 'leaked_tool_call', `failed: ${e.phase} wrote a tool call as text instead of running it — it never executed. Resume to retry.`);
|
|
34
|
+
case 'model-error':
|
|
35
|
+
case 'stream-stall': {
|
|
36
|
+
const cause = f.kind === 'model-error' ? f.cause : streamStallCause(f.idleMs);
|
|
37
|
+
return failed(`model_error in ${e.phase}: ${cause.slice(0, 160)}`, 'model_error', `failed: ${e.phase} — model error: ${cause.slice(0, 120)}. Restart the model, then resume.`);
|
|
38
|
+
}
|
|
39
|
+
case 'worker-timeout':
|
|
40
|
+
return failed(e.message.slice(0, 200), 'child_timeout', `failed: ${e.phase} ran out of time on every attempt. Resume to retry.`);
|
|
41
|
+
case 'aborted':
|
|
42
|
+
case 'exit':
|
|
43
|
+
case 'empty-answer':
|
|
44
|
+
return unreachable(e.message) ?? generic(e.message);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const generic = (msg) => failed(msg.slice(0, 200), msg.slice(0, 80), `failed: ${msg.slice(0, 120)}`);
|
|
48
|
+
/**
|
|
49
|
+
* A failure whose only evidence of a dead backend is an errno in its text: a
|
|
50
|
+
* child's stderr, the research phase's own network calls, a probe.
|
|
51
|
+
*/
|
|
52
|
+
function unreachable(msg) {
|
|
53
|
+
if (!/ECONNREFUSED|fetch failed|connect/i.test(msg))
|
|
54
|
+
return undefined;
|
|
55
|
+
return failed(`model_unreachable: ${msg.slice(0, 120)}`, 'model_unreachable', 'failed: model unreachable.');
|
|
56
|
+
}
|
|
10
57
|
export function classifyFailure(err, aborted) {
|
|
11
58
|
const msg = err instanceof Error ? err.message : String(err);
|
|
12
59
|
if (aborted || msg === USER_CANCELLED) {
|
|
13
60
|
return { state: 'cancelled', notify: 'cancelled.', level: 'warning' };
|
|
14
61
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// its message names no errno for that branch to match.
|
|
18
|
-
if (err instanceof BackendDownError) {
|
|
19
|
-
return {
|
|
20
|
-
state: 'failed',
|
|
21
|
-
reason: `model_unreachable: ${err.message}`,
|
|
22
|
-
flash: 'model_unreachable',
|
|
23
|
-
notify: 'failed: model unreachable — restart the model, then resume.',
|
|
24
|
-
level: 'error'
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
// The fix is in the SPEC, not the model, so the notify says which command.
|
|
28
|
-
if (err instanceof CommandTimeoutError) {
|
|
29
|
-
return {
|
|
30
|
-
state: 'failed',
|
|
31
|
-
reason: err.message.slice(0, 200),
|
|
32
|
-
flash: 'command_timeout',
|
|
33
|
-
notify: `failed: \`${err.kill.toolName}\` never returned on any attempt. `
|
|
34
|
-
+ `Resume to bound it in VERIFY.`,
|
|
35
|
-
level: 'error'
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
if (err instanceof LoopExhaustedError) {
|
|
39
|
-
return {
|
|
40
|
-
state: 'failed',
|
|
41
|
-
reason: `loop detected ${err.history.length}× in ${err.phase}`,
|
|
42
|
-
flash: 'loop_detected',
|
|
43
|
-
notify: `failed: ${err.phase} loop detected ${err.history.length}×. Resume to retry.`,
|
|
44
|
-
level: 'error'
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
if (err instanceof LeakedToolCallError) {
|
|
48
|
-
return {
|
|
49
|
-
state: 'failed',
|
|
50
|
-
reason: `leaked tool call in ${err.phase}: ${err.marker.trim()}`,
|
|
51
|
-
flash: 'leaked_tool_call',
|
|
52
|
-
notify: `failed: ${err.phase} wrote a tool call as text instead of running it — it never executed. Resume to retry.`,
|
|
53
|
-
level: 'error'
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
if (err instanceof ModelError) {
|
|
57
|
-
return {
|
|
58
|
-
state: 'failed',
|
|
59
|
-
reason: `model_error in ${err.phase}: ${err.cause.slice(0, 160)}`,
|
|
60
|
-
flash: 'model_error',
|
|
61
|
-
notify: `failed: ${err.phase} — model error: ${err.cause.slice(0, 120)}. Restart the model, then resume.`,
|
|
62
|
-
level: 'error'
|
|
63
|
-
};
|
|
64
|
-
}
|
|
62
|
+
if (err instanceof ChildFailureError)
|
|
63
|
+
return classifyChildFailure(err);
|
|
65
64
|
if (msg === 'no_verify_block') {
|
|
66
|
-
return
|
|
67
|
-
state: 'failed',
|
|
68
|
-
reason: 'no_verify_block',
|
|
69
|
-
flash: 'no_verify_block',
|
|
70
|
-
notify: 'failed: spec has no VERIFY block. Resume to edit and try again.',
|
|
71
|
-
level: 'error'
|
|
72
|
-
};
|
|
65
|
+
return failed('no_verify_block', 'no_verify_block', 'failed: spec has no VERIFY block. Resume to edit and try again.');
|
|
73
66
|
}
|
|
74
67
|
if (msg.startsWith('compose_invalid')) {
|
|
75
|
-
return {
|
|
76
|
-
state: 'failed',
|
|
77
|
-
reason: msg.slice(0, 200),
|
|
78
|
-
flash: 'compose_invalid',
|
|
79
|
-
notify: `failed: compose produced malformed spec (${msg.replace(/^compose_invalid:\s*/, '')}). Resume to retry.`,
|
|
80
|
-
level: 'error'
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
if (/ECONNREFUSED|fetch failed|connect/i.test(msg)) {
|
|
84
|
-
return {
|
|
85
|
-
state: 'failed',
|
|
86
|
-
reason: `model_unreachable: ${msg.slice(0, 120)}`,
|
|
87
|
-
flash: 'model_unreachable',
|
|
88
|
-
notify: 'failed: model unreachable.',
|
|
89
|
-
level: 'error'
|
|
90
|
-
};
|
|
68
|
+
return failed(msg.slice(0, 200), 'compose_invalid', `failed: compose produced malformed spec (${msg.replace(/^compose_invalid:\s*/, '')}). Resume to retry.`);
|
|
91
69
|
}
|
|
92
|
-
return
|
|
93
|
-
state: 'failed',
|
|
94
|
-
reason: msg.slice(0, 200),
|
|
95
|
-
flash: msg.slice(0, 80),
|
|
96
|
-
notify: `failed: ${msg.slice(0, 120)}`,
|
|
97
|
-
level: 'error'
|
|
98
|
-
};
|
|
70
|
+
return unreachable(msg) ?? generic(msg);
|
|
99
71
|
}
|
|
100
72
|
/**
|
|
101
73
|
* Persist, flash and announce a failure, and return the classification.
|
package/dist/task/gate-child.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* `runWorker` and the git helpers are injected, so the ordering, the trail lines
|
|
25
25
|
* and the throwing-child path are directly assertable.
|
|
26
26
|
*/
|
|
27
|
-
import { formatLoopHint } from './
|
|
27
|
+
import { formatLoopHint } from './loop-detector.js';
|
|
28
28
|
import { classifyEnforceChildFailure } from './enforce-guidelines.js';
|
|
29
29
|
/**
|
|
30
30
|
* What each kind may do. Adding a child is a row; it cannot be added without
|
|
@@ -42,6 +42,8 @@ export declare function armImplWidget(meta: ImplWidgetMeta, opts: {
|
|
|
42
42
|
}): void;
|
|
43
43
|
/** Tear down the widget and clear the armed slot (sticky/awaited path). */
|
|
44
44
|
export declare function disarmImplWidget(): void;
|
|
45
|
+
/** @internal Test seam: is a turn currently showing the widget? */
|
|
46
|
+
export declare function implWidgetArmed(): boolean;
|
|
45
47
|
/** Wire the agent-lifecycle handlers that drive the widget. Call once at setup.
|
|
46
48
|
* All three events are pi's own: `agent_start`, `tool_execution_start` and
|
|
47
49
|
* `agent_end` each have an `on()` overload, and ToolExecutionStartEvent carries
|
package/dist/task/impl-widget.js
CHANGED
|
@@ -107,6 +107,10 @@ export function disarmImplWidget() {
|
|
|
107
107
|
activeCtx = null;
|
|
108
108
|
lastLine = undefined;
|
|
109
109
|
}
|
|
110
|
+
/** @internal Test seam: is a turn currently showing the widget? */
|
|
111
|
+
export function implWidgetArmed() {
|
|
112
|
+
return armed !== null;
|
|
113
|
+
}
|
|
110
114
|
/** Wire the agent-lifecycle handlers that drive the widget. Call once at setup.
|
|
111
115
|
* All three events are pi's own: `agent_start`, `tool_execution_start` and
|
|
112
116
|
* `agent_end` each have an `on()` overload, and ToolExecutionStartEvent carries
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
*/
|
|
46
46
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
47
47
|
import { type GroupSetting } from '../config/reasoning.js';
|
|
48
|
+
import { type ModelContext, type PiModel } from '../shared/model-resolve.js';
|
|
48
49
|
import { type HoldStash } from './model-hold-stash.js';
|
|
49
50
|
/**
|
|
50
51
|
* The slice of the extension API this needs, named so tests can drive the
|
|
@@ -79,6 +80,16 @@ export interface ImplementationControls<H = unknown> {
|
|
|
79
80
|
thinking: ThinkingControl;
|
|
80
81
|
model: ModelControl<H>;
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* The live session's model as a {@link ModelControl}, for both the turn's hold
|
|
85
|
+
* and the crash restore at session_start.
|
|
86
|
+
*
|
|
87
|
+
* `current()` reads `ctx.model`, which is a live GETTER on the extension
|
|
88
|
+
* context (pi's `core/extensions/runner.js`), so a read after a set is the new
|
|
89
|
+
* value. `apply` is the caller's because the setter lives on `ExtensionAPI`,
|
|
90
|
+
* which the two callers hold differently.
|
|
91
|
+
*/
|
|
92
|
+
export declare function liveModelControl(ctx: ModelContext, apply: (handle: PiModel) => Promise<boolean>): ModelControl<PiModel>;
|
|
82
93
|
/**
|
|
83
94
|
* The whole hold: model, then thinking. Returns the release, which is async and
|
|
84
95
|
* idempotent. Always call it from a `finally`, never the happy path.
|
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { getConfig } from '../config/config.js';
|
|
2
2
|
import { MODEL_INHERIT } from '../config/group-models.js';
|
|
3
3
|
import { resolveReasoning } from '../config/reasoning.js';
|
|
4
|
+
import { resolveModel } from '../shared/model-resolve.js';
|
|
4
5
|
import { readHoldStash, writeHoldStash, clearHoldStash } from './model-hold-stash.js';
|
|
6
|
+
/**
|
|
7
|
+
* The live session's model as a {@link ModelControl}, for both the turn's hold
|
|
8
|
+
* and the crash restore at session_start.
|
|
9
|
+
*
|
|
10
|
+
* `current()` reads `ctx.model`, which is a live GETTER on the extension
|
|
11
|
+
* context (pi's `core/extensions/runner.js`), so a read after a set is the new
|
|
12
|
+
* value. `apply` is the caller's because the setter lives on `ExtensionAPI`,
|
|
13
|
+
* which the two callers hold differently.
|
|
14
|
+
*/
|
|
15
|
+
export function liveModelControl(ctx, apply) {
|
|
16
|
+
return {
|
|
17
|
+
current: () => {
|
|
18
|
+
const m = resolveModel(ctx, MODEL_INHERIT);
|
|
19
|
+
return m && { spec: m.spec, handle: m.handle };
|
|
20
|
+
},
|
|
21
|
+
resolve: spec => resolveModel(ctx, spec)?.handle,
|
|
22
|
+
apply
|
|
23
|
+
};
|
|
24
|
+
}
|
|
5
25
|
/**
|
|
6
26
|
* `before` is the level read BEFORE any model move; `applied` is what is really
|
|
7
27
|
* in force after both moves.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The implementation-turn bracket: one entry arms both the status widget and the
|
|
3
|
+
* runaway guard, one `leave` disarms both.
|
|
4
|
+
*
|
|
5
|
+
* The two modules keep their own lifecycle handlers, and they draw the turn
|
|
6
|
+
* boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
|
|
7
|
+
* over, the screen should say so), the guard survives until `agent_settled`
|
|
8
|
+
* (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
|
|
9
|
+
* they share is the caller's decision — when to arm, and on which paths to disarm
|
|
10
|
+
* — and a missed disarm on either one outlives the turn: commit ebac475 added a
|
|
11
|
+
* third disarm site for one path that was covered on the widget and not the guard.
|
|
12
|
+
*/
|
|
13
|
+
import { type ImplWidgetMeta } from './impl-widget.js';
|
|
14
|
+
/**
|
|
15
|
+
* `oneShot` true (fire-and-forget /task) lets each module's own settle handler
|
|
16
|
+
* disarm after the single turn; false (awaited /task-auto) keeps both armed
|
|
17
|
+
* across resume and steer turns until `leave` is called.
|
|
18
|
+
*
|
|
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 disarming a bracket entered since.
|
|
21
|
+
*/
|
|
22
|
+
export declare function enterImplementationTurn(meta: ImplWidgetMeta, opts: {
|
|
23
|
+
oneShot: boolean;
|
|
24
|
+
}): () => void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The implementation-turn bracket: one entry arms both the status widget and the
|
|
3
|
+
* runaway guard, one `leave` disarms both.
|
|
4
|
+
*
|
|
5
|
+
* The two modules keep their own lifecycle handlers, and they draw the turn
|
|
6
|
+
* boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
|
|
7
|
+
* over, the screen should say so), the guard survives until `agent_settled`
|
|
8
|
+
* (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
|
|
9
|
+
* they share is the caller's decision — when to arm, and on which paths to disarm
|
|
10
|
+
* — and a missed disarm on either one outlives the turn: commit ebac475 added a
|
|
11
|
+
* third disarm site for one path that was covered on the widget and not the guard.
|
|
12
|
+
*/
|
|
13
|
+
import { armImplWidget, disarmImplWidget } from './impl-widget.js';
|
|
14
|
+
import { armImplementationGuard, disarmImplementationGuard } from './implementation-guards.js';
|
|
15
|
+
/**
|
|
16
|
+
* `oneShot` true (fire-and-forget /task) lets each module's own settle handler
|
|
17
|
+
* disarm after the single turn; false (awaited /task-auto) keeps both armed
|
|
18
|
+
* across resume and steer turns until `leave` is called.
|
|
19
|
+
*
|
|
20
|
+
* `leave` is idempotent: a second call is a no-op, so a caller can put it in a
|
|
21
|
+
* `finally` and a `catch` without disarming a bracket entered since.
|
|
22
|
+
*/
|
|
23
|
+
export function enterImplementationTurn(meta, opts) {
|
|
24
|
+
armImplWidget(meta, opts);
|
|
25
|
+
armImplementationGuard(opts);
|
|
26
|
+
let left = false;
|
|
27
|
+
return () => {
|
|
28
|
+
if (left)
|
|
29
|
+
return;
|
|
30
|
+
left = true;
|
|
31
|
+
disarmImplWidget();
|
|
32
|
+
disarmImplementationGuard();
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -13,11 +13,9 @@
|
|
|
13
13
|
* Either pattern returns a LoopHit so the caller can kill the child and re-spawn
|
|
14
14
|
* with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* child-runner — and the failure is a TDZ ReferenceError on import order, which
|
|
20
|
-
* no compile step catches. This module imports nothing, so it cannot close one.
|
|
16
|
+
* This module imports nothing: worker-profiles.ts reads the tuning constants at
|
|
17
|
+
* module top level, and a dependency from here on any runner would close a cycle
|
|
18
|
+
* whose only symptom is a TDZ ReferenceError on import order.
|
|
21
19
|
*/
|
|
22
20
|
/** Recent tool calls the exact-repeat rule looks back over. */
|
|
23
21
|
export declare const LOOP_WINDOW = 20;
|
|
@@ -33,6 +31,14 @@ export interface LoopHit {
|
|
|
33
31
|
call: ToolCall;
|
|
34
32
|
count: number;
|
|
35
33
|
windowSize: number;
|
|
34
|
+
/**
|
|
35
|
+
* Set when the kill came from the whole-run StallDetector rather than this
|
|
36
|
+
* short-window detector, naming which of its two rules tripped
|
|
37
|
+
* (stall-detector.ts). Absent for an ordinary loop hit. Carried here so a
|
|
38
|
+
* stall rides the kill/restart plumbing the loop hit already has instead of
|
|
39
|
+
* needing a second channel.
|
|
40
|
+
*/
|
|
41
|
+
stall?: 'no-new-ground' | 'context-churn';
|
|
36
42
|
}
|
|
37
43
|
/**
|
|
38
44
|
* JSON.stringify with sorted object keys so {a:1,b:2} and {b:2,a:1} hash equal.
|
|
@@ -78,3 +84,5 @@ export declare class LoopDetector {
|
|
|
78
84
|
*/
|
|
79
85
|
private countRevisits;
|
|
80
86
|
}
|
|
87
|
+
/** The restart hint a re-spawned child gets after a loop kill: names the call. */
|
|
88
|
+
export declare function formatLoopHint(hit: LoopHit): string;
|
|
@@ -13,11 +13,9 @@
|
|
|
13
13
|
* Either pattern returns a LoopHit so the caller can kill the child and re-spawn
|
|
14
14
|
* with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* child-runner — and the failure is a TDZ ReferenceError on import order, which
|
|
20
|
-
* no compile step catches. This module imports nothing, so it cannot close one.
|
|
16
|
+
* This module imports nothing: worker-profiles.ts reads the tuning constants at
|
|
17
|
+
* module top level, and a dependency from here on any runner would close a cycle
|
|
18
|
+
* whose only symptom is a TDZ ReferenceError on import order.
|
|
21
19
|
*/
|
|
22
20
|
/** Recent tool calls the exact-repeat rule looks back over. */
|
|
23
21
|
export const LOOP_WINDOW = 20;
|
|
@@ -159,3 +157,11 @@ export class LoopDetector {
|
|
|
159
157
|
return revisits;
|
|
160
158
|
}
|
|
161
159
|
}
|
|
160
|
+
/** The restart hint a re-spawned child gets after a loop kill: names the call. */
|
|
161
|
+
export function formatLoopHint(hit) {
|
|
162
|
+
const argsStr = JSON.stringify(hit.call.args);
|
|
163
|
+
return (`[SYSTEM NOTE: Your prior attempt called ${hit.call.name}(${argsStr}) `
|
|
164
|
+
+ `${hit.count} times in the last ${hit.windowSize} tool calls — you appeared to be `
|
|
165
|
+
+ `stuck in a loop. Avoid repeating that exact call; if you've already seen its result, `
|
|
166
|
+
+ `work from memory or pick a different angle.]`);
|
|
167
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import { splitSpec } from '../config/group-models.js';
|
|
4
3
|
import { defaultModelRef } from '../shared/model-endpoint.js';
|
|
4
|
+
import { specOf } from '../shared/model-resolve.js';
|
|
5
5
|
import { stateFile } from '../shared/data-home.js';
|
|
6
|
-
import { restoreHeldModel } from './implementation-hold.js';
|
|
6
|
+
import { liveModelControl, restoreHeldModel } from './implementation-hold.js';
|
|
7
7
|
const stashPath = () => stateFile('model-hold.json');
|
|
8
8
|
export function readHoldStash() {
|
|
9
9
|
try {
|
|
@@ -47,23 +47,13 @@ export function clearHoldStash() {
|
|
|
47
47
|
*/
|
|
48
48
|
export function registerModelHoldRestore(pi) {
|
|
49
49
|
pi.on('session_start', (_event, ctx) => {
|
|
50
|
-
const model =
|
|
51
|
-
current: () => {
|
|
52
|
-
const m = ctx.model;
|
|
53
|
-
return m ? { spec: `${m.provider}/${m.id}`, handle: m } : undefined;
|
|
54
|
-
},
|
|
55
|
-
resolve: spec => {
|
|
56
|
-
const parts = splitSpec(spec);
|
|
57
|
-
return parts ? ctx.modelRegistry.find(parts.provider, parts.id) : undefined;
|
|
58
|
-
},
|
|
59
|
-
apply: handle => pi.setModel(handle)
|
|
60
|
-
};
|
|
50
|
+
const model = liveModelControl(ctx, handle => pi.setModel(handle));
|
|
61
51
|
// The saved default, read from the file the crash left wrong — not from
|
|
62
52
|
// `ctx.model`, which a `--model` flag or a resumed session can make say
|
|
63
53
|
// something else entirely.
|
|
64
54
|
const saved = () => {
|
|
65
55
|
const ref = defaultModelRef();
|
|
66
|
-
return ref &&
|
|
56
|
+
return ref && specOf(ref);
|
|
67
57
|
};
|
|
68
58
|
void restoreHeldModel(model, saved).catch(() => { });
|
|
69
59
|
});
|
|
@@ -21,16 +21,10 @@ import { type WidgetState } from './widget.js';
|
|
|
21
21
|
import { type RunTaskFn } from './gate-deps.js';
|
|
22
22
|
import { type GateDeps } from './task-gates.js';
|
|
23
23
|
import { type PhaseSeams } from './child-runner.js';
|
|
24
|
+
import type { PiModel } from '../shared/model-resolve.js';
|
|
24
25
|
import { type ImplementationControls } from './implementation-hold.js';
|
|
25
26
|
import { type RunEnd } from './run-end.js';
|
|
26
27
|
import { type SuperviseOptions } from './implementation-turn.js';
|
|
27
|
-
/**
|
|
28
|
-
* pi's own `Model`, named WITHOUT importing `@earendil-works/pi-ai` — which is
|
|
29
|
-
* neither a dependency, a devDependency nor a peerDependency of this package
|
|
30
|
-
* (see shared/model-endpoint.ts's header). The context already carries the type,
|
|
31
|
-
* so deriving it costs nothing and adds no edge to the dependency graph.
|
|
32
|
-
*/
|
|
33
|
-
type PiModel = NonNullable<ExtensionCommandContext['model']>;
|
|
34
28
|
/** Both halves of the implementation hold, over the live session. */
|
|
35
29
|
export declare function piImplementationControls(ctx: ExtensionCommandContext): ImplementationControls<PiModel>;
|
|
36
30
|
/**
|
|
@@ -235,4 +229,3 @@ export declare function runGatedTask(ctx: ExtensionCommandContext, cwd: string,
|
|
|
235
229
|
deps?: GateDeps;
|
|
236
230
|
}): Promise<void>;
|
|
237
231
|
export declare function registerTask(pi: ExtensionAPI): void;
|
|
238
|
-
export {};
|
|
@@ -25,8 +25,8 @@ import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parser
|
|
|
25
25
|
import { readTextFile } from '../shared/fs-text.js';
|
|
26
26
|
import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
|
|
27
27
|
import { startWidget } from './widget.js';
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
28
|
+
import { setupImplWidget } from './impl-widget.js';
|
|
29
|
+
import { enterImplementationTurn } from './implementation-scope.js';
|
|
30
30
|
import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
|
|
31
31
|
import { pushNotify } from '../remote/push.js';
|
|
32
32
|
import { getConfig } from '../config/config.js';
|
|
@@ -38,8 +38,7 @@ import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phanto
|
|
|
38
38
|
import { titleForDisplay } from './parsers.js';
|
|
39
39
|
import { USER_CANCELLED } from './child-runner.js';
|
|
40
40
|
import { cancelCheckpoint } from './cancel-points.js';
|
|
41
|
-
import {
|
|
42
|
-
import { holdImplementation } from './implementation-hold.js';
|
|
41
|
+
import { holdImplementation, liveModelControl } from './implementation-hold.js';
|
|
43
42
|
import { rearmCancelListener } from './cancel-input.js';
|
|
44
43
|
import { takeHeldInput } from './mid-run-input.js';
|
|
45
44
|
import { withRun, announceTerminal } from './run-bracket.js';
|
|
@@ -82,27 +81,12 @@ function piThinkingControl() {
|
|
|
82
81
|
}
|
|
83
82
|
/**
|
|
84
83
|
* The live session's model, as a {@link ModelControl} over pi's own `Model`.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* (pi's `core/extensions/runner.js`), so a read after a set is the new value.
|
|
88
|
-
* `resolve` goes through `find(provider, id)` — EXACT, deliberately stricter
|
|
89
|
-
* than pi's own CLI, which also substring-matches. We store a canonical
|
|
90
|
-
* `provider/id`, so exact is the only match that should ever count, and being
|
|
91
|
-
* stricter here can only cost us a hold we then decline to take.
|
|
84
|
+
* Before `registerTask(pi)` has run there is nothing to apply to, so the move
|
|
85
|
+
* reports failure and the hold declines — same degrade as the thinking half.
|
|
92
86
|
*/
|
|
93
87
|
function piModelControl(ctx) {
|
|
94
88
|
const api = piApi;
|
|
95
|
-
return
|
|
96
|
-
current: () => {
|
|
97
|
-
const m = ctx.model;
|
|
98
|
-
return m ? { spec: `${m.provider}/${m.id}`, handle: m } : undefined;
|
|
99
|
-
},
|
|
100
|
-
resolve: spec => {
|
|
101
|
-
const parts = splitSpec(spec);
|
|
102
|
-
return parts ? ctx.modelRegistry.find(parts.provider, parts.id) : undefined;
|
|
103
|
-
},
|
|
104
|
-
apply: async (handle) => (api ? api.setModel(handle) : false)
|
|
105
|
-
};
|
|
89
|
+
return liveModelControl(ctx, async (handle) => (api ? api.setModel(handle) : false));
|
|
106
90
|
}
|
|
107
91
|
/** Both halves of the implementation hold, over the live session. */
|
|
108
92
|
export function piImplementationControls(ctx) {
|
|
@@ -396,10 +380,7 @@ export class TaskRunner {
|
|
|
396
380
|
label: this._widgetState.label
|
|
397
381
|
};
|
|
398
382
|
if (this._sendSpec) {
|
|
399
|
-
|
|
400
|
-
// Same lifetime as the widget, and for the same reason: an awaited run
|
|
401
|
-
// spans resume and steer turns, a fire-and-forget one does not.
|
|
402
|
-
armImplementationGuard({ oneShot: !this._implAwaited });
|
|
383
|
+
const leave = enterImplementationTurn(meta, { oneShot: !this._implAwaited });
|
|
403
384
|
let delivered = false;
|
|
404
385
|
try {
|
|
405
386
|
await this._sendSpec(spec);
|
|
@@ -411,18 +392,15 @@ export class TaskRunner {
|
|
|
411
392
|
// run: pi's `prompt` rejects on a compaction already in progress, a
|
|
412
393
|
// missing model, or a failed auth. The next unrelated turn would
|
|
413
394
|
// inherit it, and this guard can end a turn outright.
|
|
414
|
-
if (this._implAwaited || !delivered)
|
|
415
|
-
|
|
416
|
-
disarmImplementationGuard();
|
|
417
|
-
}
|
|
395
|
+
if (this._implAwaited || !delivered)
|
|
396
|
+
leave();
|
|
418
397
|
}
|
|
419
398
|
return;
|
|
420
399
|
}
|
|
421
400
|
if (!piApi) {
|
|
422
401
|
throw new Error('extension not initialised (no ExtensionAPI captured)');
|
|
423
402
|
}
|
|
424
|
-
|
|
425
|
-
armImplementationGuard({ oneShot: true });
|
|
403
|
+
const leave = enterImplementationTurn(meta, { oneShot: true });
|
|
426
404
|
// Same reason as the awaited path's `delivered` flag: this send can throw
|
|
427
405
|
// SYNCHRONOUSLY — the loader gates every ExtensionAPI action behind
|
|
428
406
|
// `assertActive()` — and a guard left armed over a turn that never starts
|
|
@@ -436,8 +414,7 @@ export class TaskRunner {
|
|
|
436
414
|
piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
|
|
437
415
|
}
|
|
438
416
|
catch (e) {
|
|
439
|
-
|
|
440
|
-
disarmImplementationGuard();
|
|
417
|
+
leave();
|
|
441
418
|
throw e;
|
|
442
419
|
}
|
|
443
420
|
}
|
package/dist/task/phases.js
CHANGED
|
@@ -38,7 +38,7 @@ import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from '.
|
|
|
38
38
|
import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, writeOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
|
|
39
39
|
import { detachUnsatisfiableRequirements, claimPendingRequirements, unclaimedPendingRequirements, formatReassignActions } from './owned-freeze-reassign.js';
|
|
40
40
|
import { trackedSourceOracle } from './owned-freeze-conflict.js';
|
|
41
|
-
import { groupArgsForChild, runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED,
|
|
41
|
+
import { groupArgsForChild, runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED, ChildFailureError, isFatalChildCause } from './child-runner.js';
|
|
42
42
|
import { runResearchWorker, researchWorkerCacheHeading } from './research-worker.js';
|
|
43
43
|
import { SessionUI } from '../remote/bridge.js';
|
|
44
44
|
import { isYoloMode, yoloPickAutoAnswer } from './yolo.js';
|
|
@@ -308,7 +308,7 @@ export async function phaseVerifyTooling(deps, research) {
|
|
|
308
308
|
// degrade for a child that merely failed. A hung command is different: it
|
|
309
309
|
// cost the ceiling on every strike and says the SPEC named something
|
|
310
310
|
// unbounded, so it is the one cause worth a trail line rather than silence.
|
|
311
|
-
if (e instanceof
|
|
311
|
+
if (e instanceof ChildFailureError && e.failure.kind === 'command-timeout') {
|
|
312
312
|
deps.logDebug?.(`verify-tooling: ${e.message} — shipping the list unverified`);
|
|
313
313
|
}
|
|
314
314
|
return replaceToolingWithVerified(research, commands);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* takes, and that is not a property of the pathology — it is a property of the
|
|
7
7
|
* model, its sampler settings, the reasoning budget and the size of the document
|
|
8
8
|
* being read. Any of those moving turns the margin into a killer of good runs.
|
|
9
|
-
* `
|
|
9
|
+
* The `phase` profile arms no wall clock (worker-profiles.ts) for exactly that reason.
|
|
10
10
|
*
|
|
11
11
|
* WHAT REPLACES IT. Two bounds, both dimensionless — invariant to model speed,
|
|
12
12
|
* project size and reasoning budget:
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* takes, and that is not a property of the pathology — it is a property of the
|
|
7
7
|
* model, its sampler settings, the reasoning budget and the size of the document
|
|
8
8
|
* being read. Any of those moving turns the margin into a killer of good runs.
|
|
9
|
-
* `
|
|
9
|
+
* The `phase` profile arms no wall clock (worker-profiles.ts) for exactly that reason.
|
|
10
10
|
*
|
|
11
11
|
* WHAT REPLACES IT. Two bounds, both dimensionless — invariant to model speed,
|
|
12
12
|
* project size and reasoning budget:
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
* different fixes (`models.json` vs `/task-config`) and that line is already at
|
|
24
24
|
* its length budget.
|
|
25
25
|
*/
|
|
26
|
-
import type { ExtensionAPI
|
|
26
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
27
27
|
import { type ChildGroup } from '../config/groups.js';
|
|
28
|
+
import { type GroupModelSnapshot } from '../shared/model-resolve.js';
|
|
28
29
|
/** One cell that will not do what it says. */
|
|
29
30
|
export interface ModelProblem {
|
|
30
31
|
group: ChildGroup;
|
|
@@ -42,12 +43,8 @@ export interface ModelProblem {
|
|
|
42
43
|
*/
|
|
43
44
|
why: 'unresolved' | 'extension';
|
|
44
45
|
}
|
|
45
|
-
/** The
|
|
46
|
-
export
|
|
47
|
-
find: (provider: string, id: string) => unknown;
|
|
48
|
-
extensionProviders: ReadonlySet<string>;
|
|
49
|
-
}
|
|
50
|
-
export declare function findModelProblems(lookup: ModelLookup, specs: Readonly<Record<ChildGroup, string>>): ModelProblem[];
|
|
46
|
+
/** The cells worth a line, in group order. */
|
|
47
|
+
export declare function modelProblems(snapshot: Readonly<Record<ChildGroup, GroupModelSnapshot>>): ModelProblem[];
|
|
51
48
|
/**
|
|
52
49
|
* The hint line, or null when every cell is fine.
|
|
53
50
|
*
|
|
@@ -58,12 +55,3 @@ export declare function formatModelWarning(problems: readonly ModelProblem[]): s
|
|
|
58
55
|
export declare function registerModelWarning(pi: ExtensionAPI,
|
|
59
56
|
/** Injected by tests, which must not depend on the developer's saved config. */
|
|
60
57
|
readSpecs?: () => Readonly<Record<ChildGroup, string>>): void;
|
|
61
|
-
/**
|
|
62
|
-
* Answer, once, what every model cell resolves to — and leave both answers where
|
|
63
|
-
* the code that has no `ctx` can read them.
|
|
64
|
-
*
|
|
65
|
-
* `unresolved` only feeds the argv drop: an extension-provided model may well
|
|
66
|
-
* work, since children load explicitly whitelisted extensions, and dropping its
|
|
67
|
-
* flag would break a config that is merely fragile.
|
|
68
|
-
*/
|
|
69
|
-
export declare function resolveModelCells(ctx: ExtensionContext, specs: Readonly<Record<ChildGroup, string>>): ModelProblem[];
|