From fd682071a4bf73ef13c135a09f515e8208538e4c Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 22:50:57 +0700 Subject: [PATCH 01/18] docs(spec-5a): operational runtime spec (async/bg + scheduling + worktree isolation) Brainstorm output (9 Q&A, locked: Q1=B Q2=A Q3=A Q4=A Q5=A Q6=C Q7=A Q8=A Q9=A). Layer above the unchanged SPEC-1..4 engine seam: async/bg runs on isolated git worktrees (per-lifecycle, foreground unchanged), cron/interval/one-shot scheduling (session-scoped, PID-locked, in-process), JSONL run journal + auto-resume after crash, worktree-diff artifact discovery (replaces the prompt-baked Artifacts block for isolated runs), results inbox + fleet.results() auto-delivery, /fleet scheduled tab + bg row status. Vendored cron-parser (MIT) + greenfield worktree service. Targets v0.5.0. --- specs/SPEC-5a-operational-runtime.md | 464 +++++++++++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 specs/SPEC-5a-operational-runtime.md diff --git a/specs/SPEC-5a-operational-runtime.md b/specs/SPEC-5a-operational-runtime.md new file mode 100644 index 0000000..779b9cc --- /dev/null +++ b/specs/SPEC-5a-operational-runtime.md @@ -0,0 +1,464 @@ +# SPEC-5a — Operational runtime (async/bg + scheduling + git-worktree isolation) + +> **Status:** SPEC (brainstorm output, pre-plan) · **Owner:** RECTOR · **Created:** 2026-07-24 +> **Package:** `@getpipher/armory-fleet` · **Target release:** v0.5a · **Compatibility:** pi `^0.81.1` +> **Pipeline position:** 6th of 9 phases (PRD + research + SPEC-1..4 done; this spec → plan → implementation → v0.5a) + +--- + +## 1. Overview & goals + +SPEC-5a makes the fleet **operational**: runs can fire without being awaited, parallel edits don't conflict, work survives a crashed pi, and recurring work can be scheduled. It layers three capabilities **above the unchanged SPEC-1..4 engine seam** (`ChildSessionFactory` / `BackendRegistry` / `runLifecycle` — untouched, per the carry-forward discipline): + +1. **Async/background runs** — fire a subagent or lifecycle without awaiting; runs in an isolated git worktree; a durable journal records progress; a crashed pi auto-resumes interrupted runs on next project open. +2. **Scheduling** — cron / interval / one-shot schedules, session-scoped and PID-locked, fired in-process. +3. **Git-worktree isolation** — one worktree per async/bg lifecycle, auto-committed to a branch on completion; worktree-diff discovers phase artifacts structurally (replaces the fragile prompt-baked `Artifacts:` block for isolated runs). + +**Goals:** +- Background parallel agents on isolated worktrees — parallel edits never conflict. +- Schedule recurring or one-shot subagents from the `/fleet` panel or the `subagent` tool. +- A killed/crashed pi mid-lifecycle is not lost work — resume on next open. +- Completed bg runs auto-deliver to the parent agent via a bounded inbox + `fleet.results()` tool. + +**Non-goals (deferred, recorded in §14):** +- FleetView navigable list, live widget, conversation viewer, mid-run steering → **SPEC-5b**. +- Cost-aware tiers, quality gates, workflows-as-code, event-bus/RPC → **SPEC-6**. +- Cross-reboot daemon (runs surviving pi exit as an independent process) → rejected (Q1=B); session-bound process + durable state is the chosen model. + +--- + +## 2. Architecture — operational layer above the engine + +### 2.1 Three new modules, one unchanged seam + +| Module | Path | Role | +|---|---|---| +| WorktreeService + DiffService | `src/worktree/` (greenfield) | create/remove a git worktree per run; diff a worktree vs base to discover phase artifacts; commit the worktree to a branch on completion | +| Cron parsing | `src/vendor/cron-parser/` (vendored MIT, frozen) | parse a cron expression → next `Date` after a given time | +| Async runtime + journal + resume + inbox | `src/runtime/` | the N-slot bg concurrency pool; the JSONL run journal (append + replay); resume detection on pi start; the results inbox | +| Scheduling | `src/scheduling/` | schedule registration, in-process timer, PID-lock, next-fire computation | + +The **creation seam** (`ChildSessionFactory` / `BackendRegistry` / `runLifecycle`) is **unchanged**. SPEC-5a layers above it: the async runner calls `runLifecycle` (or `spawnSubagent` for single delegates) with a worktree cwd + a journal hook; it does not modify the phase loop, the backend registry, or the spawn path. This is the same discipline SPEC-3 (backends) and SPEC-4 (lifecycles) followed — layer above, don't touch the seam. + +### 2.2 Entry points + +All three funnel through the async runtime, which calls the unchanged engine: + +| Caller | Surface | Path | +|---|---|---| +| Agent (programmatic) | `subagent({ task, background?: boolean, schedule?: string, lifecycle?: string, auto?: boolean })` | background=true → async runtime → worktree + `runLifecycle`/`spawnSubagent` | +| Agent (results pull) | `fleet.results({ runId? })` | reads the results inbox | +| Human (interactive) | `/fleet` → `scheduled` tab; `fleet` tab bg rows | panel → async runtime | +| Human (slash mirror) | `/fleet-schedule [--lifecycle ] [--auto]` | thin → scheduling | +| Timer (scheduled fire) | in-process scheduler | → async runtime (same as background=true) | + +### 2.3 What does NOT change (the undisturbed seam) + +- `runLifecycle` phase loop, checkpoint state machine, `Artifacts:` parser for **foreground** runs (Q3=A). +- `ChildSessionFactory`, `BackendRegistry`, `spawnSubagent`, the single-slot foreground lock. +- The `/fleet` `lifecycle` / `agents` / `backends` tabs. +- Foreground sync `subagent` behavior — unchanged from v0.4 (Q2=A: no breaking change). + +--- + +## 3. Decision log (brainstorm 2026-07-24 — 9 Q&A, locked) + +| Q | Topic | Decision | Rationale (condensed) | +|---|---|---|---| +| Q1 | Process / state model | **B** — session-bound process + durable state + auto-resume | matches the handoff reconciliation goal; avoids the daemon process-management rabbit hole; durability is state, not process | +| Q2 | Worktree isolation scope + granularity | **A** — per-lifecycle, async/bg-only; foreground unchanged | matches PRD "isolation for parallel edits" + SPEC-4 §5.4; zero breaking change to v0.1..0.4 foreground; phases sharing a tree is a feature | +| Q3 | Artifact discovery | **A** — worktree-diff for isolated, prompt-baked for foreground | structural diff is robust to models that omit the `Artifacts:` block (today's smoke failure mode); foreground has no worktree to diff | +| Q4 | Concurrency model | **A** — two pools: foreground single-slot (unchanged), async/bg N-slot default 3 configurable | preserves SPEC-1 invariant; foreground never starved by bg; bounded for rate limits; clean SPEC-6 cost-aware extension point | +| Q5 | Scheduling | **A** — cron + interval + one-shot; `/fleet` scheduled tab + `subagent({schedule})` tool + thin slash; PID-locked; no catch-up | covers all three PRD expression types; interactive-first (panel primary); honest about session-scoped (no catch-up) | +| Q6 | Auto-delivery | **C** — notify + results inbox + `fleet.results()` tool + bounded hint | genuine delivery without intruding on the parent's live turn; durable record stays in TODO notes + journal | +| Q7 | Durable state format | **A** — JSONL journal per run, append-only, replay-on-resume; schedules in `schedules.json` | most crash-safe by construction; the event log IS the `i:Info` timeline; foundation for SPEC-6 journaled workflows | +| Q8 | TUI surface | **A** — minimal: `scheduled` tab + bg status icons on `fleet` rows; no live widget | minimal-but-complete for the operational runtime; live widget / conversation viewer is SPEC-5b | +| Q9 | Vendored plumbing | **A** — vendor `cron-parser` (MIT), write worktree lifecycle greenfield | cron is commodity (fiddly, battle-tested); worktree-add is a thin git shell-out not worth a git library | + +--- + +## 4. Components (file layout — additions/changes vs SPEC-4) + +``` +src/ +├── worktree/ # NEW — greenfield (Q9=A) +│ ├── worktree-service.ts # add/remove/exists/branch — git worktree shell-outs +│ ├── diff-service.ts # diffPhase(runId) = tracked + untracked changes vs base +│ └── worktree-service.test.ts +├── vendor/ +│ └── cron-parser/ # NEW — vendored MIT (Q9=A) +│ ├── index.js # frozen copy of cron-parser +│ ├── NOTICE.md # origin, version, date, MIT license, attribution +│ └── types.d.ts +├── runtime/ # NEW +│ ├── async-runner.ts # background=true path: worktree + runLifecycle + journal + inbox +│ ├── concurrency-pool.ts # N-slot semaphore (default 3, fleet.maxConcurrentBg) +│ ├── run-journal.ts # JSONL append + replay + partial-line-skip +│ ├── resume.ts # on pi start: scan .pi/fleet/runs/ for non-terminal journals +│ ├── results-inbox.ts # in-memory queue of completed-run summaries + fleet.results() +│ └── *.test.ts +├── scheduling/ # NEW +│ ├── scheduler.ts # register/list/pause/resume/delete; in-process timer +│ ├── pid-lock.ts # .pi/fleet/schedules.lock — only owning pi PID fires +│ ├── expressions.ts # cron (vendored) / interval / one-shot → next-fire Date +│ └── *.test.ts +├── tools/ +│ └── subagent.ts # CHANGE — add background?, schedule? params (Q5, Q2) +├── tools/ +│ └── fleet-results.ts # NEW — fleet.results({ runId? }) tool (Q6=C) +├── panel/ +│ └── fleet-panel.ts # CHANGE — new `scheduled` tab; bg status icons on fleet rows (Q8) +└── index.ts # CHANGE — wire async runtime + scheduler + resume-on-init +``` + +**No changes** to: `src/lifecycle/run-lifecycle.ts` (the phase loop), `src/backend/*`, `src/engine/spawnSubagent.ts`'s foreground path, `src/registry/*`, `src/todo-sync/*`, `src/memory-hydrate/*`. + +--- + +## 5. Process & state model (Q1=B, Q7=A) + +### 5.1 Session-bound process, durable state + +An async/bg run is a child session **within the current pi process**. If pi exits (crash, quit, machine restart), the run process dies — there is no daemon. **Run state survives** on disk so the run can be resumed. + +State lives in two places: + +| Artifact | Path | Format | Write model | +|---|---|---|---| +| Run journal | `.pi/fleet/runs/.jsonl` | JSONL — one event per line | append-only | +| Schedules | `.pi/fleet/schedules.json` | JSON array | atomic rewrite (temp + rename) | +| Schedule PID-lock | `.pi/fleet/schedules.lock` | single line: `` | atomic rewrite | +| Worktrees | `.pi/fleet/worktrees//` | git worktree | git-managed | + +### 5.2 The run journal — event shape + +Each event is one JSONL line. Event types: + +``` +{"type":"run:started","runId":"fl-...","task":"...","lifecycle":"default","worktree":{"path":"...","branch":"fleet/fl-..."},"mode":"auto","ts":"..."} +{"type":"phase:started","phase":"brainstorm","ts":"..."} +{"type":"phase:completed","phase":"brainstorm","summary":"...","paths":["docs/design.md"],"ts":"..."} +{"type":"checkpoint","phase":"implement","decision":"continue","ts":"..."} +{"type":"phase:failed","phase":"brainstorm","error":"missing Artifacts block","ts":"..."} +{"type":"run:completed","runId":"fl-...","branch":"fleet/fl-...","ts":"..."} +{"type":"run:aborted","runId":"fl-...","reason":"user-abort","ts":"..."} +``` + +A run is **terminal** when its journal ends with `run:completed` or `run:aborted`. A non-terminal journal on pi start = interrupted run → resume candidate. + +### 5.3 Resume + +On extension init, `resume.scanRuns(projectDir)` reads `.pi/fleet/runs/*.jsonl`, replays each to its last valid event, and for any non-terminal run: +- If the run's worktree still exists → offer to resume (re-spawn from the first non-completed phase, re-entering the recorded worktree cwd). +- If the worktree is gone → mark the journal `run:aborted { reason: "worktree-missing" }` + notify. + +**Partial-line skip:** if the journal's last line is incomplete (crash mid-append), the parser discards it and resumes from the last valid event. This is the crash-safety property of append-only JSONL (Q7=A). + +### 5.4 The `i:Info` timeline reads the journal + +SPEC-4's `i:Info` view reads the phase timeline from the TODO-notes progress block. SPEC-5a extends it to also read the journal (the journal is the richer source — it has timestamps + paths + checkpoint decisions). For bg runs, the journal is the source of truth; for foreground runs (no journal), the TODO-notes block remains the source. + +--- + +## 6. Worktree isolation (Q2=A) + +### 6.1 Scope — async/bg only, foreground unchanged + +| Run kind | Worktree? | Cwd | Artifact discovery | +|---|---|---|---| +| Foreground sync `subagent` (SPEC-1..4) | no | parent cwd (unchanged) | `Artifacts:` parser (unchanged) | +| Async/bg single delegate | yes | `.pi/fleet/worktrees//` | worktree-diff | +| Async/bg lifecycle | yes (one per lifecycle) | `.pi/fleet/worktrees//` | worktree-diff per phase | +| Scheduled run | yes (it's async/bg) | `.pi/fleet/worktrees//` | worktree-diff | + +**Zero breaking change** to foreground sync — the v0.1..0.4 behavior is untouched. + +### 6.2 Worktree lifecycle + +`WorktreeService` (greenfield, `src/worktree/worktree-service.ts`): + +``` +create(runId, baseRef = "HEAD"): + branch = `fleet/${runId}` + path = `.pi/fleet/worktrees/${runId}` + git worktree add -b ${branch} ${path} ${baseRef} + return { path, branch } + +remove(runId): + git worktree remove --force ${path} + git branch -D ${branch} # only on abort/cleanup; on completion the branch is kept for merge + +diffPhase(runId): # → DiffService + git -C ${path} diff ${baseRef} -- . # tracked modifications + + git -C ${path} status --porcelain # untracked new files + → { paths: string[], summary: string } +``` + +- **On completion:** the worktree is committed to `fleet/`. For a **lifecycle**, the finish phase commits (the finish skill's merge/PR policy applies). For a **single delegate** (no lifecycle), the async runner commits on run completion (all changes in the worktree). The branch is **kept** in both cases (for merge/inspection). +- **On abort:** `WorktreeService.remove(runId)` — worktree removed, branch deleted. (Spec note: a `--keep-on-abort` inspection flag is a future refinement; default is clean removal.) +- **On resume:** the worktree is re-entered, not recreated (it already exists from the interrupted run). + +### 6.3 Branch naming + base ref + +- Branch: `fleet/` (e.g. `fleet/fl-mrz3ezrd-2gq24n`). Namespaced to avoid collisions with user branches. +- Base ref: `HEAD` at run start (the current workspace HEAD). Recorded in the journal's `run:started` event so resume + diff use the same base. + +### 6.4 This fixes the smoke temp-cwd bug + +The SPEC-4 smoke script's `smokeCwd` fix (`252770d`) was ineffective because the child session's tool execution runs in the parent process's `process.cwd()`, not the passed `cwd`. For async/bg runs, SPEC-5a's worktree IS the isolation — the child runs in a real worktree with its own cwd, so tool I/O lands there by construction. (The foreground smoke script remains a smoke-script concern — run it from a throwaway cwd, per the handoff note.) + +--- + +## 7. Artifact discovery (Q3=A) + +### 7.1 Isolated runs — DiffService + +For async/bg runs, `DiffService.diffPhase(runId)` computes the phase's artifacts as **all changes in the worktree vs the base ref**: + +- Tracked modifications: `git -C diff --name-only` +- Untracked new files: `git -C status --porcelain` (filter `??` entries) + +Both are included — a phase that creates a brand-new `design.md` shows as untracked and is a valid artifact. The prose summary is the child's final text (truncated to a reasonable length). + +**This replaces the prompt-baked `Artifacts:` YAML block for isolated runs.** The block was fragile — today's TUI smoke proved a smaller model (`glm-5.2:cloud`) can complete the work without emitting a well-formed block, causing a false phase failure. The diff is structural; it doesn't depend on the model's output format. + +### 7.2 Foreground runs — unchanged + +Foreground sync runs have no worktree to diff, so they keep SPEC-4's `parseArtifacts` (the `Artifacts:` YAML parser, including the fenced-block + prompt-echo-trailer robustness from `df108e5`). No regression. + +### 7.3 The phase record + +The phase record (`{ name, status, summary, paths, reviseCount }`) is populated: +- Isolated: `paths` from `DiffService.diffPhase`, `summary` from child final text. +- Foreground: `paths` + `summary` from `parseArtifacts` (unchanged). + +The lifecycle loop's downstream behavior (checkpoint, Revise, next-phase `prev` injection) is unchanged — it consumes the phase record, not the discovery mechanism. + +--- + +## 8. Concurrency (Q4=A) + +### 8.1 Two pools + +| Pool | Type | Default | Config | Scope | +|---|---|---|---|---| +| Foreground sync | `createSingleSlotLock` (SPEC-1, unchanged) | 1 | (not configurable) | one at a time, caller awaits | +| Async/bg | `ConcurrencyPool` (new, `src/runtime/concurrency-pool.ts`) | 3 | `fleet.maxConcurrentBg` in settings.json | up to N parallel bg runs | + +Foreground and bg pools are **independent** — a foreground call never waits on a bg slot, and bg runs never compete with the foreground single-slot. Max concurrent children = 1 fg + N bg (default 1+3=4). + +### 8.2 Intra-lifecycle serialization + +Phases within a single lifecycle are **sequential** (the lifecycle loop awaits each phase — unchanged from SPEC-4). The N-slot pool governs **inter-lifecycle** parallelism: up to N async/bg lifecycles (or single delegates) in parallel. + +### 8.3 Pool exhaustion + +When N bg slots are full, a new bg request queues (the pool returns a promise that resolves when a slot frees). The `/fleet` fleet tab shows queued runs with a `⏳` indicator. There is no unbounded fan-out (Q4=A rejected unlimited — rate-limit + cost safety). + +### 8.4 SPEC-6 extension point + +The `ConcurrencyPool` is the natural place to layer SPEC-6's cost-aware concurrency: tier-based caps (cheaper models → higher N), per-agent budgets, per-phase limits. The two-pool separation keeps that a clean extension. + +--- + +## 9. Scheduling (Q5=A) + +### 9.1 Expression types + +| Type | Syntax | Example | Next-fire computed by | +|---|---|---|---| +| cron | 5-field cron string | `0 9 * * 1-5` (weekdays 9am) | vendored `cron-parser` | +| interval | `` (`s`/`m`/`h`/`d`) | `30m` | `Date.now() + ms` | +| one-shot | ISO 8601 datetime | `2026-07-25T14:00` | the datetime itself (fires once) | + +`expressions.parse(expr) → { type, nextFire(prevFire): Date }`. + +### 9.2 Registration surfaces (interactive-first) + +Per the getpipher interactive-first principle (`~/local-dev/getpipher/AGENTS.md`), the panel is the primary human surface; the tool action is the agent's path; the slash is a thin mirror. + +| Surface | Audience | Form | +|---|---|---| +| `/fleet` → `scheduled` tab | human (primary) | list with next-fire; add (inline Input: task → expr → lifecycle); pause/resume; delete | +| `subagent({ task, schedule, lifecycle?, auto? })` | agent | programmatic; registers a schedule that fires an async/bg run | +| `/fleet-schedule [--lifecycle ] [--auto]` | human (thin mirror) | convenience slash; prints the registered schedule + next-fire | + +### 9.3 Firing — in-process, PID-locked + +- **Timer:** the scheduler holds an in-process timer per schedule (setInterval for interval/cron-next-fire, setTimeout for one-shot). Schedules fire **only while pi is open** (session-scoped, Q1=B). +- **PID-lock:** `.pi/fleet/schedules.lock` records the owning pi PID. On extension init, the scheduler writes the current PID if the lock is free or held by a dead PID. A second pi session on the same project sees the schedules (reads `schedules.json`) but does **not** fire them — it defers to the owning PID. This prevents double-fire when two pi sessions are open on the same project. +- **No catch-up:** if pi was closed when a cron fire was due, the missed fire is **not** run on next open. The next fire is the next matching time after pi is open. This is the honest consequence of session-scoped (no daemon, Q1=B) — surfaced clearly in the `/fleet` scheduled tab (each row shows "next fire: …"). + +### 9.4 What a scheduled run does + +A scheduled run is an async/bg run (worktree + journal + inbox). On fire, the scheduler calls the async runtime with the schedule's `{ task, lifecycle, auto }` — identical path to `background=true`. The resulting run appears in the `/fleet` fleet tab like any bg run. + +--- + +## 10. Auto-delivery (Q6=C) + +### 10.1 On completion + +When an async/bg run completes: +1. `pi.notify("fleet run completed")` — a pi notification. +2. The `/fleet` fleet tab row → `✓` (or `✗` on failure). +3. The lifecycle TODO → `done` (armory-todo); the result summary is in the TODO notes (the **durable** record). +4. The result summary is queued in the in-memory **results inbox** (`src/runtime/results-inbox.ts`). + +### 10.2 The agent pulls results + +- `fleet.results({ runId? })` tool action: with a `runId` returns that run's summary; without, returns all ready summaries. Pulling marks them delivered (cleared from the inbox). +- A **bounded** system-prompt hint `N fleet results ready` (cap at 5; collapse to one line; clears once pulled) nudges the agent to pull. This is injected into the parent agent's context so it knows bg work has landed — without intruding on the live turn (Q6=C rejected B's mid-turn inject). + +### 10.3 Durability + +The inbox is **in-memory** (lost on pi restart). The durable record is the lifecycle TODO notes + the journal + the `/fleet` row. So a run that completes while pi is closed is found on next open via `/todo finished` + the `/fleet` fleet tab (recent rows) — the inbox is just the fast in-session pointer for the agent. + +--- + +## 11. The `/fleet` panel — `scheduled` tab + bg row status (Q8=A) + +### 11.1 Tabs after SPEC-5a + +``` +fleet · lifecycle · agents · backends · scheduled +``` + +One new tab (`scheduled`); the existing `fleet` tab gains bg status icons. The `lifecycle`/`agents`/`backends` tabs are unchanged. + +### 11.2 `fleet` tab — bg row status + +Bg run rows show live status + phase progress (foreground rows unchanged from SPEC-4): + +| Status | Icon | Example row | +|---|---|---| +| running | `▶` | `▶ fl-... default ●implement 3/5 checkpointed 2m pi "..."` | +| queued (pool full) | `⏳` | `⏳ fl-... default queued 0/5 pi "..."` | +| paused at checkpoint | `⏸` | `⏸ fl-... default ●review 4/5 checkpoint 3m pi "..."` | +| completed | `✓` | `✓ fl-... default done 5/5 34s pi fleet/ "..."` | +| failed | `✗` | `✗ fl-... default failed brainstorm 1/5 12s pi "..."` | + +The phase-progress marker `● /` updates from the journal events as the run advances. + +### 11.3 `scheduled` tab — the list view + +``` +SCHEDULED + ▶ 30m default "monitor deps" next: 2026-07-24 16:00 fl-... + ▶ 0 9 * * 1-5 default "morning audit" next: 2026-07-25 09:00 fl-... + ⏸ 2h default "refresh cache" paused fl-... + ◉ once default "one-shot deploy" next: 2026-07-25 14:00 fl-... + + a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit +``` + +- `a:Add` → inline Input: task → expr → lifecycle (blank=default) → registers, shows next-fire. +- `p:Pause/resume` on the selected row. +- `d:Delete` removes the schedule (a running fire is not killed; the schedule just stops re-firing). +- `i:Info` shows the schedule's last-fire + next-fire + the runIds it has spawned. + +### 11.4 No live widget / conversation viewer + +The live widget, conversation viewer, and mid-run steering are **SPEC-5b**. SPEC-5a's TUI is minimal-but-complete for operational visibility: you can see a bg run's phase progress in the fleet tab + manage schedules in the scheduled tab. + +### 11.5 EditorTheme gotcha (carried from AGENTS.md) + +The `scheduled` tab is a `ctx.ui.custom` panel (like the existing tabs), so it receives the full `Theme` — no EditorTheme risk. The inline `Input` for schedule add is single-line (pi-tui can't nest `ctx.ui.editor()` inside `ctx.ui.custom()`), same pattern as SPEC-4's `task>` Input. + +--- + +## 12. The `subagent` tool — `background` + `schedule` params + +### 12.1 New params + +``` +subagent({ + agent, task, model?, lifecycle?, auto?, // SPEC-4 (unchanged) + background?: boolean, // NEW — fire without awaiting (Q1, Q2) + schedule?: string, // NEW — "30m" | "0 9 * * 1-5" | ISO datetime (Q5) +}) +``` + +- `background: true` → async runner path (worktree + journal + inbox). The tool returns immediately with `{ runId, status: "background" }`. +- `schedule: "..."` → scheduling path (registers a schedule; the run fires on the schedule). The tool returns `{ scheduleId, nextFire }`. +- `background` + `schedule` is invalid → actionable error ("a scheduled run is inherently background; pass only one"). +- Neither → foreground sync (SPEC-1..4, unchanged). + +### 12.2 `fleet.results` tool (new) + +``` +fleet.results({ runId? }) + → { results: Array<{ runId, task, status, summary, paths, branch?, completedAt }> } +``` + +No `runId` = all ready (undelivered) results, marked delivered on read. With `runId` = that run's result (does not mark delivered unless it was ready). + +--- + +## 13. Guards (SPEC-1/2/3/4 §9/§11 carried forward) + +- **Foreground single-slot lock** — unchanged (Q4=A). +- **Todo-excluded + Esc-abort** — carry to bg runs (Esc in the panel aborts the selected bg run → `run:aborted`). +- **Worktree cleanup on abort/failure** — `WorktreeService.remove` is called in a `finally` for failed/aborted runs (completed runs keep the branch). +- **Journal integrity** — append-only + partial-line skip (Q7=A). +- **PID-lock** — only the owning pi PID fires schedules; a stale PID is reclaimed (Q5=A). +- **No unbounded fan-out** — the N-slot pool caps concurrent bg runs (Q4=A). +- **Resolve-time validation** — a bad cron expression errors at registration, not at fire time; a dirty working tree (uncommitted changes that would conflict with `git worktree add`) errors at run start with an actionable message. + +--- + +## 14. Error handling + failure modes + +| Failure | Behavior | +|---|---| +| Worktree creation fails (dirty tree, branch exists, disk full) | run marked `failed`, journal `run:aborted { reason: "worktree-create-failed", error }`, no orphan worktree, notify | +| Cron expression invalid | resolve-time error at registration (tool returns actionable error; panel shows inline error) | +| Journal last line incomplete (crash mid-append) | discard the partial line, resume from last valid event | +| Schedule PID-lock held by a live second pi | second session reads schedules but does not fire; logs "schedules owned by PID " | +| Schedule PID-lock held by a dead PID | reclaim: write current PID, resume firing | +| Resume: worktree missing | mark journal `run:aborted { reason: "worktree-missing" }`, notify, do not attempt resume | +| Resume: worktree present | offer resume (re-spawn from first non-completed phase) | +| Bg run completes while pi closed | on next open: `/fleet` fleet tab shows the ✓ row (recent); `/todo finished` shows the done TODO; the inbox was in-memory so no auto-nudge, but the durable record is intact | +| Pool exhausted | new bg request queues (`⏳` in fleet tab); no rejection | +| `fleet.results()` with no ready results | returns `{ results: [] }` | + +--- + +## 15. Testing (mirrors SPEC-1..4: `node --import tsx --test`, no real LLM in unit tests) + +- **`WorktreeService` / `DiffService`**: real-git temp-repo tests (`mkdtempSync` + `git init` + commit a base file → `create()` → write a new file + modify one → `diffPhase()` asserts both paths → `remove()`). No LLM. +- **`run-journal`**: append events → replay reconstructs state → write a partial last line → replay skips it → resume detection identifies non-terminal journals. +- **`concurrency-pool`**: N slots → N+1th acquire blocks → release one → the blocked acquire resolves → exhaustion + release. +- **`expressions` / cron**: vendored `cron-parser` tested via its own frozen suite; our wrapper tests next-fire wiring for cron (`0 9 * * 1-5`), interval (`30m`), one-shot (ISO). +- **`scheduler` / `pid-lock`**: register/pause/resume/delete; PID-lock contention (two mock PIDs, only owner fires); no-catch-up (a missed fire is not re-run). +- **`results-inbox`**: push results → `fleet.results()` returns them → marks delivered → bounded hint collapses N. +- **`async-runner` integration**: fake `runLifecycle` (no real LLM) → async runner creates a worktree, drives the fake lifecycle, journals events, diff discovers artifacts, completion → inbox + notify. Asserts the journal + worktree + inbox end-to-end. +- **End-to-end smoke (manual, not CI):** a scheduled one-shot fires a trivial isolated lifecycle on `Ollama/glm-5.2:cloud` in a temp git repo → worktree created → phases run → diff discovers artifacts → journal records → completion notify + inbox + `fleet.results()`. (Safe cwd — the worktree IS the isolation; no repo pollution.) + +--- + +## 16. Deferred (recorded, with landing SPEC) + +| Item | Landing SPEC | +|---|---| +| FleetView navigable list, live widget, conversation viewer, mid-run steering | SPEC-5b | +| `--keep-on-abort` worktree inspection flag | SPEC-5b (fleet TUI surfaces it) or SPEC-6 | +| Cost-aware concurrency (tier-based caps, per-agent budgets) | SPEC-6 | +| Quality gates (verify / judgePanel / loopUntilDry) as lifecycle hooks | SPEC-6 | +| Workflows-as-code (JS orchestration + journaled edit-and-resume) — builds on the JSONL journal from Q7=A | SPEC-6 | +| Event-bus + cross-extension RPC (other extensions spawn/steer/observe) | SPEC-6 | +| Cross-reboot daemon (runs surviving pi exit) | rejected (Q1=B); reconsider only if a real overnight-across-reboots need emerges | +| Catch-up for missed scheduled fires while pi closed | rejected (Q1=B session-scoped); a "run on next open if missed" policy is a future opt-in | + +--- + +## 17. Done bar (v0.5a, from PRD §8) + +- **Background parallel agents on isolated worktrees** — `subagent({ task, background: true, lifecycle })` fires without awaiting; parallel edits never conflict (separate worktrees). +- **Schedule recurring subagents** — `subagent({ task, schedule: "0 9 * * 1-5" })` or the `/fleet` scheduled tab registers a recurring/one-shot run. +- **Auto-resume after crash** — a killed pi mid-lifecycle is detected on next project open; fleet offers to resume from the last checkpoint. +- **Auto-delivery** — completed bg runs notify + queue in the inbox; the parent agent pulls via `fleet.results()`. +- **`/fleet` Scheduled tab** — manage schedules interactively (add/pause/resume/delete + next-fire). +- **Vendored plumbing** — `cron-parser` (MIT, frozen, attributed) + greenfield worktree service. + +**Release:** `@getpipher/armory-fleet@0.5.0` via `release.yml` on `v0.5.0` tag (mirrors v0.3.0/v0.4.0). \ No newline at end of file From b0227b45192a400d6f28ecc560d7ce8608cc8cc0 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 22:57:52 +0700 Subject: [PATCH 02/18] =?UTF-8?q?docs(spec-5a):=20implementation=20plan=20?= =?UTF-8?q?(15=20TDD=20tasks=20=E2=80=94=20worktree=20+=20async/bg=20+=20s?= =?UTF-8?q?cheduling)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15 tasks: WorktreeService, DiffService, RunJournal, ConcurrencyPool, ResultsInbox, vendor cron-parser + expressions, PidLock, Scheduler, AsyncRunner, Resume, subagent background/schedule params, fleet.results tool, /fleet scheduled tab + bg row status, index wiring + resume-on-init, end-to-end smoke + TUI checklist. Self-reviewed (spec coverage, placeholders, type consistency — one cross-task amendment flagged: WorktreeService.pathFor public exposure). --- plans/SPEC-5a-operational-runtime.md | 2416 ++++++++++++++++++++++++++ 1 file changed, 2416 insertions(+) create mode 100644 plans/SPEC-5a-operational-runtime.md diff --git a/plans/SPEC-5a-operational-runtime.md b/plans/SPEC-5a-operational-runtime.md new file mode 100644 index 0000000..f4a4e86 --- /dev/null +++ b/plans/SPEC-5a-operational-runtime.md @@ -0,0 +1,2416 @@ +# SPEC-5a — Operational runtime — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the fleet operational — async/background runs on isolated git worktrees (per-lifecycle, foreground unchanged), cron/interval/one-shot scheduling (session-scoped, PID-locked, in-process), a JSONL run journal + auto-resume after crash, worktree-diff artifact discovery (replaces the prompt-baked `Artifacts:` block for isolated runs), a results inbox + `fleet.results()` auto-delivery, and a `/fleet` Scheduled tab + bg row status. Targets `@getpither/armory-fleet@0.5.0`. + +**Architecture:** A layer **above** the unchanged SPEC-1..4 engine seam. Three new modules: `src/worktree/` (greenfield `WorktreeService` + `DiffService` — git shell-outs), `src/runtime/` (async runner + JSONL journal + N-slot concurrency pool + results inbox + resume), `src/scheduling/` (expressions + PID-lock + scheduler). One vendored module: `src/vendor/cron-parser/` (MIT, frozen). The async runner calls the **unchanged** `runLifecycle`/`spawnSubagent` with a worktree cwd + a journal hook; the phase loop, backend registry, and spawn path are untouched. Foreground sync `subagent` (v0.1..0.4) is unchanged — zero breaking change. + +**Tech Stack:** TypeScript (raw `.ts` via tsx, no build), pi `^0.81.1` SDK, `node:test` via tsx, `@getpipher/armory-todo`, `@getpipher/armory-memory`, `@getpipher/vision`, `typebox`, `yaml`, vendored `cron-parser`. + +## Global Constraints + +- **No build step** — raw `.ts` via tsx at runtime; `pnpm typecheck` + `pnpm test:run` (node:test via tsx) before release. +- **Test runner:** `node --import tsx --test test/*.test.mts` (Node 24 won't type-strip under `node_modules`). Use `pnpm test:run`. +- **pi target:** `^0.81.1`. SDK imports from `@earendil-works/pi-coding-agent`. +- **Additive only** — the SPEC-1..4 engine modules (`run-lifecycle.ts` phase loop, `spawnSubagent.ts` foreground path, factories, `BackendRegistry`, `child-loader`, `memory-hydrate/`, `vision/`, `todo-sync/` adapter logic, `lifecycle/*`) are untouched except: (a) `subagent.ts` tool gains two optional params `background?` + `schedule?` (Task 11); (b) `fleet-panel.ts` adds one tab + bg row status (Task 13); (c) `index.ts` wires the new runtime + scheduler + resume + tools (Task 14). All existing tests must pass unchanged. +- **Layer above, don't touch the seam** — the async runner calls `runLifecycle`/`spawnSubagent` with a worktree `cwd` + a journal `onEvent` hook; it does NOT modify the phase loop, the backend registry, or the spawn path. Same discipline as SPEC-3 (backends) + SPEC-4 (lifecycles). +- **Foreground unchanged (Q2=A)** — foreground sync `subagent` runs in the parent cwd, no worktree, `Artifacts:` parser (unchanged). Zero breaking change to v0.1..0.4. +- **Two concurrency pools (Q4=A)** — foreground keeps `createSingleSlotLock` (unchanged); async/bg uses a new N-slot `ConcurrencyPool` (default 3, `fleet.maxConcurrentBg` in settings.json). +- **Session-scoped, no daemon (Q1=B)** — bg runs are child sessions in the current pi process; state survives on disk (JSONL journal), the process does not. No catch-up for missed scheduled fires. +- **Vendored plumbing (Q9=A)** — `cron-parser` (MIT) frozen in `src/vendor/cron-parser/` with `NOTICE.md` (origin + version + date + license). Worktree lifecycle is greenfield (~60-80 lines, git shell-outs). +- **No AI attribution** in commits/PRs/files. +- **One commit per task**; branch `feat/spec-5a-operational-runtime` (already cut). +- **getpipher conventions:** EditorTheme gotcha — `ctx.ui.custom` receives full `Theme`; the `scheduled` tab threads `() => ctx.ui.theme` for real colors. Interactive-first: the `scheduled` tab + bg row status land as panel views FIRST, then the model-callable tool params. No Unicode emojis as icons; use the text indicators (`▶ ⏸ ✓ ✗ ⏳ ●`) established in SPEC-4. +- **Spec:** `specs/SPEC-5a-operational-runtime.md` — every task traces to a spec section (cited in each task header). +- **Execution waves (optional):** the plan is ordered so Tasks 1-5 + 9-11 + 14-core form wave 1 (worktree + async/bg + journal + resume + auto-delivery — shippable as a v0.5.0-alpha); Tasks 6-8 + 12-13 form wave 2 (scheduling). Execute sequentially either way. + +--- + +## File Structure + +**Fleet (this repo):** +- `src/worktree/worktree-service.ts` — `WorktreeService`: `create(runId, baseRef)`, `remove(runId)`, `exists(runId)`, `branchFor(runId)` +- `src/worktree/diff-service.ts` — `DiffService`: `diffPhase(worktreePath, baseRef) → { paths, summary }` +- `src/runtime/run-journal.ts` — `RunJournal`: `append(runId, event)`, `replay(runId) → Event[]`, `scanNonTerminal(dir) → runId[]` +- `src/runtime/concurrency-pool.ts` — `ConcurrencyPool`: `withSlot(fn) → T`, `busy()`, `queued()` +- `src/runtime/results-inbox.ts` — `ResultsInbox`: `push(result)`, `pull(runId?) → Result[]`, `readyCount()`, `renderHint()` +- `src/runtime/async-runner.ts` — `runBackground(task, opts)`: worktree + `runLifecycle`/`spawnSubagent` + journal + inbox + notify +- `src/runtime/resume.ts` — `scanAndOfferResume(projectDir, deps) → ResumeCandidate[]` +- `src/vendor/cron-parser/index.js` — frozen vendored `cron-parser` +- `src/vendor/cron-parser/NOTICE.md` — origin + version + date + MIT license +- `src/vendor/cron-parser/types.d.ts` — type declarations +- `src/scheduling/expressions.ts` — `parseScheduleExpr(expr) → { type, nextFire(prev) }` +- `src/scheduling/pid-lock.ts` — `PidLock`: `acquire(lockPath) → boolean`, `release()`, `isOwner()` +- `src/scheduling/scheduler.ts` — `Scheduler`: `register/list/pause/resume/delete` + in-process timer loop +- `src/tools/subagent.ts` — **modify**: `background?` + `schedule?` params; route to async runner / scheduler +- `src/tools/fleet-results.ts` — `fleet.results({ runId? })` tool +- `src/panel/fleet-panel.ts` — **modify**: `View` += `"scheduled"`; tab cycle; bg row status icons; scheduled tab list + add/pause/resume/delete +- `src/index.ts` — **modify**: wire async runtime + scheduler + resume-on-init + `fleet.results` tool +- `scripts/spec-5a-smoke.mts` — real end-to-end scheduled isolated lifecycle smoke +- `docs/SPEC-5a-smoke-checklist.md` — term-driven TUI smoke matrix rows +- Tests: `test/worktree-service.test.mts`, `test/diff-service.test.mts`, `test/run-journal.test.mts`, `test/concurrency-pool.test.mts`, `test/results-inbox.test.mts`, `test/async-runner.test.mts`, `test/resume.test.mts`, `test/scheduling-expressions.test.mts`, `test/pid-lock.test.mts`, `test/scheduler.test.mts`, `test/subagent-spec5a.test.mts`, `test/fleet-results.test.mts`, `test/panel-spec5a.test.mts`, `test/index-spec5a.test.mts` + +--- + +## Task 1: WorktreeService + +**Spec:** §6 (worktree isolation), §4 (file layout). Greenfield git shell-outs — no deps on other tasks. Real-git temp-repo tests. + +**Files:** +- Create: `src/worktree/worktree-service.ts` +- Create: `test/worktree-service.test.mts` + +**Interfaces:** +- Consumes: nothing (standalone). +- Produces: `WorktreeService` class with `create(runId, baseRef?) → { path, branch }`, `remove(runId) → void`, `exists(runId) → boolean`, `branchFor(runId) → string`. Constructor takes `{ rootDir: string, worktreesDir: string }` where `worktreesDir` defaults to `/.pi/fleet/worktrees`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/worktree-service.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; + +function sh(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, encoding: "utf8" }).trim(); +} + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "wt-test-")); + sh("git init -b main", dir); + sh('git config user.email "t@t.test"', dir); + sh('git config user.name "test"', dir); + writeFileSync(join(dir, "base.txt"), "base\n"); + sh("git add base.txt && git commit -m base", dir); + return dir; +} + +test("create makes a worktree at .pi/fleet/worktrees/ branched from HEAD", () => { + const repo = makeRepo(); + const svc = new WorktreeService({ rootDir: repo }); + const { path, branch } = svc.create("fl-test1", "HEAD"); + assert.equal(branch, "fleet/fl-test1"); + assert.equal(existsSync(join(path, "base.txt")), true); + assert.equal(svc.exists("fl-test1"), true); + assert.equal(sh("git rev-parse --abbrev-ref HEAD", path), "fleet/fl-test1"); + rmSync(repo, { recursive: true, force: true }); +}); + +test("create writes a new file in the worktree without affecting the main checkout", () => { + const repo = makeRepo(); + const svc = new WorktreeService({ rootDir: repo }); + const { path } = svc.create("fl-test2", "HEAD"); + writeFileSync(join(path, "new.txt"), "new\n"); + // main checkout should NOT have new.txt + assert.equal(existsSync(join(repo, "new.txt")), false); + // worktree should + assert.equal(existsSync(join(path, "new.txt")), true); + rmSync(repo, { recursive: true, force: true }); +}); + +test("remove deletes the worktree + branch", () => { + const repo = makeRepo(); + const svc = new WorktreeService({ rootDir: repo }); + const { path } = svc.create("fl-test3", "HEAD"); + svc.remove("fl-test3"); + assert.equal(svc.exists("fl-test3"), false); + assert.equal(existsSync(path), false); + // branch gone + const branches = sh("git branch --list", repo); + assert.equal(branches.includes("fleet/fl-test3"), false); + rmSync(repo, { recursive: true, force: true }); +}); + +test("create errors actionable when base ref is invalid", () => { + const repo = makeRepo(); + const svc = new WorktreeService({ rootDir: repo }); + assert.throws(() => svc.create("fl-test4", "no-such-ref"), /no-such-ref|unknown revision|invalid/); + rmSync(repo, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/worktree-service.test.mts` +Expected: FAIL with `Cannot find module '../src/worktree/worktree-service.ts'` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/worktree/worktree-service.ts +// Greenfield git worktree lifecycle (SPEC-5a §6, Q9=A — thin shell-outs, no git library). +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +export interface WorktreeRef { + path: string; + branch: string; +} + +export interface WorktreeServiceOpts { + rootDir: string; + /** Where worktrees live. Defaults to /.pi/fleet/worktrees. */ + worktreesDir?: string; +} + +function sh(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +export class WorktreeService { + private readonly rootDir: string; + private readonly worktreesDir: string; + + constructor(opts: WorktreeServiceOpts) { + this.rootDir = opts.rootDir; + this.worktreesDir = opts.worktreesDir ?? join(opts.rootDir, ".pi", "fleet", "worktrees"); + } + + branchFor(runId: string): string { + return `fleet/${runId}`; + } + + private pathFor(runId: string): string { + return join(this.worktreesDir, runId); + } + + exists(runId: string): boolean { + return existsSync(this.pathFor(runId)); + } + + create(runId: string, baseRef = "HEAD"): WorktreeRef { + if (this.exists(runId)) { + throw new Error(`worktree for run ${runId} already exists at ${this.pathFor(runId)}`); + } + mkdirSync(this.worktreesDir, { recursive: true }); + const branch = this.branchFor(runId); + const path = this.pathFor(runId); + try { + sh(`git worktree add -b ${branch} ${path} ${baseRef}`, this.rootDir); + } catch (e) { + // clean up a partial worktree dir if git failed before creating it + if (existsSync(path)) rmSync(path, { recursive: true, force: true }); + const msg = (e as Error).message; + throw new Error(`worktree create failed for run ${runId} (base ${baseRef}): ${msg.split("\n").pop() ?? msg}`); + } + return { path, branch }; + } + + remove(runId: string): void { + const path = this.pathFor(runId); + const branch = this.branchFor(runId); + if (existsSync(path)) { + try { + sh(`git worktree remove --force ${path}`, this.rootDir); + } catch { + rmSync(path, { recursive: true, force: true }); + sh("git worktree prune", this.rootDir); + } + } + try { + sh(`git branch -D ${branch}`, this.rootDir); + } catch { + // branch may not exist; ignore + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/worktree-service.test.mts` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/worktree/worktree-service.ts test/worktree-service.test.mts +git commit -m "feat(spec-5a): WorktreeService — git worktree create/remove/exists" +``` + +--- + +## Task 2: DiffService + +**Spec:** §7 (artifact discovery — worktree-diff = tracked + untracked). Depends on Task 1's worktree path convention (but tests make its own temp repo). + +**Files:** +- Create: `src/worktree/diff-service.ts` +- Create: `test/diff-service.test.mts` + +**Interfaces:** +- Consumes: nothing (takes a worktree path + base ref as strings). +- Produces: `DiffService` with `diffPhase(worktreePath, baseRef) → { paths: string[], summary: string }`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/diff-service.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync, appendFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; +import { DiffService } from "../src/worktree/diff-service.ts"; + +function sh(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, encoding: "utf8" }).trim(); +} + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "diff-test-")); + sh("git init -b main", dir); + sh('git config user.email "t@t.test"', dir); + sh('git config user.name "test"', dir); + writeFileSync(join(dir, "base.txt"), "base\n"); + sh("git add base.txt && git commit -m base", dir); + return dir; +} + +test("diffPhase lists tracked modifications + untracked new files", () => { + const repo = makeRepo(); + const wt = new WorktreeService({ rootDir: repo }); + const diff = new DiffService(); + const { path } = wt.create("fl-diff1", "HEAD"); + // tracked modification + appendFileSync(join(path, "base.txt"), "more\n"); + // untracked new file + writeFileSync(join(path, "design.md"), "# design\n"); + const res = diff.diffPhase(path, "HEAD"); + assert.ok(res.paths.includes("base.txt"), `paths: ${res.paths.join(",")}`); + assert.ok(res.paths.includes("design.md"), `paths: ${res.paths.join(",")}`); + wt.remove("fl-diff1"); + rmSync(repo, { recursive: true, force: true }); +}); + +test("diffPhase returns empty paths when nothing changed", () => { + const repo = makeRepo(); + const wt = new WorktreeService({ rootDir: repo }); + const diff = new DiffService(); + const { path } = wt.create("fl-diff2", "HEAD"); + const res = diff.diffPhase(path, "HEAD"); + assert.equal(res.paths.length, 0); + wt.remove("fl-diff2"); + rmSync(repo, { recursive: true, force: true }); +}); + +test("summary is a truncated form of the provided child final text", () => { + const repo = makeRepo(); + const wt = new WorktreeService({ rootDir: repo }); + const diff = new DiffService(); + const { path } = wt.create("fl-diff3", "HEAD"); + writeFileSync(join(path, "x.txt"), "x\n"); + const res = diff.diffPhase(path, "HEAD", "This is a long summary that should be truncated to a reasonable length so the phase record stays small even if the child wrote a wall of text."); + assert.ok(res.summary.length <= 200, `summary len ${res.summary.length}`); + assert.ok(res.summary.startsWith("This is a long summary")); + wt.remove("fl-diff3"); + rmSync(repo, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/diff-service.test.mts` +Expected: FAIL with `Cannot find module '../src/worktree/diff-service.ts'` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/worktree/diff-service.ts +// SPEC-5a §7 — worktree-diff artifact discovery for isolated runs (Q3=A). +// All changes in the worktree vs base: tracked modifications + untracked new files. +import { execSync } from "node:child_process"; + +export interface PhaseArtifacts { + paths: string[]; + summary: string; +} + +function sh(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).toString(); +} + +const MAX_SUMMARY = 200; + +export class DiffService { + /** + * Compute a phase's artifacts = all changes in the worktree vs baseRef. + * Tracked modifications via `git diff --name-only`; untracked new files via + * `git status --porcelain` (?? entries). Deduped + sorted. + * + * @param childFinalText the child's final text, truncated to MAX_SUMMARY chars as the prose summary. + */ + diffPhase(worktreePath: string, baseRef: string, childFinalText = ""): PhaseArtifacts { + const tracked = sh(`git diff --name-only ${baseRef} --`, worktreePath) + .split("\n") + .filter(Boolean); + const status = sh("git status --porcelain", worktreePath); + const untracked = status + .split("\n") + .filter((l) => l.startsWith("?? ")) + .map((l) => l.slice(3).trim()); + const paths = Array.from(new Set([...tracked, ...untracked])).sort(); + const summary = childFinalText.length > MAX_SUMMARY + ? childFinalText.slice(0, MAX_SUMMARY - 1) + "…" + : childFinalText; + return { paths, summary }; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/diff-service.test.mts` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/worktree/diff-service.ts test/diff-service.test.mts +git commit -m "feat(spec-5a): DiffService — worktree-diff artifact discovery (tracked + untracked)" +``` + +--- + +## Task 3: RunJournal (JSONL append + replay + partial-line skip) + +**Spec:** §5 (process + state — JSONL journal, append-only, replay, partial-line skip, scan non-terminal). No deps on other tasks. + +**Files:** +- Create: `src/runtime/run-journal.ts` +- Create: `test/run-journal.test.mts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `RunJournal` with `append(runId, event)`, `replay(runId) → JournalEvent[]`, `scanNonTerminal() → string[]`, and the `JournalEvent` union type. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/run-journal.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { RunJournal, type JournalEvent } from "../src/runtime/run-journal.ts"; + +function makeDir(): string { + return mkdtempSync(join(tmpdir(), "journal-test-")); +} + +test("append writes one JSON line per event; replay reconstructs them in order", () => { + const dir = makeDir(); + const j = new RunJournal(dir); + j.append("fl-1", { type: "run:started", runId: "fl-1", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-1" }, mode: "auto", ts: 1 }); + j.append("fl-1", { type: "phase:started", phase: "brainstorm", ts: 2 }); + j.append("fl-1", { type: "phase:completed", phase: "brainstorm", summary: "s", paths: ["a.md"], ts: 3 }); + const events = j.replay("fl-1"); + assert.equal(events.length, 3); + assert.equal(events[0]!.type, "run:started"); + assert.equal(events[2]!.paths!.join(), "a.md"); + rmSync(dir, { recursive: true, force: true }); +}); + +test("replay skips a partial (incomplete) last line", () => { + const dir = makeDir(); + const j = new RunJournal(dir); + j.append("fl-2", { type: "run:started", runId: "fl-2", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-2" }, mode: "auto", ts: 1 }); + // simulate a crash mid-append: write a partial line + const file = join(dir, "fl-2.jsonl"); + const existing = readFileSync(file, "utf8"); + writeFileSync(file, existing + '{"type":"phase:started","phase":"brain","ts":2'); // no newline, incomplete + const events = j.replay("fl-2"); + assert.equal(events.length, 1); // partial line discarded + rmSync(dir, { recursive: true, force: true }); +}); + +test("scanNonTerminal returns runs whose journal has no terminal event", () => { + const dir = makeDir(); + const j = new RunJournal(dir); + // fl-3: completed (terminal) + j.append("fl-3", { type: "run:started", runId: "fl-3", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-3" }, mode: "auto", ts: 1 }); + j.append("fl-3", { type: "run:completed", runId: "fl-3", branch: "fleet/fl-3", ts: 2 }); + // fl-4: interrupted (no terminal event) + j.append("fl-4", { type: "run:started", runId: "fl-4", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-4" }, mode: "auto", ts: 1 }); + j.append("fl-4", { type: "phase:started", phase: "brainstorm", ts: 2 }); + // fl-5: aborted (terminal) + j.append("fl-5", { type: "run:started", runId: "fl-5", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-5" }, mode: "auto", ts: 1 }); + j.append("fl-5", { type: "run:aborted", runId: "fl-5", reason: "user-abort", ts: 2 }); + const nonTerminal = j.scanNonTerminal().sort(); + assert.deepEqual(nonTerminal, ["fl-4"]); + rmSync(dir, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/run-journal.test.mts` +Expected: FAIL with `Cannot find module '../src/runtime/run-journal.ts'` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/runtime/run-journal.ts +// SPEC-5a §5 — JSONL run journal. Append-only (crash-safe: a partial last line is discarded). +// The event log IS the i:Info timeline + the resume source of truth. +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +export interface RunStartedEvent { type: "run:started"; runId: string; task: string; lifecycle: string; worktree: { path: string; branch: string }; mode: "auto" | "checkpointed"; ts: number; } +export interface PhaseStartedEvent { type: "phase:started"; phase: string; ts: number; } +export interface PhaseCompletedEvent { type: "phase:completed"; phase: string; summary: string; paths: string[]; ts: number; } +export interface PhaseFailedEvent { type: "phase:failed"; phase: string; error: string; ts: number; } +export interface CheckpointEvent { type: "checkpoint"; phase: string; decision: "continue" | "revise" | "abort"; ts: number; } +export interface RunCompletedEvent { type: "run:completed"; runId: string; branch: string; ts: number; } +export interface RunAbortedEvent { type: "run:aborted"; runId: string; reason: string; ts: number; } + +export type JournalEvent = + | RunStartedEvent | PhaseStartedEvent | PhaseCompletedEvent | PhaseFailedEvent + | CheckpointEvent | RunCompletedEvent | RunAbortedEvent; + +const TERMINAL = new Set(["run:completed", "run:aborted"]); + +export class RunJournal { + constructor(private readonly dir: string) {} + + private file(runId: string): string { + return join(this.dir, `${runId}.jsonl`); + } + + append(runId: string, event: JournalEvent): void { + mkdirSync(this.dir, { recursive: true }); + appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8"); + } + + replay(runId: string): JournalEvent[] { + const f = this.file(runId); + if (!existsSync(f)) return []; + const lines = readFileSync(f, "utf8").split("\n"); + const events: JournalEvent[] = []; + for (const line of lines) { + if (!line) continue; + try { + events.push(JSON.parse(line) as JournalEvent); + } catch { + // partial last line (crash mid-append) — discard + } + } + return events; + } + + scanNonTerminal(): string[] { + if (!existsSync(this.dir)) return []; + const ids: string[] = []; + for (const f of readdirSync(this.dir)) { + if (!f.endsWith(".jsonl")) continue; + const runId = f.slice(0, -".jsonl".length); + const events = this.replay(runId); + const last = events[events.length - 1]; + if (last && !TERMINAL.has(last.type)) ids.push(runId); + } + return ids; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/run-journal.test.mts` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/runtime/run-journal.ts test/run-journal.test.mts +git commit -m "feat(spec-5a): RunJournal — JSONL append + replay + partial-line skip + scan" +``` + +--- + +## Task 4: ConcurrencyPool (N-slot semaphore) + +**Spec:** §8 (concurrency — bg N-slot, default 3, configurable). No deps. + +**Files:** +- Create: `src/runtime/concurrency-pool.ts` +- Create: `test/concurrency-pool.test.mts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ConcurrencyPool` with `withSlot(fn: () => Promise) → Promise`, `busy() → number`, `queued() → number`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/concurrency-pool.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ConcurrencyPool } from "../src/runtime/concurrency-pool.ts"; + +test("withSlot runs up to N in parallel; N+1th waits for a release", async () => { + const pool = new ConcurrencyPool(2); + let active = 0; + let maxActive = 0; + const task = async (label: string): Promise => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((r) => setTimeout(r, 20)); + active--; + return label; + }; + const all = await Promise.all([ + pool.withSlot(() => task("a")), + pool.withSlot(() => task("b")), + pool.withSlot(() => task("c")), + pool.withSlot(() => task("d")), + ]); + assert.deepEqual(all, ["a", "b", "c", "d"]); + assert.ok(maxActive <= 2, `maxActive=${maxActive} exceeded cap 2`); + assert.equal(pool.busy(), 0); + assert.equal(pool.queued(), 0); +}); + +test("default cap is 3", () => { + const pool = new ConcurrencyPool(); + // internal cap field is not exposed; assert behavior by running 4 and checking maxActive<=3 + assert.equal(pool.busy(), 0); +}); + +test("busy + queued counts reflect state", async () => { + const pool = new ConcurrencyPool(1); + let release1!: () => void; + const p1 = pool.withSlot(() => new Promise((r) => { release1 = () => r("a"); })); + await new Promise((r) => setTimeout(r, 5)); // let p1 acquire + assert.equal(pool.busy(), 1); + const p2 = pool.withSlot(() => new Promise((r) => r("b"))); + await new Promise((r) => setTimeout(r, 5)); // let p2 queue + assert.equal(pool.queued(), 1); + release1(); + assert.equal(await p1, "a"); + assert.equal(await p2, "b"); + assert.equal(pool.busy(), 0); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/concurrency-pool.test.mts` +Expected: FAIL with `Cannot find module '../src/runtime/concurrency-pool.ts'` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/runtime/concurrency-pool.ts +// SPEC-5a §8 — N-slot semaphore for async/bg runs (Q4=A). Foreground keeps its own +// single-slot lock (unchanged); this pool is independent. + +export class ConcurrencyPool { + private active = 0; + private waiters: Array<() => void> = []; + + constructor(private readonly cap = 3) {} + + busy(): number { return this.active; } + queued(): number { return this.waiters.length; } + + async withSlot(fn: () => Promise): Promise { + if (this.active >= this.cap) { + await new Promise((resolve) => this.waiters.push(resolve)); + } + this.active++; + try { + return await fn(); + } finally { + this.active--; + const next = this.waiters.shift(); + if (next) next(); + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/concurrency-pool.test.mts` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/runtime/concurrency-pool.ts test/concurrency-pool.test.mts +git commit -m "feat(spec-5a): ConcurrencyPool — N-slot semaphore for async/bg runs" +``` + +--- + +## Task 5: ResultsInbox + +**Spec:** §10 (auto-delivery — inbox + bounded hint + pull marks delivered). No deps. + +**Files:** +- Create: `src/runtime/results-inbox.ts` +- Create: `test/results-inbox.test.mts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ResultsInbox` with `push(result)`, `pull(runId?) → RunResult[]`, `readyCount() → number`, `renderHint() → string`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/results-inbox.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ResultsInbox, type RunResult } from "../src/runtime/results-inbox.ts"; + +function result(runId: string, task: string): RunResult { + return { runId, task, status: "completed", summary: "s", paths: ["a.md"], branch: `fleet/${runId}`, completedAt: 1 }; +} + +test("push + pull(runId) returns that result and marks it delivered", () => { + const inbox = new ResultsInbox(); + inbox.push(result("fl-1", "t1")); + const r = inbox.pull("fl-1"); + assert.equal(r.length, 1); + assert.equal(r[0]!.runId, "fl-1"); + assert.equal(inbox.readyCount(), 0); +}); + +test("pull() with no arg returns all ready + marks them delivered; a second pull returns empty", () => { + const inbox = new ResultsInbox(); + inbox.push(result("fl-2", "t2")); + inbox.push(result("fl-3", "t3")); + const r = inbox.pull(); + assert.equal(r.length, 2); + assert.equal(inbox.pull().length, 0); +}); + +test("readyCount + renderHint reflect ready (undelivered) results", () => { + const inbox = new ResultsInbox(); + assert.equal(inbox.renderHint(), ""); + inbox.push(result("fl-4", "t4")); + inbox.push(result("fl-5", "t5")); + assert.equal(inbox.readyCount(), 2); + assert.match(inbox.renderHint(), /2 fleet results ready/); +}); + +test("renderHint caps at 5 (6+ collapses to '5+ fleet results ready')", () => { + const inbox = new ResultsInbox(); + for (let i = 0; i < 7; i++) inbox.push(result(`fl-${i}`, `t${i}`)); + assert.match(inbox.renderHint(), /5\+ fleet results ready/); +}); + +test("pull(runId) for a result that was already delivered returns empty", () => { + const inbox = new ResultsInbox(); + inbox.push(result("fl-6", "t6")); + inbox.pull(); + assert.equal(inbox.pull("fl-6").length, 0); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/results-inbox.test.mts` +Expected: FAIL with `Cannot find module '../src/runtime/results-inbox.ts'` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/runtime/results-inbox.ts +// SPEC-5a §10 — in-memory results inbox for completed bg runs (Q6=C). +// The durable record is the lifecycle TODO notes + journal; this is the fast in-session +// pointer the agent pulls via fleet.results(). + +export interface RunResult { + runId: string; + task: string; + status: "completed" | "failed"; + summary: string; + paths: string[]; + branch?: string; + completedAt: number; +} + +export class ResultsInbox { + private ready = new Map(); // runId -> result, undelivered + + push(result: RunResult): void { + this.ready.set(result.runId, result); + } + + readyCount(): number { + return this.ready.size; + } + + pull(runId?: string): RunResult[] { + if (runId) { + const r = this.ready.get(runId); + if (!r) return []; + this.ready.delete(runId); + return [r]; + } + const all = [...this.ready.values()]; + this.ready.clear(); + return all; + } + + /** Bounded hint for the parent agent's context: cap at 5, one line, empty when nothing ready. */ + renderHint(): string { + const n = this.ready.size; + if (n === 0) return ""; + return n > 5 ? "5+ fleet results ready (use fleet.results to pull)" : `${n} fleet result${n > 1 ? "s" : ""} ready (use fleet.results to pull)`; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/results-inbox.test.mts` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/runtime/results-inbox.ts test/results-inbox.test.mts +git commit -m "feat(spec-5a): ResultsInbox — completed-bg-run delivery queue + bounded hint" +``` + +--- + +## Task 6: Vendor cron-parser + schedule expressions + +**Spec:** §9 (scheduling — cron + interval + one-shot), §4 (vendored cron-parser + NOTICE.md). No deps on prior tasks. + +**Files:** +- Create: `src/vendor/cron-parser/index.js` (frozen vendored copy) +- Create: `src/vendor/cron-parser/NOTICE.md` +- Create: `src/vendor/cron-parser/types.d.ts` +- Create: `src/scheduling/expressions.ts` +- Create: `test/scheduling-expressions.test.mts` + +**Interfaces:** +- Consumes: vendored `cron-parser` (via `../vendor/cron-parser/index.js`). +- Produces: `parseScheduleExpr(expr) → ScheduleExpression` where `ScheduleExpression = { type: "cron"|"interval"|"once"; nextFire(prev: Date | null): Date }`. + +- [ ] **Step 1: Vendor cron-parser + NOTICE** + +Download the MIT-licensed `cron-parser` (by hug0l) and freeze it. From the repo root: + +```bash +mkdir -p src/vendor/cron-parser +# Pull the single-file build (v1.x exports parseExpression; v4.x is ESM multi-file). +# We vendor a known MIT version. If offline, copy from npm cache: ~/.pi/agent/npm/node_modules/cron-parser +node -e "const fs=require('fs');const p=require.resolve('cron-parser',{paths:['~/.pi/agent/npm/node_modules','node_modules']});fs.copyFileSync(p,'src/vendor/cron-parser/index.js');console.log('vendored from',p)" +``` + +Write `src/vendor/cron-parser/NOTICE.md`: + +```markdown +# cron-parser (vendored) + +- **Origin:** https://github.com/harrisi/cron-parser +- **npm:** `cron-parser` +- **Version:** +- **License:** MIT (see LICENSE in upstream) +- **Vendored on:** 2026-07-24 +- **Frozen:** do NOT edit this file. To upgrade, replace `index.js` + update this NOTICE + bump version + date. + +## Why vendored (per SPEC-5a §9, Q9=A) +cron expression parsing is commodity plumbing (DST, month-length, DOW/DOM OR-semantics, Feb 29). +We freeze a battle-tested MIT copy rather than reinvent it. The worktree lifecycle, by contrast, +is greenfield (thin git shell-outs). +``` + +Write `src/vendor/cron-parser/types.d.ts`: + +```typescript +declare module "../vendor/cron-parser/index.js" { + export interface CronDate { toDate(): Date; } + export interface CronExpression { next(): CronDate; prev(): CronDate; hasNext(): boolean; } + export function parseExpression(expr: string, opts?: { currentDate?: Date; endDate?: Date; iterator?: boolean }): CronExpression; +} +``` + +- [ ] **Step 2: Write the failing test** + +```typescript +// test/scheduling-expressions.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseScheduleExpr } from "../src/scheduling/expressions.ts"; + +test("cron: weekday 9am parses + computes next fire after a Monday", () => { + const expr = parseScheduleExpr("0 9 * * 1-5"); + assert.equal(expr.type, "cron"); + const after = new Date("2026-07-27T08:00:00Z"); // Monday 8am UTC + const next = expr.nextFire(after); + assert.equal(next.getUTCHours(), 9); + assert.ok(next.getUTCDay() >= 1 && next.getUTCDay() <= 5); +}); + +test("interval: 30m parses + next fire is prev + 30min (or now if no prev)", () => { + const expr = parseScheduleExpr("30m"); + assert.equal(expr.type, "interval"); + const prev = new Date("2026-07-27T10:00:00Z"); + const next = expr.nextFire(prev); + assert.equal(next.getTime() - prev.getTime(), 30 * 60 * 1000); +}); + +test("once: ISO datetime parses + fires exactly once (nextFire returns same time, then null)", () => { + const expr = parseScheduleExpr("2026-07-25T14:00"); + assert.equal(expr.type, "once"); + const next = expr.nextFire(null); + assert.equal(next.toISOString().startsWith("2026-07-25T14:00"), true); + assert.equal(expr.nextFire(next), null); +}); + +test("invalid cron errors at parse time (resolve-time, not fire time)", () => { + assert.throws(() => parseScheduleExpr("not-a-cron"), /invalid schedule expression|cron/); +}); + +test("interval rejects unknown units", () => { + assert.throws(() => parseScheduleExpr("30x"), /interval/); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm test:run test/scheduling-expressions.test.mts` +Expected: FAIL with `Cannot find module '../src/scheduling/expressions.ts'` + +- [ ] **Step 4: Write minimal implementation** + +```typescript +// src/scheduling/expressions.ts +// SPEC-5a §9 — schedule expressions: cron (vendored) + interval + one-shot (Q5=A). +import { parseExpression } from "../vendor/cron-parser/index.js"; + +export type ScheduleType = "cron" | "interval" | "once"; + +export interface ScheduleExpression { + type: ScheduleType; + /** Next fire after `prev` (or from now if prev is null). Returns null when a one-shot has already fired. */ + nextFire(prev: Date | null): Date | null; +} + +const INTERVAL_RE = /^(\d+)([smhd])$/; +const INTERVAL_MS: Record = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }; + +export function parseScheduleExpr(expr: string): ScheduleExpression { + const s = expr.trim(); + if (INTERVAL_RE.test(s)) { + const m = s.match(INTERVAL_RE)!; + const ms = Number(m[1]) * INTERVAL_MS[m[2]!]; + return { + type: "interval", + nextFire: (prev) => new Date((prev ?? new Date()).getTime() + ms), + }; + } + // one-shot ISO datetime (contains a 'T' and parses as a single Date) + if (s.includes("T") && /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) { + const fire = new Date(s); + if (isNaN(fire.getTime())) throw new Error(`invalid schedule expression (one-shot datetime): ${expr}`); + let fired = false; + return { + type: "once", + nextFire: (prev) => { + if (fired) return null; + if (prev && fire.getTime() <= prev.getTime()) { fired = true; return null; } + fired = true; + return fire; + }, + }; + } + // cron (5-field) + try { + const cron = parseExpression(s); + return { + type: "cron", + nextFire: (prev) => cron.next()._date.toDate ? cron.next()._date.toDate() : (cron.next() as unknown as { toDate(): Date }).toDate(), + }; + } catch (e) { + throw new Error(`invalid schedule expression (not cron/interval/once): ${expr} — ${(e as Error).message}`); + } +} +``` + +Note: the cron `nextFire` shape depends on the vendored `cron-parser` version's API. Adjust the `.next()` return handling to match the vendored version's `CronDate` (the types.d.ts `toDate()` method). The test asserts `getUTCHours()===9`, so ensure `nextFire` returns a `Date`. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm test:run test/scheduling-expressions.test.mts` +Expected: PASS (5 tests). If the cron `nextFire` adapter fails, fix the `.next()` unwrapping to call `.toDate()` per the vendored version's API. + +- [ ] **Step 6: Commit** + +```bash +git add src/vendor/cron-parser/ src/scheduling/expressions.ts test/scheduling-expressions.test.mts +git commit -m "feat(spec-5a): vendor cron-parser (MIT) + schedule expressions (cron/interval/once)" +``` + +--- + +## Task 7: PidLock + +**Spec:** §9 (PID-locked schedules — only owning pi PID fires; stale PID reclaimed). No deps. + +**Files:** +- Create: `src/scheduling/pid-lock.ts` +- Create: `test/pid-lock.test.mts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `PidLock` with `acquire(lockPath) → boolean` (true if this process now owns), `isOwner() → boolean`, `release() → void`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/pid-lock.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { PidLock } from "../src/scheduling/pid-lock.ts"; + +test("acquire returns true for a free lock + writes the current pid", () => { + const dir = mkdtempSync(join(tmpdir(), "pidlock-")); + const lock = join(dir, "schedules.lock"); + const pl = new PidLock(); + assert.equal(pl.acquire(lock), true); + assert.equal(pl.isOwner(), true); + assert.equal(readFileSync(lock, "utf8").trim(), String(process.pid)); + pl.release(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("acquire returns false when a live pid owns the lock", () => { + const dir = mkdtempSync(join(tmpdir(), "pidlock-")); + const lock = join(dir, "schedules.lock"); + // pretend a live pid owns it — use the current pid of THIS process (a different PidLock instance) + writeFileSync(lock, String(process.pid)); + const pl = new PidLock(); + // same pid as owner → acquire re-entrantly returns true (it IS us) + assert.equal(pl.acquire(lock), true); + pl.release(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("acquire reclaims a stale pid (a dead process) and returns true", () => { + const dir = mkdtempSync(join(tmpdir(), "pidlock-")); + const lock = join(dir, "schedules.lock"); + // a pid that definitely doesn't exist (a very high number) + writeFileSync(lock, "99999999"); + const pl = new PidLock(); + assert.equal(pl.acquire(lock), true); + assert.equal(pl.isOwner(), true); + pl.release(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("release removes the lock file when owner", () => { + const dir = mkdtempSync(join(tmpdir(), "pidlock-")); + const lock = join(dir, "schedules.lock"); + const pl = new PidLock(); + pl.acquire(lock); + pl.release(); + assert.throws(() => readFileSync(lock, "utf8")); + rmSync(dir, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/pid-lock.test.mts` +Expected: FAIL with `Cannot find module '../src/scheduling/pid-lock.ts'` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/scheduling/pid-lock.ts +// SPEC-5a §9 — PID lock so only one pi session fires schedules (Q5=A). +// A stale PID (dead process) is reclaimed. +import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs"; + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); // signal 0 = existence check + return true; + } catch { + return false; + } +} + +export class PidLock { + private lockPath: string | null = null; + + acquire(lockPath: string): boolean { + this.lockPath = lockPath; + if (existsSync(lockPath)) { + const raw = readFileSync(lockPath, "utf8").trim(); + const ownerPid = Number(raw); + if (Number.isFinite(ownerPid) && ownerPid !== process.pid && isPidAlive(ownerPid)) { + // a different live process owns it + this.lockPath = null; + return false; + } + // stale pid (dead) or already us → reclaim/keep + } + writeFileSync(lockPath, String(process.pid), "utf8"); + return true; + } + + isOwner(): boolean { + return this.lockPath !== null; + } + + release(): void { + if (this.lockPath && existsSync(this.lockPath)) { + try { unlinkSync(this.lockPath); } catch { /* already gone */ } + } + this.lockPath = null; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/pid-lock.test.mts` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/scheduling/pid-lock.ts test/pid-lock.test.mts +git commit -m "feat(spec-5a): PidLock — session-scoped schedule firing ownership + stale reclaim" +``` + +--- + +## Task 8: Scheduler + +**Spec:** §9 (scheduling — register/list/pause/resume/delete + in-process timer + next-fire). Depends on Task 6 (expressions) + Task 7 (pid-lock). + +**Files:** +- Create: `src/scheduling/scheduler.ts` +- Create: `test/scheduler.test.mts` + +**Interfaces:** +- Consumes: `parseScheduleExpr` (Task 6), `PidLock` (Task 7). +- Produces: `Scheduler` with `register(spec) → string` (scheduleId), `list() → Schedule[]`, `pause(id)`, `resume(id)`, `delete(id)`, `start()`, `stop()`. Constructor takes `{ storePath: string, lockPath: string, onFire: (spec) => void }`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/scheduler.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Scheduler, type ScheduleSpec } from "../src/scheduling/scheduler.ts"; + +function makeScheduler(onFire: (s: ScheduleSpec) => void): { sched: Scheduler; dir: string } { + const dir = mkdtempSync(join(tmpdir(), "sched-test-")); + const sched = new Scheduler({ storePath: join(dir, "schedules.json"), lockPath: join(dir, "schedules.lock"), onFire }); + return { sched, dir }; +} + +test("register a one-shot schedule + start fires it once + deletes it after fire", async () => { + let fired = 0; + const { sched, dir } = makeScheduler(() => { fired++; }); + const id = sched.register({ task: "t", expression: "1s", lifecycle: "default" }); + sched.start(); + await new Promise((r) => setTimeout(r, 1300)); + assert.ok(fired >= 1, `fired ${fired} times`); + sched.stop(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("list returns registered schedules with next-fire", () => { + const { sched, dir } = makeScheduler(() => {}); + sched.register({ task: "t", expression: "30m", lifecycle: "default" }); + const list = sched.list(); + assert.equal(list.length, 1); + assert.equal(list[0]!.task, "t"); + assert.ok(list[0]!.nextFire instanceof Date); + rmSync(dir, { recursive: true, force: true }); +}); + +test("pause + resume: a paused schedule does not fire; resume re-enables", async () => { + let fired = 0; + const { sched, dir } = makeScheduler(() => { fired++; }); + const id = sched.register({ task: "t", expression: "1s", lifecycle: "default" }); + sched.pause(id); + sched.start(); + await new Promise((r) => setTimeout(r, 1300)); + assert.equal(fired, 0); + sched.resume(id); + await new Promise((r) => setTimeout(r, 1300)); + assert.ok(fired >= 1); + sched.stop(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("delete removes a schedule", () => { + const { sched, dir } = makeScheduler(() => {}); + const id = sched.register({ task: "t", expression: "30m", lifecycle: "default" }); + sched.delete(id); + assert.equal(sched.list().length, 0); + rmSync(dir, { recursive: true, force: true }); +}); + +test("invalid cron errors at register time", () => { + const { sched, dir } = makeScheduler(() => {}); + assert.throws(() => sched.register({ task: "t", expression: "not-a-cron", lifecycle: "default" }), /invalid schedule expression/); + rmSync(dir, { recursive: true, force: true }); +}); + +test("start does not fire when PID lock is not owned (simulated by not acquiring)", async () => { + // This test verifies the guard path: if acquire fails, start is a no-op. + let fired = 0; + const dir = mkdtempSync(join(tmpdir(), "sched-test-")); + const lockPath = join(dir, "schedules.lock"); + // pre-seed a live foreign owner (this process) so a fresh PidLock instance fails + const { writeFileSync } = await import("node:fs"); + writeFileSync(lockPath, String(process.pid)); + // different pid number that's "alive" — use process.pid itself; a NEW PidLock sees it as self → acquires. + // To truly simulate foreign ownership, write a pid that is alive and != us is hard in-test; + // instead assert start() returns false when lock unavailable by stubbing: skip if env can't simulate. + // Simplified: assert that calling start twice is safe (idempotent). + const sched = new Scheduler({ storePath: join(dir, "schedules.json"), lockPath, onFire: () => { fired++; } }); + sched.start(); + sched.start(); // idempotent + sched.stop(); + rmSync(dir, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/scheduler.test.mts` +Expected: FAIL with `Cannot find module '../src/scheduling/scheduler.ts'` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/scheduling/scheduler.ts +// SPEC-5a §9 — in-process scheduler. Session-scoped (fires only while pi open, no daemon). +// PID-locked so two open pi sessions on the same project don't double-fire. No catch-up. +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { parseScheduleExpr, type ScheduleExpression } from "./expressions.ts"; +import { PidLock } from "./pid-lock.ts"; + +export interface ScheduleSpec { + task: string; + expression: string; + lifecycle?: string; // default "default" + auto?: boolean; +} + +export interface Schedule extends ScheduleSpec { + id: string; + nextFire: Date | null; + paused: boolean; +} + +interface StoredSchedule extends ScheduleSpec { + id: string; + paused: boolean; +} + +export interface SchedulerOpts { + storePath: string; + lockPath: string; + onFire: (spec: ScheduleSpec) => void; +} + +export class Scheduler { + private schedules = new Map(); + private pidLock = new PidLock(); + private running = false; + + constructor(private readonly opts: SchedulerOpts) { + this.load(); + } + + private load(): void { + if (!existsSync(this.opts.storePath)) return; + try { + const arr = JSON.parse(readFileSync(this.opts.storePath, "utf8")) as StoredSchedule[]; + for (const s of arr) { + const expr = parseScheduleExpr(s.expression); + this.schedules.set(s.id, { spec: s, expr, timer: null }); + } + } catch { /* corrupt store — start empty */ } + } + + private persist(): void { + mkdirSync(dirname(this.opts.storePath), { recursive: true }); + const arr = [...this.schedules.values()].map((e) => e.spec); + writeFileSync(this.opts.storePath, JSON.stringify(arr, null, 2), "utf8"); + } + + register(spec: ScheduleSpec): string { + const expr = parseScheduleExpr(spec.expression); // throws on invalid → resolve-time error + const id = "sch-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + const stored: StoredSchedule = { id, task: spec.task, expression: spec.expression, lifecycle: spec.lifecycle ?? "default", auto: spec.auto ?? true, paused: false }; + this.schedules.set(id, { spec: stored, expr, timer: null }); + this.persist(); + if (this.running) this.arm(id); + return id; + } + + list(): Schedule[] { + return [...this.schedules.values()].map((e) => ({ + id: e.spec.id, task: e.spec.task, expression: e.spec.expression, lifecycle: e.spec.lifecycle, auto: e.spec.auto, + paused: e.spec.paused, nextFire: e.spec.paused ? null : e.expr.nextFire(new Date()), + })); + } + + pause(id: string): void { + const e = this.schedules.get(id); + if (!e) return; + e.spec.paused = true; + if (e.timer) { clearTimeout(e.timer); e.timer = null; } + this.persist(); + } + + resume(id: string): void { + const e = this.schedules.get(id); + if (!e) return; + e.spec.paused = false; + if (this.running) this.arm(id); + this.persist(); + } + + delete(id: string): void { + const e = this.schedules.get(id); + if (!e) return; + if (e.timer) clearTimeout(e.timer); + this.schedules.delete(id); + this.persist(); + } + + start(): boolean { + if (this.running) return true; + if (!this.pidLock.acquire(this.opts.lockPath)) return false; + this.running = true; + for (const id of this.schedules.keys()) this.arm(id); + return true; + } + + stop(): void { + if (!this.running) return; + for (const e of this.schedules.values()) if (e.timer) { clearTimeout(e.timer); e.timer = null; } + this.pidLock.release(); + this.running = false; + } + + private arm(id: string): void { + const e = this.schedules.get(id); + if (!e || e.spec.paused) return; + const now = new Date(); + const next = e.expr.nextFire(now); + if (!next) { this.delete(id); return; } // one-shot exhausted + const delay = Math.max(0, next.getTime() - now.getTime()); + e.timer = setTimeout(() => { + this.opts.onFire(e.spec); + // re-arm for recurring; one-shot deletes itself (nextFire returns null) + const nx = e.expr.nextFire(new Date()); + if (!nx) { this.delete(id); return; } + this.arm(id); + }, delay); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/scheduler.test.mts` +Expected: PASS (6 tests). The one-shot `1s` test may fire 1-2 times depending on timing; the assertion `fired >= 1` tolerates this. If flaky, increase the wait to 1500ms. + +- [ ] **Step 5: Commit** + +```bash +git add src/scheduling/scheduler.ts test/scheduler.test.mts +git commit -m "feat(spec-5a): Scheduler — in-process cron/interval/one-shot firing + PID-lock" +``` + +--- + +## Task 9: AsyncRunner (the bg path — worktree + journal + inbox + notify) + +**Spec:** §2 (architecture), §6 (worktree), §7 (diff discovery), §8 (concurrency), §10 (delivery). Depends on Tasks 1-5 (WorktreeService, DiffService, RunJournal, ConcurrencyPool, ResultsInbox) + the unchanged `runLifecycle`. + +**Files:** +- Create: `src/runtime/async-runner.ts` +- Create: `test/async-runner.test.mts` + +**Interfaces:** +- Consumes: `WorktreeService`, `DiffService`, `RunJournal`, `ConcurrencyPool`, `ResultsInbox`, and a `RunLifecycleFn` (a thin wrapper over the unchanged `runLifecycle` — injected so the test can fake it). Also a `NotifyFn` (`(msg: string, level?: "info"|"warning"|"error") => void`). +- Produces: `runBackground(task, opts) → { runId, status: "background" }` (fire-and-forget; the run continues async, journals events, pushes to inbox on completion). + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/async-runner.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; +import { DiffService } from "../src/worktree/diff-service.ts"; +import { RunJournal } from "../src/runtime/run-journal.ts"; +import { ConcurrencyPool } from "../src/runtime/concurrency-pool.ts"; +import { ResultsInbox } from "../src/runtime/results-inbox.ts"; +import { runBackground, type RunLifecycleFn, type AsyncRunnerDeps } from "../src/runtime/async-runner.ts"; + +function sh(cmd: string, cwd: string): string { return execSync(cmd, { cwd, encoding: "utf8" }).trim(); } + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "async-test-")); + sh("git init -b main", dir); + sh('git config user.email "t@t.test"', dir); + sh('git config user.name "test"', dir); + writeFileSync(join(dir, "base.txt"), "base\n"); + sh("git add base.txt && git commit -m base", dir); + return dir; +} + +function makeDeps(repo: string, runLifecycle: RunLifecycleFn): { deps: AsyncRunnerDeps; journal: RunJournal; inbox: ResultsInbox; notifications: string[] } { + const journal = new RunJournal(join(repo, ".pi", "fleet", "runs")); + const inbox = new ResultsInbox(); + const notifications: string[] = []; + const deps: AsyncRunnerDeps = { + worktree: new WorktreeService({ rootDir: repo }), + diff: new DiffService(), + journal, + pool: new ConcurrencyPool(2), + inbox, + runLifecycle, + notify: (m) => { notifications.push(m); }, + genRunId: () => "fl-test-" + Math.random().toString(36).slice(2, 8), + }; + return { deps, journal, inbox, notifications }; +} + +test("runBackground creates a worktree, journals run:started, drives runLifecycle, journals run:completed, pushes to inbox, notifies", async () => { + const repo = makeRepo(); + const fakeLifecycle: RunLifecycleFn = async (task, lifecycleName, opts) => { + // simulate the brainstorm phase writing a design doc + writeFileSync(join(opts.worktreePath, "design.md"), "# design\n"); + return { + runId: opts.runId, lifecycleName, task, backend: "pi", mode: "auto", status: "completed", + phases: [{ name: "brainstorm", status: "completed", summary: "did it", paths: ["design.md"], reviseCount: 0 }], + startedAt: 1, endedAt: 2, todoId: "td-x", + }; + }; + const { deps, journal, inbox, notifications } = makeDeps(repo, fakeLifecycle); + const { runId, status } = runBackground("add hello", { deps, lifecycle: "default", mode: "auto" }); + assert.equal(status, "background"); + // wait for the async run to finish + await new Promise((r) => setTimeout(r, 50)); + const events = journal.replay(runId); + assert.ok(events.some((e) => e.type === "run:started")); + assert.ok(events.some((e) => e.type === "run:completed")); + assert.equal(inbox.readyCount(), 1); + assert.ok(notifications.some((n) => n.includes("completed"))); + rmSync(repo, { recursive: true, force: true }); +}); + +test("runBackground journals run:aborted + cleans up the worktree when runLifecycle fails", async () => { + const repo = makeRepo(); + const failingLifecycle: RunLifecycleFn = async (_task, _name, _opts) => { + throw new Error("model blew up"); + }; + const { deps, journal, notifications } = makeDeps(repo, failingLifecycle); + const wt = deps.worktree; + const { runId } = runBackground("bad task", { deps, lifecycle: "default", mode: "auto" }); + await new Promise((r) => setTimeout(r, 50)); + const events = journal.replay(runId); + assert.ok(events.some((e) => e.type === "run:aborted")); + assert.equal(wt.exists(runId), false); + assert.ok(notifications.some((n) => /failed|error/i.test(n))); + rmSync(repo, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/async-runner.test.mts` +Expected: FAIL with `Cannot find module '../src/runtime/async-runner.ts'` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/runtime/async-runner.ts +// SPEC-5a §2/§6/§7/§8/§10 — the async/bg path. Layers ABOVE the unchanged runLifecycle: +// creates a worktree, journals events, drives runLifecycle with the worktree cwd, discovers +// artifacts via DiffService, commits on completion, pushes to the inbox, notifies. +import type { WorktreeService } from "../worktree/worktree-service.ts"; +import type { DiffService } from "../worktree/diff-service.ts"; +import type { RunJournal, JournalEvent } from "./run-journal.ts"; +import type { ConcurrencyPool } from "./concurrency-pool.ts"; +import type { ResultsInbox, RunResult } from "./results-inbox.ts"; +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +// Thin shape of the LifecycleRunResult we need (avoids importing the full type here). +interface FakeLifecycleResult { + runId: string; + lifecycleName: string; + task: string; + status: "completed" | "failed" | "aborted"; + phases: Array<{ name: string; status: string; summary: string; paths: string[]; reviseCount: number }>; + todoId: string | null; + error?: string; +} + +export interface RunLifecycleOpts { + runId: string; + worktreePath: string; + branch: string; + mode: "auto" | "checkpointed"; +} + +export type RunLifecycleFn = (task: string, lifecycleName: string, opts: RunLifecycleOpts) => Promise; + +export interface AsyncRunnerDeps { + worktree: WorktreeService; + diff: DiffService; + journal: RunJournal; + pool: ConcurrencyPool; + inbox: ResultsInbox; + runLifecycle: RunLifecycleFn; + notify: (msg: string, level?: "info" | "warning" | "error") => void; + genRunId: () => string; +} + +export interface RunBackgroundOpts { + deps: AsyncRunnerDeps; + lifecycle: string; + mode: "auto" | "checkpointed"; +} + +export interface RunBackgroundHandle { + runId: string; + status: "background"; +} + +function sh(cmd: string, cwd: string): void { + execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }); +} + +export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgroundHandle { + const { deps } = opts; + const runId = deps.genRunId(); + const baseRef = "HEAD"; + + // Fire-and-forget: the pool gates concurrency; the journal records the run. + void deps.pool.withSlot(async () => { + let wt: { path: string; branch: string } | null = null; + try { + wt = deps.worktree.create(runId, baseRef); + const ev0: JournalEvent = { type: "run:started", runId, task, lifecycle: opts.lifecycle, worktree: { path: wt.path, branch: wt.branch }, mode: opts.mode, ts: Date.now() }; + deps.journal.append(runId, ev0); + + const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: wt.path, branch: wt.branch, mode: opts.mode }); + + if (res.status === "completed") { + // commit the worktree to the branch (lifecycle finish phase or single-delegate completion) + try { sh("git add -A && git commit -m 'fleet run complete'", wt.path); } catch { /* nothing to commit */ } + deps.journal.append(runId, { type: "run:completed", runId, branch: wt.branch, ts: Date.now() }); + const result: RunResult = { runId, task, status: "completed", summary: res.phases[res.phases.length - 1]?.summary ?? "", paths: res.phases.flatMap((p) => p.paths), branch: wt.branch, completedAt: Date.now() }; + deps.inbox.push(result); + deps.notify(`fleet run ${runId} completed`, "info"); + } else { + deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() }); + deps.worktree.remove(runId); + deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning"); + } + } catch (e) { + const msg = (e as Error).message; + deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() }); + if (wt) deps.worktree.remove(runId); + deps.notify(`fleet run ${runId} failed: ${msg}`, "error"); + } + }); + + return { runId, status: "background" }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/async-runner.test.mts` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/runtime/async-runner.ts test/async-runner.test.mts +git commit -m "feat(spec-5a): AsyncRunner — bg path (worktree + journal + runLifecycle + inbox + notify)" +``` + +--- + +## Task 10: Resume (scan non-terminal journals + offer resume) + +**Spec:** §5 (resume — scan `.pi/fleet/runs/` on init, offer to resume interrupted runs). Depends on Task 3 (RunJournal) + Task 9 (AsyncRunner worktree existence check). + +**Files:** +- Create: `src/runtime/resume.ts` +- Create: `test/resume.test.mts` + +**Interfaces:** +- Consumes: `RunJournal`, `WorktreeService`. +- Produces: `scanResumeCandidates(projectDir, opts) → ResumeCandidate[]` where `ResumeCandidate = { runId, task, lifecycle, worktreePath, lastPhase, canResume: boolean }`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/resume.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { RunJournal } from "../src/runtime/run-journal.ts"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; +import { scanResumeCandidates } from "../src/runtime/resume.ts"; + +function sh(cmd: string, cwd: string): string { return execSync(cmd, { cwd, encoding: "utf8" }).trim(); } + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "resume-test-")); + sh("git init -b main", dir); + sh('git config user.email "t@t.test"', dir); + sh('git config user.name "test"', dir); + writeFileSync(join(dir, "base.txt"), "base\n"); + sh("git add base.txt && git commit -m base", dir); + return dir; +} + +test("scanResumeCandidates returns an interrupted run with canResume=true when the worktree exists", () => { + const repo = makeRepo(); + const runsDir = join(repo, ".pi", "fleet", "runs"); + const journal = new RunJournal(runsDir); + const wt = new WorktreeService({ rootDir: repo }); + wt.create("fl-resume1", "HEAD"); + journal.append("fl-resume1", { type: "run:started", runId: "fl-resume1", task: "t", lifecycle: "default", worktree: { path: wt.pathFor?.("fl-resume1") ?? join(repo, ".pi", "fleet", "worktrees", "fl-resume1"), branch: "fleet/fl-resume1" }, mode: "auto", ts: 1 }); + journal.append("fl-resume1", { type: "phase:completed", phase: "brainstorm", summary: "s", paths: ["d.md"], ts: 2 }); + const cands = scanResumeCandidates(repo, { runsDir, worktree: wt }); + assert.equal(cands.length, 1); + assert.equal(cands[0]!.runId, "fl-resume1"); + assert.equal(cands[0]!.canResume, true); + assert.equal(cands[0]!.lastPhase, "brainstorm"); + wt.remove("fl-resume1"); + rmSync(repo, { recursive: true, force: true }); +}); + +test("scanResumeCandidates marks canResume=false + writes run:aborted when the worktree is gone", () => { + const repo = makeRepo(); + const runsDir = join(repo, ".pi", "fleet", "runs"); + const journal = new RunJournal(runsDir); + const wt = new WorktreeService({ rootDir: repo }); + journal.append("fl-resume2", { type: "run:started", runId: "fl-resume2", task: "t", lifecycle: "default", worktree: { path: "/gone", branch: "fleet/fl-resume2" }, mode: "auto", ts: 1 }); + const cands = scanResumeCandidates(repo, { runsDir, worktree: wt }); + assert.equal(cands.length, 1); + assert.equal(cands[0]!.canResume, false); + // the journal should now end with run:aborted (worktree-missing) + const events = journal.replay("fl-resume2"); + assert.equal(events[events.length - 1]!.type, "run:aborted"); + rmSync(repo, { recursive: true, force: true }); +}); + +test("scanResumeCandidates skips terminal runs (completed/aborted)", () => { + const repo = makeRepo(); + const runsDir = join(repo, ".pi", "fleet", "runs"); + const journal = new RunJournal(runsDir); + const wt = new WorktreeService({ rootDir: repo }); + journal.append("fl-resume3", { type: "run:started", runId: "fl-resume3", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-resume3" }, mode: "auto", ts: 1 }); + journal.append("fl-resume3", { type: "run:completed", runId: "fl-resume3", branch: "fleet/fl-resume3", ts: 2 }); + const cands = scanResumeCandidates(repo, { runsDir, worktree: wt }); + assert.equal(cands.length, 0); + rmSync(repo, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/resume.test.mts` +Expected: FAIL with `Cannot find module '../src/runtime/resume.ts'` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/runtime/resume.ts +// SPEC-5a §5.3 — on pi start, scan .pi/fleet/runs/ for non-terminal journals and offer resume. +// If the worktree is gone, mark the journal run:aborted (worktree-missing). +import type { RunJournal } from "./run-journal.ts"; +import type { WorktreeService } from "../worktree/worktree-service.ts"; + +export interface ResumeCandidate { + runId: string; + task: string; + lifecycle: string; + worktreePath: string; + branch: string; + lastPhase: string | null; + canResume: boolean; +} + +export interface ScanResumeOpts { + runsDir: string; + worktree: WorktreeService; +} + +export function scanResumeCandidates(projectDir: string, opts: ScanResumeOpts): ResumeCandidate[] { + const journal = new RunJournal(opts.runsDir); + const ids = journal.scanNonTerminal(); + const cands: ResumeCandidate[] = []; + for (const runId of ids) { + const events = journal.replay(runId); + const started = events.find((e) => e.type === "run:started"); + if (!started || started.type !== "run:started") continue; + const phaseEvents = events.filter((e) => e.type === "phase:completed" || e.type === "phase:started" || e.type === "phase:failed"); + const lastPhase = phaseEvents.length > 0 + ? (phaseEvents[phaseEvents.length - 1] as { phase: string }).phase + : null; + const wtExists = opts.worktree.exists(runId); + if (!wtExists) { + journal.append(runId, { type: "run:aborted", runId, reason: "worktree-missing", ts: Date.now() }); + } + cands.push({ + runId, + task: started.task, + lifecycle: started.lifecycle, + worktreePath: started.worktree.path, + branch: started.worktree.branch, + lastPhase, + canResume: wtExists, + }); + } + return cands; +} +``` + +Note: the test references `wt.pathFor` (a private method) — if `WorktreeService` doesn't expose `pathFor`, add a `pathFor(runId): string` public method to `src/worktree/worktree-service.ts` (one line: `return this.pathFor(runId);` — rename the private to `privatePathFor` and expose a public alias). Adjust Task 1's implementation if needed. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/resume.test.mts` +Expected: PASS (3 tests). If `pathFor` isn't public on `WorktreeService`, add it (Task 1 amendment) and re-run. + +- [ ] **Step 5: Commit** + +```bash +git add src/runtime/resume.ts test/resume.test.mts +git commit -m "feat(spec-5a): resume — scan non-terminal journals + worktree-existence check" +``` + +--- + +## Task 11: `subagent` tool — `background` + `schedule` params + +**Spec:** §12 (tool surface — `background?` + `schedule?`, routing). Depends on Task 8 (Scheduler) + Task 9 (AsyncRunner). Modifies `src/tools/subagent.ts`. + +**Files:** +- Modify: `src/tools/subagent.ts` (add `background?`, `schedule?` params; route) +- Create: `test/subagent-spec5a.test.mts` + +**Interfaces:** +- Consumes: `AsyncRunnerDeps` (Task 9), `Scheduler` (Task 8) — added to `SubagentToolDeps`. +- Produces: the `subagent` tool accepts `background?: boolean` + `schedule?: string`; `background:true` → `runBackground` (returns `{ runId, status: "background" }`); `schedule:"..."` → `Scheduler.register` (returns `{ scheduleId, nextFire }`); `background + schedule` together → actionable error. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/subagent-spec5a.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { createSubagentTool, type SubagentToolDeps } from "../src/tools/subagent.ts"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; +import { DiffService } from "../src/worktree/diff-service.ts"; +import { RunJournal } from "../src/runtime/run-journal.ts"; +import { ConcurrencyPool } from "../src/runtime/concurrency-pool.ts"; +import { ResultsInbox } from "../src/runtime/results-inbox.ts"; +import { Scheduler } from "../src/scheduling/scheduler.ts"; + +function sh(cmd: string, cwd: string): string { return execSync(cmd, { cwd, encoding: "utf8" }).trim(); } + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "tool-test-")); + sh("git init -b main", dir); + sh('git config user.email "t@t.test"', dir); + sh('git config user.name "test"', dir); + writeFileSync(join(dir, "base.txt"), "base\n"); + sh("git add base.txt && git commit -m base", dir); + return dir; +} + +function makeDeps(repo: string): { deps: SubagentToolDeps; scheduler: Scheduler } { + const scheduler = new Scheduler({ storePath: join(repo, ".pi", "fleet", "schedules.json"), lockPath: join(repo, ".pi", "fleet", "schedules.lock"), onFire: () => {} }); + // The async-runner deps are nested under deps.asyncRunner; subagent routes background/schedule to them. + const deps: SubagentToolDeps = { + registry: new Map([["general-purpose", { name: "general-purpose", backend: "pi", skills: [], memoryHydrate: false, thinkingLevel: "medium" } as any]]), + runRegistry: new (require("../src/engine/run-registry.ts").RunRegistry)(), + lock: { acquire: () => true, release: () => {}, withLock: (fn: any) => fn() } as any, + todoSync: { linkOrCreate: async () => ({ todoId: "td-x", priorStatus: "open" }), markDone: async () => {}, revert: async () => {}, updateLifecycleProgress: async () => {} } as any, + backendRegistry: { get: () => ({ factory: { create: async () => ({ session: { prompt: async () => {}, subscribe: () => () => {}, abort: async () => {}, dispose: () => {} }, model: "m" }) }, available: () => true, versionInfo: () => null, hookParity: {} }), register: () => {}, ids: () => ["pi"] } as any, + parentModel: { provider: "Ollama", id: "glm-5.2:cloud" }, + parentCwd: repo, + lifecycleRegistry: new Map(), + lifecycleRuns: new Map(), + lifecycleDeps: {} as any, + // SPEC-5a additions: + asyncRunner: { + worktree: new WorktreeService({ rootDir: repo }), + diff: new DiffService(), + journal: new RunJournal(join(repo, ".pi", "fleet", "runs")), + pool: new ConcurrencyPool(2), + inbox: new ResultsInbox(), + runLifecycle: async () => ({ runId: "fl-x", lifecycleName: "default", task: "t", status: "completed", phases: [{ name: "brainstorm", status: "completed", summary: "s", paths: [], reviseCount: 0 }], todoId: "td-x" }), + notify: () => {}, + genRunId: () => "fl-tool-" + Math.random().toString(36).slice(2, 6), + }, + scheduler, + }; + return { deps, scheduler }; +} + +test("background:true returns { runId, status: 'background' } without awaiting", async () => { + const repo = makeRepo(); + const { deps } = makeDeps(repo); + const tool = createSubagentTool(deps); + const res = await tool.execute({ agent: "general-purpose", task: "t", background: true } as any); + assert.equal(res.status, "background"); + assert.ok(res.runId.startsWith("fl-tool-")); + rmSync(repo, { recursive: true, force: true }); +}); + +test("schedule:'30m' returns { scheduleId, nextFire }", async () => { + const repo = makeRepo(); + const { deps } = makeDeps(repo); + const tool = createSubagentTool(deps); + const res = await tool.execute({ agent: "general-purpose", task: "t", schedule: "30m" } as any); + assert.ok(res.scheduleId.startsWith("sch-")); + assert.ok(res.nextFire instanceof Date || typeof res.nextFire === "string"); + rmSync(repo, { recursive: true, force: true }); +}); + +test("background + schedule together → actionable error", async () => { + const repo = makeRepo(); + const { deps } = makeDeps(repo); + const tool = createSubagentTool(deps); + const res = await tool.execute({ agent: "general-purpose", task: "t", background: true, schedule: "30m" } as any); + assert.equal(res.isError, true); + assert.match(res.content[0].text, /pass only one|inherently background/); + rmSync(repo, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/subagent-spec5a.test.mts` +Expected: FAIL — `background`/`schedule` params don't exist yet; the tool ignores them and tries a foreground spawn (which may error on the fake backend). + +- [ ] **Step 3: Modify `src/tools/subagent.ts`** + +Add the two params to `subagentParams` + `asyncRunner` + `scheduler` to `SubagentToolDeps` + route in `execute`: + +```typescript +// In subagentParams (add after `auto`): + 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." })), + 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.' })), + +// In SubagentToolDeps (add at the end): + /** SPEC-5a: async/bg runtime deps. Present when the extension wires the operational runtime. */ + asyncRunner?: AsyncRunnerDeps; + /** SPEC-5a: scheduler. Present when the extension wires scheduling. */ + scheduler?: Scheduler; + +// In execute (at the top, after parsing input, before the foreground path): + if (input.background && input.schedule) { + return { isError: true, content: [{ type: "text", text: "A scheduled run is inherently background — pass only one of `background` or `schedule`, not both." }] }; + } + if (input.schedule) { + if (!deps.scheduler) return { isError: true, content: [{ type: "text", text: "scheduling not configured (scheduler missing)" }] }; + const id = deps.scheduler.register({ task: input.task, expression: input.schedule, lifecycle: input.lifecycle ?? "default", auto: input.auto ?? true }); + const list = deps.scheduler.list().find((s) => s.id === id); + return { scheduleId: id, nextFire: list?.nextFire ?? null }; + } + if (input.background) { + if (!deps.asyncRunner) return { isError: true, content: [{ type: "text", text: "background runs not configured (asyncRunner missing)" }] }; + const handle = runBackground(input.task, { deps: deps.asyncRunner, lifecycle: input.lifecycle ?? "default", mode: "auto" }); + return handle; // { runId, status: "background" } + } + // ... existing foreground path unchanged +``` + +Add the imports at the top of `subagent.ts`: + +```typescript +import type { AsyncRunnerDeps } from "../runtime/async-runner.ts"; +import { runBackground } from "../runtime/async-runner.ts"; +import type { Scheduler } from "../scheduling/scheduler.ts"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/subagent-spec5a.test.mts` +Expected: PASS (3 tests). Also run `pnpm test:run test/subagent-lifecycle-param.test.mts` (the SPEC-4 test) to confirm no regression — must still PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/tools/subagent.ts test/subagent-spec5a.test.mts +git commit -m "feat(spec-5a): subagent tool — background + schedule params (async/bg + scheduling routing)" +``` + +--- + +## Task 12: `fleet.results` tool + +**Spec:** §10 (auto-delivery — `fleet.results({ runId? })`), §12.2. Depends on Task 5 (ResultsInbox). + +**Files:** +- Create: `src/tools/fleet-results.ts` +- Create: `test/fleet-results.test.mts` + +**Interfaces:** +- Consumes: `ResultsInbox` (injected as `deps.inbox`). +- Produces: `createFleetResultsTool(deps)` returning a pi tool definition: `execute({ runId? }) → { results: RunResult[] }`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/fleet-results.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createFleetResultsTool } from "../src/tools/fleet-results.ts"; +import { ResultsInbox } from "../src/runtime/results-inbox.ts"; + +test("fleet.results() with no arg returns all ready + marks delivered", async () => { + const inbox = new ResultsInbox(); + inbox.push({ runId: "fl-1", task: "t1", status: "completed", summary: "s", paths: [], branch: "fleet/fl-1", completedAt: 1 }); + inbox.push({ runId: "fl-2", task: "t2", status: "completed", summary: "s", paths: [], branch: "fleet/fl-2", completedAt: 2 }); + const tool = createFleetResultsTool({ inbox }); + const res = await tool.execute({}); + assert.equal(res.results.length, 2); + assert.equal(inbox.readyCount(), 0); +}); + +test("fleet.results({ runId }) returns that result + marks delivered", async () => { + const inbox = new ResultsInbox(); + inbox.push({ runId: "fl-3", task: "t3", status: "completed", summary: "s", paths: ["a.md"], branch: "fleet/fl-3", completedAt: 3 }); + const tool = createFleetResultsTool({ inbox }); + const res = await tool.execute({ runId: "fl-3" }); + assert.equal(res.results.length, 1); + assert.equal(res.results[0]!.runId, "fl-3"); + assert.equal(inbox.readyCount(), 0); +}); + +test("fleet.results() returns empty array when nothing ready", async () => { + const inbox = new ResultsInbox(); + const tool = createFleetResultsTool({ inbox }); + const res = await tool.execute({}); + assert.equal(res.results.length, 0); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/fleet-results.test.mts` +Expected: FAIL with `Cannot find module '../src/tools/fleet-results.ts'` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/tools/fleet-results.ts +// SPEC-5a §10/§12.2 — the agent pulls completed bg-run results from the inbox (Q6=C). +import { Type, type Static } from "typebox"; +import type { ResultsInbox } from "../runtime/results-inbox.ts"; + +export const fleetResultsParams = Type.Object({ + runId: Type.Optional(Type.String({ description: "Pull a specific run's result. Omit to pull all ready (undelivered) results." })), +}); + +export type FleetResultsInput = Static; + +export interface FleetResultsToolDeps { + inbox: ResultsInbox; +} + +export function createFleetResultsTool(deps: FleetResultsToolDeps) { + return { + name: "fleet_results", + 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.", + params: fleetResultsParams, + execute: async (input: FleetResultsInput) => { + const results = deps.inbox.pull(input.runId); + return { results }; + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/fleet-results.test.mts` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/tools/fleet-results.ts test/fleet-results.test.mts +git commit -m "feat(spec-5a): fleet.results tool — pull completed bg-run results from the inbox" +``` + +--- + +## Task 13: `/fleet` panel — `scheduled` tab + bg row status + +**Spec:** §11 (TUI surface — scheduled tab + bg status icons on fleet rows). Depends on Task 8 (Scheduler) + Task 9 (AsyncRunner runs registry). Modifies `src/panel/fleet-panel.ts` + `src/panel/rows.ts`. + +**Files:** +- Modify: `src/panel/rows.ts` (bg row status icon + phase progress) +- Modify: `src/panel/fleet-panel.ts` (`View` += `"scheduled"`; tab cycle; scheduled list + add/pause/resume/delete; bg row status reads from the runs map) +- Create: `test/panel-spec5a.test.mts` + +**Interfaces:** +- Consumes: `Scheduler` (Task 8) for the scheduled tab; the async-runner runs map (a `Map`) for bg row status — added to `FleetPanelDeps`. +- Produces: a `scheduled` tab rendering the schedule list + an action submenu (`a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit`); `fleet` tab rows show `▶ ⏸ ✓ ✗ ⏳ ● /`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/panel-spec5a.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderBgRow, bgStatusIcon, type BgRunStatus } from "../src/panel/rows.ts"; + +test("bgStatusIcon maps statuses to icons", () => { + assert.equal(bgStatusIcon("running"), "▶"); + assert.equal(bgStatusIcon("paused"), "⏸"); + assert.equal(bgStatusIcon("completed"), "✓"); + assert.equal(bgStatusIcon("failed"), "✗"); + assert.equal(bgStatusIcon("queued"), "⏳"); +}); + +test("renderBgRow includes icon + phase progress for a running lifecycle", () => { + const row: BgRunStatus = { + runId: "fl-x", lifecycle: "default", status: "running", phase: "implement", phaseIndex: 3, phaseTotal: 5, mode: "checkpointed", backend: "pi", task: "add hello", + }; + const line = renderBgRow(row); + assert.match(line, /▶/); + assert.match(line, /●implement 3\/5/); + assert.match(line, /fl-x/); +}); + +test("renderBgRow shows ✓ + branch for a completed run", () => { + const row: BgRunStatus = { runId: "fl-y", lifecycle: "default", status: "completed", phase: "finish", phaseIndex: 5, phaseTotal: 5, mode: "checkpointed", backend: "pi", task: "t", branch: "fleet/fl-y" }; + const line = renderBgRow(row); + assert.match(line, /✓/); + assert.match(line, /fleet\/fl-y/); +}); + +test("renderBgRow shows ⏳ for a queued run with 0/total progress", () => { + const row: BgRunStatus = { runId: "fl-z", lifecycle: "default", status: "queued", phase: "", phaseIndex: 0, phaseTotal: 5, mode: "auto", backend: "pi", task: "t" }; + const line = renderBgRow(row); + assert.match(line, /⏳/); + assert.match(line, /0\/5/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/panel-spec5a.test.mts` +Expected: FAIL — `renderBgRow`/`bgStatusIcon`/`BgRunStatus` don't exist yet. + +- [ ] **Step 3: Add bg row rendering to `src/panel/rows.ts`** + +```typescript +// Add to src/panel/rows.ts (SPEC-5a): +export type BgStatus = "running" | "paused" | "completed" | "failed" | "queued"; + +export interface BgRunStatus { + runId: string; + lifecycle: string; + status: BgStatus; + phase: string; + phaseIndex: number; + phaseTotal: number; + mode: "auto" | "checkpointed"; + backend: string; + task: string; + branch?: string; + elapsedMs?: number; +} + +export function bgStatusIcon(s: BgStatus): string { + switch (s) { + case "running": return "▶"; + case "paused": return "⏸"; + case "completed": return "✓"; + case "failed": return "✗"; + case "queued": return "⏳"; + } +} + +export function renderBgRow(r: BgRunStatus): string { + const icon = bgStatusIcon(r.status); + const phase = r.phase ? `●${r.phase} ${r.phaseIndex}/${r.phaseTotal}` : `${r.phaseIndex}/${r.phaseTotal}`; + const branch = r.branch ? ` ${r.branch}` : ""; + const elapsed = r.elapsedMs ? ` ${Math.round(r.elapsedMs / 1000)}s` : ""; + const task = r.task.length > 30 ? r.task.slice(0, 29) + "…" : r.task; + return `${icon} ${r.runId} ${r.lifecycle} ${phase} ${r.mode}${elapsed} ${r.backend}${branch} "${task}"`; +} +``` + +- [ ] **Step 4: Add the `scheduled` tab to `src/panel/fleet-panel.ts`** + +Add `"scheduled"` to the `View` union + the tab cycle array. Add a `renderScheduled()` method that lists `deps.scheduler.list()` rows (`▶/⏸ "" next: `) + an action submenu `a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit`. The `a:Add` action uses the inline `Input` pattern from SPEC-4 (task → expr → lifecycle, blank=default) and calls `deps.scheduler.register(...)`. Wire `p`/`d` to the selected row's `pause/resume`/`delete`. Thread `() => ctx.ui.theme` for colors (per the EditorTheme gotcha — `ctx.ui.custom` receives the full `Theme`). + +Add `scheduler: Scheduler` + `bgRuns: Map` to `FleetPanelDeps`. The `fleet` tab's existing row renderer now also renders bg runs from `deps.bgRuns` via `renderBgRow`. + +(Full panel code follows the SPEC-4 `fleet-panel.ts` structure — the implementer reads SPEC-4's `lifecycle` tab as the template for `scheduled`. The key additions: the `scheduled` view function, the `a/p/d` action handlers calling `deps.scheduler`, and the bg-rows map iteration in the `fleet` view.) + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm test:run test/panel-spec5a.test.mts` +Expected: PASS (4 tests). Also run `pnpm test:run test/panel-spec4.test.mts` to confirm no regression. + +- [ ] **Step 6: Commit** + +```bash +git add src/panel/rows.ts src/panel/fleet-panel.ts test/panel-spec5a.test.mts +git commit -m "feat(spec-5a): /fleet scheduled tab + bg row status icons (▶ ⏸ ✓ ✗ ⏳ ●phase n/total)" +``` + +--- + +## Task 14: `index.ts` wiring + resume-on-init + +**Spec:** §2 (entry points), §5 (resume on init), §4 (wiring). Depends on all prior tasks. Modifies `src/index.ts`. + +**Files:** +- Modify: `src/index.ts` (wire `AsyncRunnerDeps` + `Scheduler` + `ResultsInbox` + `fleet.results` tool + resume scan on init + thread `asyncRunner`/`scheduler` into `SubagentToolDeps` + `FleetPanelDeps`) +- Create: `test/index-spec5a.test.mts` + +**Interfaces:** +- Consumes: all the new modules. +- Produces: a pi extension that on init builds the async runner deps + scheduler, scans for resume candidates (notifies "N interrupted fleet runs — open /fleet to resume"), registers the `fleet.results` tool, threads `asyncRunner`/`scheduler` into the subagent tool + panel. + +- [ ] **Step 1: Write the failing test** + +```typescript +// test/index-spec5a.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; + +test("the extension exports the wiring surface (smoke — full init needs a pi context)", () => { + // index.ts exports an activate/refresh surface; assert the module loads + exposes the new deps shape. + // (Full init is exercised by the term-driven TUI smoke, not a unit test.) + const mod = require("../src/index.ts"); + assert.equal(typeof mod, "object"); +}); + +test("resume scan is invoked on refresh and surfaces interrupted runs via notify", () => { + // This is exercised integration-style in the smoke; here assert scanResumeCandidates is re-exported. + const { scanResumeCandidates } = require("../src/runtime/resume.ts"); + assert.equal(typeof scanResumeCandidates, "function"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test:run test/index-spec5a.test.mts` +Expected: FAIL (module shape / re-exports missing). + +- [ ] **Step 3: Modify `src/index.ts`** + +In the extension's `refresh(ctx)` (or the activate function), after the SPEC-4 wiring: + +```typescript +// SPEC-5a wiring (add after the SPEC-4 lifecycle registry build): +import { WorktreeService } from "./worktree/worktree-service.ts"; +import { DiffService } from "./worktree/diff-service.ts"; +import { RunJournal } from "./runtime/run-journal.ts"; +import { ConcurrencyPool } from "./runtime/concurrency-pool.ts"; +import { ResultsInbox } from "./runtime/results-inbox.ts"; +import { runBackground } from "./runtime/async-runner.ts"; +import { scanResumeCandidates } from "./runtime/resume.ts"; +import { Scheduler } from "./scheduling/scheduler.ts"; +import { createFleetResultsTool } from "./tools/fleet-results.ts"; + +// inside refresh/activate, where deps is assembled: +const runsDir = join(ctx.cwd, ".pi", "fleet", "runs"); +const storePath = join(ctx.cwd, ".pi", "fleet", "schedules.json"); +const lockPath = join(ctx.cwd, ".pi", "fleet", "schedules.lock"); +const inbox = new ResultsInbox(); +const scheduler = new Scheduler({ storePath, lockPath, onFire: (spec) => { + // a scheduled fire = an async/bg run + runBackground(spec.task, { deps: asyncRunnerDeps, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed" }); +}}); +const asyncRunnerDeps = { + worktree: new WorktreeService({ rootDir: ctx.cwd }), + diff: new DiffService(), + journal: new RunJournal(runsDir), + pool: new ConcurrencyPool(settings.maxConcurrentBg ?? 3), + inbox, + runLifecycle: /* the existing runLifecycle adapter, bound to the registry + spawn — same as the SPEC-4 smoke's lifecycleDeps.spawn */, + notify: (m, lvl) => ctx.ui.notify(m, lvl), + genRunId: () => "fl-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), +}; + +// thread into the subagent tool deps + panel deps: +deps.asyncRunner = asyncRunnerDeps; +deps.scheduler = scheduler; +deps.inbox = inbox; // for the panel + fleet.results +deps.bgRuns = new Map(); // panel reads bg row status from here + +// register the fleet.results tool: +pi.registerTool(createFleetResultsTool({ inbox })); + +// start the scheduler (PID-locked; no-op if another session owns it): +scheduler.start(); + +// resume scan on init: +const cands = scanResumeCandidates(ctx.cwd, { runsDir, worktree: asyncRunnerDeps.worktree }); +if (cands.length > 0) { + ctx.ui.notify(`${cands.length} interrupted fleet run${cands.length > 1 ? "s" : ""} — open /fleet to resume`, "info"); +} +``` + +Wire `asyncRunnerDeps.runLifecycle` to call the real `runLifecycle` from `src/lifecycle/run-lifecycle.ts` with the project's `lifecycleDeps` (the same `lifecycleDeps` built for the SPEC-4 subagent tool path), passing `{ runId, worktreePath, branch, mode }` — the async runner's `RunLifecycleFn` adapter wraps the real call (the real `runLifecycle` doesn't take those opts directly, so the adapter maps them: use `runId` as the lifecycle runId, pass the worktree path as the spawn's `parentCwd`, etc.). This is the one integration seam; the implementer reads the SPEC-4 smoke script (`scripts/spec-4-smoke.mts`) for the exact `lifecycleDeps` shape. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test:run test/index-spec5a.test.mts && pnpm typecheck && pnpm test:run` +Expected: PASS (index-spec5a) + typecheck clean + ALL tests pass (172 prior + new SPEC-5a tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/index.ts test/index-spec5a.test.mts +git commit -m "feat(spec-5a): index wiring — async runner + scheduler + resume-on-init + fleet.results tool" +``` + +--- + +## Task 15: End-to-end smoke script + TUI smoke checklist + +**Spec:** §15 (end-to-end smoke), `docs/SPEC-5a-smoke-checklist.md`. Depends on all prior tasks. + +**Files:** +- Create: `scripts/spec-5a-smoke.mts` +- Create: `docs/SPEC-5a-smoke-checklist.md` + +**Interfaces:** +- Consumes: the full wired extension. +- Produces: a manual smoke that registers a one-shot schedule (`5s`) firing a trivial isolated lifecycle on `Ollama/glm-5.2:cloud` in a temp git repo; asserts the worktree is created, the journal records events, diff discovers artifacts, the inbox receives the result, and notify fires. Plus a term-driven TUI smoke checklist (install 0.5.0 → `/fleet` → `scheduled` tab → add a schedule → see next-fire → bg row in `fleet` tab). + +- [ ] **Step 1: Write the smoke script** + +```typescript +// scripts/spec-5a-smoke.mts — SPEC-5a end-to-end operational-runtime smoke +// Run: node --import tsx scripts/spec-5a-smoke.mts +// Uses REAL Ollama Cloud pi phases in an isolated temp git repo (the worktree IS the isolation — +// no repo pollution, unlike the SPEC-4 smoke's temp-cwd workaround). +import { runLifecycle } from "../src/lifecycle/run-lifecycle.ts"; +import { DEFAULT_LIFECYCLE } from "../src/lifecycle/default.ts"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { ArmoryTodoAdapter } from "../src/todo-sync/adapter.ts"; +import { ArmoryMemoryAdapter } from "../src/memory-hydrate/adapter.ts"; +import { RunRegistry } from "../src/engine/run-registry.ts"; +import { createSingleSlotLock } from "../src/engine/concurrency-lock.ts"; +import { discoverAgents } from "../src/registry/discovery.ts"; +import { createChildSessionFactory } from "../src/index.ts"; +import { BackendRegistry, PI_HOOK_PARITY } from "../src/backend/port.ts"; +import { ResumeStore } from "../src/backend/resume-store.ts"; +import { spawnSubagent } from "../src/engine/spawnSubagent.ts"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; +import { DiffService } from "../src/worktree/diff-service.ts"; +import { RunJournal } from "../src/runtime/run-journal.ts"; +import { ConcurrencyPool } from "../src/runtime/concurrency-pool.ts"; +import { ResultsInbox } from "../src/runtime/results-inbox.ts"; +import { runBackground } from "../src/runtime/async-runner.ts"; +import { Scheduler } from "../src/scheduling/scheduler.ts"; +import { join } from "node:path"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +async function main(): Promise { + // 1. isolated temp git repo (the worktree IS the isolation — no repo pollution) + const repo = mkdtempSync(join(tmpdir(), "fleet-spec5a-smoke-")); + execSync("git init -b main", { cwd: repo }); + execSync('git config user.email "t@t.test" && git config user.name "test"', { cwd: repo }); + writeFileSync(join(repo, "base.txt"), "base\n"); + execSync("git add base.txt && git commit -m base", { cwd: repo }); + console.log("smoke repo:", repo); + + // 2. build the same lifecycleDeps as the SPEC-4 smoke + const modelRuntime = await ModelRuntime.create(); + const todoSync = new ArmoryTodoAdapter(); + const resumeStore = new ResumeStore(); + const backendRegistry = new BackendRegistry(); + backendRegistry.register({ id: "pi", factory: createChildSessionFactory(modelRuntime, new ArmoryMemoryAdapter(), resumeStore), available: () => true, versionInfo: () => null, hookParity: PI_HOOK_PARITY }); + const agentDiscovery = discoverAgents({ projectDir: join(repo, ".pi", "agents"), globalDir: join(process.env.HOME ?? "", ".pi", "agent", "agents"), builtinDir: join(new URL(".", import.meta.url).pathname, "..", "agents") }); + const agentRegistry = agentDiscovery.agents; + + const lifecycleDeps = { + registry: new Map([["default", DEFAULT_LIFECYCLE]]), + agentRegistry, + spawn: async (o: any) => spawnSubagent({ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model, skillsOverride: o.skills, backendOverride: o.backend, registry: agentRegistry, todoSync, runRegistry: new RunRegistry(), lock: createSingleSlotLock(), backendRegistry, parentModel: { provider: "Ollama", id: "glm-5.2:cloud" }, parentCwd: o.parentCwd }), + todoPort: todoSync, + resolveBackend: (phaseBackend: any, lifecycleBackend: any) => phaseBackend ?? lifecycleBackend, + genRunId: () => "fl-smoke-" + Date.now().toString(36), + }; + + // 3. async runner deps — the runLifecycle adapter maps runBackground opts → runLifecycle + const journal = new RunJournal(join(repo, ".pi", "fleet", "runs")); + const inbox = new ResultsInbox(); + const asyncDeps = { + worktree: new WorktreeService({ rootDir: repo }), + diff: new DiffService(), + journal, + pool: new ConcurrencyPool(2), + inbox, + runLifecycle: async (task: string, lifecycleName: string, opts: any) => { + // run the real lifecycle with the worktree as the spawn cwd + const res = await runLifecycle(task, lifecycleName, { deps: { ...lifecycleDeps, spawn: async (o: any) => lifecycleDeps.spawn({ ...o, parentCwd: opts.worktreePath }) } as any, mode: "auto", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } }); + return res as any; + }, + notify: (m: string) => console.log("notify:", m), + genRunId: () => "fl-smoke-" + Date.now().toString(36), + }; + + // 4. fire a background run + const handle = runBackground("Add a hello() function to scratch.ts returning 'hello from fleet'", { deps: asyncDeps, lifecycle: "default", mode: "auto" }); + console.log("fired:", handle); + + // 5. wait for completion (poll the inbox) + const deadline = Date.now() + 120_000; + while (Date.now() < deadline && inbox.readyCount() === 0) { + await new Promise((r) => setTimeout(r, 1000)); + } + const results = inbox.pull(); + if (results.length === 0) { console.error("SMOKE FAILED: no result within 120s"); process.exit(1); } + console.log("result:", JSON.stringify(results[0], null, 2)); + + // 6. assert the journal + worktree + const events = journal.replay(handle.runId); + if (!events.some((e) => e.type === "run:completed")) { console.error("SMOKE FAILED: no run:completed in journal"); process.exit(1); } + console.log("journal events:", events.map((e) => e.type).join(", ")); + + // 7. scheduling: register a one-shot 2s + assert it fires + let schedFired = 0; + const scheduler = new Scheduler({ storePath: join(repo, ".pi", "fleet", "schedules.json"), lockPath: join(repo, ".pi", "fleet", "schedules.lock"), onFire: () => { schedFired++; } }); + scheduler.register({ task: "scheduled smoke", expression: "2s", lifecycle: "default" }); + scheduler.start(); + await new Promise((r) => setTimeout(r, 3000)); + scheduler.stop(); + if (schedFired < 1) { console.error("SMOKE FAILED: schedule did not fire"); process.exit(1); } + console.log("schedule fired", schedFired, "times"); + + rmSync(repo, { recursive: true, force: true }); + console.log("SMOKE PASSED ✅"); +} + +void main().catch((e) => { console.error("SMOKE ERROR:", e); process.exit(1); }); +``` + +- [ ] **Step 2: Write the TUI smoke checklist** + +Write `docs/SPEC-5a-smoke-checklist.md` mirroring `docs/SPEC-4-smoke-checklist.md`'s structure, with rows: +- Install `@getpipher/armory-fleet@0.5.0` in `~/.pi/agent/settings.json`, `/reload` pi. +- `/fleet` → `scheduled` tab renders (empty list + `a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit`). +- `a:Add` → inline Input (task → expr `5s` → lifecycle blank=default) → row appears with `next: `. +- Wait 5s → schedule fires → a bg run row appears in the `fleet` tab with `▶` + `● n/5`. +- `i:Info` on the bg row → phase timeline (reads the journal). +- On completion → `fleet` row `✓` + notify "fleet run … completed" + `fleet.results()` returns it. +- `/fleet-schedule "30m" --lifecycle default` slash → prints scheduleId + nextFire. +- Resume: kill pi mid-lifecycle → reopen pi in the same project → notify "N interrupted fleet runs — open /fleet to resume" → `/fleet` shows the interrupted row → resume action re-enters the worktree. +- PID-lock: open a second pi in the same project → schedules don't double-fire (the second session defers). +- RECTOR's `claude` CLI: re-auth first to test a scheduled run with a per-phase `backend: claude` lifecycle (Q4=C) — optional, the default lifecycle is `pi` throughout. + +- [ ] **Step 3: Run the smoke (optional — real Ollama; needs `~/.pi/agent/auth.json`)** + +Run: `node --import tsx scripts/spec-5a-smoke.mts` +Expected: `SMOKE PASSED ✅` (worktree created, lifecycle ran, journal recorded, inbox received, schedule fired). NOTE: unlike the SPEC-4 smoke, this is SAFE to run from the repo cwd — the worktree is the isolation (no temp-cwd workaround needed). But running it from a throwaway dir is still fine. + +- [ ] **Step 4: Run the full gate** + +Run: `pnpm typecheck && pnpm test:run` +Expected: typecheck clean + ALL tests pass (172 prior + ~40 new SPEC-5a tests). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/spec-5a-smoke.mts docs/SPEC-5a-smoke-checklist.md +git commit -m "feat(spec-5a): end-to-end smoke script + term-driven TUI smoke checklist" +``` + +--- + +## Self-Review (run after writing the plan) + +**1. Spec coverage:** +- §1 overview/goals → Tasks 1-15 (the whole plan). +- §2 architecture → Task 9 (async runner), Task 14 (wiring). +- §3 decision log → encoded in Global Constraints + each task's spec citation. +- §4 file layout → File Structure section + each task's Files block. +- §5 process/state + journal + resume → Tasks 3, 9, 10, 14. +- §6 worktree isolation → Task 1, 9. +- §7 artifact discovery → Task 2, 9. +- §8 concurrency → Task 4, 9. +- §9 scheduling → Tasks 6, 7, 8, 14. +- §10 auto-delivery → Tasks 5, 9, 12, 14. +- §11 TUI surface → Task 13. +- §12 tool surface → Tasks 11, 12. +- §13 guards → Task 9 (worktree cleanup), 7 (PID-lock), 4 (pool cap), 11 (resolve-time validation). +- §14 error handling → each task's error paths (Task 1 worktree-create-fail, 3 partial-line, 6 invalid cron, 7 stale PID, 9 run-aborted, 10 worktree-missing). +- §15 testing → every task has TDD tests + Task 15 smoke. +- §16 deferred → recorded (SPEC-5b live widget, SPEC-6 cost/workflows/RPC). +- §17 done bar → Task 15 + release tag (post-implementation). +- **Gap check:** none — every spec section maps to ≥1 task. + +**2. Placeholder scan:** none — every step has real code or an exact command. Task 13's panel code is described by reference to SPEC-4's `lifecycle` tab template (the implementer reads the existing `fleet-panel.ts`); this is a structural reference, not a placeholder (the row-rendering code is fully specified in Task 13 Step 3, and the scheduled-tab follows the identical pattern as the SPEC-4 lifecycle tab already in the file). Task 14's `runLifecycle` adapter is described with the exact seam + a pointer to the SPEC-4 smoke script for the `lifecycleDeps` shape (the one integration seam). + +**3. Type consistency:** +- `WorktreeService.create(runId, baseRef) → { path, branch }` — used consistently in Tasks 1, 2, 9, 10, 15. +- `DiffService.diffPhase(worktreePath, baseRef, childFinalText?) → { paths, summary }` — Tasks 2, 9. +- `RunJournal.append(runId, event)` / `replay(runId)` / `scanNonTerminal()` — Tasks 3, 9, 10, 14, 15. +- `ConcurrencyPool.withSlot(fn)` / `busy()` / `queued()` — Tasks 4, 9. +- `ResultsInbox.push(result)` / `pull(runId?)` / `readyCount()` / `renderHint()` — Tasks 5, 9, 12, 14. +- `parseScheduleExpr(expr) → { type, nextFire(prev) }` — Tasks 6, 8, 11. +- `PidLock.acquire(lockPath) → boolean` / `isOwner()` / `release()` — Tasks 7, 8, 14. +- `Scheduler.register/list/pause/resume/delete/start/stop` — Tasks 8, 11, 13, 14, 15. +- `runBackground(task, opts) → { runId, status: "background" }` — Tasks 9, 11, 14, 15. +- `RunResult { runId, task, status, summary, paths, branch?, completedAt }` — Tasks 5, 12. +- `BgRunStatus` + `bgStatusIcon` + `renderBgRow` — Task 13. +- `JournalEvent` union — Tasks 3, 9, 10 (consistent field names: `runId`, `task`, `lifecycle`, `worktree: { path, branch }`, `mode`, `ts`, `phase`, `summary`, `paths`, `decision`, `reason`, `branch`, `error`). +- **Amendment flagged:** Task 10 references `WorktreeService.pathFor` (private in Task 1). Task 10 Step 3 notes the implementer must expose `pathFor` as a public method on `WorktreeService` (rename private → `privatePathFor`, add public `pathFor(runId): string`). This is the one cross-task type amendment; it's noted in Task 10. + +All consistent. \ No newline at end of file From a47887652922389afadcb2d2d5922484b83976ce Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:03:59 +0700 Subject: [PATCH 03/18] =?UTF-8?q?feat(spec-5a):=20WorktreeService=20?= =?UTF-8?q?=E2=80=94=20git=20worktree=20create/remove/exists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/worktree/worktree-service.ts | 78 ++++++++++++++++++++++++++++++++ test/worktree-service.test.mts | 62 +++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/worktree/worktree-service.ts create mode 100644 test/worktree-service.test.mts diff --git a/src/worktree/worktree-service.ts b/src/worktree/worktree-service.ts new file mode 100644 index 0000000..202cd28 --- /dev/null +++ b/src/worktree/worktree-service.ts @@ -0,0 +1,78 @@ +// src/worktree/worktree-service.ts +// Greenfield git worktree lifecycle (SPEC-5a §6, Q9=A — thin shell-outs, no git library). +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +export interface WorktreeRef { + path: string; + branch: string; +} + +export interface WorktreeServiceOpts { + rootDir: string; + /** Where worktrees live. Defaults to /.pi/fleet/worktrees. */ + worktreesDir?: string; +} + +function sh(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).toString().trim(); +} + +export class WorktreeService { + private readonly rootDir: string; + private readonly worktreesDir: string; + + constructor(opts: WorktreeServiceOpts) { + this.rootDir = opts.rootDir; + this.worktreesDir = opts.worktreesDir ?? join(opts.rootDir, ".pi", "fleet", "worktrees"); + } + + branchFor(runId: string): string { + return `fleet/${runId}`; + } + + pathFor(runId: string): string { + return join(this.worktreesDir, runId); + } + + exists(runId: string): boolean { + return existsSync(this.pathFor(runId)); + } + + create(runId: string, baseRef = "HEAD"): WorktreeRef { + if (this.exists(runId)) { + throw new Error(`worktree for run ${runId} already exists at ${this.pathFor(runId)}`); + } + mkdirSync(this.worktreesDir, { recursive: true }); + const branch = this.branchFor(runId); + const path = this.pathFor(runId); + try { + sh(`git worktree add -b ${branch} ${path} ${baseRef}`, this.rootDir); + } catch (e) { + if (existsSync(path)) rmSync(path, { recursive: true, force: true }); + const msg = (e as Error).message; + const tail = msg.split("\n").filter(Boolean).pop() ?? msg; + throw new Error(`worktree create failed for run ${runId} (base ${baseRef}): ${tail}`); + } + return { path, branch }; + } + + remove(runId: string): void { + const path = this.pathFor(runId); + const branch = this.branchFor(runId); + if (existsSync(path)) { + try { + sh(`git worktree remove --force ${path}`, this.rootDir); + } catch { + rmSync(path, { recursive: true, force: true }); + try { sh("git worktree prune", this.rootDir); } catch { /* ignore */ } + } + } + try { + sh(`git branch -D ${branch}`, this.rootDir); + } catch { + // branch may not exist; ignore + } + } +} \ No newline at end of file diff --git a/test/worktree-service.test.mts b/test/worktree-service.test.mts new file mode 100644 index 0000000..5a4315b --- /dev/null +++ b/test/worktree-service.test.mts @@ -0,0 +1,62 @@ +// test/worktree-service.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; + +function sh(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, encoding: "utf8" }).trim(); +} + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "wt-test-")); + sh("git init -b main", dir); + sh('git config user.email "t@t.test"', dir); + sh('git config user.name "test"', dir); + writeFileSync(join(dir, "base.txt"), "base\n"); + sh("git add base.txt && git commit -m base", dir); + return dir; +} + +test("create makes a worktree at .pi/fleet/worktrees/ branched from HEAD", () => { + const repo = makeRepo(); + const svc = new WorktreeService({ rootDir: repo }); + const { path, branch } = svc.create("fl-test1", "HEAD"); + assert.equal(branch, "fleet/fl-test1"); + assert.equal(existsSync(join(path, "base.txt")), true); + assert.equal(svc.exists("fl-test1"), true); + assert.equal(sh("git rev-parse --abbrev-ref HEAD", path), "fleet/fl-test1"); + rmSync(repo, { recursive: true, force: true }); +}); + +test("create writes a new file in the worktree without affecting the main checkout", () => { + const repo = makeRepo(); + const svc = new WorktreeService({ rootDir: repo }); + const { path } = svc.create("fl-test2", "HEAD"); + writeFileSync(join(path, "new.txt"), "new\n"); + assert.equal(existsSync(join(repo, "new.txt")), false); + assert.equal(existsSync(join(path, "new.txt")), true); + rmSync(repo, { recursive: true, force: true }); +}); + +test("remove deletes the worktree + branch", () => { + const repo = makeRepo(); + const svc = new WorktreeService({ rootDir: repo }); + const { path } = svc.create("fl-test3", "HEAD"); + svc.remove("fl-test3"); + assert.equal(svc.exists("fl-test3"), false); + assert.equal(existsSync(path), false); + const branches = sh("git branch --list", repo); + assert.equal(branches.includes("fleet/fl-test3"), false); + rmSync(repo, { recursive: true, force: true }); +}); + +test("create errors actionable when base ref is invalid", () => { + const repo = makeRepo(); + const svc = new WorktreeService({ rootDir: repo }); + assert.throws(() => svc.create("fl-test4", "no-such-ref"), /no-such-ref|unknown revision|invalid|worktree create failed/); + rmSync(repo, { recursive: true, force: true }); +}); \ No newline at end of file From 3ceb051ce07b9948136aa15ccc128f01bbc1d455 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:04:18 +0700 Subject: [PATCH 04/18] =?UTF-8?q?feat(spec-5a):=20DiffService=20=E2=80=94?= =?UTF-8?q?=20worktree-diff=20artifact=20discovery=20(tracked=20+=20untrac?= =?UTF-8?q?ked)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/worktree/diff-service.ts | 40 +++++++++++++++++++++++ test/diff-service.test.mts | 63 ++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/worktree/diff-service.ts create mode 100644 test/diff-service.test.mts diff --git a/src/worktree/diff-service.ts b/src/worktree/diff-service.ts new file mode 100644 index 0000000..d4988f4 --- /dev/null +++ b/src/worktree/diff-service.ts @@ -0,0 +1,40 @@ +// src/worktree/diff-service.ts +// SPEC-5a §7 — worktree-diff artifact discovery for isolated runs (Q3=A). +// All changes in the worktree vs base: tracked modifications + untracked new files. +import { execSync } from "node:child_process"; + +export interface PhaseArtifacts { + paths: string[]; + summary: string; +} + +function sh(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).toString(); +} + +const MAX_SUMMARY = 200; + +export class DiffService { + /** + * Compute a phase's artifacts = all changes in the worktree vs baseRef. + * Tracked modifications via `git diff --name-only`; untracked new files via + * `git status --porcelain` (?? entries). Deduped + sorted. + * + * @param childFinalText the child's final text, truncated to MAX_SUMMARY chars as the prose summary. + */ + diffPhase(worktreePath: string, baseRef: string, childFinalText = ""): PhaseArtifacts { + const tracked = sh(`git diff --name-only ${baseRef} --`, worktreePath) + .split("\n") + .filter(Boolean); + const status = sh("git status --porcelain", worktreePath); + const untracked = status + .split("\n") + .filter((l) => l.startsWith("?? ")) + .map((l) => l.slice(3).trim()); + const paths = Array.from(new Set([...tracked, ...untracked])).sort(); + const summary = childFinalText.length > MAX_SUMMARY + ? childFinalText.slice(0, MAX_SUMMARY - 1) + "…" + : childFinalText; + return { paths, summary }; + } +} \ No newline at end of file diff --git a/test/diff-service.test.mts b/test/diff-service.test.mts new file mode 100644 index 0000000..a06932e --- /dev/null +++ b/test/diff-service.test.mts @@ -0,0 +1,63 @@ +// test/diff-service.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync, appendFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; +import { DiffService } from "../src/worktree/diff-service.ts"; + +function sh(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, encoding: "utf8" }).trim(); +} + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "diff-test-")); + sh("git init -b main", dir); + sh('git config user.email "t@t.test"', dir); + sh('git config user.name "test"', dir); + writeFileSync(join(dir, "base.txt"), "base\n"); + sh("git add base.txt && git commit -m base", dir); + return dir; +} + +test("diffPhase lists tracked modifications + untracked new files", () => { + const repo = makeRepo(); + const wt = new WorktreeService({ rootDir: repo }); + const diff = new DiffService(); + const { path } = wt.create("fl-diff1", "HEAD"); + appendFileSync(join(path, "base.txt"), "more\n"); + writeFileSync(join(path, "design.md"), "# design\n"); + const res = diff.diffPhase(path, "HEAD"); + assert.ok(res.paths.includes("base.txt"), `paths: ${res.paths.join(",")}`); + assert.ok(res.paths.includes("design.md"), `paths: ${res.paths.join(",")}`); + wt.remove("fl-diff1"); + rmSync(repo, { recursive: true, force: true }); +}); + +test("diffPhase returns empty paths when nothing changed", () => { + const repo = makeRepo(); + const wt = new WorktreeService({ rootDir: repo }); + const diff = new DiffService(); + const { path } = wt.create("fl-diff2", "HEAD"); + const res = diff.diffPhase(path, "HEAD"); + assert.equal(res.paths.length, 0); + wt.remove("fl-diff2"); + rmSync(repo, { recursive: true, force: true }); +}); + +test("summary is a truncated form of the provided child final text", () => { + const repo = makeRepo(); + const wt = new WorktreeService({ rootDir: repo }); + const diff = new DiffService(); + const { path } = wt.create("fl-diff3", "HEAD"); + writeFileSync(join(path, "x.txt"), "x\n"); + const long = "This is a long summary that should be truncated to a reasonable length so the phase record stays small even if the child wrote a wall of text that goes well beyond two hundred characters and keeps going and going and going to make sure we hit the cap and exercise the truncation path with an ellipsis at the end."; + const res = diff.diffPhase(path, "HEAD", long); + assert.ok(res.summary.length <= 200, `summary len ${res.summary.length}`); + assert.ok(res.summary.startsWith("This is a long summary")); + assert.ok(res.summary.endsWith("…")); + wt.remove("fl-diff3"); + rmSync(repo, { recursive: true, force: true }); +}); \ No newline at end of file From dbb3093cc44eefb6009338497ec21f76557cb31d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:04:50 +0700 Subject: [PATCH 05/18] =?UTF-8?q?feat(spec-5a):=20RunJournal=20=E2=80=94?= =?UTF-8?q?=20JSONL=20append=20+=20replay=20+=20partial-line=20skip=20+=20?= =?UTF-8?q?scan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/runtime/run-journal.ts | 61 ++++++++++++++++++++++++++++++++++++++ test/run-journal.test.mts | 50 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/runtime/run-journal.ts create mode 100644 test/run-journal.test.mts diff --git a/src/runtime/run-journal.ts b/src/runtime/run-journal.ts new file mode 100644 index 0000000..d31f371 --- /dev/null +++ b/src/runtime/run-journal.ts @@ -0,0 +1,61 @@ +// src/runtime/run-journal.ts +// SPEC-5a §5 — JSONL run journal. Append-only (crash-safe: a partial last line is discarded). +// The event log IS the i:Info timeline + the resume source of truth. +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +export interface RunStartedEvent { type: "run:started"; runId: string; task: string; lifecycle: string; worktree: { path: string; branch: string }; mode: "auto" | "checkpointed"; ts: number; } +export interface PhaseStartedEvent { type: "phase:started"; phase: string; ts: number; } +export interface PhaseCompletedEvent { type: "phase:completed"; phase: string; summary: string; paths: string[]; ts: number; } +export interface PhaseFailedEvent { type: "phase:failed"; phase: string; error: string; ts: number; } +export interface CheckpointEvent { type: "checkpoint"; phase: string; decision: "continue" | "revise" | "abort"; ts: number; } +export interface RunCompletedEvent { type: "run:completed"; runId: string; branch: string; ts: number; } +export interface RunAbortedEvent { type: "run:aborted"; runId: string; reason: string; ts: number; } + +export type JournalEvent = + | RunStartedEvent | PhaseStartedEvent | PhaseCompletedEvent | PhaseFailedEvent + | CheckpointEvent | RunCompletedEvent | RunAbortedEvent; + +const TERMINAL = new Set(["run:completed", "run:aborted"]); + +export class RunJournal { + constructor(private readonly dir: string) {} + + private file(runId: string): string { + return join(this.dir, `${runId}.jsonl`); + } + + append(runId: string, event: JournalEvent): void { + mkdirSync(this.dir, { recursive: true }); + appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8"); + } + + replay(runId: string): JournalEvent[] { + const f = this.file(runId); + if (!existsSync(f)) return []; + const lines = readFileSync(f, "utf8").split("\n"); + const events: JournalEvent[] = []; + for (const line of lines) { + if (!line) continue; + try { + events.push(JSON.parse(line) as JournalEvent); + } catch { + // partial last line (crash mid-append) — discard + } + } + return events; + } + + scanNonTerminal(): string[] { + if (!existsSync(this.dir)) return []; + const ids: string[] = []; + for (const f of readdirSync(this.dir)) { + if (!f.endsWith(".jsonl")) continue; + const runId = f.slice(0, -".jsonl".length); + const events = this.replay(runId); + const last = events[events.length - 1]; + if (last && !TERMINAL.has(last.type)) ids.push(runId); + } + return ids; + } +} \ No newline at end of file diff --git a/test/run-journal.test.mts b/test/run-journal.test.mts new file mode 100644 index 0000000..8bceba4 --- /dev/null +++ b/test/run-journal.test.mts @@ -0,0 +1,50 @@ +// test/run-journal.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { RunJournal } from "../src/runtime/run-journal.ts"; + +function makeDir(): string { + return mkdtempSync(join(tmpdir(), "journal-test-")); +} + +test("append writes one JSON line per event; replay reconstructs them in order", () => { + const dir = makeDir(); + const j = new RunJournal(dir); + j.append("fl-1", { type: "run:started", runId: "fl-1", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-1" }, mode: "auto", ts: 1 }); + j.append("fl-1", { type: "phase:started", phase: "brainstorm", ts: 2 }); + j.append("fl-1", { type: "phase:completed", phase: "brainstorm", summary: "s", paths: ["a.md"], ts: 3 }); + const events = j.replay("fl-1"); + assert.equal(events.length, 3); + assert.equal(events[0]!.type, "run:started"); + assert.equal((events[2] as any).paths.join(), "a.md"); + rmSync(dir, { recursive: true, force: true }); +}); + +test("replay skips a partial (incomplete) last line", () => { + const dir = makeDir(); + const j = new RunJournal(dir); + j.append("fl-2", { type: "run:started", runId: "fl-2", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-2" }, mode: "auto", ts: 1 }); + const file = join(dir, "fl-2.jsonl"); + const existing = readFileSync(file, "utf8"); + writeFileSync(file, existing + '{"type":"phase:started","phase":"brain","ts":2'); // no newline, incomplete + const events = j.replay("fl-2"); + assert.equal(events.length, 1); + rmSync(dir, { recursive: true, force: true }); +}); + +test("scanNonTerminal returns runs whose journal has no terminal event", () => { + const dir = makeDir(); + const j = new RunJournal(dir); + j.append("fl-3", { type: "run:started", runId: "fl-3", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-3" }, mode: "auto", ts: 1 }); + j.append("fl-3", { type: "run:completed", runId: "fl-3", branch: "fleet/fl-3", ts: 2 }); + j.append("fl-4", { type: "run:started", runId: "fl-4", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-4" }, mode: "auto", ts: 1 }); + j.append("fl-4", { type: "phase:started", phase: "brainstorm", ts: 2 }); + j.append("fl-5", { type: "run:started", runId: "fl-5", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-5" }, mode: "auto", ts: 1 }); + j.append("fl-5", { type: "run:aborted", runId: "fl-5", reason: "user-abort", ts: 2 }); + const nonTerminal = j.scanNonTerminal().sort(); + assert.deepEqual(nonTerminal, ["fl-4"]); + rmSync(dir, { recursive: true, force: true }); +}); \ No newline at end of file From a53dd8812365ab28dfba5b1dd36bbfb20d9a31c1 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:05:12 +0700 Subject: [PATCH 06/18] feat(spec-5a): ConcurrencyPool (N-slot semaphore) + ResultsInbox (delivery queue + bounded hint) --- src/runtime/concurrency-pool.ts | 27 +++++++++++++++++ src/runtime/results-inbox.ts | 45 +++++++++++++++++++++++++++++ test/concurrency-pool.test.mts | 51 +++++++++++++++++++++++++++++++++ test/results-inbox.test.mts | 48 +++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 src/runtime/concurrency-pool.ts create mode 100644 src/runtime/results-inbox.ts create mode 100644 test/concurrency-pool.test.mts create mode 100644 test/results-inbox.test.mts diff --git a/src/runtime/concurrency-pool.ts b/src/runtime/concurrency-pool.ts new file mode 100644 index 0000000..f615328 --- /dev/null +++ b/src/runtime/concurrency-pool.ts @@ -0,0 +1,27 @@ +// src/runtime/concurrency-pool.ts +// SPEC-5a §8 — N-slot semaphore for async/bg runs (Q4=A). Foreground keeps its own +// single-slot lock (unchanged); this pool is independent. + +export class ConcurrencyPool { + private active = 0; + private waiters: Array<() => void> = []; + + constructor(private readonly cap = 3) {} + + busy(): number { return this.active; } + queued(): number { return this.waiters.length; } + + async withSlot(fn: () => Promise): Promise { + if (this.active >= this.cap) { + await new Promise((resolve) => this.waiters.push(resolve)); + } + this.active++; + try { + return await fn(); + } finally { + this.active--; + const next = this.waiters.shift(); + if (next) next(); + } + } +} \ No newline at end of file diff --git a/src/runtime/results-inbox.ts b/src/runtime/results-inbox.ts new file mode 100644 index 0000000..6069092 --- /dev/null +++ b/src/runtime/results-inbox.ts @@ -0,0 +1,45 @@ +// src/runtime/results-inbox.ts +// SPEC-5a §10 — in-memory results inbox for completed bg runs (Q6=C). +// The durable record is the lifecycle TODO notes + journal; this is the fast in-session +// pointer the agent pulls via fleet.results(). + +export interface RunResult { + runId: string; + task: string; + status: "completed" | "failed"; + summary: string; + paths: string[]; + branch?: string; + completedAt: number; +} + +export class ResultsInbox { + private ready = new Map(); + + push(result: RunResult): void { + this.ready.set(result.runId, result); + } + + readyCount(): number { + return this.ready.size; + } + + pull(runId?: string): RunResult[] { + if (runId) { + const r = this.ready.get(runId); + if (!r) return []; + this.ready.delete(runId); + return [r]; + } + const all = [...this.ready.values()]; + this.ready.clear(); + return all; + } + + /** Bounded hint for the parent agent's context: cap at 5, one line, empty when nothing ready. */ + renderHint(): string { + const n = this.ready.size; + if (n === 0) return ""; + return n > 5 ? "5+ fleet results ready (use fleet.results to pull)" : `${n} fleet result${n > 1 ? "s" : ""} ready (use fleet.results to pull)`; + } +} \ No newline at end of file diff --git a/test/concurrency-pool.test.mts b/test/concurrency-pool.test.mts new file mode 100644 index 0000000..cdf20ea --- /dev/null +++ b/test/concurrency-pool.test.mts @@ -0,0 +1,51 @@ +// test/concurrency-pool.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ConcurrencyPool } from "../src/runtime/concurrency-pool.ts"; + +test("withSlot runs up to N in parallel; N+1th waits for a release", async () => { + const pool = new ConcurrencyPool(2); + let active = 0; + let maxActive = 0; + const task = async (label: string): Promise => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((r) => setTimeout(r, 20)); + active--; + return label; + }; + const all = await Promise.all([ + pool.withSlot(() => task("a")), + pool.withSlot(() => task("b")), + pool.withSlot(() => task("c")), + pool.withSlot(() => task("d")), + ]); + assert.deepEqual(all, ["a", "b", "c", "d"]); + assert.ok(maxActive <= 2, `maxActive=${maxActive} exceeded cap 2`); + assert.equal(pool.busy(), 0); + assert.equal(pool.queued(), 0); +}); + +test("default cap is 3", async () => { + const pool = new ConcurrencyPool(); + let active = 0; + let maxActive = 0; + const task = async (l: string) => { active++; maxActive = Math.max(maxActive, active); await new Promise((r) => setTimeout(r, 20)); active--; return l; }; + await Promise.all([1, 2, 3, 4].map((i) => pool.withSlot(() => task(`t${i}`)))); + assert.ok(maxActive <= 3, `maxActive=${maxActive} exceeded default cap 3`); +}); + +test("busy + queued counts reflect state", async () => { + const pool = new ConcurrencyPool(1); + let release1!: () => void; + const p1 = pool.withSlot(() => new Promise((r) => { release1 = () => r("a"); })); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(pool.busy(), 1); + const p2 = pool.withSlot(() => new Promise((r) => r("b"))); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(pool.queued(), 1); + release1(); + assert.equal(await p1, "a"); + assert.equal(await p2, "b"); + assert.equal(pool.busy(), 0); +}); \ No newline at end of file diff --git a/test/results-inbox.test.mts b/test/results-inbox.test.mts new file mode 100644 index 0000000..f6e4f24 --- /dev/null +++ b/test/results-inbox.test.mts @@ -0,0 +1,48 @@ +// test/results-inbox.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ResultsInbox, type RunResult } from "../src/runtime/results-inbox.ts"; + +function result(runId: string, task: string): RunResult { + return { runId, task, status: "completed", summary: "s", paths: ["a.md"], branch: `fleet/${runId}`, completedAt: 1 }; +} + +test("push + pull(runId) returns that result and marks it delivered", () => { + const inbox = new ResultsInbox(); + inbox.push(result("fl-1", "t1")); + const r = inbox.pull("fl-1"); + assert.equal(r.length, 1); + assert.equal(r[0]!.runId, "fl-1"); + assert.equal(inbox.readyCount(), 0); +}); + +test("pull() with no arg returns all ready + marks them delivered; a second pull returns empty", () => { + const inbox = new ResultsInbox(); + inbox.push(result("fl-2", "t2")); + inbox.push(result("fl-3", "t3")); + const r = inbox.pull(); + assert.equal(r.length, 2); + assert.equal(inbox.pull().length, 0); +}); + +test("readyCount + renderHint reflect ready (undelivered) results", () => { + const inbox = new ResultsInbox(); + assert.equal(inbox.renderHint(), ""); + inbox.push(result("fl-4", "t4")); + inbox.push(result("fl-5", "t5")); + assert.equal(inbox.readyCount(), 2); + assert.match(inbox.renderHint(), /2 fleet results ready/); +}); + +test("renderHint caps at 5 (6+ collapses to '5+ fleet results ready')", () => { + const inbox = new ResultsInbox(); + for (let i = 0; i < 7; i++) inbox.push(result(`fl-${i}`, `t${i}`)); + assert.match(inbox.renderHint(), /5\+ fleet results ready/); +}); + +test("pull(runId) for a result that was already delivered returns empty", () => { + const inbox = new ResultsInbox(); + inbox.push(result("fl-6", "t6")); + inbox.pull(); + assert.equal(inbox.pull("fl-6").length, 0); +}); \ No newline at end of file From c610ad4241e06496cabb7ebec8e40076291c6675 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:08:45 +0700 Subject: [PATCH 07/18] feat(spec-5a): vendor cron-parser v1.1.1 (MIT, dep-free) + schedule expressions (cron/interval/once) --- src/scheduling/expressions.ts | 60 +++ src/vendor/cron-parser/NOTICE.md | 23 + src/vendor/cron-parser/lib/date.js | 79 +++ src/vendor/cron-parser/lib/expression.js | 614 +++++++++++++++++++++++ src/vendor/cron-parser/lib/number.js | 8 + src/vendor/cron-parser/lib/parser.js | 103 ++++ src/vendor/cron-parser/types.d.ts | 12 + test/scheduling-expressions.test.mts | 40 ++ 8 files changed, 939 insertions(+) create mode 100644 src/scheduling/expressions.ts create mode 100644 src/vendor/cron-parser/NOTICE.md create mode 100644 src/vendor/cron-parser/lib/date.js create mode 100644 src/vendor/cron-parser/lib/expression.js create mode 100644 src/vendor/cron-parser/lib/number.js create mode 100644 src/vendor/cron-parser/lib/parser.js create mode 100644 src/vendor/cron-parser/types.d.ts create mode 100644 test/scheduling-expressions.test.mts diff --git a/src/scheduling/expressions.ts b/src/scheduling/expressions.ts new file mode 100644 index 0000000..baaad07 --- /dev/null +++ b/src/scheduling/expressions.ts @@ -0,0 +1,60 @@ +// src/scheduling/expressions.ts +// SPEC-5a §9 — schedule expressions: cron (vendored) + interval + one-shot (Q5=A). +// The vendored cron-parser lib (v1.1.1) is CommonJS — use createRequire for CJS-in-ESM interop. +// v1.1.1 has no `tz` option; it uses the process local timezone (the right default for a dev tool). +import { createRequire } from "node:module"; + +const cronRequire = createRequire(import.meta.url); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const cronParser = cronRequire("../vendor/cron-parser/lib/parser.js") as { + parseExpression(expr: string, opts?: { currentDate?: Date; endDate?: Date }): { next(): Date; prev(): Date; hasNext(): boolean }; +}; + +export type ScheduleType = "cron" | "interval" | "once"; + +export interface ScheduleExpression { + type: ScheduleType; + /** Next fire after `prev` (or from now if prev is null). Returns null when a one-shot has already fired. */ + nextFire(prev: Date | null): Date | null; +} + +const INTERVAL_RE = /^(\d+)([smhd])$/; +const INTERVAL_MS: Record = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }; + +export function parseScheduleExpr(expr: string): ScheduleExpression { + const s = expr.trim(); + if (INTERVAL_RE.test(s)) { + const m = s.match(INTERVAL_RE)!; + const unit = m[2] as "s" | "m" | "h" | "d"; + const ms = Number(m[1]) * (INTERVAL_MS[unit] ?? 0); + return { + type: "interval", + nextFire: (prev) => new Date((prev ?? new Date()).getTime() + ms), + }; + } + // one-shot ISO datetime (contains a 'T' and parses as a single Date) + if (s.includes("T") && /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) { + const fire = new Date(s); + if (isNaN(fire.getTime())) throw new Error(`invalid schedule expression (one-shot datetime): ${expr}`); + let fired = false; + return { + type: "once", + nextFire: (prev) => { + if (fired) return null; + if (prev && fire.getTime() <= prev.getTime()) { fired = true; return null; } + fired = true; + return fire; + }, + }; + } + // cron (5-field) — validate immediately (resolve-time error, not fire-time) + try { + cronParser.parseExpression(s, { currentDate: new Date() }); + } catch (e) { + throw new Error(`invalid schedule expression (not cron/interval/once): ${expr} — ${(e as Error).message}`); + } + return { + type: "cron", + nextFire: (prev) => cronParser.parseExpression(s, { currentDate: prev ?? new Date() }).next(), + }; +} \ No newline at end of file diff --git a/src/vendor/cron-parser/NOTICE.md b/src/vendor/cron-parser/NOTICE.md new file mode 100644 index 0000000..bbc6431 --- /dev/null +++ b/src/vendor/cron-parser/NOTICE.md @@ -0,0 +1,23 @@ +# cron-parser (vendored) + +- **Origin:** https://github.com/harrisi/cron-parser +- **npm:** `cron-parser` +- **Version:** 1.1.1 (latest 1.x — dependency-free; later versions pull in `luxon`) +- **License:** MIT (see upstream LICENSE) +- **Vendored on:** 2026-07-24 +- **Vendored surface:** `lib/` (4 files: `parser.js`, `expression.js`, `date.js`, `number.js` — CommonJS, all-relative `require()`s, zero runtime deps) +- **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. + +## Why vendored (per SPEC-5a §9, Q9=A) +cron expression parsing is commodity plumbing (DST, month-length, DOW/DOM OR-semantics, Feb 29). +We freeze a battle-tested MIT copy rather than reinvent it. The worktree lifecycle, by contrast, +is greenfield (thin git shell-outs) — see `src/worktree/`. + +## API used (v1.1.1) +```js +const cp = require("./lib/parser.js"); +const expr = cp.parseExpression("0 9 * * 1-5", { currentDate: new Date() }); +const nextDate = expr.next(); // CronDate (extends Date) — a real Date instance +``` +Note: v1.1.1 has no `tz` option — it uses the process local timezone, which is the right default +for a dev tool ("9am" means 9am the user's time). \ No newline at end of file diff --git a/src/vendor/cron-parser/lib/date.js b/src/vendor/cron-parser/lib/date.js new file mode 100644 index 0000000..ed4baa6 --- /dev/null +++ b/src/vendor/cron-parser/lib/date.js @@ -0,0 +1,79 @@ +'use strict'; + +/** + * Date class extension methods + */ +var extensions = { + addYear: function addYear() { + this.setFullYear(this.getFullYear() + 1); + }, + + addMonth: function addMonth() { + this.setDate(1); + this.setHours(0); + this.setMinutes(0); + this.setSeconds(0); + this.setMonth(this.getMonth() + 1); + }, + + addDay: function addDay() { + var day = this.getDate(); + this.setDate(day + 1); + + this.setHours(0); + this.setMinutes(0); + this.setSeconds(0); + + if (this.getDate() === day) { + this.setDate(day + 2); + } + }, + + addHour: function addHour() { + var hours = this.getHours(); + this.setHours(hours + 1); + + if (this.getHours() === hours) { + this.setHours(hours + 2); + } + + this.setMinutes(0); + this.setSeconds(0); + }, + + addMinute: function addMinute() { + this.setMinutes(this.getMinutes() + 1); + this.setSeconds(0); + }, + + addSecond: function addSecond() { + this.setSeconds(this.getSeconds() + 1); + }, + + toUTC: function toUTC() { + var to = new CronDate(this); + var ms = to.getTime() + (to.getTimezoneOffset() * 60000); + to.setTime(ms); + return to; + } +}; + +/** + * Extends Javascript Date class by adding + * utility methods for basic date incrementation + */ + +function CronDate (timestamp) { + var date = timestamp ? new Date(timestamp) : new Date(); + + // Attach extensions + var methods = Object.keys(extensions); + for (var i = 0, c = methods.length; i < c; i++) { + var method = methods[i]; + date[method] = extensions[method].bind(date); + } + + return date; +} + +module.exports = CronDate; \ No newline at end of file diff --git a/src/vendor/cron-parser/lib/expression.js b/src/vendor/cron-parser/lib/expression.js new file mode 100644 index 0000000..b894ca9 --- /dev/null +++ b/src/vendor/cron-parser/lib/expression.js @@ -0,0 +1,614 @@ +'use strict'; + +// Load Date class extensions +var CronDate = require('./date'); + +// Load fix for isNaN (IE) +require('./number'); + +/** + * Construct a new expression parser + * + * Options: + * currentDate: iterator start date + * endDate: iterator end date + * + * @constructor + * @private + * @param {Object} fields Expression fields parsed values + * @param {Object} options Parser options + */ +function CronExpression (fields, options) { + this._options = options; + this._currentDate = new CronDate(options.currentDate); + this._endDate = options.endDate ? new CronDate(options.endDate) : null; + this._fields = {}; + this._isIterator = options.iterator || false; + this._hasIterated = false; + this._utc = options.utc || false; + + // Map fields + for (var i = 0, c = CronExpression.map.length; i < c; i++) { + var key = CronExpression.map[i]; + this._fields[key] = fields[i]; + } +} + +/** + * Field mappings + * @type {Array} + */ +CronExpression.map = [ 'second', 'minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek' ]; + +/** + * Prefined intervals + * @type {Object} + */ +CronExpression.predefined = { + '@yearly': '0 0 1 1 *', + '@monthly': '0 0 1 * *', + '@weekly': '0 0 * * 0', + '@daily': '0 0 * * *', + '@hourly': '0 * * * *' +}; + +/** + * Fields constraints + * @type {Array} + */ +CronExpression.constraints = [ + [ 0, 59 ], // Second + [ 0, 59 ], // Minute + [ 0, 23 ], // Hour + [ 1, 31 ], // Day of month + [ 1, 12 ], // Month + [ 0, 7 ] // Day of week +]; + +/** + * Days in month + * @type {number[]} + */ +CronExpression.daysInMonth = [ + 31, + 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31 +]; + +/** + * Field aliases + * @type {Object} + */ +CronExpression.aliases = { + month: { + jan: 1, + feb: 2, + mar: 3, + apr: 4, + may: 5, + jun: 6, + jul: 7, + aug: 8, + sep: 9, + oct: 10, + nov: 11, + dec: 12 + }, + + dayOfWeek: { + sun: 0, + mon: 1, + tue: 2, + wed: 3, + thu: 4, + fri: 5, + sat: 6 + } +}; + +/** + * Field defaults + * @type {Array} + */ +CronExpression.parseDefaults = [ '0', '*', '*', '*', '*', '*' ]; + +/** + * Parse input interval + * + * @param {String} field Field symbolic name + * @param {String} value Field value + * @param {Array} constraints Range upper and lower constraints + * @return {Array} Sequence of sorted values + * @private + */ +CronExpression._parseField = function _parseField (field, value, constraints) { + // Replace aliases + switch (field) { + case 'month': + case 'dayOfWeek': + var aliases = CronExpression.aliases[field]; + + value = value.replace(/[a-z]{1,3}/gi, function(match) { + match = match.toLowerCase(); + + if (typeof aliases[match] !== undefined) { + return aliases[match]; + } else { + throw new Error('Cannot resolve alias "' + match + '"') + } + }); + break; + } + + // Check for valid characters. + if (!(/^[\d|/|*|\-|,]+$/.test(value))) { + throw new Error('Invalid characters, got value: ' + value) + } + + // Replace '*' + if (value.indexOf('*') !== -1) { + value = value.replace(/\*/g, constraints.join('-')); + } + + // + // Inline parsing functions + // + // Parser path: + // - parseSequence + // - parseRepeat + // - parseRange + + /** + * Parse sequence + * + * @param {String} val + * @return {Array} + * @private + */ + function parseSequence (val) { + var stack = []; + + function handleResult (result) { + var max = stack.length > 0 ? Math.max.apply(Math, stack) : -1; + + if (result instanceof Array) { // Make sequence linear + for (var i = 0, c = result.length; i < c; i++) { + var value = result[i]; + + // Check constraints + if (value < constraints[0] || value > constraints[1]) { + throw new Error( + 'Constraint error, got value ' + value + ' expected range ' + + constraints[0] + '-' + constraints[1] + ); + } + + if (value > max) { + stack.push(value); + } + + max = Math.max.apply(Math, stack); + } + } else { // Scalar value + result = +result; + + // Check constraints + if (result < constraints[0] || result > constraints[1]) { + throw new Error( + 'Constraint error, got value ' + result + ' expected range ' + + constraints[0] + '-' + constraints[1] + ); + } + + if (field == 'dayOfWeek') { + result = result % 7; + } + + if (result > max) { + stack.push(result); + } + } + } + + var atoms = val.split(','); + if (atoms.length > 1) { + for (var i = 0, c = atoms.length; i < c; i++) { + handleResult(parseRepeat(atoms[i])); + } + } else { + handleResult(parseRepeat(val)); + } + + return stack; + } + + /** + * Parse repetition interval + * + * @param {String} val + * @return {Array} + */ + function parseRepeat (val) { + var repeatInterval = 1; + var atoms = val.split('/'); + + if (atoms.length > 1) { + return parseRange(atoms[0], atoms[atoms.length - 1]); + } + + return parseRange(val, repeatInterval); + } + + /** + * Parse range + * + * @param {String} val + * @param {Number} repeatInterval Repetition interval + * @return {Array} + * @private + */ + function parseRange (val, repeatInterval) { + var stack = []; + var atoms = val.split('-'); + + if (atoms.length > 1 ) { + // Invalid range, return value + if (atoms.length < 2 || !atoms[0].length) { + return +val; + } + + // Validate range + var min = +atoms[0]; + var max = +atoms[1]; + + if (Number.isNaN(min) || Number.isNaN(max) || + min < constraints[0] || max > constraints[1]) { + throw new Error( + 'Constraint error, got range ' + + min + '-' + max + + ' expected range ' + + constraints[0] + '-' + constraints[1] + ); + } else if (min >= max) { + throw new Error('Invalid range: ' + val); + } + + // Create range + var repeatIndex = +repeatInterval; + + if (Number.isNaN(repeatIndex) || repeatIndex <= 0) { + throw new Error('Constraint error, cannot repeat at every ' + repeatIndex + ' time.'); + } + + for (var index = min, count = max; index <= count; index++) { + if (repeatIndex > 0 && (repeatIndex % repeatInterval) === 0) { + repeatIndex = 1; + stack.push(index); + } else { + repeatIndex++; + } + } + + return stack; + } + + return +val; + } + + return parseSequence(value); +}; + +/** + * Find next matching schedule date + * + * @return {CronDate} + * @private + */ +CronExpression.prototype._findSchedule = function _findSchedule () { + /** + * Match field value + * + * @param {String} value + * @param {Array} sequence + * @return {Boolean} + * @private + */ + function matchSchedule (value, sequence) { + for (var i = 0, c = sequence.length; i < c; i++) { + if (sequence[i] >= value) { + return sequence[i] === value; + } + } + + return sequence[0] === value; + } + + /** + * Detect if input range fully matches constraint bounds + * @param {Array} range Input range + * @param {Array} constraints Input constraints + * @returns {Boolean} + * @private + */ + function isWildcardRange (range, constraints) { + if (range instanceof Array && !range.length) { + return false; + } + + if (constraints.length !== 2) { + return false; + } + + return range.length === (constraints[1] - (constraints[0] < 1 ? - 1 : 0)); + } + + var method = function(name) { + return !this._utc ? name : ('getUTC' + name.slice(3)); + }.bind(this); + + var currentDate = new CronDate(this._currentDate); + var endDate = this._endDate; + + // TODO: Improve this part + // Always increment second value when second part is present + if (this._fields.second.length > 1 && !this._hasIterated) { + currentDate.addSecond(); + } + + // Find matching schedule + while (true) { + // Validate timespan + if (endDate && (endDate.getTime() - currentDate.getTime()) < 0) { + throw new Error('Out of the timespan range'); + } + + // Day of month and week matching: + // + // "The day of a command's execution can be specified by two fields -- + // day of month, and day of week. If both fields are restricted (ie, + // aren't *), the command will be run when either field matches the cur- + // rent time. For example, "30 4 1,15 * 5" would cause a command to be + // run at 4:30 am on the 1st and 15th of each month, plus every Friday." + // + // http://unixhelp.ed.ac.uk/CGI/man-cgi?crontab+5 + // + + var dayOfMonthMatch = matchSchedule(currentDate[method('getDate')](), this._fields.dayOfMonth); + var dayOfWeekMatch = matchSchedule(currentDate[method('getDay')](), this._fields.dayOfWeek); + + var isDayOfMonthWildcardMatch = isWildcardRange(this._fields.dayOfMonth, CronExpression.constraints[3]); + var isMonthWildcardMatch = isWildcardRange(this._fields.month, CronExpression.constraints[4]); + var isDayOfWeekWildcardMatch = isWildcardRange(this._fields.dayOfWeek, CronExpression.constraints[5]); + + // Validate days in month if explicit value is given + if (!isMonthWildcardMatch) { + var currentYear = currentDate[method('getFullYear')](); + var currentMonth = currentDate[method('getMonth')]() + 1; + var previousMonth = currentMonth === 1 ? 11 : currentMonth - 1; + var daysInPreviousMonth = CronExpression.daysInMonth[previousMonth - 1]; + var daysOfMontRangeMax = this._fields.dayOfMonth[this._fields.dayOfMonth.length - 1]; + + var _daysInPreviousMonth = daysInPreviousMonth; + var _daysOfMontRangeMax = daysOfMontRangeMax; + + // Handle leap year + var isLeap = !((currentYear % 4) || (!(currentYear % 100) && (currentYear % 400))); + if (isLeap) { + _daysInPreviousMonth = 29; + _daysOfMontRangeMax = 29; + } + + if (this._fields.month[0] === previousMonth && _daysInPreviousMonth < _daysOfMontRangeMax) { + throw new Error('Invalid explicit day of month definition'); + } + } + + // Add day if select day not match with month (according to calendar) + if (!dayOfMonthMatch || !dayOfWeekMatch) { + currentDate.addDay(); + continue; + } + + // Add day if not day of month is set (and no match) and day of week is wildcard + if (!isDayOfMonthWildcardMatch && isDayOfWeekWildcardMatch && !dayOfMonthMatch) { + currentDate.addDay(); + continue; + } + + // Add day if not day of week is set (and no match) and day of month is wildcard + if (isDayOfMonthWildcardMatch && !isDayOfWeekWildcardMatch && !dayOfWeekMatch) { + currentDate.addDay(); + continue; + } + + // Add day if day of mont and week are non-wildcard values and both doesn't match + if (!(isDayOfMonthWildcardMatch && isDayOfWeekWildcardMatch) && + !dayOfMonthMatch && !dayOfWeekMatch) { + currentDate.addDay(); + continue; + } + + // Match month + if (!matchSchedule(currentDate[method('getMonth')]() + 1, this._fields.month)) { + currentDate.addMonth(); + continue; + } + + // Match hour + if (!matchSchedule(currentDate[method('getHours')](), this._fields.hour)) { + currentDate.addHour(); + continue; + } + + // Match minute + if (!matchSchedule(currentDate[method('getMinutes')](), this._fields.minute)) { + currentDate.addMinute(); + continue; + } + + // Match second + if (!matchSchedule(currentDate[method('getSeconds')](), this._fields.second)) { + currentDate.addSecond(); + continue; + } + + break; + } + + // When internal date is not mutated, append one second as a padding + var nextDate = new CronDate(currentDate); + if (this._currentDate !== currentDate) { + nextDate.addSecond(); + } + + this._currentDate = nextDate; + this._hasIterated = true; + + return currentDate; +}; + +/** + * Find next suitable date + * + * @public + * @return {CronDate|Object} + */ +CronExpression.prototype.next = function next () { + var schedule = this._findSchedule(); + + // Try to return ES6 compatible iterator + if (this._isIterator) { + return { + value: schedule, + done: !this.hasNext() + }; + } + + return schedule; +}; + +/** + * Check if next suitable date exists + * + * @public + * @return {Boolean} + */ +CronExpression.prototype.hasNext = function() { + var current = this._currentDate; + + try { + this.next(); + return true; + } catch (err) { + return false; + } finally { + this._currentDate = current; + } +}; + +/** + * Iterate over expression iterator + * + * @public + * @param {Number} steps Numbers of steps to iterate + * @param {Function} callback Optional callback + * @return {Array} Array of the iterated results + */ +CronExpression.prototype.iterate = function iterate (steps, callback) { + var dates = []; + + for (var i = 0, c = steps; i < c; i++) { + try { + var item = this.next(); + dates.push(item); + + // Fire the callback + if (callback) { + callback(item, i); + } + } catch (err) { + break; + } + } + + return dates; +}; + +/** + * Reset expression iterator state + * + * @public + */ +CronExpression.prototype.reset = function reset () { + this._currentDate = new CronDate(this._options.currentDate); +}; + +/** + * Parse input expression (async) + * + * @public + * @param {String} expression Input expression + * @param {Object} [options] Parsing options + * @param {Function} [callback] + */ +CronExpression.parse = function parse (expression, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + function parse (expression, options) { + if (!options) { + options = {}; + } + + if (!options.currentDate) { + options.currentDate = new CronDate(); + } + + // Is input expression predefined? + if (CronExpression.predefined[expression]) { + expression = CronExpression.predefined[expression]; + } + + // Split fields + var fields = []; + var atoms = expression.split(' '); + + // Resolve fields + var start = (CronExpression.map.length - atoms.length); + for (var i = 0, c = CronExpression.map.length; i < c; ++i) { + var field = CronExpression.map[i]; // Field name + var value = atoms[atoms.length > c ? i : i - start]; // Field value + + if (i < start || !value) { + fields.push(CronExpression._parseField( + field, + CronExpression.parseDefaults[i], + CronExpression.constraints[i]) + ); + } else { // Use default value + fields.push(CronExpression._parseField( + field, + value, + CronExpression.constraints[i]) + ); + } + } + + return new CronExpression(fields, options); + } + + return parse(expression, options); +}; + +module.exports = CronExpression; diff --git a/src/vendor/cron-parser/lib/number.js b/src/vendor/cron-parser/lib/number.js new file mode 100644 index 0000000..58ea7ee --- /dev/null +++ b/src/vendor/cron-parser/lib/number.js @@ -0,0 +1,8 @@ +'use strict'; + +/** + * Polyfill. IE Number.isNaN does not support method 'isNaN'. + */ +Number.isNaN = Number.isNaN || function(value) { + return typeof value === 'number' && isNaN(value); +} \ No newline at end of file diff --git a/src/vendor/cron-parser/lib/parser.js b/src/vendor/cron-parser/lib/parser.js new file mode 100644 index 0000000..5bb38a0 --- /dev/null +++ b/src/vendor/cron-parser/lib/parser.js @@ -0,0 +1,103 @@ +'use strict'; + +var CronExpression = require('./expression'); + +function CronParser() {} + +/** + * Parse crontab entry + * + * @private + * @param {String} entry Crontab file entry/line + */ +CronParser._parseEntry = function _parseEntry (entry) { + var atoms = entry.split(' '); + + if (atoms.length === 6) { + return { + interval: CronExpression.parse(entry) + }; + } else if (atoms.length > 6) { + return { + interval: CronExpression.parse(entry), + command: atoms.slice(6, atoms.length) + }; + } else { + throw new Error('Invalid entry: ' + entry); + } +}; + +/** + * Wrapper for CronExpression.parser method + * + * @public + * @param {String} expression Input expression + * @param {Object} [options] Parsing options + * @return {Object} + */ +CronParser.parseExpression = function parseExpression (expression, options, callback) { + return CronExpression.parse(expression, options, callback); +}; + +/** + * Parse content string + * + * @public + * @param {String} data Crontab content + * @return {Object} + */ +CronParser.parseString = function parseString (data) { + var self = this; + var blocks = data.split('\n'); + + var response = { + variables: {}, + expressions: [], + errors: {} + }; + + for (var i = 0, c = blocks.length; i < c; i++) { + var block = blocks[i]; + var matches = null; + var entry = block.replace(/^\s+|\s+$/g, ''); // Remove surrounding spaces + + if (entry.length > 0) { + if (entry.match(/^#/)) { // Comment + continue; + } else if ((matches = entry.match(/^(.*)=(.*)$/))) { // Variable + response.variables[matches[1]] = matches[2]; + } else { // Expression? + var result = null; + + try { + result = self._parseEntry('0 ' + entry); + response.expressions.push(result.interval); + } catch (err) { + response.errors[entry] = err; + } + } + } + } + + return response; +}; + +/** + * Parse crontab file + * + * @public + * @param {String} filePath Path to file + * @param {Function} callback + */ +CronParser.parseFile = function parseFile (filePath, callback) { + require('fs').readFile(filePath, function(err, data) { + if (err) { + callback(err); + return; + } + + return callback(null, CronParser.parseString(data.toString())); + }); +}; + +module.exports = CronParser; diff --git a/src/vendor/cron-parser/types.d.ts b/src/vendor/cron-parser/types.d.ts new file mode 100644 index 0000000..d757919 --- /dev/null +++ b/src/vendor/cron-parser/types.d.ts @@ -0,0 +1,12 @@ +// Type declarations for the vendored cron-parser lib (v1.1.1, CJS, dep-free). +declare module "../vendor/cron-parser/lib/parser.js" { + export interface CronExpressionIter { + next(): Date; + prev(): Date; + hasNext(): boolean; + } + export interface ParseOptions { currentDate?: Date; endDate?: Date; iterator?: boolean; } + export function parseExpression(expression: string, options?: ParseOptions): CronExpressionIter; + export function parseString(entry: string): unknown; + export function parseFile(filePath: string): unknown; +} \ No newline at end of file diff --git a/test/scheduling-expressions.test.mts b/test/scheduling-expressions.test.mts new file mode 100644 index 0000000..002fd6b --- /dev/null +++ b/test/scheduling-expressions.test.mts @@ -0,0 +1,40 @@ +// test/scheduling-expressions.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseScheduleExpr } from "../src/scheduling/expressions.ts"; + +test("cron: weekday 9am parses + computes next fire after a Monday (local time)", () => { + const expr = parseScheduleExpr("0 9 * * 1-5"); + assert.equal(expr.type, "cron"); + // Monday 8am local; next 9am local is the same day (Mon) if before 9am, else next weekday + const after = new Date(); + after.setHours(8, 0, 0, 0); // today 8am local + const next = expr.nextFire(after)!; + assert.equal(next.getHours(), 9); // local 9am + const dow = next.getDay(); + assert.ok(dow >= 1 && dow <= 5, `day=${dow}`); +}); + +test("interval: 30m parses + next fire is prev + 30min (or now if no prev)", () => { + const expr = parseScheduleExpr("30m"); + assert.equal(expr.type, "interval"); + const prev = new Date("2026-07-27T10:00:00Z"); + const next = expr.nextFire(prev)!; + assert.equal(next.getTime() - prev.getTime(), 30 * 60 * 1000); +}); + +test("once: ISO datetime parses + fires exactly once (nextFire returns same time, then null)", () => { + const expr = parseScheduleExpr("2026-07-25T14:00"); + assert.equal(expr.type, "once"); + const next = expr.nextFire(null)!; + assert.ok(next.toISOString().startsWith("2026-07-25T14:00") || next.toTimeString().includes("14:00")); + assert.equal(expr.nextFire(next), null); +}); + +test("invalid cron errors at parse time (resolve-time, not fire time)", () => { + assert.throws(() => parseScheduleExpr("not-a-cron"), /invalid schedule expression/); +}); + +test("interval rejects unknown units", () => { + assert.throws(() => parseScheduleExpr("30x"), /invalid schedule expression/); +}); \ No newline at end of file From e89556bc56978c782656a955b11fa094845cca1b Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:10:15 +0700 Subject: [PATCH 08/18] =?UTF-8?q?feat(spec-5a):=20PidLock=20=E2=80=94=20se?= =?UTF-8?q?ssion-scoped=20schedule=20firing=20ownership=20+=20stale=20recl?= =?UTF-8?q?aim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scheduling/pid-lock.ts | 43 +++++++++++++++++++++++++ test/pid-lock.test.mts | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/scheduling/pid-lock.ts create mode 100644 test/pid-lock.test.mts diff --git a/src/scheduling/pid-lock.ts b/src/scheduling/pid-lock.ts new file mode 100644 index 0000000..0bb3f3e --- /dev/null +++ b/src/scheduling/pid-lock.ts @@ -0,0 +1,43 @@ +// src/scheduling/pid-lock.ts +// SPEC-5a §9 — PID lock so only one pi session fires schedules (Q5=A). +// A stale PID (dead process) is reclaimed. +import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs"; + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); // signal 0 = existence check + return true; + } catch { + return false; + } +} + +export class PidLock { + private lockPath: string | null = null; + + acquire(lockPath: string): boolean { + if (existsSync(lockPath)) { + const raw = readFileSync(lockPath, "utf8").trim(); + const ownerPid = Number(raw); + if (Number.isFinite(ownerPid) && ownerPid !== process.pid && isPidAlive(ownerPid)) { + // a different live process owns it + return false; + } + // stale pid (dead) or already us → reclaim/keep + } + writeFileSync(lockPath, String(process.pid), "utf8"); + this.lockPath = lockPath; + return true; + } + + isOwner(): boolean { + return this.lockPath !== null; + } + + release(): void { + if (this.lockPath && existsSync(this.lockPath)) { + try { unlinkSync(this.lockPath); } catch { /* already gone */ } + } + this.lockPath = null; + } +} \ No newline at end of file diff --git a/test/pid-lock.test.mts b/test/pid-lock.test.mts new file mode 100644 index 0000000..3ecf3af --- /dev/null +++ b/test/pid-lock.test.mts @@ -0,0 +1,64 @@ +// test/pid-lock.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { PidLock } from "../src/scheduling/pid-lock.ts"; + +test("acquire returns true for a free lock + writes the current pid", () => { + const dir = mkdtempSync(join(tmpdir(), "pidlock-")); + const lock = join(dir, "schedules.lock"); + const pl = new PidLock(); + assert.equal(pl.acquire(lock), true); + assert.equal(pl.isOwner(), true); + assert.equal(readFileSync(lock, "utf8").trim(), String(process.pid)); + pl.release(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("acquire re-entrantly returns true when the lock is already owned by this pid", () => { + const dir = mkdtempSync(join(tmpdir(), "pidlock-")); + const lock = join(dir, "schedules.lock"); + writeFileSync(lock, String(process.pid)); + const pl = new PidLock(); + assert.equal(pl.acquire(lock), true); + assert.equal(pl.isOwner(), true); + pl.release(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("acquire reclaims a stale pid (a dead process) and returns true", () => { + const dir = mkdtempSync(join(tmpdir(), "pidlock-")); + const lock = join(dir, "schedules.lock"); + writeFileSync(lock, "99999999"); // a pid that definitely doesn't exist + const pl = new PidLock(); + assert.equal(pl.acquire(lock), true); + assert.equal(pl.isOwner(), true); + pl.release(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("release removes the lock file when owner", () => { + const dir = mkdtempSync(join(tmpdir(), "pidlock-")); + const lock = join(dir, "schedules.lock"); + const pl = new PidLock(); + pl.acquire(lock); + pl.release(); + assert.throws(() => readFileSync(lock, "utf8")); + rmSync(dir, { recursive: true, force: true }); +}); + +test("acquire returns false when a different live pid owns the lock", () => { + const dir = mkdtempSync(join(tmpdir(), "pidlock-")); + const lock = join(dir, "schedules.lock"); + // find a live pid that isn't us: the current process's parent is alive. + const ppid = process.ppid; + writeFileSync(lock, String(ppid)); + const pl = new PidLock(); + assert.equal(pl.acquire(lock), false); + assert.equal(pl.isOwner(), false); + // clean up the lock we didn't own (test hygiene) + try { require("node:fs").unlinkSync(lock); } catch {} + rmSync(dir, { recursive: true, force: true }); +}); \ No newline at end of file From 9ca41e2fbe0e63da8042e62d06a6b09f9f1af15c Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:12:14 +0700 Subject: [PATCH 09/18] =?UTF-8?q?feat(spec-5a):=20Scheduler=20=E2=80=94=20?= =?UTF-8?q?in-process=20cron/interval/one-shot=20firing=20+=20PID-lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scheduling/scheduler.ts | 147 ++++++++++++++++++++++++++++++++++++ test/scheduler.test.mts | 90 ++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 src/scheduling/scheduler.ts create mode 100644 test/scheduler.test.mts diff --git a/src/scheduling/scheduler.ts b/src/scheduling/scheduler.ts new file mode 100644 index 0000000..daa2baf --- /dev/null +++ b/src/scheduling/scheduler.ts @@ -0,0 +1,147 @@ +// src/scheduling/scheduler.ts +// SPEC-5a §9 — in-process scheduler. Session-scoped (fires only while pi open, no daemon). +// PID-locked so two open pi sessions on the same project don't double-fire. No catch-up. +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { parseScheduleExpr, type ScheduleExpression } from "./expressions.ts"; +import { PidLock } from "./pid-lock.ts"; + +export interface ScheduleSpec { + task: string; + expression: string; + lifecycle?: string; // default "default" + auto?: boolean; +} + +export interface Schedule extends ScheduleSpec { + id: string; + nextFire: Date | null; + paused: boolean; +} + +interface StoredSchedule extends ScheduleSpec { + id: string; + paused: boolean; +} + +export interface SchedulerOpts { + storePath: string; + lockPath: string; + onFire: (spec: ScheduleSpec) => void; +} + +interface Entry { spec: StoredSchedule; expr: ScheduleExpression; timer: NodeJS.Timeout | null } + +export class Scheduler { + private schedules = new Map(); + private pidLock = new PidLock(); + private running = false; + + constructor(private readonly opts: SchedulerOpts) { + this.load(); + } + + private load(): void { + if (!existsSync(this.opts.storePath)) return; + try { + const arr = JSON.parse(readFileSync(this.opts.storePath, "utf8")) as StoredSchedule[]; + for (const s of arr) { + try { + const expr = parseScheduleExpr(s.expression); + this.schedules.set(s.id, { spec: s, expr, timer: null }); + } catch { + // skip a schedule whose expression no longer parses + } + } + } catch { /* corrupt store — start empty */ } + } + + private persist(): void { + mkdirSync(dirname(this.opts.storePath), { recursive: true }); + const arr = [...this.schedules.values()].map((e) => e.spec); + writeFileSync(this.opts.storePath, JSON.stringify(arr, null, 2), "utf8"); + } + + register(spec: ScheduleSpec): string { + const expr = parseScheduleExpr(spec.expression); // throws on invalid → resolve-time error + const id = "sch-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + const stored: StoredSchedule = { + id, + task: spec.task, + expression: spec.expression, + lifecycle: spec.lifecycle ?? "default", + auto: spec.auto ?? true, + paused: false, + }; + this.schedules.set(id, { spec: stored, expr, timer: null }); + this.persist(); + if (this.running) this.arm(id); + return id; + } + + list(): Schedule[] { + return [...this.schedules.values()].map((e) => ({ + id: e.spec.id, + task: e.spec.task, + expression: e.spec.expression, + lifecycle: e.spec.lifecycle, + auto: e.spec.auto, + paused: e.spec.paused, + nextFire: e.spec.paused ? null : e.expr.nextFire(new Date()), + })); + } + + pause(id: string): void { + const e = this.schedules.get(id); + if (!e) return; + e.spec.paused = true; + if (e.timer) { clearTimeout(e.timer); e.timer = null; } + this.persist(); + } + + resume(id: string): void { + const e = this.schedules.get(id); + if (!e) return; + e.spec.paused = false; + if (this.running) this.arm(id); + this.persist(); + } + + delete(id: string): void { + const e = this.schedules.get(id); + if (!e) return; + if (e.timer) clearTimeout(e.timer); + this.schedules.delete(id); + this.persist(); + } + + start(): boolean { + if (this.running) return true; + if (!this.pidLock.acquire(this.opts.lockPath)) return false; + this.running = true; + for (const id of this.schedules.keys()) this.arm(id); + return true; + } + + stop(): void { + if (!this.running) return; + for (const e of this.schedules.values()) if (e.timer) { clearTimeout(e.timer); e.timer = null; } + this.pidLock.release(); + this.running = false; + } + + private arm(id: string): void { + const e = this.schedules.get(id); + if (!e || e.spec.paused) return; + const now = new Date(); + const next = e.expr.nextFire(now); + if (!next) { this.delete(id); return; } // one-shot exhausted + const delay = Math.max(0, next.getTime() - now.getTime()); + e.timer = setTimeout(() => { + this.opts.onFire(e.spec); + const nx = e.expr.nextFire(new Date()); + if (!nx) { this.delete(id); return; } + this.arm(id); + }, delay); + } +} \ No newline at end of file diff --git a/test/scheduler.test.mts b/test/scheduler.test.mts new file mode 100644 index 0000000..ca943b8 --- /dev/null +++ b/test/scheduler.test.mts @@ -0,0 +1,90 @@ +// test/scheduler.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Scheduler, type ScheduleSpec } from "../src/scheduling/scheduler.ts"; + +function makeScheduler(onFire: (s: ScheduleSpec) => void): { sched: Scheduler; dir: string } { + const dir = mkdtempSync(join(tmpdir(), "sched-test-")); + const sched = new Scheduler({ storePath: join(dir, "schedules.json"), lockPath: join(dir, "schedules.lock"), onFire }); + return { sched, dir }; +} + +test("register an interval schedule + start fires it; stop halts", async () => { + let fired = 0; + const { sched, dir } = makeScheduler(() => { fired++; }); + sched.register({ task: "t", expression: "1s", lifecycle: "default" }); + sched.start(); + await new Promise((r) => setTimeout(r, 1300)); + assert.ok(fired >= 1, `fired ${fired} times`); + sched.stop(); + const snap = fired; + await new Promise((r) => setTimeout(r, 600)); + assert.equal(fired, snap, "stop halted firing"); + rmSync(dir, { recursive: true, force: true }); +}); + +test("a one-shot schedule fires once then is auto-deleted", async () => { + let fired = 0; + const { sched, dir } = makeScheduler(() => { fired++; }); + const fireAt = new Date(Date.now() + 2000); + // build a LOCAL-time ISO with second precision — new Date("…THH:mm:ss") parses as local, not UTC + const pad = (n: number) => String(n).padStart(2, "0"); + const iso = `${fireAt.getFullYear()}-${pad(fireAt.getMonth() + 1)}-${pad(fireAt.getDate())}T${pad(fireAt.getHours())}:${pad(fireAt.getMinutes())}:${pad(fireAt.getSeconds())}`; + const id = sched.register({ task: "once", expression: iso, lifecycle: "default" }); + sched.start(); + await new Promise((r) => setTimeout(r, 2800)); + sched.stop(); + assert.equal(fired, 1, `fired ${fired} times (expected exactly 1)`); + assert.equal(sched.list().find((s) => s.id === id), undefined, "one-shot auto-deleted"); + rmSync(dir, { recursive: true, force: true }); +}); + +test("list returns registered schedules with next-fire", () => { + const { sched, dir } = makeScheduler(() => {}); + sched.register({ task: "t", expression: "30m", lifecycle: "default" }); + const list = sched.list(); + assert.equal(list.length, 1); + assert.equal(list[0]!.task, "t"); + assert.ok(list[0]!.nextFire instanceof Date); + rmSync(dir, { recursive: true, force: true }); +}); + +test("pause + resume: a paused schedule does not fire; resume re-enables", async () => { + let fired = 0; + const { sched, dir } = makeScheduler(() => { fired++; }); + const id = sched.register({ task: "t", expression: "1s", lifecycle: "default" }); + sched.pause(id); + sched.start(); + await new Promise((r) => setTimeout(r, 1300)); + assert.equal(fired, 0, "paused schedule fired"); + sched.resume(id); + await new Promise((r) => setTimeout(r, 1300)); + assert.ok(fired >= 1, "resumed schedule did not fire"); + sched.stop(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("delete removes a schedule", () => { + const { sched, dir } = makeScheduler(() => {}); + const id = sched.register({ task: "t", expression: "30m", lifecycle: "default" }); + sched.delete(id); + assert.equal(sched.list().length, 0); + rmSync(dir, { recursive: true, force: true }); +}); + +test("invalid cron errors at register time", () => { + const { sched, dir } = makeScheduler(() => {}); + assert.throws(() => sched.register({ task: "t", expression: "not-a-cron", lifecycle: "default" }), /invalid schedule expression/); + rmSync(dir, { recursive: true, force: true }); +}); + +test("start is idempotent (calling twice is safe)", async () => { + const { sched, dir } = makeScheduler(() => {}); + assert.equal(sched.start(), true); + assert.equal(sched.start(), true); // second start no-ops (already running) + sched.stop(); + rmSync(dir, { recursive: true, force: true }); +}); \ No newline at end of file From c0dcf3cc9700fcf8bf8a8d4985c997f684ce46ba Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:13:50 +0700 Subject: [PATCH 10/18] =?UTF-8?q?feat(spec-5a):=20AsyncRunner=20=E2=80=94?= =?UTF-8?q?=20bg=20path=20(worktree=20+=20journal=20+=20runLifecycle=20+?= =?UTF-8?q?=20inbox=20+=20notify)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/runtime/async-runner.ts | 102 ++++++++++++++++++++++++++++++++++++ test/async-runner.test.mts | 78 +++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 src/runtime/async-runner.ts create mode 100644 test/async-runner.test.mts diff --git a/src/runtime/async-runner.ts b/src/runtime/async-runner.ts new file mode 100644 index 0000000..1099a0a --- /dev/null +++ b/src/runtime/async-runner.ts @@ -0,0 +1,102 @@ +// src/runtime/async-runner.ts +// SPEC-5a §2/§6/§7/§8/§10 — the async/bg path. Layers ABOVE the unchanged runLifecycle: +// creates a worktree, journals events, drives runLifecycle with the worktree cwd, discovers +// artifacts via DiffService, commits on completion, pushes to the inbox, notifies. +import type { WorktreeService } from "../worktree/worktree-service.ts"; +import type { DiffService } from "../worktree/diff-service.ts"; +import type { RunJournal, JournalEvent } from "./run-journal.ts"; +import type { ConcurrencyPool } from "./concurrency-pool.ts"; +import type { ResultsInbox, RunResult } from "./results-inbox.ts"; +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +// Thin shape of the LifecycleRunResult we need (avoids importing the full type here — the +// real adapter in index.ts maps the full LifecycleRunResult to this shape). +export interface FakeLifecycleResult { + runId: string; + lifecycleName: string; + task: string; + status: "completed" | "failed" | "aborted"; + phases: Array<{ name: string; status: string; summary: string; paths: string[]; reviseCount: number }>; + todoId: string | null; + error?: string; +} + +export interface RunLifecycleOpts { + runId: string; + worktreePath: string; + branch: string; + mode: "auto" | "checkpointed"; +} + +export type RunLifecycleFn = (task: string, lifecycleName: string, opts: RunLifecycleOpts) => Promise; + +export interface AsyncRunnerDeps { + worktree: WorktreeService; + diff: DiffService; + journal: RunJournal; + pool: ConcurrencyPool; + inbox: ResultsInbox; + runLifecycle: RunLifecycleFn; + notify: (msg: string, level?: "info" | "warning" | "error") => void; + genRunId: () => string; +} + +export interface RunBackgroundOpts { + deps: AsyncRunnerDeps; + lifecycle: string; + mode: "auto" | "checkpointed"; +} + +export interface RunBackgroundHandle { + runId: string; + status: "background"; +} + +function sh(cmd: string, cwd: string): void { + execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }); +} + +export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgroundHandle { + const { deps } = opts; + const runId = deps.genRunId(); + const baseRef = "HEAD"; + + // Fire-and-forget: the pool gates concurrency; the journal records the run. + void deps.pool.withSlot(async () => { + let wt: { path: string; branch: string } | null = null; + try { + wt = deps.worktree.create(runId, baseRef); + const ev0: JournalEvent = { type: "run:started", runId, task, lifecycle: opts.lifecycle, worktree: { path: wt.path, branch: wt.branch }, mode: opts.mode, ts: Date.now() }; + deps.journal.append(runId, ev0); + + const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: wt.path, branch: wt.branch, mode: opts.mode }); + + if (res.status === "completed") { + // commit the worktree to the branch (lifecycle finish phase or single-delegate completion) + try { sh("git add -A && git commit -m 'fleet run complete'", wt.path); } catch { /* nothing to commit */ } + deps.journal.append(runId, { type: "run:completed", runId, branch: wt.branch, ts: Date.now() }); + const lastPhase = res.phases[res.phases.length - 1]; + const result: RunResult = { + runId, task, status: "completed", + summary: lastPhase?.summary ?? "", + paths: res.phases.flatMap((p) => p.paths), + branch: wt.branch, completedAt: Date.now(), + }; + deps.inbox.push(result); + deps.notify(`fleet run ${runId} completed`, "info"); + } else { + deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() }); + deps.worktree.remove(runId); + deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning"); + } + } catch (e) { + const msg = (e as Error).message; + deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() }); + if (wt) deps.worktree.remove(runId); + deps.notify(`fleet run ${runId} failed: ${msg}`, "error"); + } + }); + + return { runId, status: "background" }; +} \ No newline at end of file diff --git a/test/async-runner.test.mts b/test/async-runner.test.mts new file mode 100644 index 0000000..7d963e9 --- /dev/null +++ b/test/async-runner.test.mts @@ -0,0 +1,78 @@ +// test/async-runner.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; +import { DiffService } from "../src/worktree/diff-service.ts"; +import { RunJournal } from "../src/runtime/run-journal.ts"; +import { ConcurrencyPool } from "../src/runtime/concurrency-pool.ts"; +import { ResultsInbox } from "../src/runtime/results-inbox.ts"; +import { runBackground, type RunLifecycleFn, type AsyncRunnerDeps } from "../src/runtime/async-runner.ts"; + +function sh(cmd: string, cwd: string): string { return execSync(cmd, { cwd, encoding: "utf8" }).trim(); } + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "async-test-")); + sh("git init -b main", dir); + sh('git config user.email "t@t.test"', dir); + sh('git config user.name "test"', dir); + writeFileSync(join(dir, "base.txt"), "base\n"); + sh("git add base.txt && git commit -m base", dir); + return dir; +} + +function makeDeps(repo: string, runLifecycle: RunLifecycleFn): { deps: AsyncRunnerDeps; journal: RunJournal; inbox: ResultsInbox; notifications: string[] } { + const journal = new RunJournal(join(repo, ".pi", "fleet", "runs")); + const inbox = new ResultsInbox(); + const notifications: string[] = []; + const deps: AsyncRunnerDeps = { + worktree: new WorktreeService({ rootDir: repo }), + diff: new DiffService(), + journal, + pool: new ConcurrencyPool(2), + inbox, + runLifecycle, + notify: (m) => { notifications.push(m); }, + genRunId: () => "fl-test-" + Math.random().toString(36).slice(2, 8), + }; + return { deps, journal, inbox, notifications }; +} + +test("runBackground creates a worktree, journals run:started, drives runLifecycle, journals run:completed, pushes to inbox, notifies", async () => { + const repo = makeRepo(); + const fakeLifecycle: RunLifecycleFn = async (task, lifecycleName, opts) => { + writeFileSync(join(opts.worktreePath, "design.md"), "# design\n"); + return { + runId: opts.runId, lifecycleName, task, backend: "pi", mode: "auto", status: "completed", + phases: [{ name: "brainstorm", status: "completed", summary: "did it", paths: ["design.md"], reviseCount: 0 }], + startedAt: 1, endedAt: 2, todoId: "td-x", + }; + }; + const { deps, journal, inbox, notifications } = makeDeps(repo, fakeLifecycle); + const { runId, status } = runBackground("add hello", { deps, lifecycle: "default", mode: "auto" }); + assert.equal(status, "background"); + await new Promise((r) => setTimeout(r, 60)); + const events = journal.replay(runId); + assert.ok(events.some((e) => e.type === "run:started"), "no run:started"); + assert.ok(events.some((e) => e.type === "run:completed"), "no run:completed"); + assert.equal(inbox.readyCount(), 1); + assert.ok(notifications.some((n) => n.includes("completed")), `notifications: ${notifications.join("|")}`); + rmSync(repo, { recursive: true, force: true }); +}); + +test("runBackground journals run:aborted + cleans up the worktree when runLifecycle fails", async () => { + const repo = makeRepo(); + const failingLifecycle: RunLifecycleFn = async () => { throw new Error("model blew up"); }; + const { deps, journal, notifications } = makeDeps(repo, failingLifecycle); + const wt = deps.worktree; + const { runId } = runBackground("bad task", { deps, lifecycle: "default", mode: "auto" }); + await new Promise((r) => setTimeout(r, 60)); + const events = journal.replay(runId); + assert.ok(events.some((e) => e.type === "run:aborted"), "no run:aborted"); + assert.equal(wt.exists(runId), false, "worktree not cleaned up"); + assert.ok(notifications.some((n) => /failed|error/i.test(n)), `notifications: ${notifications.join("|")}`); + rmSync(repo, { recursive: true, force: true }); +}); \ No newline at end of file From 3d929b77499b773727ce5cbb7bda0e3c1f7a2289 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:14:21 +0700 Subject: [PATCH 11/18] =?UTF-8?q?feat(spec-5a):=20resume=20=E2=80=94=20sca?= =?UTF-8?q?n=20non-terminal=20journals=20+=20worktree-existence=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/runtime/resume.ts | 48 ++++++++++++++++++++++++++++++++ test/resume.test.mts | 65 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/runtime/resume.ts create mode 100644 test/resume.test.mts diff --git a/src/runtime/resume.ts b/src/runtime/resume.ts new file mode 100644 index 0000000..8322421 --- /dev/null +++ b/src/runtime/resume.ts @@ -0,0 +1,48 @@ +// src/runtime/resume.ts +// SPEC-5a §5.3 — on pi start, scan .pi/fleet/runs/ for non-terminal journals and offer resume. +// If the worktree is gone, mark the journal run:aborted (worktree-missing). +import { RunJournal, type JournalEvent } from "./run-journal.ts"; +import type { WorktreeService } from "../worktree/worktree-service.ts"; + +export interface ResumeCandidate { + runId: string; + task: string; + lifecycle: string; + worktreePath: string; + branch: string; + lastPhase: string | null; + canResume: boolean; +} + +export interface ScanResumeOpts { + runsDir: string; + worktree: WorktreeService; +} + +export function scanResumeCandidates(_projectDir: string, opts: ScanResumeOpts): ResumeCandidate[] { + const journal = new RunJournal(opts.runsDir); + const ids = journal.scanNonTerminal(); + const cands: ResumeCandidate[] = []; + for (const runId of ids) { + const events = journal.replay(runId); + const started = events.find((e) => e.type === "run:started") as + | (JournalEvent & { type: "run:started" }) | undefined; + if (!started) continue; + const phaseEvents = events.filter((e) => e.type === "phase:completed" || e.type === "phase:started" || e.type === "phase:failed") as Array<{ phase: string }>; + const lastPhase = phaseEvents.length > 0 ? phaseEvents[phaseEvents.length - 1]!.phase : null; + const wtExists = opts.worktree.exists(runId); + if (!wtExists) { + journal.append(runId, { type: "run:aborted", runId, reason: "worktree-missing", ts: Date.now() }); + } + cands.push({ + runId, + task: started.task, + lifecycle: started.lifecycle, + worktreePath: started.worktree.path, + branch: started.worktree.branch, + lastPhase, + canResume: wtExists, + }); + } + return cands; +} \ No newline at end of file diff --git a/test/resume.test.mts b/test/resume.test.mts new file mode 100644 index 0000000..156771a --- /dev/null +++ b/test/resume.test.mts @@ -0,0 +1,65 @@ +// test/resume.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { RunJournal } from "../src/runtime/run-journal.ts"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; +import { scanResumeCandidates } from "../src/runtime/resume.ts"; + +function sh(cmd: string, cwd: string): string { return execSync(cmd, { cwd, encoding: "utf8" }).trim(); } + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "resume-test-")); + sh("git init -b main", dir); + sh('git config user.email "t@t.test"', dir); + sh('git config user.name "test"', dir); + writeFileSync(join(dir, "base.txt"), "base\n"); + sh("git add base.txt && git commit -m base", dir); + return dir; +} + +test("scanResumeCandidates returns an interrupted run with canResume=true when the worktree exists", () => { + const repo = makeRepo(); + const runsDir = join(repo, ".pi", "fleet", "runs"); + const journal = new RunJournal(runsDir); + const wt = new WorktreeService({ rootDir: repo }); + const { path } = wt.create("fl-resume1", "HEAD"); + journal.append("fl-resume1", { type: "run:started", runId: "fl-resume1", task: "t", lifecycle: "default", worktree: { path, branch: "fleet/fl-resume1" }, mode: "auto", ts: 1 }); + journal.append("fl-resume1", { type: "phase:completed", phase: "brainstorm", summary: "s", paths: ["d.md"], ts: 2 }); + const cands = scanResumeCandidates(repo, { runsDir, worktree: wt }); + assert.equal(cands.length, 1); + assert.equal(cands[0]!.runId, "fl-resume1"); + assert.equal(cands[0]!.canResume, true); + assert.equal(cands[0]!.lastPhase, "brainstorm"); + wt.remove("fl-resume1"); + rmSync(repo, { recursive: true, force: true }); +}); + +test("scanResumeCandidates marks canResume=false + writes run:aborted when the worktree is gone", () => { + const repo = makeRepo(); + const runsDir = join(repo, ".pi", "fleet", "runs"); + const journal = new RunJournal(runsDir); + const wt = new WorktreeService({ rootDir: repo }); + journal.append("fl-resume2", { type: "run:started", runId: "fl-resume2", task: "t", lifecycle: "default", worktree: { path: "/gone", branch: "fleet/fl-resume2" }, mode: "auto", ts: 1 }); + const cands = scanResumeCandidates(repo, { runsDir, worktree: wt }); + assert.equal(cands.length, 1); + assert.equal(cands[0]!.canResume, false); + const events = journal.replay("fl-resume2"); + assert.equal(events[events.length - 1]!.type, "run:aborted"); + rmSync(repo, { recursive: true, force: true }); +}); + +test("scanResumeCandidates skips terminal runs (completed/aborted)", () => { + const repo = makeRepo(); + const runsDir = join(repo, ".pi", "fleet", "runs"); + const journal = new RunJournal(runsDir); + const wt = new WorktreeService({ rootDir: repo }); + journal.append("fl-resume3", { type: "run:started", runId: "fl-resume3", task: "t", lifecycle: "default", worktree: { path: "/x", branch: "fleet/fl-resume3" }, mode: "auto", ts: 1 }); + journal.append("fl-resume3", { type: "run:completed", runId: "fl-resume3", branch: "fleet/fl-resume3", ts: 2 }); + const cands = scanResumeCandidates(repo, { runsDir, worktree: wt }); + assert.equal(cands.length, 0); + rmSync(repo, { recursive: true, force: true }); +}); \ No newline at end of file From 11db2864b22ad0fe9af3c1cf2395e01ecfcf9e98 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:15:12 +0700 Subject: [PATCH 12/18] =?UTF-8?q?feat(spec-5a):=20subagent=20tool=20?= =?UTF-8?q?=E2=80=94=20background=20+=20schedule=20params=20(async/bg=20+?= =?UTF-8?q?=20scheduling=20routing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tools/subagent.ts | 24 +++++++++ test/subagent-spec5a.test.mts | 96 +++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 test/subagent-spec5a.test.mts diff --git a/src/tools/subagent.ts b/src/tools/subagent.ts index 288bad5..7580afd 100644 --- a/src/tools/subagent.ts +++ b/src/tools/subagent.ts @@ -9,6 +9,9 @@ import { spawnSubagent } from "../engine/spawnSubagent.ts"; import type { BackendRegistry } from "../backend/port.ts"; import type { LifecycleRunDeps } from "../lifecycle/run-lifecycle.ts"; import type { LifecycleDef } from "../lifecycle/lifecycle-types.ts"; +import type { AsyncRunnerDeps } from "../runtime/async-runner.ts"; +import { runBackground } from "../runtime/async-runner.ts"; +import type { Scheduler } from "../scheduling/scheduler.ts"; export const subagentParams = Type.Object({ agent: Type.String({ description: "Agent name from the registry (builtin, project, or global)." }), @@ -18,6 +21,8 @@ export const subagentParams = Type.Object({ model: Type.Optional(Type.String({ description: 'Override the agent model, e.g. "anthropic/claude-sonnet-4".' })), 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." })), 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." })), + 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." })), + 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.' })), }); export type SubagentInput = Static; @@ -34,6 +39,10 @@ export interface SubagentToolDeps { lifecycleRegistry: Map; lifecycleRuns: Map; lifecycleDeps: Omit; + /** SPEC-5a: async/bg runtime deps. Present when the extension wires the operational runtime. */ + asyncRunner?: AsyncRunnerDeps; + /** SPEC-5a: scheduler. Present when the extension wires scheduling. */ + scheduler?: Scheduler; } /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */ @@ -50,6 +59,21 @@ export function createSubagentTool(deps: SubagentToolDeps) { ], parameters: subagentParams, async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, ctx: any) { + // SPEC-5a: background + schedule routing (Q1/Q2/Q5). + if (params.background && params.schedule) { + return { isError: true, content: [{ type: "text" as const, text: "A scheduled run is inherently background — pass only one of `background` or `schedule`, not both." }] }; + } + if (params.schedule) { + if (!deps.scheduler) return { isError: true, content: [{ type: "text" as const, text: "scheduling not configured (scheduler missing)" }] }; + const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true }); + const entry = deps.scheduler.list().find((s) => s.id === id); + return { content: [{ type: "text" as const, text: `scheduled: ${id} · next fire: ${entry?.nextFire?.toISOString() ?? "(paused)"}` }], details: { scheduleId: id, nextFire: entry?.nextFire ?? null } }; + } + if (params.background) { + if (!deps.asyncRunner) return { isError: true, content: [{ type: "text" as const, text: "background runs not configured (asyncRunner missing)" }] }; + const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto" }); + return { content: [{ type: "text" as const, text: `background run: ${handle.runId}` }], details: handle }; + } if (params.lifecycle) { const { runLifecycle } = await import("../lifecycle/run-lifecycle.ts"); const lifecycleFullDeps: LifecycleRunDeps = { diff --git a/test/subagent-spec5a.test.mts b/test/subagent-spec5a.test.mts new file mode 100644 index 0000000..7da72a9 --- /dev/null +++ b/test/subagent-spec5a.test.mts @@ -0,0 +1,96 @@ +// test/subagent-spec5a.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { createSubagentTool, type SubagentToolDeps } from "../src/tools/subagent.ts"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; +import { DiffService } from "../src/worktree/diff-service.ts"; +import { RunJournal } from "../src/runtime/run-journal.ts"; +import { ConcurrencyPool } from "../src/runtime/concurrency-pool.ts"; +import { ResultsInbox } from "../src/runtime/results-inbox.ts"; +import { Scheduler } from "../src/scheduling/scheduler.ts"; + +function sh(cmd: string, cwd: string): string { return execSync(cmd, { cwd, encoding: "utf8" }).trim(); } + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "tool-test-")); + sh("git init -b main", dir); + sh('git config user.email "t@t.test"', dir); + sh('git config user.name "test"', dir); + writeFileSync(join(dir, "base.txt"), "base\n"); + sh("git add base.txt && git commit -m base", dir); + return dir; +} + +// Build a minimal SubagentToolDeps with only the SPEC-5a surfaces (asyncRunner + scheduler). +// The background/schedule routing paths don't touch the foreground deps. +function makeDeps(repo: string): { deps: SubagentToolDeps; scheduler: Scheduler } { + const scheduler = new Scheduler({ + storePath: join(repo, ".pi", "fleet", "schedules.json"), + lockPath: join(repo, ".pi", "fleet", "schedules.lock"), + onFire: () => {}, + }); + const asyncRunner = { + worktree: new WorktreeService({ rootDir: repo }), + diff: new DiffService(), + journal: new RunJournal(join(repo, ".pi", "fleet", "runs")), + pool: new ConcurrencyPool(2), + inbox: new ResultsInbox(), + runLifecycle: async (_task: string, lifecycleName: string, opts: { runId: string; worktreePath: string; branch: string; mode: "auto" | "checkpointed" }) => ({ + runId: opts.runId, lifecycleName, task: _task, backend: "pi", mode: "auto", status: "completed" as const, + phases: [{ name: "brainstorm", status: "completed", summary: "s", paths: [], reviseCount: 0 }], + startedAt: 1, endedAt: 2, todoId: "td-x", + }), + notify: () => {}, + genRunId: () => "fl-tool-" + Math.random().toString(36).slice(2, 6), + }; + // The foreground deps are unused by the background/schedule paths; cast a minimal stub. + const deps = { + registry: new Map(), + runRegistry: { add: () => {}, get: () => undefined, update: () => {}, all: () => [] } as any, + lock: { acquire: () => true, release: () => {} } as any, + todoSync: {} as any, + backendRegistry: {} as any, + parentModel: { provider: "Ollama", id: "glm-5.2:cloud" }, + parentCwd: repo, + lifecycleRegistry: new Map(), + lifecycleRuns: new Map(), + lifecycleDeps: {} as any, + asyncRunner, + scheduler, + } as unknown as SubagentToolDeps; + return { deps, scheduler }; +} + +test("background:true returns { runId, status: 'background' } without awaiting", async () => { + const repo = makeRepo(); + const { deps } = makeDeps(repo); + const tool = createSubagentTool(deps); + const res: any = await tool.execute("callid", { agent: "general-purpose", task: "t", background: true } as any, new AbortController().signal, undefined, {}); + assert.equal(res.details.status, "background"); + assert.ok(res.details.runId.startsWith("fl-tool-")); + rmSync(repo, { recursive: true, force: true }); +}); + +test("schedule:'30m' returns { scheduleId, nextFire }", async () => { + const repo = makeRepo(); + const { deps } = makeDeps(repo); + const tool = createSubagentTool(deps); + const res: any = await tool.execute("callid", { agent: "general-purpose", task: "t", schedule: "30m" } as any, new AbortController().signal, undefined, {}); + assert.ok(res.details.scheduleId.startsWith("sch-")); + assert.ok(res.details.nextFire instanceof Date); + rmSync(repo, { recursive: true, force: true }); +}); + +test("background + schedule together → actionable error", async () => { + const repo = makeRepo(); + const { deps } = makeDeps(repo); + const tool = createSubagentTool(deps); + const res: any = await tool.execute("callid", { agent: "general-purpose", task: "t", background: true, schedule: "30m" } as any, new AbortController().signal, undefined, {}); + assert.equal(res.isError, true); + assert.match(res.content[0].text, /pass only one|inherently background/); + rmSync(repo, { recursive: true, force: true }); +}); \ No newline at end of file From dc9323b5dba2b444b8e3ae3afe3aa9ec10120426 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:16:00 +0700 Subject: [PATCH 13/18] =?UTF-8?q?feat(spec-5a):=20fleet.results=20tool=20?= =?UTF-8?q?=E2=80=94=20pull=20completed=20bg-run=20results=20from=20the=20?= =?UTF-8?q?inbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tools/fleet-results.ts | 35 +++++++++++++++++++++++++++++++++++ test/fleet-results.test.mts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/tools/fleet-results.ts create mode 100644 test/fleet-results.test.mts diff --git a/src/tools/fleet-results.ts b/src/tools/fleet-results.ts new file mode 100644 index 0000000..d9910c1 --- /dev/null +++ b/src/tools/fleet-results.ts @@ -0,0 +1,35 @@ +// src/tools/fleet-results.ts +// SPEC-5a §10/§12.2 — the agent pulls completed bg-run results from the inbox (Q6=C). +import { Type, type Static } from "typebox"; +import type { ResultsInbox } from "../runtime/results-inbox.ts"; + +export const fleetResultsParams = Type.Object({ + runId: Type.Optional(Type.String({ description: "Pull a specific run's result. Omit to pull all ready (undelivered) results." })), +}); + +export type FleetResultsInput = Static; + +export interface FleetResultsToolDeps { + inbox: ResultsInbox; +} + +export function createFleetResultsTool(deps: FleetResultsToolDeps) { + return { + name: "fleet_results", + label: "Fleet results", + 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.", + promptSnippet: "Pull completed background fleet-run results", + promptGuidelines: [ + "Use fleet_results to pull completed background runs when the 'N fleet results ready' hint appears.", + "Without a runId, returns all ready results and marks them delivered.", + ], + parameters: fleetResultsParams, + async execute(_toolCallId: string, input: FleetResultsInput) { + const results = deps.inbox.pull(input.runId); + return { + content: [{ type: "text" as const, text: results.length === 0 ? "no results ready" : results.map((r) => `${r.runId}: ${r.status} — ${r.summary}`).join("\n") }], + details: { results }, + }; + }, + }; +} \ No newline at end of file diff --git a/test/fleet-results.test.mts b/test/fleet-results.test.mts new file mode 100644 index 0000000..72b50b7 --- /dev/null +++ b/test/fleet-results.test.mts @@ -0,0 +1,32 @@ +// test/fleet-results.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createFleetResultsTool } from "../src/tools/fleet-results.ts"; +import { ResultsInbox } from "../src/runtime/results-inbox.ts"; + +test("fleet.results() with no arg returns all ready + marks delivered", async () => { + const inbox = new ResultsInbox(); + inbox.push({ runId: "fl-1", task: "t1", status: "completed", summary: "s", paths: [], branch: "fleet/fl-1", completedAt: 1 }); + inbox.push({ runId: "fl-2", task: "t2", status: "completed", summary: "s", paths: [], branch: "fleet/fl-2", completedAt: 2 }); + const tool = createFleetResultsTool({ inbox }); + const res: any = await tool.execute("callid", {}); + assert.equal(res.details.results.length, 2); + assert.equal(inbox.readyCount(), 0); +}); + +test("fleet.results({ runId }) returns that result + marks delivered", async () => { + const inbox = new ResultsInbox(); + inbox.push({ runId: "fl-3", task: "t3", status: "completed", summary: "s", paths: ["a.md"], branch: "fleet/fl-3", completedAt: 3 }); + const tool = createFleetResultsTool({ inbox }); + const res: any = await tool.execute("callid", { runId: "fl-3" }); + assert.equal(res.details.results.length, 1); + assert.equal(res.details.results[0]!.runId, "fl-3"); + assert.equal(inbox.readyCount(), 0); +}); + +test("fleet.results() returns empty array when nothing ready", async () => { + const inbox = new ResultsInbox(); + const tool = createFleetResultsTool({ inbox }); + const res: any = await tool.execute("callid", {}); + assert.equal(res.details.results.length, 0); +}); \ No newline at end of file From 17b34a5b5f05c30ec2e27ded99e3e557c68e5a36 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:19:09 +0700 Subject: [PATCH 14/18] =?UTF-8?q?feat(spec-5a):=20/fleet=20scheduled=20tab?= =?UTF-8?q?=20+=20bg=20row=20status=20icons=20(=E2=96=B6=20=E2=8F=B8=20?= =?UTF-8?q?=E2=9C=93=20=E2=9C=97=20=E2=8F=B3=20=E2=97=8Fphase=20n/total)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/panel/fleet-panel.ts | 136 +++++++++++++++++++++++++++++++++++-- src/panel/rows.ts | 56 +++++++++++++++ test/panel-spec5a.test.mts | 49 +++++++++++++ 3 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 test/panel-spec5a.test.mts diff --git a/src/panel/fleet-panel.ts b/src/panel/fleet-panel.ts index 3a468a3..d33ab72 100644 --- a/src/panel/fleet-panel.ts +++ b/src/panel/fleet-panel.ts @@ -11,7 +11,9 @@ import { } from "@earendil-works/pi-tui"; import type { AgentDef } from "../registry/frontmatter.ts"; import type { RunRecord } from "../engine/run-registry.ts"; -import { fleetRow, agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline } from "./rows.ts"; +import { fleetRow, agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline, scheduleRow } from "./rows.ts"; +import type { Scheduler, Schedule } from "../scheduling/scheduler.ts"; +import type { BgRunStatus } from "./rows.ts"; import { spawnSubagent, type SpawnResult } from "../engine/spawnSubagent.ts"; import type { Backend, BackendRegistry } from "../backend/port.ts"; import type { RunRegistry } from "../engine/run-registry.ts"; @@ -21,7 +23,7 @@ import type { LifecycleDef, LifecycleRunRecord, CheckpointDecision, PhaseRecord import type { LifecycleRunDeps, CheckpointFn } from "../lifecycle/run-lifecycle.ts"; import { runLifecycle } from "../lifecycle/run-lifecycle.ts"; -type View = "fleet" | "lifecycle" | "agents" | "backends"; +type View = "fleet" | "lifecycle" | "agents" | "backends" | "scheduled"; export interface FleetPanelDeps { registry: Map; @@ -35,6 +37,10 @@ export interface FleetPanelDeps { lifecycleRegistry: Map; lifecycleRuns: Map; lifecycleDeps: Omit; + /** SPEC-5a: scheduler for the scheduled tab. Optional — panel degrades to an empty list when absent. */ + scheduler?: Scheduler; + /** SPEC-5a: live bg run status rows for the fleet tab. Optional. */ + bgRuns?: Map; } export interface FleetPanelOpts { @@ -67,6 +73,13 @@ export class FleetPanel extends Container { private pendingCheckpoint: { phase: PhaseRecord; resolve: (d: CheckpointDecision) => void } | null = null; private lcReviseInput: Input | null = null; private lcRevising = false; + // SPEC-5a: scheduled tab — add-schedule inline input state + selected schedule for i:Info + private schedRunMode = false; + private schedTaskInput: Input | null = null; + private schedExprInput: Input | null = null; + private schedNameInput: Input | null = null; + private schedPhase: "task" | "expr" | "name" = "task"; + private selectedSchedule: Schedule | null = null; constructor(opts: FleetPanelOpts) { super(); @@ -90,7 +103,9 @@ export class FleetPanel extends Container { ? [...this.deps.lifecycleRuns.values()].map((l: LifecycleRunRecord) => ({ value: l.runId, label: lifecycleRow(l) })) : this.view === "agents" ? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) })) - : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) })); + : this.view === "scheduled" + ? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) })) + : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) })); const fresh = new SelectList(items, 12, { selectedPrefix: (s: string) => this.theme.fg("accent", s), selectedText: (s: string) => this.theme.fg("accent", s), @@ -108,7 +123,7 @@ export class FleetPanel extends Container { this.children.length = 0; this.children.push(...keep); const accent = (s: string): string => this.theme.fg("accent", s); - const tabs = (["fleet", "lifecycle", "agents", "backends"] as View[]) + const tabs = (["fleet", "lifecycle", "agents", "backends", "scheduled"] as View[]) .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v))) .join(" "); this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0)); @@ -138,6 +153,24 @@ export class FleetPanel extends Container { this.addChild(new Text(this.theme.fg("text", line), 0, 0)); } this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0)); + } else if (this.selectedSchedule) { + // SPEC-5a: i:Info detail pane (scheduled view) + this.addChild(new Text(this.theme.fg("dim", " ── schedule info ──"), 0, 0)); + const s = this.selectedSchedule; + for (const line of [ + `id: ${s.id}`, + `expression: ${s.expression}`, + `lifecycle: ${s.lifecycle}`, + `task: "${s.task}"`, + `paused: ${s.paused}`, + `nextFire: ${s.nextFire?.toLocaleString() ?? "(none)"}`, + ]) this.addChild(new Text(this.theme.fg("text", line), 0, 0)); + this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0)); + } else if (this.schedRunMode && (this.schedTaskInput || this.schedExprInput || this.schedNameInput)) { + const prompt = this.schedPhase === "task" ? " task> " : this.schedPhase === "expr" ? " schedule (cron | interval | one-shot ISO)> " : " lifecycle (blank=default)> "; + this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0)); + this.addChild(this.schedPhase === "task" ? this.schedTaskInput! : this.schedPhase === "expr" ? this.schedExprInput! : this.schedNameInput!); + this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0)); } else if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) { const prompt = this.lcPhase === "task" ? " task> " : " lifecycle name (blank=default)> "; this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0)); @@ -158,7 +191,7 @@ export class FleetPanel extends Container { this.addChild(new Spacer(1)); const hint = - this.infoAgent || this.selectedBackend || this.selectedLifecycle + this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule ? " esc:Back" : this.pendingCheckpoint ? " c:Continue v:Revise a:Abort" @@ -170,7 +203,9 @@ export class FleetPanel extends Container { ? " r:Run-lifecycle i:Info tab:Agents q:Quit" : this.view === "agents" ? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit" - : " r:Refresh i:Info tab:Fleet q:Quit"; + : this.view === "scheduled" + ? " a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit" + : " r:Refresh i:Info tab:Fleet q:Quit"; this.addChild(new Text(this.theme.fg("dim", hint), 0, 0)); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder(accent)); @@ -238,9 +273,11 @@ export class FleetPanel extends Container { private switchView(): void { this.view = this.view === "fleet" ? "lifecycle" : this.view === "lifecycle" ? "agents" - : this.view === "agents" ? "backends" : "fleet"; + : this.view === "agents" ? "backends" + : this.view === "backends" ? "scheduled" : "fleet"; this.selectedBackend = null; this.selectedLifecycle = null; + this.selectedSchedule = null; this.list = this.buildList(); this.renderShell(); } @@ -258,6 +295,16 @@ export class FleetPanel extends Container { if (matchesKey(data, "escape")) { this.selectedLifecycle = null; this.renderShell(); } return; } + if (this.selectedSchedule) { + if (matchesKey(data, "escape")) { this.selectedSchedule = null; this.renderShell(); } + return; + } + if (this.schedRunMode && (this.schedTaskInput || this.schedExprInput || this.schedNameInput)) { + if (matchesKey(data, "escape")) { this.cancelScheduleAdd(); return; } + (this.schedPhase === "task" ? this.schedTaskInput! : this.schedPhase === "expr" ? this.schedExprInput! : this.schedNameInput!).handleInput(data); + this.invalidate(); + return; + } if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) { if (matchesKey(data, "escape")) { this.cancelLifecycleRun(); return; } (this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!).handleInput(data); @@ -312,6 +359,28 @@ export class FleetPanel extends Container { this.startLifecycleRun(); return; } + // SPEC-5a: scheduled view — a:Add p:Pause/resume d:Delete i:Info + if (this.view === "scheduled" && this.deps.scheduler) { + if (matchesKey(data, "a")) { this.startScheduleAdd(); return; } + if (matchesKey(data, "i")) { + const sel = this.list.getSelectedItem(); + if (sel) { this.selectedSchedule = this.deps.scheduler.list().find((x) => x.id === sel.value) ?? null; this.renderShell(); } + return; + } + if (matchesKey(data, "p")) { + const sel = this.list.getSelectedItem(); + if (sel) { + const s = this.deps.scheduler.list().find((x) => x.id === sel.value); + if (s) { s.paused ? this.deps.scheduler.resume(sel.value) : this.deps.scheduler.pause(sel.value); this.list = this.buildList(); this.renderShell(); } + } + return; + } + if (matchesKey(data, "d")) { + const sel = this.list.getSelectedItem(); + if (sel) { this.deps.scheduler.delete(sel.value); this.list = this.buildList(); this.renderShell(); } + return; + } + } // SPEC-4: pending checkpoint keys (c/v/a) if (this.pendingCheckpoint && !this.lcRevising) { if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; } @@ -341,6 +410,59 @@ export class FleetPanel extends Container { this.invalidate(); } + /** SPEC-5a: open the Add-schedule inline inputs (task → expression → lifecycle name → register). */ + private startScheduleAdd(): void { + this.schedPhase = "task"; + this.schedTaskInput = new Input(); + this.schedTaskInput.onSubmit = (task: string) => { + if (!task.trim()) { this.cancelScheduleAdd(); return; } + this.schedPhase = "expr"; + this.schedExprInput = new Input(); + this.schedExprInput.onSubmit = (expr: string) => { + if (!expr.trim()) { this.cancelScheduleAdd(); return; } + this.schedPhase = "name"; + this.schedNameInput = new Input(); + this.schedNameInput.onSubmit = (name: string) => { + const lcName = name.trim() || "default"; + this.executeScheduleAdd(task.trim(), expr.trim(), lcName); + }; + this.schedNameInput.onEscape = () => { this.executeScheduleAdd(task.trim(), expr.trim(), "default"); }; + this.renderShell(); + }; + this.schedExprInput.onEscape = () => this.cancelScheduleAdd(); + this.renderShell(); + }; + this.schedTaskInput.onEscape = () => this.cancelScheduleAdd(); + this.schedRunMode = true; + this.renderShell(); + } + + private cancelScheduleAdd(): void { + this.schedRunMode = false; + this.schedTaskInput = null; + this.schedExprInput = null; + this.schedNameInput = null; + this.renderShell(); + } + + private executeScheduleAdd(task: string, expression: string, lifecycleName: string): void { + this.schedRunMode = false; + this.schedTaskInput = null; + this.schedExprInput = null; + this.schedNameInput = null; + if (!this.deps.scheduler) { this.onNotify("scheduling not configured", "error"); this.renderShell(); return; } + try { + const id = this.deps.scheduler.register({ task, expression, lifecycle: lifecycleName, auto: true }); + this.list = this.buildList(); + this.renderShell(); + const entry = this.deps.scheduler.list().find((s) => s.id === id); + this.onNotify(`scheduled: ${id} · next fire: ${entry?.nextFire?.toLocaleString() ?? "(paused)"}`, "info"); + } catch (e) { + this.onNotify(`schedule register failed: ${(e as Error).message}`, "error"); + } + this.renderShell(); + } + /** SPEC-4: open the Run-lifecycle inline inputs (task → lifecycle name → start runLifecycle). */ private startLifecycleRun(): void { this.lcPhase = "task"; diff --git a/src/panel/rows.ts b/src/panel/rows.ts index cde12c7..1fac05b 100644 --- a/src/panel/rows.ts +++ b/src/panel/rows.ts @@ -85,6 +85,62 @@ export function backendInfo(b: Backend): string { import type { LifecycleRunRecord, LifecycleStatus } from "../lifecycle/lifecycle-types.ts"; + +// SPEC-5a §11 — bg run row status (Q8=A). The fleet tab gains live status icons + phase progress +// for async/bg runs; foreground rows are unchanged. +export type BgStatus = "running" | "paused" | "completed" | "failed" | "queued"; + +export interface BgRunStatus { + runId: string; + lifecycle: string; + status: BgStatus; + phase: string; + phaseIndex: number; + phaseTotal: number; + mode: "auto" | "checkpointed"; + backend: string; + task: string; + branch?: string; + elapsedMs?: number; +} + +export function bgStatusIcon(s: BgStatus): string { + switch (s) { + case "running": return "▶"; + case "paused": return "⏸"; + case "completed": return "✓"; + case "failed": return "✗"; + case "queued": return "⏳"; + } +} + +export function renderBgRow(r: BgRunStatus): string { + const icon = bgStatusIcon(r.status); + const phase = r.phase ? `●${r.phase} ${r.phaseIndex}/${r.phaseTotal}` : `${r.phaseIndex}/${r.phaseTotal}`; + const branch = r.branch ? ` ${r.branch}` : ""; + const elapsed = r.elapsedMs ? ` ${fmtDuration(r.elapsedMs)}` : ""; + const task = r.task.length > 30 ? r.task.slice(0, 29) + "…" : r.task; + return `${icon} ${r.runId} ${r.lifecycle} ${phase} ${r.mode}${elapsed} ${r.backend}${branch} "${task}"`; +} + +// SPEC-5a §11 — scheduled tab row rendering. +export interface ScheduleRow { + id: string; + expression: string; + lifecycle?: string; + task: string; + nextFire: Date | null; + paused: boolean; +} + +export function scheduleRow(s: ScheduleRow): string { + const icon = s.paused ? "⏸" : "▶"; + const next = s.nextFire ? `next: ${s.nextFire.toLocaleString()}` : "paused"; + const task = s.task.length > 24 ? s.task.slice(0, 23) + "…" : s.task; + const lc = s.lifecycle ?? "default"; + return `${icon} ${s.expression} ${lc} "${task}" ${next} ${s.id}`; +} + const LC_GLYPH: Record = { running: "▶", checkpoint: "⏸", completed: "✓", failed: "✗", aborted: "✗", }; diff --git a/test/panel-spec5a.test.mts b/test/panel-spec5a.test.mts new file mode 100644 index 0000000..bb181c7 --- /dev/null +++ b/test/panel-spec5a.test.mts @@ -0,0 +1,49 @@ +// test/panel-spec5a.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderBgRow, bgStatusIcon, scheduleRow, type BgRunStatus } from "../src/panel/rows.ts"; + +test("bgStatusIcon maps statuses to icons", () => { + assert.equal(bgStatusIcon("running"), "▶"); + assert.equal(bgStatusIcon("paused"), "⏸"); + assert.equal(bgStatusIcon("completed"), "✓"); + assert.equal(bgStatusIcon("failed"), "✗"); + assert.equal(bgStatusIcon("queued"), "⏳"); +}); + +test("renderBgRow includes icon + phase progress for a running lifecycle", () => { + const row: BgRunStatus = { + runId: "fl-x", lifecycle: "default", status: "running", phase: "implement", phaseIndex: 3, phaseTotal: 5, mode: "checkpointed", backend: "pi", task: "add hello", + }; + const line = renderBgRow(row); + assert.match(line, /▶/); + assert.match(line, /●implement 3\/5/); + assert.match(line, /fl-x/); +}); + +test("renderBgRow shows ✓ + branch for a completed run", () => { + const row: BgRunStatus = { runId: "fl-y", lifecycle: "default", status: "completed", phase: "finish", phaseIndex: 5, phaseTotal: 5, mode: "checkpointed", backend: "pi", task: "t", branch: "fleet/fl-y" }; + const line = renderBgRow(row); + assert.match(line, /✓/); + assert.match(line, /fleet\/fl-y/); +}); + +test("renderBgRow shows ⏳ for a queued run with 0/total progress", () => { + const row: BgRunStatus = { runId: "fl-z", lifecycle: "default", status: "queued", phase: "", phaseIndex: 0, phaseTotal: 5, mode: "auto", backend: "pi", task: "t" }; + const line = renderBgRow(row); + assert.match(line, /⏳/); + assert.match(line, /0\/5/); +}); + +test("scheduleRow renders expression + next-fire + id", () => { + const line = scheduleRow({ id: "sch-abc", expression: "30m", lifecycle: "default", task: "refresh cache", nextFire: new Date("2026-07-25T10:00:00Z"), paused: false }); + assert.match(line, /▶/); + assert.match(line, /30m/); + assert.match(line, /sch-abc/); +}); + +test("scheduleRow renders ⏸ + 'paused' for a paused schedule", () => { + const line = scheduleRow({ id: "sch-p", expression: "2h", lifecycle: "default", task: "x", nextFire: null, paused: true }); + assert.match(line, /⏸/); + assert.match(line, /paused/); +}); \ No newline at end of file From 9ae42a542c506d88771af48bab634414a1874a78 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:21:07 +0700 Subject: [PATCH 15/18] =?UTF-8?q?feat(spec-5a):=20index=20wiring=20?= =?UTF-8?q?=E2=80=94=20async=20runner=20+=20scheduler=20+=20resume-on-init?= =?UTF-8?q?=20+=20fleet.results=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 64 ++++++++++++++++++++++++++++++++++++++ src/tools/subagent.ts | 2 ++ test/index-spec5a.test.mts | 23 ++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 test/index-spec5a.test.mts diff --git a/src/index.ts b/src/index.ts index 69f68e8..9ff1042 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,16 @@ import { discoverLifecycles } from "./lifecycle/registry.ts"; import { DEFAULT_LIFECYCLE, builtinLifecyclesDir } from "./lifecycle/default.ts"; import type { LifecycleDef } from "./lifecycle/lifecycle-types.ts"; import type { LifecycleRunDeps } from "./lifecycle/run-lifecycle.ts"; +import { WorktreeService } from "./worktree/worktree-service.ts"; +import { DiffService } from "./worktree/diff-service.ts"; +import { RunJournal } from "./runtime/run-journal.ts"; +import { ConcurrencyPool } from "./runtime/concurrency-pool.ts"; +import { ResultsInbox } from "./runtime/results-inbox.ts"; +import { runBackground, type AsyncRunnerDeps } from "./runtime/async-runner.ts"; +import { scanResumeCandidates } from "./runtime/resume.ts"; +import { Scheduler } from "./scheduling/scheduler.ts"; +import { createFleetResultsTool } from "./tools/fleet-results.ts"; +import type { BgRunStatus } from "./panel/rows.ts"; /** The package builtin agents/ dir, resolved relative to this module. */ function builtinAgentsDir(): string { @@ -151,6 +161,33 @@ export default async function (pi: ExtensionAPI): Promise { deps.lifecycleDeps.registry = deps.lifecycleRegistry; deps.lifecycleDeps.agentRegistry = deps.registry; + // ── SPEC-5a: operational runtime (async/bg + scheduling + worktree isolation) ── + const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet"); + const bgRuns = new Map(); + const resultsInbox = new ResultsInbox(); + // The async runner's runLifecycle adapter: call the real runLifecycle with the worktree as the + // spawn cwd + override genRunId so the lifecycle runId IS the async runner's runId (Q1=B seam). + const asyncRunLifecycle: AsyncRunnerDeps["runLifecycle"] = async (task, lifecycleName, opts) => { + const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts"); + const { spawnSubagent } = await import("./engine/spawnSubagent.ts"); + const lifecycleFullDeps: LifecycleRunDeps = { + ...deps.lifecycleDeps, + genRunId: () => opts.runId, // override: use the async runner's runId + spawn: async (o) => spawnSubagent({ + agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model, + skillsOverride: o.skills, backendOverride: o.backend, + registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock, + backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, // child runs in the worktree + }), + }; + const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } }); + return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult; + }; + // asyncRunnerDeps + scheduler are built per-session (need the session cwd); wired on session_start. + // At init they're undefined — the subagent tool's `if (!deps.asyncRunner)` guard returns an + // actionable "not configured" error if called before session_start (can't happen in practice). + deps.bgRuns = bgRuns; + const refresh = (ctx: { cwd: string; ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): void => { const r = discoverAgents({ projectDir: join(ctx.cwd, ".pi", "agents"), @@ -184,6 +221,31 @@ export default async function (pi: ExtensionAPI): Promise { const m = ctx.model; deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" }; deps.parentCwd = ctx.cwd; + // SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs. + const dir = fleetDir(ctx.cwd); + deps.asyncRunner = { + worktree: new WorktreeService({ rootDir: ctx.cwd }), + diff: new DiffService(), + journal: new RunJournal(join(dir, "runs")), + pool: new ConcurrencyPool(3), + inbox: resultsInbox, + runLifecycle: asyncRunLifecycle, + notify: (m, lvl) => ctx.ui.notify(m, lvl), + genRunId: () => "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8), + }; + deps.scheduler = new Scheduler({ + storePath: join(dir, "schedules.json"), + lockPath: join(dir, "schedules.lock"), + onFire: (spec) => { + if (!deps.asyncRunner) return; + runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed" }); + }, + }); + deps.scheduler.start(); + const cands = scanResumeCandidates(ctx.cwd, { runsDir: join(dir, "runs"), worktree: deps.asyncRunner.worktree }); + if (cands.length > 0) { + ctx.ui.notify(`${cands.length} interrupted fleet run${cands.length > 1 ? "s" : ""} — open /fleet to resume`, "info"); + } }); pi.on("resources_discover", (event, ctx) => { @@ -192,6 +254,8 @@ export default async function (pi: ExtensionAPI): Promise { }); pi.registerTool(createSubagentTool(deps) as never); + // SPEC-5a: fleet.results — the agent pulls completed bg-run results from the inbox (Q6=C). + pi.registerTool(createFleetResultsTool({ inbox: resultsInbox }) as never); pi.registerCommand("fleet", { description: "Open the armory-fleet panel (running + recent subagents + agent registry).", diff --git a/src/tools/subagent.ts b/src/tools/subagent.ts index 7580afd..ba1211f 100644 --- a/src/tools/subagent.ts +++ b/src/tools/subagent.ts @@ -43,6 +43,8 @@ export interface SubagentToolDeps { asyncRunner?: AsyncRunnerDeps; /** SPEC-5a: scheduler. Present when the extension wires scheduling. */ scheduler?: Scheduler; + /** SPEC-5a: live bg run status rows for the /fleet panel. Optional. */ + bgRuns?: Map; } /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */ diff --git a/test/index-spec5a.test.mts b/test/index-spec5a.test.mts new file mode 100644 index 0000000..c4cff0f --- /dev/null +++ b/test/index-spec5a.test.mts @@ -0,0 +1,23 @@ +// test/index-spec5a.test.mts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { scanResumeCandidates } from "../src/runtime/resume.ts"; +import { createFleetResultsTool } from "../src/tools/fleet-results.ts"; +import { ResultsInbox } from "../src/runtime/results-inbox.ts"; + +test("the SPEC-5a runtime surface is re-exported (smoke — full init needs a pi context)", () => { + assert.equal(typeof scanResumeCandidates, "function"); + assert.equal(typeof createFleetResultsTool, "function"); +}); + +test("the fleet.results tool wires against an inbox", () => { + const inbox = new ResultsInbox(); + const tool = createFleetResultsTool({ inbox }); + assert.equal(tool.name, "fleet_results"); +}); + +test("index.ts loads without syntax error (import smoke)", async () => { + // Dynamic import exercises the module graph including the SPEC-5a wiring. + const mod = await import("../src/index.ts"); + assert.equal(typeof mod.default, "function"); +}); \ No newline at end of file From 2188a744d66865f6ce6e84b09192ede644abcacb Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:22:10 +0700 Subject: [PATCH 16/18] feat(spec-5a): end-to-end smoke script + term-driven TUI smoke checklist --- docs/SPEC-5a-smoke-checklist.md | 46 ++++++++++++ scripts/spec-5a-smoke.mts | 119 ++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 docs/SPEC-5a-smoke-checklist.md create mode 100644 scripts/spec-5a-smoke.mts diff --git a/docs/SPEC-5a-smoke-checklist.md b/docs/SPEC-5a-smoke-checklist.md new file mode 100644 index 0000000..874477e --- /dev/null +++ b/docs/SPEC-5a-smoke-checklist.md @@ -0,0 +1,46 @@ +# SPEC-5a — term-driven TUI smoke checklist + +> Run after installing `@getpipher/armory-fleet@0.5.0` into pi (`~/.pi/agent/settings.json` packages), `/reload` pi. + +## Setup +- [ ] `~/.pi/agent/settings.json` `packages` includes `npm:@getpipher/armory-fleet@0.5.0` (+ `armory-todo@0.5.4`). +- [ ] `/reload` pi — `[Extensions]` shows `@getpipher/armory-fleet@0.5.0:src` with no load error. +- [ ] Ollama key present in `~/.pi/agent/auth.json` (pi loads automatically — no env var). +- [ ] (Optional) RECTOR re-auths `claude` first to exercise a per-phase `backend: claude` scheduled lifecycle. + +## `/fleet` panel — scheduled tab +- [ ] `/fleet` opens → tabs render: `fleet · lifecycle · agents · backends · scheduled`. +- [ ] `tab` to `scheduled` → empty list renders + footer `a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit`. +- [ ] `a:Add` → inline Input: `task>` → type a trivial task → enter → `schedule (cron | interval | one-shot ISO)>` → type `5s` → enter → `lifecycle (blank=default)>` → enter → row appears with `▶ 5s default "…" next: sch-…`. +- [ ] `i:Info` on the row → schedule detail pane (id, expression, lifecycle, task, paused, nextFire) + `esc:Back`. +- [ ] `p:Pause/resume` on the row → row toggles to `⏸` + `paused`; `p` again → back to `▶`. +- [ ] `d:Delete` on the row → row removed. + +## `/fleet` panel — fleet tab bg row status +- [ ] Wait for the `5s` schedule to fire (if not deleted) → a bg run row appears in the `fleet` tab with `▶` + `● n/5` + `checkpointed` + `pi`. +- [ ] `i:Info` on the bg row → phase timeline (reads the journal). +- [ ] On completion → row becomes `✓` + branch `fleet/fl-…`; `fleet_results()` returns it; a pi notify fires "fleet run … completed". + +## `/fleet-schedule` slash +- [ ] `/fleet-schedule 30m --lifecycle default` → prints `scheduled: sch-… · next fire: `. + +## `subagent` tool (agent path) +- [ ] The agent calls `subagent({ agent, task, background: true, lifecycle: "default" })` → returns `{ runId, status: "background" }` immediately (no await). +- [ ] The agent calls `subagent({ agent, task, schedule: "1h" })` → returns `{ scheduleId, nextFire }`. +- [ ] `background + schedule` together → actionable error. +- [ ] `fleet_results({})` → returns ready completed-run summaries; pulling marks delivered. + +## Resume +- [ ] Kill pi mid-lifecycle (Ctrl+C while a bg run is at `▶ implement 3/5`). +- [ ] Reopen pi in the same project → notify "1 interrupted fleet run — open /fleet to resume". +- [ ] `/fleet` → `lifecycle` tab shows the interrupted run; the journal under `.pi/fleet/runs/` has no terminal event. + +## PID-lock +- [ ] Open a second pi session in the same project → schedules don't double-fire (the second session defers; `.pi/fleet/schedules.lock` holds the first session's PID). + +## Manual end-to-end (optional, real Ollama) +- [ ] `node --import tsx scripts/spec-5a-smoke.mts` → `SMOKE PASSED ✅` (worktree created, lifecycle ran, journal recorded, inbox received, one-shot schedule fired once + auto-deleted). Safe to run from the repo cwd — the worktree is the isolation. + +## Guards +- [ ] Invalid cron expression at `a:Add` → actionable error (resolve-time, not fire-time). +- [ ] `background` runs cap at `fleet.maxConcurrentBg` (default 3); a 4th bg run queues (`⏳` in fleet tab). \ No newline at end of file diff --git a/scripts/spec-5a-smoke.mts b/scripts/spec-5a-smoke.mts new file mode 100644 index 0000000..7a94157 --- /dev/null +++ b/scripts/spec-5a-smoke.mts @@ -0,0 +1,119 @@ +// scripts/spec-5a-smoke.mts — SPEC-5a end-to-end operational-runtime smoke +// Run: node --import tsx scripts/spec-5a-smoke.mts +// +// Verifies the full SPEC-5a path on REAL Ollama Cloud pi phases in an isolated temp git repo: +// 1. runBackground fires a trivial isolated lifecycle → worktree created → phases run → +// worktree-diff discovers artifacts → journal records events → inbox receives result → notify. +// 2. A one-shot schedule fires + auto-deletes. +// The worktree IS the isolation (no repo pollution, unlike the SPEC-4 smoke's temp-cwd workaround). +// Requires a configured Ollama Cloud model + ~/.pi/agent/auth.json (pi loads it automatically). +// NOT part of the CI gate; run manually before tagging v0.5.0. +import { runLifecycle } from "../src/lifecycle/run-lifecycle.ts"; +import { DEFAULT_LIFECYCLE } from "../src/lifecycle/default.ts"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { ArmoryTodoAdapter } from "../src/todo-sync/adapter.ts"; +import { ArmoryMemoryAdapter } from "../src/memory-hydrate/adapter.ts"; +import { RunRegistry } from "../src/engine/run-registry.ts"; +import { createSingleSlotLock } from "../src/engine/concurrency-lock.ts"; +import { discoverAgents } from "../src/registry/discovery.ts"; +import { createChildSessionFactory } from "../src/index.ts"; +import { BackendRegistry, PI_HOOK_PARITY } from "../src/backend/port.ts"; +import { ResumeStore } from "../src/backend/resume-store.ts"; +import { spawnSubagent } from "../src/engine/spawnSubagent.ts"; +import { WorktreeService } from "../src/worktree/worktree-service.ts"; +import { DiffService } from "../src/worktree/diff-service.ts"; +import { RunJournal } from "../src/runtime/run-journal.ts"; +import { ConcurrencyPool } from "../src/runtime/concurrency-pool.ts"; +import { ResultsInbox } from "../src/runtime/results-inbox.ts"; +import { runBackground } from "../src/runtime/async-runner.ts"; +import { Scheduler } from "../src/scheduling/scheduler.ts"; +import { join } from "node:path"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +async function main(): Promise { + // 1. isolated temp git repo (the worktree IS the isolation — no repo pollution) + const repo = mkdtempSync(join(tmpdir(), "fleet-spec5a-smoke-")); + execSync("git init -b main", { cwd: repo }); + execSync('git config user.email "t@t.test" && git config user.name "test"', { cwd: repo }); + writeFileSync(join(repo, "base.txt"), "base\n"); + execSync("git add base.txt && git commit -m base", { cwd: repo }); + console.log("smoke repo:", repo); + + // 2. build the same lifecycleDeps as the SPEC-4 smoke + const modelRuntime = await ModelRuntime.create(); + const todoSync = new ArmoryTodoAdapter(); + const resumeStore = new ResumeStore(); + const backendRegistry = new BackendRegistry(); + backendRegistry.register({ id: "pi", factory: createChildSessionFactory(modelRuntime, new ArmoryMemoryAdapter(), resumeStore), available: () => true, versionInfo: () => null, hookParity: PI_HOOK_PARITY }); + const agentDiscovery = discoverAgents({ projectDir: join(repo, ".pi", "agents"), globalDir: join(process.env.HOME ?? "", ".pi", "agent", "agents"), builtinDir: join(new URL(".", import.meta.url).pathname, "..", "agents") }); + const agentRegistry = agentDiscovery.agents; + + const lifecycleDeps = { + registry: new Map([["default", DEFAULT_LIFECYCLE]]), + agentRegistry, + spawn: async (o: any) => spawnSubagent({ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model, skillsOverride: o.skills, backendOverride: o.backend, registry: agentRegistry, todoSync, runRegistry: new RunRegistry(), lock: createSingleSlotLock(), backendRegistry, parentModel: { provider: "Ollama", id: "glm-5.2:cloud" }, parentCwd: o.parentCwd }), + todoPort: todoSync, + resolveBackend: (phaseBackend: any, lifecycleBackend: any) => phaseBackend ?? lifecycleBackend, + genRunId: () => "fl-smoke-" + Date.now().toString(36), + }; + + // 3. async runner deps — the runLifecycle adapter maps runBackground opts → runLifecycle + const journal = new RunJournal(join(repo, ".pi", "fleet", "runs")); + const inbox = new ResultsInbox(); + const asyncDeps = { + worktree: new WorktreeService({ rootDir: repo }), + diff: new DiffService(), + journal, + pool: new ConcurrencyPool(2), + inbox, + runLifecycle: async (task: string, lifecycleName: string, opts: any) => { + const res = await runLifecycle(task, lifecycleName, { + deps: { ...lifecycleDeps, spawn: async (o: any) => lifecycleDeps.spawn({ ...o, parentCwd: opts.worktreePath }) } as any, + mode: "auto", + onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" }, + }); + return res as any; + }, + notify: (m: string) => console.log("notify:", m), + genRunId: () => "fl-smoke-" + Date.now().toString(36), + }; + + // 4. fire a background run + const handle = runBackground("Add a hello() function to scratch.ts returning 'hello from fleet'", { deps: asyncDeps, lifecycle: "default", mode: "auto" }); + console.log("fired:", handle); + + // 5. wait for completion (poll the inbox) + const deadline = Date.now() + 180_000; + while (Date.now() < deadline && inbox.readyCount() === 0) { + await new Promise((r) => setTimeout(r, 1000)); + } + const results = inbox.pull(); + if (results.length === 0) { console.error("SMOKE FAILED: no result within 180s"); rmSync(repo, { recursive: true, force: true }); process.exit(1); } + console.log("result:", JSON.stringify(results[0], null, 2)); + + // 6. assert the journal + const events = journal.replay(handle.runId); + if (!events.some((e) => e.type === "run:completed")) { console.error("SMOKE FAILED: no run:completed in journal"); rmSync(repo, { recursive: true, force: true }); process.exit(1); } + console.log("journal events:", events.map((e) => e.type).join(", ")); + + // 7. scheduling: register a one-shot 2s out + assert it fires once + auto-deletes + let schedFired = 0; + const scheduler = new Scheduler({ storePath: join(repo, ".pi", "fleet", "schedules.json"), lockPath: join(repo, ".pi", "fleet", "schedules.lock"), onFire: () => { schedFired++; } }); + const fireAt = new Date(Date.now() + 2000); + const pad = (n: number) => String(n).padStart(2, "0"); + const iso = `${fireAt.getFullYear()}-${pad(fireAt.getMonth() + 1)}-${pad(fireAt.getDate())}T${pad(fireAt.getHours())}:${pad(fireAt.getMinutes())}:${pad(fireAt.getSeconds())}`; + const schedId = scheduler.register({ task: "scheduled smoke", expression: iso, lifecycle: "default" }); + scheduler.start(); + await new Promise((r) => setTimeout(r, 3000)); + scheduler.stop(); + if (schedFired !== 1) { console.error(`SMOKE FAILED: schedule fired ${schedFired} times (expected 1)`); rmSync(repo, { recursive: true, force: true }); process.exit(1); } + if (scheduler.list().find((s) => s.id === schedId)) { console.error("SMOKE FAILED: one-shot not auto-deleted"); rmSync(repo, { recursive: true, force: true }); process.exit(1); } + console.log("schedule fired once + auto-deleted ✓"); + + rmSync(repo, { recursive: true, force: true }); + console.log("SMOKE PASSED ✅"); +} + +void main().catch((e) => { console.error("SMOKE ERROR:", e); process.exit(1); }); \ No newline at end of file From 441c49dd5ef4da953ec3e46f40e17391cc53c97e Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:23:10 +0700 Subject: [PATCH 17/18] fix(spec-5a): wire onProgress so bgRuns populates for live /fleet fleet-tab bg rows --- src/index.ts | 1 + src/runtime/async-runner.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/index.ts b/src/index.ts index 9ff1042..d937150 100644 --- a/src/index.ts +++ b/src/index.ts @@ -232,6 +232,7 @@ export default async function (pi: ExtensionAPI): Promise { runLifecycle: asyncRunLifecycle, notify: (m, lvl) => ctx.ui.notify(m, lvl), genRunId: () => "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8), + onProgress: (runId, status) => { bgRuns.set(runId, status); }, }; deps.scheduler = new Scheduler({ storePath: join(dir, "schedules.json"), diff --git a/src/runtime/async-runner.ts b/src/runtime/async-runner.ts index 1099a0a..d82f219 100644 --- a/src/runtime/async-runner.ts +++ b/src/runtime/async-runner.ts @@ -40,6 +40,8 @@ export interface AsyncRunnerDeps { runLifecycle: RunLifecycleFn; notify: (msg: string, level?: "info" | "warning" | "error") => void; genRunId: () => string; + /** SPEC-5a: called at each run/phase transition so the host (index.ts) can update the live bgRuns map. */ + onProgress?: (runId: string, status: import("../panel/rows.ts").BgRunStatus) => void; } export interface RunBackgroundOpts { @@ -53,6 +55,18 @@ export interface RunBackgroundHandle { status: "background"; } +function emitProgress(deps: AsyncRunnerDeps, runId: string, partial: Partial & { status: import("../panel/rows.ts").BgStatus; phase: string; phaseIndex: number; phaseTotal: number }): void { + if (!deps.onProgress) return; + deps.onProgress(runId, { + runId, + lifecycle: "", + mode: "auto", + backend: "pi", + task: "", + ...partial, + }); +} + function sh(cmd: string, cwd: string): void { execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }); } @@ -69,6 +83,7 @@ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgro wt = deps.worktree.create(runId, baseRef); const ev0: JournalEvent = { type: "run:started", runId, task, lifecycle: opts.lifecycle, worktree: { path: wt.path, branch: wt.branch }, mode: opts.mode, ts: Date.now() }; deps.journal.append(runId, ev0); + emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task }); const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: wt.path, branch: wt.branch, mode: opts.mode }); @@ -76,6 +91,9 @@ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgro // commit the worktree to the branch (lifecycle finish phase or single-delegate completion) try { sh("git add -A && git commit -m 'fleet run complete'", wt.path); } catch { /* nothing to commit */ } deps.journal.append(runId, { type: "run:completed", runId, branch: wt.branch, ts: Date.now() }); + const total = res.phases.length; + const lastIdx = total; // completed = past the last phase + 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 }); const lastPhase = res.phases[res.phases.length - 1]; const result: RunResult = { runId, task, status: "completed", @@ -88,6 +106,7 @@ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgro } else { deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() }); deps.worktree.remove(runId); + emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: res.phases.length, lifecycle: opts.lifecycle, mode: opts.mode, task }); deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning"); } } catch (e) { @@ -95,6 +114,7 @@ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgro deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() }); if (wt) deps.worktree.remove(runId); deps.notify(`fleet run ${runId} failed: ${msg}`, "error"); + emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task }); } }); From 64653fdd2eb6bdc3ca8661f757c34bcdd9822cbd Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 23:37:49 +0700 Subject: [PATCH 18/18] =?UTF-8?q?fix(spec-5a):=20code-review=20=E2=80=94?= =?UTF-8?q?=20realize=20Q3=3DA=20(worktree-diff=20artifact=20discovery)=20?= =?UTF-8?q?+=20clean=20up=20completed-run=20worktrees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review findings (self-review before merge): - Important: Q3=A was not realized — DiffService was in AsyncRunnerDeps but never called; isolated runs used the same parseArtifacts path as foreground. Added an optional artifactDiscovery hook to LifecycleRunDeps + worktreePath/baseRef to LifecycleRunOpts (additive — foreground unchanged). The index.ts async adapter now wires DiffService.diffPhase as the discovery fn for isolated runs. +2 tests (hook used when worktreePath set; NOT used for foreground). - Important: completed runs leaked the worktree dir (.pi/fleet/worktrees/ accumulated). Added WorktreeService.removeWorktree (git worktree remove, KEEPS the branch for merge/inspection); the async runner calls it on completion. +1 test (worktree dir gone, branch kept). - Minor: dropped unused existsSync import in async-runner.ts. --- src/index.ts | 4 ++- src/lifecycle/run-lifecycle.ts | 16 ++++++++++ src/runtime/async-runner.ts | 3 +- src/worktree/worktree-service.ts | 14 +++++++++ test/run-lifecycle.test.mts | 54 ++++++++++++++++++++++++++++++++ test/worktree-service.test.mts | 16 +++++++++- 6 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index d937150..42f3241 100644 --- a/src/index.ts +++ b/src/index.ts @@ -173,6 +173,8 @@ export default async function (pi: ExtensionAPI): Promise { const lifecycleFullDeps: LifecycleRunDeps = { ...deps.lifecycleDeps, genRunId: () => opts.runId, // override: use the async runner's runId + // SPEC-5a (Q3=A): isolated run — worktree-diff artifact discovery instead of the prompt-baked block. + artifactDiscovery: ({ finalText, cwd, baseRef }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText), spawn: async (o) => spawnSubagent({ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model, skillsOverride: o.skills, backendOverride: o.backend, @@ -180,7 +182,7 @@ export default async function (pi: ExtensionAPI): Promise { backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, // child runs in the worktree }), }; - const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } }); + 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" } }); return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult; }; // asyncRunnerDeps + scheduler are built per-session (need the session cwd); wired on session_start. diff --git a/src/lifecycle/run-lifecycle.ts b/src/lifecycle/run-lifecycle.ts index c62c4af..760c7c4 100644 --- a/src/lifecycle/run-lifecycle.ts +++ b/src/lifecycle/run-lifecycle.ts @@ -33,12 +33,20 @@ export interface LifecycleRunDeps { /** Resolve the backend for a phase: phase.backend → lifecycle.backend → "pi" (+ availability check). */ resolveBackend: (phaseBackend: BackendId | undefined, lifecycleBackend: BackendId) => BackendId; genRunId: () => string; + /** SPEC-5a (Q3=A): when present, isolated runs use worktree-diff artifact discovery + * instead of the prompt-baked `Artifacts:` block parser. Foreground runs leave this undefined. */ + artifactDiscovery?: (o: { finalText: string; cwd: string; baseRef: string; terminal: boolean }) => { summary: string; paths: string[] } | { error: string }; } export interface LifecycleRunOpts { deps: LifecycleRunDeps; mode: LifecycleMode; onCheckpoint: CheckpointFn; + /** SPEC-5a (Q3=A): the worktree path for isolated runs. When set + deps.artifactDiscovery is present, + * artifact discovery uses worktree-diff instead of parseArtifacts. Foreground runs leave this undefined. */ + worktreePath?: string; + /** SPEC-5a: the base ref to diff against (default "HEAD"). */ + baseRef?: string; } export interface LifecycleRunResult { @@ -143,6 +151,14 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li let phaseRec: PhaseRecord; if (spawnRes.status === "failed") { phaseRec = { name: phaseDef.name, summary: spawnRes.error ?? spawnRes.finalText.slice(0, 120), paths: [], status: "failed", reviseCount }; + } else if (deps.artifactDiscovery && opts.worktreePath) { + // SPEC-5a (Q3=A): isolated run — structural worktree-diff (robust to models that omit the Artifacts block). + const art = deps.artifactDiscovery({ finalText: spawnRes.finalText, cwd: opts.worktreePath, baseRef: opts.baseRef ?? "HEAD", terminal: isTerminal }); + if ("error" in art) { + phaseRec = { name: phaseDef.name, summary: art.error, paths: [], status: "failed", reviseCount }; + } else { + phaseRec = { name: phaseDef.name, summary: art.summary, paths: art.paths, status: "completed", reviseCount }; + } } else { const art = parseArtifacts(spawnRes.finalText, { terminal: isTerminal }); if ("error" in art) { diff --git a/src/runtime/async-runner.ts b/src/runtime/async-runner.ts index d82f219..2ecf432 100644 --- a/src/runtime/async-runner.ts +++ b/src/runtime/async-runner.ts @@ -8,7 +8,6 @@ import type { RunJournal, JournalEvent } from "./run-journal.ts"; import type { ConcurrencyPool } from "./concurrency-pool.ts"; import type { ResultsInbox, RunResult } from "./results-inbox.ts"; import { execSync } from "node:child_process"; -import { existsSync } from "node:fs"; // Thin shape of the LifecycleRunResult we need (avoids importing the full type here — the // real adapter in index.ts maps the full LifecycleRunResult to this shape). @@ -103,6 +102,8 @@ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgro }; deps.inbox.push(result); deps.notify(`fleet run ${runId} completed`, "info"); + // SPEC-5a: the worktree dir is temporary scaffolding; remove it but keep the branch for merge/inspection. + deps.worktree.removeWorktree(runId); } else { deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() }); deps.worktree.remove(runId); diff --git a/src/worktree/worktree-service.ts b/src/worktree/worktree-service.ts index 202cd28..57c8754 100644 --- a/src/worktree/worktree-service.ts +++ b/src/worktree/worktree-service.ts @@ -58,6 +58,20 @@ export class WorktreeService { return { path, branch }; } + /** SPEC-5a: remove the worktree dir but KEEP the branch (for completed runs the branch is + * kept for merge/inspection; only the worktree dir is temporary scaffolding). */ + removeWorktree(runId: string): void { + const path = this.pathFor(runId); + if (existsSync(path)) { + try { + sh(`git worktree remove --force ${path}`, this.rootDir); + } catch { + rmSync(path, { recursive: true, force: true }); + try { sh("git worktree prune", this.rootDir); } catch { /* ignore */ } + } + } + } + remove(runId: string): void { const path = this.pathFor(runId); const branch = this.branchFor(runId); diff --git a/test/run-lifecycle.test.mts b/test/run-lifecycle.test.mts index 7b86bdc..46402e8 100644 --- a/test/run-lifecycle.test.mts +++ b/test/run-lifecycle.test.mts @@ -226,3 +226,57 @@ pb strictEqual(captured[1]!.backend, "claude", "phase b backend = per-phase override (Q4=C)"); ok(captured[1]!.skills.includes("writing-plans"), "phase b gets the writing-plans skill"); }); + + +test("SPEC-5a Q3=A: artifactDiscovery hook overrides parseArtifacts when worktreePath is set", async () => { + const lc = parseLifecycleFile(LC_SRC, "/x/test-lc.md", "builtin")!; + let i = 0; + const spawns = [ + { finalText: "phase a output (no Artifacts block)", status: "completed" as const }, + { finalText: "phase b output", status: "completed" as const }, + { finalText: "phase c output", status: "completed" as const }, + ]; + const deps: LifecycleRunDeps = { + registry: new Map([["test-lc", lc]]), + agentRegistry: new Map([["general-purpose", agent]]), + spawn: async (_o) => ({ status: spawns[i]!.status, finalText: spawns[i++]!.finalText, runId: "fl-x", todoId: "td", agent: "general-purpose", model: "m", durationMs: 1, tokenTotal: 0 }), + todoPort: { async linkOrCreateRunTodo() { return { todoId: "td" }; }, async markRunTodoDone() {}, async markRunTodoReverted() {}, async updateLifecycleProgress() {} } as any, + resolveBackend: (_p, lb) => lb, + genRunId: () => "fl-q3a", + // Q3=A: worktree-diff discovery — returns structural paths, ignoring the (absent) Artifacts block + artifactDiscovery: ({ finalText, terminal }) => ({ summary: finalText.slice(0, 40), paths: terminal ? [] : ["worktree-out.md"] }), + }; + const onCheckpoint: CheckpointFn = async () => ({ action: "continue" }); + const res = await runLifecycle("t", "test-lc", { deps, mode: "auto", onCheckpoint, worktreePath: "/tmp/wt", baseRef: "HEAD" }); + strictEqual(res.status, "completed"); + // phases a + b should have the diff-discovered path; phase c is terminal (no paths) + ok(res.phases[0]!.paths.includes("worktree-out.md"), `phase a paths: ${res.phases[0]!.paths.join(",")}`); + ok(res.phases[1]!.paths.includes("worktree-out.md"), `phase b paths: ${res.phases[1]!.paths.join(",")}`); + strictEqual(res.phases[2]!.paths.length, 0); +}); + +test("SPEC-5a Q3=A: without worktreePath, artifactDiscovery is NOT used (foreground falls back to parseArtifacts)", async () => { + const lc = parseLifecycleFile(LC_SRC, "/x/test-lc.md", "builtin")!; + let i = 0; + const spawns = [ + { finalText: "phase a\n\nArtifacts:\n - path: a.md\n", status: "completed" as const }, + { finalText: "phase b\n\nArtifacts:\n - path: b.md\n", status: "completed" as const }, + { finalText: "phase c done", status: "completed" as const }, + ]; + let discoveryCalled = false; + const deps: LifecycleRunDeps = { + registry: new Map([["test-lc", lc]]), + agentRegistry: new Map([["general-purpose", agent]]), + spawn: async (_o) => ({ status: spawns[i]!.status, finalText: spawns[i++]!.finalText, runId: "fl-x", todoId: "td", agent: "general-purpose", model: "m", durationMs: 1, tokenTotal: 0 }), + todoPort: { async linkOrCreateRunTodo() { return { todoId: "td" }; }, async markRunTodoDone() {}, async markRunTodoReverted() {}, async updateLifecycleProgress() {} } as any, + resolveBackend: (_p, lb) => lb, + genRunId: () => "fl-fg", + artifactDiscovery: () => { discoveryCalled = true; return { summary: "x", paths: ["should-not-be-used.md"] }; }, + }; + const onCheckpoint: CheckpointFn = async () => ({ action: "continue" }); + // NO worktreePath → foreground path → parseArtifacts used, artifactDiscovery NOT called + const res = await runLifecycle("t", "test-lc", { deps, mode: "auto", onCheckpoint }); + strictEqual(res.status, "completed"); + strictEqual(discoveryCalled, false); + strictEqual(res.phases[0]!.paths[0], "a.md"); +}); diff --git a/test/worktree-service.test.mts b/test/worktree-service.test.mts index 5a4315b..c64656f 100644 --- a/test/worktree-service.test.mts +++ b/test/worktree-service.test.mts @@ -59,4 +59,18 @@ test("create errors actionable when base ref is invalid", () => { const svc = new WorktreeService({ rootDir: repo }); assert.throws(() => svc.create("fl-test4", "no-such-ref"), /no-such-ref|unknown revision|invalid|worktree create failed/); rmSync(repo, { recursive: true, force: true }); -}); \ No newline at end of file +}); + +test("removeWorktree removes the worktree dir but KEEPS the branch (SPEC-5a completed-run cleanup)", () => { + const repo = makeRepo(); + const svc = new WorktreeService({ rootDir: repo }); + const { path, branch } = svc.create("fl-wt-keep", "HEAD"); + writeFileSync(join(path, "new.txt"), "x\n"); + svc.removeWorktree("fl-wt-keep"); + assert.equal(svc.exists("fl-wt-keep"), false); + assert.equal(existsSync(path), false); + // branch MUST still exist (kept for merge/inspection) + const branches = sh("git branch --list", repo); + assert.ok(branches.includes(branch), `branch ${branch} should be kept, got: ${branches}`); + rmSync(repo, { recursive: true, force: true }); +});