@getpipher/armory-fleet 0.15.0 → 1.0.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 +73 -1
- package/package.json +1 -1
- package/src/engine/spawnSubagent.ts +7 -3
- package/src/index.ts +105 -5
- package/src/panel/fleet-panel.ts +54 -8
- package/src/panel/live-timeline.ts +32 -0
- package/src/rpc/event-bus.ts +105 -0
- package/src/rpc/rpc-server.ts +244 -0
- package/src/runtime/async-runner.ts +44 -18
- package/src/runtime/run-journal.ts +11 -0
- package/src/runtime/run-log.ts +15 -0
- package/src/scheduling/scheduler.ts +4 -0
- package/src/tools/subagent.ts +2 -2
package/README.md
CHANGED
|
@@ -326,7 +326,79 @@ 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
|
+
## Event bus + RPC (v1.0)
|
|
332
|
+
|
|
333
|
+
armory-fleet publishes every run on pi's cross-extension event bus and answers an RPC verb
|
|
334
|
+
set — so your own extensions can spawn, steer, observe, and abort subagents programmatically.
|
|
335
|
+
|
|
336
|
+
### Event stream (broadcast, `pi.events`)
|
|
337
|
+
|
|
338
|
+
Two tiers, every event enveloped as `{ runId, seq, ts, ...payload }` (`seq` = per-run
|
|
339
|
+
monotonic, one space per source store):
|
|
340
|
+
|
|
341
|
+
| Channel | Payload |
|
|
342
|
+
|---|---|
|
|
343
|
+
| `fleet:run:started` | `{ agent, model?, cwd?, sessionCwd?, mode: foreground\|background\|scheduled\|workflow, task }` |
|
|
344
|
+
| `fleet:phase:started` / `completed` / `failed` | `{ phase, … }` (lifecycle runs) |
|
|
345
|
+
| `fleet:run:ended` | `{ status: completed\|failed\|aborted, result?, error?, filesTouched?, toolCallCount?, durationMs? }` |
|
|
346
|
+
| `fleet:child:message` | `{ role, text }` (journal excerpts) |
|
|
347
|
+
| `fleet:child:tool` | `{ toolName, args, result, isError }` (one per completed tool call) |
|
|
348
|
+
|
|
349
|
+
### RPC (`fleet:rpc` → `fleet:rpc:result`)
|
|
350
|
+
|
|
351
|
+
Emit `{ id, verb, params }`, get exactly one reply `{ id, ok, data }` or
|
|
352
|
+
`{ id, ok: false, error: { code, message } }`. Verbs: `spawn` (returns `{ runId }`
|
|
353
|
+
immediately; result arrives via `fleet:run:ended`), `status`, `observe` (replay dump —
|
|
354
|
+
subscribe to the broadcast channels + dedupe by `(channel, runId, seq)` for the live tail),
|
|
355
|
+
`steer`, `abort`.
|
|
356
|
+
|
|
357
|
+
Error codes: `E-CONTROL-DISABLED`, `E-RUN-NOT-FOUND`, `E-RUN-FINISHED`, `E-BAD-VERB`,
|
|
358
|
+
`E-BAD-PARAMS`, `E-STEER-UNSUPPORTED`, `E-INTERNAL`.
|
|
359
|
+
|
|
360
|
+
### Client helper (~15 lines)
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
type FleetReply = { id: string; ok: true; data: any } | { id: string; ok: false; error: { code: string; message: string } };
|
|
364
|
+
let n = 0;
|
|
365
|
+
export function fleetRpc(pi: { events: { emit(c: string, d: unknown): void; on(c: string, h: (d: unknown) => void): () => void } }) {
|
|
366
|
+
return <T = any>(verb: string, params?: Record<string, unknown>): Promise<T> => {
|
|
367
|
+
const id = `fleet-${Date.now().toString(36)}-${++n}`;
|
|
368
|
+
return new Promise<T>((resolve, reject) => {
|
|
369
|
+
const unsub = pi.events.on("fleet:rpc:result", (raw) => {
|
|
370
|
+
const r = raw as FleetReply;
|
|
371
|
+
if (r.id !== id) return;
|
|
372
|
+
unsub();
|
|
373
|
+
r.ok ? resolve(r.data as T) : reject(new Error(`${r.error.code}: ${r.error.message}`));
|
|
374
|
+
});
|
|
375
|
+
pi.events.emit("fleet:rpc", { id, verb, params });
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
// const rpc = fleetRpc(pi); const { runId } = await rpc("spawn", { agent: "scout", task: "look" });
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Control gate
|
|
383
|
+
|
|
384
|
+
`spawn`/`steer`/`abort` are on by default. Set `ARMORY_FLEET_RPC_CONTROL=0` (or `false`) to
|
|
385
|
+
reject them with `E-CONTROL-DISABLED`; read-only `observe`/`status` stay available. Honest
|
|
386
|
+
threat model: in-process extensions already have full system access through pi itself — the
|
|
387
|
+
switch guards accidents, not adversaries.
|
|
388
|
+
|
|
389
|
+
### Live conversation viewer
|
|
390
|
+
|
|
391
|
+
`/fleet` → Runs → open a **running** run: the timeline overlay streams the child's
|
|
392
|
+
conversation as it happens (tail-follows; scroll up to read, back to the bottom to re-pin).
|
|
393
|
+
Finished runs replay from the journal exactly as before.
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
The #62 tail of SPEC-6-5: `subagent({ cwd })` now scopes **background and scheduled** runs too, not just foreground —
|
|
398
|
+
|
|
399
|
+
- **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.
|
|
400
|
+
- **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.
|
|
401
|
+
- **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
402
|
|
|
331
403
|
## Provider-agnostic tiers (v0.15.0)
|
|
332
404
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.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",
|
|
@@ -132,6 +132,10 @@ export interface SpawnOptions {
|
|
|
132
132
|
visionPort?: VisionPort;
|
|
133
133
|
signal?: AbortSignal;
|
|
134
134
|
onEvent?: (e: ChildSessionEvent) => void;
|
|
135
|
+
/** SPEC-6-4: pre-minted runId (RPC spawn replies before the detached spawn resolves). Absent → mint. */
|
|
136
|
+
runId?: string;
|
|
137
|
+
/** SPEC-6-4: dispatch origin for RunMetaEvent.mode. Default "foreground". */
|
|
138
|
+
mode?: "foreground" | "background" | "scheduled" | "workflow";
|
|
135
139
|
/** SPEC-4: when set, this spawn is a lifecycle phase child. It links to this lifecycle todo
|
|
136
140
|
* (not creates a new one) and finishRun skips mark-done/revert — the lifecycle engine owns
|
|
137
141
|
* the lifecycle todo's status + progress block. */
|
|
@@ -241,13 +245,13 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
241
245
|
const readOnly = opts.readOnly ?? false;
|
|
242
246
|
let runId: string;
|
|
243
247
|
if (readOnly) {
|
|
244
|
-
runId = genRunId();
|
|
248
|
+
runId = opts.runId ?? genRunId();
|
|
245
249
|
} else {
|
|
246
250
|
// #31 tail: foreground concurrency lock. cap=1 (default) is FAIL-FAST (backward-compat — a
|
|
247
251
|
// 2nd dispatch is rejected with the held runId + the cap + the env hint); cap>1 (opt-in via
|
|
248
252
|
// ARMORY_FLEET_FOREGROUND_CONCURRENCY) QUEUES — up to `cap` writes run in parallel, the rest wait.
|
|
249
253
|
// The cap is session-level (a shared lock can't be re-sized per dispatch); see concurrency-lock.ts.
|
|
250
|
-
runId = genRunId();
|
|
254
|
+
runId = opts.runId ?? genRunId();
|
|
251
255
|
const acq = await opts.lock.acquire(runId);
|
|
252
256
|
if (!acq.ok) {
|
|
253
257
|
const hint = opts.lock.cap === 1
|
|
@@ -401,7 +405,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
401
405
|
if (e.type === "session_init" && e.backendSessionId) {
|
|
402
406
|
opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
|
|
403
407
|
try {
|
|
404
|
-
opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey, cwd: childCwd, sessionCwd: opts.parentCwd, pid: (session as { proc?: { pid?: number } }).proc?.pid });
|
|
408
|
+
opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey, cwd: childCwd, sessionCwd: opts.parentCwd, mode: opts.mode ?? "foreground", pid: (session as { proc?: { pid?: number } }).proc?.pid });
|
|
405
409
|
} catch { /* best-effort */ }
|
|
406
410
|
} else if (e.type === "turn_start") {
|
|
407
411
|
turnIdx++;
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
getAgentDir,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import type { Model } from "@earendil-works/pi-ai";
|
|
11
|
-
import { createSubagentTool, type SubagentToolDeps } from "./tools/subagent.ts";
|
|
11
|
+
import { createSubagentTool, resolveDispatchCwd, type SubagentToolDeps } from "./tools/subagent.ts";
|
|
12
12
|
// SPEC-6-3: /fleet uses openWorkflowPanelLoop (Task 12) instead of the raw openFleetPanel factory.
|
|
13
13
|
import { discoverAgents } from "./registry/discovery.ts";
|
|
14
14
|
import { RunRegistry } from "./engine/run-registry.ts";
|
|
@@ -40,6 +40,8 @@ import { ResultsInbox } from "./runtime/results-inbox.ts";
|
|
|
40
40
|
import { runBackground, type AsyncRunnerDeps } from "./runtime/async-runner.ts";
|
|
41
41
|
import { scanResumeCandidates } from "./runtime/resume.ts";
|
|
42
42
|
import { RunLog } from "./runtime/run-log.ts";
|
|
43
|
+
import { FleetEventBus } from "./rpc/event-bus.ts";
|
|
44
|
+
import { RpcServer } from "./rpc/rpc-server.ts";
|
|
43
45
|
import { reconcileRuns } from "./runtime/reconcile.ts";
|
|
44
46
|
import { Scheduler } from "./scheduling/scheduler.ts";
|
|
45
47
|
import { createFleetResultsTool } from "./tools/fleet-results.ts";
|
|
@@ -263,6 +265,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
263
265
|
// SPEC-5b-2: the live widget (above editor) controller.
|
|
264
266
|
// Display-only, independent of the /fleet panel; constructed per-session in session_start.
|
|
265
267
|
let fleetWidget: FleetWidgetController | null = null;
|
|
268
|
+
// SPEC-6-4: hoisted so session_shutdown can dispose the bus + unsubscribe fleet:rpc.
|
|
269
|
+
let fleetBus: FleetEventBus | null = null;
|
|
270
|
+
let unsubscribeRpc: (() => void) | null = null;
|
|
266
271
|
// SPEC-6-3: hoisted so /fleet command handler + session_shutdown can reach them.
|
|
267
272
|
let wfController: WorkflowController | null = null;
|
|
268
273
|
let wfStore: WorkflowRunStore | null = null;
|
|
@@ -289,14 +294,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
289
294
|
...(isolated ? { artifactDiscovery: ({ finalText, cwd, baseRef }: { finalText: string; cwd: string; baseRef: string }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText) } : {}),
|
|
290
295
|
spawn: withModelFallbackRetry(async (o) => spawnSubagent({
|
|
291
296
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
297
|
+
mode: opts.fleetMode ?? "background",
|
|
292
298
|
skillsOverride: o.skills, backendOverride: o.backend,
|
|
293
299
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
|
|
294
300
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
|
|
295
301
|
parentCwd: isolated ? opts.worktreePath! : deps.parentCwd,
|
|
302
|
+
// #62: in-place bg runs honor the lifecycle cwd resolution (lifecycle.cwd ?? entryCwd);
|
|
303
|
+
// isolated runs pin the phase cwd to the worktree. parentCwd stays the SESSION cwd for
|
|
304
|
+
// in-place runs so the cross-cwd surfacing (↗ glyph + sessionCwd audit) still fires.
|
|
305
|
+
cwd: isolated ? opts.worktreePath : o.cwd,
|
|
296
306
|
runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
297
307
|
}), deps.defaultModelFallback),
|
|
298
308
|
};
|
|
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" } });
|
|
309
|
+
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
310
|
return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
|
|
301
311
|
};
|
|
302
312
|
// asyncRunnerDeps + scheduler are built per-session (need the session cwd); wired on session_start.
|
|
@@ -355,6 +365,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
355
365
|
// SPEC-5b-1: per-session RunLog at .pi/fleet/conversations/ (separate from the
|
|
356
366
|
// SPEC-5a phase journal at .pi/fleet/runs/ — different granularity, no filename collision).
|
|
357
367
|
deps.runLog = new RunLog(join(dir, "conversations"));
|
|
368
|
+
// SPEC-6-4: ONE shared RunJournal per session — the FleetEventBus subscribes to THIS
|
|
369
|
+
// instance, so every journal append (phases AND bookends) reaches the bus exactly once.
|
|
370
|
+
const fleetJournal = new RunJournal(join(dir, "runs"));
|
|
358
371
|
// v0.10.2: pass the in-memory RunRegistry so reconcile syncs it too — otherwise orphaned
|
|
359
372
|
// (process-gone) runs keep status:"running" in memory and the live widget shows a stale ▶ forever.
|
|
360
373
|
// #22 bg-watchdog: pass todoSync so a process-gone run's linked TODO is reverted to open
|
|
@@ -366,10 +379,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
366
379
|
ctx.ui.notify(`reconciled ${reconciled.length} interrupted fleet run${reconciled.length > 1 ? "s" : ""} (marked aborted; linked TODOs reverted to open)`, "info");
|
|
367
380
|
}
|
|
368
381
|
});
|
|
382
|
+
const sessionWorktree = new WorktreeService({ rootDir: ctx.cwd });
|
|
369
383
|
deps.asyncRunner = {
|
|
370
|
-
worktree:
|
|
384
|
+
worktree: sessionWorktree,
|
|
385
|
+
// #62: cross-cwd bg dispatches get a per-dispatch worktree service rooted at the dispatch
|
|
386
|
+
// cwd, so isolation:'worktree' creates the worktree from the CHILD's repo (the session
|
|
387
|
+
// service would create it from the session's repo — the #62 gap). Same-cwd → shared.
|
|
388
|
+
worktreeFor: (cwd: string) => (cwd === ctx.cwd ? sessionWorktree : new WorktreeService({ rootDir: cwd })),
|
|
371
389
|
diff: new DiffService(),
|
|
372
|
-
journal:
|
|
390
|
+
journal: fleetJournal,
|
|
373
391
|
pool: new ConcurrencyPool(3),
|
|
374
392
|
inbox: resultsInbox,
|
|
375
393
|
runLifecycle: asyncRunLifecycle,
|
|
@@ -383,7 +401,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
383
401
|
lockPath: join(dir, "schedules.lock"),
|
|
384
402
|
onFire: (spec) => {
|
|
385
403
|
if (!deps.asyncRunner) return;
|
|
386
|
-
runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation });
|
|
404
|
+
runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation, cwd: spec.cwd, origin: "scheduled" });
|
|
387
405
|
},
|
|
388
406
|
});
|
|
389
407
|
deps.scheduler.start();
|
|
@@ -391,6 +409,82 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
391
409
|
if (cands.length > 0) {
|
|
392
410
|
ctx.ui.notify(`${cands.length} interrupted fleet run${cands.length > 1 ? "s" : ""} — open /fleet to resume`, "info");
|
|
393
411
|
}
|
|
412
|
+
// SPEC-6-4: defensive re-entrancy — a re-entered session_start must dispose the old
|
|
413
|
+
// bus/listener BEFORE constructing new ones (dispose-after-construction kills the fresh
|
|
414
|
+
// bus at birth and orphans the previous session's subscriptions).
|
|
415
|
+
fleetBus?.dispose();
|
|
416
|
+
fleetBus = null;
|
|
417
|
+
unsubscribeRpc?.();
|
|
418
|
+
unsubscribeRpc = null;
|
|
419
|
+
// SPEC-6-4: FleetEventBus — store appends → public fleet:* events on pi.events.
|
|
420
|
+
fleetBus = new FleetEventBus({
|
|
421
|
+
runLog: deps.runLog,
|
|
422
|
+
journal: fleetJournal,
|
|
423
|
+
emit: (channel, payload) => { pi.events.emit(channel, payload); },
|
|
424
|
+
});
|
|
425
|
+
// SPEC-6-4: fleet:rpc request surface — replies on fleet:rpc:result (null replies dropped).
|
|
426
|
+
const rpcServer = new RpcServer({
|
|
427
|
+
runRegistry: deps.runRegistry,
|
|
428
|
+
runLog: deps.runLog,
|
|
429
|
+
journal: fleetJournal,
|
|
430
|
+
parentCwd: ctx.cwd,
|
|
431
|
+
hasAsyncRunner: true,
|
|
432
|
+
spawn: (params: Record<string, unknown>, runId: string) => {
|
|
433
|
+
// Detached fire-and-forget: NEVER throws — spawnSubagent journals its own fail path
|
|
434
|
+
// (run:ended + todo revert), so the caller's pre-minted runId always resolves to events.
|
|
435
|
+
void (async () => {
|
|
436
|
+
const requestedCwd = typeof params.cwd === "string" ? params.cwd : undefined;
|
|
437
|
+
const { cwd: resolvedCwd } = resolveDispatchCwd(requestedCwd, ctx.cwd);
|
|
438
|
+
if (params.background === true) {
|
|
439
|
+
// RPC background: routed through the async runner (pool slot + isolation + origin),
|
|
440
|
+
// identical to the tool's runBackground path (spec §3.2). Ghost-runId prevention:
|
|
441
|
+
// a synchronous pre-flight failure journals run:ended failed under the caller's id.
|
|
442
|
+
const handle = runBackground(String(params.task), {
|
|
443
|
+
deps: deps.asyncRunner!,
|
|
444
|
+
lifecycle: "default",
|
|
445
|
+
mode: "auto",
|
|
446
|
+
isolation: params.isolation as "worktree" | "none" | "auto" | undefined,
|
|
447
|
+
cwd: resolvedCwd ?? ctx.cwd,
|
|
448
|
+
origin: "background",
|
|
449
|
+
runId,
|
|
450
|
+
});
|
|
451
|
+
if (handle.status === "failed") {
|
|
452
|
+
deps.runLog?.append(runId, { type: "run:ended", runId, status: "failed", endedAt: Date.now(), tokenTotal: 0, error: handle.error });
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
// Foreground-semantics detached spawn (no mode — "foreground" is the truth).
|
|
457
|
+
const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
|
|
458
|
+
await spawnSubagent({
|
|
459
|
+
agent: params.agent as string,
|
|
460
|
+
task: params.task as string,
|
|
461
|
+
todoId: typeof params.todoId === "string" ? params.todoId : undefined,
|
|
462
|
+
track: params.track === true,
|
|
463
|
+
model: typeof params.model === "string" ? params.model : undefined,
|
|
464
|
+
readOnly: params.readOnly === true,
|
|
465
|
+
skillsOverride: Array.isArray(params.skills) ? params.skills as string[] : undefined,
|
|
466
|
+
registry: deps.registry,
|
|
467
|
+
todoSync: deps.todoSync,
|
|
468
|
+
runRegistry: deps.runRegistry,
|
|
469
|
+
lock: deps.lock,
|
|
470
|
+
backendRegistry: deps.backendRegistry,
|
|
471
|
+
parentModel: deps.parentModel,
|
|
472
|
+
parentCwd: ctx.cwd,
|
|
473
|
+
runLog: deps.runLog,
|
|
474
|
+
maxTurns: typeof params.maxTurns === "number" ? params.maxTurns : undefined,
|
|
475
|
+
tierRegistry: deps.tierRegistry,
|
|
476
|
+
modelRegistry: deps.modelRegistry,
|
|
477
|
+
cwd: resolvedCwd ?? ctx.cwd,
|
|
478
|
+
runId,
|
|
479
|
+
});
|
|
480
|
+
})().catch(() => {});
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
unsubscribeRpc = pi.events.on("fleet:rpc", (data: unknown) => {
|
|
484
|
+
void rpcServer.handle(data).then((reply) => {
|
|
485
|
+
if (reply) pi.events.emit("fleet:rpc:result", reply);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
394
488
|
// SPEC-5b-2: live widget (above editor). Display-only, independent
|
|
395
489
|
// of the /fleet panel. getTheme is a live getter (EditorTheme gotcha). Disposed on session end.
|
|
396
490
|
const getModelContextWindow = (m: string): number | undefined => {
|
|
@@ -492,6 +586,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
492
586
|
});
|
|
493
587
|
|
|
494
588
|
pi.on("session_shutdown", () => {
|
|
589
|
+
// SPEC-6-4: dispose the event bus + unsubscribe fleet:rpc before the other resets.
|
|
590
|
+
fleetBus?.dispose();
|
|
591
|
+
fleetBus = null;
|
|
592
|
+
unsubscribeRpc?.();
|
|
593
|
+
unsubscribeRpc = null;
|
|
495
594
|
if (fleetWidget) { fleetWidget.dispose(); fleetWidget = null; }
|
|
496
595
|
// SPEC-6-3: abort in-flight workflow children via the session-wide adapter signal.
|
|
497
596
|
// Terminal runs are not re-journaled — only non-terminal spawns observe the abort.
|
|
@@ -573,6 +672,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
573
672
|
...deps.lifecycleDeps,
|
|
574
673
|
spawn: withModelFallbackRetry(async (o) => spawnSubagent({
|
|
575
674
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
675
|
+
mode: "workflow",
|
|
576
676
|
skillsOverride: o.skills, backendOverride: o.backend,
|
|
577
677
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
|
|
578
678
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
|
package/src/panel/fleet-panel.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { runsRow, runTimelineRow } from "./runs-rows.ts";
|
|
|
16
16
|
import { messageBody, toolBody, messageHeader, toolHeader } from "./conversation-rows.ts";
|
|
17
17
|
import { buildRunsIndex } from "./runs-index.ts";
|
|
18
18
|
import type { RunLog, RunMeta, RunLogEvent, MessageEvent, ToolEvent } from "../runtime/run-log.ts";
|
|
19
|
+
import { LiveTimelineState } from "./live-timeline.ts";
|
|
19
20
|
import type { Scheduler, Schedule } from "../scheduling/scheduler.ts";
|
|
20
21
|
import type { BgRunsStore } from "./bg-runs-store.ts";
|
|
21
22
|
import { spawnSubagent, type SpawnResult } from "../engine/spawnSubagent.ts";
|
|
@@ -132,6 +133,43 @@ export class FleetPanel extends Container {
|
|
|
132
133
|
* so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
|
|
133
134
|
private readonly unsubs: (() => void)[] = [];
|
|
134
135
|
private closed = false; // SPEC-5a proper-fix: guard against double-close calling onDone twice
|
|
136
|
+
// SPEC-6-4: live mode for the timeline overlay (running runs stream; finished runs replay).
|
|
137
|
+
private liveUnsub: (() => void) | null = null;
|
|
138
|
+
private liveState: LiveTimelineState | null = null;
|
|
139
|
+
|
|
140
|
+
private renderedTimelineCount(): number {
|
|
141
|
+
return (this.runTimeline ?? []).filter((e) => e.type === "message" || e.type === "tool").length;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** SPEC-6-4: release the live subscription (overlay close / different run / panel close). */
|
|
145
|
+
private releaseLiveTimeline(): void {
|
|
146
|
+
this.liveUnsub?.();
|
|
147
|
+
this.liveUnsub = null;
|
|
148
|
+
this.liveState = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** SPEC-6-4: shared timeline-open (Runs view + gate evidence). Hydrates replay; LIVE when the
|
|
152
|
+
* run is still running — subscribes to appends and tail-follows via LiveTimelineState. */
|
|
153
|
+
private openRunTimeline(runId: string): void {
|
|
154
|
+
const runLog = this.deps.runLog;
|
|
155
|
+
if (!runLog) return;
|
|
156
|
+
const meta = buildRunsIndex(runLog.dir).find((r) => r.runId === runId) ?? null;
|
|
157
|
+
this.selectedRun = meta;
|
|
158
|
+
this.runTimeline = runLog.replay(runId);
|
|
159
|
+
this.releaseLiveTimeline();
|
|
160
|
+
if (meta?.status === "running") {
|
|
161
|
+
this.liveState = new LiveTimelineState();
|
|
162
|
+
this.liveState.index = Math.max(0, this.renderedTimelineCount() - 1);
|
|
163
|
+
this.liveUnsub = runLog.subscribe((rid, ev) => {
|
|
164
|
+
if (rid !== runId || (ev.type !== "message" && ev.type !== "tool")) return;
|
|
165
|
+
this.runTimeline = [...(this.runTimeline ?? []), ev];
|
|
166
|
+
const idx = this.liveState?.append(this.renderedTimelineCount());
|
|
167
|
+
this.selectedEventIndex = idx ?? null;
|
|
168
|
+
this.renderShell();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
this.renderShell();
|
|
172
|
+
}
|
|
135
173
|
// SPEC-6-3: Workflows view — inline Run prompt input state
|
|
136
174
|
private wfRunMode = false;
|
|
137
175
|
private wfPromptInput: Input | null = null;
|
|
@@ -205,6 +243,8 @@ export class FleetPanel extends Container {
|
|
|
205
243
|
this.closed = true;
|
|
206
244
|
for (const u of this.unsubs) u();
|
|
207
245
|
this.unsubs.length = 0;
|
|
246
|
+
// SPEC-6-4: the live timeline subscription dies with the panel.
|
|
247
|
+
this.releaseLiveTimeline();
|
|
208
248
|
this.onDone(intent ?? null);
|
|
209
249
|
}
|
|
210
250
|
|
|
@@ -324,7 +364,7 @@ export class FleetPanel extends Container {
|
|
|
324
364
|
this.selectedEventIndex = null;
|
|
325
365
|
}
|
|
326
366
|
// esc → back to Runs list (replaces the v0.6.0 panel-level escape catch).
|
|
327
|
-
tl.onCancel = () => { this.selectedRun = null; this.runTimeline = null; this.timelineList = null; this.renderShell(); };
|
|
367
|
+
tl.onCancel = () => { this.releaseLiveTimeline(); this.selectedRun = null; this.runTimeline = null; this.timelineList = null; this.renderShell(); };
|
|
328
368
|
this.timelineList = tl;
|
|
329
369
|
this.addChild(tl);
|
|
330
370
|
}
|
|
@@ -512,8 +552,7 @@ export class FleetPanel extends Container {
|
|
|
512
552
|
.flatMap((p) => p.gateResults ?? [])
|
|
513
553
|
.find((gr) => gr.runId);
|
|
514
554
|
if (agentGate?.runId && this.deps.runLog) {
|
|
515
|
-
this.
|
|
516
|
-
this.runTimeline = this.deps.runLog.replay(agentGate.runId);
|
|
555
|
+
this.openRunTimeline(agentGate.runId);
|
|
517
556
|
this.selectedLifecycle = null;
|
|
518
557
|
this.view = "runs";
|
|
519
558
|
this.renderShell();
|
|
@@ -555,8 +594,17 @@ export class FleetPanel extends Container {
|
|
|
555
594
|
return;
|
|
556
595
|
}
|
|
557
596
|
if (this.selectedRun) {
|
|
558
|
-
// SPEC-
|
|
559
|
-
//
|
|
597
|
+
// SPEC-6-4: in live mode the panel owns up/down (tail-follow cursor) — intercepted before
|
|
598
|
+
// the SelectList forward; everything else forwards as before. Finished runs: replay path.
|
|
599
|
+
if (this.liveState && (matchesKey(data, "up") || matchesKey(data, "down"))) {
|
|
600
|
+
const total = this.renderedTimelineCount();
|
|
601
|
+
const key = matchesKey(data, "up") ? "up" : "down";
|
|
602
|
+
if (this.liveState.onKey(key, total)) {
|
|
603
|
+
this.selectedEventIndex = this.liveState.index;
|
|
604
|
+
this.renderShell();
|
|
605
|
+
}
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
560
608
|
this.timelineList?.handleInput(data);
|
|
561
609
|
this.invalidate();
|
|
562
610
|
return;
|
|
@@ -645,9 +693,7 @@ export class FleetPanel extends Container {
|
|
|
645
693
|
if (matchesKey(data, "enter") || matchesKey(data, "i")) {
|
|
646
694
|
const sel = this.list.getSelectedItem();
|
|
647
695
|
if (sel) {
|
|
648
|
-
this.
|
|
649
|
-
this.runTimeline = this.deps.runLog.replay(sel.value);
|
|
650
|
-
this.renderShell();
|
|
696
|
+
this.openRunTimeline(sel.value);
|
|
651
697
|
}
|
|
652
698
|
return;
|
|
653
699
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/panel/live-timeline.ts
|
|
2
|
+
// SPEC-6-4 — tail-follow state for the live timeline overlay. Pure logic (unit-tested);
|
|
3
|
+
// the panel owns the SelectList and consults this on forwarded keys + live appends.
|
|
4
|
+
export class LiveTimelineState {
|
|
5
|
+
/** 0-based cursor into the RENDERED (message/tool-filtered) event list. */
|
|
6
|
+
index = 0;
|
|
7
|
+
/** True while the cursor rides the newest row (live appends move the view). */
|
|
8
|
+
pinned = true;
|
|
9
|
+
|
|
10
|
+
/** Handle a forwarded scroll key. Returns true when the view must re-render. */
|
|
11
|
+
onKey(key: "up" | "down", total: number): boolean {
|
|
12
|
+
if (total === 0) return false;
|
|
13
|
+
if (key === "up") {
|
|
14
|
+
if (this.index <= 0) return false;
|
|
15
|
+
this.index--;
|
|
16
|
+
this.pinned = false;
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
// down
|
|
20
|
+
if (this.index >= total - 1) return false;
|
|
21
|
+
this.index++;
|
|
22
|
+
this.pinned = this.index === total - 1;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A new event arrived (list now has `total` rendered rows). Returns the cursor to restore:
|
|
27
|
+
* the newest row while pinned, unchanged otherwise. */
|
|
28
|
+
append(total: number): number {
|
|
29
|
+
if (this.pinned) this.index = total - 1;
|
|
30
|
+
return this.index;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// src/rpc/event-bus.ts
|
|
2
|
+
// SPEC-6-4 — FleetEventBus: translates RunLog/RunJournal appends into the public fleet:*
|
|
3
|
+
// taxonomy. THE FROZEN SURFACE LIVES HERE: channel names + envelope shapes are pinned by
|
|
4
|
+
// test/fleet-event-bus.test.mts — change them and every consumer breaks loudly.
|
|
5
|
+
//
|
|
6
|
+
// Seq spaces: one per source store (RunLog event order / journal event order), per run.
|
|
7
|
+
// Replay (RpcServer) reconstructs identical seqs by walking the same orders — consumers
|
|
8
|
+
// dedupe live-vs-replay by (channel, runId, seq).
|
|
9
|
+
import type { RunJournal, JournalEvent } from "../runtime/run-journal.ts";
|
|
10
|
+
import type { RunLog, RunLogEvent } from "../runtime/run-log.ts";
|
|
11
|
+
|
|
12
|
+
export type FleetChannel =
|
|
13
|
+
| "fleet:run:started" | "fleet:run:ended"
|
|
14
|
+
| "fleet:phase:started" | "fleet:phase:completed" | "fleet:phase:failed"
|
|
15
|
+
| "fleet:child:message" | "fleet:child:tool";
|
|
16
|
+
|
|
17
|
+
export interface FleetEnvelope {
|
|
18
|
+
runId: string;
|
|
19
|
+
seq: number;
|
|
20
|
+
/** Publish time (Date.now() at translation) — not the event's own timestamp. */
|
|
21
|
+
ts: number;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FleetEventBusDeps {
|
|
26
|
+
runLog: Pick<RunLog, "subscribe">;
|
|
27
|
+
journal: Pick<RunJournal, "subscribe">;
|
|
28
|
+
/** Transport seam. In-process = (c, p) => pi.events.emit(c, p). A future external bridge
|
|
29
|
+
* re-implements ONLY this — the taxonomy above is its wire format. */
|
|
30
|
+
emit: (channel: FleetChannel, payload: FleetEnvelope) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface MetaLike { startedAt: number }
|
|
34
|
+
|
|
35
|
+
export class FleetEventBus {
|
|
36
|
+
private readonly runSeq = new Map<string, number>();
|
|
37
|
+
private readonly phaseSeq = new Map<string, number>();
|
|
38
|
+
private readonly startedAt = new Map<string, number>();
|
|
39
|
+
private readonly unsubs: Array<() => void> = [];
|
|
40
|
+
|
|
41
|
+
constructor(private readonly deps: FleetEventBusDeps) {
|
|
42
|
+
this.unsubs.push(
|
|
43
|
+
deps.runLog.subscribe((runId, event) => this.safe(() => this.onRunLogEvent(runId, event))),
|
|
44
|
+
deps.journal.subscribe((runId, event) => this.safe(() => this.onJournalEvent(runId, event))),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Unsubscribe from both stores. Call from session_shutdown. */
|
|
49
|
+
dispose(): void {
|
|
50
|
+
for (const u of this.unsubs) u();
|
|
51
|
+
this.unsubs.length = 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A bus failure must never break a run's append path. */
|
|
55
|
+
private safe(fn: () => void): void {
|
|
56
|
+
try { fn(); } catch { /* swallow: telemetry must not kill the product */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private next(map: Map<string, number>, runId: string): number {
|
|
60
|
+
const n = (map.get(runId) ?? 0) + 1;
|
|
61
|
+
map.set(runId, n);
|
|
62
|
+
return n;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private publish(channel: FleetChannel, runId: string, seq: number, payload: Record<string, unknown>): void {
|
|
66
|
+
this.deps.emit(channel, { runId, seq, ts: Date.now(), ...payload });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private onRunLogEvent(runId: string, e: RunLogEvent): void {
|
|
70
|
+
const seq = this.next(this.runSeq, runId);
|
|
71
|
+
if (e.type === "run:meta") {
|
|
72
|
+
this.startedAt.set(runId, e.startedAt);
|
|
73
|
+
this.publish("fleet:run:started", runId, seq, {
|
|
74
|
+
agent: e.agent, model: e.model, cwd: e.cwd, sessionCwd: e.sessionCwd,
|
|
75
|
+
mode: e.mode ?? "foreground", task: e.task,
|
|
76
|
+
});
|
|
77
|
+
} else if (e.type === "message") {
|
|
78
|
+
this.publish("fleet:child:message", runId, seq, { role: e.role, text: e.text });
|
|
79
|
+
} else if (e.type === "tool") {
|
|
80
|
+
this.publish("fleet:child:tool", runId, seq, { toolName: e.toolName, args: e.args, result: e.result, isError: e.isError });
|
|
81
|
+
} else if (e.type === "run:ended") {
|
|
82
|
+
const start = this.startedAt.get(runId);
|
|
83
|
+
this.publish("fleet:run:ended", runId, seq, {
|
|
84
|
+
status: e.status,
|
|
85
|
+
...(e.resultSummary !== undefined ? { result: e.resultSummary } : {}),
|
|
86
|
+
...(e.error !== undefined ? { error: e.error } : {}),
|
|
87
|
+
...(e.filesTouched !== undefined ? { filesTouched: e.filesTouched } : {}),
|
|
88
|
+
...(e.toolCallCount !== undefined ? { toolCallCount: e.toolCallCount } : {}),
|
|
89
|
+
...(start !== undefined ? { durationMs: e.endedAt - start } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private onJournalEvent(runId: string, e: JournalEvent): void {
|
|
95
|
+
// Only the phase tier is public; run:started/completed/aborted/checkpoint/agent:*/helper:*
|
|
96
|
+
// stay internal (run-level events come from RunLog, which every spawn writes).
|
|
97
|
+
if (e.type === "phase:started") {
|
|
98
|
+
this.publish("fleet:phase:started", runId, this.next(this.phaseSeq, runId), { phase: e.phase });
|
|
99
|
+
} else if (e.type === "phase:completed") {
|
|
100
|
+
this.publish("fleet:phase:completed", runId, this.next(this.phaseSeq, runId), { phase: e.phase, summary: e.summary, paths: e.paths });
|
|
101
|
+
} else if (e.type === "phase:failed") {
|
|
102
|
+
this.publish("fleet:phase:failed", runId, this.next(this.phaseSeq, runId), { phase: e.phase, error: e.error });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// src/rpc/rpc-server.ts
|
|
2
|
+
// SPEC-6-4 — the fleet:rpc verb surface. Frozen: verb names, param contracts, reply envelope
|
|
3
|
+
// { id, ok, data | error{code,message} }, and the error-code enum — all pinned by
|
|
4
|
+
// test/rpc-server.test.mts. handle() NEVER throws and replies EXACTLY once per request.
|
|
5
|
+
import { genRunId } from "../engine/run-registry.ts";
|
|
6
|
+
import type { RunRegistry, RunRecord } from "../engine/run-registry.ts";
|
|
7
|
+
import type { RunLog } from "../runtime/run-log.ts";
|
|
8
|
+
import type { RunJournal } from "../runtime/run-journal.ts";
|
|
9
|
+
import { resolveDispatchCwd } from "../tools/subagent.ts";
|
|
10
|
+
|
|
11
|
+
export type RpcErrorCode =
|
|
12
|
+
| "E-CONTROL-DISABLED" | "E-RUN-NOT-FOUND" | "E-RUN-FINISHED" | "E-BAD-VERB"
|
|
13
|
+
| "E-BAD-PARAMS" | "E-STEER-UNSUPPORTED" | "E-INTERNAL";
|
|
14
|
+
|
|
15
|
+
export interface RpcRequest { id: string; verb: string; params?: unknown }
|
|
16
|
+
export type RpcReply =
|
|
17
|
+
| { id: string; ok: true; data: unknown }
|
|
18
|
+
| { id: string; ok: false; error: { code: RpcErrorCode; message: string } };
|
|
19
|
+
|
|
20
|
+
/** SPEC-6-4 gate: ON unless ARMORY_FLEET_RPC_CONTROL is "0"/"false" (case-insensitive).
|
|
21
|
+
* Read-only verbs (observe/status) ignore this. Honest threat model: in-process extensions
|
|
22
|
+
* already have full system access via pi itself — the gate guards accidents, not adversaries. */
|
|
23
|
+
export function rpcControlEnabled(env: string | undefined = process.env.ARMORY_FLEET_RPC_CONTROL): boolean {
|
|
24
|
+
const v = (env ?? "").trim().toLowerCase();
|
|
25
|
+
return !(v === "0" || v === "false");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RpcRunSummary {
|
|
29
|
+
runId: string; agent: string; model: string; status: string; startedAt: number;
|
|
30
|
+
endedAt?: number; task: string; cwd?: string; resultSummary?: string; tokenTotal?: number; sessionKey?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RpcServerDeps {
|
|
34
|
+
runRegistry: Pick<RunRegistry, "get" | "list">;
|
|
35
|
+
runLog: Pick<RunLog, "replay">;
|
|
36
|
+
journal: Pick<RunJournal, "replay">;
|
|
37
|
+
parentCwd: string;
|
|
38
|
+
hasAsyncRunner: boolean;
|
|
39
|
+
/** Detached spawn: index.ts builds the real spawnSubagent invocation (foreground or bg routing).
|
|
40
|
+
* Never throws — runtime failures land via the registry + RunLog journal (spawnSubagent's own
|
|
41
|
+
* fail path journals run:ended), so the caller's { runId } always resolves to a real run. */
|
|
42
|
+
spawn: (params: Record<string, unknown>, runId: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LIST_CAP = 25;
|
|
46
|
+
const TASK_SUMMARY_CAP = 80;
|
|
47
|
+
|
|
48
|
+
function summarize(r: RunRecord): RpcRunSummary {
|
|
49
|
+
const task = r.task.length > TASK_SUMMARY_CAP ? r.task.slice(0, TASK_SUMMARY_CAP - 1) + "…" : r.task;
|
|
50
|
+
return {
|
|
51
|
+
runId: r.runId, agent: r.agent, model: r.model, status: r.status, startedAt: r.startedAt,
|
|
52
|
+
...(r.endedAt !== undefined ? { endedAt: r.endedAt } : {}),
|
|
53
|
+
task, ...(r.cwd ? { cwd: r.cwd } : {}),
|
|
54
|
+
...(r.resultSummary !== undefined ? { resultSummary: r.resultSummary } : {}),
|
|
55
|
+
...(r.tokenTotal !== undefined ? { tokenTotal: r.tokenTotal } : {}),
|
|
56
|
+
...(r.sessionKey ? { sessionKey: r.sessionKey } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class RpcServer {
|
|
61
|
+
constructor(
|
|
62
|
+
private readonly deps: RpcServerDeps,
|
|
63
|
+
private readonly controlEnabled: () => boolean = rpcControlEnabled,
|
|
64
|
+
) {}
|
|
65
|
+
|
|
66
|
+
/** Returns the reply, or null for a malformed request with no usable id (caller drops).
|
|
67
|
+
* Never throws — a handler exception becomes E-INTERNAL. */
|
|
68
|
+
async handle(req: unknown): Promise<RpcReply | null> {
|
|
69
|
+
const id = requestId(req);
|
|
70
|
+
try {
|
|
71
|
+
return await this.dispatch(req, id);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (!id) return null;
|
|
74
|
+
return { id, ok: false, error: { code: "E-INTERNAL", message: `unexpected rpc failure: ${(e as Error).message}` } };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async dispatch(req: unknown, id: string | null): Promise<RpcReply | null> {
|
|
79
|
+
if (!req || typeof req !== "object" || !id) return null;
|
|
80
|
+
const { verb, params } = req as Record<string, unknown>;
|
|
81
|
+
if (typeof verb !== "string") return this.err(id, "E-BAD-VERB", "missing verb");
|
|
82
|
+
const gated = this.controlEnabled();
|
|
83
|
+
switch (verb) {
|
|
84
|
+
case "spawn": return gated ? this.spawnVerb(id, params) : this.controlDisabled(id);
|
|
85
|
+
case "steer": return gated ? this.steerVerb(id, params) : this.controlDisabled(id);
|
|
86
|
+
case "abort": return gated ? this.abortVerb(id, params) : this.controlDisabled(id);
|
|
87
|
+
case "observe": return this.observeVerb(id, params);
|
|
88
|
+
case "status": return this.statusVerb(id, params);
|
|
89
|
+
default: return this.err(id, "E-BAD-VERB", `unknown verb '${verb}' (known: spawn, steer, observe, abort, status)`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private controlDisabled(id: string): RpcReply {
|
|
94
|
+
return this.err(id, "E-CONTROL-DISABLED", "fleet rpc control is disabled (ARMORY_FLEET_RPC_CONTROL is set to off; remove it or set it to 1 to enable spawn/steer/abort)");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private err(id: string, code: RpcErrorCode, message: string): RpcReply {
|
|
98
|
+
return { id, ok: false, error: { code, message } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private obj(params: unknown): Record<string, unknown> | null {
|
|
102
|
+
return params && typeof params === "object" ? params as Record<string, unknown> : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private spawnVerb(id: string, params: unknown): RpcReply {
|
|
106
|
+
const p = this.obj(params);
|
|
107
|
+
if (!p) return this.err(id, "E-BAD-PARAMS", "spawn requires params: { agent, task, ... }");
|
|
108
|
+
if (typeof p.agent !== "string" || !p.agent) return this.err(id, "E-BAD-PARAMS", "params.agent must be a non-empty string");
|
|
109
|
+
if (typeof p.task !== "string" || !p.task) return this.err(id, "E-BAD-PARAMS", "params.task must be a non-empty string");
|
|
110
|
+
if (p.lifecycle !== undefined) return this.err(id, "E-BAD-PARAMS", "params.lifecycle is not supported over RPC spawn yet (single-delegate + background only — spec §7)");
|
|
111
|
+
if (p.schedule !== undefined) return this.err(id, "E-BAD-PARAMS", "params.schedule is not supported over RPC spawn yet");
|
|
112
|
+
if (p.modelFallback !== undefined) return this.err(id, "E-BAD-PARAMS", "params.modelFallback is not supported over RPC spawn yet");
|
|
113
|
+
if (p.cwd !== undefined && (typeof p.cwd !== "string" || p.cwd === "")) return this.err(id, "E-BAD-PARAMS", "params.cwd must be a non-empty string when set");
|
|
114
|
+
if (p.cwd !== undefined) {
|
|
115
|
+
const { error } = resolveDispatchCwd(p.cwd, this.deps.parentCwd);
|
|
116
|
+
if (error) return this.err(id, "E-BAD-PARAMS", error);
|
|
117
|
+
}
|
|
118
|
+
if (p.background !== undefined && typeof p.background !== "boolean") return this.err(id, "E-BAD-PARAMS", "params.background must be a boolean");
|
|
119
|
+
if (p.background && !this.deps.hasAsyncRunner) return this.err(id, "E-BAD-PARAMS", "background runs not configured in this session (asyncRunner missing)");
|
|
120
|
+
if (p.isolation !== undefined && p.isolation !== "worktree" && p.isolation !== "none" && p.isolation !== "auto") {
|
|
121
|
+
return this.err(id, "E-BAD-PARAMS", "params.isolation must be 'worktree' | 'none' | 'auto'");
|
|
122
|
+
}
|
|
123
|
+
if (p.maxTurns !== undefined && (typeof p.maxTurns !== "number" || !Number.isInteger(p.maxTurns) || p.maxTurns < 1)) {
|
|
124
|
+
return this.err(id, "E-BAD-PARAMS", "params.maxTurns must be a positive integer");
|
|
125
|
+
}
|
|
126
|
+
if (p.readOnly !== undefined && typeof p.readOnly !== "boolean") return this.err(id, "E-BAD-PARAMS", "params.readOnly must be a boolean");
|
|
127
|
+
if (p.track !== undefined && typeof p.track !== "boolean") return this.err(id, "E-BAD-PARAMS", "params.track must be a boolean");
|
|
128
|
+
if (p.todoId !== undefined && typeof p.todoId !== "string") return this.err(id, "E-BAD-PARAMS", "params.todoId must be a string");
|
|
129
|
+
if (p.model !== undefined && (typeof p.model !== "string" || !p.model)) return this.err(id, "E-BAD-PARAMS", "params.model must be a non-empty string when set");
|
|
130
|
+
if (p.skills !== undefined && (!Array.isArray(p.skills) || !p.skills.every((s) => typeof s === "string"))) {
|
|
131
|
+
return this.err(id, "E-BAD-PARAMS", "params.skills must be an array of strings");
|
|
132
|
+
}
|
|
133
|
+
const runId = genRunId();
|
|
134
|
+
this.deps.spawn(p, runId);
|
|
135
|
+
return { id, ok: true, data: { runId } };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private statusVerb(id: string, params: unknown): RpcReply {
|
|
139
|
+
const p = this.obj(params) ?? {};
|
|
140
|
+
if (p.runId !== undefined) {
|
|
141
|
+
if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
|
|
142
|
+
const rec = this.deps.runRegistry.get(p.runId);
|
|
143
|
+
if (!rec) return this.err(id, "E-RUN-NOT-FOUND", `no live run '${p.runId}' in the registry (finished runs older than the session are not listed)`);
|
|
144
|
+
return { id, ok: true, data: { runs: [summarize(rec)] } };
|
|
145
|
+
}
|
|
146
|
+
const runs = this.deps.runRegistry.list().slice(0, LIST_CAP).map(summarize);
|
|
147
|
+
return { id, ok: true, data: { runs } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private observeVerb(id: string, params: unknown): RpcReply {
|
|
151
|
+
const p = this.obj(params);
|
|
152
|
+
if (!p) return this.err(id, "E-BAD-PARAMS", "observe requires params: { runId, tier? }");
|
|
153
|
+
if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
|
|
154
|
+
const tier = p.tier ?? "both";
|
|
155
|
+
if (tier !== "lifecycle" && tier !== "child" && tier !== "both") {
|
|
156
|
+
return this.err(id, "E-BAD-PARAMS", "params.tier must be 'lifecycle' | 'child' | 'both'");
|
|
157
|
+
}
|
|
158
|
+
const logEvents = this.deps.runLog.replay(p.runId);
|
|
159
|
+
const journalEvents = this.deps.journal.replay(p.runId);
|
|
160
|
+
if (logEvents.length === 0 && journalEvents.length === 0) {
|
|
161
|
+
return this.err(id, "E-RUN-NOT-FOUND", `no journaled run '${p.runId}'`);
|
|
162
|
+
}
|
|
163
|
+
const events: Array<{ channel: string; payload: Record<string, unknown> }> = [];
|
|
164
|
+
// Seq = position in the FULL store event list (index + 1) — identical to the live bus's
|
|
165
|
+
// per-store dense counting, so the (channel, runId, seq) dedupe contract holds across
|
|
166
|
+
// the live→replay handoff. Tier filters decide WHICH entries emit, not how they count.
|
|
167
|
+
// Journal exception: the live bus increments phaseSeq ONLY on the three phase types, so
|
|
168
|
+
// replay filters to phases BEFORE counting — real lifecycle runs bookend the journal with
|
|
169
|
+
// run:started/completed, and position-in-full-list would overshoot by the bookends.
|
|
170
|
+
const phases = journalEvents.filter((e) => e.type === "phase:started" || e.type === "phase:completed" || e.type === "phase:failed");
|
|
171
|
+
if (tier === "lifecycle" || tier === "both") {
|
|
172
|
+
logEvents.forEach((e, i) => {
|
|
173
|
+
if (e.type === "run:meta") {
|
|
174
|
+
events.push({ channel: "fleet:run:started", payload: { seq: i + 1, agent: e.agent, model: e.model, cwd: e.cwd, sessionCwd: e.sessionCwd, mode: e.mode ?? "foreground", task: e.task, ts: e.startedAt } });
|
|
175
|
+
} else if (e.type === "run:ended") {
|
|
176
|
+
events.push({
|
|
177
|
+
channel: "fleet:run:ended",
|
|
178
|
+
payload: { seq: i + 1, status: e.status, ts: e.endedAt,
|
|
179
|
+
...(e.resultSummary !== undefined ? { result: e.resultSummary } : {}),
|
|
180
|
+
...(e.error !== undefined ? { error: e.error } : {}),
|
|
181
|
+
...(e.filesTouched !== undefined ? { filesTouched: e.filesTouched } : {}),
|
|
182
|
+
...(e.toolCallCount !== undefined ? { toolCallCount: e.toolCallCount } : {}) },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
phases.forEach((e, i) => {
|
|
187
|
+
if (e.type === "phase:started") events.push({ channel: "fleet:phase:started", payload: { seq: i + 1, phase: e.phase, ts: e.ts } });
|
|
188
|
+
else if (e.type === "phase:completed") events.push({ channel: "fleet:phase:completed", payload: { seq: i + 1, phase: e.phase, summary: e.summary, paths: e.paths, ts: e.ts } });
|
|
189
|
+
else if (e.type === "phase:failed") events.push({ channel: "fleet:phase:failed", payload: { seq: i + 1, phase: e.phase, error: e.error, ts: e.ts } });
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (tier === "child" || tier === "both") {
|
|
193
|
+
logEvents.forEach((e, i) => {
|
|
194
|
+
if (e.type === "message") events.push({ channel: "fleet:child:message", payload: { seq: i + 1, role: e.role, text: e.text } });
|
|
195
|
+
else if (e.type === "tool") events.push({ channel: "fleet:child:tool", payload: { seq: i + 1, toolName: e.toolName, args: e.args, result: e.result, isError: e.isError } });
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return { id, ok: true, data: { runId: p.runId, tier, events } };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async steerVerb(id: string, params: unknown): Promise<RpcReply> {
|
|
202
|
+
const p = this.obj(params);
|
|
203
|
+
if (!p) return this.err(id, "E-BAD-PARAMS", "steer requires params: { runId, message }");
|
|
204
|
+
if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
|
|
205
|
+
if (typeof p.message !== "string" || !p.message) return this.err(id, "E-BAD-PARAMS", "params.message must be a non-empty string");
|
|
206
|
+
const rec = this.deps.runRegistry.get(p.runId);
|
|
207
|
+
if (!rec) return this.err(id, "E-RUN-NOT-FOUND", `no live run '${p.runId}' in the registry`);
|
|
208
|
+
const session = rec.session;
|
|
209
|
+
if (!session) return this.err(id, "E-RUN-FINISHED", `run '${p.runId}' has no live session (status: ${rec.status})`);
|
|
210
|
+
if (!session.supportsSteer) return this.err(id, "E-STEER-UNSUPPORTED", `run '${p.runId}' backend has no steer support (claude children)`);
|
|
211
|
+
try {
|
|
212
|
+
await session.steer(p.message);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
const msg = (e as Error).message ?? "steer failed";
|
|
215
|
+
if (msg.includes("not supported")) return this.err(id, "E-STEER-UNSUPPORTED", msg);
|
|
216
|
+
return this.err(id, "E-INTERNAL", `steer failed: ${msg}`);
|
|
217
|
+
}
|
|
218
|
+
return { id, ok: true, data: { steered: true } };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async abortVerb(id: string, params: unknown): Promise<RpcReply> {
|
|
222
|
+
const p = this.obj(params);
|
|
223
|
+
if (!p) return this.err(id, "E-BAD-PARAMS", "abort requires params: { runId }");
|
|
224
|
+
if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
|
|
225
|
+
const rec = this.deps.runRegistry.get(p.runId);
|
|
226
|
+
if (!rec) return this.err(id, "E-RUN-NOT-FOUND", `no live run '${p.runId}' in the registry`);
|
|
227
|
+
const session = rec.session;
|
|
228
|
+
if (!session) return this.err(id, "E-RUN-FINISHED", `run '${p.runId}' has no live session (status: ${rec.status})`);
|
|
229
|
+
try {
|
|
230
|
+
await session.abort();
|
|
231
|
+
} catch (e) {
|
|
232
|
+
const msg = (e as Error).message ?? "abort failed";
|
|
233
|
+
if (msg.includes("already")) return this.err(id, "E-RUN-FINISHED", msg);
|
|
234
|
+
return this.err(id, "E-INTERNAL", `abort failed: ${msg}`);
|
|
235
|
+
}
|
|
236
|
+
return { id, ok: true, data: { aborted: true } };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function requestId(req: unknown): string | null {
|
|
241
|
+
if (!req || typeof req !== "object") return null;
|
|
242
|
+
const id = (req as Record<string, unknown>).id;
|
|
243
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
244
|
+
}
|
|
@@ -26,6 +26,11 @@ export interface RunLifecycleOpts {
|
|
|
26
26
|
worktreePath?: string;
|
|
27
27
|
branch?: string;
|
|
28
28
|
mode: "auto" | "checkpointed";
|
|
29
|
+
/** SPEC-6-4: origin threading to the bg lifecycle spawn adapter (→ spawnSubagent mode → run:meta). */
|
|
30
|
+
fleetMode?: "background" | "scheduled";
|
|
31
|
+
/** #62: the dispatch target cwd for in-place runs (isolated runs pass the worktree path).
|
|
32
|
+
* Flows into runLifecycle's SPEC-6-5 cwd resolution (lifecycle.cwd ?? entryCwd). */
|
|
33
|
+
entryCwd?: string;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
export type RunLifecycleFn = (task: string, lifecycleName: string, opts: RunLifecycleOpts) => Promise<FakeLifecycleResult>;
|
|
@@ -43,14 +48,28 @@ export interface AsyncRunnerDeps {
|
|
|
43
48
|
onProgress?: (runId: string, status: import("../panel/rows.ts").BgRunStatus) => void;
|
|
44
49
|
/** SPEC-6-2: the RunRegistry so emitProgress can read the run's actual backend. */
|
|
45
50
|
runRegistry?: import("../engine/run-registry.ts").RunRegistry;
|
|
51
|
+
/** #62: per-dispatch worktree service for cross-cwd bg runs (rootDir = the dispatch cwd).
|
|
52
|
+
* Consulted whenever a bg dispatch carries a `cwd`; absent → deps.worktree (session-scoped,
|
|
53
|
+
* pre-#62 behavior). index.ts wires it so a cross-cwd worktree is created from the CHILD's
|
|
54
|
+
* repo, not the session's. */
|
|
55
|
+
worktreeFor?: (cwd: string) => WorktreeService;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
export interface RunBackgroundOpts {
|
|
49
59
|
deps: AsyncRunnerDeps;
|
|
50
60
|
lifecycle: string;
|
|
51
61
|
mode: "auto" | "checkpointed";
|
|
62
|
+
/** SPEC-6-4: dispatch origin for fleet:run:started `mode`. "scheduled" when fired by the scheduler. */
|
|
63
|
+
origin?: "background" | "scheduled";
|
|
64
|
+
/** SPEC-6-4: pre-minted runId (RPC spawn replies with the id BEFORE the detached bg run starts).
|
|
65
|
+
* Absent → the runner mints via deps.genRunId() exactly as before. */
|
|
66
|
+
runId?: string;
|
|
52
67
|
/** v0.11.1: edit isolation for background runs. Default "auto" (worktree when cwd is a git repo, in-place otherwise). */
|
|
53
68
|
isolation?: Isolation;
|
|
69
|
+
/** #62: the dispatch target cwd (undefined = session cwd, back-compat). Scopes the run:
|
|
70
|
+
* isolation routing + worktree creation resolve against THIS cwd (via deps.worktreeFor),
|
|
71
|
+
* and in-place runs pass it as the lifecycle entryCwd. */
|
|
72
|
+
cwd?: string;
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
/** The success shape (back-compat: existing external refs to RunBackgroundHandle still typecheck). */
|
|
@@ -82,9 +101,16 @@ function sh(cmd: string, cwd: string): void {
|
|
|
82
101
|
execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
83
102
|
}
|
|
84
103
|
|
|
104
|
+
/** #62: the worktree service for a dispatch — per-dispatch (rootDir = the dispatch cwd) when
|
|
105
|
+
* deps.worktreeFor is wired, else the session-scoped deps.worktree (back-compat). */
|
|
106
|
+
function worktreeFor(deps: AsyncRunnerDeps, cwd?: string): WorktreeService {
|
|
107
|
+
if (!cwd) return deps.worktree;
|
|
108
|
+
return deps.worktreeFor?.(cwd) ?? deps.worktree;
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
/** The git-agnostic core: pool slot → journal → runLifecycle in ctx.cwd (or a worktree, when `isolated` is set) → inbox + notify.
|
|
86
112
|
* 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 {
|
|
113
|
+
function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOpts, isolated?: { worktreePath: string; branch: string }, worktree: WorktreeService = opts.deps.worktree): void {
|
|
88
114
|
const { deps } = opts;
|
|
89
115
|
void deps.pool.withSlot(async () => {
|
|
90
116
|
try {
|
|
@@ -94,7 +120,7 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
|
|
|
94
120
|
deps.journal.append(runId, ev0);
|
|
95
121
|
emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
|
|
96
122
|
|
|
97
|
-
const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode });
|
|
123
|
+
const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode, entryCwd: isolated ? isolated.worktreePath : opts.cwd, fleetMode: opts.origin ?? "background" });
|
|
98
124
|
|
|
99
125
|
if (res.status === "completed") {
|
|
100
126
|
if (isolated) {
|
|
@@ -116,17 +142,17 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
|
|
|
116
142
|
};
|
|
117
143
|
deps.inbox.push(result);
|
|
118
144
|
deps.notify(`fleet run ${runId} completed`, "info");
|
|
119
|
-
if (isolated)
|
|
145
|
+
if (isolated) worktree.removeWorktree(runId);
|
|
120
146
|
} else {
|
|
121
147
|
deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() });
|
|
122
|
-
if (isolated)
|
|
148
|
+
if (isolated) worktree.remove(runId);
|
|
123
149
|
emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: res.phases.length, lifecycle: opts.lifecycle, mode: opts.mode, task });
|
|
124
150
|
deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning");
|
|
125
151
|
}
|
|
126
152
|
} catch (e) {
|
|
127
153
|
const msg = (e as Error).message;
|
|
128
154
|
deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() });
|
|
129
|
-
if (isolated)
|
|
155
|
+
if (isolated) worktree.remove(runId);
|
|
130
156
|
deps.notify(`fleet run ${runId} failed: ${msg}`, "error");
|
|
131
157
|
emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
|
|
132
158
|
}
|
|
@@ -135,34 +161,34 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
|
|
|
135
161
|
|
|
136
162
|
/** The worktree wrapper: SYNCHRONOUS pre-flight + worktree create, then core-with-isolation. */
|
|
137
163
|
function runBackgroundIsolated(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
|
|
138
|
-
const
|
|
139
|
-
if (!
|
|
164
|
+
const worktree = worktreeFor(opts.deps, opts.cwd);
|
|
165
|
+
if (!worktree.isGitRepo()) {
|
|
140
166
|
return { status: "failed", error: "isolation: 'worktree' requires a git repo; cwd is not one — use isolation: 'none' or run in a git repo" };
|
|
141
167
|
}
|
|
142
|
-
const runId = deps.genRunId();
|
|
168
|
+
const runId = opts.runId ?? opts.deps.genRunId();
|
|
143
169
|
const baseRef = "HEAD";
|
|
144
170
|
let wt: { path: string; branch: string };
|
|
145
171
|
try {
|
|
146
|
-
wt =
|
|
172
|
+
wt = worktree.create(runId, baseRef);
|
|
147
173
|
} catch (e) {
|
|
148
174
|
return { status: "failed", error: (e as Error).message };
|
|
149
175
|
}
|
|
150
|
-
runBackgroundInPlace(runId, task, opts, { worktreePath: wt.path, branch: wt.branch });
|
|
176
|
+
runBackgroundInPlace(runId, task, opts, { worktreePath: wt.path, branch: wt.branch }, worktree);
|
|
151
177
|
return { runId, status: "background" };
|
|
152
178
|
}
|
|
153
179
|
|
|
154
|
-
/** The auto router: isolated when cwd is a git repo, in-place + one per-session notify when not. */
|
|
180
|
+
/** The auto router: isolated when the DISPATCH cwd is a git repo, in-place + one per-session notify when not. */
|
|
155
181
|
function runBackgroundAuto(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
182
|
+
const worktree = worktreeFor(opts.deps, opts.cwd);
|
|
183
|
+
if (worktree.isGitRepo()) {
|
|
158
184
|
return runBackgroundIsolated(task, opts);
|
|
159
185
|
}
|
|
160
186
|
if (!inPlaceFallbackWarned) {
|
|
161
187
|
inPlaceFallbackWarned = true;
|
|
162
|
-
deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
|
|
188
|
+
opts.deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
|
|
163
189
|
}
|
|
164
|
-
const runId = deps.genRunId();
|
|
165
|
-
runBackgroundInPlace(runId, task, opts);
|
|
190
|
+
const runId = opts.runId ?? opts.deps.genRunId();
|
|
191
|
+
runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
|
|
166
192
|
return { runId, status: "background" };
|
|
167
193
|
}
|
|
168
194
|
|
|
@@ -171,8 +197,8 @@ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgro
|
|
|
171
197
|
const isolation = opts.isolation ?? "auto";
|
|
172
198
|
if (isolation === "worktree") return runBackgroundIsolated(task, opts);
|
|
173
199
|
if (isolation === "none") {
|
|
174
|
-
const runId = opts.deps.genRunId();
|
|
175
|
-
runBackgroundInPlace(runId, task, opts);
|
|
200
|
+
const runId = opts.runId ?? opts.deps.genRunId();
|
|
201
|
+
runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
|
|
176
202
|
return { runId, status: "background" };
|
|
177
203
|
}
|
|
178
204
|
return runBackgroundAuto(task, opts);
|
|
@@ -25,9 +25,20 @@ export class RunJournal {
|
|
|
25
25
|
return join(this.dir, `${runId}.jsonl`);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** SPEC-6-4: append fan-out (FleetEventBus phase tier). Same contract as RunLog.subscribe. */
|
|
29
|
+
private readonly subscribers = new Set<(runId: string, event: JournalEvent) => void>();
|
|
30
|
+
|
|
31
|
+
subscribe(fn: (runId: string, event: JournalEvent) => void): () => void {
|
|
32
|
+
this.subscribers.add(fn);
|
|
33
|
+
return () => { this.subscribers.delete(fn); };
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
append(runId: string, event: JournalEvent): void {
|
|
29
37
|
mkdirSync(this.dir, { recursive: true });
|
|
30
38
|
appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8");
|
|
39
|
+
for (const fn of this.subscribers) {
|
|
40
|
+
try { fn(runId, event); } catch { /* a faulty subscriber must not fail the append or others */ }
|
|
41
|
+
}
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
replay(runId: string): JournalEvent[] {
|
package/src/runtime/run-log.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface RunMetaEvent {
|
|
|
17
17
|
cwd?: string;
|
|
18
18
|
/** SPEC-6-5: the session cwd the dispatch originated from (= parentCwd). */
|
|
19
19
|
sessionCwd?: string;
|
|
20
|
+
/** SPEC-6-4: dispatch origin — fleet:run:started `mode`. Default "foreground". */
|
|
21
|
+
mode?: "foreground" | "background" | "scheduled" | "workflow";
|
|
20
22
|
}
|
|
21
23
|
export interface MessageEvent {
|
|
22
24
|
type: "message"; role: string; text: string;
|
|
@@ -77,12 +79,25 @@ export class RunLog {
|
|
|
77
79
|
|
|
78
80
|
private file(runId: string): string { return join(this.dir, `${runId}.jsonl`); }
|
|
79
81
|
|
|
82
|
+
/** SPEC-6-4: append fan-out (the FleetEventBus + live overlay subscribe). Fired synchronously
|
|
83
|
+
* after a successful write, in append order. A throwing subscriber never fails the append. */
|
|
84
|
+
private readonly subscribers = new Set<(runId: string, event: RunLogEvent) => void>();
|
|
85
|
+
|
|
86
|
+
subscribe(fn: (runId: string, event: RunLogEvent) => void): () => void {
|
|
87
|
+
this.subscribers.add(fn);
|
|
88
|
+
return () => { this.subscribers.delete(fn); };
|
|
89
|
+
}
|
|
90
|
+
|
|
80
91
|
append(runId: string, event: RunLogEvent): void {
|
|
81
92
|
try {
|
|
82
93
|
mkdirSync(this.dir, { recursive: true });
|
|
83
94
|
appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8");
|
|
84
95
|
} catch {
|
|
85
96
|
// best-effort: the run is the product; the journal is the index. Never fail the run.
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const fn of this.subscribers) {
|
|
100
|
+
try { fn(runId, event); } catch { /* a faulty subscriber must not fail the append or others */ }
|
|
86
101
|
}
|
|
87
102
|
}
|
|
88
103
|
|
|
@@ -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
|
}
|