@mjasnikovs/pi-task 0.38.31 → 0.39.0
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 +70 -0
- package/dist/config/option-picker.js +113 -0
- package/dist/config/reasoning.d.ts +26 -64
- package/dist/config/reasoning.js +31 -115
- package/dist/config/register.d.ts +144 -24
- package/dist/config/register.js +345 -56
- package/dist/index.js +2 -0
- package/dist/remote/push.js +1 -7
- 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/child-runner.d.ts +19 -16
- package/dist/task/child-runner.js +64 -36
- package/dist/task/context-usage.d.ts +46 -0
- package/dist/task/context-usage.js +41 -0
- 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-hold.d.ts +118 -0
- package/dist/task/implementation-hold.js +165 -0
- 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 +36 -4
- package/dist/task/phases.js +2 -2
- 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 +4 -3
- package/dist/workers/focused-extractor.js +5 -4
- 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 +7 -7
- package/dist/workers/pi-worker-core.js +4 -3
- 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/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
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* the planning and gate children; the single-task widget (TaskRunner) calls it
|
|
5
5
|
* directly, because its state is the whole-run `WidgetState`, not one child's.
|
|
6
6
|
*/
|
|
7
|
+
import { getConfig } from '../config/config.js';
|
|
8
|
+
import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
|
|
7
9
|
/**
|
|
8
10
|
* The parent session's context window, or 0 when the model doesn't expose it.
|
|
9
11
|
*
|
|
@@ -19,6 +21,45 @@
|
|
|
19
21
|
export function getParentContextWindow(ctx) {
|
|
20
22
|
return ctx.model?.contextWindow ?? 0;
|
|
21
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* The window for the model ONE GROUP's children will actually run on.
|
|
26
|
+
*
|
|
27
|
+
* This number drives the widget and, more importantly, `StallDetector`'s
|
|
28
|
+
* context-churn rule, and the two error directions are not symmetric. A parent
|
|
29
|
+
* window LARGER than the child's makes churn fire late — degraded, and the
|
|
30
|
+
* no-new-ground rule still covers it. A parent window SMALLER makes churn fire
|
|
31
|
+
* early and KILL A HEALTHY CHILD. A big-context research model under a small
|
|
32
|
+
* host model is a real false positive, which is why this exists at all.
|
|
33
|
+
*
|
|
34
|
+
* For the same reason there is no `min(parent, group)`: that would import the
|
|
35
|
+
* dangerous direction on purpose.
|
|
36
|
+
*
|
|
37
|
+
* `inherit`, an unresolvable spec, or a model with no declared window all return
|
|
38
|
+
* exactly `getParentContextWindow(ctx)` — byte-identical to the behaviour before
|
|
39
|
+
* per-group models existed.
|
|
40
|
+
*
|
|
41
|
+
* Callers WITHOUT a ctx read `groupWindow` from config/group-args.ts instead,
|
|
42
|
+
* which the session pass fills from this. One producer, so the two views cannot
|
|
43
|
+
* describe different models.
|
|
44
|
+
*/
|
|
45
|
+
export function contextWindowForGroup(ctx, group, cfg = getConfig()) {
|
|
46
|
+
return contextWindowForSpec(ctx, cfg.groupModels[group]);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The same answer for a spec the caller already has.
|
|
50
|
+
*
|
|
51
|
+
* The session pass needs this: it walks an INJECTED spec table, and reaching for
|
|
52
|
+
* `getConfig()` here would let the window it stores describe a different model
|
|
53
|
+
* from the one it just checked.
|
|
54
|
+
*/
|
|
55
|
+
export function contextWindowForSpec(ctx, spec) {
|
|
56
|
+
if (spec === MODEL_INHERIT)
|
|
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);
|
|
62
|
+
}
|
|
22
63
|
/**
|
|
23
64
|
* Fold a raw context snapshot into a display snapshot: prefer the child's own
|
|
24
65
|
* contextWindow, else the last known one, else the parent session's; then derive
|
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The crash half of the implementation hold.
|
|
3
|
+
*
|
|
4
|
+
* `release()` runs in a `finally`, and a `finally` does not run for SIGKILL, a
|
|
5
|
+
* segfault, or the power going out. An implementation turn is measured in hours.
|
|
6
|
+
* Without this, one of those leaves pi's GLOBAL `defaultModel` pointing at the
|
|
7
|
+
* implementation model — and because children carry no `-m` and resolve that
|
|
8
|
+
* same default, every child of every future run, in every project, quietly
|
|
9
|
+
* follows it until the user notices.
|
|
10
|
+
*
|
|
11
|
+
* So acquire leaves a note on disk and release removes it. A later session
|
|
12
|
+
* finds the note and puts the model back.
|
|
13
|
+
*/
|
|
14
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
15
|
+
/**
|
|
16
|
+
* Both halves of the switch, as `provider/id` strings.
|
|
17
|
+
*
|
|
18
|
+
* `applied` is what makes the restore safe to run in a second, unrelated
|
|
19
|
+
* session: it is the value we are allowed to overwrite, and nothing else.
|
|
20
|
+
*/
|
|
21
|
+
export interface StashRecord {
|
|
22
|
+
before: string;
|
|
23
|
+
applied: string;
|
|
24
|
+
}
|
|
25
|
+
/** The seam, so the hold's tests never touch a real home directory. */
|
|
26
|
+
export interface HoldStash {
|
|
27
|
+
read(): StashRecord | undefined;
|
|
28
|
+
write(record: StashRecord): void;
|
|
29
|
+
clear(): void;
|
|
30
|
+
}
|
|
31
|
+
export declare function readHoldStash(): StashRecord | undefined;
|
|
32
|
+
export declare function writeHoldStash(record: StashRecord): void;
|
|
33
|
+
export declare function clearHoldStash(): void;
|
|
34
|
+
/**
|
|
35
|
+
* Register the crash restore. Runs once per `session_start`, before any task.
|
|
36
|
+
*
|
|
37
|
+
* It reaches for `pi.setModel` rather than writing `settings.json` itself:
|
|
38
|
+
* pi-task has no sanctioned way to write that file, and inventing one would fork
|
|
39
|
+
* the format. Everything it can go wrong on — no registry, a model that has
|
|
40
|
+
* since vanished, a rejected auth check — ends the same way, with the note
|
|
41
|
+
* dropped, because a restore that cannot happen must not re-fire forever.
|
|
42
|
+
*/
|
|
43
|
+
export declare function registerModelHoldRestore(pi: ExtensionAPI): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { splitSpec } from '../config/group-models.js';
|
|
4
|
+
import { defaultModelRef } from '../shared/model-endpoint.js';
|
|
5
|
+
import { stateFile } from '../shared/data-home.js';
|
|
6
|
+
import { restoreHeldModel } from './implementation-hold.js';
|
|
7
|
+
const stashPath = () => stateFile('model-hold.json');
|
|
8
|
+
export function readHoldStash() {
|
|
9
|
+
try {
|
|
10
|
+
const j = JSON.parse(fs.readFileSync(stashPath(), 'utf8'));
|
|
11
|
+
if (typeof j.before !== 'string' || typeof j.applied !== 'string')
|
|
12
|
+
return undefined;
|
|
13
|
+
if (j.before === '' || j.applied === '')
|
|
14
|
+
return undefined;
|
|
15
|
+
return { before: j.before, applied: j.applied };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function writeHoldStash(record) {
|
|
22
|
+
try {
|
|
23
|
+
const p = stashPath();
|
|
24
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
25
|
+
fs.writeFileSync(p, JSON.stringify(record));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A stash we cannot write costs us the crash restore, not the turn.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function clearHoldStash() {
|
|
32
|
+
try {
|
|
33
|
+
fs.rmSync(stashPath(), { force: true });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* already gone */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Register the crash restore. Runs once per `session_start`, before any task.
|
|
41
|
+
*
|
|
42
|
+
* It reaches for `pi.setModel` rather than writing `settings.json` itself:
|
|
43
|
+
* pi-task has no sanctioned way to write that file, and inventing one would fork
|
|
44
|
+
* the format. Everything it can go wrong on — no registry, a model that has
|
|
45
|
+
* since vanished, a rejected auth check — ends the same way, with the note
|
|
46
|
+
* dropped, because a restore that cannot happen must not re-fire forever.
|
|
47
|
+
*/
|
|
48
|
+
export function registerModelHoldRestore(pi) {
|
|
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
|
+
};
|
|
61
|
+
// The saved default, read from the file the crash left wrong — not from
|
|
62
|
+
// `ctx.model`, which a `--model` flag or a resumed session can make say
|
|
63
|
+
// something else entirely.
|
|
64
|
+
const saved = () => {
|
|
65
|
+
const ref = defaultModelRef();
|
|
66
|
+
return ref && `${ref.provider}/${ref.id}`;
|
|
67
|
+
};
|
|
68
|
+
void restoreHeldModel(model, saved).catch(() => { });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -21,9 +21,18 @@ 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
|
|
24
|
+
import { type ImplementationControls } from './implementation-hold.js';
|
|
25
25
|
import { type RunEnd } from './run-end.js';
|
|
26
26
|
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
|
+
/** Both halves of the implementation hold, over the live session. */
|
|
35
|
+
export declare function piImplementationControls(ctx: ExtensionCommandContext): ImplementationControls<PiModel>;
|
|
27
36
|
/**
|
|
28
37
|
* Everything one TaskRunner needs, as one object. The runner is the shared core
|
|
29
38
|
* under `runSingleTask` (and so under /task-auto's per-task loop), so this is the
|
|
@@ -163,11 +172,14 @@ export interface RunSingleTaskOptions extends Pick<TaskRunnerOptions, 'resumeId'
|
|
|
163
172
|
*/
|
|
164
173
|
notifyFinish?: boolean;
|
|
165
174
|
/**
|
|
166
|
-
* How the implementation turn's thinking level
|
|
167
|
-
* to the live pi session; injectable so the
|
|
168
|
-
* with no real session to restore.
|
|
175
|
+
* How the implementation turn's MODEL and thinking level are read and
|
|
176
|
+
* written. Defaults to the live pi session; injectable so the
|
|
177
|
+
* hold-and-restore is assertable with no real session to restore.
|
|
178
|
+
*
|
|
179
|
+
* One object rather than two, because the two must be acquired and released
|
|
180
|
+
* in one order and a caller handed two seams could supply half of one.
|
|
169
181
|
*/
|
|
170
|
-
|
|
182
|
+
implementationControls?: ImplementationControls<never>;
|
|
171
183
|
}
|
|
172
184
|
export interface RunSingleTaskResult {
|
|
173
185
|
taskId: string;
|
|
@@ -223,3 +235,4 @@ export declare function runGatedTask(ctx: ExtensionCommandContext, cwd: string,
|
|
|
223
235
|
deps?: GateDeps;
|
|
224
236
|
}): Promise<void>;
|
|
225
237
|
export declare function registerTask(pi: ExtensionAPI): void;
|
|
238
|
+
export {};
|
|
@@ -38,7 +38,8 @@ 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 {
|
|
41
|
+
import { splitSpec } from '../config/group-models.js';
|
|
42
|
+
import { holdImplementation } from './implementation-hold.js';
|
|
42
43
|
import { rearmCancelListener } from './cancel-input.js';
|
|
43
44
|
import { takeHeldInput } from './mid-run-input.js';
|
|
44
45
|
import { withRun, announceTerminal } from './run-bracket.js';
|
|
@@ -74,7 +75,38 @@ function piThinkingControl() {
|
|
|
74
75
|
const api = piApi;
|
|
75
76
|
if (!api)
|
|
76
77
|
return { get: () => 'off', set: () => { } };
|
|
77
|
-
return {
|
|
78
|
+
return {
|
|
79
|
+
get: () => api.getThinkingLevel(),
|
|
80
|
+
set: (level) => api.setThinkingLevel(level)
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The live session's model, as a {@link ModelControl} over pi's own `Model`.
|
|
85
|
+
*
|
|
86
|
+
* `current()` reads `ctx.model`, which is a live GETTER on the extension context
|
|
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.
|
|
92
|
+
*/
|
|
93
|
+
function piModelControl(ctx) {
|
|
94
|
+
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
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** Both halves of the implementation hold, over the live session. */
|
|
108
|
+
export function piImplementationControls(ctx) {
|
|
109
|
+
return { thinking: piThinkingControl(), model: piModelControl(ctx) };
|
|
78
110
|
}
|
|
79
111
|
// ─── TaskRunner class ────────────────────────────────────────────────────────
|
|
80
112
|
/** Encapsulates the full lifecycle of a single pi-task run. */
|
|
@@ -487,7 +519,7 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
487
519
|
// The autofix re-runner (gateRunTask) re-enters runSingleTask
|
|
488
520
|
// and so re-enters this closure, which is why the hold lives
|
|
489
521
|
// here rather than at either call site.
|
|
490
|
-
const release =
|
|
522
|
+
const release = await holdImplementation(opts.implementationControls ?? piImplementationControls(newCtx));
|
|
491
523
|
try {
|
|
492
524
|
// Queue-or-run: naming a delivery mode means pi's
|
|
493
525
|
// "Agent is already processing" throw is unreachable here.
|
|
@@ -507,7 +539,7 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
507
539
|
}
|
|
508
540
|
}
|
|
509
541
|
finally {
|
|
510
|
-
release();
|
|
542
|
+
await release();
|
|
511
543
|
}
|
|
512
544
|
},
|
|
513
545
|
seams: opts.seams,
|
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 {
|
|
41
|
+
import { groupArgsForChild, runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED, CommandTimeoutError, 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';
|
|
@@ -654,7 +654,7 @@ export async function phaseResearch(deps, refined) {
|
|
|
654
654
|
// questions, so one shared `research` cell is the tempting shape —
|
|
655
655
|
// but the four cells do not ship identical, so sharing one would
|
|
656
656
|
// silently change three of them. See config/reasoning.ts.
|
|
657
|
-
|
|
657
|
+
groupArgsFor: groupArgsForChild,
|
|
658
658
|
logDebug: deps.logDebug,
|
|
659
659
|
onChildOutput: deps.onChildOutput,
|
|
660
660
|
record: recordWorker,
|