@getpipher/armory-fleet 0.16.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 +65 -1
- package/package.json +1 -1
- package/src/engine/spawnSubagent.ts +7 -3
- package/src/index.ts +94 -3
- 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 +11 -4
- package/src/runtime/run-journal.ts +11 -0
- package/src/runtime/run-log.ts +15 -0
package/README.md
CHANGED
|
@@ -328,7 +328,71 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
|
|
|
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
330
|
|
|
331
|
-
##
|
|
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
|
+
|
|
332
396
|
|
|
333
397
|
The #62 tail of SPEC-6-5: `subagent({ cwd })` now scopes **background and scheduled** runs too, not just foreground —
|
|
334
398
|
|
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,6 +294,7 @@ 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,
|
|
@@ -359,6 +365,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
359
365
|
// SPEC-5b-1: per-session RunLog at .pi/fleet/conversations/ (separate from the
|
|
360
366
|
// SPEC-5a phase journal at .pi/fleet/runs/ — different granularity, no filename collision).
|
|
361
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"));
|
|
362
371
|
// v0.10.2: pass the in-memory RunRegistry so reconcile syncs it too — otherwise orphaned
|
|
363
372
|
// (process-gone) runs keep status:"running" in memory and the live widget shows a stale ▶ forever.
|
|
364
373
|
// #22 bg-watchdog: pass todoSync so a process-gone run's linked TODO is reverted to open
|
|
@@ -378,7 +387,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
378
387
|
// service would create it from the session's repo — the #62 gap). Same-cwd → shared.
|
|
379
388
|
worktreeFor: (cwd: string) => (cwd === ctx.cwd ? sessionWorktree : new WorktreeService({ rootDir: cwd })),
|
|
380
389
|
diff: new DiffService(),
|
|
381
|
-
journal:
|
|
390
|
+
journal: fleetJournal,
|
|
382
391
|
pool: new ConcurrencyPool(3),
|
|
383
392
|
inbox: resultsInbox,
|
|
384
393
|
runLifecycle: asyncRunLifecycle,
|
|
@@ -392,7 +401,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
392
401
|
lockPath: join(dir, "schedules.lock"),
|
|
393
402
|
onFire: (spec) => {
|
|
394
403
|
if (!deps.asyncRunner) return;
|
|
395
|
-
runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation, cwd: spec.cwd });
|
|
404
|
+
runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation, cwd: spec.cwd, origin: "scheduled" });
|
|
396
405
|
},
|
|
397
406
|
});
|
|
398
407
|
deps.scheduler.start();
|
|
@@ -400,6 +409,82 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
400
409
|
if (cands.length > 0) {
|
|
401
410
|
ctx.ui.notify(`${cands.length} interrupted fleet run${cands.length > 1 ? "s" : ""} — open /fleet to resume`, "info");
|
|
402
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
|
+
});
|
|
403
488
|
// SPEC-5b-2: live widget (above editor). Display-only, independent
|
|
404
489
|
// of the /fleet panel. getTheme is a live getter (EditorTheme gotcha). Disposed on session end.
|
|
405
490
|
const getModelContextWindow = (m: string): number | undefined => {
|
|
@@ -501,6 +586,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
501
586
|
});
|
|
502
587
|
|
|
503
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;
|
|
504
594
|
if (fleetWidget) { fleetWidget.dispose(); fleetWidget = null; }
|
|
505
595
|
// SPEC-6-3: abort in-flight workflow children via the session-wide adapter signal.
|
|
506
596
|
// Terminal runs are not re-journaled — only non-terminal spawns observe the abort.
|
|
@@ -582,6 +672,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
582
672
|
...deps.lifecycleDeps,
|
|
583
673
|
spawn: withModelFallbackRetry(async (o) => spawnSubagent({
|
|
584
674
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
675
|
+
mode: "workflow",
|
|
585
676
|
skillsOverride: o.skills, backendOverride: o.backend,
|
|
586
677
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
|
|
587
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,8 @@ 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";
|
|
29
31
|
/** #62: the dispatch target cwd for in-place runs (isolated runs pass the worktree path).
|
|
30
32
|
* Flows into runLifecycle's SPEC-6-5 cwd resolution (lifecycle.cwd ?? entryCwd). */
|
|
31
33
|
entryCwd?: string;
|
|
@@ -57,6 +59,11 @@ export interface RunBackgroundOpts {
|
|
|
57
59
|
deps: AsyncRunnerDeps;
|
|
58
60
|
lifecycle: string;
|
|
59
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;
|
|
60
67
|
/** v0.11.1: edit isolation for background runs. Default "auto" (worktree when cwd is a git repo, in-place otherwise). */
|
|
61
68
|
isolation?: Isolation;
|
|
62
69
|
/** #62: the dispatch target cwd (undefined = session cwd, back-compat). Scopes the run:
|
|
@@ -113,7 +120,7 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
|
|
|
113
120
|
deps.journal.append(runId, ev0);
|
|
114
121
|
emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
|
|
115
122
|
|
|
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 });
|
|
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" });
|
|
117
124
|
|
|
118
125
|
if (res.status === "completed") {
|
|
119
126
|
if (isolated) {
|
|
@@ -158,7 +165,7 @@ function runBackgroundIsolated(task: string, opts: RunBackgroundOpts): RunBackgr
|
|
|
158
165
|
if (!worktree.isGitRepo()) {
|
|
159
166
|
return { status: "failed", error: "isolation: 'worktree' requires a git repo; cwd is not one — use isolation: 'none' or run in a git repo" };
|
|
160
167
|
}
|
|
161
|
-
const runId = opts.deps.genRunId();
|
|
168
|
+
const runId = opts.runId ?? opts.deps.genRunId();
|
|
162
169
|
const baseRef = "HEAD";
|
|
163
170
|
let wt: { path: string; branch: string };
|
|
164
171
|
try {
|
|
@@ -180,7 +187,7 @@ function runBackgroundAuto(task: string, opts: RunBackgroundOpts): RunBackground
|
|
|
180
187
|
inPlaceFallbackWarned = true;
|
|
181
188
|
opts.deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
|
|
182
189
|
}
|
|
183
|
-
const runId = opts.deps.genRunId();
|
|
190
|
+
const runId = opts.runId ?? opts.deps.genRunId();
|
|
184
191
|
runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
|
|
185
192
|
return { runId, status: "background" };
|
|
186
193
|
}
|
|
@@ -190,7 +197,7 @@ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgro
|
|
|
190
197
|
const isolation = opts.isolation ?? "auto";
|
|
191
198
|
if (isolation === "worktree") return runBackgroundIsolated(task, opts);
|
|
192
199
|
if (isolation === "none") {
|
|
193
|
-
const runId = opts.deps.genRunId();
|
|
200
|
+
const runId = opts.runId ?? opts.deps.genRunId();
|
|
194
201
|
runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
|
|
195
202
|
return { runId, status: "background" };
|
|
196
203
|
}
|
|
@@ -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
|
|