From 637525c50759fd9284ff655c62268209439ec922 Mon Sep 17 00:00:00 2001 From: Fotis Stamatelopoulos Date: Mon, 18 May 2026 17:42:27 -0700 Subject: [PATCH 1/3] feat(history): separate section for interactive agents (PA/HA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfood feedback from gmbot: PA sessions can stay alive for hours or days. In a single chronological History list, an active long-running PA's row gets pushed deep as iteration events accumulate above it. Scanning for "is PA still running?" forced scrolling. Fix: partition the History tab into two sections — "Interactive agents" (PA + HA events, today only pa-session) at the top, and the existing "Loop history" below. Both sorted newest-first within their section. Critical UX rule: every event has ONE permanent home. A PA's row stays in the Interactive section regardless of status — no disappearing on completion, no relocating to chronological slot in the Loop section. Status badge handles running-vs-completed within the section. The "active-only" framing would have hidden terminated history; partition by event-type only preserves it. When there are zero interactive events, no top section + no header — the table looks exactly like today. The new structure only appears when it's needed. Complements F.22 (v0.24.0): F.22's chip surfaces loop-spawned active agents on the dashboard card + Status tab; this PR surfaces interactive agents in the History tab. Together they close the "what's running RIGHT NOW" question for both classes. Implementation: new pure helper `partitionInteractiveEvents(events)` in packages/web/src/utils/history-partition.ts (returns {interactive, loop}). WorkspaceHistory.tsx renders two
blocks with count-bearing headers when interactive events exist. INTERACTIVE_EVENT_TYPES is a Set — when HA grows a history-event type, add it; no other changes needed. Test coverage: 10 new tests covering partition correctness, sorting, status-independence, empty input, totality, immutability of input. All 1059 tests pass. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 67 ++++- .../web/src/components/WorkspaceHistory.tsx | 135 +++++++--- .../web/src/utils/history-partition.test.ts | 242 ++++++++++++++++++ packages/web/src/utils/history-partition.ts | 62 +++++ 4 files changed, 469 insertions(+), 37 deletions(-) create mode 100644 packages/web/src/utils/history-partition.test.ts create mode 100644 packages/web/src/utils/history-partition.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f2bc941..bb998ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,72 @@ Changes are tracked via git tags. Each release tag corresponds to an entry here. ## [Unreleased] -_No changes yet._ +### Changed — History tab: separate section for interactive agents (PA + HA) + +Dogfood feedback from the gmbot run: PA sessions can stay alive +for hours or days. In the History tab's single chronological list, +an active long-running PA's row gets pushed deep into the stack as +iteration events accumulate above it. Scanning for "is PA still +running?" forced scrolling. + +**Fix**: partition the events into two sections in the History tab: + +- **Interactive agents (top section)** — every PA + HA event for + this workspace (active and terminated), newest-first within the + section. Active sessions naturally appear at the top because + they're the newest by `startedAt`. The section header shows the + count: `Interactive agents (2)`. +- **Loop history (bottom section)** — iteration / review / + document / reflection / loop-stopped events, newest-first. + Unchanged from today, just gets a header (`Loop history (47)`) + when the interactive section is present. + +When there are no interactive events (most workspaces, most of the +time), there's no top section and no header on the bottom — the +table looks exactly like it does today. The new structure only +appears when it's needed. + +**Critical UX rule**: every event has exactly ONE permanent home. +A PA's row stays in the Interactive section regardless of status — +no disappearing-on-completion, no relocating to its chronological +slot in section B when it terminates. Status badge differentiates +running/completed/failed within the section. This was the +load-bearing correction in the design discussion: the initial +"active-only" framing would have moved terminated PAs out of +the top section, hiding history. The corrected framing partitions +by event-type only. + +**Multiple active PAs** (user-error case): all appear in section A +with their `running` status badges. No warning today — defer until +real users hit it. + +**Where F.22's active-agent chip fits**: this is the History tab +counterpart of F.22 (which solved the same "what's running RIGHT +NOW" problem on the workspace card / Status tab, but only for +loop-spawned roles — F.22 doesn't track PA / HA because they're +not in `active-processes` registry). Together: F.22 = loop on the +dashboard / Status tab; this PR = interactive on the History tab. + +**Implementation** (~125 LoC + tests): + +- New `packages/web/src/utils/history-partition.ts` — pure helper + `partitionInteractiveEvents(events)` returns + `{interactive, loop}`, both newest-first. Extensible: today + `INTERACTIVE_EVENT_TYPES = new Set(["pa-session"])`; when HA + grows a history-event type, add it to the set — no other code + changes needed. +- `packages/web/src/components/WorkspaceHistory.tsx` — render two + `
` blocks (with headers + count) when interactive + events exist, or fall back to the original single-table render + when they don't. + +**Test coverage** (10 new tests in `history-partition.test.ts`, +all 1059 total pass): partition splits correctly; both arrays +sorted newest-first; active PA stays at top of interactive section +regardless of startedAt; terminated events stay in their section +(status does NOT change partition); handles all loop event types; +empty input; partition is total (no events lost); does not mutate +input array. ## [0.24.4] -- 2026-05-14 diff --git a/packages/web/src/components/WorkspaceHistory.tsx b/packages/web/src/components/WorkspaceHistory.tsx index 192d489..dd9f347 100644 --- a/packages/web/src/components/WorkspaceHistory.tsx +++ b/packages/web/src/components/WorkspaceHistory.tsx @@ -20,6 +20,7 @@ import { deriveJudgeRowStatus, deriveJudgeRowTime, } from "../utils/iteration-row-status"; +import { partitionInteractiveEvents } from "../utils/history-partition"; const determinationColor: Record = { SUCCESS: "var(--color-success)", @@ -82,45 +83,107 @@ export function WorkspaceHistory({ ); } - // Sort newest first - const sorted = [...events].sort( - (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), - ); + // Split into interactive (PA/HA) + loop (everything else). Both + // arrays come back newest-first. Rationale: PA sessions can run + // for hours/days, and in a single chronological list an active + // long-running PA gets pushed deep by accumulating iteration + // events. Giving interactive agents their own stable section + // keeps "PA is alive" findable. Every event still has exactly + // one home — terminated PAs stay in the Interactive section + // (status badge tells running-vs-completed). See + // `utils/history-partition.ts` for the full rationale. + const { interactive, loop } = partitionInteractiveEvents(events); return (
- - - - - - - - - - - - - - {sorted.map((e) => - e.type === "iteration" ? ( - - ) : ( - - ), - )} - -
TimeTypeAgentStatusResultDurationLog
+ {interactive.length > 0 && ( +
+

+ Interactive agents ({interactive.length}) +

+ + + + + + + + + + + + + + {interactive.map((e) => ( + + ))} + +
TimeTypeAgentStatusResultDurationLog
+
+ )} +
0 ? "1rem" : 0 }} + > + {interactive.length > 0 && ( +

+ Loop history ({loop.length}) +

+ )} + + + + + + + + + + + + + + {loop.map((e) => + e.type === "iteration" ? ( + + ) : ( + + ), + )} + +
TimeTypeAgentStatusResultDurationLog
+
); } diff --git a/packages/web/src/utils/history-partition.test.ts b/packages/web/src/utils/history-partition.test.ts new file mode 100644 index 0000000..d37f63a --- /dev/null +++ b/packages/web/src/utils/history-partition.test.ts @@ -0,0 +1,242 @@ +/** + * Tests for the History tab's interactive/loop partition (v0.24.5). + * Locks in the rule that "interactive" events keep ONE permanent + * home regardless of status — terminated PAs stay in the + * Interactive section. This was the load-bearing correction during + * design: the initial proposal would have moved terminated rows + * out, hiding history; the corrected proposal partitions by + * event-type only, never by status. + */ + +import { describe, test, expect } from "bun:test"; +import type { + HistoryEvent, + IterationHistoryEvent, + ReviewHistoryEvent, + ReflectionHistoryEvent, + DocumentHistoryEvent, + PaSessionHistoryEvent, + LoopStoppedHistoryEvent, +} from "../types"; +import { + partitionInteractiveEvents, + INTERACTIVE_EVENT_TYPES, +} from "./history-partition"; + +function makePaSession(overrides?: Partial): PaSessionHistoryEvent { + return { + id: `pa-${Math.random().toString(36).slice(2, 10)}`, + type: "pa-session", + status: "completed", + startedAt: "2026-05-14T10:00:00.000Z", + logFile: "pa.log", + agent: "claude-code", + sessionId: "pa-2026-05-14T10-00-00-abc123", + sessionFilePath: ".cfcf-pa/session-pa-2026-05-14T10-00-00-abc123.md", + workspaceRegisteredAtStart: true, + gitInitializedAtStart: true, + problemPackFilesAtStart: 3, + ...overrides, + }; +} + +function makeIteration(overrides?: Partial): IterationHistoryEvent { + return { + id: `iter-${Math.random().toString(36).slice(2, 10)}`, + type: "iteration", + status: "completed", + startedAt: "2026-05-14T11:00:00.000Z", + logFile: "iter.log", + agent: "codex", + iteration: 1, + branch: "cfcf/iteration-1", + devLogFile: "iter-1-dev.log", + judgeLogFile: "iter-1-judge.log", + devAgent: "codex", + judgeAgent: "codex", + ...overrides, + }; +} + +function makeReview(overrides?: Partial): ReviewHistoryEvent { + return { + id: `review-${Math.random().toString(36).slice(2, 10)}`, + type: "review", + status: "completed", + startedAt: "2026-05-14T12:00:00.000Z", + logFile: "review.log", + agent: "claude-code", + trigger: "manual", + ...overrides, + }; +} + +function makeReflection(overrides?: Partial): ReflectionHistoryEvent { + return { + id: `refl-${Math.random().toString(36).slice(2, 10)}`, + type: "reflection", + status: "completed", + startedAt: "2026-05-14T13:00:00.000Z", + logFile: "refl.log", + agent: "claude-code", + iteration: 1, + trigger: "loop", + ...overrides, + }; +} + +function makeDocument(overrides?: Partial): DocumentHistoryEvent { + return { + id: `doc-${Math.random().toString(36).slice(2, 10)}`, + type: "document", + status: "completed", + startedAt: "2026-05-14T14:00:00.000Z", + logFile: "doc.log", + agent: "claude-code", + ...overrides, + }; +} + +function makeLoopStopped(overrides?: Partial): LoopStoppedHistoryEvent { + return { + id: `stopped-${Math.random().toString(36).slice(2, 10)}`, + type: "loop-stopped", + status: "completed", + startedAt: "2026-05-14T15:00:00.000Z", + logFile: "", + iteration: 5, + ...overrides, + }; +} + +describe("INTERACTIVE_EVENT_TYPES", () => { + test("contains pa-session (the only interactive event type today)", () => { + expect(INTERACTIVE_EVENT_TYPES.has("pa-session")).toBe(true); + }); + + test("does NOT contain loop event types — those are the audit trail of automation", () => { + expect(INTERACTIVE_EVENT_TYPES.has("iteration")).toBe(false); + expect(INTERACTIVE_EVENT_TYPES.has("review")).toBe(false); + expect(INTERACTIVE_EVENT_TYPES.has("reflection")).toBe(false); + expect(INTERACTIVE_EVENT_TYPES.has("document")).toBe(false); + expect(INTERACTIVE_EVENT_TYPES.has("loop-stopped")).toBe(false); + }); +}); + +describe("partitionInteractiveEvents", () => { + test("splits PA sessions into interactive[], everything else into loop[]", () => { + const events: HistoryEvent[] = [ + makePaSession({ id: "pa1" }), + makeIteration({ id: "iter1" }), + makeReview({ id: "rev1" }), + makePaSession({ id: "pa2" }), + makeDocument({ id: "doc1" }), + ]; + const result = partitionInteractiveEvents(events); + expect(result.interactive.map((e) => e.id).sort()).toEqual(["pa1", "pa2"]); + expect(result.loop.map((e) => e.id).sort()).toEqual(["doc1", "iter1", "rev1"]); + }); + + test("both arrays come back sorted newest-first (descending by startedAt)", () => { + const events: HistoryEvent[] = [ + makePaSession({ id: "pa-old", startedAt: "2026-05-14T08:00:00.000Z" }), + makePaSession({ id: "pa-new", startedAt: "2026-05-14T10:00:00.000Z" }), + makePaSession({ id: "pa-mid", startedAt: "2026-05-14T09:00:00.000Z" }), + makeIteration({ id: "iter-old", startedAt: "2026-05-14T11:00:00.000Z" }), + makeIteration({ id: "iter-new", startedAt: "2026-05-14T13:00:00.000Z" }), + makeIteration({ id: "iter-mid", startedAt: "2026-05-14T12:00:00.000Z" }), + ]; + const result = partitionInteractiveEvents(events); + expect(result.interactive.map((e) => e.id)).toEqual(["pa-new", "pa-mid", "pa-old"]); + expect(result.loop.map((e) => e.id)).toEqual(["iter-new", "iter-mid", "iter-old"]); + }); + + test("ACTIVE PA stays at the top of interactive[] (newest startedAt) regardless of status", () => { + // The load-bearing case for this whole feature: a long-running + // PA that started days ago is still alive. New iteration events + // accumulate. The active PA stays findable in section A. + const activeOldPa = makePaSession({ + id: "pa-running", + status: "running", + startedAt: "2026-05-12T08:00:00.000Z", // started 2 days ago + }); + const recentIter = makeIteration({ + id: "iter-recent", + startedAt: "2026-05-14T15:00:00.000Z", // just now + }); + const recentlyCompletedPa = makePaSession({ + id: "pa-just-done", + status: "completed", + startedAt: "2026-05-14T14:00:00.000Z", // an hour ago, completed + }); + const events: HistoryEvent[] = [recentIter, activeOldPa, recentlyCompletedPa]; + const result = partitionInteractiveEvents(events); + // The "just-done" PA is newer-by-startedAt and shows first; the + // active 2-day-old PA is second. Both are in interactive[], + // independent of status — the row is FINDABLE either way because + // section A has at most a handful of entries. + expect(result.interactive.map((e) => e.id)).toEqual(["pa-just-done", "pa-running"]); + expect(result.loop.map((e) => e.id)).toEqual(["iter-recent"]); + }); + + test("TERMINATED interactive events stay in interactive[] (status does NOT change partition)", () => { + // The correction the user pushed for in design review: don't + // move events between sections when status flips. If a PA was + // ever interactive, it stays in interactive[] forever — no + // disappearing-on-completion, no "where did it go" confusion. + const events: HistoryEvent[] = [ + makePaSession({ id: "pa-running", status: "running" }), + makePaSession({ id: "pa-completed", status: "completed" }), + makePaSession({ id: "pa-failed", status: "failed" }), + ]; + const result = partitionInteractiveEvents(events); + expect(result.interactive).toHaveLength(3); + expect(result.loop).toHaveLength(0); + }); + + test("handles all loop event types correctly", () => { + const events: HistoryEvent[] = [ + makeIteration(), + makeReview(), + makeReflection(), + makeDocument(), + makeLoopStopped(), + ]; + const result = partitionInteractiveEvents(events); + expect(result.interactive).toHaveLength(0); + expect(result.loop).toHaveLength(5); + }); + + test("empty input returns empty arrays (defensive — never throws)", () => { + const result = partitionInteractiveEvents([]); + expect(result.interactive).toEqual([]); + expect(result.loop).toEqual([]); + }); + + test("preserves all events — partition is total (interactive ∪ loop = input)", () => { + const events: HistoryEvent[] = [ + makePaSession({ id: "pa1" }), + makeIteration({ id: "iter1" }), + makeReview({ id: "rev1" }), + makePaSession({ id: "pa2" }), + makeReflection({ id: "refl1" }), + makeDocument({ id: "doc1" }), + makeLoopStopped({ id: "stop1" }), + ]; + const result = partitionInteractiveEvents(events); + const allOut = [...result.interactive, ...result.loop].map((e) => e.id).sort(); + const allIn = events.map((e) => e.id).sort(); + expect(allOut).toEqual(allIn); + }); + + test("does not mutate the input array", () => { + const events: HistoryEvent[] = [ + makePaSession({ startedAt: "2026-05-14T10:00:00.000Z" }), + makeIteration({ startedAt: "2026-05-14T08:00:00.000Z" }), + makePaSession({ startedAt: "2026-05-14T12:00:00.000Z" }), + ]; + const originalOrder = events.map((e) => e.startedAt); + partitionInteractiveEvents(events); + expect(events.map((e) => e.startedAt)).toEqual(originalOrder); + }); +}); diff --git a/packages/web/src/utils/history-partition.ts b/packages/web/src/utils/history-partition.ts new file mode 100644 index 0000000..00274a2 --- /dev/null +++ b/packages/web/src/utils/history-partition.ts @@ -0,0 +1,62 @@ +/** + * Partition workspace-history events into interactive-agent events + * (PA today, HA when it gets a history-event type) and everything + * else (the loop's events: iteration, review, document, reflection, + * loop-stopped). + * + * Rationale: PA sessions can run for hours or days. In a single + * chronological list, an active long-running PA's row gets pushed + * deep into the stack as iteration events accumulate above it, + * making the "PA is alive right now" signal hard to scan for. + * + * The two surfaces serve different mental models: + * - Interactive agents: a stable, mostly-empty surface where + * short-and-medium-cardinality PA/HA history lives. Active + * sessions naturally appear at the top because they're the + * newest. + * - Loop history: the chronological audit trail of automated + * iteration work. Unchanged by this partition. + * + * Every event has exactly ONE permanent home — events do NOT move + * between sections based on status. A terminated PA stays in the + * Interactive section (just with `status: "completed"`). This + * preserves history fidelity: nothing disappears or relocates. + * + * Both arrays come back sorted newest-first (descending by + * `startedAt`), matching the existing single-list behaviour. + */ + +import type { HistoryEvent } from "../types"; + +/** + * Event types that belong to the interactive-agents section. Today + * only PA writes history events; HA is ephemeral. When HA grows a + * history-event type, add it here. + */ +export const INTERACTIVE_EVENT_TYPES: ReadonlySet = new Set([ + "pa-session", +]); + +export interface PartitionedHistory { + /** PA + HA events, newest-first. */ + interactive: HistoryEvent[]; + /** Iteration / review / document / reflection / loop-stopped events, newest-first. */ + loop: HistoryEvent[]; +} + +export function partitionInteractiveEvents(events: HistoryEvent[]): PartitionedHistory { + const interactive: HistoryEvent[] = []; + const loop: HistoryEvent[] = []; + for (const e of events) { + if (INTERACTIVE_EVENT_TYPES.has(e.type)) { + interactive.push(e); + } else { + loop.push(e); + } + } + const byStartedDesc = (a: HistoryEvent, b: HistoryEvent) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(); + interactive.sort(byStartedDesc); + loop.sort(byStartedDesc); + return { interactive, loop }; +} From 5472b134f37f3a3b6891e8327279b3184dbc474e Mon Sep 17 00:00:00 2001 From: Fotis Stamatelopoulos Date: Mon, 18 May 2026 17:50:29 -0700 Subject: [PATCH 2/3] feat(ui): workspace card + Status tab PA-active chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Natural extension of the History tab partition (same PR): F.22's active-agent chip on the workspace card shows loop / review / document / reflect activity but doesn't surface PA. PA runs outside the cfcf server (interactive cfcf spec) and is tracked separately. This adds a parallel chip — "● PA active" — that appears alongside the F.22 chip when a PA session is alive. Why separate chip vs new activeAgent enum value: F.22's activeAgent is mutually exclusive. PA can run concurrently with loop / standalone runs. Two chips can coexist; different accent color makes them visually separable. Liveness check: per-call process.kill(launcherPid, 0) against the PID in the most-recent pa-session event with status=running. Same primitive F.28's boot-reconcile uses, just called on every /api/workspaces poll. Side benefit: PA sessions that terminate uncleanly (shell killed) get the chip cleared in real time instead of waiting for next server boot. Status tab chip: matching "PA active" tag in the workspace detail header, derived from history (same trust level as the History tab). Pre-v0.24 events without launcherPid are skipped — no precise verification possible, stale chip worse than no chip. Boot-reconcile's mtime fallback handles them at next boot. Implementation: - packages/core/src/product-architect/pa-liveness.ts (new): getPaSessionLiveness, getPaSessionsForWorkspaces, isPidAlive - packages/server/src/app.ts: /api/workspaces enriched with paSession in parallel with activeAgent - packages/web/src/types.ts: mirror paSession field - packages/web/src/components/WorkspaceCard.tsx: PA active chip - packages/web/src/pages/WorkspaceDetail.tsx: PA active header tag Test coverage: 12 new tests covering null cases (missing history, no pa-session, no running event, no launcherPid, dead PID), positive case (live PID), ordering (newest-running wins), fall through stale to older live, batch shape, empty input, primitive correctness. All 1071 tests pass. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 70 +++++- packages/core/src/product-architect/index.ts | 8 + .../src/product-architect/pa-liveness.test.ts | 230 ++++++++++++++++++ .../core/src/product-architect/pa-liveness.ts | 124 ++++++++++ packages/server/src/app.ts | 12 +- packages/web/src/components/WorkspaceCard.tsx | 23 ++ packages/web/src/pages/WorkspaceDetail.tsx | 15 ++ packages/web/src/types.ts | 13 + 8 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/product-architect/pa-liveness.test.ts create mode 100644 packages/core/src/product-architect/pa-liveness.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bb998ca..55dc1e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,13 +69,81 @@ dashboard / Status tab; this PR = interactive on the History tab. when they don't. **Test coverage** (10 new tests in `history-partition.test.ts`, -all 1059 total pass): partition splits correctly; both arrays +all 1071 total pass): partition splits correctly; both arrays sorted newest-first; active PA stays at top of interactive section regardless of startedAt; terminated events stay in their section (status does NOT change partition); handles all loop event types; empty input; partition is total (no events lost); does not mutate input array. +### Added — Workspace card + Status tab: "PA active" chip + +Natural extension of the History tab partition (same PR): the +F.22 active-agent chip on the workspace card shows loop / review +/ document / reflect activity, but does NOT surface PA. PA runs +outside the cfcf server (interactive `cfcf spec`), so it's +tracked separately. This adds a parallel chip — *"● PA active"* — +that appears alongside the F.22 chip when a PA session is alive +for the workspace. + +**Why a separate chip, not a new `activeAgent` enum value**: F.22's +`activeAgent` is mutually exclusive (the loop runs one phase at a +time). PA can run **concurrently** with loop / standalone runs. +Two chips can coexist on the card. The PA chip uses a different +accent color so they're visually separable at a glance. + +**Liveness check**: per-call `process.kill(launcherPid, 0)` +against the recorded PID in the most-recent `pa-session` history +event with `status === "running"`. This is the same primitive +F.28's boot-reconcile uses, but called on every `/api/workspaces` +poll instead of only at server boot. **Side benefit**: if a PA +session terminates uncleanly (shell killed, terminal closed), +`status: "running"` lingers in `history.json` until the next boot. +The new check correctly reports "not active" for that case in real +time — no boot required. + +**Status tab chip**: matching `PA active` tag in the workspace +detail header (next to the existing `review running` / +`document running` / `reflect running` tags). Derived from +`history.some(e => e.type === "pa-session" && e.status === +"running")` — same trust level as the History tab itself. +PID-verified liveness is a card-surface concern (dashboard +overview); the detail-page chip trusts the on-disk status because +the user is already deep in this workspace. + +**Pre-v0.24 PA events**: events without `launcherPid` are skipped +by the liveness check rather than shown. No precise way to verify +them, and a stale chip is worse than no chip — boot-reconcile's +mtime fallback handles them at next server boot. + +**Implementation** (~120 LoC + tests): + +- New `packages/core/src/product-architect/pa-liveness.ts`: + - `getPaSessionLiveness(workspaceId)` — finds most-recent + running pa-session event with a live PID, returns details. + - `getPaSessionsForWorkspaces(ids)` — batch resolver matching + `getActiveAgentsForWorkspaces` shape. + - `isPidAlive(pid)` — extracted primitive (boot-reconcile has + its own inline version; future cleanup could migrate). +- `packages/server/src/app.ts` — `/api/workspaces` enriched with + `paSession: PaSessionLiveness | null` in parallel with the + existing `activeAgent` enrichment (both via `Promise.all`). +- `packages/web/src/types.ts` — mirror the new field. +- `packages/web/src/components/WorkspaceCard.tsx` — render + `● PA active` chip alongside F.22's chip when + `workspace.paSession?.active`; tooltip shows session id + start + time + PID. +- `packages/web/src/pages/WorkspaceDetail.tsx` — `PA active` tag + in the header alongside the other in-page chips. + +**Test coverage** (12 new tests in `pa-liveness.test.ts`, all +1071 total pass): null on missing history; null on no pa-session; +null on no `running` event; null on missing launcherPid (pre-v0.24 +event); positive case with live PID; null on dead PID; +newest-running wins when multiple alive; falls through stale newer +to surface older live; batch shape; empty input; isPidAlive +primitive correctness. + ## [0.24.4] -- 2026-05-14 Single-feature patch closing a long-standing Clio auto-ingest diff --git a/packages/core/src/product-architect/index.ts b/packages/core/src/product-architect/index.ts index 8b18c5e..5d14cb1 100644 --- a/packages/core/src/product-architect/index.ts +++ b/packages/core/src/product-architect/index.ts @@ -66,3 +66,11 @@ export { reconcileStalePaSessions, type PaReconcileResult, } from "./boot-reconcile.js"; + +// On-demand liveness check (v0.24.5 — workspace card "PA active" chip) +export { + getPaSessionLiveness, + getPaSessionsForWorkspaces, + isPidAlive, + type PaSessionLiveness, +} from "./pa-liveness.js"; diff --git a/packages/core/src/product-architect/pa-liveness.test.ts b/packages/core/src/product-architect/pa-liveness.test.ts new file mode 100644 index 0000000..1902ac5 --- /dev/null +++ b/packages/core/src/product-architect/pa-liveness.test.ts @@ -0,0 +1,230 @@ +/** + * Tests for the on-demand PA-session liveness check (v0.24.5). + * + * The "PID is alive" path is hard to exercise deterministically + * without spawning + killing real subprocesses. These tests pin + * the observable contract: history wiring, status filter, ordering + * (newest live wins), and the safe defaults around missing / + * malformed inputs. The PID primitive (`isPidAlive`) is also + * tested directly with `process.pid` (this process is definitely + * alive) + a known-dead PID. + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { mkdir, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + getPaSessionLiveness, + getPaSessionsForWorkspaces, + isPidAlive, +} from "./pa-liveness.js"; +import type { PaSessionHistoryEvent } from "../workspace-history.js"; + +let tempDir: string; +const originalConfigDir = process.env.CFCF_CONFIG_DIR; + +beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "cfcf-pa-liveness-test-")); + process.env.CFCF_CONFIG_DIR = tempDir; +}); + +afterEach(async () => { + process.env.CFCF_CONFIG_DIR = originalConfigDir; + try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +/** + * Write a fake history.json for the given workspace. The format + * matches what readHistory() expects: a JSON array of events. + */ +async function writeHistory(workspaceId: string, events: Array & { type: string }>): Promise { + const dir = join(tempDir, "workspaces", workspaceId); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "history.json"), JSON.stringify(events, null, 2), "utf-8"); +} + +describe("isPidAlive", () => { + it("returns true for the current process PID (definitely alive)", () => { + expect(isPidAlive(process.pid)).toBe(true); + }); + + it("returns false for a PID that's almost certainly dead", () => { + // PID 1 is init / launchd — alive but likely owned by root, + // which would return EPERM (alive). Use a high PID unlikely + // to exist instead. + expect(isPidAlive(2147483646)).toBe(false); + }); +}); + +describe("getPaSessionLiveness", () => { + it("returns null when no history exists", async () => { + const result = await getPaSessionLiveness("nonexistent-workspace-id"); + expect(result).toBeNull(); + }); + + it("returns null when history has no pa-session events", async () => { + await writeHistory("ws1", [ + { type: "iteration", id: "iter1", status: "completed", startedAt: "2026-05-14T10:00:00.000Z" } as never, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result).toBeNull(); + }); + + it("returns null when only completed/failed pa-sessions exist (no `running` candidates)", async () => { + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa1", + status: "completed", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "pa-2026-05-14T10-00-00-abc", + launcherPid: process.pid, + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result).toBeNull(); + }); + + it("returns null when running event exists but lacks launcherPid (pre-v0.24 event)", async () => { + // No precise way to verify pre-v0.24 events; we skip them + // rather than show a potentially-stale chip. boot-reconcile's + // mtime fallback handles them at next server boot. + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa1", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "pa-2026-05-14T10-00-00-abc", + // launcherPid missing + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result).toBeNull(); + }); + + it("returns liveness details when running event has a live launcherPid", async () => { + // Using process.pid as the launcher PID — guaranteed alive + // (this test process IS that PID). + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa1", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "pa-2026-05-14T10-00-00-abc123", + launcherPid: process.pid, + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result).not.toBeNull(); + expect(result?.active).toBe(true); + expect(result?.sessionId).toBe("pa-2026-05-14T10-00-00-abc123"); + expect(result?.launcherPid).toBe(process.pid); + expect(result?.startedAt).toBe("2026-05-14T10:00:00.000Z"); + expect(result?.eventId).toBe("pa1"); + }); + + it("returns null when running event's launcherPid is dead", async () => { + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa1", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "pa-2026-05-14T10-00-00-abc", + launcherPid: 2147483646, // unlikely-to-exist PID + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result).toBeNull(); + }); + + it("walks newest-first; surfaces the most recent live PA when multiple are running", async () => { + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa-old", + status: "running", + startedAt: "2026-05-14T09:00:00.000Z", + sessionId: "pa-old-session", + launcherPid: process.pid, + }, + { + type: "pa-session", + id: "pa-new", + status: "running", + startedAt: "2026-05-14T11:00:00.000Z", + sessionId: "pa-new-session", + launcherPid: process.pid, + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result?.sessionId).toBe("pa-new-session"); + expect(result?.eventId).toBe("pa-new"); + }); + + it("falls through stale entries when the newest running event has a dead PID", async () => { + // newest = dead PID; older = live PID. The walk should + // continue past the stale one and surface the older live PA. + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa-stale-newer", + status: "running", + startedAt: "2026-05-14T11:00:00.000Z", + sessionId: "pa-stale", + launcherPid: 2147483646, // dead + }, + { + type: "pa-session", + id: "pa-live-older", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "pa-live", + launcherPid: process.pid, // alive + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result?.sessionId).toBe("pa-live"); + }); +}); + +describe("getPaSessionsForWorkspaces (batch)", () => { + it("returns a map of workspace-id → liveness | null, with one entry per id", async () => { + await writeHistory("ws-alive", [ + { + type: "pa-session", + id: "pa1", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "alive-session", + launcherPid: process.pid, + }, + ]); + await writeHistory("ws-dead", [ + { + type: "pa-session", + id: "pa2", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "dead-session", + launcherPid: 2147483646, + }, + ]); + // ws-empty has no history at all + + const result = await getPaSessionsForWorkspaces(["ws-alive", "ws-dead", "ws-empty"]); + expect(Object.keys(result).sort()).toEqual(["ws-alive", "ws-dead", "ws-empty"]); + expect(result["ws-alive"]?.sessionId).toBe("alive-session"); + expect(result["ws-dead"]).toBeNull(); + expect(result["ws-empty"]).toBeNull(); + }); + + it("returns an empty object for empty input (defensive — never throws)", async () => { + const result = await getPaSessionsForWorkspaces([]); + expect(result).toEqual({}); + }); +}); diff --git a/packages/core/src/product-architect/pa-liveness.ts b/packages/core/src/product-architect/pa-liveness.ts new file mode 100644 index 0000000..4103843 --- /dev/null +++ b/packages/core/src/product-architect/pa-liveness.ts @@ -0,0 +1,124 @@ +/** + * On-demand PA-session liveness check for the workspace card chip + * + Status tab indicator. + * + * The boot-reconcile path (boot-reconcile.ts) runs ONCE at server + * startup to flip stale `running` pa-session events to `failed` via + * the same `process.kill(pid, 0)` primitive. This module exposes + * the per-workspace check as an API-callable function so the + * dashboard's "PA active" chip can reflect real liveness on every + * `/api/workspaces` poll — not just whatever the on-disk history + * status says. + * + * Side benefit beyond the chip itself: if a PA session terminates + * uncleanly (shell killed, terminal closed without an exit + * handshake), `status: "running"` lingers in history.json until + * the next server boot. This check correctly reports "not active" + * for that case in real time, no boot required. + * + * Mirrors the `getActiveAgent` / `getActiveAgentsForWorkspaces` + * pattern in `active-agent.ts` so the `/api/workspaces` enrichment + * follows a single shape. + */ + +import type { PaSessionHistoryEvent } from "../workspace-history.js"; +import { readHistory } from "../workspace-history.js"; + +export interface PaSessionLiveness { + active: true; + sessionId: string; + /** ISO timestamp when the PA session started. */ + startedAt: string; + /** Server-recorded launcher PID for the live process. */ + launcherPid: number; + /** History event id (for the UI to deep-link / filter). */ + eventId: string; +} + +/** + * Return liveness details for the workspace's PA session, or `null` + * if no live PA exists. + * + * Algorithm: + * 1. Read history events. + * 2. Filter to `pa-session` events with `status === "running"`. + * 3. Walk newest-first; the first one whose `launcherPid` is + * alive (per `process.kill(pid, 0)`) wins. + * 4. Pre-v0.24 events without `launcherPid` are skipped — there's + * no precise way to verify them, and a stale chip is worse + * than no chip. Those events are still cleaned up by + * boot-reconcile's mtime fallback at next server boot. + */ +export async function getPaSessionLiveness( + workspaceId: string, +): Promise { + let events; + try { + events = await readHistory(workspaceId); + } catch { + return null; // history file missing / unreadable; safest to claim no PA + } + + // Find pa-session events in `running` state. Sort newest-first so + // we surface the most recent live session if multiple exist + // (uncommon — usually one at most; defensive against accidental + // duplicates). + const candidates = events + .filter((e) => e.type === "pa-session" && e.status === "running") + .sort( + (a, b) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ) as PaSessionHistoryEvent[]; + + for (const ev of candidates) { + if (typeof ev.launcherPid !== "number") continue; + if (!isPidAlive(ev.launcherPid)) continue; + return { + active: true, + sessionId: ev.sessionId, + startedAt: ev.startedAt, + launcherPid: ev.launcherPid, + eventId: ev.id, + }; + } + return null; +} + +/** + * Batch resolver for multiple workspaces — used by the dashboard + * list endpoint. Same shape as + * `getActiveAgentsForWorkspaces(...)` for callsite symmetry. + */ +export async function getPaSessionsForWorkspaces( + workspaceIds: string[], +): Promise> { + const out: Record = {}; + for (const id of workspaceIds) { + out[id] = await getPaSessionLiveness(id); + } + return out; +} + +/** + * Primitive: is this PID currently running and signalable? + * + * `process.kill(pid, 0)` sends nothing — it just exercises the + * permission/lookup path. Throws on failure: + * - ESRCH ("no such process") → not running → return false + * - EPERM ("operation not permitted") → exists but owned by + * another user → return true (still alive, just not ours) + * + * Exported for direct use by tests + the boot-reconcile path + * (which has its own inline implementation today; could migrate + * to this helper for consistency, out of scope for this commit). + */ +export function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === "EPERM") return true; + return false; + } +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 40374b9..a73ac82 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -53,6 +53,7 @@ import { getAllActiveProcesses, getActiveProcess, killProcessTree, + getPaSessionsForWorkspaces, markReflectStateFailed, markDocumentStateFailed, markReviewStateFailed, @@ -372,11 +373,20 @@ export function createApp() { // Review/Document/Reflect runs (which don't touch workspace.status // by design — that field tracks the loop only). null when nothing // is running. + // + // v0.24.5: also enrich with `paSession` for an INDEPENDENT + // "PA active" chip. PA can run concurrently with the loop / + // standalone runs, so it's a parallel field — not a new + // `activeAgent` enum value. Two chips can coexist on the card. const ids = workspaces.map((w) => w.id); - const activeAgents = await getActiveAgentsForWorkspaces(ids); + const [activeAgents, paSessions] = await Promise.all([ + getActiveAgentsForWorkspaces(ids), + getPaSessionsForWorkspaces(ids), + ]); const enriched = workspaces.map((w) => ({ ...w, activeAgent: activeAgents[w.id] ?? null, + paSession: paSessions[w.id] ?? null, })); return c.json(enriched); }); diff --git a/packages/web/src/components/WorkspaceCard.tsx b/packages/web/src/components/WorkspaceCard.tsx index edd562a..7ff42c9 100644 --- a/packages/web/src/components/WorkspaceCard.tsx +++ b/packages/web/src/components/WorkspaceCard.tsx @@ -25,6 +25,14 @@ function activeAgentLabel(activeAgent: WorkspaceConfig["activeAgent"]): string | export function WorkspaceCard({ workspace }: { workspace: WorkspaceConfig }) { const activeLabel = activeAgentLabel(workspace.activeAgent); + // v0.24.5: independent chip for PA-session liveness. PA runs + // outside the cfcf server (interactive `cfcf spec`), so it's + // tracked separately from F.22's activeAgent and can coexist + // with it on the card. + const pa = workspace.paSession; + const paTooltip = pa + ? `PA session ${pa.sessionId} alive since ${new Date(pa.startedAt).toLocaleString()} (launcher PID ${pa.launcherPid})` + : ""; return (
)} + {pa && ( + + ● PA active + + )}
diff --git a/packages/web/src/pages/WorkspaceDetail.tsx b/packages/web/src/pages/WorkspaceDetail.tsx index 118f3fb..dcc996a 100644 --- a/packages/web/src/pages/WorkspaceDetail.tsx +++ b/packages/web/src/pages/WorkspaceDetail.tsx @@ -248,6 +248,21 @@ export function WorkspaceDetail({ workspaceId }: { workspaceId: string }) { {isReviewActive && review running} {isDocumentActive && document running} {isReflectActive && reflect running} + {/* v0.24.5: in-page chip for PA session liveness. Derived + from `history` (status === "running" on a pa-session + event) — matches the same pattern as the chips above + that read from in-memory state. PID-verified liveness + via the server-enriched `workspace.paSession` is on the + workspace card (dashboard surface); the in-page chip + trusts the same on-disk status the History tab does. */} + {history.some((e) => e.type === "pa-session" && e.status === "running") && ( + + PA active + + )}
Date: Mon, 18 May 2026 18:11:49 -0700 Subject: [PATCH 3/3] ux(history): rename to "Interactive sessions"; sections collapsible + visually distinct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfood feedback after PR #51 first pass: - "Interactive agents" → "Interactive sessions" (more accurate; each PA invocation IS a session, and the row content shows sessions, not agents-as-entities) - Sections previously looked visually similar; user wanted them optically distinct + collapsible Changes (display-only, no logic): -
+

+: native HTML collapsible (no React state). Disclosure triangle affordance, click-to-collapse, default open. Browser handles persistence across rerenders. - Each section gets a 3px left-border accent: interactive section uses var(--color-accent) (purple-ish), loop section uses var(--color-border) (subtle gray). Distinct at a glance. - Summary has a light background tint + radius to read as a header-like band rather than plain text. - Extracted LoopHistoryTable subcomponent so the loop section can render either inside a
(paired with interactive) or as a plain bare table (when no interactive events exist). The no-interactive-events case looks EXACTLY like today. No test changes — pure presentation tweaks. Existing partition tests still pass (10/10). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/components/WorkspaceHistory.tsx | 174 +++++++++++------- 1 file changed, 112 insertions(+), 62 deletions(-) diff --git a/packages/web/src/components/WorkspaceHistory.tsx b/packages/web/src/components/WorkspaceHistory.tsx index dd9f347..12d51d6 100644 --- a/packages/web/src/components/WorkspaceHistory.tsx +++ b/packages/web/src/components/WorkspaceHistory.tsx @@ -94,21 +94,38 @@ export function WorkspaceHistory({ // `utils/history-partition.ts` for the full rationale. const { interactive, loop } = partitionInteractiveEvents(events); + // Shared summary style: visual hierarchy + disclosure-triangle + // affordance via the native
/ element (no + // React state needed — browser handles open/close + persisted + // state across rerenders). Each section gets a left-border + // accent so the two sections feel optically distinct. + const summaryBase: React.CSSProperties = { + cursor: "pointer", + padding: "0.4rem 0.6rem 0.4rem 0.8rem", + margin: "0 0 0.4rem 0", + fontSize: "var(--text-md)", + fontWeight: 600, + color: "var(--color-text)", + background: "color-mix(in srgb, var(--color-text-muted) 6%, transparent)", + borderRadius: "4px", + userSelect: "none", + }; + return (
{interactive.length > 0 && ( -
-

- Interactive agents ({interactive.length}) -

+
+ + Interactive sessions ({interactive.length}) + @@ -132,62 +149,95 @@ export function WorkspaceHistory({ ))}
-
+
)} -
0 ? "1rem" : 0 }} - > - {interactive.length > 0 && ( -

+ {/* The loop section uses
+ only when the + interactive section is also rendered (so the two are + visually paired as collapsible sections). When there's + no interactive section, the loop renders as a plain + table — matches today's layout exactly, no disclosure + affordance for a single-section view. */} + {interactive.length > 0 ? ( +
+ Loop history ({loop.length}) -

- )} - - - - - - - - - - - - - - {loop.map((e) => - e.type === "iteration" ? ( - - ) : ( - - ), - )} - -
TimeTypeAgentStatusResultDurationLog
-
+
+ +
+ ) : ( + + )} ); } +/** + * The loop-events table extracted as a small subcomponent so it + * can be rendered either inside a
(when the Interactive + * section is present and both should be collapsible) or as a + * plain bare table (when there are no interactive events — keeps + * today's flat layout for the common case). + */ +function LoopHistoryTable({ + loop, + workspaceId, + onSelectLog, +}: { + loop: HistoryEvent[]; + workspaceId: string; + onSelectLog: (target: LogTarget) => void; +}) { + return ( + + + + + + + + + + + + + + {loop.map((e) => + e.type === "iteration" ? ( + + ) : ( + + ), + )} + +
TimeTypeAgentStatusResultDurationLog
+ ); +} + function HistoryRow({ event, workspaceId,