@getpipher/armory-fleet 0.14.0 → 0.16.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 +38 -9
- package/package.json +1 -1
- package/src/index.ts +39 -4
- package/src/panel/fleet-panel.ts +10 -18
- package/src/runtime/async-runner.ts +36 -17
- package/src/scheduling/scheduler.ts +4 -0
- package/src/tiers/builtin.ts +14 -5
- package/src/tiers/resolve.ts +14 -0
- package/src/tools/subagent.ts +2 -2
package/README.md
CHANGED
|
@@ -123,22 +123,22 @@ export const meta = {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
phase('Plan')
|
|
126
|
-
const plan = await agent('Plan this feature: ' + args.task, { tier: '
|
|
126
|
+
const plan = await agent('Plan this feature: ' + args.task, { tier: 'economy' })
|
|
127
127
|
|
|
128
128
|
phase('Implement')
|
|
129
|
-
const impl = await agent(`Implement the plan:\n${plan}`, { tier: '
|
|
129
|
+
const impl = await agent(`Implement the plan:\n${plan}`, { tier: 'standard' })
|
|
130
130
|
|
|
131
131
|
phase('Review')
|
|
132
132
|
const angles = ['security', 'performance', 'correctness']
|
|
133
133
|
const reviews = await parallel(
|
|
134
|
-
angles.map((a) => () => agent(`Review the implementation for ${a} issues.`, { tier: '
|
|
134
|
+
angles.map((a) => () => agent(`Review the implementation for ${a} issues.`, { tier: 'economy' })),
|
|
135
135
|
)
|
|
136
136
|
|
|
137
137
|
// gate: revise the synthesis until it passes a validator
|
|
138
138
|
const synthesis = await gate(
|
|
139
139
|
async (_feedback, n) => n === 0
|
|
140
|
-
? agent(`Synthesize ${reviews.length} reviews.`, { tier: '
|
|
141
|
-
: agent('Revise synthesis per feedback.', { tier: '
|
|
140
|
+
? agent(`Synthesize ${reviews.length} reviews.`, { tier: 'economy' })
|
|
141
|
+
: agent('Revise synthesis per feedback.', { tier: 'economy' }),
|
|
142
142
|
(v) => typeof v === 'string' && v.length > 200 ? { ok: true } : { ok: false, feedback: 'more detail' },
|
|
143
143
|
{ attempts: 3 },
|
|
144
144
|
)
|
|
@@ -236,9 +236,21 @@ Composite helpers (`src/workflows/helpers/`) — usable from any workflow:
|
|
|
236
236
|
|
|
237
237
|
| Tier | Models | Cost cap | Context floor |
|
|
238
238
|
|---|---|---|---|
|
|
239
|
-
| `economy` | `
|
|
240
|
-
| `standard` | `
|
|
241
|
-
| `frontier` | `
|
|
239
|
+
| `economy` | `inherit` | — | — |
|
|
240
|
+
| `standard` | `inherit` | — | — |
|
|
241
|
+
| `frontier` | `inherit` | — | 200k ctx |
|
|
242
|
+
|
|
243
|
+
The shipped defaults use the **`inherit` sentinel** — each tier resolves to your **active session model**, so tier routing works on any provider out of the box. To route across models, override a tier by name with a concrete `provider/id` chain in `~/.pi/agent/fleet/tiers.json` (global) or `<project>/.pi/fleet/tiers.json` (project):
|
|
244
|
+
|
|
245
|
+
```json
|
|
246
|
+
[
|
|
247
|
+
{ "name": "economy", "models": ["Ollama/minimax-m3:cloud"] },
|
|
248
|
+
{ "name": "standard", "models": ["Ollama/glm-5.2:cloud", "inherit"] },
|
|
249
|
+
{ "name": "frontier", "models": ["anthropic/claude-sonnet-4"], "costCap": 5, "contextFloor": 200000 }
|
|
250
|
+
]
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Models are an ordered fallback chain (primary first; a spawn retries the next candidate if model creation is rejected); the `inherit` sentinel (case-insensitive) may appear anywhere in the chain as a provider-agnostic fallback and always resolves to the session model without catalog or floor checks. `contextFloor` skips catalog models below the window size; `costCap` aborts a run whose live cost exceeds the cap (a no-op on flat subscriptions). Tier routing applies to pi-backend agents — `backend: "claude"` agents receive the resolved string via `--model` and the claude CLI expects its own model names, so route those by `model:` instead.
|
|
242
254
|
|
|
243
255
|
Live cost $ and context % are tracked per run and surfaced in the Tiers view. Override per-run with `model`, or let the tier registry route based on the task class.
|
|
244
256
|
|
|
@@ -314,7 +326,24 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
|
|
|
314
326
|
- **`userMemory` default flip:** the global cross-project user memory scope (`/__armory-fleet-user__`) is no longer hydrated by default. If you populated that dir + relied on it, add `userMemory: true` to the agent frontmatter (only meaningful with `memoryHydrate: true`). TS consumers constructing `AgentDef` literals must now include `userMemory: boolean` (required field; use `false` for the old default behavior).
|
|
315
327
|
- **Lifecycle `cwd` field:** lifecycles accept an optional `cwd` frontmatter field to pin a target repo; absent → the entry-point cwd (the panel's chosen cwd, or the dispatching `subagent` tool's cwd/session cwd). When present, it overrides the entry-point cwd for all phases.
|
|
316
328
|
- **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
|
|
317
|
-
- **
|
|
329
|
+
- **bg/scheduled + worktree cwd-isolation (#62):** the `cwd` param now scopes background and scheduled runs too — in-place bg runs pass it as the lifecycle entry cwd, and `isolation: 'worktree'`/`'auto'` resolve isolation (and create the worktree) against the **dispatch cwd's** repo, not the session's. Cross-cwd bg worktrees land in `<child-cwd>/.pi/fleet/worktrees/`.
|
|
330
|
+
|
|
331
|
+
## Cross-cwd everywhere (v0.16.0)
|
|
332
|
+
|
|
333
|
+
The #62 tail of SPEC-6-5: `subagent({ cwd })` now scopes **background and scheduled** runs too, not just foreground —
|
|
334
|
+
|
|
335
|
+
- **bg/scheduled cwd** — in-place bg runs pass the dispatch cwd as the lifecycle entry cwd (full context scoping: cascade, skills, memory); schedules persist the pinned cwd and honor it on every fire.
|
|
336
|
+
- **Per-dispatch worktrees** — `isolation: 'worktree'` / `'auto'` resolve against the **dispatch cwd's repo**, not the session's; cross-cwd worktrees land in `<child-cwd>/.pi/fleet/worktrees/` and are cleaned up via the same service.
|
|
337
|
+
- **bg lifecycle parity** — the bg adapter now honors the lifecycle cwd chain (`lifecycle.cwd` → dispatch `cwd`), which also fixes a pre-existing mismatch where bg isolated runs spawned phases in the session cwd but committed in the worktree.
|
|
338
|
+
|
|
339
|
+
## Provider-agnostic tiers (v0.15.0)
|
|
340
|
+
|
|
341
|
+
Small-backlog batch (issues #57/#63/#64/#65):
|
|
342
|
+
|
|
343
|
+
- **Tier `inherit` sentinel (#64)** — builtin tiers no longer hardcode a provider: `economy`/`standard`/`frontier` now resolve to your **active session model** out of the box (frontier keeps its 200k context floor; the $5 cost cap moved to the override example — a no-op on flat subs). Override by name in `tiers.json` with concrete `provider/id` chains for real multi-model routing; `inherit` can appear mid-chain as a provider-agnostic fallback. See [Cost-aware tiers](#cost-aware-tiers).
|
|
344
|
+
- **Self-correcting model errors (#57)** — dispatching with a model the runtime doesn't have now lists the session's available (authed) models in the error, so the orchestrating model can pick a valid one on the retry instead of guessing.
|
|
345
|
+
- **Panel Escape semantics documented + dead code removed (#63)** — Escape always cancels the active panel flow; defaults are accepted via Enter-on-blank. (Also fixed: ctrl+c could trigger the never-documented "escape accepts default" callbacks.)
|
|
346
|
+
- **README example tier names fixed (#65)** — the `ship-feature` example now uses real tier names (`economy`/`standard`).
|
|
318
347
|
|
|
319
348
|
## Dogfood reliability (v0.14.0)
|
|
320
349
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -109,6 +109,26 @@ async function buildDefaultBackendRegistry(modelRuntime: ModelRuntime): Promise<
|
|
|
109
109
|
return reg;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Format the runtime's available models for the #57 self-correcting error message:
|
|
114
|
+
* dedup `provider/id` pairs, cap the list (min 1 — a non-empty input always lists
|
|
115
|
+
* something), actionable hint when empty.
|
|
116
|
+
*/
|
|
117
|
+
export function formatAvailableModels(models: readonly { provider: string; id: string }[], cap = 12): string {
|
|
118
|
+
const effectiveCap = Math.max(1, cap);
|
|
119
|
+
const seen = new Set<string>();
|
|
120
|
+
const list: string[] = [];
|
|
121
|
+
for (const m of models) {
|
|
122
|
+
const ref = `${m.provider}/${m.id}`;
|
|
123
|
+
if (seen.has(ref)) continue;
|
|
124
|
+
seen.add(ref);
|
|
125
|
+
if (list.length < effectiveCap) list.push(ref);
|
|
126
|
+
}
|
|
127
|
+
if (list.length === 0) return "(none — check provider auth / models.json)";
|
|
128
|
+
const omitted = seen.size - list.length;
|
|
129
|
+
return list.join(", ") + (omitted > 0 ? ` … +${omitted} more` : "");
|
|
130
|
+
}
|
|
131
|
+
|
|
112
132
|
export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort, resumeStore: ResumeStore): ChildSessionFactory {
|
|
113
133
|
return {
|
|
114
134
|
async create(opts) {
|
|
@@ -119,7 +139,13 @@ export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort
|
|
|
119
139
|
const provider = opts.model.slice(0, slash);
|
|
120
140
|
const id = opts.model.slice(slash + 1);
|
|
121
141
|
model = modelRuntime.getModel(provider, id);
|
|
122
|
-
|
|
142
|
+
// #57: name what IS usable so the orchestrating model can self-correct on retry.
|
|
143
|
+
if (!model) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`agent model '${opts.model}' not found in runtime (provider '${provider}', id '${id}'). ` +
|
|
146
|
+
`Available: ${formatAvailableModels(modelRuntime.getAvailableSnapshot())} — pick one of these for the model param`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
123
149
|
}
|
|
124
150
|
// Fleet CustomResourceLoader: noExtensions + composed systemPromptOverride (rolePrompt + memory + base) + scoped skills.
|
|
125
151
|
const loader = buildChildLoader({ cwd: opts.cwd, agent: opts.agent, memoryPort });
|
|
@@ -267,10 +293,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
267
293
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
|
|
268
294
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
|
|
269
295
|
parentCwd: isolated ? opts.worktreePath! : deps.parentCwd,
|
|
296
|
+
// #62: in-place bg runs honor the lifecycle cwd resolution (lifecycle.cwd ?? entryCwd);
|
|
297
|
+
// isolated runs pin the phase cwd to the worktree. parentCwd stays the SESSION cwd for
|
|
298
|
+
// in-place runs so the cross-cwd surfacing (↗ glyph + sessionCwd audit) still fires.
|
|
299
|
+
cwd: isolated ? opts.worktreePath : o.cwd,
|
|
270
300
|
runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
271
301
|
}), deps.defaultModelFallback),
|
|
272
302
|
};
|
|
273
|
-
const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
|
|
303
|
+
const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", entryCwd: opts.entryCwd, onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
|
|
274
304
|
return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
|
|
275
305
|
};
|
|
276
306
|
// asyncRunnerDeps + scheduler are built per-session (need the session cwd); wired on session_start.
|
|
@@ -340,8 +370,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
340
370
|
ctx.ui.notify(`reconciled ${reconciled.length} interrupted fleet run${reconciled.length > 1 ? "s" : ""} (marked aborted; linked TODOs reverted to open)`, "info");
|
|
341
371
|
}
|
|
342
372
|
});
|
|
373
|
+
const sessionWorktree = new WorktreeService({ rootDir: ctx.cwd });
|
|
343
374
|
deps.asyncRunner = {
|
|
344
|
-
worktree:
|
|
375
|
+
worktree: sessionWorktree,
|
|
376
|
+
// #62: cross-cwd bg dispatches get a per-dispatch worktree service rooted at the dispatch
|
|
377
|
+
// cwd, so isolation:'worktree' creates the worktree from the CHILD's repo (the session
|
|
378
|
+
// service would create it from the session's repo — the #62 gap). Same-cwd → shared.
|
|
379
|
+
worktreeFor: (cwd: string) => (cwd === ctx.cwd ? sessionWorktree : new WorktreeService({ rootDir: cwd })),
|
|
345
380
|
diff: new DiffService(),
|
|
346
381
|
journal: new RunJournal(join(dir, "runs")),
|
|
347
382
|
pool: new ConcurrencyPool(3),
|
|
@@ -357,7 +392,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
357
392
|
lockPath: join(dir, "schedules.lock"),
|
|
358
393
|
onFire: (spec) => {
|
|
359
394
|
if (!deps.asyncRunner) return;
|
|
360
|
-
runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation });
|
|
395
|
+
runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation, cwd: spec.cwd });
|
|
361
396
|
},
|
|
362
397
|
});
|
|
363
398
|
deps.scheduler.start();
|
package/src/panel/fleet-panel.ts
CHANGED
|
@@ -425,10 +425,8 @@ export class FleetPanel extends Container {
|
|
|
425
425
|
this.linkInput.onSubmit = (todoIdRaw: string) => {
|
|
426
426
|
void this.executeRun(agentName, task.trim(), todoIdRaw.trim() || undefined);
|
|
427
427
|
};
|
|
428
|
-
this.linkInput.onEscape = () => { void this.executeRun(agentName, task.trim(), undefined); };
|
|
429
428
|
this.renderShell();
|
|
430
429
|
};
|
|
431
|
-
this.taskInput.onEscape = () => this.cancelRun();
|
|
432
430
|
this.runMode = true;
|
|
433
431
|
this.renderShell();
|
|
434
432
|
}
|
|
@@ -492,6 +490,12 @@ export class FleetPanel extends Container {
|
|
|
492
490
|
}
|
|
493
491
|
|
|
494
492
|
handleInput(data: string): void {
|
|
493
|
+
// Escape policy (#63): every modal branch below intercepts Escape BEFORE the active Input
|
|
494
|
+
// sees it, so pi-tui's Input.onEscape never fires in this panel — Escape always cancels
|
|
495
|
+
// the active flow. Defaults are accepted via Enter-on-blank ("blank=default" prompts).
|
|
496
|
+
// Caveat: ctrl+c also matches pi-tui's tui.select.cancel but is NOT intercepted here —
|
|
497
|
+
// it forwards to the Input, which ignores control characters (silent no-op). An onEscape
|
|
498
|
+
// callback re-added later would fire on ctrl+c but never on Escape — do not re-add.
|
|
495
499
|
if (this.infoAgent) {
|
|
496
500
|
if (matchesKey(data, "escape")) { this.infoAgent = null; this.renderShell(); }
|
|
497
501
|
return;
|
|
@@ -811,13 +815,13 @@ export class FleetPanel extends Container {
|
|
|
811
815
|
this.pendingCheckpoint = null;
|
|
812
816
|
this.renderShell();
|
|
813
817
|
};
|
|
814
|
-
this.lcReviseInput.onEscape = () => { this.lcRevising = false; this.lcReviseInput = null; this.renderShell(); };
|
|
815
818
|
this.renderShell();
|
|
816
819
|
return;
|
|
817
820
|
}
|
|
818
821
|
}
|
|
819
822
|
if (this.lcRevising && this.lcReviseInput) {
|
|
820
|
-
|
|
823
|
+
// Escape never reaches here — the panel-level intercept above resolves the pending
|
|
824
|
+
// checkpoint as abort + closes the panel first (#63). Only printable input forwards.
|
|
821
825
|
this.lcReviseInput.handleInput(data);
|
|
822
826
|
this.invalidate();
|
|
823
827
|
return;
|
|
@@ -842,13 +846,10 @@ export class FleetPanel extends Container {
|
|
|
842
846
|
const lcName = name.trim() || "default";
|
|
843
847
|
this.executeScheduleAdd(task.trim(), expr.trim(), lcName);
|
|
844
848
|
};
|
|
845
|
-
this.schedNameInput.onEscape = () => { this.executeScheduleAdd(task.trim(), expr.trim(), "default"); };
|
|
846
849
|
this.renderShell();
|
|
847
850
|
};
|
|
848
|
-
this.schedExprInput.onEscape = () => this.cancelScheduleAdd();
|
|
849
851
|
this.renderShell();
|
|
850
852
|
};
|
|
851
|
-
this.schedTaskInput.onEscape = () => this.cancelScheduleAdd();
|
|
852
853
|
this.schedRunMode = true;
|
|
853
854
|
this.renderShell();
|
|
854
855
|
}
|
|
@@ -892,7 +893,6 @@ export class FleetPanel extends Container {
|
|
|
892
893
|
if (!followUp.trim()) { this.cancelResume(); return; }
|
|
893
894
|
void this.executeResume(run, followUp.trim());
|
|
894
895
|
};
|
|
895
|
-
this.resumeInput.onEscape = () => this.cancelResume();
|
|
896
896
|
this.resumeMode = true;
|
|
897
897
|
this.renderShell();
|
|
898
898
|
}
|
|
@@ -916,7 +916,6 @@ export class FleetPanel extends Container {
|
|
|
916
916
|
if (!text.trim()) { this.cancelSteer(); return; }
|
|
917
917
|
void this.executeSteer(run.runId, text.trim());
|
|
918
918
|
};
|
|
919
|
-
this.steerInput.onEscape = () => this.cancelSteer();
|
|
920
919
|
this.steerMode = true;
|
|
921
920
|
this.renderShell();
|
|
922
921
|
}
|
|
@@ -969,7 +968,6 @@ export class FleetPanel extends Container {
|
|
|
969
968
|
}
|
|
970
969
|
this.tiersInput = new Input();
|
|
971
970
|
this.tiersInput.onSubmit = (value: string) => { void this.executeTiersEdit(value, phase); };
|
|
972
|
-
this.tiersInput.onEscape = () => this.cancelTiersEdit();
|
|
973
971
|
this.tiersEditPhase = phase;
|
|
974
972
|
this.renderShell();
|
|
975
973
|
}
|
|
@@ -995,7 +993,6 @@ export class FleetPanel extends Container {
|
|
|
995
993
|
}
|
|
996
994
|
this.cancelWorkflowRun();
|
|
997
995
|
};
|
|
998
|
-
this.wfPromptInput.onEscape = () => this.cancelWorkflowRun();
|
|
999
996
|
this.wfRunMode = true;
|
|
1000
997
|
this.renderShell();
|
|
1001
998
|
}
|
|
@@ -1097,10 +1094,8 @@ export class FleetPanel extends Container {
|
|
|
1097
1094
|
this.linkInput.onSubmit = (todoIdRaw: string) => {
|
|
1098
1095
|
void this.executeFork(run.agent, finalTask, todoIdRaw.trim() || undefined, run.runId);
|
|
1099
1096
|
};
|
|
1100
|
-
this.linkInput.onEscape = () => { void this.executeFork(run.agent, finalTask, undefined, run.runId); };
|
|
1101
1097
|
this.renderShell();
|
|
1102
1098
|
};
|
|
1103
|
-
this.taskInput.onEscape = () => this.cancelRun();
|
|
1104
1099
|
this.runMode = true;
|
|
1105
1100
|
this.renderShell();
|
|
1106
1101
|
}
|
|
@@ -1134,19 +1129,16 @@ export class FleetPanel extends Container {
|
|
|
1134
1129
|
const lcName = name.trim() || "default";
|
|
1135
1130
|
this.lcPhase = "cwd";
|
|
1136
1131
|
this.lcCwdInput = new Input();
|
|
1137
|
-
// SPEC-6-5: 3rd input step — the dispatch cwd.
|
|
1138
|
-
//
|
|
1132
|
+
// SPEC-6-5: 3rd input step — the dispatch cwd. Enter accepts the session cwd (blank)
|
|
1133
|
+
// or a typed path; Escape cancels the run (panel-level intercept — #63).
|
|
1139
1134
|
this.lcCwdInput.onSubmit = (cwd: string) => {
|
|
1140
1135
|
const picked = cwd.trim() || this.deps.parentCwd;
|
|
1141
1136
|
void this.executeLifecycleRun(task.trim(), lcName, picked);
|
|
1142
1137
|
};
|
|
1143
|
-
this.lcCwdInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), lcName, this.deps.parentCwd); };
|
|
1144
1138
|
this.renderShell();
|
|
1145
1139
|
};
|
|
1146
|
-
this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default", this.deps.parentCwd); };
|
|
1147
1140
|
this.renderShell();
|
|
1148
1141
|
};
|
|
1149
|
-
this.lcTaskInput.onEscape = () => this.cancelLifecycleRun();
|
|
1150
1142
|
this.lcRunMode = true;
|
|
1151
1143
|
this.renderShell();
|
|
1152
1144
|
}
|
|
@@ -26,6 +26,9 @@ export interface RunLifecycleOpts {
|
|
|
26
26
|
worktreePath?: string;
|
|
27
27
|
branch?: string;
|
|
28
28
|
mode: "auto" | "checkpointed";
|
|
29
|
+
/** #62: the dispatch target cwd for in-place runs (isolated runs pass the worktree path).
|
|
30
|
+
* Flows into runLifecycle's SPEC-6-5 cwd resolution (lifecycle.cwd ?? entryCwd). */
|
|
31
|
+
entryCwd?: string;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
export type RunLifecycleFn = (task: string, lifecycleName: string, opts: RunLifecycleOpts) => Promise<FakeLifecycleResult>;
|
|
@@ -43,6 +46,11 @@ export interface AsyncRunnerDeps {
|
|
|
43
46
|
onProgress?: (runId: string, status: import("../panel/rows.ts").BgRunStatus) => void;
|
|
44
47
|
/** SPEC-6-2: the RunRegistry so emitProgress can read the run's actual backend. */
|
|
45
48
|
runRegistry?: import("../engine/run-registry.ts").RunRegistry;
|
|
49
|
+
/** #62: per-dispatch worktree service for cross-cwd bg runs (rootDir = the dispatch cwd).
|
|
50
|
+
* Consulted whenever a bg dispatch carries a `cwd`; absent → deps.worktree (session-scoped,
|
|
51
|
+
* pre-#62 behavior). index.ts wires it so a cross-cwd worktree is created from the CHILD's
|
|
52
|
+
* repo, not the session's. */
|
|
53
|
+
worktreeFor?: (cwd: string) => WorktreeService;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export interface RunBackgroundOpts {
|
|
@@ -51,6 +59,10 @@ export interface RunBackgroundOpts {
|
|
|
51
59
|
mode: "auto" | "checkpointed";
|
|
52
60
|
/** v0.11.1: edit isolation for background runs. Default "auto" (worktree when cwd is a git repo, in-place otherwise). */
|
|
53
61
|
isolation?: Isolation;
|
|
62
|
+
/** #62: the dispatch target cwd (undefined = session cwd, back-compat). Scopes the run:
|
|
63
|
+
* isolation routing + worktree creation resolve against THIS cwd (via deps.worktreeFor),
|
|
64
|
+
* and in-place runs pass it as the lifecycle entryCwd. */
|
|
65
|
+
cwd?: string;
|
|
54
66
|
}
|
|
55
67
|
|
|
56
68
|
/** The success shape (back-compat: existing external refs to RunBackgroundHandle still typecheck). */
|
|
@@ -82,9 +94,16 @@ function sh(cmd: string, cwd: string): void {
|
|
|
82
94
|
execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
/** #62: the worktree service for a dispatch — per-dispatch (rootDir = the dispatch cwd) when
|
|
98
|
+
* deps.worktreeFor is wired, else the session-scoped deps.worktree (back-compat). */
|
|
99
|
+
function worktreeFor(deps: AsyncRunnerDeps, cwd?: string): WorktreeService {
|
|
100
|
+
if (!cwd) return deps.worktree;
|
|
101
|
+
return deps.worktreeFor?.(cwd) ?? deps.worktree;
|
|
102
|
+
}
|
|
103
|
+
|
|
85
104
|
/** The git-agnostic core: pool slot → journal → runLifecycle in ctx.cwd (or a worktree, when `isolated` is set) → inbox + notify.
|
|
86
105
|
* Fire-and-forget. When `isolated` is present, journals the worktree field, commits on completion, and removes the worktree. */
|
|
87
|
-
function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOpts, isolated?: { worktreePath: string; branch: string }): void {
|
|
106
|
+
function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOpts, isolated?: { worktreePath: string; branch: string }, worktree: WorktreeService = opts.deps.worktree): void {
|
|
88
107
|
const { deps } = opts;
|
|
89
108
|
void deps.pool.withSlot(async () => {
|
|
90
109
|
try {
|
|
@@ -94,7 +113,7 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
|
|
|
94
113
|
deps.journal.append(runId, ev0);
|
|
95
114
|
emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
|
|
96
115
|
|
|
97
|
-
const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode });
|
|
116
|
+
const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode, entryCwd: isolated ? isolated.worktreePath : opts.cwd });
|
|
98
117
|
|
|
99
118
|
if (res.status === "completed") {
|
|
100
119
|
if (isolated) {
|
|
@@ -116,17 +135,17 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
|
|
|
116
135
|
};
|
|
117
136
|
deps.inbox.push(result);
|
|
118
137
|
deps.notify(`fleet run ${runId} completed`, "info");
|
|
119
|
-
if (isolated)
|
|
138
|
+
if (isolated) worktree.removeWorktree(runId);
|
|
120
139
|
} else {
|
|
121
140
|
deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() });
|
|
122
|
-
if (isolated)
|
|
141
|
+
if (isolated) worktree.remove(runId);
|
|
123
142
|
emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: res.phases.length, lifecycle: opts.lifecycle, mode: opts.mode, task });
|
|
124
143
|
deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning");
|
|
125
144
|
}
|
|
126
145
|
} catch (e) {
|
|
127
146
|
const msg = (e as Error).message;
|
|
128
147
|
deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() });
|
|
129
|
-
if (isolated)
|
|
148
|
+
if (isolated) worktree.remove(runId);
|
|
130
149
|
deps.notify(`fleet run ${runId} failed: ${msg}`, "error");
|
|
131
150
|
emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
|
|
132
151
|
}
|
|
@@ -135,34 +154,34 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
|
|
|
135
154
|
|
|
136
155
|
/** The worktree wrapper: SYNCHRONOUS pre-flight + worktree create, then core-with-isolation. */
|
|
137
156
|
function runBackgroundIsolated(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
|
|
138
|
-
const
|
|
139
|
-
if (!
|
|
157
|
+
const worktree = worktreeFor(opts.deps, opts.cwd);
|
|
158
|
+
if (!worktree.isGitRepo()) {
|
|
140
159
|
return { status: "failed", error: "isolation: 'worktree' requires a git repo; cwd is not one — use isolation: 'none' or run in a git repo" };
|
|
141
160
|
}
|
|
142
|
-
const runId = deps.genRunId();
|
|
161
|
+
const runId = opts.deps.genRunId();
|
|
143
162
|
const baseRef = "HEAD";
|
|
144
163
|
let wt: { path: string; branch: string };
|
|
145
164
|
try {
|
|
146
|
-
wt =
|
|
165
|
+
wt = worktree.create(runId, baseRef);
|
|
147
166
|
} catch (e) {
|
|
148
167
|
return { status: "failed", error: (e as Error).message };
|
|
149
168
|
}
|
|
150
|
-
runBackgroundInPlace(runId, task, opts, { worktreePath: wt.path, branch: wt.branch });
|
|
169
|
+
runBackgroundInPlace(runId, task, opts, { worktreePath: wt.path, branch: wt.branch }, worktree);
|
|
151
170
|
return { runId, status: "background" };
|
|
152
171
|
}
|
|
153
172
|
|
|
154
|
-
/** The auto router: isolated when cwd is a git repo, in-place + one per-session notify when not. */
|
|
173
|
+
/** The auto router: isolated when the DISPATCH cwd is a git repo, in-place + one per-session notify when not. */
|
|
155
174
|
function runBackgroundAuto(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
175
|
+
const worktree = worktreeFor(opts.deps, opts.cwd);
|
|
176
|
+
if (worktree.isGitRepo()) {
|
|
158
177
|
return runBackgroundIsolated(task, opts);
|
|
159
178
|
}
|
|
160
179
|
if (!inPlaceFallbackWarned) {
|
|
161
180
|
inPlaceFallbackWarned = true;
|
|
162
|
-
deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
|
|
181
|
+
opts.deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
|
|
163
182
|
}
|
|
164
|
-
const runId = deps.genRunId();
|
|
165
|
-
runBackgroundInPlace(runId, task, opts);
|
|
183
|
+
const runId = opts.deps.genRunId();
|
|
184
|
+
runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
|
|
166
185
|
return { runId, status: "background" };
|
|
167
186
|
}
|
|
168
187
|
|
|
@@ -172,7 +191,7 @@ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgro
|
|
|
172
191
|
if (isolation === "worktree") return runBackgroundIsolated(task, opts);
|
|
173
192
|
if (isolation === "none") {
|
|
174
193
|
const runId = opts.deps.genRunId();
|
|
175
|
-
runBackgroundInPlace(runId, task, opts);
|
|
194
|
+
runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
|
|
176
195
|
return { runId, status: "background" };
|
|
177
196
|
}
|
|
178
197
|
return runBackgroundAuto(task, opts);
|
|
@@ -13,6 +13,8 @@ export interface ScheduleSpec {
|
|
|
13
13
|
auto?: boolean;
|
|
14
14
|
/** v0.11.1: edit isolation for the background run on fire. Default "auto". */
|
|
15
15
|
isolation?: "worktree" | "none" | "auto";
|
|
16
|
+
/** #62: the dispatch target cwd (undefined = session cwd). Threaded to runBackground on fire. */
|
|
17
|
+
cwd?: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface Schedule extends ScheduleSpec {
|
|
@@ -74,6 +76,7 @@ export class Scheduler {
|
|
|
74
76
|
lifecycle: spec.lifecycle ?? "default",
|
|
75
77
|
auto: spec.auto ?? true,
|
|
76
78
|
isolation: spec.isolation,
|
|
79
|
+
...(spec.cwd ? { cwd: spec.cwd } : {}),
|
|
77
80
|
paused: false,
|
|
78
81
|
};
|
|
79
82
|
this.schedules.set(id, { spec: stored, expr, timer: null });
|
|
@@ -90,6 +93,7 @@ export class Scheduler {
|
|
|
90
93
|
lifecycle: e.spec.lifecycle,
|
|
91
94
|
auto: e.spec.auto,
|
|
92
95
|
isolation: e.spec.isolation,
|
|
96
|
+
...(e.spec.cwd ? { cwd: e.spec.cwd } : {}),
|
|
93
97
|
paused: e.spec.paused,
|
|
94
98
|
nextFire: e.spec.paused ? null : e.expr.nextFire(new Date()),
|
|
95
99
|
}));
|
package/src/tiers/builtin.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import type { Tier } from "./tier-registry.ts";
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Shipped default tiers (Q10). Overridable via global/project tiers.json.
|
|
5
|
+
*
|
|
6
|
+
* Provider-agnostic since #64: the `inherit` sentinel resolves to the parent/active
|
|
7
|
+
* session model, so zero-config fleets work on ANY provider. Users who want real
|
|
8
|
+
* multi-model cost routing override these by name with concrete `provider/id`
|
|
9
|
+
* chains (`inherit` may also appear mid-chain as a fallback). `contextFloor`
|
|
10
|
+
* guards concrete candidates; `costCap` is a per-run $ abort (configure where
|
|
11
|
+
* meaningful — it is a no-op on flat subscriptions).
|
|
12
|
+
*/
|
|
4
13
|
export const BUILTIN_TIERS: Tier[] = [
|
|
5
|
-
{ name: "economy", models: ["
|
|
6
|
-
{ name: "standard", models: ["
|
|
7
|
-
{ name: "frontier", models: ["
|
|
8
|
-
];
|
|
14
|
+
{ name: "economy", models: ["inherit"] },
|
|
15
|
+
{ name: "standard", models: ["inherit"] },
|
|
16
|
+
{ name: "frontier", models: ["inherit"], contextFloor: 200000 },
|
|
17
|
+
];
|
package/src/tiers/resolve.ts
CHANGED
|
@@ -34,6 +34,20 @@ export function resolveAgentModel(
|
|
|
34
34
|
if (!tier) return { error: `tier '${agent.tier}' not found; available: ${tiers.list().map((t) => t.name).join(", ")}` };
|
|
35
35
|
const candidates: string[] = [];
|
|
36
36
|
for (const m of tier.models) {
|
|
37
|
+
if (m.trim().toLowerCase() === "inherit") {
|
|
38
|
+
// #64: "inherit" = use the parent/active model — always eligible, no catalog or
|
|
39
|
+
// contextFloor check (the session model is presumed appropriate; the floor guards
|
|
40
|
+
// concrete candidates). Participates in the chain: eligible concrete models listed
|
|
41
|
+
// before it win; after them it acts as the provider-agnostic fallback.
|
|
42
|
+
// Sentinel is case-insensitive/trimmed; concrete model strings stay verbatim.
|
|
43
|
+
// Empty parentModel (dispatch before a session model exists) is NOT pushed — a
|
|
44
|
+
// pure-inherit tier then falls to the descriptive no-eligible-model error below.
|
|
45
|
+
if (parentModel.provider || parentModel.id) {
|
|
46
|
+
const parent = `${parentModel.provider}/${parentModel.id}`;
|
|
47
|
+
if (!candidates.includes(parent)) candidates.push(parent);
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
37
51
|
const { provider, id } = splitModel(m, parentModel.provider);
|
|
38
52
|
const model = modelRegistry.find(provider, id);
|
|
39
53
|
if (!model) continue; // not in catalog → skip
|
package/src/tools/subagent.ts
CHANGED
|
@@ -131,13 +131,13 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
131
131
|
}
|
|
132
132
|
if (params.schedule) {
|
|
133
133
|
if (!deps.scheduler) return { isError: true, content: [{ type: "text" as const, text: "scheduling not configured (scheduler missing)" }] };
|
|
134
|
-
const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true, isolation: params.isolation });
|
|
134
|
+
const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true, isolation: params.isolation, cwd: resolvedCwd });
|
|
135
135
|
const entry = deps.scheduler.list().find((s) => s.id === id);
|
|
136
136
|
return { content: [{ type: "text" as const, text: `scheduled: ${id} · next fire: ${entry?.nextFire?.toISOString() ?? "(paused)"}` }], details: { scheduleId: id, nextFire: entry?.nextFire ?? null } };
|
|
137
137
|
}
|
|
138
138
|
if (params.background) {
|
|
139
139
|
if (!deps.asyncRunner) return { isError: true, content: [{ type: "text" as const, text: "background runs not configured (asyncRunner missing)" }] };
|
|
140
|
-
const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto", isolation: params.isolation });
|
|
140
|
+
const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto", isolation: params.isolation, cwd: resolvedCwd });
|
|
141
141
|
if (handle.status === "failed") return { isError: true, content: [{ type: "text" as const, text: handle.error }] };
|
|
142
142
|
return { content: [{ type: "text" as const, text: `background run: ${handle.runId}` }], details: handle };
|
|
143
143
|
}
|