@mjasnikovs/pi-task 0.38.30 → 0.38.32
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/config/config.d.ts +16 -2
- package/dist/config/config.js +7 -2
- package/dist/config/group-args.d.ts +52 -0
- package/dist/config/group-args.js +110 -0
- package/dist/config/group-models.d.ts +88 -0
- package/dist/config/group-models.js +117 -0
- package/dist/config/groups.d.ts +76 -0
- package/dist/config/groups.js +110 -0
- package/dist/config/option-picker.d.ts +42 -0
- package/dist/config/option-picker.js +73 -0
- package/dist/config/reasoning.d.ts +22 -63
- package/dist/config/reasoning.js +37 -108
- package/dist/config/register.d.ts +98 -12
- package/dist/config/register.js +228 -23
- package/dist/index.js +4 -0
- package/dist/remote/push.js +1 -7
- package/dist/shared/command-watchdog.d.ts +63 -0
- package/dist/shared/command-watchdog.js +87 -0
- package/dist/shared/data-home.d.ts +8 -0
- package/dist/shared/data-home.js +14 -0
- package/dist/shared/model-endpoint.d.ts +53 -0
- package/dist/shared/model-endpoint.js +98 -2
- package/dist/shared/reasoning-capability.d.ts +25 -5
- package/dist/shared/reasoning-capability.js +18 -9
- package/dist/task/auto-orchestrator.js +14 -3
- package/dist/task/child-runner.d.ts +92 -15
- package/dist/task/child-runner.js +303 -66
- package/dist/task/context-usage.d.ts +46 -0
- package/dist/task/context-usage.js +41 -0
- package/dist/task/failure-classifier.js +24 -1
- package/dist/task/gate-child.d.ts +15 -4
- package/dist/task/gate-child.js +2 -2
- package/dist/task/gate-deps.js +7 -2
- package/dist/task/implementation-guards.d.ts +26 -0
- package/dist/task/implementation-guards.js +177 -0
- package/dist/task/implementation-hold.d.ts +118 -0
- package/dist/task/implementation-hold.js +165 -0
- package/dist/task/implementation-turn.d.ts +5 -0
- package/dist/task/implementation-turn.js +12 -1
- package/dist/task/loop-detector.d.ts +18 -0
- package/dist/task/loop-detector.js +22 -2
- package/dist/task/model-hold-stash.d.ts +43 -0
- package/dist/task/model-hold-stash.js +70 -0
- package/dist/task/orchestrator.d.ts +18 -5
- package/dist/task/orchestrator.js +63 -6
- package/dist/task/phases.js +18 -5
- package/dist/task/research-worker.d.ts +2 -2
- package/dist/task/research-worker.js +1 -1
- package/dist/workers/docs-core.js +2 -2
- package/dist/workers/docs-lookup.d.ts +4 -3
- package/dist/workers/docs-lookup.js +1 -1
- package/dist/workers/fetch-core.js +2 -2
- package/dist/workers/focused-extractor.d.ts +6 -4
- package/dist/workers/focused-extractor.js +17 -5
- package/dist/workers/index.js +2 -0
- package/dist/workers/model-warning.d.ts +69 -0
- package/dist/workers/model-warning.js +113 -0
- package/dist/workers/pi-worker-core.d.ts +9 -38
- package/dist/workers/pi-worker-core.js +8 -86
- package/dist/workers/pi-worker-docs.js +2 -2
- package/dist/workers/pi-worker.js +4 -4
- package/dist/workers/reasoning-warning.d.ts +17 -9
- package/dist/workers/reasoning-warning.js +69 -22
- package/dist/workers/single-read-guard.d.ts +6 -6
- package/dist/workers/single-read-guard.js +8 -8
- package/dist/workers/worker-profiles.d.ts +11 -3
- package/dist/workers/worker-profiles.js +33 -1
- package/package.json +1 -1
- package/dist/config/reasoning-args.d.ts +0 -23
- package/dist/config/reasoning-args.js +0 -28
- package/dist/task/implementation-thinking.d.ts +0 -56
- package/dist/task/implementation-thinking.js +0 -32
|
@@ -5,13 +5,36 @@
|
|
|
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 { LoopExhaustedError, LeakedToolCallError, ModelError, USER_CANCELLED } from './child-runner.js';
|
|
8
|
+
import { BackendDownError, CommandTimeoutError, LoopExhaustedError, LeakedToolCallError, ModelError, USER_CANCELLED } from './child-runner.js';
|
|
9
9
|
// ─── Classifier ──────────────────────────────────────────────────────────────
|
|
10
10
|
export function classifyFailure(err, aborted) {
|
|
11
11
|
const msg = err instanceof Error ? err.message : String(err);
|
|
12
12
|
if (aborted || msg === USER_CANCELLED) {
|
|
13
13
|
return { state: 'cancelled', notify: 'cancelled.', level: 'warning' };
|
|
14
14
|
}
|
|
15
|
+
// Classified by TYPE, above the message-sniffing branch below: this is the one
|
|
16
|
+
// case where the probe positively established the endpoint did not answer, and
|
|
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
|
+
}
|
|
15
38
|
if (err instanceof LoopExhaustedError) {
|
|
16
39
|
return {
|
|
17
40
|
state: 'failed',
|
|
@@ -75,15 +75,26 @@ export interface GateChildDeps {
|
|
|
75
75
|
/** Hung-stream bound; the probe-based stall guard cannot supply it. */
|
|
76
76
|
streamInactivityMs: number;
|
|
77
77
|
/**
|
|
78
|
-
* The resolved
|
|
79
|
-
*
|
|
78
|
+
* The resolved argv fragment for the `gate` group — its model and its
|
|
79
|
+
* thinking level — or `[]` to inherit both.
|
|
80
80
|
*
|
|
81
81
|
* REQUIRED, like its two neighbours above: gate-child takes resolved config
|
|
82
82
|
* values and gate-deps supplies them. Optional-with-a-default would let a new
|
|
83
|
-
* gate wiring silently run
|
|
83
|
+
* gate wiring silently run on a model nobody chose, which is the failure the
|
|
84
84
|
* whole profile feature exists to end.
|
|
85
85
|
*/
|
|
86
|
-
|
|
86
|
+
groupArgs: readonly string[];
|
|
87
|
+
/**
|
|
88
|
+
* The context window of the model THESE children run on, for the churn rule.
|
|
89
|
+
*
|
|
90
|
+
* Not `status.parentContextWindow`, which is a per-RUN value and a run spans
|
|
91
|
+
* several groups. The direction matters: a window smaller than the child's
|
|
92
|
+
* real one makes churn fire early and kill a healthy child, so this follows
|
|
93
|
+
* the `gate` group's model and falls back to the host's.
|
|
94
|
+
*
|
|
95
|
+
* REQUIRED, like its neighbours: gate-child takes resolved config values.
|
|
96
|
+
*/
|
|
97
|
+
contextWindow: number;
|
|
87
98
|
/**
|
|
88
99
|
* The live widget state this child feeds and its loader reads. SHARED with
|
|
89
100
|
* the caller — the verify gate's own loader reads the same status while this
|
package/dist/task/gate-child.js
CHANGED
|
@@ -98,7 +98,7 @@ export function makeGateChild(deps) {
|
|
|
98
98
|
commandTimeoutMs: deps.commandTimeoutMs,
|
|
99
99
|
streamInactivityMs: deps.streamInactivityMs
|
|
100
100
|
},
|
|
101
|
-
|
|
101
|
+
groupArgs: deps.groupArgs,
|
|
102
102
|
// A discarded attempt is otherwise invisible: the returned
|
|
103
103
|
// exitCode/text describe the FINAL attempt, so a child that
|
|
104
104
|
// burned two attempts reads exactly like one that ran clean.
|
|
@@ -125,7 +125,7 @@ export function makeGateChild(deps) {
|
|
|
125
125
|
// stream — what `--mode json` emits — carries token counts but
|
|
126
126
|
// no context window; `contextWindow` appears nowhere in
|
|
127
127
|
// agent-session.d.ts.
|
|
128
|
-
contextWindow: deps.
|
|
128
|
+
contextWindow: deps.contextWindow
|
|
129
129
|
});
|
|
130
130
|
}
|
|
131
131
|
finally {
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -48,7 +48,8 @@ import { assessRunnerGlobs, runnerGlobVerifyFindings } from './runner-globs.js';
|
|
|
48
48
|
import { captureGitState, reconcileGitState } from './git-state-guard.js';
|
|
49
49
|
import { runWorker } from '../workers/pi-worker-core.js';
|
|
50
50
|
import { getConfig } from '../config/config.js';
|
|
51
|
-
import {
|
|
51
|
+
import { groupChildArgs } from '../config/group-args.js';
|
|
52
|
+
import { contextWindowForGroup } from './context-usage.js';
|
|
52
53
|
import { makeDebugAppender } from './debug-log.js';
|
|
53
54
|
import { startAutoLoader } from './widget.js';
|
|
54
55
|
import { ChildStatus } from './child-status.js';
|
|
@@ -602,7 +603,11 @@ export function buildGateDeps(params) {
|
|
|
602
603
|
streamInactivityMs: getConfig().streamInactivityMs,
|
|
603
604
|
// Read per gateChild() call, like its two neighbours, so a
|
|
604
605
|
// /task-config change lands on the next gate without a restart.
|
|
605
|
-
|
|
606
|
+
groupArgs: groupChildArgs('gate'),
|
|
607
|
+
// `gateCtx` rather than the run's own window: a run spans several
|
|
608
|
+
// groups, and a window smaller than the child's real one makes the
|
|
609
|
+
// churn rule fire early and kill a healthy child.
|
|
610
|
+
contextWindow: contextWindowForGroup(gateCtx, 'gate'),
|
|
606
611
|
status,
|
|
607
612
|
runWorker,
|
|
608
613
|
makeDebugAppender,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
/** `oneShot` mirrors the impl widget's split: fire-and-forget lets the settle
|
|
3
|
+
* event disarm; an awaited run spans resume/steer turns and disarms in its finally. */
|
|
4
|
+
export declare function armImplementationGuard(opts: {
|
|
5
|
+
oneShot: boolean;
|
|
6
|
+
}): void;
|
|
7
|
+
export declare function disarmImplementationGuard(): void;
|
|
8
|
+
/** @internal Test seam: is a turn currently guarded? */
|
|
9
|
+
export declare function implementationGuardArmed(): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Present tense, unlike `formatLoopHint`, which addresses a re-spawned child
|
|
12
|
+
* about an attempt that does not exist here.
|
|
13
|
+
*
|
|
14
|
+
* It claims nothing about the call's RESULT, which this hook fires too early to
|
|
15
|
+
* see, and it does not offer "change the call" — that is an escape, not advice:
|
|
16
|
+
* one altered byte is a new key and a clean slate on both counters.
|
|
17
|
+
*/
|
|
18
|
+
export declare function blockedCallReason(toolName: string, count: number): string;
|
|
19
|
+
/** The reason on the final block, which also ends the turn. */
|
|
20
|
+
export declare function terminalCallReason(): string;
|
|
21
|
+
export declare function consumeGuardTermination(): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Inert until armed. Registering ANY `tool_call` handler switches on pi's
|
|
24
|
+
* `beforeToolCall` for every call in the session, so the armed check comes first.
|
|
25
|
+
*/
|
|
26
|
+
export declare function registerImplementationGuards(pi: ExtensionAPI): void;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { LoopDetector, loopKey, LOOP_THRESHOLD, LOOP_WINDOW, MAX_LOOP_RESTARTS } from './loop-detector.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runaway guard for the IMPLEMENTATION TURN — the one model surface with none.
|
|
4
|
+
*
|
|
5
|
+
* MEASURED: one turn ran 5h16m and 6,760 tool calls, alternating two
|
|
6
|
+
* byte-identical bash commands 3,300 times each with its output frozen and zero
|
|
7
|
+
* edits. The command watchdog is per call (each took ~2.5s), the stream was never
|
|
8
|
+
* silent, and MAX_COMPACTION_RESUMES counts only compactions that PARK at idle,
|
|
9
|
+
* while all 18 of these were inside the turn. The two detectors are built only in
|
|
10
|
+
* the CHILD spawn paths, and this turn runs in the user's own session.
|
|
11
|
+
*
|
|
12
|
+
* IT BLOCKS RATHER THAN KILLS because there is no re-spawn here — the argument
|
|
13
|
+
* single-read-extension.ts already makes: "detect-and-kill only re-spawns a model
|
|
14
|
+
* that deterministically re-thrashes". pi's ctx.abort() would also empty the
|
|
15
|
+
* queued-message list into the user's editor. That also raises the bar on false
|
|
16
|
+
* positives: a `gate` child killed by mistake costs one attempt of three, this
|
|
17
|
+
* costs the user their turn.
|
|
18
|
+
*
|
|
19
|
+
* WHY PROGRESS IS READ OFF THE CALL, NOT THE RESULT. The gate profile pairs its
|
|
20
|
+
* LoopDetector with a StallDetector, which judges results. That cannot work here:
|
|
21
|
+
* pi's edit tool returns the constant `Successfully replaced N block(s) in <path>.`
|
|
22
|
+
* and puts the diff in `details`, not `content`, so every real edit to one file is
|
|
23
|
+
* byte-identical result text and scores as dead ground. An edit's ARGUMENTS carry
|
|
24
|
+
* the progress its result throws away, so an edit is what resets the window.
|
|
25
|
+
*
|
|
26
|
+
* Known blind spots, all deliberate: a bash-driven mutation (`sed -i`, `>`,
|
|
27
|
+
* `git apply`) does not reset; one varying `write` per iteration buys unlimited
|
|
28
|
+
* immunity; a repeat cycle of LOOP_WINDOW/LOOP_THRESHOLD or longer never fills the
|
|
29
|
+
* window; polling a booting server with an identical curl trips at five; and
|
|
30
|
+
* schema-invalid calls never reach this hook at all, since pi validates first.
|
|
31
|
+
*/
|
|
32
|
+
/** Tool names that mutate the tree. pi ships exactly seven core tools, and
|
|
33
|
+
* pi-task's own four (pi-worker, -search, -fetch, -docs) are all read-only. */
|
|
34
|
+
const MUTATING_TOOLS = new Set(['edit', 'write']);
|
|
35
|
+
/**
|
|
36
|
+
* Path-revisit is OFF for both detectors (that is the Infinity). MEASURED: at the
|
|
37
|
+
* default threshold, six DISTINCT edits to one file trip the path rule, because
|
|
38
|
+
* an edit names a `file_path` and no `limit`, so the first one sets the
|
|
39
|
+
* high-water mark and every later one scores as already-covered ground. Six edits
|
|
40
|
+
* to one file is the most ordinary thing an implementation turn does. mx5
|
|
41
|
+
* TASK_0002 is the same lesson from the other side: the rule killed an enforce
|
|
42
|
+
* child that was editing one file as its job.
|
|
43
|
+
*/
|
|
44
|
+
function freshDetector() {
|
|
45
|
+
return new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD, Number.POSITIVE_INFINITY);
|
|
46
|
+
}
|
|
47
|
+
/** The armed turn's state, or null outside one. One slot: one task runs at a time. */
|
|
48
|
+
let armed = null;
|
|
49
|
+
/** Built, never spread from the previous state: a leaked `terminating` would
|
|
50
|
+
* block every call for the rest of an awaited run. */
|
|
51
|
+
function freshArmedState(oneShot) {
|
|
52
|
+
return {
|
|
53
|
+
loop: freshDetector(),
|
|
54
|
+
edits: freshDetector(),
|
|
55
|
+
strikes: new Map(),
|
|
56
|
+
terminating: false,
|
|
57
|
+
oneShot
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** `oneShot` mirrors the impl widget's split: fire-and-forget lets the settle
|
|
61
|
+
* event disarm; an awaited run spans resume/steer turns and disarms in its finally. */
|
|
62
|
+
export function armImplementationGuard(opts) {
|
|
63
|
+
armed = freshArmedState(opts.oneShot);
|
|
64
|
+
}
|
|
65
|
+
export function disarmImplementationGuard() {
|
|
66
|
+
armed = null;
|
|
67
|
+
}
|
|
68
|
+
/** @internal Test seam: is a turn currently guarded? */
|
|
69
|
+
export function implementationGuardArmed() {
|
|
70
|
+
return armed !== null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Present tense, unlike `formatLoopHint`, which addresses a re-spawned child
|
|
74
|
+
* about an attempt that does not exist here.
|
|
75
|
+
*
|
|
76
|
+
* It claims nothing about the call's RESULT, which this hook fires too early to
|
|
77
|
+
* see, and it does not offer "change the call" — that is an escape, not advice:
|
|
78
|
+
* one altered byte is a new key and a clean slate on both counters.
|
|
79
|
+
*/
|
|
80
|
+
export function blockedCallReason(toolName, count) {
|
|
81
|
+
return (`Blocked: this is the ${count}th identical ${toolName} call in this turn. `
|
|
82
|
+
+ `Use what you already have, or do something different, then continue the task.`);
|
|
83
|
+
}
|
|
84
|
+
/** The reason on the final block, which also ends the turn. */
|
|
85
|
+
export function terminalCallReason() {
|
|
86
|
+
return (`Blocked: this turn repeated one call past every warning, so it is being stopped `
|
|
87
|
+
+ `here. Nothing further will run.`);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* One-shot: the guard ended a turn, and nothing in the session state says so.
|
|
91
|
+
*
|
|
92
|
+
* `terminate` lets the agent loop finish normally — the last assistant message
|
|
93
|
+
* keeps `stopReason: "toolUse"`, so `classifyTurnEnd` reads `'stop'` and the run
|
|
94
|
+
* reports a clean finish over work that was cut off mid-task. Verified against a
|
|
95
|
+
* live model: a real guard-terminated turn ends exactly that way. Same shape as
|
|
96
|
+
* `consumeWatchdogAbort`, and consumed for the same reason — one reader, then it
|
|
97
|
+
* is gone.
|
|
98
|
+
*/
|
|
99
|
+
let terminatedTurn = false;
|
|
100
|
+
export function consumeGuardTermination() {
|
|
101
|
+
const hit = terminatedTurn;
|
|
102
|
+
terminatedTurn = false;
|
|
103
|
+
return hit;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Inert until armed. Registering ANY `tool_call` handler switches on pi's
|
|
107
|
+
* `beforeToolCall` for every call in the session, so the armed check comes first.
|
|
108
|
+
*/
|
|
109
|
+
export function registerImplementationGuards(pi) {
|
|
110
|
+
pi.on('tool_call', event => {
|
|
111
|
+
const state = armed;
|
|
112
|
+
if (!state)
|
|
113
|
+
return;
|
|
114
|
+
try {
|
|
115
|
+
// Every call, whatever it is: pi terminates only when EVERY finalized
|
|
116
|
+
// result in the batch carries the flag (agent-loop.js
|
|
117
|
+
// shouldTerminateToolBatch). The batch that trips it has already
|
|
118
|
+
// finalized its earlier calls without it and so survives; the next one
|
|
119
|
+
// ends. One batch, and it is the only bound this path has.
|
|
120
|
+
if (state.terminating) {
|
|
121
|
+
return { block: true, terminate: true, reason: terminalCallReason() };
|
|
122
|
+
}
|
|
123
|
+
const call = { name: event.toolName, args: event.input };
|
|
124
|
+
const mutating = MUTATING_TOOLS.has(event.toolName);
|
|
125
|
+
const hit = mutating ? state.edits.record(call) : state.loop.record(call);
|
|
126
|
+
if (!hit) {
|
|
127
|
+
if (mutating) {
|
|
128
|
+
state.loop = freshDetector();
|
|
129
|
+
// Strikes go with the window. Keeping them made a LATER episode
|
|
130
|
+
// terminate on its first hit, skipping both warnings, because an
|
|
131
|
+
// earlier one had part-spent the budget. MEASURED over 494 real
|
|
132
|
+
// turns: this loses no catch — the incident still ends at call
|
|
133
|
+
// 173 of 6,760, both live-model loops still end — and drops one
|
|
134
|
+
// termination of a turn that was editing between episodes.
|
|
135
|
+
// A determined model is still bounded: three hits inside ONE
|
|
136
|
+
// episode terminate, which is the runaway shape (it makes no
|
|
137
|
+
// edits at all).
|
|
138
|
+
state.strikes.clear();
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const key = loopKey(call);
|
|
143
|
+
const strikes = (state.strikes.get(key) ?? 0) + 1;
|
|
144
|
+
state.strikes.set(key, strikes);
|
|
145
|
+
// Blocking alone does not stop a determined model: nothing prevents the
|
|
146
|
+
// next identical call.
|
|
147
|
+
if (strikes > MAX_LOOP_RESTARTS) {
|
|
148
|
+
state.terminating = true;
|
|
149
|
+
terminatedTurn = true;
|
|
150
|
+
return { block: true, terminate: true, reason: terminalCallReason() };
|
|
151
|
+
}
|
|
152
|
+
return { block: true, reason: blockedCallReason(event.toolName, hit.count) };
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// pi does not guard this hook, and a throw here would block a
|
|
156
|
+
// legitimate call. A broken guard must cost nothing.
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
// NOT `agent_end`, which fires again for every auto-retry, every threshold
|
|
161
|
+
// compaction and every queued message — pi drives those with `agent.continue()`,
|
|
162
|
+
// each a fresh agent loop. The measured runaway compacted 18 times INSIDE its
|
|
163
|
+
// turn, so a one-shot disarm on agent_end would have retired the guard after the
|
|
164
|
+
// first ~375 of its 6,760 calls. `agent_settled` is the boundary that means what
|
|
165
|
+
// this needs: no retry, compaction or queued continuation left to run.
|
|
166
|
+
pi.on('agent_settled', () => {
|
|
167
|
+
if (!armed)
|
|
168
|
+
return;
|
|
169
|
+
if (armed.oneShot)
|
|
170
|
+
disarmImplementationGuard();
|
|
171
|
+
// An awaited run spans resume and steer turns. Counters are per TURN, so a
|
|
172
|
+
// fresh one starts clean rather than inheriting the last one's strikes.
|
|
173
|
+
else
|
|
174
|
+
armed = freshArmedState(false);
|
|
175
|
+
});
|
|
176
|
+
pi.on('session_shutdown', disarmImplementationGuard);
|
|
177
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hold the host session on the `implementation` group's model AND thinking level
|
|
3
|
+
* for one implementation turn, then put both back.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS GROUP IS NOT LIKE THE OTHERS
|
|
6
|
+
* -------------------------------------
|
|
7
|
+
* Every other group runs in a child process, so its settings are two argv flags
|
|
8
|
+
* (`groupChildArgs` in config/group-args.ts) that die with the child. The
|
|
9
|
+
* implementation turn runs in the USER'S OWN session (orchestrator.ts `sendSpec`
|
|
10
|
+
* -> `sendUserMessage` -> `superviseImplementation`), so the only levers are
|
|
11
|
+
* `pi.setThinkingLevel` and `pi.setModel`, and both are session-global.
|
|
12
|
+
*
|
|
13
|
+
* WHAT pi DOES that this has to survive:
|
|
14
|
+
*
|
|
15
|
+
* 1. BOTH PERSIST. `setThinkingLevel` writes `defaultThinkingLevel` and
|
|
16
|
+
* `setModel` writes `defaultProvider`/`defaultModel`, into pi's global
|
|
17
|
+
* `~/.pi/agent/settings.json`. Without the restore, running one task would
|
|
18
|
+
* silently rewrite the user's global defaults — and since children carry no
|
|
19
|
+
* `-m` and resolve exactly those defaults, it would re-point every future
|
|
20
|
+
* child in every project. That makes `release()` load-bearing, not tidy-up.
|
|
21
|
+
* 2. THEY CLAMP. A model with no reasoning support offers only `off`, so asking
|
|
22
|
+
* for `medium` yields `off`. The restore writes back what was READ after
|
|
23
|
+
* setting, never what was asked for, or a clamp would ratchet the stored
|
|
24
|
+
* default further every run.
|
|
25
|
+
* 3. `setModel` RE-CLAMPS THINKING as part of switching. So the level must be
|
|
26
|
+
* read before any model move, and written after the model is back.
|
|
27
|
+
* 4. THE USER CAN CHANGE EITHER MID-TURN — `shift+tab` cycles thinking. We
|
|
28
|
+
* detect it by comparing the live value at release against what we applied:
|
|
29
|
+
* if it has moved, somebody else moved it, and we leave it alone.
|
|
30
|
+
*
|
|
31
|
+
* We compare rather than subscribe because the extension API's `on(...)` returns
|
|
32
|
+
* `void` — there is no unsubscribe handle — so a per-turn listener could only
|
|
33
|
+
* ever be added, never removed.
|
|
34
|
+
*
|
|
35
|
+
* WHAT THIS COSTS, so nobody has to rediscover it
|
|
36
|
+
* -----------------------------------------------
|
|
37
|
+
* A model switch re-bills the whole prompt. pi counts that deliberately —
|
|
38
|
+
* `core/cache-stats.js` says "Model switches are NOT exempt: they re-bill the
|
|
39
|
+
* full prompt and should be counted" — and prints `Cache miss after model
|
|
40
|
+
* switch: N tokens re-billed` once the miss clears 20k tokens or $0.10, which an
|
|
41
|
+
* implementation prompt does. It happens twice: once acquiring, once releasing.
|
|
42
|
+
* On a local server that is a full prompt reprocess, not a bill. This is why the
|
|
43
|
+
* cell ships `inherit`, and why the target-equals-current degrade below is the
|
|
44
|
+
* main guard rather than an optimisation.
|
|
45
|
+
*/
|
|
46
|
+
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
47
|
+
import { type GroupSetting } from '../config/reasoning.js';
|
|
48
|
+
import { type HoldStash } from './model-hold-stash.js';
|
|
49
|
+
/**
|
|
50
|
+
* The slice of the extension API this needs, named so tests can drive the
|
|
51
|
+
* hold-and-restore with a fake object instead of a live pi session.
|
|
52
|
+
*/
|
|
53
|
+
export interface ThinkingControl {
|
|
54
|
+
get(): ThinkingLevel;
|
|
55
|
+
set(level: ThinkingLevel): void;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The model half, generic in the HANDLE so tests can use a literal.
|
|
59
|
+
*
|
|
60
|
+
* `current()` returns the spec AND the handle: the comparison is on a plain
|
|
61
|
+
* string, and the restore uses the handle captured at acquire rather than
|
|
62
|
+
* re-resolving against a registry that may have moved underneath us.
|
|
63
|
+
*
|
|
64
|
+
* `apply` may return `false` OR REJECT. pi's `setModel` returns false only when
|
|
65
|
+
* `hasConfiguredAuth` — a cached snapshot Set — is false; it then calls the
|
|
66
|
+
* session's own `setModel`, which awaits a live `checkAuth` and throws when the
|
|
67
|
+
* two disagree, as they do for an expired OAuth token. Callers here treat a
|
|
68
|
+
* throw and a `false` identically, because nothing useful differs between them.
|
|
69
|
+
*/
|
|
70
|
+
export interface ModelControl<H = unknown> {
|
|
71
|
+
current(): {
|
|
72
|
+
spec: string;
|
|
73
|
+
handle: H;
|
|
74
|
+
} | undefined;
|
|
75
|
+
resolve(spec: string): H | undefined;
|
|
76
|
+
apply(handle: H): Promise<boolean>;
|
|
77
|
+
}
|
|
78
|
+
export interface ImplementationControls<H = unknown> {
|
|
79
|
+
thinking: ThinkingControl;
|
|
80
|
+
model: ModelControl<H>;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The whole hold: model, then thinking. Returns the release, which is async and
|
|
84
|
+
* idempotent. Always call it from a `finally`, never the happy path.
|
|
85
|
+
*
|
|
86
|
+
* ONE function rather than two composable holds, because two independent holds
|
|
87
|
+
* acquired in the wrong order fail SILENTLY — `setModel` re-clamps thinking, so
|
|
88
|
+
* a thinking hold taken first is erased and a thinking restore taken last is
|
|
89
|
+
* clamped by the wrong model's ladder. A composition that can only be assembled
|
|
90
|
+
* one way belongs in one function.
|
|
91
|
+
*/
|
|
92
|
+
export declare function holdImplementation<H>(controls: ImplementationControls<H>, setting?: GroupSetting, spec?: string, stash?: HoldStash): Promise<() => Promise<void>>;
|
|
93
|
+
/**
|
|
94
|
+
* Put back a model a crashed session left applied. Runs at `session_start`.
|
|
95
|
+
*
|
|
96
|
+
* THE GUARDS are the whole design, because this runs in a session that knows
|
|
97
|
+
* nothing about the one that crashed. Four cases, and only the last writes:
|
|
98
|
+
*
|
|
99
|
+
* 1. pi's saved default is still the note's `before` — the file is already
|
|
100
|
+
* right. Either a live hold has written its note but not yet switched, or a
|
|
101
|
+
* crash landed in that same gap. Decline, and KEEP the note: clearing here
|
|
102
|
+
* is what would let an unrelated session start delete a live hold's only
|
|
103
|
+
* crash record, in the millisecond before it applies.
|
|
104
|
+
* 2. the saved default is neither value — somebody moved on. Clear, decline.
|
|
105
|
+
* 3. the saved default matches, but THIS session is on a different model — it
|
|
106
|
+
* was launched with an explicit `--model`, or resumed onto one. Restoring
|
|
107
|
+
* would silently override a choice made on the command line. Decline, and
|
|
108
|
+
* keep the note so a later ordinary start still repairs the file.
|
|
109
|
+
* 4. everything agrees. Restore, and clear.
|
|
110
|
+
*
|
|
111
|
+
* The note is also cleared on a failed restore: one that cannot happen must not
|
|
112
|
+
* re-fire on every subsequent startup.
|
|
113
|
+
*
|
|
114
|
+
* Thinking is deliberately NOT restored here. `setModel` re-clamps it to the
|
|
115
|
+
* model we are restoring TO, which is the level that model was running at
|
|
116
|
+
* before the crashed session touched anything.
|
|
117
|
+
*/
|
|
118
|
+
export declare function restoreHeldModel<H>(model: ModelControl<H>, savedDefaultSpec: () => string | undefined, stash?: HoldStash): Promise<'restored' | 'declined' | 'nothing'>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { getConfig } from '../config/config.js';
|
|
2
|
+
import { MODEL_INHERIT } from '../config/group-models.js';
|
|
3
|
+
import { resolveReasoning } from '../config/reasoning.js';
|
|
4
|
+
import { readHoldStash, writeHoldStash, clearHoldStash } from './model-hold-stash.js';
|
|
5
|
+
/**
|
|
6
|
+
* `before` is the level read BEFORE any model move; `applied` is what is really
|
|
7
|
+
* in force after both moves.
|
|
8
|
+
*
|
|
9
|
+
* `inherit` writes nothing but still RECORDS, because the model switch may have
|
|
10
|
+
* moved the level on its own. `applied === before` means nothing moved, and then
|
|
11
|
+
* there is nothing to restore — a write there would be a settings.json write for
|
|
12
|
+
* no reason.
|
|
13
|
+
*/
|
|
14
|
+
function acquireThinking(control, setting, before) {
|
|
15
|
+
if (setting !== 'inherit')
|
|
16
|
+
control.set(setting);
|
|
17
|
+
// Post-clamp, so a model that cannot do `medium` does not leave us believing
|
|
18
|
+
// it is at `medium` and treating the user's later change as our own.
|
|
19
|
+
const applied = control.get();
|
|
20
|
+
return applied === before ? undefined : { before, applied };
|
|
21
|
+
}
|
|
22
|
+
const userMovedThinking = (control, hold) => control.get() !== hold.applied;
|
|
23
|
+
/**
|
|
24
|
+
* The whole hold: model, then thinking. Returns the release, which is async and
|
|
25
|
+
* idempotent. Always call it from a `finally`, never the happy path.
|
|
26
|
+
*
|
|
27
|
+
* ONE function rather than two composable holds, because two independent holds
|
|
28
|
+
* acquired in the wrong order fail SILENTLY — `setModel` re-clamps thinking, so
|
|
29
|
+
* a thinking hold taken first is erased and a thinking restore taken last is
|
|
30
|
+
* clamped by the wrong model's ladder. A composition that can only be assembled
|
|
31
|
+
* one way belongs in one function.
|
|
32
|
+
*/
|
|
33
|
+
export async function holdImplementation(controls, setting = resolveReasoning('implementation', getConfig()), spec = getConfig().groupModels.implementation, stash = { read: readHoldStash, write: writeHoldStash, clear: clearHoldStash }) {
|
|
34
|
+
const { thinking, model } = controls;
|
|
35
|
+
// BEFORE any model move: `setModel` re-clamps, so this is the only moment
|
|
36
|
+
// the pre-hold level is readable.
|
|
37
|
+
const beforeThinking = thinking.get();
|
|
38
|
+
const modelHold = await acquireModel(model, spec, stash);
|
|
39
|
+
// A model move that was ASKED FOR and failed no-ops the whole hold. Running
|
|
40
|
+
// the implementation turn on the wrong model at the right level is worse
|
|
41
|
+
// than running it exactly as it ran last week.
|
|
42
|
+
if (modelHold === 'failed')
|
|
43
|
+
return async () => { };
|
|
44
|
+
// A MODEL move alone moves the level, even with the thinking cell on
|
|
45
|
+
// `inherit`: pi's `setModel` re-clamps to the target's ladder and PERSISTS
|
|
46
|
+
// the result. So the thinking hold is taken whenever either half moved
|
|
47
|
+
// something, not only when a level was asked for — otherwise a session at
|
|
48
|
+
// `high` switched onto an off/medium model is left globally at `medium`
|
|
49
|
+
// with nothing to put it back, which is the one thing release() exists for.
|
|
50
|
+
const thinkingHold = setting === 'inherit' && modelHold === undefined ?
|
|
51
|
+
undefined
|
|
52
|
+
: acquireThinking(thinking, setting, beforeThinking);
|
|
53
|
+
let released = false;
|
|
54
|
+
return async () => {
|
|
55
|
+
if (released)
|
|
56
|
+
return;
|
|
57
|
+
released = true;
|
|
58
|
+
// Read the thinking comparison BEFORE restoring the model. A read taken
|
|
59
|
+
// after it is post-clamp, and the mid-turn-change detector then answers
|
|
60
|
+
// wrongly in both directions.
|
|
61
|
+
const moved = thinkingHold !== undefined && userMovedThinking(thinking, thinkingHold);
|
|
62
|
+
if (modelHold !== undefined) {
|
|
63
|
+
try {
|
|
64
|
+
await model.apply(modelHold.before);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// A failed model restore is still followed by the thinking
|
|
68
|
+
// restore. Restoring what we can beats restoring nothing.
|
|
69
|
+
}
|
|
70
|
+
stash.clear();
|
|
71
|
+
}
|
|
72
|
+
// LAST. Writing `before` while still on the target model has pi clamp it
|
|
73
|
+
// to the TARGET's ladder, and the model restore then re-clamps from that
|
|
74
|
+
// already-wrong value.
|
|
75
|
+
if (thinkingHold !== undefined && !moved)
|
|
76
|
+
thinking.set(thinkingHold.before);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `undefined` = no move was needed or possible, and today's behaviour stands.
|
|
81
|
+
* `'failed'` = a move was asked for and did not happen, which voids the hold.
|
|
82
|
+
*/
|
|
83
|
+
async function acquireModel(model, spec, stash) {
|
|
84
|
+
if (spec === MODEL_INHERIT)
|
|
85
|
+
return undefined;
|
|
86
|
+
const cur = model.current();
|
|
87
|
+
if (!cur)
|
|
88
|
+
return undefined;
|
|
89
|
+
// Not an optimisation. `setDefaultModelAndProvider` runs unconditionally
|
|
90
|
+
// inside pi's `setModel`, so a redundant call rewrites the user's global
|
|
91
|
+
// default, appends a model change to their session and re-bills the whole
|
|
92
|
+
// prompt as a cache miss — all to arrive where we already were.
|
|
93
|
+
if (cur.spec === spec)
|
|
94
|
+
return undefined;
|
|
95
|
+
const handle = model.resolve(spec);
|
|
96
|
+
// Model gone, or its provider unauthed. The session hint names it; the turn
|
|
97
|
+
// runs where it already was.
|
|
98
|
+
if (handle === undefined)
|
|
99
|
+
return undefined;
|
|
100
|
+
// Written BEFORE the apply, so a crash between the two costs an unnecessary
|
|
101
|
+
// restore attempt rather than a missed one.
|
|
102
|
+
stash.write({ before: cur.spec, applied: spec });
|
|
103
|
+
try {
|
|
104
|
+
if (await model.apply(handle))
|
|
105
|
+
return { before: cur.handle, beforeSpec: cur.spec, appliedSpec: spec };
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Identical to `false`: see ModelControl.apply.
|
|
109
|
+
}
|
|
110
|
+
stash.clear();
|
|
111
|
+
return 'failed';
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Put back a model a crashed session left applied. Runs at `session_start`.
|
|
115
|
+
*
|
|
116
|
+
* THE GUARDS are the whole design, because this runs in a session that knows
|
|
117
|
+
* nothing about the one that crashed. Four cases, and only the last writes:
|
|
118
|
+
*
|
|
119
|
+
* 1. pi's saved default is still the note's `before` — the file is already
|
|
120
|
+
* right. Either a live hold has written its note but not yet switched, or a
|
|
121
|
+
* crash landed in that same gap. Decline, and KEEP the note: clearing here
|
|
122
|
+
* is what would let an unrelated session start delete a live hold's only
|
|
123
|
+
* crash record, in the millisecond before it applies.
|
|
124
|
+
* 2. the saved default is neither value — somebody moved on. Clear, decline.
|
|
125
|
+
* 3. the saved default matches, but THIS session is on a different model — it
|
|
126
|
+
* was launched with an explicit `--model`, or resumed onto one. Restoring
|
|
127
|
+
* would silently override a choice made on the command line. Decline, and
|
|
128
|
+
* keep the note so a later ordinary start still repairs the file.
|
|
129
|
+
* 4. everything agrees. Restore, and clear.
|
|
130
|
+
*
|
|
131
|
+
* The note is also cleared on a failed restore: one that cannot happen must not
|
|
132
|
+
* re-fire on every subsequent startup.
|
|
133
|
+
*
|
|
134
|
+
* Thinking is deliberately NOT restored here. `setModel` re-clamps it to the
|
|
135
|
+
* model we are restoring TO, which is the level that model was running at
|
|
136
|
+
* before the crashed session touched anything.
|
|
137
|
+
*/
|
|
138
|
+
export async function restoreHeldModel(model, savedDefaultSpec, stash = { read: readHoldStash, write: writeHoldStash, clear: clearHoldStash }) {
|
|
139
|
+
const note = stash.read();
|
|
140
|
+
if (!note)
|
|
141
|
+
return 'nothing';
|
|
142
|
+
const saved = savedDefaultSpec();
|
|
143
|
+
if (saved === note.before)
|
|
144
|
+
return 'declined';
|
|
145
|
+
if (saved !== note.applied) {
|
|
146
|
+
stash.clear();
|
|
147
|
+
return 'declined';
|
|
148
|
+
}
|
|
149
|
+
if (model.current()?.spec !== note.applied)
|
|
150
|
+
return 'declined';
|
|
151
|
+
const handle = model.resolve(note.before);
|
|
152
|
+
if (handle === undefined) {
|
|
153
|
+
stash.clear();
|
|
154
|
+
return 'declined';
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return (await model.apply(handle)) ? 'restored' : 'declined';
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return 'declined';
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
stash.clear();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -96,6 +96,8 @@ export interface SteerWatchdogDeps {
|
|
|
96
96
|
export interface ImplementationTurnDeps {
|
|
97
97
|
/** The live session entries — the only thing the classifier reads. */
|
|
98
98
|
entries: () => ReadonlyArray<SessionEntryLike>;
|
|
99
|
+
/** Test seam over the module-level one-shot; the real reader is the default. */
|
|
100
|
+
consumeGuardTermination?: () => boolean;
|
|
99
101
|
/** Queue a follow-up user turn on the (idle) session. */
|
|
100
102
|
send: (text: string) => Promise<void>;
|
|
101
103
|
/** Wait for the session to go idle again. */
|
|
@@ -139,6 +141,9 @@ export declare const CONTINUE_AFTER_COMPACTION: string;
|
|
|
139
141
|
* lets the verify gate and `/task-auto-resume` catch any leftover incompleteness.
|
|
140
142
|
*/
|
|
141
143
|
export declare const MAX_COMPACTION_RESUMES = 20;
|
|
144
|
+
/** How a guard-stopped turn is reported. Named so a caller can tell it from a
|
|
145
|
+
* provider error: the fix is a different task, not a retry of this one. */
|
|
146
|
+
export declare const GUARD_TERMINATED = "the runaway guard stopped this turn: one tool call was repeated past every warning";
|
|
142
147
|
/**
|
|
143
148
|
* Resume an implementation turn that went idle at a threshold-compaction boundary.
|
|
144
149
|
* The runtime compacts and parks at idle without auto-continuing; we send a
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { SessionUI } from '../remote/bridge.js';
|
|
19
19
|
import { consumeWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
|
|
20
|
+
import { consumeGuardTermination } from './implementation-guards.js';
|
|
20
21
|
const isAssistant = (e) => e.message !== undefined && e.message.role === 'assistant';
|
|
21
22
|
/** Index of the last assistant message and of the last compaction boundary. */
|
|
22
23
|
function tailPositions(entries) {
|
|
@@ -160,6 +161,9 @@ export const CONTINUE_AFTER_COMPACTION = 'Your context was automatically compact
|
|
|
160
161
|
* lets the verify gate and `/task-auto-resume` catch any leftover incompleteness.
|
|
161
162
|
*/
|
|
162
163
|
export const MAX_COMPACTION_RESUMES = 20;
|
|
164
|
+
/** How a guard-stopped turn is reported. Named so a caller can tell it from a
|
|
165
|
+
* provider error: the fix is a different task, not a retry of this one. */
|
|
166
|
+
export const GUARD_TERMINATED = 'the runaway guard stopped this turn: one tool call was repeated past every warning';
|
|
163
167
|
/**
|
|
164
168
|
* Resume an implementation turn that went idle at a threshold-compaction boundary.
|
|
165
169
|
* The runtime compacts and parks at idle without auto-continuing; we send a
|
|
@@ -260,6 +264,13 @@ export async function superviseWith(deps) {
|
|
|
260
264
|
const interrupted = await steerUntilDone(deps);
|
|
261
265
|
// A user-declined steer (interrupted) is its own paused path; otherwise
|
|
262
266
|
// inspect how the turn actually ended.
|
|
263
|
-
|
|
267
|
+
// The runaway guard ends a turn WITHOUT an error stopReason, so classifyTurnEnd
|
|
268
|
+
// reads `'stop'` and the caller would verify a half-done implementation and
|
|
269
|
+
// re-deliver to a model that deterministically re-thrashes. Consumed here
|
|
270
|
+
// because this is the one place that reports how the turn really ended.
|
|
271
|
+
const guardEnded = deps.consumeGuardTermination?.() ?? consumeGuardTermination();
|
|
272
|
+
const error = interrupted ? undefined
|
|
273
|
+
: guardEnded ? GUARD_TERMINATED
|
|
274
|
+
: turnErrorMessage(deps.entries());
|
|
264
275
|
return { interrupted, error, resumes };
|
|
265
276
|
}
|