@getpipher/armory-fleet 0.15.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 +9 -1
- package/package.json +1 -1
- package/src/index.ts +12 -3
- package/src/runtime/async-runner.ts +36 -17
- package/src/scheduling/scheduler.ts +4 -0
- package/src/tools/subagent.ts +2 -2
package/README.md
CHANGED
|
@@ -326,7 +326,15 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
|
|
|
326
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).
|
|
327
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.
|
|
328
328
|
- **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
|
|
329
|
-
- **
|
|
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.
|
|
330
338
|
|
|
331
339
|
## Provider-agnostic tiers (v0.15.0)
|
|
332
340
|
|
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
|
@@ -293,10 +293,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
293
293
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
|
|
294
294
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
|
|
295
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,
|
|
296
300
|
runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
297
301
|
}), deps.defaultModelFallback),
|
|
298
302
|
};
|
|
299
|
-
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" } });
|
|
300
304
|
return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
|
|
301
305
|
};
|
|
302
306
|
// asyncRunnerDeps + scheduler are built per-session (need the session cwd); wired on session_start.
|
|
@@ -366,8 +370,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
366
370
|
ctx.ui.notify(`reconciled ${reconciled.length} interrupted fleet run${reconciled.length > 1 ? "s" : ""} (marked aborted; linked TODOs reverted to open)`, "info");
|
|
367
371
|
}
|
|
368
372
|
});
|
|
373
|
+
const sessionWorktree = new WorktreeService({ rootDir: ctx.cwd });
|
|
369
374
|
deps.asyncRunner = {
|
|
370
|
-
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 })),
|
|
371
380
|
diff: new DiffService(),
|
|
372
381
|
journal: new RunJournal(join(dir, "runs")),
|
|
373
382
|
pool: new ConcurrencyPool(3),
|
|
@@ -383,7 +392,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
383
392
|
lockPath: join(dir, "schedules.lock"),
|
|
384
393
|
onFire: (spec) => {
|
|
385
394
|
if (!deps.asyncRunner) return;
|
|
386
|
-
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 });
|
|
387
396
|
},
|
|
388
397
|
});
|
|
389
398
|
deps.scheduler.start();
|
|
@@ -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/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
|
}
|