@getpipher/armory-fleet 0.4.0 → 0.5.1

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.
@@ -0,0 +1,123 @@
1
+ // src/runtime/async-runner.ts
2
+ // SPEC-5a §2/§6/§7/§8/§10 — the async/bg path. Layers ABOVE the unchanged runLifecycle:
3
+ // creates a worktree, journals events, drives runLifecycle with the worktree cwd, discovers
4
+ // artifacts via DiffService, commits on completion, pushes to the inbox, notifies.
5
+ import type { WorktreeService } from "../worktree/worktree-service.ts";
6
+ import type { DiffService } from "../worktree/diff-service.ts";
7
+ import type { RunJournal, JournalEvent } from "./run-journal.ts";
8
+ import type { ConcurrencyPool } from "./concurrency-pool.ts";
9
+ import type { ResultsInbox, RunResult } from "./results-inbox.ts";
10
+ import { execSync } from "node:child_process";
11
+
12
+ // Thin shape of the LifecycleRunResult we need (avoids importing the full type here — the
13
+ // real adapter in index.ts maps the full LifecycleRunResult to this shape).
14
+ export interface FakeLifecycleResult {
15
+ runId: string;
16
+ lifecycleName: string;
17
+ task: string;
18
+ status: "completed" | "failed" | "aborted";
19
+ phases: Array<{ name: string; status: string; summary: string; paths: string[]; reviseCount: number }>;
20
+ todoId: string | null;
21
+ error?: string;
22
+ }
23
+
24
+ export interface RunLifecycleOpts {
25
+ runId: string;
26
+ worktreePath: string;
27
+ branch: string;
28
+ mode: "auto" | "checkpointed";
29
+ }
30
+
31
+ export type RunLifecycleFn = (task: string, lifecycleName: string, opts: RunLifecycleOpts) => Promise<FakeLifecycleResult>;
32
+
33
+ export interface AsyncRunnerDeps {
34
+ worktree: WorktreeService;
35
+ diff: DiffService;
36
+ journal: RunJournal;
37
+ pool: ConcurrencyPool;
38
+ inbox: ResultsInbox;
39
+ runLifecycle: RunLifecycleFn;
40
+ notify: (msg: string, level?: "info" | "warning" | "error") => void;
41
+ genRunId: () => string;
42
+ /** SPEC-5a: called at each run/phase transition so the host (index.ts) can update the live bgRuns map. */
43
+ onProgress?: (runId: string, status: import("../panel/rows.ts").BgRunStatus) => void;
44
+ }
45
+
46
+ export interface RunBackgroundOpts {
47
+ deps: AsyncRunnerDeps;
48
+ lifecycle: string;
49
+ mode: "auto" | "checkpointed";
50
+ }
51
+
52
+ export interface RunBackgroundHandle {
53
+ runId: string;
54
+ status: "background";
55
+ }
56
+
57
+ function emitProgress(deps: AsyncRunnerDeps, runId: string, partial: Partial<import("../panel/rows.ts").BgRunStatus> & { status: import("../panel/rows.ts").BgStatus; phase: string; phaseIndex: number; phaseTotal: number }): void {
58
+ if (!deps.onProgress) return;
59
+ deps.onProgress(runId, {
60
+ runId,
61
+ lifecycle: "",
62
+ mode: "auto",
63
+ backend: "pi",
64
+ task: "",
65
+ ...partial,
66
+ });
67
+ }
68
+
69
+ function sh(cmd: string, cwd: string): void {
70
+ execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
71
+ }
72
+
73
+ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgroundHandle {
74
+ const { deps } = opts;
75
+ const runId = deps.genRunId();
76
+ const baseRef = "HEAD";
77
+
78
+ // Fire-and-forget: the pool gates concurrency; the journal records the run.
79
+ void deps.pool.withSlot(async () => {
80
+ let wt: { path: string; branch: string } | null = null;
81
+ try {
82
+ wt = deps.worktree.create(runId, baseRef);
83
+ const ev0: JournalEvent = { type: "run:started", runId, task, lifecycle: opts.lifecycle, worktree: { path: wt.path, branch: wt.branch }, mode: opts.mode, ts: Date.now() };
84
+ deps.journal.append(runId, ev0);
85
+ emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
86
+
87
+ const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: wt.path, branch: wt.branch, mode: opts.mode });
88
+
89
+ if (res.status === "completed") {
90
+ // commit the worktree to the branch (lifecycle finish phase or single-delegate completion)
91
+ try { sh("git add -A && git commit -m 'fleet run complete'", wt.path); } catch { /* nothing to commit */ }
92
+ deps.journal.append(runId, { type: "run:completed", runId, branch: wt.branch, ts: Date.now() });
93
+ const total = res.phases.length;
94
+ const lastIdx = total; // completed = past the last phase
95
+ emitProgress(deps, runId, { status: "completed", phase: res.phases[total - 1]?.name ?? "finish", phaseIndex: lastIdx, phaseTotal: total, lifecycle: opts.lifecycle, mode: opts.mode, task, branch: wt.branch });
96
+ const lastPhase = res.phases[res.phases.length - 1];
97
+ const result: RunResult = {
98
+ runId, task, status: "completed",
99
+ summary: lastPhase?.summary ?? "",
100
+ paths: res.phases.flatMap((p) => p.paths),
101
+ branch: wt.branch, completedAt: Date.now(),
102
+ };
103
+ deps.inbox.push(result);
104
+ deps.notify(`fleet run ${runId} completed`, "info");
105
+ // SPEC-5a: the worktree dir is temporary scaffolding; remove it but keep the branch for merge/inspection.
106
+ deps.worktree.removeWorktree(runId);
107
+ } else {
108
+ deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() });
109
+ deps.worktree.remove(runId);
110
+ emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: res.phases.length, lifecycle: opts.lifecycle, mode: opts.mode, task });
111
+ deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning");
112
+ }
113
+ } catch (e) {
114
+ const msg = (e as Error).message;
115
+ deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() });
116
+ if (wt) deps.worktree.remove(runId);
117
+ deps.notify(`fleet run ${runId} failed: ${msg}`, "error");
118
+ emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
119
+ }
120
+ });
121
+
122
+ return { runId, status: "background" };
123
+ }
@@ -0,0 +1,27 @@
1
+ // src/runtime/concurrency-pool.ts
2
+ // SPEC-5a §8 — N-slot semaphore for async/bg runs (Q4=A). Foreground keeps its own
3
+ // single-slot lock (unchanged); this pool is independent.
4
+
5
+ export class ConcurrencyPool {
6
+ private active = 0;
7
+ private waiters: Array<() => void> = [];
8
+
9
+ constructor(private readonly cap = 3) {}
10
+
11
+ busy(): number { return this.active; }
12
+ queued(): number { return this.waiters.length; }
13
+
14
+ async withSlot<T>(fn: () => Promise<T>): Promise<T> {
15
+ if (this.active >= this.cap) {
16
+ await new Promise<void>((resolve) => this.waiters.push(resolve));
17
+ }
18
+ this.active++;
19
+ try {
20
+ return await fn();
21
+ } finally {
22
+ this.active--;
23
+ const next = this.waiters.shift();
24
+ if (next) next();
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,45 @@
1
+ // src/runtime/results-inbox.ts
2
+ // SPEC-5a §10 — in-memory results inbox for completed bg runs (Q6=C).
3
+ // The durable record is the lifecycle TODO notes + journal; this is the fast in-session
4
+ // pointer the agent pulls via fleet.results().
5
+
6
+ export interface RunResult {
7
+ runId: string;
8
+ task: string;
9
+ status: "completed" | "failed";
10
+ summary: string;
11
+ paths: string[];
12
+ branch?: string;
13
+ completedAt: number;
14
+ }
15
+
16
+ export class ResultsInbox {
17
+ private ready = new Map<string, RunResult>();
18
+
19
+ push(result: RunResult): void {
20
+ this.ready.set(result.runId, result);
21
+ }
22
+
23
+ readyCount(): number {
24
+ return this.ready.size;
25
+ }
26
+
27
+ pull(runId?: string): RunResult[] {
28
+ if (runId) {
29
+ const r = this.ready.get(runId);
30
+ if (!r) return [];
31
+ this.ready.delete(runId);
32
+ return [r];
33
+ }
34
+ const all = [...this.ready.values()];
35
+ this.ready.clear();
36
+ return all;
37
+ }
38
+
39
+ /** Bounded hint for the parent agent's context: cap at 5, one line, empty when nothing ready. */
40
+ renderHint(): string {
41
+ const n = this.ready.size;
42
+ if (n === 0) return "";
43
+ return n > 5 ? "5+ fleet results ready (use fleet.results to pull)" : `${n} fleet result${n > 1 ? "s" : ""} ready (use fleet.results to pull)`;
44
+ }
45
+ }
@@ -0,0 +1,48 @@
1
+ // src/runtime/resume.ts
2
+ // SPEC-5a §5.3 — on pi start, scan .pi/fleet/runs/ for non-terminal journals and offer resume.
3
+ // If the worktree is gone, mark the journal run:aborted (worktree-missing).
4
+ import { RunJournal, type JournalEvent } from "./run-journal.ts";
5
+ import type { WorktreeService } from "../worktree/worktree-service.ts";
6
+
7
+ export interface ResumeCandidate {
8
+ runId: string;
9
+ task: string;
10
+ lifecycle: string;
11
+ worktreePath: string;
12
+ branch: string;
13
+ lastPhase: string | null;
14
+ canResume: boolean;
15
+ }
16
+
17
+ export interface ScanResumeOpts {
18
+ runsDir: string;
19
+ worktree: WorktreeService;
20
+ }
21
+
22
+ export function scanResumeCandidates(_projectDir: string, opts: ScanResumeOpts): ResumeCandidate[] {
23
+ const journal = new RunJournal(opts.runsDir);
24
+ const ids = journal.scanNonTerminal();
25
+ const cands: ResumeCandidate[] = [];
26
+ for (const runId of ids) {
27
+ const events = journal.replay(runId);
28
+ const started = events.find((e) => e.type === "run:started") as
29
+ | (JournalEvent & { type: "run:started" }) | undefined;
30
+ if (!started) continue;
31
+ const phaseEvents = events.filter((e) => e.type === "phase:completed" || e.type === "phase:started" || e.type === "phase:failed") as Array<{ phase: string }>;
32
+ const lastPhase = phaseEvents.length > 0 ? phaseEvents[phaseEvents.length - 1]!.phase : null;
33
+ const wtExists = opts.worktree.exists(runId);
34
+ if (!wtExists) {
35
+ journal.append(runId, { type: "run:aborted", runId, reason: "worktree-missing", ts: Date.now() });
36
+ }
37
+ cands.push({
38
+ runId,
39
+ task: started.task,
40
+ lifecycle: started.lifecycle,
41
+ worktreePath: started.worktree.path,
42
+ branch: started.worktree.branch,
43
+ lastPhase,
44
+ canResume: wtExists,
45
+ });
46
+ }
47
+ return cands;
48
+ }
@@ -0,0 +1,61 @@
1
+ // src/runtime/run-journal.ts
2
+ // SPEC-5a §5 — JSONL run journal. Append-only (crash-safe: a partial last line is discarded).
3
+ // The event log IS the i:Info timeline + the resume source of truth.
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ export interface RunStartedEvent { type: "run:started"; runId: string; task: string; lifecycle: string; worktree: { path: string; branch: string }; mode: "auto" | "checkpointed"; ts: number; }
8
+ export interface PhaseStartedEvent { type: "phase:started"; phase: string; ts: number; }
9
+ export interface PhaseCompletedEvent { type: "phase:completed"; phase: string; summary: string; paths: string[]; ts: number; }
10
+ export interface PhaseFailedEvent { type: "phase:failed"; phase: string; error: string; ts: number; }
11
+ export interface CheckpointEvent { type: "checkpoint"; phase: string; decision: "continue" | "revise" | "abort"; ts: number; }
12
+ export interface RunCompletedEvent { type: "run:completed"; runId: string; branch: string; ts: number; }
13
+ export interface RunAbortedEvent { type: "run:aborted"; runId: string; reason: string; ts: number; }
14
+
15
+ export type JournalEvent =
16
+ | RunStartedEvent | PhaseStartedEvent | PhaseCompletedEvent | PhaseFailedEvent
17
+ | CheckpointEvent | RunCompletedEvent | RunAbortedEvent;
18
+
19
+ const TERMINAL = new Set<JournalEvent["type"]>(["run:completed", "run:aborted"]);
20
+
21
+ export class RunJournal {
22
+ constructor(private readonly dir: string) {}
23
+
24
+ private file(runId: string): string {
25
+ return join(this.dir, `${runId}.jsonl`);
26
+ }
27
+
28
+ append(runId: string, event: JournalEvent): void {
29
+ mkdirSync(this.dir, { recursive: true });
30
+ appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8");
31
+ }
32
+
33
+ replay(runId: string): JournalEvent[] {
34
+ const f = this.file(runId);
35
+ if (!existsSync(f)) return [];
36
+ const lines = readFileSync(f, "utf8").split("\n");
37
+ const events: JournalEvent[] = [];
38
+ for (const line of lines) {
39
+ if (!line) continue;
40
+ try {
41
+ events.push(JSON.parse(line) as JournalEvent);
42
+ } catch {
43
+ // partial last line (crash mid-append) — discard
44
+ }
45
+ }
46
+ return events;
47
+ }
48
+
49
+ scanNonTerminal(): string[] {
50
+ if (!existsSync(this.dir)) return [];
51
+ const ids: string[] = [];
52
+ for (const f of readdirSync(this.dir)) {
53
+ if (!f.endsWith(".jsonl")) continue;
54
+ const runId = f.slice(0, -".jsonl".length);
55
+ const events = this.replay(runId);
56
+ const last = events[events.length - 1];
57
+ if (last && !TERMINAL.has(last.type)) ids.push(runId);
58
+ }
59
+ return ids;
60
+ }
61
+ }
@@ -0,0 +1,60 @@
1
+ // src/scheduling/expressions.ts
2
+ // SPEC-5a §9 — schedule expressions: cron (vendored) + interval + one-shot (Q5=A).
3
+ // The vendored cron-parser lib (v1.1.1) is CommonJS — use createRequire for CJS-in-ESM interop.
4
+ // v1.1.1 has no `tz` option; it uses the process local timezone (the right default for a dev tool).
5
+ import { createRequire } from "node:module";
6
+
7
+ const cronRequire = createRequire(import.meta.url);
8
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
9
+ const cronParser = cronRequire("../vendor/cron-parser/lib/parser.js") as {
10
+ parseExpression(expr: string, opts?: { currentDate?: Date; endDate?: Date }): { next(): Date; prev(): Date; hasNext(): boolean };
11
+ };
12
+
13
+ export type ScheduleType = "cron" | "interval" | "once";
14
+
15
+ export interface ScheduleExpression {
16
+ type: ScheduleType;
17
+ /** Next fire after `prev` (or from now if prev is null). Returns null when a one-shot has already fired. */
18
+ nextFire(prev: Date | null): Date | null;
19
+ }
20
+
21
+ const INTERVAL_RE = /^(\d+)([smhd])$/;
22
+ const INTERVAL_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
23
+
24
+ export function parseScheduleExpr(expr: string): ScheduleExpression {
25
+ const s = expr.trim();
26
+ if (INTERVAL_RE.test(s)) {
27
+ const m = s.match(INTERVAL_RE)!;
28
+ const unit = m[2] as "s" | "m" | "h" | "d";
29
+ const ms = Number(m[1]) * (INTERVAL_MS[unit] ?? 0);
30
+ return {
31
+ type: "interval",
32
+ nextFire: (prev) => new Date((prev ?? new Date()).getTime() + ms),
33
+ };
34
+ }
35
+ // one-shot ISO datetime (contains a 'T' and parses as a single Date)
36
+ if (s.includes("T") && /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) {
37
+ const fire = new Date(s);
38
+ if (isNaN(fire.getTime())) throw new Error(`invalid schedule expression (one-shot datetime): ${expr}`);
39
+ let fired = false;
40
+ return {
41
+ type: "once",
42
+ nextFire: (prev) => {
43
+ if (fired) return null;
44
+ if (prev && fire.getTime() <= prev.getTime()) { fired = true; return null; }
45
+ fired = true;
46
+ return fire;
47
+ },
48
+ };
49
+ }
50
+ // cron (5-field) — validate immediately (resolve-time error, not fire-time)
51
+ try {
52
+ cronParser.parseExpression(s, { currentDate: new Date() });
53
+ } catch (e) {
54
+ throw new Error(`invalid schedule expression (not cron/interval/once): ${expr} — ${(e as Error).message}`);
55
+ }
56
+ return {
57
+ type: "cron",
58
+ nextFire: (prev) => cronParser.parseExpression(s, { currentDate: prev ?? new Date() }).next(),
59
+ };
60
+ }
@@ -0,0 +1,43 @@
1
+ // src/scheduling/pid-lock.ts
2
+ // SPEC-5a §9 — PID lock so only one pi session fires schedules (Q5=A).
3
+ // A stale PID (dead process) is reclaimed.
4
+ import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
5
+
6
+ function isPidAlive(pid: number): boolean {
7
+ try {
8
+ process.kill(pid, 0); // signal 0 = existence check
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ export class PidLock {
16
+ private lockPath: string | null = null;
17
+
18
+ acquire(lockPath: string): boolean {
19
+ if (existsSync(lockPath)) {
20
+ const raw = readFileSync(lockPath, "utf8").trim();
21
+ const ownerPid = Number(raw);
22
+ if (Number.isFinite(ownerPid) && ownerPid !== process.pid && isPidAlive(ownerPid)) {
23
+ // a different live process owns it
24
+ return false;
25
+ }
26
+ // stale pid (dead) or already us → reclaim/keep
27
+ }
28
+ writeFileSync(lockPath, String(process.pid), "utf8");
29
+ this.lockPath = lockPath;
30
+ return true;
31
+ }
32
+
33
+ isOwner(): boolean {
34
+ return this.lockPath !== null;
35
+ }
36
+
37
+ release(): void {
38
+ if (this.lockPath && existsSync(this.lockPath)) {
39
+ try { unlinkSync(this.lockPath); } catch { /* already gone */ }
40
+ }
41
+ this.lockPath = null;
42
+ }
43
+ }
@@ -0,0 +1,150 @@
1
+ // src/scheduling/scheduler.ts
2
+ // SPEC-5a §9 — in-process scheduler. Session-scoped (fires only while pi open, no daemon).
3
+ // PID-locked so two open pi sessions on the same project don't double-fire. No catch-up.
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+ import { parseScheduleExpr, type ScheduleExpression } from "./expressions.ts";
7
+ import { PidLock } from "./pid-lock.ts";
8
+
9
+ export interface ScheduleSpec {
10
+ task: string;
11
+ expression: string;
12
+ lifecycle?: string; // default "default"
13
+ auto?: boolean;
14
+ }
15
+
16
+ export interface Schedule extends ScheduleSpec {
17
+ id: string;
18
+ nextFire: Date | null;
19
+ paused: boolean;
20
+ }
21
+
22
+ interface StoredSchedule extends ScheduleSpec {
23
+ id: string;
24
+ paused: boolean;
25
+ }
26
+
27
+ export interface SchedulerOpts {
28
+ storePath: string;
29
+ lockPath: string;
30
+ onFire: (spec: ScheduleSpec) => void;
31
+ }
32
+
33
+ interface Entry { spec: StoredSchedule; expr: ScheduleExpression; timer: NodeJS.Timeout | null }
34
+
35
+ export class Scheduler {
36
+ private schedules = new Map<string, Entry>();
37
+ private pidLock = new PidLock();
38
+ private running = false;
39
+
40
+ constructor(private readonly opts: SchedulerOpts) {
41
+ this.load();
42
+ }
43
+
44
+ private load(): void {
45
+ if (!existsSync(this.opts.storePath)) return;
46
+ try {
47
+ const arr = JSON.parse(readFileSync(this.opts.storePath, "utf8")) as StoredSchedule[];
48
+ for (const s of arr) {
49
+ try {
50
+ const expr = parseScheduleExpr(s.expression);
51
+ this.schedules.set(s.id, { spec: s, expr, timer: null });
52
+ } catch {
53
+ // skip a schedule whose expression no longer parses
54
+ }
55
+ }
56
+ } catch { /* corrupt store — start empty */ }
57
+ }
58
+
59
+ private persist(): void {
60
+ mkdirSync(dirname(this.opts.storePath), { recursive: true });
61
+ const arr = [...this.schedules.values()].map((e) => e.spec);
62
+ writeFileSync(this.opts.storePath, JSON.stringify(arr, null, 2), "utf8");
63
+ }
64
+
65
+ register(spec: ScheduleSpec): string {
66
+ const expr = parseScheduleExpr(spec.expression); // throws on invalid → resolve-time error
67
+ const id = "sch-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
68
+ const stored: StoredSchedule = {
69
+ id,
70
+ task: spec.task,
71
+ expression: spec.expression,
72
+ lifecycle: spec.lifecycle ?? "default",
73
+ auto: spec.auto ?? true,
74
+ paused: false,
75
+ };
76
+ this.schedules.set(id, { spec: stored, expr, timer: null });
77
+ this.persist();
78
+ if (this.running) this.arm(id);
79
+ return id;
80
+ }
81
+
82
+ list(): Schedule[] {
83
+ return [...this.schedules.values()].map((e) => ({
84
+ id: e.spec.id,
85
+ task: e.spec.task,
86
+ expression: e.spec.expression,
87
+ lifecycle: e.spec.lifecycle,
88
+ auto: e.spec.auto,
89
+ paused: e.spec.paused,
90
+ nextFire: e.spec.paused ? null : e.expr.nextFire(new Date()),
91
+ }));
92
+ }
93
+
94
+ pause(id: string): void {
95
+ const e = this.schedules.get(id);
96
+ if (!e) return;
97
+ e.spec.paused = true;
98
+ if (e.timer) { clearTimeout(e.timer); e.timer = null; }
99
+ this.persist();
100
+ }
101
+
102
+ resume(id: string): void {
103
+ const e = this.schedules.get(id);
104
+ if (!e) return;
105
+ e.spec.paused = false;
106
+ if (this.running) this.arm(id);
107
+ this.persist();
108
+ }
109
+
110
+ delete(id: string): void {
111
+ const e = this.schedules.get(id);
112
+ if (!e) return;
113
+ if (e.timer) clearTimeout(e.timer);
114
+ this.schedules.delete(id);
115
+ this.persist();
116
+ }
117
+
118
+ start(): boolean {
119
+ if (this.running) return true;
120
+ // Ensure the lock file's parent dir exists (fresh projects have no `.pi/fleet/` yet;
121
+ // persist() only runs on register(), so start() must self-sufficient the dir first).
122
+ mkdirSync(dirname(this.opts.lockPath), { recursive: true });
123
+ if (!this.pidLock.acquire(this.opts.lockPath)) return false;
124
+ this.running = true;
125
+ for (const id of this.schedules.keys()) this.arm(id);
126
+ return true;
127
+ }
128
+
129
+ stop(): void {
130
+ if (!this.running) return;
131
+ for (const e of this.schedules.values()) if (e.timer) { clearTimeout(e.timer); e.timer = null; }
132
+ this.pidLock.release();
133
+ this.running = false;
134
+ }
135
+
136
+ private arm(id: string): void {
137
+ const e = this.schedules.get(id);
138
+ if (!e || e.spec.paused) return;
139
+ const now = new Date();
140
+ const next = e.expr.nextFire(now);
141
+ if (!next) { this.delete(id); return; } // one-shot exhausted
142
+ const delay = Math.max(0, next.getTime() - now.getTime());
143
+ e.timer = setTimeout(() => {
144
+ this.opts.onFire(e.spec);
145
+ const nx = e.expr.nextFire(new Date());
146
+ if (!nx) { this.delete(id); return; }
147
+ this.arm(id);
148
+ }, delay);
149
+ }
150
+ }
@@ -0,0 +1,35 @@
1
+ // src/tools/fleet-results.ts
2
+ // SPEC-5a §10/§12.2 — the agent pulls completed bg-run results from the inbox (Q6=C).
3
+ import { Type, type Static } from "typebox";
4
+ import type { ResultsInbox } from "../runtime/results-inbox.ts";
5
+
6
+ export const fleetResultsParams = Type.Object({
7
+ runId: Type.Optional(Type.String({ description: "Pull a specific run's result. Omit to pull all ready (undelivered) results." })),
8
+ });
9
+
10
+ export type FleetResultsInput = Static<typeof fleetResultsParams>;
11
+
12
+ export interface FleetResultsToolDeps {
13
+ inbox: ResultsInbox;
14
+ }
15
+
16
+ export function createFleetResultsTool(deps: FleetResultsToolDeps) {
17
+ return {
18
+ name: "fleet_results",
19
+ label: "Fleet results",
20
+ description: "Pull completed background fleet-run results from the inbox. With a runId, returns that run's result. Without, returns all ready (undelivered) results. Pulling marks them delivered. The durable record also lives in the lifecycle TODO notes + the /fleet panel.",
21
+ promptSnippet: "Pull completed background fleet-run results",
22
+ promptGuidelines: [
23
+ "Use fleet_results to pull completed background runs when the 'N fleet results ready' hint appears.",
24
+ "Without a runId, returns all ready results and marks them delivered.",
25
+ ],
26
+ parameters: fleetResultsParams,
27
+ async execute(_toolCallId: string, input: FleetResultsInput) {
28
+ const results = deps.inbox.pull(input.runId);
29
+ return {
30
+ content: [{ type: "text" as const, text: results.length === 0 ? "no results ready" : results.map((r) => `${r.runId}: ${r.status} — ${r.summary}`).join("\n") }],
31
+ details: { results },
32
+ };
33
+ },
34
+ };
35
+ }
@@ -9,6 +9,9 @@ import { spawnSubagent } from "../engine/spawnSubagent.ts";
9
9
  import type { BackendRegistry } from "../backend/port.ts";
10
10
  import type { LifecycleRunDeps } from "../lifecycle/run-lifecycle.ts";
11
11
  import type { LifecycleDef } from "../lifecycle/lifecycle-types.ts";
12
+ import type { AsyncRunnerDeps } from "../runtime/async-runner.ts";
13
+ import { runBackground } from "../runtime/async-runner.ts";
14
+ import type { Scheduler } from "../scheduling/scheduler.ts";
12
15
 
13
16
  export const subagentParams = Type.Object({
14
17
  agent: Type.String({ description: "Agent name from the registry (builtin, project, or global)." }),
@@ -18,6 +21,8 @@ export const subagentParams = Type.Object({
18
21
  model: Type.Optional(Type.String({ description: 'Override the agent model, e.g. "anthropic/claude-sonnet-4".' })),
19
22
  lifecycle: Type.Optional(Type.String({ description: "Run a multi-phase superpowers lifecycle by name (e.g. 'default') instead of a single delegate. Tool-driven lifecycles run end-to-end (auto) — checkpoints are a /fleet panel feature." })),
20
23
  auto: Type.Optional(Type.Boolean({ description: "Only relevant with `lifecycle`. Tool-driven is always auto; this flag is forward-compat. Panel-driven uses --auto on /fleet-implement." })),
24
+ background: Type.Optional(Type.Boolean({ description: "Fire without awaiting. The run goes to the async/bg pool on an isolated git worktree; this returns { runId, status: 'background' } immediately. Foreground (default) awaits the result." })),
25
+ schedule: Type.Optional(Type.String({ description: 'Schedule the run instead of firing now: a cron string ("0 9 * * 1-5"), an interval ("30m"/"2h"), or a one-shot ISO datetime ("2026-07-25T14:00"). Returns { scheduleId, nextFire }. Session-scoped (fires only while pi is open); no catch-up.' })),
21
26
  });
22
27
 
23
28
  export type SubagentInput = Static<typeof subagentParams>;
@@ -34,6 +39,12 @@ export interface SubagentToolDeps {
34
39
  lifecycleRegistry: Map<string, LifecycleDef>;
35
40
  lifecycleRuns: Map<string, import("../lifecycle/lifecycle-types.ts").LifecycleRunRecord>;
36
41
  lifecycleDeps: Omit<LifecycleRunDeps, "spawn">;
42
+ /** SPEC-5a: async/bg runtime deps. Present when the extension wires the operational runtime. */
43
+ asyncRunner?: AsyncRunnerDeps;
44
+ /** SPEC-5a: scheduler. Present when the extension wires scheduling. */
45
+ scheduler?: Scheduler;
46
+ /** SPEC-5a: live bg run status rows for the /fleet panel. Optional. */
47
+ bgRuns?: import("../panel/bg-runs-store.ts").BgRunsStore;
37
48
  }
38
49
 
39
50
  /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
@@ -50,6 +61,21 @@ export function createSubagentTool(deps: SubagentToolDeps) {
50
61
  ],
51
62
  parameters: subagentParams,
52
63
  async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
64
+ // SPEC-5a: background + schedule routing (Q1/Q2/Q5).
65
+ if (params.background && params.schedule) {
66
+ return { isError: true, content: [{ type: "text" as const, text: "A scheduled run is inherently background — pass only one of `background` or `schedule`, not both." }] };
67
+ }
68
+ if (params.schedule) {
69
+ if (!deps.scheduler) return { isError: true, content: [{ type: "text" as const, text: "scheduling not configured (scheduler missing)" }] };
70
+ const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true });
71
+ const entry = deps.scheduler.list().find((s) => s.id === id);
72
+ return { content: [{ type: "text" as const, text: `scheduled: ${id} · next fire: ${entry?.nextFire?.toISOString() ?? "(paused)"}` }], details: { scheduleId: id, nextFire: entry?.nextFire ?? null } };
73
+ }
74
+ if (params.background) {
75
+ if (!deps.asyncRunner) return { isError: true, content: [{ type: "text" as const, text: "background runs not configured (asyncRunner missing)" }] };
76
+ const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto" });
77
+ return { content: [{ type: "text" as const, text: `background run: ${handle.runId}` }], details: handle };
78
+ }
53
79
  if (params.lifecycle) {
54
80
  const { runLifecycle } = await import("../lifecycle/run-lifecycle.ts");
55
81
  const lifecycleFullDeps: LifecycleRunDeps = {
@@ -0,0 +1,23 @@
1
+ # cron-parser (vendored)
2
+
3
+ - **Origin:** https://github.com/harrisi/cron-parser
4
+ - **npm:** `cron-parser`
5
+ - **Version:** 1.1.1 (latest 1.x — dependency-free; later versions pull in `luxon`)
6
+ - **License:** MIT (see upstream LICENSE)
7
+ - **Vendored on:** 2026-07-24
8
+ - **Vendored surface:** `lib/` (4 files: `parser.js`, `expression.js`, `date.js`, `number.js` — CommonJS, all-relative `require()`s, zero runtime deps) + `package.json` (`{"type":"commonjs"}`) scoping the dir as CJS so Node's loader treats the `.js` files as CommonJS despite the package root's `"type": "module"`.
9
+ - **Frozen:** do NOT edit files under `lib/`. To upgrade, replace `lib/` + update this NOTICE (version + date). Note: v2+ adds `luxon` as a runtime dep — vendoring those would require also vendoring luxon; v1.1.1 is intentionally dep-free. The `package.json` scope file is NOT part of the upstream lib — it's our CJS-in-ESM interop shim; keep it across upgrades.
10
+
11
+ ## Why vendored (per SPEC-5a §9, Q9=A)
12
+ cron expression parsing is commodity plumbing (DST, month-length, DOW/DOM OR-semantics, Feb 29).
13
+ We freeze a battle-tested MIT copy rather than reinvent it. The worktree lifecycle, by contrast,
14
+ is greenfield (thin git shell-outs) — see `src/worktree/`.
15
+
16
+ ## API used (v1.1.1)
17
+ ```js
18
+ const cp = require("./lib/parser.js");
19
+ const expr = cp.parseExpression("0 9 * * 1-5", { currentDate: new Date() });
20
+ const nextDate = expr.next(); // CronDate (extends Date) — a real Date instance
21
+ ```
22
+ Note: v1.1.1 has no `tz` option — it uses the process local timezone, which is the right default
23
+ for a dev tool ("9am" means 9am the user's time).