feat(pi-workflow): improve workflow usability and recovery

This commit is contained in:
jb
2026-08-26 00:20:24 -04:00
parent c291ca083c
commit ea74dbc6a7
23 changed files with 4422 additions and 913 deletions
@@ -0,0 +1,52 @@
---
status: accepted
supersedes: 0001-pi-workflow-architecture-24AUG2026.md
---
# Use drill-down overlays and bounded recovery
This decision supersedes the dashboard, dirty-source, execution-timeout, and startup-ownership assumptions in [ADR 0001](./0001-pi-workflow-architecture-24AUG2026.md). The persisted gated pipeline, worktree isolation, and independent-review decisions from ADR 0001 remain accepted.
## Context
Three managed runs exposed usability and liveness gaps:
- `512d6c31-5bde-4e93-8164-5d31fb684b00` reached Design, described prototype work, but emitted no `prototype` Assignment. The engine failed with `invalid-design-output: Design must select exactly one prototype Assignment`.
- `51b0c1cb-f5b2-47f9-8c0c-b323307b720b` was interrupted during Discovery.
- `ecb11e1e-fe38-4076-862e-739498dbe33b` completed Discovery, then remained in the feature-designer Assignment for nearly five minutes before interruption.
The prior focused dashboard did not scale cleanly from run selection through Gate decisions and long verified artifacts. Fresh-session selection returned instructions instead of entering the already implemented continuation path. A dirty source checkout blocked all starts even when the user understood that a new worktree starts from `HEAD`. Internal Git calls and child Assignments lacked complete deadlines.
Static prototypes compared a centered compact modal, a near-full-screen workspace, and shallow drill-down overlays with a dedicated reader. Direction C provided the clearest responsive behavior without a permanent side pane.
## Decision
- TUI run selection, actions, Gates, confirmations, questions, and feedback use shallow popup overlays. Escape returns one level.
- Verified plans, reports, prototype evidence, and Assignment transcripts use a sanitized full-screen reader. It shows an H1-H3 outline at 80 columns and wider, uses one document pane from 60 through 79 columns, and reports a 48x12 minimum below either boundary.
- RPC, print, and JSON interfaces remain linear and never invoke custom TUI overlays.
- A dashboard fresh-session action immediately calls the existing `/workflow continue` handler and `newSession()` flow. Noninteractive callers continue with an explicit run ID.
- One widget above Pi's editor shows the newest active run attached to the current session. Persisted state drives refresh and terminal state clears it.
- Startup may adopt a continuable run when its recorded process is dead or its lease is absent. Adoption atomically rotates the lease and Implementation Context active-parent IDs. Live processes and live Assignments remain fail-closed.
- Worktrees continue to start from a pinned source `HEAD`. Ordinary staged, unstaged, and untracked changes produce bounded path/status evidence and require explicit authorization because those changes are omitted. Unmerged paths remain blocked.
- Internal Git calls have a 30-second timeout. Approved host commands retain their three-minute timeout. Process-tree termination has a separate deadline and distinguishes command timeout exit `124` from cleanup failure exit `125`.
- Child Assignment execution has a five-minute deadline and persists timeout/interruption evidence.
- The feature-designer contract requires exactly one eligible, isolated prototype Assignment for non-trivial UI work, plus explicit implementation, verification, and independent-review Assignments.
## Consequences
- Dashboard behavior is easier to reason about because each overlay has one local task and the verified reader owns long-document navigation.
- Interactive continuation no longer requires a second command, while the persisted handoff and revision fences remain authoritative.
- Dead ownership can recover without manual state surgery. A running process or Assignment cannot be displaced based only on an old heartbeat.
- Users may proceed from a dirty source checkout without copying local changes into the worktree. Persisted evidence records what was knowingly omitted.
- Git, host-command cleanup, and child execution now have bounded failure paths rather than indefinite waits.
- Direction C has automated renderer and orchestration coverage. Its completed manual application validation is recorded below. The static frames in `docs/pi-workflow/dashboard-mocks.md` remain labeled prototype evidence, not production screenshots.
## Manual application validation
An actual Pi TUI session driven through a PTY passed these checks:
- Plan reader at `120x30`, `80x24`, `60x24`, and `47x11`.
- Selection popup at `60x24`.
- Input popup at `80x24`.
This record does not claim manual validation of other dashboard or workflow paths.
+7 -5
View File
@@ -20,7 +20,7 @@ You can also ask the assistant to use `pi_workflow` with:
- The feature request - The feature request
- A unique idempotency key - A unique idempotency key
The extension creates `feature/<feature-name>` in an exclusive worktree. Existing branch names are rejected rather than silently renamed. The extension creates `feature/<feature-name>` in an exclusive worktree pinned to the source checkout's current `HEAD`. Ordinary staged, unstaged, or untracked source changes are not copied into that worktree. In TUI mode the extension shows bounded path/status evidence and requires explicit confirmation before omitting those changes. Tool, RPC, print, and JSON callers must pass `allowDirtySource: true`. Unmerged paths remain blocked. Existing branch names are rejected rather than silently renamed.
The managed flow is: The managed flow is:
@@ -30,14 +30,16 @@ The managed flow is:
4. Choose a fresh plan-seeded session, compacted current session, or unchanged current context. 4. Choose a fresh plan-seeded session, compacted current session, or unchanged current context.
5. Implement with guarded file ownership. 5. Implement with guarded file ownership.
6. Remove prototype-only behavior. 6. Remove prototype-only behavior.
7. Run plan-approved verification commands and focused tests. Each host command is terminated after three minutes so an interactive or stalled command cannot block the run indefinitely. 7. Run plan-approved verification commands and focused tests. Approved host commands retain a three-minute timeout, internal Git commands have a 30-second timeout, and process-tree cleanup is bounded.
8. Complete independent specification and standards review. 8. Complete independent specification and standards review.
The assistant must inspect the current run before each state-changing call and obtain the user's explicit Gate Decision. It must not infer approval. The assistant must inspect the current run before each state-changing call and obtain the user's explicit Gate Decision. It must not infer approval.
## Inspect and continue ## Inspect and continue
Use `/workflow` for the focused-card dashboard. It lists runs attached to the current session and shows persisted status, progress, Gate, plan, report, branch, and worktree information. Human-facing surfaces use the feature name as the run label. Analysis and legacy runs fall back to a request label of at most 48 characters, then to the run ID when no request exists. Duplicate labels in one available-run list receive ` · <run-id-prefix>`, using the shortest unique prefix of at least eight characters. Use `/workflow` for the drill-down dashboard. TUI mode opens shallow popup overlays for the run list, run actions, Gates, confirmations, questions, and feedback. Plans, reports, prototype evidence, and Assignment transcripts open in a sanitized full-screen reader. Escape returns one level. RPC, print, and JSON modes remain linear and never open custom overlays.
The dashboard lists runs attached to the current session and shows persisted status, core-stage progress, Gate, plan, report, branch, and worktree information. While work is active, one line above Pi's editor tracks only the newest attached run and clears when no attached run remains active. Human-facing surfaces use the feature name as the run label. Analysis and legacy runs fall back to a request label of at most 48 characters, then to the run ID when no request exists. Duplicate labels in one available-run list receive ` · <run-id-prefix>`, using the shortest unique prefix of at least eight characters.
Use `/workflow continue` to select a run with a pending fresh-session handoff or normal-compaction continuation. `/workflow resume` is an alias. A run ID may be supplied directly: Use `/workflow continue` to select a run with a pending fresh-session handoff or normal-compaction continuation. `/workflow resume` is an alias. A run ID may be supplied directly:
@@ -46,7 +48,7 @@ Use `/workflow continue` to select a run with a pending fresh-session handoff or
/workflow resume <run-id> /workflow resume <run-id>
``` ```
Selection requires interactive TUI mode. In non-interactive modes, provide the run ID. Labels are presentation-only and cannot be used as lookup aliases; copyable continue and stop commands always retain the full run ID. They require no schema migration and do not change persisted paths. Selection requires interactive TUI mode. In non-interactive modes, provide the run ID. Choosing a pending fresh-session continuation in the dashboard immediately enters the existing `newSession()` handoff path; it does not require a second command. Tool and RPC callers receive the explicit `/workflow continue <run-id>` continuation instead. Labels are presentation-only and cannot be used as lookup aliases; copyable continue and stop commands always retain the full run ID. They require no schema migration and do not change persisted paths.
## Stop active work ## Stop active work
@@ -70,7 +72,7 @@ An analysis run uses `action: "start"` with `workflow: { mode: "analysis-script"
## Recovery ## Recovery
Interrupted work opens a Recovery Gate with retry, inspect, and abort choices. Completed Assignments and Decisions remain persisted. Potentially mutating interrupted Assignments are preserved and are not replayed automatically; an explicit retry creates newly fenced work where policy permits it. Startup recovery leaves an idle run already waiting at Recovery under its current parent instead of letting another Pi session claim it. If that parent's process has exited, an explicit retry from a previously attached parent session may reclaim the stale lease before continuing. Retry clears prior failed-command output and reruns verification from a pending state. Once remediation completes the Implement stage, an older interrupted mutating Assignment no longer triggers duplicate remediation on later retries. Interrupted work opens a Recovery Gate with retry, inspect, and abort choices. Completed Assignments and Decisions remain persisted. Potentially mutating interrupted Assignments are preserved and are not replayed automatically; an explicit retry creates newly fenced work where policy permits it. Startup recovery may adopt a continuable run when its recorded owner process is dead or absent. Adoption atomically rotates the lease and Implementation Context parent IDs. A live owner or live Assignment remains fail-closed, and historical sessions remain inspection-only unless such an adoption makes one active. Retry clears prior failed-command output and reruns verification from a pending state. Once remediation completes the Implement stage, an older interrupted mutating Assignment no longer triggers duplicate remediation on later retries.
## Artifacts ## Artifacts
+244 -111
View File
@@ -1,151 +1,284 @@
# pi-workflow dashboard mocks # pi-workflow popup prototypes
These are static TUI direction mocks for `/workflow`. They are not implementation screenshots. **Status:** Direction C accepted
## A. Split workspace These static terminal frames compare popup directions for `/workflow`. They are prototype evidence, not production screenshots or manual validation of the implemented extension. All variants use Pi theme tokens, keyboard-visible focus, one border at most, and no animation.
Best for comparing Stage progress and Assignment detail without navigation. Representative run: `pi-workflow-usability-resume`, waiting at Plan Approval after Discover and Design.
## A. Centered compact modal
A small modal preserves the surrounding conversation and keeps one decision in focus.
```text ```text
pi-workflow 118 cols ┌──────────────────────────────────────────────────────────────────────┐
│ │
Runs feature/search-page · waiting │ ╭─ pi-workflow ──────────────────────────────╮ │
> search-page waiting plan-approval · revision 12 │ │ pi-workflow-usability-resume │ │
auth-cleanup running Worktree .worktrees/pi-workflow-search-page-r1 │ │ WAITING · Plan Approval · revision 12 │ │
cache-audit done Branch feature/search-page │ │ │ │
Plan artifacts/plan-11-a31f.md │ │ ✓ Discover ✓ Design · Prototype │ │
Stages Report pending │ │ · Implement · Verify · Review │ │
✓ Discover │ │ │ │
✓ Design Open Gate · Plan Approval │ │ > Review plan │ │
· Prototype UI impact: non-trivial │ │ Approve plan │ │
· Implement Review the plan and UI evidence before deciding. │ │ Request revision │ │
· Verify │ │ Close │ │
· Review [a] approve [r] revise [o] open plan │ │ │ │
│ │ ↑↓ navigate enter select esc close │ │
↑↓ run tab pane enter action esc close │ ╰─────────────────────────────────────────────╯ │
│ │
└──────────────────────────────────────────────────────────────────────┘
``` ```
Narrow terminal: Strengths:
- Minimal visual interruption.
- Familiar one-step prompt behavior.
- Low chrome and clear focus.
Weaknesses:
- Long plans and detailed run state require repeated modal replacement.
- Context disappears quickly as the interaction deepens.
- Poor fit for browsing artifacts or transcripts.
### A at 60 columns
```text ```text
pi-workflow · search-page ╭─ pi-workflow ───────────────────────╮
waiting · plan-approval · r12 │ usability-resume · WAITING │
│ Plan Approval · r12 │
✓ Discover ✓ Design · Prototype │ │
· Implement · Verify · Review │ > Review plan │
│ Approve plan │
Plan Approval │ Request revision │
UI impact: non-trivial │ Close │
[o] plan [enter] actions │ │
│ ↑↓ enter esc │
╰─────────────────────────────────────╯
``` ```
## B. Focused run cards ## B. Near-full-screen workspace
Minimal chrome. The list stays compact; selecting a run opens one vertically ordered detail card. A stable workspace keeps the run list, selected run, progress, and actions visible together.
```text ```text
pi-workflow ╭─ pi-workflow ────────────────────────────────────────────────────────────────╮
│ Runs │ pi-workflow-usability-resume WAITING │
> search-page WAITING Plan Approval r12 │ │ feature/pi-workflow-usability-resume · r12 │
auth-cleanup RUNNING Implement r31 │ > usability-resume │ │
cache-audit DONE report available r18 │ auth-cleanup │ Progress │
│ cache-audit │ ✓ Discover ✓ Design · Prototype · Implement │
──────────────────────────────────────────────────────────── │ │ · Verify · Review │
search-page │ │ │
feature/search-page │ │ Open Gate │
│ │ Plan Approval │
Progress Discover ✓ Design ✓ Prototype · Implement · │ │ Review the implementation plan before work begins. │
Verify · Review · │ │ │
│ │ > Review plan │
Gate Plan Approval │ │ Approve plan │
non-trivial UI · 3 prototype candidates required │ │ Request revision │
│ │ Browse artifacts │
Artifacts plan-11-a31f.md │ │ Close │
Worktree .worktrees/pi-workflow-search-page-r1 │ │ │
│ ↑↓ run tab focus │ ↑↓ action enter select esc back │
[enter] actions [o] artifact [esc] close ╰──────────────────────────────────────────────────────────────────────────────╯
``` ```
Action sheet: Strengths:
- High information density with stable spatial context.
- Fast comparison between several active runs.
- Actions and state remain visible together.
Weaknesses:
- The permanent left pane spends space on uncommon multi-run comparison.
- Focus management and responsive collapse are more complex.
- At 80 columns the content pane becomes cramped; below 80 it must become a different UI.
### B at 80x24
```text ```text
search-page · Plan Approval ╭─ pi-workflow ────────────────────────────────────────────────────────────────╮
│ Runs │ usability-resume · WAITING · r12 │
> Inspect plan │ > usability │ ✓ Discover ✓ Design · Prototype │
Record approval │ auth-cleanup │ · Implement · Verify · Review │
Request revision │ cache-audit │ │
Copy worktree path │ │ Plan Approval │
Close │ │ > Review plan │
│ │ Approve plan │
│ │ Request revision │
│ │ Browse artifacts │
│ │ │
│ tab focus │ ↑↓ action enter select esc back │
╰──────────────────────────────────────────────────────────────────────────────╯
``` ```
Prototype Gate: ### B at 60 columns
The workspace must collapse to a single run-detail pane. The run list becomes a prior drill-down view, effectively converging on direction C.
## C. Drill-down overlays with dedicated readers
Direction C uses shallow browser-style navigation. Each overlay does one job. Escape always returns one level.
### C1. Run list popup
```text ```text
search-page · Prototype Acceptance ╭─ pi-workflow ─────────────────────────────╮
│ > usability-resume WAITING Plan Approval│
1 Sidebar navigation /prototype?variant=1 │ auth-cleanup RUNNING Implement │
mobile ✓ desktop ✓ browser evidence retained │ cache-audit DONE report ready │
2 Command palette /prototype?variant=2 │ │
mobile ✓ desktop ✓ browser evidence retained │ ↑↓ navigate enter open esc close │
3 Inline workspace /prototype?variant=3 ╰────────────────────────────────────────────╯
mobile ✓ desktop ✓ browser evidence retained
> Open candidate 1
Accept candidate 1
Request refinement
Abort run
``` ```
Narrow terminal uses the same vertical card and hides paths until `o`. ### C2. Run detail popup
## C. Event timeline
Best for recovery and auditability. The primary view is an immutable history; actions belong to the current event.
```text ```text
pi-workflow · search-page WAITING ╭─ usability-resume ───────────────────────────────────╮
│ WAITING · Plan Approval · revision 12 │
12:04 ✓ Discover 2 assignments │ │
12:09 ✓ Questions decision recorded │ ✓ Discover ✓ Design · Prototype · Implement │
12:15 ✓ Design plan revision 11 │ · Verify · Review │
12:16 ● Plan Approval waiting for Decision │ │
│ > Review plan │
Current │ Record Gate Decision │
UI impact non-trivial │ Browse prototype evidence │
Plan plan-11-a31f.md │ Browse Assignment transcripts │
Worktree feature/search-page │ Show worktree path │
│ │
[enter] current actions [p] prototypes [r] report [esc] │ ↑↓ navigate enter select esc runs │
╰──────────────────────────────────────────────────────╯
``` ```
Run list: ### C3. Plan reader at wide width
The reader uses the whole overlay. The terminal edge is the outer frame, so there is no decorative full-screen border. The left outline tracks the heading containing the top visible document line.
```text ```text
Recent workflow events pi-workflow plan · usability-resume line 42/216
> search-page Plan Approval 2m Outline Plan
auth-cleanup Implement 8m ───────────────────────────── ─────────────────────────────────────────────
cache-audit Completed 31m pi-workflow usability update # pi-workflow usability update plan
Locked decisions
> Implementation phases ## Implementation phases
Prototype overlays
Session lifecycle ### 2. Session lifecycle and recovery
Worktree safety
Popup TUI - Route the dashboard's fresh-session action
Progress widget through the existing continuation path.
Design regression
Acceptance criteria - Atomically rotate lease and context owners
during absent/dead adoption.
Primary files: dashboard.ts,
context-transition.ts, continuation.ts,
engine.ts, and focused tests.
↑↓ scroll pgup/pgdn page home/end document esc back
``` ```
If labels collide in this available list, only the duplicates gain the shortest unique run-ID prefix (at least eight characters): ### C3 at 80x24
```text ```text
> search-page · 12345678a Plan Approval pi-workflow plan · usability-resume row 42/216
search-page · 12345678b Implement
cache-audit Completed Outline Plan
────────────────────── ─────────────────────────────────────────────
Locked decisions ## Implementation phases
> Implementation
Prototype overlays ### 2. Session lifecycle and recovery
Session lifecycle - Route the dashboard's fresh-session action
Worktree safety through the existing continuation path.
Popup TUI
Progress widget - Atomically rotate lease and context owners
Acceptance criteria during absent/dead adoption.
↑↓ scroll pgup/pgdn page home/end document esc back
``` ```
Narrow terminals preserve the same timeline and omit timestamps first. ### C3 at 60 columns
## Direction criteria The outline hides and the document becomes the only pane. The current heading remains visible in the header.
The implementation should: ```text
pi-workflow plan · Implementation phases · row 42/216
- Start dark-mode friendly through Pi theme tokens. ## Implementation phases
- Keep the default view compact and reveal paths, evidence, and transcripts on demand.
- Show only valid actions for the current persisted Gate or continuation intent. ### 2. Session lifecycle and recovery
- Use feature-name-first presentation labels, with bounded request fallbacks and collision suffixes only when needed.
- Keep the full run ID out of ordinary prose while retaining it in copyable commands, structured details, and report metadata. - Route the dashboard's fresh-session action through the
- Keep run status, revision, branch, worktree, plan, report, and prototype evidence discoverable. existing continuation path.
- Remain usable from narrow terminals through 4K-width terminals without continuous animation.
- Atomically rotate lease and context owners during
absent/dead adoption.
↑↓ scroll pgup/pgdn page home/end document esc back
```
### C3 at 47x11
```text
terminal too small
pi-workflow plan needs 48x12
current terminal is 47x11
resize the terminal or press esc
```
Strengths:
- Each screen has one dominant task and a complete local keymap.
- Best responsive behavior because narrow layouts remain single-pane.
- Long plans and transcripts get dedicated virtualized readers.
- The shallow stack preserves context without permanent side-panel cost.
Weaknesses:
- Comparing several runs takes more navigation than direction B.
- Back-stack state and overlay focus/disposal require careful implementation.
## Persistent progress widget
The widget is independent of the selected popup direction and stays above Pi's editor while the newest attached run is active.
Standard:
```text
workflow usability-resume DESIGN 2/6 Gate: Plan Approval
```
Narrow:
```text
workflow usability-resume DESIGN 2/6
```
Terminal runs disappear rather than leaving a stale receipt above the editor.
## Recommendation
Use direction C. It has one border only on bounded popup cards, no redundant frame around the full-screen reader, keyboard-visible selection, and the most truthful 80-column and 60-column degradation. Direction B spends too much space on multi-run comparison, while direction A does not provide enough stable space for plans and evidence.
## Production mapping
The accepted direction maps to `extensions/pi-workflow/tui.ts` and `dashboard.ts`. The implementation uses popup overlays for bounded selections and text entry, then switches to the full-screen reader for verified plans and retained evidence. `progress.ts` owns the separate one-line widget. Pure renderer tests cover the wide, 80-column, 60-column, and below-48x12 contracts; interactive Pi validation remains a separate manual step.
## Interaction contract
- Arrow keys navigate every selection surface; Enter selects; Escape cancels or returns one level.
- Page Up/Page Down and Home/End apply only to long-document readers.
- Text inputs use Pi's `Input` or `Editor` components and propagate focus for IME positioning.
- The current selection uses position plus selected background or reverse video, never color alone.
- TUI overlays are never opened in RPC, print, or JSON mode.
- Every rendered line is truncated or wrapped by terminal cell width rather than JavaScript string length.
- No continuously repainting animation is used.
+17 -9
View File
@@ -74,6 +74,7 @@ type PiWorkflowInput =
| { mode: "analysis-script"; source: string; args?: string }; | { mode: "analysis-script"; source: string; args?: string };
concurrency?: number; concurrency?: number;
background?: boolean; background?: boolean;
allowDirtySource?: boolean;
idempotencyKey: string; idempotencyKey: string;
} }
| { | {
@@ -151,7 +152,9 @@ Feature and feature-script starts require `featureName` as a concise lowercase k
Human-facing dashboard, selector, notification, handoff, compaction, tool, and report prose uses a shared presentation label. It prefers `featureName`; otherwise it normalizes the request to one line and bounds it to 48 characters, preserving whole words where possible and adding `…` when truncated; only a missing request falls back to `runId`. When derived labels collide within an available run list, only those labels receive ` · <prefix>`, where the run-ID prefix is the shortest unique prefix with a minimum of eight characters. This label is never persisted as identity or accepted as a lookup alias. It requires no schema migration and does not change persisted paths. The complete `runId` remains canonical for snapshots, structured fields, command arguments, paths, receipts, leases, and idempotency. Copyable `/workflow continue <run-id>` and `/workflow stop <run-id>` commands retain it, and reports include it as `Run ID` metadata beneath the readable heading. Human-facing dashboard, selector, notification, handoff, compaction, tool, and report prose uses a shared presentation label. It prefers `featureName`; otherwise it normalizes the request to one line and bounds it to 48 characters, preserving whole words where possible and adding `…` when truncated; only a missing request falls back to `runId`. When derived labels collide within an available run list, only those labels receive ` · <prefix>`, where the run-ID prefix is the shortest unique prefix with a minimum of eight characters. This label is never persisted as identity or accepted as a lookup alias. It requires no schema migration and does not change persisted paths. The complete `runId` remains canonical for snapshots, structured fields, command arguments, paths, receipts, leases, and idempotency. Copyable `/workflow continue <run-id>` and `/workflow stop <run-id>` commands retain it, and reports include it as `Run ID` metadata beneath the readable heading.
The `pi_workflow` tool calls a single internal `execute(command)` interface. `/workflow` opens the dashboard and may accept a run ID for direct inspection. `/workflow start <message>` sends the initial request through the assistant so the normal `pi_workflow` tool path owns feature naming and idempotency; in interactive TUI mode, omitting the message opens an input prompt. `/workflow continue` presents eligible pending context transitions for selection, while `/workflow continue <run-id>` continues one directly; `/workflow resume` is an alias for both forms. `/workflow stop [run-id]` aborts a non-terminal run owned by the active parent session and presents a selector when multiple owned runs are active. The misspelled `/worlflow` alias is intentionally unsupported. Continue and stop selectors require interactive TUI mode and provide direct-run-ID guidance otherwise. The `pi_workflow` tool calls a single internal `execute(command)` interface. `/workflow` opens a drill-down TUI dashboard and may accept a run ID for direct inspection. Run selection, actions, Gates, confirmations, questions, and feedback use bounded popup overlays. Verified plans, reports, prototype evidence, and Assignment transcripts use a sanitized full-screen reader with H1-H3 outline navigation at widths of 80 columns or more and a single document pane from 60 through 79 columns. Below 48x12 it renders only a truthful minimum-size state. RPC, print, and JSON paths remain linear and do not call `ctx.ui.custom()`.
`/workflow start <message>` sends the initial request through the assistant so the normal `pi_workflow` tool path owns feature naming and idempotency; in interactive TUI mode, omitting the message opens an input overlay. `/workflow continue` presents eligible pending context transitions for selection, while `/workflow continue <run-id>` continues one directly; `/workflow resume` is an alias for both forms. Selecting a fresh-session continuation from the dashboard routes directly through the same handler and calls `newSession()` without a second command. `/workflow stop [run-id]` aborts a non-terminal run owned by the active parent session and presents a selector when multiple owned runs are active. The misspelled `/worlflow` alias is intentionally unsupported. Continue and stop selectors require interactive TUI mode and provide direct-run-ID guidance otherwise.
Continuation performs only a pending fresh-session handoff or normal-compaction fallback after the run is safely waiting; it cannot approve plans, select prototypes, or make other Decisions. Stop treats `running` and `waiting` runs as active and excludes terminal runs and runs owned only by historical parent sessions. Continuation performs only a pending fresh-session handoff or normal-compaction fallback after the run is safely waiting; it cannot approve plans, select prototypes, or make other Decisions. Stop treats `running` and `waiting` runs as active and excludes terminal runs and runs owned only by historical parent sessions.
@@ -346,7 +349,7 @@ Initialize
- Resolve effective configuration, validate the feature name, and validate the repository without mutation. - Resolve effective configuration, validate the feature name, and validate the repository without mutation.
- Load, validate, and pin Agent Profiles from the source checkout. - Load, validate, and pin Agent Profiles from the source checkout.
- Verify that eligible discovery, design, implementation, verification, and independent review profiles exist before mutating the repository. - Verify that eligible discovery, design, implementation, verification, and independent review profiles exist before mutating the repository.
- For feature mode, verify that the configured worktree root is ignored by Git and that the source checkout is clean. - For feature mode, verify that the configured worktree root is ignored by Git. Capture at most 20 sorted path/status records plus an omitted count for staged, unstaged, or untracked source changes. Unmerged paths always block creation. Ordinary changes require explicit TUI confirmation or `allowDirtySource: true` from a noninteractive caller.
- Persist a run snapshot with workspace state `creating` before invoking Git. - Persist a run snapshot with workspace state `creating` before invoking Git.
- Create a distinct worktree transactionally, then persist workspace state `ready`. - Create a distinct worktree transactionally, then persist workspace state `ready`.
- On creation failure, roll back what can be removed safely, persist the failure, and leave enough intent for startup reconciliation. - On creation failure, roll back what can be removed safely, persist the failure, and leave enough intent for startup reconciliation.
@@ -405,7 +408,8 @@ Mutating Assignments run in the Workflow Run's worktree.
- Mutating child sessions do not receive `bash` or other process-execution tools. Assignment-scoped file operations reject symlink traversal and require canonical containment beneath both the worktree root and the Assignment's owned path roots. - Mutating child sessions do not receive `bash` or other process-execution tools. Assignment-scoped file operations reject symlink traversal and require canonical containment beneath both the worktree root and the Assignment's owned path roots.
- Plan-approved verification and generation commands run separately through a host executor using fixed executable/argument arrays, never agent-authored shell strings. The only accepted wrapper form is `env PI_OFFLINE=1 <executable> ...`; the executor unwraps it, sets that single variable, and applies the normal executable safety checks. - Plan-approved verification and generation commands run separately through a host executor using fixed executable/argument arrays, never agent-authored shell strings. The only accepted wrapper form is `env PI_OFFLINE=1 <executable> ...`; the executor unwraps it, sets that single variable, and applies the normal executable safety checks.
- The host executor sets `PYTHONDONTWRITEBYTECODE=1` so Python-backed checks do not create out-of-scope `__pycache__` artifacts. - The host executor sets `PYTHONDONTWRITEBYTECODE=1` so Python-backed checks do not create out-of-scope `__pycache__` artifacts.
- Every host command has a three-minute execution timeout and reports exit code `124` when terminated, preventing interactive or stalled commands from blocking a run indefinitely. - Every host command has a three-minute execution timeout and reports exit code `124` when terminated. Internal Git probes and worktree commands have a 30-second timeout. Process-tree termination, including Windows `taskkill`, has its own deadline and reports exit code `125` when cleanup cannot be completed.
- Every child Assignment has a five-minute execution deadline. Timeout aborts the child signal and persists the Assignment as `interrupted` with `assignment-timeout` evidence rather than reporting success.
- The engine records branch, HEAD, worktree identity, and Git diff state before and after every mutating Assignment and host command. - The engine records branch, HEAD, worktree identity, and Git diff state before and after every mutating Assignment and host command.
- Out-of-scope changes, branch movement, commits, or worktree metadata changes invalidate the Assignment and open a Recovery Gate before more mutation starts. - Out-of-scope changes, branch movement, commits, or worktree metadata changes invalidate the Assignment and open a Recovery Gate before more mutation starts.
- The scheduler reserves capacity synchronously before launching children. - The scheduler reserves capacity synchronously before launching children.
@@ -480,7 +484,7 @@ feature/<feature-name>
`<feature-name>` comes from the validated `featureName` start input and describes the feature rather than the workflow mechanism. Any existing branch with that name rejects a new start. If persisted state associates the branch with a Workflow Run, the error returns its run ID and directs the caller to continue it through `inspect` or `decide`; otherwise it directs the caller to choose another descriptive name. The extension never adds a numeric or run-ID suffix silently. `<feature-name>` comes from the validated `featureName` start input and describes the feature rather than the workflow mechanism. Any existing branch with that name rejects a new start. If persisted state associates the branch with a Workflow Run, the error returns its run ID and directs the caller to continue it through `inspect` or `decide`; otherwise it directs the caller to choose another descriptive name. The extension never adds a numeric or run-ID suffix silently.
The configured worktree root must already be ignored by Git. The extension verifies this with Git before persisting creation intent and fails with setup guidance rather than modifying `.gitignore` automatically. A dirty source checkout blocks creation so uncommitted work is never silently omitted. The configured worktree root must already be ignored by Git. The extension verifies this with Git before persisting creation intent and fails with setup guidance rather than modifying `.gitignore` automatically. A worktree is created from the pinned source `HEAD`; source-only staged, unstaged, and untracked changes are not copied. The engine persists bounded path/status evidence and whether omission was explicitly allowed. TUI callers must confirm a warning that states those changes are omitted. Noninteractive callers must set `allowDirtySource: true`. Conflicts and unmerged paths always block creation.
The engine records an ownership lease for the run and refuses to adopt or share another run's worktree. The completed worktree remains available for inspection and manual integration. Removal is explicit and never part of normal completion. The engine records an ownership lease for the run and refuses to adopt or share another run's worktree. The completed worktree remains available for inspection and manual integration. Removal is explicit and never part of normal completion.
@@ -512,7 +516,7 @@ Writes are atomic and mode `0600`. Serialization, transcript, source, argument,
The snapshot contains Decisions and prior Assignment outcomes, so a separate event store is unnecessary. Every `start`, `decide`, and `abort` command requires an idempotency key. The launch key and initial result are persisted before workspace creation. State-changing commands use revisions and idempotency keys to reject stale or duplicate transitions. Repeating a completed key returns its recorded result without another transition. The snapshot contains Decisions and prior Assignment outcomes, so a separate event store is unnecessary. Every `start`, `decide`, and `abort` command requires an idempotency key. The launch key and initial result are persisted before workspace creation. State-changing commands use revisions and idempotency keys to reject stale or duplicate transitions. Repeating a completed key returns its recorded result without another transition.
An active run owns a persisted lease containing process identity, active parent-session identity, heartbeat time, and worktree identity. The run retains every parent session ID attached through a handoff so `/workflow` can discover it from either session, but only the active ID may mutate the run. The narrow exception is an explicit Recovery retry from a retained parent ID after the recorded lease process has died; that retry reclaims the stale lease before applying the Decision. Startup and session recovery reconcile stale leases, child processes, workspace-creation intent, Git identity, context handoffs, compaction intents, and assignments left `running`. An idle run that already has an open Recovery Gate is left attached to its current parent so unrelated Pi sessions cannot steal ownership during startup. An active run owns a persisted lease containing process identity, active parent-session identity, heartbeat time, and worktree identity. The run retains every parent session ID attached through a handoff so `/workflow` can discover it from either session, but only the active ID may mutate the run. Startup or explicit Recovery may adopt a continuable run only when its recorded owner process is dead or its lease is absent. Adoption atomically rotates both `lease.activeParentSessionId` and `implementationContext.activeParentSessionId`, appends the adopting session to `parentSessionIds`, and preserves revision fencing. A live process or live Assignment blocks adoption even when its heartbeat is old. Startup and session recovery reconcile child processes, workspace-creation intent, Git identity, context handoffs, compaction intents, and assignments left `running`.
Fresh-session handoff intent is durable before `newSession()`. A `session_shutdown` with reason `new` bypasses ordinary interruption recovery only when its source session matches the pending intent; that handler atomically claims the event's `targetSessionFile` and marks the intent `claimed-target-awaiting-withSession`. The claimed target's immediately following `session_start` is exempt from recovery only when its reason is `new` and `previousSessionFile` matches the source. The replacement `withSession` callback must then match the claimed target before it atomically consumes the intent, rotates the active parent-session ID, records both session IDs, and resumes delivery. Any later `session_start` caused by `startup`, `reload`, `resume`, or `fork`, or a bounded claim expiry before consumption, follows normal recovery and leaves implementation blocked. Fresh-session handoff intent is durable before `newSession()`. A `session_shutdown` with reason `new` bypasses ordinary interruption recovery only when its source session matches the pending intent; that handler atomically claims the event's `targetSessionFile` and marks the intent `claimed-target-awaiting-withSession`. The claimed target's immediately following `session_start` is exempt from recovery only when its reason is `new` and `previousSessionFile` matches the source. The replacement `withSession` callback must then match the claimed target before it atomically consumes the intent, rotates the active parent-session ID, records both session IDs, and resumes delivery. Any later `session_start` caused by `startup`, `reload`, `resume`, or `fork`, or a bounded claim expiry before consumption, follows normal recovery and leaves implementation blocked.
@@ -553,7 +557,9 @@ Children cannot access recursive workflow or subagent tools or `ask_user`. `pi_w
- `store.ts`: atomic revisioned snapshots and bounded artifacts. - `store.ts`: atomic revisioned snapshots and bounded artifacts.
- `workspace.ts`: worktree creation, ownership conflict checks, and lifecycle. - `workspace.ts`: worktree creation, ownership conflict checks, and lifecycle.
- `context-transition.ts`: plan handoffs, new-session replacement, smart/default compaction callbacks, and session attachment. - `context-transition.ts`: plan handoffs, new-session replacement, smart/default compaction callbacks, and session attachment.
- `dashboard.ts`: run inspection, continuation handoff, and prototype/report links. - `dashboard.ts`: drill-down run actions, Gate orchestration, continuation handoff, and artifact selection.
- `tui.ts`: reusable popup inputs/selections and the full-screen verified-document reader.
- `progress.ts`: newest-attached-run projection and the bounded editor widget.
- `agents/*.md`: bundled default profiles. - `agents/*.md`: bundled default profiles.
Pure transition and eligibility logic stay inside the engine and profile modules rather than behind speculative adapter interfaces. Tests may inject filesystem, executor, decision, and script-runtime adapters at the composition root. Pure transition and eligibility logic stay inside the engine and profile modules rather than behind speculative adapter interfaces. Tests may inject filesystem, executor, decision, and script-runtime adapters at the composition root.
@@ -592,7 +598,7 @@ Implement the command interface, legal transitions, closed Gate Decisions, atomi
- `start`, `decide`, and `abort` require idempotency keys; launch intent is persisted before worktree creation, and a repeated completed key returns its prior result without a second transition. - `start`, `decide`, and `abort` require idempotency keys; launch intent is persisted before worktree creation, and a repeated completed key returns its prior result without a second transition.
- Stale revisions are rejected for `decide` and `abort`. - Stale revisions are rejected for `decide` and `abort`.
- Using the injected scripted Assignment executor, Questions and Plan Approval pause and resume across process restarts with the accepted UI classification intact and no dependency on live Pi child execution. - Using the injected scripted Assignment executor, Questions and Plan Approval pause and resume across process restarts with the accepted UI classification intact and no dependency on live Pi child execution.
- Startup reconciliation detects stale leases and transitions abandoned `running` Assignments to `interrupted` exactly once. - Startup reconciliation detects absent or dead ownership and transitions abandoned `running` Assignments to `interrupted` exactly once.
- Potentially mutating interrupted Assignments require an explicit Recovery Decision; completed Decisions and Assignments are not replayed. - Potentially mutating interrupted Assignments require an explicit Recovery Decision; completed Decisions and Assignments are not replayed.
- Feature names are validated before launch persistence; worktree branches use exactly `feature/<feature-name>`, and every existing branch collision rejects new start with recorded-run `inspect`/`decide` guidance or descriptive-name rename guidance, never a silent suffix. - Feature names are validated before launch persistence; worktree branches use exactly `feature/<feature-name>`, and every existing branch collision rejects new start with recorded-run `inspect`/`decide` guidance or descriptive-name rename guidance, never a silent suffix.
- Workspace creation intent and lease are persisted before Git mutation; creation succeeds transactionally or leaves a reconciled failure that startup can recover. - Workspace creation intent and lease are persisted before Git mutation; creation succeeds transactionally or leaves a reconciled failure that startup can recover.
@@ -618,7 +624,7 @@ Extend the Phase 2 worktree scaffold with the Implementation Context Gate, `/wor
#### Phase 4 Success Criteria #### Phase 4 Success Criteria
- A dirty source state or unignored worktree root blocks creation before Git mutation. - An unignored worktree root or unmerged source state blocks creation before Git mutation. Ordinary source changes require bounded evidence and explicit authorization, then create from the persisted `HEAD` without copying those changes.
- Workspace creation intent is persisted first; creation failure rolls back safely or remains reconcilable after restart. - Workspace creation intent is persisted first; creation failure rolls back safely or remains reconcilable after restart.
- Every feature run receives a distinct leased worktree and worktrees are retained until explicitly removed. - Every feature run receives a distinct leased worktree and worktrees are retained until explicitly removed.
- A versioned `plan.md` is persisted before the Implementation Context Gate and revised with an accepted prototype when applicable. - A versioned `plan.md` is persisted before the Implementation Context Gate and revised with an accepted prototype when applicable.
@@ -655,7 +661,9 @@ Add the dashboard integration around the Phase 4 `/workflow continue` command, p
#### Phase 6 Success Criteria #### Phase 6 Success Criteria
- Dashboard state is derived from persisted snapshots and active runs, prior supported snapshot versions remain readable, and the existing `/workflow continue` command is presented only for a valid pending fresh-session or normal-compaction intent. - Dashboard state is derived from persisted snapshots and active runs, prior supported snapshot versions remain readable, and continuation is presented only for a valid pending fresh-session or normal-compaction intent. Selecting a fresh-session continuation enters `newSession()` immediately.
- Bounded TUI interactions use responsive popup overlays. Verified documents use the dedicated sanitized reader with H1-H3 navigation, cell-width-safe viewports, and the locked 80-column, 60-column, and 48x12 behavior. RPC, print, and JSON remain linear.
- The widget above Pi's editor projects only the newest attached active run and clears after terminal persistence or session shutdown.
- Stable delivery keys, durable custom parent-session message receipts, and outbox reconciliation ensure each background Gate or terminal result is delivered once across reload and recovery. - Stable delivery keys, durable custom parent-session message receipts, and outbox reconciliation ensure each background Gate or terminal result is delivered once across reload and recovery.
- Focused tests, typecheck, full test suite, and manual Pi TUI validation of Gates, dashboard navigation, background delivery, and prototype handoff pass. - Focused tests, typecheck, full test suite, and manual Pi TUI validation of Gates, dashboard navigation, background delivery, and prototype handoff pass.
- Exposure is performed only through `config/pi-links.json`; unmanaged extension links are rejected and link reconciliation check passes before sync. - Exposure is performed only through `config/pi-links.json`; unmanaged extension links are rejected and link reconciliation check passes before sync.
+101
View File
@@ -0,0 +1,101 @@
# pi-workflow usability update plan
Status: manual recovery plan after managed Workflow Runs `512d6c31`, `51b0c1cb`, and `ecb11e1e` failed or were interrupted before implementation.
## Locked decisions
- The interactive dashboard automatically enters a fresh plan-seeded session. Tool and RPC callers receive `/workflow continue <run-id>` instead.
- Startup may adopt a continuable run only when its prior owner is stale or dead. A live owner remains fail-closed.
- The widget above Pi's editor shows only the newest attached active run.
- Dashboard navigation, action sheets, Gates, confirmations, questions, and feedback use popup overlays in TUI mode. RPC, print, and JSON paths remain linear.
- The plan reader is a full-screen overlay with an H1-H3 outline, current-section highlight, arrows, Page Up/Page Down, Home/End, and Escape.
- The outline is visible at 80 columns and wider, hidden from 60 through 79 columns, and replaced by a truthful minimum-size message below 48x12.
- Dirty source changes require a bounded TUI warning and explicit confirmation when `allowDirtySource` is omitted. TUI starts honor an explicit `allowDirtySource: true` without prompting. Non-TUI tool, RPC, print, and JSON starts require `allowDirtySource: true`. Approval is not tied to a later status digest recheck.
- Approved host commands retain a three-minute timeout. Internal Git commands receive a 30-second timeout. Process-tree termination is bounded on every platform.
- Prototype direction C, drill-down overlays with a dedicated full-screen reader, is the baseline after comparing A, B, and C.
## Failure evidence captured during this change
- Run `512d6c31-5bde-4e93-8164-5d31fb684b00` reached design, described a prototype phase, but emitted no `prototype` assignment. The engine terminated with `invalid-design-output: Design must select exactly one prototype Assignment`.
- Run `51b0c1cb-f5b2-47f9-8c0c-b323307b720b` was interrupted during discovery.
- Run `ecb11e1e-fe38-4076-862e-739498dbe33b` completed discovery, then remained in the feature-designer assignment for nearly five minutes until interrupted.
These failures are acceptance-test inputs for assignment timeout handling and designer guidance.
## Implementation phases
### 1. Prototype overlays
Own only `docs/pi-workflow/dashboard-mocks.md` and isolated prototype artifacts. Compare:
- A: centered compact modal
- B: near-full-screen workspace with list/detail panes
- C: drill-down overlays with a dedicated full-screen plan reader
Validate wide, 80x24, 60-column, and 47x11 frames. Production TypeScript remains untouched until the prototype direction is accepted.
### 2. Session lifecycle and recovery
- Route the dashboard's fresh-session action through the existing `handleContinue()` and `ctx.newSession({ withSession })` path.
- Add an integration test proving a dashboard result triggers session replacement without a second command.
- During absent/dead startup adoption, atomically rotate both `lease.activeParentSessionId` and `implementationContext.activeParentSessionId`.
- Preserve live-owner, active-assignment, and historical-session fences.
Primary files: `dashboard.ts`, `context-transition.ts`, `continuation.ts`, `engine.ts`, and focused tests.
### 3. Worktree safety and command deadlines
- Return bounded dirty path/status evidence instead of rejecting all ordinary source dirt.
- Keep conflicts, unmerged entries, repository identity, ignored-root, branch collision, and target-path checks fail-closed.
- Create the worktree from the pinned `HEAD`; warning copy states source-only changes are omitted.
- Add `allowDirtySource` to tool/domain inputs and idempotency fingerprints.
- Apply 30-second timeouts to every internal Git invocation.
- Keep approved commands at three minutes and bound Windows `taskkill` itself.
- Preserve exit `124` for timeout and `125` for termination failure.
Primary files: `workspace.ts`, `domain.ts`, `tool.ts`, `engine.ts`, and focused tests.
### 4. Popup TUI and plan reader
- Add reusable, modeless overlay components with one earned border, visible selection, keyboard hints, width-safe rendering, and focus propagation for embedded `Input` or `Editor` children.
- Use a drill-down stack: run list to run detail to action/Gate overlay. Escape always returns one level or closes.
- Parse Markdown H1-H3 headings into a pure outline model.
- Render only the current plan viewport, not the entire artifact.
- Sanitize untrusted transcript/plan control sequences before rendering.
- Keep `ctx.ui.custom()` guarded by `ctx.mode === "tui"`.
Primary files: new `tui.ts` and tests, plus orchestration changes in `dashboard.ts`.
### 5. Persistent progress widget
- Add a pure projection for readable label, active stage, completed/total core stages, and open Gate.
- Refresh from persisted attached runs after `afterRunPersist` and `session_start`.
- Clear after terminal state and `session_shutdown`.
- Render one bounded line at narrow widths.
Primary files: new `progress.ts` and tests, plus registration in `index.ts`.
### 6. Design-contract and timeout regression
- Strengthen the bundled feature-designer instructions so non-trivial UI plans explicitly select exactly one eligible prototype assignment with isolated ownership.
- Add a focused test for the exact missing-prototype failure and guidance contract.
- Ensure assignment execution cannot wait forever; surface timeout/interruption evidence without falsely reporting success.
- Record any behavior that cannot be safely fixed in this slice in `docs/todo.md` with the three run IDs above.
Primary files: `agents/feature-designer.md`, engine/execution tests, and related runtime code.
### 7. Documentation, verification, and review
Update the README, design document, dashboard mocks, and a superseding ADR. Run focused tests, typecheck, full tests, and manual Pi validation at wide, 80x24, 60-column, and 47x11 sizes. An agent that did not implement the change performs the final standards and specification review.
## Acceptance criteria
- Fresh-session selection continues automatically in interactive Pi without restart or a second command.
- A new Pi process can adopt an absent/dead continuable run but cannot steal one from a live owner.
- Exactly one newest active-run widget is visible above the editor and clears promptly.
- Every bounded TUI interaction is an overlay; RPC/print/JSON remain usable and never call unsupported custom overlays.
- The full-screen plan reader meets the locked outline, navigation, responsive, and width contracts.
- Dirty source starts are warned and explicitly authorized while conflicts remain blocked.
- No internal Git or approved host subprocess can wait indefinitely, and process cleanup is bounded.
- The designer contract prevents the missing-prototype-assignment failure, while assignment execution has a bounded failure path.
- Focused tests, typecheck, full tests, manual Pi validation, and independent review pass.
@@ -13,4 +13,8 @@ selection:
# Feature designer # Feature designer
Design the smallest maintainable implementation that satisfies the request and follows repository patterns. Define acceptance criteria, ordered assignments, exact file ownership, verification commands, UI-impact classification, affected UI surfaces, concrete UI-impact evidence, recovery risks, and an independent review target. Record why each selected agent is eligible. The plan is complete when an implementer can execute it without inventing missing contracts or crossing another assignment's ownership. Design the smallest maintainable implementation that satisfies the request and follows repository patterns. Define acceptance criteria, ordered assignments, exact file ownership, verification commands, UI-impact classification, affected UI surfaces, concrete UI-impact evidence, recovery risks, and an independent review target. Record why each selected agent is eligible.
Emit exactly one eligible `implement` assignment, one eligible `verify` assignment, and one eligible independent `review` assignment. For `non-trivial` UI impact, also emit exactly one eligible `prototype` assignment whose owned paths isolate prototype artifacts from production implementation ownership. The prototype assignment is required even when the plan prose already describes prototypes. For `none` or `minor` UI impact, emit no prototype assignment.
The plan is complete only when every required assignment is present and an implementer can execute it without inventing missing contracts or crossing another assignment's ownership.
+115 -37
View File
@@ -131,8 +131,16 @@ function commandContext(options: {
hasUI?: boolean; hasUI?: boolean;
input?: (title: string, placeholder?: string) => Promise<string | undefined>; input?: (title: string, placeholder?: string) => Promise<string | undefined>;
select?: (title: string, options: string[]) => Promise<string | undefined>; select?: (title: string, options: string[]) => Promise<string | undefined>;
customResults?: unknown[];
onCustomRender?: (lines: string[]) => void;
}) { }) {
const notifications: string[] = []; const notifications: string[] = [];
const customResults = [...(options.customResults ?? [])];
const overlayTheme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const context = { const context = {
cwd: "/repo", cwd: "/repo",
sessionManager: { sessionManager: {
@@ -145,6 +153,24 @@ function commandContext(options: {
notify(message: string) { notify(message: string) {
notifications.push(message); notifications.push(message);
}, },
custom: async (
factory: (
tui: { requestRender(): void; terminal: { rows: number } },
theme: typeof overlayTheme,
keybindings: object,
done: (value: unknown) => void,
) => { render(width: number): string[] },
) => {
let completed: unknown;
const component = factory(
{ requestRender() {}, terminal: { rows: 24 } },
overlayTheme,
{},
(value) => { completed = value; },
);
options.onCustomRender?.(component.render(100));
return customResults.length > 0 ? customResults.shift() : completed;
},
input: options.input ?? (async () => undefined), input: options.input ?? (async () => undefined),
select: options.select ?? (async () => undefined), select: options.select ?? (async () => undefined),
}, },
@@ -233,13 +259,11 @@ test("/workflow start prompts in the TUI and requires a message otherwise", asyn
sessionFile: "/sessions/source.jsonl", sessionFile: "/sessions/source.jsonl",
mode: "tui", mode: "tui",
hasUI: true, hasUI: true,
input: async (title) => { customResults: ["Add workflow lifecycle commands"],
prompt = title; onCustomRender: (lines) => { prompt = lines.join("\n"); },
return "Add workflow lifecycle commands";
},
}).context, }).context,
); );
assert.equal(prompt, "What do you want to start working on?"); assert.match(prompt, /What do you want to start working on\?/);
assert.ok( assert.ok(
registered.userMessages[0]!.endsWith("\n\nAdd workflow lifecycle commands"), registered.userMessages[0]!.endsWith("\n\nAdd workflow lifecycle commands"),
); );
@@ -284,8 +308,7 @@ test("/workflow continue lists only valid runs and resume is an alias", async ()
], ],
assignments: [], assignments: [],
}) as unknown as WorkflowRun; }) as unknown as WorkflowRun;
const selectedTitles: string[] = []; const renderedSelections: string[] = [];
const selectedOptions: string[][] = [];
const inspected: string[] = []; const inspected: string[] = [];
const registered = fakeApi(); const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, { registerWorkflowContextTransitions(registered.api, {
@@ -325,29 +348,17 @@ test("/workflow continue lists only valid runs and resume is an alias", async ()
sessionFile: "/sessions/source.jsonl", sessionFile: "/sessions/source.jsonl",
mode: "tui", mode: "tui",
hasUI: true, hasUI: true,
select: async (title, options) => { onCustomRender: (lines) => renderedSelections.push(lines.join("\n")),
selectedTitles.push(title);
selectedOptions.push(options);
return undefined;
},
}); });
await registered.command()("continue", interactive.context); await registered.command()("continue", interactive.context);
await registered.command()("resume", interactive.context); await registered.command()("resume", interactive.context);
assert.deepEqual(selectedTitles, [ assert.equal(renderedSelections.length, 2);
"Continue Workflow Run", for (const rendered of renderedSelections) {
"Continue Workflow Run", assert.match(rendered, /Continue Workflow Run/);
]); assert.match(rendered, /shared-feature · run-1/);
assert.equal(selectedOptions[0]?.length, 2); assert.match(rendered, /shared-feature · run-2/);
assert.equal(selectedOptions[1]?.length, 2); assert.doesNotMatch(rendered, /historical/);
assert.deepEqual(selectedOptions[0], [ }
"shared-feature · run-1 · waiting",
"shared-feature · run-2 · waiting",
]);
assert.ok(
selectedOptions.every((options) =>
options.every((label) => !label.includes("historical")),
),
);
await registered.command()("resume run-2", interactive.context); await registered.command()("resume run-2", interactive.context);
assert.deepEqual(inspected, ["run-2"]); assert.deepEqual(inspected, ["run-2"]);
@@ -437,23 +448,20 @@ test("/workflow stop selects among active runs owned by the current session", as
}, },
}) as WorkflowEngine, }) as WorkflowEngine,
} as unknown as ContextTransitionRuntime); } as unknown as ContextTransitionRuntime);
let choices: string[] = []; let choices = "";
const command = commandContext({ const command = commandContext({
sessionId: "session-1", sessionId: "session-1",
sessionFile: "/sessions/source.jsonl", sessionFile: "/sessions/source.jsonl",
mode: "tui", mode: "tui",
hasUI: true, hasUI: true,
select: async (title, options) => { customResults: [run("run-2", "waiting", "session-1")],
assert.equal(title, "Stop Workflow Run"); onCustomRender: (lines) => { choices = lines.join("\n"); },
choices = options;
return options[1];
},
}); });
await registered.command()("stop", command.context); await registered.command()("stop", command.context);
assert.deepEqual(choices, [ assert.match(choices, /Stop Workflow Run/);
"duplicate-label · run-1 · running", assert.match(choices, /duplicate-label · run-1/);
"duplicate-label · run-2 · waiting", assert.match(choices, /duplicate-label · run-2/);
]); assert.doesNotMatch(choices, /completed|historical/);
assert.deepEqual( assert.deepEqual(
commands.map((entry) => commands.map((entry) =>
`${entry.action}:${"runId" in entry ? entry.runId : ""}`, `${entry.action}:${"runId" in entry ? entry.runId : ""}`,
@@ -686,6 +694,76 @@ test("cancelled fresh-session replacement clears the source-bound intent", async
}); });
}); });
test("dashboard continuation immediately enters the fresh-session transition", async () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");
await writeFile(planPath, "# Plan", "utf8");
const implementationContext: ImplementationContextState = {
gateId: "gate-1",
runRevision: 10,
planRevision: 1,
preparationId: "prep-1",
activeParentSessionId: "session-1",
sourceParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
};
let view = workflowView(planPath, 10, implementationContext);
const commands: WorkflowCommand[] = [];
const engine = {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "inspect") return structuredClone(view);
if (command.action === "continue-context") {
const next = structuredClone(view.implementationContext!);
next.sourceSessionFile = command.sourceSessionFile;
next.runRevision = 11;
view = workflowView(planPath, 11, next);
return structuredClone(view);
}
if (command.action === "recover-context-intent") {
const next = structuredClone(view.implementationContext!);
next.sourceSessionFile = undefined;
next.runRevision = 12;
view = workflowView(planPath, 12, next);
return structuredClone(view);
}
throw new Error(`Unexpected command ${command.action}`);
},
} as WorkflowEngine;
const registered = fakeApi();
const dashboardArguments: string[] = [];
registerWorkflowContextTransitions(
registered.api,
{
store: { listRuns: async () => [] },
engineForContext: () => engine,
} as unknown as ContextTransitionRuntime,
async (args) => {
dashboardArguments.push(args);
return { continueRunId: "run-1" };
},
);
let newSessionCalls = 0;
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
newSession: (async () => {
newSessionCalls++;
return { cancelled: true };
}) as ExtensionCommandContext["newSession"],
});
await registered.command()("run-1", command.context);
assert.deepEqual(dashboardArguments, ["run-1"]);
assert.equal(newSessionCalls, 1);
assert.deepEqual(
commands.map((entry) => entry.action),
["inspect", "continue-context", "recover-context-intent"],
);
});
});
test("a later startup recovers an unconsumed claimed handoff", async () => { test("a later startup recovers an unconsumed claimed handoff", async () => {
await withTempDir(async (directory) => { await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md"); const planPath = path.join(directory, "plan.md");
+13 -6
View File
@@ -17,6 +17,7 @@ import {
import { isValidContextContinuation } from "./continuation.ts"; import { isValidContextContinuation } from "./continuation.ts";
import { deriveRunLabel, deriveRunLabels } from "./run-label.ts"; import { deriveRunLabel, deriveRunLabels } from "./run-label.ts";
import { readVerifiedPlanArtifact, type WorkflowStore } from "./store.ts"; import { readVerifiedPlanArtifact, type WorkflowStore } from "./store.ts";
import { workflowInput, workflowSelect } from "./tui.ts";
const MAX_HANDOFF_BYTES = 32 * 1024; const MAX_HANDOFF_BYTES = 32 * 1024;
type NewSessionOptions = NonNullable< type NewSessionOptions = NonNullable<
@@ -259,11 +260,16 @@ async function selectRun(
title: string, title: string,
runs: WorkflowRun[], runs: WorkflowRun[],
) { ) {
const labels = deriveRunLabels(runs).map( const labels = deriveRunLabels(runs);
(label, index) => `${label} · ${runs[index]!.status}`, return workflowSelect(
ctx,
title,
runs.map((run, index) => ({
value: run,
label: labels[index]!,
description: run.status,
})),
); );
const selected = await ctx.ui.select(title, labels);
return runs[labels.indexOf(selected ?? "")];
} }
async function continueRun( async function continueRun(
@@ -336,9 +342,10 @@ async function handleStart(
return; return;
} }
initialRequest = initialRequest =
(await ctx.ui.input( (await workflowInput(
ctx,
"Start Workflow Run",
"What do you want to start working on?", "What do you want to start working on?",
"Describe the feature request",
))?.trim() ?? ""; ))?.trim() ?? "";
if (!initialRequest) return; if (!initialRequest) return;
} }
+453 -214
View File
@@ -8,16 +8,12 @@ import type {
ExtensionAPI, ExtensionAPI,
ExtensionCommandContext, ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { visibleWidth } from "@earendil-works/pi-tui";
import { import {
DEFAULT_WORKFLOW_CONFIG, DEFAULT_WORKFLOW_CONFIG,
type WorkflowRun, type WorkflowRun,
} from "./domain.ts"; } from "./domain.ts";
import type { WorkflowCommand, WorkflowView } from "./engine.ts"; import type { WorkflowCommand, WorkflowView } from "./engine.ts";
import { import { openWorkflowDashboard } from "./dashboard.ts";
FocusedWorkflowDashboard,
openWorkflowDashboard,
} from "./dashboard.ts";
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun { function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
return { return {
@@ -86,14 +82,30 @@ function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
}; };
} }
const theme = { function prototypeCandidate() {
fg: (_color: string, text: string) => text, return {
bold: (text: string) => text, id: "candidate-1",
}; revision: 2,
assignmentId: "prototype-assignment-1",
profileName: "frontend-prototyper",
status: "succeeded" as const,
path: "/repo/prototypes/candidate-1",
route: "/search?variant=1",
structuralRationale: "Compact drill-down flow",
responsiveChecks: ["mobile", "desktop"],
browserEvidence: ["validated in browser"],
launchInstructions: "Run the prototype server.",
visualDigest: "visual-digest",
artifactPath: "/state/artifacts/candidate-1.md",
retainedArtifacts: ["/state/artifacts/candidate-1.png"],
};
}
function view(workflow: WorkflowRun): WorkflowView { function view(workflow: WorkflowRun): WorkflowView {
return { return {
runId: workflow.runId, runId: workflow.runId,
featureName: workflow.featureName,
request: workflow.request,
revision: workflow.revision, revision: workflow.revision,
status: workflow.status, status: workflow.status,
background: workflow.background, background: workflow.background,
@@ -113,76 +125,141 @@ function view(workflow: WorkflowRun): WorkflowView {
}; };
} }
test("focused dashboard renders compact and narrow cards within width", () => { function tuiContext(results: unknown[], sessionId = "session-1") {
let selected: string | null | undefined; const notifications: string[] = [];
const dashboard = new FocusedWorkflowDashboard( const renders: string[][] = [];
[run()], let customCalls = 0;
theme, const theme = {
(value) => { fg: (_color: string, text: string) => text,
selected = value; bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const unsupported = () => {
throw new Error("built-in bounded dialog must not be called");
};
const context = {
hasUI: true,
mode: "tui",
sessionManager: {
getSessionId: () => sessionId,
}, },
); ui: {
dashboard.handleInput("\r"); custom: async (
const wide = dashboard.render(100); factory: (
assert.ok(wide.some((line) => line.includes("Progress"))); tui: { requestRender(): void; terminal: { columns: number; rows: number } },
assert.ok(wide.some((line) => line.includes("Worktree"))); overlayTheme: typeof theme,
assert.ok(wide.every((line) => visibleWidth(line) <= 100)); keybindings: object,
const narrow = dashboard.render(48); done: (value: unknown) => void,
assert.equal(narrow.some((line) => line.includes("Worktree")), false); ) => { render(width: number): string[] },
assert.ok(narrow.every((line) => visibleWidth(line) <= 48)); ) => {
dashboard.handleInput("\r"); customCalls++;
assert.equal(selected, "run-1"); const component = factory(
}); {
requestRender() {},
terminal: { columns: 100, rows: 24 },
},
theme,
{},
() => {},
);
renders.push(component.render(100));
return results.shift();
},
select: unsupported,
confirm: unsupported,
input: unsupported,
editor: unsupported,
notify: (message: string) => notifications.push(message),
},
} as unknown as ExtensionCommandContext;
return { context, notifications, renders, customCalls: () => customCalls };
}
test("dashboard disambiguates duplicate feature labels without changing selection IDs", () => { function interactiveTuiContext(inputs: string[][], sessionId = "session-1") {
let selected: string | null | undefined; let customCalls = 0;
const dashboard = new FocusedWorkflowDashboard( const theme = {
[ fg: (_color: string, text: string) => text,
run({ runId: "12345678-aaaa", featureName: "search-page" }), bg: (_color: string, text: string) => text,
run({ runId: "12345678-bbbb", featureName: "search-page" }), bold: (text: string) => text,
};
const context = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => sessionId },
ui: {
async custom(
factory: (
tui: { requestRender(): void; terminal: { columns: number; rows: number } },
overlayTheme: typeof theme,
keybindings: object,
done: (value: unknown) => void,
) => {
render(width: number): string[];
handleInput?(data: string): void;
},
) {
customCalls++;
let completed: unknown;
const component = factory(
{
requestRender() {},
terminal: { columns: 80, rows: 16 },
},
theme,
{},
(value) => { completed = value; },
);
component.render(56);
for (const input of inputs.shift() ?? []) component.handleInput?.(input);
return completed;
},
notify() {},
},
} as unknown as ExtensionCommandContext;
return { context, customCalls: () => customCalls };
}
const pi = {
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
test("dashboard fresh-session action returns an immediate continuation result", async () => {
const workflow = run({
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
gates: [
{
id: "context-gate",
kind: "implementation-context",
status: "open",
revision: 12,
openedAt: 1,
},
], ],
theme, implementationContext: {
(value) => { selected = value; }, gateId: "context-gate",
runRevision: 12,
planRevision: 3,
preparationId: "prep-1",
activeParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
},
});
const tui = tuiContext(["run-1", "continue"]);
const result = await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
); );
const lines = dashboard.render(100); assert.deepEqual(result, { continueRunId: "run-1" });
assert.ok(lines.some((line) => line.includes("search-page · 12345678-a"))); assert.equal(tui.customCalls(), 2);
assert.ok(lines.some((line) => line.includes("search-page · 12345678-b")));
dashboard.handleInput("\r");
dashboard.handleInput("\r");
assert.equal(selected, "12345678-aaaa");
}); });
test("dashboard disambiguates collisions outside its visible window at common widths", () => { test("dashboard records Plan Approval using only custom TUI overlays", async () => {
const runs = Array.from({ length: 30 }, (_, index) =>
run({
runId:
index === 0
? "12345678-aaaa"
: index === 29
? "12345678-bbbb"
: `run-${index}`,
featureName: index === 0 || index === 29 ? "shared-label" : `feature-${index}`,
}),
);
const dashboard = new FocusedWorkflowDashboard(runs, theme, () => {});
for (const width of [80, 60]) {
const lines = dashboard.render(width);
assert.ok(lines.some((line) => line.includes("shared-label · 12345678-a")));
assert.ok(lines.every((line) => visibleWidth(line) <= width));
}
});
test("dashboard windows long-lived session run lists", () => {
const runs = Array.from({ length: 30 }, (_, index) =>
run({ runId: `run-${index}`, featureName: `feature-${index}` }),
);
const dashboard = new FocusedWorkflowDashboard(runs, theme, () => {});
const lines = dashboard.render(100);
assert.ok(lines.length <= 16);
assert.ok(lines.some((line) => line.includes("of 30 runs")));
});
test("dashboard records explicit Plan Approval through the engine", async () => {
const workflow = run({ const workflow = run({
planArtifact: { planArtifact: {
path: "/state/artifacts/plan.md", path: "/state/artifacts/plan.md",
@@ -191,26 +268,14 @@ test("dashboard records explicit Plan Approval through the engine", async () =>
}, },
}); });
const commands: WorkflowCommand[] = []; const commands: WorkflowCommand[] = [];
const selections = [ const tui = tuiContext([
"Record Gate Decision", "run-1",
"Approve plan · UI impact minor", "decide",
]; "approve",
const ctx = { true,
hasUI: true, undefined,
mode: "tui", undefined,
sessionManager: { ]);
getSessionId: () => "session-1",
},
ui: {
custom: async () => "run-1",
select: async () => selections.shift(),
confirm: async () => true,
notify: () => {},
},
} as unknown as ExtensionCommandContext;
const pi = {
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
await openWorkflowDashboard( await openWorkflowDashboard(
pi, pi,
{ {
@@ -223,7 +288,7 @@ test("dashboard records explicit Plan Approval through the engine", async () =>
}, },
}) as never, }) as never,
}, },
ctx, tui.context,
); );
const decision = commands[0]; const decision = commands[0];
assert.equal(decision?.action, "decide"); assert.equal(decision?.action, "decide");
@@ -235,6 +300,188 @@ test("dashboard records explicit Plan Approval through the engine", async () =>
acceptedUiImpact: "minor", acceptedUiImpact: "minor",
}, },
); );
assert.ok(tui.customCalls() >= 4);
});
test("prototype reader Escape returns to candidate actions without accepting", async () => {
const candidate = prototypeCandidate();
const workflow = run({
stages: run().stages.map((stage) =>
stage.kind === "prototype"
? { ...stage, status: "waiting" as const }
: stage,
),
gates: [
{
id: "prototype-gate",
kind: "prototype-acceptance",
status: "open",
revision: 12,
openedAt: 1,
},
],
prototypeCandidates: [candidate],
});
const commands: WorkflowCommand[] = [];
const tui = tuiContext([
"run-1",
"decide",
{ kind: "candidate", candidate },
"review",
undefined,
"back",
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: async () =>
({
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
return view(workflow);
},
}) as never,
},
tui.context,
);
assert.deepEqual(commands, []);
assert.ok(
tui.renders.filter((lines) => lines.join("\n").includes("Prototype 2"))
.length >= 2,
);
});
test("prototype acceptance requires the explicit candidate Accept action", async () => {
const candidate = prototypeCandidate();
const workflow = run({
gates: [
{
id: "prototype-gate",
kind: "prototype-acceptance",
status: "open",
revision: 12,
openedAt: 1,
},
],
prototypeCandidates: [candidate],
});
const commands: WorkflowCommand[] = [];
const tui = tuiContext([
"run-1",
"decide",
{ kind: "candidate", candidate },
"review",
undefined,
"accept",
true,
"",
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: async () =>
({
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
return view(workflow);
},
}) as never,
},
tui.context,
);
const decision = commands[0];
assert.deepEqual(
decision?.action === "decide" ? decision.decision : undefined,
{
kind: "accept-prototype",
candidateId: candidate.id,
candidateRevision: candidate.revision,
browserReviewed: true,
},
);
});
test("Escape from run detail returns one level to the run list", async () => {
const tui = tuiContext(["run-1", undefined, undefined]);
const result = await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.deepEqual(result, {});
assert.equal(tui.customCalls(), 3);
});
test("dashboard handleInput drives selection and Escape back-stack navigation", async () => {
const tui = interactiveTuiContext([["\r"], ["\x1b"], ["\x1b"]]);
const result = await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.deepEqual(result, {});
assert.equal(tui.customCalls(), 3);
});
test("dashboard duplicate labels retain their canonical run selection IDs", async () => {
const workflows = [
run({ runId: "12345678-aaaa", featureName: "search-page" }),
run({ runId: "12345678-bbbb", featureName: "search-page" }),
];
const tui = tuiContext(["12345678-bbbb", undefined, undefined]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => workflows } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
const list = tui.renders[0]?.join("\n") ?? "";
assert.match(list, /search-page · 12345678-a/);
assert.match(list, /search-page · 12345678-b/);
assert.match(tui.renders[1]?.join("\n") ?? "", /search-page · 12345678-b/);
});
test("dashboard disambiguates duplicate labels across the full run list", async () => {
const workflows = Array.from({ length: 30 }, (_, index) =>
run({
runId:
index === 0
? "12345678-aaaa"
: index === 29
? "12345678-bbbb"
: `run-${index}`,
featureName:
index === 0 || index === 29 ? "shared-label" : `feature-${index}`,
}),
);
const tui = tuiContext([undefined]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => workflows } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
const rendered = tui.renders[0]?.join("\n") ?? "";
assert.match(rendered, /shared-label · 12345678-a/);
assert.match(rendered, /1-12 of 30/);
assert.doesNotMatch(rendered, /shared-label\s+WAITING/);
}); });
test("historical parent sessions receive inspection-only dashboard actions", async () => { test("historical parent sessions receive inspection-only dashboard actions", async () => {
@@ -242,34 +489,22 @@ test("historical parent sessions receive inspection-only dashboard actions", asy
parentSessionIds: ["session-1", "session-2"], parentSessionIds: ["session-1", "session-2"],
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" }, planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
}); });
let actions: string[] = []; const tui = tuiContext(["run-1", undefined, undefined], "session-2");
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => "session-2" },
ui: {
custom: async () => "run-1",
select: async (_title: string, choices: string[]) => {
actions = choices;
return undefined;
},
notify: () => {},
},
} as unknown as ExtensionCommandContext;
await openWorkflowDashboard( await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI, pi,
{ {
store: { listRuns: async () => [workflow] } as never, store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")), engineForContext: () => Promise.reject(new Error("unused")),
}, },
ctx, tui.context,
); );
assert.equal(actions.includes("Record Gate Decision"), false); const actions = tui.renders[1]?.join("\n") ?? "";
assert.equal(actions.includes("Continue context transition"), false); assert.match(actions, /Review verified plan/);
assert.equal(actions.includes("Open plan"), true); assert.doesNotMatch(actions, /Record Gate Decision/);
assert.doesNotMatch(actions, /Continue context transition/);
}); });
test("dashboard opens retained report content on demand", async () => { test("dashboard verifies and opens retained report content", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-dashboard-")); const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-dashboard-"));
try { try {
const reportPath = path.join(directory, "report.md"); const reportPath = path.join(directory, "report.md");
@@ -284,36 +519,65 @@ test("dashboard opens retained report content on demand", async () => {
revision: 12, revision: 12,
}, },
}); });
let opened = ""; const tui = tuiContext([
const ctx = { "run-1",
hasUI: true, "report",
mode: "tui", undefined,
sessionManager: { getSessionId: () => "session-1" }, undefined,
ui: { undefined,
custom: async () => "run-1", ]);
select: async () => "Open report",
editor: async (_title: string, contents: string) => {
opened = contents;
return contents;
},
notify: () => {},
},
} as unknown as ExtensionCommandContext;
await openWorkflowDashboard( await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI, pi,
{ {
store: { listRuns: async () => [workflow] } as never, store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")), engineForContext: () => Promise.reject(new Error("unused")),
}, },
ctx, tui.context,
); );
assert.match(opened, /Completed report[\s\S]*Verified/); assert.ok(
tui.renders.some((lines) =>
lines.join("\n").includes("Completed report"),
),
);
assert.ok(tui.renders.some((lines) => lines.join("\n").includes("Verified.")));
} finally { } finally {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
}); });
test("dashboard verifies and opens Assignment transcripts on demand", async () => { test("retained artifact reader Escape returns through artifact and candidate selection", async () => {
const candidate = prototypeCandidate();
const workflow = run({ prototypeCandidates: [candidate] });
const tui = tuiContext([
"run-1",
"prototypes",
candidate,
candidate.artifactPath,
undefined,
undefined,
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.ok(
tui.renders.filter((lines) => lines.join("\n").includes("Retained artifact"))
.length >= 2,
);
assert.ok(
tui.renders.filter((lines) => lines.join("\n").includes("Prototype evidence"))
.length >= 2,
);
});
test("dashboard verifies and opens Assignment transcripts", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-transcript-")); const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-transcript-"));
try { try {
const transcriptPath = path.join(directory, "assignment.json"); const transcriptPath = path.join(directory, "assignment.json");
@@ -344,107 +608,82 @@ test("dashboard verifies and opens Assignment transcripts on demand", async () =
}, },
], ],
}); });
const selections = [ const assignment = workflow.assignments[0];
"Browse Assignment transcripts", assert.ok(assignment);
"1. review/independent-reviewer · succeeded", const tui = tuiContext([
]; "run-1",
let opened = ""; "transcripts",
const ctx = { assignment,
hasUI: true, undefined,
mode: "tui", undefined,
sessionManager: { getSessionId: () => "session-1" }, undefined,
ui: { undefined,
custom: async () => "run-1", ]);
select: async () => selections.shift(),
editor: async (_title: string, contents: string) => {
opened = contents;
return contents;
},
notify: () => {},
},
} as unknown as ExtensionCommandContext;
await openWorkflowDashboard( await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI, pi,
{ {
store: { listRuns: async () => [workflow] } as never, store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")), engineForContext: () => Promise.reject(new Error("unused")),
}, },
ctx, tui.context,
);
assert.ok(
tui.renders.some((lines) =>
lines.join("\n").includes('"text":"verified"'),
),
);
assert.ok(
tui.renders.filter((lines) =>
lines.join("\n").includes("Assignment transcripts"),
).length >= 2,
); );
assert.match(opened, /assistant.*verified/);
} finally { } finally {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
}); });
test("dashboard reports the supplied run ID when direct inspection cannot resolve it", async () => { test("RPC and print dashboards stay linear and never call custom", async () => {
const notifications: string[] = []; for (const mode of ["rpc", "print", "json"] as const) {
const ctx = { const notifications: string[] = [];
hasUI: true, const ctx = {
mode: "tui", hasUI: mode === "rpc",
sessionManager: { getSessionId: () => "session-1" }, mode,
ui: { sessionManager: { getSessionId: () => "session-1" },
custom: async () => { ui: {
throw new Error("missing runs must not open the dashboard"); custom: async () => {
throw new Error("custom must not be called outside TUI mode");
},
notify: (message: string) => notifications.push(message),
}, },
notify: (message: string) => notifications.push(message), } as unknown as ExtensionCommandContext;
}, assert.deepEqual(
} as unknown as ExtensionCommandContext; await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
),
{},
);
if (mode === "rpc") assert.match(notifications[0] ?? "", /search-page/);
}
});
test("dashboard reports the supplied run ID when direct inspection cannot resolve it", async () => {
const tui = tuiContext([]);
const result = await openWorkflowDashboard( const result = await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI, pi,
{ {
store: { listRuns: async () => [run()] } as never, store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")), engineForContext: () => Promise.reject(new Error("unused")),
}, },
ctx, tui.context,
"missing-run-id", "missing-run-id",
); );
assert.deepEqual(result, {}); assert.deepEqual(result, {});
assert.deepEqual(notifications, [ assert.deepEqual(tui.notifications, [
"Workflow Run missing-run-id was not found for this session.", "Workflow Run missing-run-id was not found for this session.",
]); ]);
}); });
test("dashboard exposes continuation only for pending handoff intents", async () => {
const workflow = run({
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
gates: [
{
id: "context-gate",
kind: "implementation-context",
status: "open",
revision: 12,
openedAt: 1,
},
],
implementationContext: {
gateId: "context-gate",
runRevision: 12,
planRevision: 3,
preparationId: "prep-1",
activeParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
},
});
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => "run-1",
select: async () => "Continue context transition",
notify: () => {},
},
} as unknown as ExtensionCommandContext;
const result = await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
);
assert.deepEqual(result, { continueRunId: "run-1" });
});
+322 -374
View File
@@ -5,12 +5,6 @@ import type {
ExtensionCommandContext, ExtensionCommandContext,
ExtensionContext, ExtensionContext,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import {
Key,
matchesKey,
truncateToWidth,
type Component,
} from "@earendil-works/pi-tui";
import { isValidContextContinuation } from "./continuation.ts"; import { isValidContextContinuation } from "./continuation.ts";
import type { import type {
GateDecision, GateDecision,
@@ -27,36 +21,29 @@ import {
readVerifiedReportArtifact, readVerifiedReportArtifact,
readVerifiedTranscriptArtifact, readVerifiedTranscriptArtifact,
} from "./store.ts"; } from "./store.ts";
import {
workflowConfirm,
workflowEditor,
workflowInput,
workflowPlanReader,
workflowSelect,
} from "./tui.ts";
interface DashboardTheme { const CORE_STAGES = [
fg( "discover",
color: "design",
| "accent" "prototype",
| "dim" "implement",
| "error" "verify",
| "muted" "review",
| "success" ] as const;
| "text"
| "warning",
text: string,
): string;
bold(text: string): string;
}
function statusColor(status: WorkflowRun["status"]) {
if (status === "completed") return "success" as const;
if (status === "failed" || status === "aborted") return "error" as const;
if (status === "waiting") return "warning" as const;
return "accent" as const;
}
function activeStage(run: WorkflowRun) { function activeStage(run: WorkflowRun) {
return ( return (
[...run.stages] [...run.stages]
.reverse() .reverse()
.find((stage) => .find((stage) => stage.status === "running" || stage.status === "waiting")
stage.status === "running" || stage.status === "waiting" ?.kind ?? run.status
)?.kind ?? run.status
); );
} }
@@ -64,152 +51,29 @@ function selectedGate(run: WorkflowRun) {
return [...run.gates].reverse().find((gate) => gate.status === "open"); return [...run.gates].reverse().find((gate) => gate.status === "open");
} }
function stageLine(run: WorkflowRun, theme: DashboardTheme) { function stageSummary(run: WorkflowRun) {
return run.stages return run.stages
.filter((stage) => .filter((stage) => CORE_STAGES.includes(stage.kind as (typeof CORE_STAGES)[number]))
["discover", "design", "prototype", "implement", "verify", "review"].includes(
stage.kind,
),
)
.map((stage) => { .map((stage) => {
const marker = const marker =
stage.status === "completed" stage.status === "completed"
? theme.fg("success", "✓") ? "✓"
: stage.status === "failed" : stage.status === "failed"
? theme.fg("error", "×") ? "×"
: stage.status === "running" || stage.status === "waiting" : stage.status === "running" || stage.status === "waiting"
? theme.fg("accent", "●") ? "●"
: theme.fg("dim", "·"); : "·";
return `${marker} ${stage.kind}`; return `${marker} ${stage.kind}`;
}) })
.join(" "); .join(" ");
} }
export class FocusedWorkflowDashboard implements Component {
private readonly runs: WorkflowRun[];
private readonly theme: DashboardTheme;
private readonly done: (result: string | null) => void;
private readonly labels: string[];
private selected = 0;
private detail = false;
constructor(
runs: WorkflowRun[],
theme: DashboardTheme,
done: (result: string | null) => void,
initialRunId?: string,
) {
this.runs = runs;
this.theme = theme;
this.done = done;
this.labels = deriveRunLabels(runs);
const initial = initialRunId
? runs.findIndex((run) => run.runId === initialRunId)
: -1;
if (initial >= 0) {
this.selected = initial;
this.detail = true;
}
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) {
if (this.detail) this.detail = false;
else this.done(null);
return;
}
if (matchesKey(data, Key.up) && this.selected > 0) {
this.selected--;
return;
}
if (matchesKey(data, Key.down) && this.selected < this.runs.length - 1) {
this.selected++;
return;
}
if (matchesKey(data, Key.enter)) {
const run = this.runs[this.selected];
if (!run) return;
if (this.detail) this.done(run.runId);
else this.detail = true;
}
}
render(width: number) {
const lines = [this.theme.fg("accent", this.theme.bold("pi-workflow")), ""];
if (this.runs.length === 0) {
lines.push(this.theme.fg("muted", "No Workflow Runs are attached to this session."));
lines.push("", this.theme.fg("dim", "esc close"));
return lines.map((line) => truncateToWidth(line, width));
}
const windowSize = 10;
const windowStart = Math.max(
0,
Math.min(
this.selected - Math.floor(windowSize / 2),
Math.max(0, this.runs.length - windowSize),
),
);
const visibleRuns = this.runs.slice(windowStart, windowStart + windowSize);
for (const [visibleIndex, run] of visibleRuns.entries()) {
const index = windowStart + visibleIndex;
const prefix = index === this.selected ? "> " : " ";
const name = this.labels[index]!;
const status = this.theme.fg(
statusColor(run.status),
run.status.toUpperCase().padEnd(9),
);
lines.push(
`${prefix}${name.padEnd(24)} ${status} ${String(activeStage(run)).padEnd(20)} r${run.revision}`,
);
}
if (this.runs.length > windowSize) {
lines.push(
this.theme.fg(
"dim",
`${windowStart + 1}-${windowStart + visibleRuns.length} of ${this.runs.length} runs`,
),
);
}
if (this.detail) {
const run = this.runs[this.selected]!;
const gate = selectedGate(run);
lines.push("", this.theme.fg("dim", "─".repeat(Math.max(1, width))));
lines.push(this.theme.bold(this.labels[this.selected]!));
if (run.workspace.branch) lines.push(this.theme.fg("muted", run.workspace.branch));
lines.push("", `${this.theme.bold("Progress")} ${stageLine(run, this.theme)}`);
if (gate) {
lines.push(
"",
`${this.theme.bold("Gate")} ${gate.kind} · revision ${gate.revision}`,
);
}
if (run.planArtifact) {
lines.push(`${this.theme.bold("Plan")} ${run.planArtifact.path}`);
}
if (run.reportArtifact) {
lines.push(`${this.theme.bold("Report")} ${run.reportArtifact.path}`);
}
if (width >= 80 && run.workspace.root) {
lines.push(`${this.theme.bold("Worktree")} ${run.workspace.root}`);
}
lines.push(
"",
this.theme.fg("dim", "enter actions esc runs"),
);
} else {
lines.push("", this.theme.fg("dim", "↑↓ run enter details esc close"));
}
return lines.map((line) => truncateToWidth(line, width));
}
invalidate() {}
}
function runSummary(run: WorkflowRun, label: string) { function runSummary(run: WorkflowRun, label: string) {
const gate = selectedGate(run); const gate = selectedGate(run);
return [ return [
`${label} · ${run.status} · revision ${run.revision}`, `${label} · ${run.status} · revision ${run.revision}`,
`Stage: ${activeStage(run)}`, `Stage: ${activeStage(run)}`,
`Progress: ${stageSummary(run)}`,
...(gate ? [`Gate: ${gate.kind} (${gate.id})`] : []), ...(gate ? [`Gate: ${gate.kind} (${gate.id})`] : []),
...(run.workspace.branch ? [`Branch: ${run.workspace.branch}`] : []), ...(run.workspace.branch ? [`Branch: ${run.workspace.branch}`] : []),
...(run.workspace.root ? [`Worktree: ${run.workspace.root}`] : []), ...(run.workspace.root ? [`Worktree: ${run.workspace.root}`] : []),
@@ -243,8 +107,7 @@ async function decide(
} }
async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) { async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
const gate = selectedGate(run); const payload = selectedGate(run)?.payload;
const payload = gate?.payload;
if (!payload || typeof payload !== "object") return undefined; if (!payload || typeof payload !== "object") return undefined;
const questions = (payload as { questions?: unknown }).questions; const questions = (payload as { questions?: unknown }).questions;
if (!Array.isArray(questions)) return undefined; if (!Array.isArray(questions)) return undefined;
@@ -254,7 +117,7 @@ async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
const id = (item as { id?: unknown }).id; const id = (item as { id?: unknown }).id;
const prompt = (item as { prompt?: unknown }).prompt; const prompt = (item as { prompt?: unknown }).prompt;
if (typeof id !== "string" || typeof prompt !== "string") continue; if (typeof id !== "string" || typeof prompt !== "string") continue;
const answer = await ctx.ui.input(prompt, "Required answer"); const answer = await workflowInput(ctx, "Required question", prompt);
if (!answer?.trim()) return undefined; if (!answer?.trim()) return undefined;
answers[id] = answer.trim(); answers[id] = answer.trim();
} }
@@ -263,24 +126,33 @@ async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
async function planDecision(ctx: ExtensionContext, run: WorkflowRun) { async function planDecision(ctx: ExtensionContext, run: WorkflowRun) {
if (!run.planArtifact || !run.uiImpact) return undefined; if (!run.planArtifact || !run.uiImpact) return undefined;
const action = await ctx.ui.select("Plan Approval", [ while (true) {
"Inspect plan", const action = await workflowSelect(ctx, "Plan Approval", [
`Approve plan · UI impact ${run.uiImpact}`, { value: "inspect" as const, label: "Review verified plan" },
"Request revision", {
]); value: "approve" as const,
if (action === "Inspect plan") { label: `Approve plan · UI impact ${run.uiImpact}`,
const plan = await readVerifiedPlanArtifact(run.planArtifact); },
await ctx.ui.editor("Approved plan (read-only copy)", plan.slice(0, 32 * 1024)); { value: "revise" as const, label: "Request revision" },
return undefined; ]);
} if (!action) return undefined;
if (action === "Request revision") { if (action === "inspect") {
const feedback = await ctx.ui.editor("Plan revision feedback", ""); const plan = await readVerifiedPlanArtifact(run.planArtifact);
return feedback?.trim() await workflowPlanReader(ctx, run.featureName ?? run.runId, plan);
? { kind: "revise-plan" as const, feedback: feedback.trim() } continue;
: undefined; }
} if (action === "revise") {
if (action?.startsWith("Approve plan")) { const feedback = await workflowEditor(
const approved = await ctx.ui.confirm( ctx,
"Plan revision feedback",
"Describe the required plan changes.",
);
return feedback?.trim()
? { kind: "revise-plan" as const, feedback: feedback.trim() }
: undefined;
}
const approved = await workflowConfirm(
ctx,
"Approve plan", "Approve plan",
`Approve plan revision ${run.planArtifact.revision} and explicitly accept UI impact ${run.uiImpact}?`, `Approve plan revision ${run.planArtifact.revision} and explicitly accept UI impact ${run.uiImpact}?`,
); );
@@ -292,78 +164,121 @@ async function planDecision(ctx: ExtensionContext, run: WorkflowRun) {
} }
: undefined; : undefined;
} }
return undefined;
} }
async function prototypeDecision(ctx: ExtensionContext, run: WorkflowRun) { async function prototypeDecision(ctx: ExtensionContext, run: WorkflowRun) {
const candidates = run.prototypeCandidates.slice(-3); const candidates = run.prototypeCandidates.slice(-3);
const items = candidates.map( while (true) {
(candidate, index) => const action = await workflowSelect<
`${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`, | { kind: "candidate"; candidate: PrototypeCandidate }
); | { kind: "refine" }
const action = await ctx.ui.select("Prototype Acceptance", [ | { kind: "abort" }
...items, >(ctx, "Prototype Acceptance", [
"Request refinement", ...candidates.map((candidate, index) => ({
"Abort run", value: { kind: "candidate" as const, candidate },
]); label: `${index + 1}. ${candidate.structuralRationale}`,
if (action === "Request refinement") { description: candidate.route,
const feedback = await ctx.ui.editor("Prototype refinement feedback", ""); })),
return feedback?.trim() { value: { kind: "refine" as const }, label: "Request refinement" },
? { kind: "refine-prototypes" as const, feedback: feedback.trim() } { value: { kind: "abort" as const }, label: "Abort run" },
: undefined; ]);
if (!action) return undefined;
if (action.kind === "refine") {
const feedback = await workflowEditor(
ctx,
"Prototype refinement feedback",
"Describe what the next candidates must change.",
);
return feedback?.trim()
? { kind: "refine-prototypes" as const, feedback: feedback.trim() }
: undefined;
}
if (action.kind === "abort") {
return (await workflowConfirm(
ctx,
"Abort Workflow Run",
"Abort this run and preserve its current evidence?",
))
? ("abort" as const)
: undefined;
}
const candidate = action.candidate;
while (true) {
const candidateAction = await workflowSelect(
ctx,
`Prototype ${candidate.revision}`,
[
{ value: "review" as const, label: "Review candidate evidence" },
{ value: "accept" as const, label: "Accept this candidate" },
{ value: "back" as const, label: "Back to candidates" },
],
);
if (!candidateAction || candidateAction === "back") break;
if (candidateAction === "review") {
await workflowPlanReader(
ctx,
`prototype ${candidate.revision}`,
[
candidate.launchInstructions,
`Route: ${candidate.route}`,
`Evidence: ${candidate.retainedArtifacts.join(", ")}`,
].join("\n\n"),
);
continue;
}
const browserReviewed = await workflowConfirm(
ctx,
"Accept prototype",
"Confirm you reviewed this exact candidate in the browser or application at mobile and desktop sizes.",
);
if (!browserReviewed) continue;
const feedback = await workflowInput(
ctx,
"Acceptance feedback",
"Optional feedback. Submit an empty value for none.",
);
return {
kind: "accept-prototype" as const,
candidateId: candidate.id,
candidateRevision: candidate.revision,
browserReviewed: true as const,
...(feedback?.trim() ? { feedback: feedback.trim() } : {}),
};
}
} }
if (action === "Abort run") return "abort" as const;
const index = items.indexOf(action ?? "");
const candidate: PrototypeCandidate | undefined = candidates[index];
if (!candidate) return undefined;
ctx.ui.notify(
[
candidate.launchInstructions,
`Route: ${candidate.route}`,
`Evidence: ${candidate.retainedArtifacts.join(", ")}`,
].join("\n"),
"info",
);
const browserReviewed = await ctx.ui.confirm(
"Accept prototype",
"Confirm you reviewed this exact candidate in the browser or application at mobile and desktop sizes.",
);
if (!browserReviewed) return undefined;
const feedback = await ctx.ui.input("Acceptance feedback", "Optional");
return {
kind: "accept-prototype" as const,
candidateId: candidate.id,
candidateRevision: candidate.revision,
browserReviewed: true as const,
...(feedback?.trim() ? { feedback: feedback.trim() } : {}),
};
} }
async function implementationContextDecision(ctx: ExtensionContext) { async function implementationContextDecision(ctx: ExtensionContext) {
const strategy = await ctx.ui.select("Implementation Context", [ const strategy = await workflowSelect(ctx, "Implementation Context", [
"Fresh plan-seeded session", {
"Compact current session", value: "fresh-session" as const,
"Keep current context unchanged", label: "Fresh plan-seeded session",
description: "Replace this session and continue immediately.",
},
{
value: "smart-compact" as const,
label: "Compact current session",
},
{
value: "current-session" as const,
label: "Keep current context unchanged",
},
]); ]);
if (strategy === "Fresh plan-seeded session") { return strategy
return { kind: "select-implementation-context" as const, strategy: "fresh-session" as const }; ? { kind: "select-implementation-context" as const, strategy }
} : undefined;
if (strategy === "Compact current session") {
return { kind: "select-implementation-context" as const, strategy: "smart-compact" as const };
}
if (strategy === "Keep current context unchanged") {
return { kind: "select-implementation-context" as const, strategy: "current-session" as const };
}
return undefined;
} }
async function recoveryDecision(ctx: ExtensionContext) { async function recoveryDecision(ctx: ExtensionContext) {
const resolution = await ctx.ui.select("Recovery", ["Retry", "Inspect", "Abort"]); const resolution = await workflowSelect(ctx, "Recovery", [
if (!resolution) return undefined; { value: "retry" as const, label: "Retry" },
return { { value: "inspect" as const, label: "Inspect" },
kind: "recover" as const, { value: "abort" as const, label: "Abort" },
resolution: resolution.toLowerCase() as "retry" | "inspect" | "abort", ]);
}; return resolution
? { kind: "recover" as const, resolution }
: undefined;
} }
async function gateDecision(ctx: ExtensionContext, run: WorkflowRun) { async function gateDecision(ctx: ExtensionContext, run: WorkflowRun) {
@@ -386,128 +301,161 @@ function validContinuation(run: WorkflowRun, sessionId?: string) {
} }
function isActiveParent(run: WorkflowRun, sessionId?: string) { function isActiveParent(run: WorkflowRun, sessionId?: string) {
return Boolean( return Boolean(sessionId && run.lease?.activeParentSessionId === sessionId);
sessionId && run.lease?.activeParentSessionId === sessionId,
);
} }
async function browsePrototypeArtifacts( async function browsePrototypeArtifacts(ctx: ExtensionContext, run: WorkflowRun) {
ctx: ExtensionContext, while (true) {
run: WorkflowRun, const candidate = await workflowSelect(
) { ctx,
const candidates = run.prototypeCandidates; "Prototype evidence",
const labels = candidates.map( run.prototypeCandidates.map((entry, index) => ({
(candidate, index) => value: entry,
`${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`, label: `${index + 1}. ${entry.structuralRationale}`,
); description: entry.route,
const selected = await ctx.ui.select("Prototype evidence", labels); })),
const candidate = candidates[labels.indexOf(selected ?? "")]; );
if (!candidate) return; if (!candidate) return;
ctx.ui.notify( while (true) {
`${candidate.launchInstructions}\nRoute: ${candidate.route}`, const artifact = await workflowSelect(
"info", ctx,
); "Retained artifact",
const artifacts = [candidate.artifactPath, ...candidate.retainedArtifacts]; [candidate.artifactPath, ...candidate.retainedArtifacts].map((entry) => ({
const artifact = await ctx.ui.select("Retained artifact", artifacts); value: entry,
if (!artifact) return; label: entry,
if (artifact.endsWith(".json")) { })),
const contents = await readFile(artifact, "utf8"); );
await ctx.ui.editor("Retained prototype evidence", contents.slice(0, 32 * 1024)); if (!artifact) break;
} else { if (artifact.endsWith(".json")) {
ctx.ui.notify(artifact, "info"); await workflowPlanReader(
ctx,
"prototype evidence",
await readFile(artifact, "utf8"),
);
} else {
await workflowPlanReader(
ctx,
"prototype evidence",
`${candidate.launchInstructions}\n\nRoute: ${candidate.route}\n\nArtifact: ${artifact}`,
);
}
}
} }
} }
async function browseAssignmentTranscripts( async function browseAssignmentTranscripts(ctx: ExtensionContext, run: WorkflowRun) {
ctx: ExtensionContext,
run: WorkflowRun,
) {
const assignments = run.assignments.filter( const assignments = run.assignments.filter(
(assignment) => assignment.transcriptArtifact, (assignment) => assignment.transcriptArtifact,
); );
const labels = assignments.map( while (true) {
(assignment, index) => const assignment = await workflowSelect(
`${index + 1}. ${assignment.role}/${assignment.profileName} · ${assignment.status}`, ctx,
); "Assignment transcripts",
const selected = await ctx.ui.select("Assignment transcripts", labels); assignments.map((entry, index) => ({
const assignment = assignments[labels.indexOf(selected ?? "")]; value: entry,
if (!assignment?.transcriptArtifact) return; label: `${index + 1}. ${entry.role}/${entry.profileName}`,
const transcript = await readVerifiedTranscriptArtifact( description: entry.status,
assignment.transcriptArtifact, })),
); );
await ctx.ui.editor( if (!assignment?.transcriptArtifact) return;
`${assignment.role} transcript`, const transcript = await readVerifiedTranscriptArtifact(
transcript.slice(0, 32 * 1024), assignment.transcriptArtifact,
); );
await workflowPlanReader(ctx, `${assignment.role} transcript`, transcript);
}
} }
type DashboardActionResult =
| { kind: "back" }
| { kind: "continue"; runId: string }
| { kind: "closed" };
async function actionSheet( async function actionSheet(
pi: ExtensionAPI, pi: ExtensionAPI,
runtime: ContextTransitionRuntime, runtime: ContextTransitionRuntime,
ctx: ExtensionCommandContext, ctx: ExtensionCommandContext,
run: WorkflowRun, initialRun: WorkflowRun,
label: string, label: string,
) { ): Promise<DashboardActionResult> {
const actions = [ let run = initialRun;
"Inspect run", while (true) {
...(selectedGate(run) && const actions = [
isActiveParent(run, ctx.sessionManager.getSessionId()) { value: "inspect" as const, label: "Inspect run", description: runSummary(run, label) },
? ["Record Gate Decision"] ...(selectedGate(run) && isActiveParent(run, ctx.sessionManager.getSessionId())
: []), ? [{ value: "decide" as const, label: "Record Gate Decision", description: selectedGate(run)?.kind }]
...(validContinuation(run, ctx.sessionManager.getSessionId()) : []),
? ["Continue context transition"] ...(validContinuation(run, ctx.sessionManager.getSessionId())
: []), ? [{ value: "continue" as const, label: "Continue context transition", description: "Enter the selected implementation context now." }]
...(run.planArtifact ? ["Open plan"] : []), : []),
...(run.reportArtifact ? ["Open report"] : []), ...(run.planArtifact ? [{ value: "plan" as const, label: "Review verified plan" }] : []),
...(run.prototypeCandidates.length > 0 ? ["Browse prototype evidence"] : []), ...(run.reportArtifact ? [{ value: "report" as const, label: "Open report" }] : []),
...(run.assignments.some((assignment) => assignment.transcriptArtifact) ...(run.prototypeCandidates.length > 0
? ["Browse Assignment transcripts"] ? [{ value: "prototypes" as const, label: "Browse prototype evidence" }]
: []), : []),
...(run.workspace.root ? ["Show worktree path"] : []), ...(run.assignments.some((assignment) => assignment.transcriptArtifact)
]; ? [{ value: "transcripts" as const, label: "Browse Assignment transcripts" }]
const action = await ctx.ui.select(label, actions); : []),
if (action === "Inspect run") ctx.ui.notify(runSummary(run, label), "info"); ...(run.workspace.root ? [{ value: "worktree" as const, label: "Show worktree path" }] : []),
else if (action === "Open plan") { ];
const plan = await readVerifiedPlanArtifact(run.planArtifact!); const action = await workflowSelect(ctx, label, actions);
await ctx.ui.editor("Workflow plan", plan.slice(0, 32 * 1024)); if (!action) return { kind: "back" };
} else if (action === "Open report") { if (action === "continue") return { kind: "continue", runId: run.runId };
const report = await readVerifiedReportArtifact(run.reportArtifact!); if (action === "inspect") {
await ctx.ui.editor("Workflow report", report.slice(0, 32 * 1024)); await workflowPlanReader(ctx, label, runSummary(run, label));
} else if (action === "Browse prototype evidence") { } else if (action === "plan") {
await browsePrototypeArtifacts(ctx, run); await workflowPlanReader(
} else if (action === "Browse Assignment transcripts") { ctx,
await browseAssignmentTranscripts(ctx, run); label,
} else if (action === "Show worktree path") { await readVerifiedPlanArtifact(run.planArtifact!),
ctx.ui.notify(run.workspace.root!, "info"); );
} } else if (action === "report") {
else if (action === "Continue context transition") { await workflowPlanReader(
return { continueRunId: run.runId }; ctx,
} else if (action === "Record Gate Decision") { `${label} report`,
const decision = await gateDecision(ctx, run); await readVerifiedReportArtifact(run.reportArtifact!),
if (decision === "abort") { );
const engine = await runtime.engineForContext(ctx); } else if (action === "prototypes") {
await engine.execute({ await browsePrototypeArtifacts(ctx, run);
action: "abort", } else if (action === "transcripts") {
runId: run.runId, await browseAssignmentTranscripts(ctx, run);
expectedRevision: run.revision, } else if (action === "worktree") {
idempotencyKey: `dashboard-abort-${run.runId}-${run.revision}-${randomUUID()}`, await workflowPlanReader(ctx, "worktree", run.workspace.root!);
}); } else if (action === "decide") {
} else if (decision) { const decision = await gateDecision(ctx, run);
const result = await decide(pi, runtime, ctx, run, decision); if (decision === "abort") {
if ( const engine = await runtime.engineForContext(ctx);
decision.kind === "select-implementation-context" && await engine.execute({
decision.strategy === "smart-compact" action: "abort",
) { runId: run.runId,
ctx.ui.notify( expectedRevision: run.revision,
`Workflow ${label} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through pi_workflow.`, idempotencyKey: `dashboard-abort-${run.runId}-${run.revision}-${randomUUID()}`,
"info", });
); } else if (decision) {
} else { const result = await decide(pi, runtime, ctx, run, decision);
ctx.ui.notify(`Workflow ${label}: ${result.status}`, "info"); if (
decision.kind === "select-implementation-context" &&
decision.strategy === "smart-compact"
) {
ctx.ui.notify(
`Workflow ${label} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through pi_workflow.`,
"info",
);
} else if (
decision.kind === "select-implementation-context" &&
decision.strategy === "fresh-session"
) {
return { kind: "continue", runId: run.runId };
} else {
ctx.ui.notify(`Workflow ${label}: ${result.status}`, "info");
}
} }
const refreshed = (await runtime.store.listRuns()).find((candidate) => candidate.runId === run.runId);
if (!refreshed || ["completed", "failed", "aborted"].includes(refreshed.status)) {
return { kind: "closed" };
}
run = refreshed;
} }
} }
return {};
} }
export interface DashboardResult { export interface DashboardResult {
@@ -523,12 +471,13 @@ export async function openWorkflowDashboard(
const sessionId = ctx.sessionManager.getSessionId(); const sessionId = ctx.sessionManager.getSessionId();
const runs = (await runtime.store.listRuns()) const runs = (await runtime.store.listRuns())
.filter((run) => !sessionId || run.parentSessionIds.includes(sessionId)) .filter((run) => !sessionId || run.parentSessionIds.includes(sessionId))
.sort((left, right) => right.revision - left.revision); .sort((left, right) => {
const leftTime = Math.max(left.lease?.heartbeatAt ?? 0, ...left.gates.map((gate) => gate.openedAt));
const rightTime = Math.max(right.lease?.heartbeatAt ?? 0, ...right.gates.map((gate) => gate.openedAt));
return rightTime - leftTime;
});
if (initialRunId && !runs.some((run) => run.runId === initialRunId)) { if (initialRunId && !runs.some((run) => run.runId === initialRunId)) {
ctx.ui.notify( ctx.ui.notify(`Workflow Run ${initialRunId} was not found for this session.`, "error");
`Workflow Run ${initialRunId} was not found for this session.`,
"error",
);
return {}; return {};
} }
const labels = deriveRunLabels(runs); const labels = deriveRunLabels(runs);
@@ -537,34 +486,33 @@ export async function openWorkflowDashboard(
const omitted = runs.length - visibleRuns.length; const omitted = runs.length - visibleRuns.length;
ctx.ui.notify( ctx.ui.notify(
runs.length > 0 runs.length > 0
? `${visibleRuns.map((run, index) => runSummary(run, labels[index]!)).join("\n\n")}${ ? `${visibleRuns.map((run, index) => runSummary(run, labels[index]!)).join("\n\n")}${omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""}`
omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""
}`
: "No Workflow Runs.", : "No Workflow Runs.",
"info", "info",
); );
return {}; return {};
} }
const selected = await ctx.ui.custom<string | null>(
(tui, theme, _keybindings, done) => { let selectedRunId = initialRunId;
const dashboard = new FocusedWorkflowDashboard( while (true) {
runs, if (!selectedRunId) {
theme as DashboardTheme, selectedRunId = await workflowSelect(
done, ctx,
initialRunId, "pi-workflow",
runs.map((run, index) => ({
value: run.runId,
label: `${labels[index]} ${run.status.toUpperCase()} ${String(activeStage(run))}`,
description: selectedGate(run)?.kind,
})),
); );
return { if (!selectedRunId) return {};
render: (width) => dashboard.render(width), }
invalidate: () => dashboard.invalidate(), const index = runs.findIndex((candidate) => candidate.runId === selectedRunId);
handleInput: (data) => { const run = runs[index];
dashboard.handleInput(data); if (!run) return {};
tui.requestRender(); const result = await actionSheet(pi, runtime, ctx, run, labels[index]!);
}, if (result.kind === "continue") return { continueRunId: result.runId };
}; if (result.kind === "closed") return {};
}, selectedRunId = undefined;
); }
if (!selected) return {};
const index = runs.findIndex((candidate) => candidate.runId === selected);
const run = runs[index];
return run ? actionSheet(pi, runtime, ctx, run, labels[index]!) : {};
} }
+8
View File
@@ -154,12 +154,20 @@ export interface RunLease {
worktreeIdentity?: string; worktreeIdentity?: string;
} }
export interface SourceChangeEvidence {
path: string;
status: string;
}
export interface WorkflowWorkspace { export interface WorkflowWorkspace {
state: "not-required" | "creating" | "ready" | "failed"; state: "not-required" | "creating" | "ready" | "failed";
sourceRoot: string; sourceRoot: string;
root?: string; root?: string;
branch?: string; branch?: string;
baseHead?: string; baseHead?: string;
sourceChanges?: SourceChangeEvidence[];
sourceChangesOmitted?: number;
dirtySourceAllowed?: boolean;
error?: string; error?: string;
} }
+718 -17
View File
@@ -29,6 +29,7 @@ import {
type HostCommandExecutor, type HostCommandExecutor,
type StartWorkflowCommand, type StartWorkflowCommand,
type WorkflowCommand, type WorkflowCommand,
type WorkflowView,
type WorkspaceManager, type WorkspaceManager,
} from "./engine.ts"; } from "./engine.ts";
import { loadAgentProfiles } from "./profiles.ts"; import { loadAgentProfiles } from "./profiles.ts";
@@ -113,10 +114,12 @@ class FakeWorkspace implements WorkspaceManager {
branchExists = false; branchExists = false;
createError?: Error; createError?: Error;
reconcileState: WorkflowRun["workspace"]["state"] = "ready"; reconcileState: WorkflowRun["workspace"]["state"] = "ready";
beforePrepare?: () => Promise<void>;
beforeCreate?: (plan: WorkspacePlan) => Promise<void>; beforeCreate?: (plan: WorkspacePlan) => Promise<void>;
async prepare(options: PrepareWorkspaceOptions): Promise<WorkspacePlan> { async prepare(options: PrepareWorkspaceOptions): Promise<WorkspacePlan> {
this.prepareCalls++; this.prepareCalls++;
await this.beforePrepare?.();
return { return {
sourceRoot: options.sourceRoot, sourceRoot: options.sourceRoot,
worktreeRoot: path.join(options.sourceRoot, options.configuredRoot), worktreeRoot: path.join(options.sourceRoot, options.configuredRoot),
@@ -128,6 +131,9 @@ class FakeWorkspace implements WorkspaceManager {
branch: `feature/${options.featureName}`, branch: `feature/${options.featureName}`,
baseHead: "abc123", baseHead: "abc123",
branchExists: this.branchExists, branchExists: this.branchExists,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
}; };
} }
@@ -316,6 +322,449 @@ function design(uiImpact: "none" | "minor" | "non-trivial" = "minor") {
}; };
} }
async function createClaimedContextHandoff(
setup: Fixture,
): Promise<WorkflowView> {
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
setup.executor.enqueue("design", design("minor"));
const planned = await setup.engine().execute(startCommand());
const approved = await setup.engine().execute({
action: "decide",
runId: planned.runId,
gateId: planned.openGate?.id ?? "missing",
expectedRevision: planned.revision,
decision: {
kind: "approve-plan",
approved: true,
acceptedUiImpact: "minor",
},
idempotencyKey: "approve-claimed-context",
});
const selected = await setup.engine().execute({
action: "decide",
runId: approved.runId,
gateId: approved.openGate?.id ?? "missing",
expectedRevision: approved.revision,
decision: {
kind: "select-implementation-context",
strategy: "fresh-session",
},
idempotencyKey: "select-claimed-context",
});
const intent = selected.implementationContext;
assert.ok(intent);
const continued = await setup.engine().execute({
action: "continue-context",
runId: selected.runId,
gateId: intent.gateId,
preparationId: intent.preparationId,
planRevision: intent.planRevision,
contextRevision: intent.runRevision,
expectedRevision: selected.revision,
sourceSessionFile: "/sessions/source.jsonl",
});
const pending = continued.implementationContext;
assert.ok(pending);
return setup.engine().execute({
action: "claim-context-handoff",
runId: continued.runId,
gateId: pending.gateId,
preparationId: pending.preparationId,
planRevision: pending.planRevision,
contextRevision: pending.runRevision,
expectedRevision: continued.revision,
sourceSessionFile: "/sessions/source.jsonl",
targetSessionFile: "/sessions/target.jsonl",
reason: "new",
});
}
test("non-trivial UI design fails with the exact missing-prototype validation error", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
const missingPrototype = design("non-trivial");
missingPrototype.assignments = missingPrototype.assignments.filter(
(assignment) => assignment.role !== "prototype",
);
setup.executor.enqueue("design", missingPrototype);
const failed = await setup.engine().execute(startCommand());
assert.equal(failed.status, "failed");
assert.equal(failed.error?.code, "invalid-design-output");
assert.equal(
failed.error?.message,
"Design must select exactly one prototype Assignment",
);
});
});
test("feature designer contract requires the non-trivial prototype assignment", async () => {
const instructions = await readFile(
new URL("./agents/feature-designer.md", import.meta.url),
"utf8",
);
assert.match(
instructions,
/For `non-trivial` UI impact, also emit exactly one eligible `prototype` assignment/,
);
assert.match(instructions, /owned paths isolate prototype artifacts/);
assert.match(instructions, /one eligible `implement` assignment/);
assert.match(instructions, /one eligible `verify` assignment/);
assert.match(instructions, /one eligible independent `review` assignment/);
});
test("design Assignment execution times out with persisted evidence", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
const blocking = new BlockingExecutor(design("minor"));
const operation = setup.engine("session-1", {
executor: {
async run(request) {
if (request.role === "discover") {
return setup.executor.run(request);
}
return blocking.run(request);
},
},
assignmentExecutionTimeoutMs: 20,
assignmentAbortTimeoutMs: 5,
}).execute(startCommand());
await blocking.started;
const failed = await operation;
blocking.release();
assert.equal(failed.status, "failed");
assert.equal(failed.error?.code, "assignment-timeout");
assert.match(failed.error?.message ?? "", /Assignment design timed out after 20ms/);
const persisted = await setup.store.loadRun(failed.runId);
const designAssignment = persisted.assignments.find(
(assignment) => assignment.role === "design",
);
assert.equal(designAssignment?.status, "interrupted");
assert.match(designAssignment?.error ?? "", /timed out after 20ms/);
});
});
test("executor rejection clears its deadline before transcript persistence", async (context) => {
await withTempDir(async (directory) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
const setup = await fixture(directory);
let markStarted = () => {};
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
let rejectExecution = (_error: Error) => {};
const execution = new Promise<never>((_resolve, reject) => {
rejectExecution = reject;
});
const executor: AssignmentExecutor = {
run() {
markStarted();
return execution;
},
};
const saveTranscript = setup.store.saveAssignmentTranscript.bind(setup.store);
let markTranscriptStarted = () => {};
const transcriptStarted = new Promise<void>((resolve) => {
markTranscriptStarted = resolve;
});
let finishTranscript = () => {};
const transcriptMayFinish = new Promise<void>((resolve) => {
finishTranscript = resolve;
});
setup.store.saveAssignmentTranscript = async (
runId,
assignmentId,
transcript,
) => {
markTranscriptStarted();
await transcriptMayFinish;
return saveTranscript(runId, assignmentId, transcript);
};
const operation = setup.engine("session-1", {
executor,
assignmentExecutionTimeoutMs: 10,
}).execute(startCommand());
await started;
context.mock.timers.tick(9);
rejectExecution(Object.assign(new Error("provider rejected near deadline"), {
assignmentMetadata: {
transcript: [{ role: "assistant", text: "partial response" }],
},
}));
await transcriptStarted;
context.mock.timers.tick(1);
finishTranscript();
const failed = await operation;
assert.equal(failed.status, "failed");
assert.equal(failed.error?.code, "assignment-failed");
assert.match(failed.error?.message ?? "", /provider rejected near deadline/);
assert.equal(failed.assignments[0]?.status, "failed");
});
});
test("assignment timeout retains its mutation fence until the executor settles", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
setup.executor.enqueue("design", design("minor"));
const blocking = new BlockingExecutor({
summary: "late implementation",
changedPaths: ["src/index.ts"],
focusedTests: ["late behavior"],
});
const executor: AssignmentExecutor = {
async run(request) {
return request.role === "implement"
? blocking.run(request)
: setup.executor.run(request);
},
};
const engine = setup.engine("session-1", {
executor,
assignmentExecutionTimeoutMs: 20,
assignmentAbortTimeoutMs: 5,
});
const planned = await engine.execute(startCommand());
const approved = await engine.execute({
action: "decide",
runId: planned.runId,
gateId: planned.openGate?.id ?? "missing",
expectedRevision: planned.revision,
decision: {
kind: "approve-plan",
approved: true,
acceptedUiImpact: "minor",
},
idempotencyKey: "approve-timeout-plan",
});
const operation = engine.execute({
action: "decide",
runId: approved.runId,
gateId: approved.openGate?.id ?? "missing",
expectedRevision: approved.revision,
decision: {
kind: "select-implementation-context",
strategy: "current-session",
},
idempotencyKey: "start-timeout-implementation",
});
await blocking.started;
const timedOut = await operation;
assert.equal(timedOut.status, "waiting");
assert.equal(timedOut.openGate?.kind, "recovery");
assert.equal(timedOut.error?.code, "mutation-recovery-required");
const interrupted = timedOut.assignments.find(
(assignment) => assignment.role === "implement",
);
assert.equal(interrupted?.status, "interrupted");
assert.ok(interrupted?.executionToken);
assert.ok(
await setup.store.loadAssignmentLease(
timedOut.runId,
interrupted?.id ?? "missing",
),
);
await assert.rejects(
engine.execute({
action: "decide",
runId: timedOut.runId,
gateId: timedOut.openGate?.id ?? "missing",
expectedRevision: timedOut.revision,
decision: { kind: "recover", resolution: "retry" },
idempotencyKey: "recover-before-timeout-settlement",
}),
/still active/,
);
blocking.release();
for (let attempt = 0; attempt < 100; attempt++) {
const lease = await setup.store.loadAssignmentLease(
timedOut.runId,
interrupted?.id ?? "missing",
);
if (!lease) break;
await new Promise((resolve) => setTimeout(resolve, 1));
}
assert.equal(
await setup.store.loadAssignmentLease(
timedOut.runId,
interrupted?.id ?? "missing",
),
undefined,
);
setup.executor.enqueue("remediate", {
summary: "recovered implementation",
changedPaths: ["src/index.ts"],
focusedTests: ["recovered behavior"],
});
setup.executor.enqueue("verify", {
succeeded: true,
focusedBehavioralTests: true,
summary: "verified after settlement",
});
setup.executor.enqueue("review", {
specification: { verdict: "approved", findings: [] },
standards: { verdict: "approved", findings: [] },
});
const recovered = await engine.execute({
action: "decide",
runId: timedOut.runId,
gateId: timedOut.openGate?.id ?? "missing",
expectedRevision: timedOut.revision,
decision: { kind: "recover", resolution: "retry" },
idempotencyKey: "recover-after-timeout-settlement",
});
assert.equal(recovered.status, "completed", recovered.error?.message);
});
});
test("old heartbeat never permits takeover from a live run owner", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
setup.executor.enqueue("design", design("minor"));
const planned = await setup.engine().execute(startCommand());
const approved = await setup.engine().execute({
action: "decide",
runId: planned.runId,
gateId: planned.openGate?.id ?? "missing",
expectedRevision: planned.revision,
decision: {
kind: "approve-plan",
approved: true,
acceptedUiImpact: "minor",
},
idempotencyKey: "approve-before-live-owner-recovery",
});
const owned = await setup.store.loadRun(approved.runId);
assert.ok(owned.implementationContext);
owned.lease = {
processId: 901,
activeParentSessionId: "session-1",
heartbeatAt: 1,
worktreeIdentity: owned.workspace.root,
};
owned.revision++;
owned.implementationContext.runRevision = owned.revision;
await setup.store.saveRun(owned, owned.revision - 1);
assert.deepEqual(
await setup.engine("session-2", {
now: () => 100_000,
processId: 902,
isProcessAlive: (processId) => processId === 901,
}).recover(),
[],
);
const fenced = await setup.store.loadRun(owned.runId);
assert.equal(fenced.lease?.activeParentSessionId, "session-1");
assert.equal(
fenced.implementationContext?.activeParentSessionId,
"session-1",
);
assert.equal(fenced.lease?.processId, 901);
assert.equal(fenced.parentSessionIds.includes("session-2"), false);
});
});
test("startup recovery fails closed for a fresh live owner and atomically rotates dead ownership", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
setup.executor.enqueue("design", design("minor"));
const planned = await setup.engine().execute(startCommand());
const approved = await setup.engine().execute({
action: "decide",
runId: planned.runId,
gateId: planned.openGate!.id,
expectedRevision: planned.revision,
decision: {
kind: "approve-plan",
approved: true,
acceptedUiImpact: "minor",
},
idempotencyKey: "approve-before-owner-recovery",
});
const abandoned = await setup.store.loadRun(approved.runId);
assert.ok(abandoned.implementationContext);
abandoned.lease = {
processId: 901,
activeParentSessionId: "session-1",
heartbeatAt: 1,
worktreeIdentity: abandoned.workspace.root,
};
abandoned.revision++;
abandoned.implementationContext.runRevision = abandoned.revision;
await setup.store.saveRun(abandoned, abandoned.revision - 1);
const liveRecovery = await setup.engine("session-2", {
processId: 902,
isProcessAlive: (processId) => processId === 901,
}).recover();
assert.deepEqual(liveRecovery, []);
const stillOwned = await setup.store.loadRun(abandoned.runId);
assert.equal(stillOwned.lease?.activeParentSessionId, "session-1");
assert.equal(
stillOwned.implementationContext?.activeParentSessionId,
"session-1",
);
await setup.engine("session-2", {
processId: 902,
isProcessAlive: () => false,
}).recover();
const adopted = await setup.store.loadRun(abandoned.runId);
assert.equal(adopted.lease?.activeParentSessionId, "session-2");
assert.equal(
adopted.implementationContext?.activeParentSessionId,
"session-2",
);
assert.equal(adopted.lease?.processId, 902);
assert.ok(adopted.parentSessionIds.includes("session-2"));
adopted.lease = undefined;
adopted.revision++;
adopted.implementationContext!.runRevision = adopted.revision;
await setup.store.saveRun(adopted, adopted.revision - 1);
await setup.engine("session-3", {
processId: 903,
isProcessAlive: () => false,
}).recover();
const adoptedFromAbsent = await setup.store.loadRun(abandoned.runId);
assert.equal(adoptedFromAbsent.lease?.activeParentSessionId, "session-3");
assert.equal(
adoptedFromAbsent.implementationContext?.activeParentSessionId,
"session-3",
);
assert.equal(adoptedFromAbsent.lease?.processId, 903);
});
});
test("analysis scripts run persisted read-only Assignments with tool intersection", async () => { test("analysis scripts run persisted read-only Assignments with tool intersection", async () => {
await withTempDir(async (directory) => { await withTempDir(async (directory) => {
const setup = await fixture(directory); const setup = await fixture(directory);
@@ -1032,6 +1481,98 @@ test("fresh-session handoff is revision-bound and rotates the active parent only
}); });
}); });
test("expired context recovery rejects another live process without mutation", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
const claimed = await createClaimedContextHandoff(setup);
const claim = claimed.implementationContext;
assert.ok(claim);
const before = await setup.store.loadRun(claimed.runId);
await assert.rejects(
setup.engine("unrelated-session", {
now: () => 310_001,
processId: 200,
isProcessAlive: (processId) => processId === 100,
}).execute({
action: "recover-context-intent",
runId: claimed.runId,
gateId: claim.gateId,
preparationId: claim.preparationId,
planRevision: claim.planRevision,
contextRevision: claim.runRevision,
expectedRevision: claimed.revision,
reason: "expired",
}),
/owned by live process 100/,
);
assert.deepEqual(await setup.store.loadRun(claimed.runId), before);
});
});
test("expired context recovery succeeds when the owning process is dead", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
const claimed = await createClaimedContextHandoff(setup);
const claim = claimed.implementationContext;
assert.ok(claim);
const recovered = await setup.engine("replacement-session", {
now: () => 310_001,
processId: 200,
isProcessAlive: () => false,
}).execute({
action: "recover-context-intent",
runId: claimed.runId,
gateId: claim.gateId,
preparationId: claim.preparationId,
planRevision: claim.planRevision,
contextRevision: claim.runRevision,
expectedRevision: claimed.revision,
reason: "expired",
});
assert.equal(recovered.revision, claimed.revision + 1);
assert.equal(recovered.status, "waiting");
assert.equal(recovered.implementationContext?.status, "handoff-pending");
assert.equal(recovered.implementationContext?.sourceSessionFile, undefined);
assert.equal(recovered.implementationContext?.targetSessionFile, undefined);
assert.match(recovered.implementationContext?.error ?? "", /during expired/);
});
});
test("expired context recovery succeeds for the owning process", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
const claimed = await createClaimedContextHandoff(setup);
const claim = claimed.implementationContext;
assert.ok(claim);
const recovered = await setup.engine("session-1", {
now: () => 310_001,
processId: 100,
isProcessAlive: (processId) => processId === 100,
}).execute({
action: "recover-context-intent",
runId: claimed.runId,
gateId: claim.gateId,
preparationId: claim.preparationId,
planRevision: claim.planRevision,
contextRevision: claim.runRevision,
expectedRevision: claimed.revision,
reason: "expired",
});
assert.equal(recovered.revision, claimed.revision + 1);
assert.equal(recovered.status, "waiting");
assert.equal(recovered.implementationContext?.status, "handoff-pending");
assert.equal(recovered.implementationContext?.sourceSessionFile, undefined);
assert.equal(recovered.implementationContext?.targetSessionFile, undefined);
assert.match(recovered.implementationContext?.error ?? "", /during expired/);
});
});
test("smart compaction reports are fenced and unavailable alone enables fallback", async () => { test("smart compaction reports are fenced and unavailable alone enables fallback", async () => {
await withTempDir(async (directory) => { await withTempDir(async (directory) => {
const setup = await fixture(directory); const setup = await fixture(directory);
@@ -1190,7 +1731,6 @@ test("recovery marks an interrupted normal compaction intent failed", async () =
}); });
const recovered = await setup.engine("session-1", { const recovered = await setup.engine("session-1", {
now: () => 100_000, now: () => 100_000,
leaseStaleMs: 1,
processId: 200, processId: 200,
isProcessAlive: () => false, isProcessAlive: () => false,
}).recover(); }).recover();
@@ -1602,7 +2142,7 @@ test("Recovery retry reruns failed verification after the remediation limit", as
hostCommands, hostCommands,
effectiveConfig, effectiveConfig,
processId: 200, processId: 200,
isProcessAlive: () => false, isProcessAlive: (processId) => processId === 100,
}); });
assert.deepEqual(await transientRecovery.recover(), []); assert.deepEqual(await transientRecovery.recover(), []);
const untouched = await setup.store.loadRun(waiting.runId); const untouched = await setup.store.loadRun(waiting.runId);
@@ -2340,6 +2880,114 @@ test("failed execution metadata and transcripts are persisted", async () => {
}); });
}); });
test("start cancellation during slow preflight settles before rejecting without publication", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
let markPreparing = () => {};
let releasePrepare = () => {};
const preparing = new Promise<void>((resolve) => {
markPreparing = resolve;
});
const prepareBlocked = new Promise<void>((resolve) => {
releasePrepare = resolve;
});
setup.workspace.beforePrepare = async () => {
markPreparing();
await prepareBlocked;
};
const engine = setup.engine();
const operation = engine.execute(startCommand());
await preparing;
const cancellation = engine.cancelStart(
"start-key",
"cancel-slow-preflight",
new Error("cancelled during slow preflight"),
);
let cancellationSettled = false;
void cancellation.then(() => {
cancellationSettled = true;
});
await Promise.resolve();
assert.equal(cancellationSettled, false);
releasePrepare();
await assert.rejects(operation, /cancelled during slow preflight/);
await cancellation;
assert.equal(setup.workspace.createCalls, 0);
assert.equal(setup.executor.requests.length, 0);
assert.deepEqual(await setup.store.listRuns(), []);
});
});
test("start cancellation after reservation prevents later run and worktree publication", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
const reserveStart = setup.store.reserveStart.bind(setup.store);
let markReserved = () => {};
let releaseReservation = () => {};
const reserved = new Promise<void>((resolve) => {
markReserved = resolve;
});
const reservationBlocked = new Promise<void>((resolve) => {
releaseReservation = resolve;
});
setup.store.reserveStart = async (key, fingerprint, runId) => {
const reservation = await reserveStart(key, fingerprint, runId);
markReserved();
await reservationBlocked;
return reservation;
};
const engine = setup.engine();
const operation = engine.execute(startCommand());
await reserved;
const cancellation = engine.cancelStart(
"start-key",
"cancel-reserved-start",
new Error("cancelled after reservation"),
);
let cancellationSettled = false;
void cancellation.then(() => {
cancellationSettled = true;
});
await Promise.resolve();
assert.equal(cancellationSettled, false);
releaseReservation();
await assert.rejects(operation, /cancelled after reservation/);
await cancellation;
assert.equal(setup.workspace.createCalls, 0);
assert.equal(setup.executor.requests.length, 0);
assert.deepEqual(await setup.store.listRuns(), []);
});
});
test("start cancellation after publication durably aborts a settled run", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", discoveryWithQuestions());
const engine = setup.engine();
const started = await engine.execute(startCommand());
await new Promise((resolve) => setImmediate(resolve));
await engine.cancelStart(
"start-key",
"cancel-published-start",
new Error("cancelled after publication"),
);
const aborted = await setup.store.loadRun(started.runId);
assert.equal(aborted.status, "aborted");
assert.equal(
(await setup.store.getStart("start-key"))?.status,
"completed",
);
assert.equal(
((await setup.store.getStart("start-key"))?.result as WorkflowView).status,
"aborted",
);
});
});
test("start and decision idempotency prevent replay and reject changed commands", async () => { test("start and decision idempotency prevent replay and reject changed commands", async () => {
await withTempDir(async (directory) => { await withTempDir(async (directory) => {
const setup = await fixture(directory); const setup = await fixture(directory);
@@ -2353,6 +3001,10 @@ test("start and decision idempotency prevent replay and reject changed commands"
assert.equal(setup.workspace.prepareCalls, 1); assert.equal(setup.workspace.prepareCalls, 1);
assert.equal(setup.workspace.createCalls, 1); assert.equal(setup.workspace.createCalls, 1);
assert.equal(setup.executor.requests.length, 1); assert.equal(setup.executor.requests.length, 1);
await assert.rejects(
engine.execute({ ...command, allowDirtySource: true }),
/another start command/,
);
const decide = { const decide = {
action: "decide" as const, action: "decide" as const,
@@ -2508,7 +3160,6 @@ test("live workspace creation cannot be stolen after its heartbeat ages", async
now: () => now, now: () => now,
createId, createId,
processId, processId,
leaseStaleMs: 10,
isProcessAlive: () => true, isProcessAlive: () => true,
}); });
const owner = makeEngine( const owner = makeEngine(
@@ -2823,7 +3474,6 @@ test("active assignment leases heartbeat and block concurrent recovery", async (
executor: blocking, executor: blocking,
now: () => now, now: () => now,
processId: 100, processId: 100,
leaseStaleMs: 10,
assignmentHeartbeatMs: 2, assignmentHeartbeatMs: 2,
}); });
const start = engine.execute(startCommand()); const start = engine.execute(startCommand());
@@ -2843,7 +3493,6 @@ test("active assignment leases heartbeat and block concurrent recovery", async (
.engine("session-2", { .engine("session-2", {
now: () => now, now: () => now,
processId: 100, processId: 100,
leaseStaleMs: 10,
isProcessAlive: () => false, isProcessAlive: () => false,
}) })
.recover(), .recover(),
@@ -2854,7 +3503,7 @@ test("active assignment leases heartbeat and block concurrent recovery", async (
}); });
}); });
test("a stale heartbeat fences a live read-only assignment during recovery", async () => { test("a live read-only assignment remains fenced when its heartbeat is old", async () => {
await withTempDir(async (directory) => { await withTempDir(async (directory) => {
const setup = await fixture(directory); const setup = await fixture(directory);
const blocking = new BlockingExecutor(discoveryWithQuestions()); const blocking = new BlockingExecutor(discoveryWithQuestions());
@@ -2864,27 +3513,21 @@ test("a stale heartbeat fences a live read-only assignment during recovery", asy
executor: blocking, executor: blocking,
now: () => now, now: () => now,
processId: 100, processId: 100,
leaseStaleMs: 10,
assignmentHeartbeatMs: 1_000, assignmentHeartbeatMs: 1_000,
}) })
.execute(startCommand()); .execute(startCommand());
const startOutcome = start.then(
() => undefined,
(error: unknown) => error,
);
await blocking.started; await blocking.started;
now = 100_000; now = 100_000;
const recovery = setup.engine("session-2", { const recovery = setup.engine("session-2", {
now: () => now, now: () => now,
processId: 200, processId: 200,
leaseStaleMs: 10,
isProcessAlive: () => true, isProcessAlive: () => true,
}); });
const [recovered] = await recovery.recover(); assert.deepEqual(await recovery.recover(), []);
assert.equal(recovered?.openGate?.kind, "recovery"); const [running] = await setup.store.listRuns();
assert.equal(recovered?.assignments[0]?.status, "interrupted"); assert.equal(running?.assignments[0]?.status, "running");
blocking.release(); blocking.release();
assert.match(String(await startOutcome), /lost its execution lease/); assert.equal((await start).openGate?.kind, "questions");
}); });
}); });
@@ -2916,7 +3559,6 @@ test("a live mutating assignment is not reclaimed on heartbeat age alone", async
const recovery = setup.engine("session-2", { const recovery = setup.engine("session-2", {
now: () => 100_000, now: () => 100_000,
processId: 200, processId: 200,
leaseStaleMs: 10,
isProcessAlive: () => true, isProcessAlive: () => true,
}); });
assert.deepEqual(await recovery.recover(), []); assert.deepEqual(await recovery.recover(), []);
@@ -3423,6 +4065,65 @@ test("abort fails closed for an Assignment owned by another live process", async
}); });
}); });
test("cross-instance abort fails closed for a live timed-out mutator lease", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", discoveryWithQuestions());
const started = await setup.engine().execute(startCommand());
const current = await setup.store.loadRun(started.runId);
const profile = setup.profiles.find((candidate) =>
candidate.roles.includes("implement"),
);
const evidence = current.preflightEligibility.find(
(candidate) => candidate.role === "implement",
);
assert.ok(profile && evidence);
current.assignments.push({
id: "timed-out-external-mutator",
stageId: `${current.runId}:implement`,
role: "implement",
profileName: profile.name,
profileDigest: profile.digest,
selection: {
eligibleProfiles: structuredClone(evidence.candidates),
selectedProfileName: profile.name,
rationale: "Timed-out mutator owned by another engine instance",
},
status: "interrupted",
mutates: true,
ownedPaths: ["src"],
executionToken: "timed-out-external-token",
error: "Assignment implement timed out",
});
current.status = "waiting";
current.revision++;
await setup.store.saveRun(current, current.revision - 1);
await setup.store.createAssignmentLease(
current.runId,
"timed-out-external-mutator",
{
token: "timed-out-external-token",
processId: 200,
heartbeatAt: 10_000,
},
);
await assert.rejects(
setup.engine("session-1", {
processId: 100,
isProcessAlive: (processId) => processId === 200,
}).execute({
action: "abort",
runId: current.runId,
expectedRevision: current.revision,
idempotencyKey: "abort-timed-out-external",
}),
/Cannot safely abort Assignment timed-out-external-mutator owned by live process 200/,
);
assert.equal((await setup.store.loadRun(current.runId)).status, "waiting");
});
});
test("abort is revision checked and idempotent", async () => { test("abort is revision checked and idempotent", async () => {
await withTempDir(async (directory) => { await withTempDir(async (directory) => {
const setup = await fixture(directory); const setup = await fixture(directory);
+315 -77
View File
@@ -35,9 +35,9 @@ import {
const FEATURE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const FEATURE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const MAX_FEATURE_NAME_LENGTH = 80; const MAX_FEATURE_NAME_LENGTH = 80;
const DEFAULT_LEASE_STALE_MS = 30_000;
const DEFAULT_ASSIGNMENT_HEARTBEAT_MS = 10_000; const DEFAULT_ASSIGNMENT_HEARTBEAT_MS = 10_000;
const DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS = 15_000; const DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS = 15_000;
const DEFAULT_ASSIGNMENT_EXECUTION_TIMEOUT_MS = 5 * 60 * 1_000;
const ACTIVE_ASSIGNMENT_TOKENS = new Set<string>(); const ACTIVE_ASSIGNMENT_TOKENS = new Set<string>();
interface ActiveAssignmentExecution { interface ActiveAssignmentExecution {
runId: string; runId: string;
@@ -59,6 +59,7 @@ const ACTIVE_STARTS = new Map<
string, string,
{ {
fingerprint: string; fingerprint: string;
controller: AbortController;
operation: Promise<WorkflowView>; operation: Promise<WorkflowView>;
published: Promise<WorkflowView>; published: Promise<WorkflowView>;
} }
@@ -78,6 +79,7 @@ export interface StartWorkflowCommand {
idempotencyKey: string; idempotencyKey: string;
concurrency?: number; concurrency?: number;
background?: boolean; background?: boolean;
allowDirtySource?: boolean;
script?: FeatureScriptInput; script?: FeatureScriptInput;
} }
@@ -372,10 +374,10 @@ export interface WorkflowEngineOptions {
now?: () => number; now?: () => number;
createId?: () => string; createId?: () => string;
processId?: number; processId?: number;
leaseStaleMs?: number;
isProcessAlive?: (processId: number) => boolean; isProcessAlive?: (processId: number) => boolean;
assignmentHeartbeatMs?: number; assignmentHeartbeatMs?: number;
assignmentAbortTimeoutMs?: number; assignmentAbortTimeoutMs?: number;
assignmentExecutionTimeoutMs?: number;
afterAssignmentPersist?: ( afterAssignmentPersist?: (
assignment: WorkflowAssignment, assignment: WorkflowAssignment,
run: WorkflowRun, run: WorkflowRun,
@@ -613,7 +615,7 @@ function validateWorkflowCommand(
assertKeys( assertKeys(
value, value,
["action", "request", "featureName", "idempotencyKey"], ["action", "request", "featureName", "idempotencyKey"],
["background", "concurrency", "script"], ["allowDirtySource", "background", "concurrency", "script"],
); );
nonEmptyString(value.request, "request"); nonEmptyString(value.request, "request");
nonEmptyString(value.featureName, "featureName"); nonEmptyString(value.featureName, "featureName");
@@ -632,6 +634,12 @@ function validateWorkflowCommand(
) { ) {
throw new Error("background must be a boolean"); throw new Error("background must be a boolean");
} }
if (
value.allowDirtySource !== undefined &&
typeof value.allowDirtySource !== "boolean"
) {
throw new Error("allowDirtySource must be a boolean");
}
if (value.script !== undefined) validateFeatureScript(value.script); if (value.script !== undefined) validateFeatureScript(value.script);
return; return;
} }
@@ -1243,22 +1251,76 @@ export class WorkflowEngine {
private readonly now: () => number; private readonly now: () => number;
private readonly createId: () => string; private readonly createId: () => string;
private readonly processId: number; private readonly processId: number;
private readonly leaseStaleMs: number;
private readonly isProcessAlive: (processId: number) => boolean; private readonly isProcessAlive: (processId: number) => boolean;
private readonly assignmentHeartbeatMs: number; private readonly assignmentHeartbeatMs: number;
private readonly assignmentAbortTimeoutMs: number; private readonly assignmentAbortTimeoutMs: number;
private readonly assignmentExecutionTimeoutMs: number;
constructor(options: WorkflowEngineOptions) { constructor(options: WorkflowEngineOptions) {
this.options = options; this.options = options;
this.now = options.now ?? Date.now; this.now = options.now ?? Date.now;
this.createId = options.createId ?? randomUUID; this.createId = options.createId ?? randomUUID;
this.processId = options.processId ?? process.pid; this.processId = options.processId ?? process.pid;
this.leaseStaleMs = options.leaseStaleMs ?? DEFAULT_LEASE_STALE_MS;
this.isProcessAlive = options.isProcessAlive ?? processAlive; this.isProcessAlive = options.isProcessAlive ?? processAlive;
this.assignmentHeartbeatMs = this.assignmentHeartbeatMs =
options.assignmentHeartbeatMs ?? DEFAULT_ASSIGNMENT_HEARTBEAT_MS; options.assignmentHeartbeatMs ?? DEFAULT_ASSIGNMENT_HEARTBEAT_MS;
this.assignmentAbortTimeoutMs = this.assignmentAbortTimeoutMs =
options.assignmentAbortTimeoutMs ?? DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS; options.assignmentAbortTimeoutMs ?? DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS;
this.assignmentExecutionTimeoutMs =
options.assignmentExecutionTimeoutMs ?? DEFAULT_ASSIGNMENT_EXECUTION_TIMEOUT_MS;
if (this.assignmentExecutionTimeoutMs < 1) {
throw new Error("Assignment execution timeout must be positive");
}
}
async cancelStart(
idempotencyKey: string,
abortIdempotencyKey: string,
reason: Error,
): Promise<void> {
const activeKey = `${this.options.store.root}\0${idempotencyKey}`;
const active = ACTIVE_STARTS.get(activeKey);
if (!active) {
const start = await this.options.store.getStart(idempotencyKey);
if (!start) return;
const terminal = await this.abortCancelledStart(
start.runId,
abortIdempotencyKey,
);
if (terminal) {
await this.options.store.completeStart(idempotencyKey, terminal);
}
return;
}
active.controller.abort(reason);
const published = await Promise.race([
active.published.then(
(result) => result,
() => undefined,
),
active.operation.then(
() => undefined,
() => undefined,
),
]);
let terminal = published
? await this.abortCancelledStart(published.runId, abortIdempotencyKey)
: undefined;
await active.operation.catch(() => {});
if (!terminal) {
const start = await this.options.store.getStart(idempotencyKey);
if (start) {
terminal = await this.abortCancelledStart(
start.runId,
abortIdempotencyKey,
);
}
}
if (terminal) {
await this.options.store.completeStart(idempotencyKey, terminal);
}
} }
async execute(command: WorkflowCommand): Promise<WorkflowView> { async execute(command: WorkflowCommand): Promise<WorkflowView> {
@@ -1317,9 +1379,10 @@ export class WorkflowEngine {
} }
if ( if (
run.implementationContext?.status === "normal-compact-running" && run.implementationContext?.status === "normal-compact-running" &&
this.leaseIsStale(run) this.ownershipIsRecoverable(run)
) { ) {
run = await this.transition(run, (next) => { run = await this.transition(run, (next) => {
this.rotateOwnership(next);
const context = next.implementationContext; const context = next.implementationContext;
if (!context) throw new Error("Implementation Context state is missing"); if (!context) throw new Error("Implementation Context state is missing");
context.status = "failed"; context.status = "failed";
@@ -1335,22 +1398,21 @@ export class WorkflowEngine {
const running = run.assignments.filter( const running = run.assignments.filter(
(assignment) => (assignment) =>
assignment.status === "running" || assignment.status === "running" ||
(assignment.status === "queued" && Boolean(assignment.executionToken)), ((assignment.status === "queued" || assignment.status === "interrupted") &&
Boolean(assignment.executionToken)),
); );
const alreadyWaiting = activeGate(run)?.kind === "recovery"; const alreadyWaiting = activeGate(run)?.kind === "recovery";
if (running.length === 0 && alreadyWaiting) continue; if (
running.length === 0 &&
alreadyWaiting &&
!this.ownershipIsRecoverable(run)
) {
continue;
}
if (await this.hasActiveAssignmentLease(run.runId, running)) continue; if (await this.hasActiveAssignmentLease(run.runId, running)) continue;
if (!this.leaseIsStale(run)) continue; if (!this.ownershipIsRecoverable(run)) continue;
run = await this.transition(run, (next) => { run = await this.transition(run, (next) => {
next.lease = { this.rotateOwnership(next);
processId: this.processId,
activeParentSessionId: this.options.parentSessionId,
heartbeatAt: this.now(),
worktreeIdentity: next.workspace.root,
};
next.parentSessionIds = [
...new Set([...next.parentSessionIds, this.options.parentSessionId]),
];
}); });
if (running.length === 0) { if (running.length === 0) {
const claimedRevision = run.revision; const claimedRevision = run.revision;
@@ -1374,15 +1436,7 @@ export class WorkflowEngine {
for (const assignment of next.assignments) { for (const assignment of next.assignments) {
if (interruptedIds.has(assignment.id)) assignment.status = "interrupted"; if (interruptedIds.has(assignment.id)) assignment.status = "interrupted";
} }
next.lease = { this.rotateOwnership(next);
processId: this.processId,
activeParentSessionId: this.options.parentSessionId,
heartbeatAt: this.now(),
worktreeIdentity: next.workspace.root,
};
next.parentSessionIds = [
...new Set([...next.parentSessionIds, this.options.parentSessionId]),
];
next.status = "waiting"; next.status = "waiting";
if (!activeGate(next)) { if (!activeGate(next)) {
const gate: WorkflowGate = { const gate: WorkflowGate = {
@@ -1428,11 +1482,13 @@ export class WorkflowEngine {
resolvePublished = resolve; resolvePublished = resolve;
rejectPublished = reject; rejectPublished = reject;
}); });
const operation = this.startOwned(command, resolvePublished); const controller = new AbortController();
const operation = this.startOwned(command, controller.signal, resolvePublished);
void operation.catch(rejectPublished); void operation.catch(rejectPublished);
void published.catch(() => {}); void published.catch(() => {});
ACTIVE_STARTS.set(activeKey, { ACTIVE_STARTS.set(activeKey, {
fingerprint: commandFingerprint, fingerprint: commandFingerprint,
controller,
operation, operation,
published, published,
}); });
@@ -1467,11 +1523,25 @@ export class WorkflowEngine {
} }
return active.operation; return active.operation;
} }
const operation = this.startAnalysisOwned(command); let resolvePublished = (_view: WorkflowView) => {};
let rejectPublished = (_error: unknown) => {};
const published = new Promise<WorkflowView>((resolve, reject) => {
resolvePublished = resolve;
rejectPublished = reject;
});
const controller = new AbortController();
const operation = this.startAnalysisOwned(
command,
controller.signal,
resolvePublished,
);
void operation.catch(rejectPublished);
void published.catch(() => {});
ACTIVE_STARTS.set(activeKey, { ACTIVE_STARTS.set(activeKey, {
fingerprint: commandFingerprint, fingerprint: commandFingerprint,
controller,
operation, operation,
published: operation, published,
}); });
void operation void operation
.finally(() => { .finally(() => {
@@ -1483,33 +1553,47 @@ export class WorkflowEngine {
return operation; return operation;
} }
private async startAnalysisOwned(command: StartAnalysisWorkflowCommand) { private async startAnalysisOwned(
command: StartAnalysisWorkflowCommand,
cancellationSignal: AbortSignal,
onPublished?: (view: WorkflowView) => void,
) {
this.throwIfStartCancelled(cancellationSignal);
if (!this.options.scriptRuntime) { if (!this.options.scriptRuntime) {
throw new Error("Analysis scripts require the restricted script runtime"); throw new Error("Analysis scripts require the restricted script runtime");
} }
const commandFingerprint = fingerprint(command); const commandFingerprint = fingerprint(command);
const existing = await this.options.store.getStart(command.idempotencyKey); const existing = await this.options.store.getStart(command.idempotencyKey);
this.throwIfStartCancelled(cancellationSignal);
if (existing) { if (existing) {
this.validateExistingStart(existing, commandFingerprint); this.validateExistingStart(existing, commandFingerprint);
if (existing.status === "completed") { if (existing.status === "completed") {
return existing.result as unknown as WorkflowView; const result = existing.result as unknown as WorkflowView;
onPublished?.(result);
return result;
} }
} }
const runId = existing?.runId ?? this.createId(); const runId = existing?.runId ?? this.createId();
this.throwIfStartCancelled(cancellationSignal);
const reservation = await this.options.store.reserveStart( const reservation = await this.options.store.reserveStart(
command.idempotencyKey, command.idempotencyKey,
commandFingerprint, commandFingerprint,
runId, runId,
); );
this.throwIfStartCancelled(cancellationSignal);
this.validateExistingStart(reservation.record, commandFingerprint); this.validateExistingStart(reservation.record, commandFingerprint);
let run: WorkflowRun; let run: WorkflowRun;
try { try {
run = await this.options.store.loadRun(reservation.record.runId); run = await this.options.store.loadRun(reservation.record.runId);
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
this.throwIfStartCancelled(cancellationSignal);
run = this.initialAnalysisRun(reservation.record.runId, command); run = this.initialAnalysisRun(reservation.record.runId, command);
this.throwIfStartCancelled(cancellationSignal);
run = (await this.options.store.createRun(run)).run; run = (await this.options.store.createRun(run)).run;
} }
onPublished?.(view(run));
this.throwIfStartCancelled(cancellationSignal);
if (run.status !== "completed" && run.status !== "failed") { if (run.status !== "completed" && run.status !== "failed") {
run = await this.runWorkflowScript(run); run = await this.runWorkflowScript(run);
} }
@@ -1520,11 +1604,15 @@ export class WorkflowEngine {
private async startOwned( private async startOwned(
command: StartWorkflowCommand, command: StartWorkflowCommand,
cancellationSignal: AbortSignal,
onPublished?: (view: WorkflowView) => void, onPublished?: (view: WorkflowView) => void,
) { ) {
this.throwIfStartCancelled(cancellationSignal);
this.validateStart(command); this.validateStart(command);
this.throwIfStartCancelled(cancellationSignal);
const commandFingerprint = fingerprint(command); const commandFingerprint = fingerprint(command);
const existing = await this.options.store.getStart(command.idempotencyKey); const existing = await this.options.store.getStart(command.idempotencyKey);
this.throwIfStartCancelled(cancellationSignal);
if (existing) { if (existing) {
this.validateExistingStart(existing, commandFingerprint); this.validateExistingStart(existing, commandFingerprint);
if (existing.status === "completed") { if (existing.status === "completed") {
@@ -1546,6 +1634,7 @@ export class WorkflowEngine {
}; };
const preflightEligibility = this.preflightEligibility(initialFacts); const preflightEligibility = this.preflightEligibility(initialFacts);
selectInitialDiscoveryProfile(this.options.profiles, initialFacts); selectInitialDiscoveryProfile(this.options.profiles, initialFacts);
this.throwIfStartCancelled(cancellationSignal);
let persistedRun: WorkflowRun | undefined; let persistedRun: WorkflowRun | undefined;
if (existing?.status === "pending") { if (existing?.status === "pending") {
@@ -1556,6 +1645,7 @@ export class WorkflowEngine {
} }
} }
let runId = existing?.runId ?? this.createId(); let runId = existing?.runId ?? this.createId();
this.throwIfStartCancelled(cancellationSignal);
let workspacePlan = persistedRun let workspacePlan = persistedRun
? this.workspacePlanForRun(persistedRun) ? this.workspacePlanForRun(persistedRun)
: await this.options.workspace.prepare({ : await this.options.workspace.prepare({
@@ -1563,11 +1653,14 @@ export class WorkflowEngine {
configuredRoot: this.options.effectiveConfig.worktrees.root, configuredRoot: this.options.effectiveConfig.worktrees.root,
featureName: command.featureName, featureName: command.featureName,
runId, runId,
allowDirtySource: command.allowDirtySource,
}); });
this.throwIfStartCancelled(cancellationSignal);
if (workspacePlan.branchExists) { if (workspacePlan.branchExists) {
const owner = await this.options.store.findRunByBranch( const owner = await this.options.store.findRunByBranch(
workspacePlan.branch, workspacePlan.branch,
); );
this.throwIfStartCancelled(cancellationSignal);
const resumable = const resumable =
existing?.status === "pending" && owner?.runId === runId; existing?.status === "pending" && owner?.runId === runId;
const uncertainPending = existing?.status === "pending" && !owner; const uncertainPending = existing?.status === "pending" && !owner;
@@ -1583,11 +1676,13 @@ export class WorkflowEngine {
} }
} }
this.throwIfStartCancelled(cancellationSignal);
const reservation = await this.options.store.reserveStart( const reservation = await this.options.store.reserveStart(
command.idempotencyKey, command.idempotencyKey,
commandFingerprint, commandFingerprint,
runId, runId,
); );
this.throwIfStartCancelled(cancellationSignal);
this.validateExistingStart(reservation.record, commandFingerprint); this.validateExistingStart(reservation.record, commandFingerprint);
if (reservation.record.runId !== runId) { if (reservation.record.runId !== runId) {
runId = reservation.record.runId; runId = reservation.record.runId;
@@ -1597,6 +1692,7 @@ export class WorkflowEngine {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
persistedRun = undefined; persistedRun = undefined;
} }
this.throwIfStartCancelled(cancellationSignal);
workspacePlan = persistedRun workspacePlan = persistedRun
? this.workspacePlanForRun(persistedRun) ? this.workspacePlanForRun(persistedRun)
: await this.options.workspace.prepare({ : await this.options.workspace.prepare({
@@ -1604,28 +1700,34 @@ export class WorkflowEngine {
configuredRoot: this.options.effectiveConfig.worktrees.root, configuredRoot: this.options.effectiveConfig.worktrees.root,
featureName: command.featureName, featureName: command.featureName,
runId, runId,
allowDirtySource: command.allowDirtySource,
}); });
this.throwIfStartCancelled(cancellationSignal);
} }
{ {
this.throwIfStartCancelled(cancellationSignal);
let run: WorkflowRun; let run: WorkflowRun;
let persistedBeforeThisAttempt = true; let persistedBeforeThisAttempt = true;
try { try {
run = persistedRun ?? (await this.options.store.loadRun(runId)); run = persistedRun ?? (await this.options.store.loadRun(runId));
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
this.throwIfStartCancelled(cancellationSignal);
run = this.initialRun( run = this.initialRun(
runId, runId,
command, command,
workspacePlan, workspacePlan,
preflightEligibility, preflightEligibility,
); );
this.throwIfStartCancelled(cancellationSignal);
const publication = await this.options.store.createRun(run); const publication = await this.options.store.createRun(run);
persistedBeforeThisAttempt = !publication.created; persistedBeforeThisAttempt = !publication.created;
run = publication.run; run = publication.run;
} }
onPublished?.(view(run)); onPublished?.(view(run));
this.throwIfStartCancelled(cancellationSignal);
if (persistedBeforeThisAttempt) { if (persistedBeforeThisAttempt) {
const claimed = await this.claimPersistedStartRun(run); const claimed = await this.claimPersistedStartRun(run);
@@ -1634,16 +1736,19 @@ export class WorkflowEngine {
} }
if (run.workspace.state === "creating" && persistedBeforeThisAttempt) { if (run.workspace.state === "creating" && persistedBeforeThisAttempt) {
this.throwIfStartCancelled(cancellationSignal);
const reconciled = await this.options.workspace.reconcile( const reconciled = await this.options.workspace.reconcile(
run.workspace, run.workspace,
run.lease?.worktreeIdentity, run.lease?.worktreeIdentity,
); );
this.throwIfStartCancelled(cancellationSignal);
if (reconciled.state !== "creating") { if (reconciled.state !== "creating") {
run = await this.persistWorkspaceOutcome(run, reconciled); run = await this.persistWorkspaceOutcome(run, reconciled);
} }
} }
if (run.workspace.state === "creating") { if (run.workspace.state === "creating") {
this.throwIfStartCancelled(cancellationSignal);
let workspace: WorkflowRun["workspace"]; let workspace: WorkflowRun["workspace"];
try { try {
workspace = await this.options.workspace.create(workspacePlan); workspace = await this.options.workspace.create(workspacePlan);
@@ -1654,6 +1759,7 @@ export class WorkflowEngine {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}; };
} }
this.throwIfStartCancelled(cancellationSignal);
run = await this.persistWorkspaceOutcome(run, workspace); run = await this.persistWorkspaceOutcome(run, workspace);
} }
@@ -1661,12 +1767,14 @@ export class WorkflowEngine {
const discoverStatus = stage(run, "discover").status; const discoverStatus = stage(run, "discover").status;
const designStatus = stage(run, "design").status; const designStatus = stage(run, "design").status;
if (discoverStatus === "pending" || discoverStatus === "running") { if (discoverStatus === "pending" || discoverStatus === "running") {
run = await this.runDiscovery(run); this.throwIfStartCancelled(cancellationSignal);
run = await this.runDiscovery(run, cancellationSignal);
} else if ( } else if (
discoverStatus === "completed" && discoverStatus === "completed" &&
(designStatus === "pending" || designStatus === "running") (designStatus === "pending" || designStatus === "running")
) { ) {
run = await this.runDesign(run); this.throwIfStartCancelled(cancellationSignal);
run = await this.runDesign(run, false, cancellationSignal);
} }
} }
@@ -1691,18 +1799,10 @@ export class WorkflowEngine {
) { ) {
return undefined; return undefined;
} }
if (!this.leaseIsStale(run)) return undefined; if (!this.ownershipIsRecoverable(run)) return undefined;
try { try {
return await this.transition(run, (next) => { return await this.transition(run, (next) => {
next.lease = { this.rotateOwnership(next);
processId: this.processId,
activeParentSessionId: this.options.parentSessionId,
heartbeatAt: this.now(),
worktreeIdentity: next.workspace.root,
};
next.parentSessionIds = [
...new Set([...next.parentSessionIds, this.options.parentSessionId]),
];
}); });
} catch (error) { } catch (error) {
if ( if (
@@ -1787,6 +1887,9 @@ export class WorkflowEngine {
branch: "", branch: "",
baseHead: "", baseHead: "",
branchExists: false, branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
}, },
[], [],
); );
@@ -1880,6 +1983,9 @@ export class WorkflowEngine {
root: workspacePlan.worktreePath, root: workspacePlan.worktreePath,
branch: workspacePlan.branch, branch: workspacePlan.branch,
baseHead: workspacePlan.baseHead, baseHead: workspacePlan.baseHead,
sourceChanges: structuredClone(workspacePlan.sourceChanges),
sourceChangesOmitted: workspacePlan.sourceChangesOmitted,
dirtySourceAllowed: workspacePlan.dirtySourceAllowed,
}, },
effectiveConfig: { effectiveConfig: {
...structuredClone(this.options.effectiveConfig), ...structuredClone(this.options.effectiveConfig),
@@ -2340,7 +2446,10 @@ export class WorkflowEngine {
} }
} }
private async runDiscovery(run: WorkflowRun) { private async runDiscovery(
run: WorkflowRun,
cancellationSignal?: AbortSignal,
) {
const selected = selectionEvidence( const selected = selectionEvidence(
run.pinnedProfiles, run.pinnedProfiles,
{ ...run.facts, stage: "discover" }, { ...run.facts, stage: "discover" },
@@ -2350,6 +2459,8 @@ export class WorkflowEngine {
run, run,
"discover", "discover",
selected, selected,
false,
cancellationSignal,
); );
run = execution.run; run = execution.run;
if (!execution.output) return run; if (!execution.output) return run;
@@ -2389,7 +2500,9 @@ export class WorkflowEngine {
}); });
if (output.questions.length > 0) return run; if (output.questions.length > 0) return run;
run = await this.runWorkflowScript(run, "before-design"); run = await this.runWorkflowScript(run, "before-design");
return run.status === "failed" ? run : this.runDesign(run); return run.status === "failed"
? run
: this.runDesign(run, false, cancellationSignal);
} }
private plannerCandidates(run: WorkflowRun) { private plannerCandidates(run: WorkflowRun) {
@@ -2493,7 +2606,11 @@ export class WorkflowEngine {
return planned; return planned;
} }
private async runDesign(run: WorkflowRun, forceNewAssignment = false) { private async runDesign(
run: WorkflowRun,
forceNewAssignment = false,
cancellationSignal?: AbortSignal,
) {
const selected = selectionEvidence( const selected = selectionEvidence(
run.pinnedProfiles, run.pinnedProfiles,
{ ...run.facts, stage: "design" }, { ...run.facts, stage: "design" },
@@ -2504,6 +2621,7 @@ export class WorkflowEngine {
"design", "design",
selected, selected,
forceNewAssignment, forceNewAssignment,
cancellationSignal,
); );
run = execution.run; run = execution.run;
if (!execution.output) return run; if (!execution.output) return run;
@@ -2606,6 +2724,7 @@ export class WorkflowEngine {
role: "discover" | "design", role: "discover" | "design",
selected: ReturnType<typeof selectionEvidence>, selected: ReturnType<typeof selectionEvidence>,
forceNewAssignment = false, forceNewAssignment = false,
cancellationSignal?: AbortSignal,
) { ) {
const stageId = stage(run, role).id; const stageId = stage(run, role).id;
const existing = [...run.assignments] const existing = [...run.assignments]
@@ -2620,6 +2739,7 @@ export class WorkflowEngine {
output: existing.status === "succeeded" ? existing.result : undefined, output: existing.status === "succeeded" ? existing.result : undefined,
}; };
} }
if (cancellationSignal) this.throwIfStartCancelled(cancellationSignal);
return this.runAssignment(run, role, selected); return this.runAssignment(run, role, selected);
} }
@@ -2658,6 +2778,11 @@ export class WorkflowEngine {
const assignmentId = planned?.id ?? this.createId(); const assignmentId = planned?.id ?? this.createId();
const executionToken = this.createId(); const executionToken = this.createId();
const abortController = new AbortController(); const abortController = new AbortController();
let executionTimeout: ReturnType<typeof setTimeout> | undefined;
let executorExecution: Promise<unknown> | undefined;
let executionTimedOut = false;
let retainExecutionFence = false;
let cleanupStarted = false;
let resolveSettled = () => {}; let resolveSettled = () => {};
let resolveResumeAfterAbort = () => {}; let resolveResumeAfterAbort = () => {};
const activeExecution: ActiveAssignmentExecution = { const activeExecution: ActiveAssignmentExecution = {
@@ -2740,6 +2865,17 @@ export class WorkflowEngine {
assignment.status = "running"; assignment.status = "running";
}); });
}; };
const cleanupExecutionFence = async () => {
if (cleanupStarted) return;
cleanupStarted = true;
await stopHeartbeat().catch(() => {});
ACTIVE_ASSIGNMENT_TOKENS.delete(executionToken);
ACTIVE_ASSIGNMENT_EXECUTIONS.delete(executionToken);
await this.options.store
.releaseAssignmentLease(run.runId, assignmentId, executionToken)
.catch(() => {});
activeExecution.resolveSettled();
};
if (!this.options.executor.reservesCapacity) await markRunning(); if (!this.options.executor.reservesCapacity) await markRunning();
try { try {
@@ -2748,7 +2884,7 @@ export class WorkflowEngine {
let transcriptArtifact: WorkflowAssignment["transcriptArtifact"]; let transcriptArtifact: WorkflowAssignment["transcriptArtifact"];
try { try {
const evidence = await this.assignmentEvidence(run, role); const evidence = await this.assignmentEvidence(run, role);
const executionResult = await this.options.executor.run({ executorExecution = this.options.executor.run({
runId: run.runId, runId: run.runId,
role, role,
request: planned request: planned
@@ -2771,6 +2907,31 @@ export class WorkflowEngine {
? { onReserved: markRunning } ? { onReserved: markRunning }
: {}), : {}),
}); });
void executorExecution.catch(() => {});
const timeout = new Promise<never>((_resolve, reject) => {
executionTimeout = setTimeout(() => {
executionTimedOut = true;
const timeoutError = new Error(
`Assignment ${role} timed out after ${this.assignmentExecutionTimeoutMs}ms`,
);
abortController.abort(timeoutError);
reject(timeoutError);
}, this.assignmentExecutionTimeoutMs);
});
const settledExecution = executorExecution.then(
(result) => {
if (executionTimeout) clearTimeout(executionTimeout);
return result;
},
(error: unknown) => {
if (executionTimeout) clearTimeout(executionTimeout);
throw error;
},
);
const executionResult = await Promise.race([
settledExecution,
timeout,
]);
metadata = isAssignmentExecutionResult(executionResult) metadata = isAssignmentExecutionResult(executionResult)
? executionResult.metadata ? executionResult.metadata
: undefined; : undefined;
@@ -2799,13 +2960,30 @@ export class WorkflowEngine {
: undefined; : undefined;
activeExecution.metadata = metadata; activeExecution.metadata = metadata;
activeExecution.transcriptArtifact = transcriptArtifact; activeExecution.transcriptArtifact = transcriptArtifact;
await stopHeartbeat(); if (executionTimedOut && executorExecution) {
let abortGrace: ReturnType<typeof setTimeout> | undefined;
const settledDuringGrace = await Promise.race([
executorExecution.then(
() => true,
() => true,
),
new Promise<false>((resolve) => {
abortGrace = setTimeout(
() => resolve(false),
this.assignmentAbortTimeoutMs,
);
}),
]);
if (abortGrace) clearTimeout(abortGrace);
if (!settledDuringGrace) retainExecutionFence = true;
}
if (!retainExecutionFence) await stopHeartbeat();
await this.assertAssignmentLease( await this.assertAssignmentLease(
run.runId, run.runId,
assignmentId, assignmentId,
executionToken, executionToken,
); );
if (abortController.signal.aborted) { if (abortController.signal.aborted && !executionTimedOut) {
activeExecution.resolveSettled(); activeExecution.resolveSettled();
await activeExecution.resumeAfterAbort; await activeExecution.resumeAfterAbort;
run = await this.options.store.loadRun(run.runId); run = await this.options.store.loadRun(run.runId);
@@ -2816,7 +2994,7 @@ export class WorkflowEngine {
(item) => item.id === assignmentId, (item) => item.id === assignmentId,
); );
if (assignment) { if (assignment) {
assignment.status = "failed"; assignment.status = executionTimedOut ? "interrupted" : "failed";
assignment.error = assignment.error =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
applyExecutionMetadata(assignment, metadata, transcriptArtifact); applyExecutionMetadata(assignment, metadata, transcriptArtifact);
@@ -2849,7 +3027,7 @@ export class WorkflowEngine {
} else { } else {
next.status = "failed"; next.status = "failed";
next.error = { next.error = {
code: "assignment-failed", code: executionTimedOut ? "assignment-timeout" : "assignment-failed",
message, message,
stageId: targetStage.id, stageId: targetStage.id,
assignmentId, assignmentId,
@@ -2881,14 +3059,16 @@ export class WorkflowEngine {
); );
return { run, output }; return { run, output };
} finally { } finally {
if (executionTimeout) clearTimeout(executionTimeout);
abortController.abort(); abortController.abort();
await stopHeartbeat().catch(() => {}); if (retainExecutionFence && executorExecution) {
ACTIVE_ASSIGNMENT_TOKENS.delete(executionToken); void executorExecution.then(
ACTIVE_ASSIGNMENT_EXECUTIONS.delete(executionToken); () => cleanupExecutionFence(),
await this.options.store () => cleanupExecutionFence(),
.releaseAssignmentLease(run.runId, assignmentId, executionToken) );
.catch(() => {}); } else {
activeExecution.resolveSettled(); await cleanupExecutionFence();
}
} }
} }
@@ -2998,6 +3178,23 @@ export class WorkflowEngine {
} }
} }
validateDecisionPayload(gate, command.decision); validateDecisionPayload(gate, command.decision);
if (
command.decision.kind === "recover" &&
command.decision.resolution !== "inspect"
) {
const fencedAssignments = run.assignments.filter(
(assignment) =>
Boolean(assignment.executionToken) &&
(assignment.status === "queued" ||
assignment.status === "running" ||
assignment.status === "interrupted"),
);
if (await this.hasActiveAssignmentLease(run.runId, fencedAssignments)) {
throw new Error(
"Recovery is blocked while an interrupted Assignment is still active",
);
}
}
if ( if (
command.decision.kind === "recover" && command.decision.kind === "recover" &&
command.decision.resolution === "retry" && command.decision.resolution === "retry" &&
@@ -4025,7 +4222,6 @@ export class WorkflowEngine {
command: RecoverContextIntentCommand, command: RecoverContextIntentCommand,
) { ) {
if ( if (
command.reason !== "expired" &&
run.lease && run.lease &&
run.lease.processId !== this.processId && run.lease.processId !== this.processId &&
this.isProcessAlive(run.lease.processId) this.isProcessAlive(run.lease.processId)
@@ -4101,6 +4297,38 @@ export class WorkflowEngine {
return view(run); return view(run);
} }
private throwIfStartCancelled(signal: AbortSignal) {
if (!signal.aborted) return;
throw signal.reason instanceof Error
? signal.reason
: new Error("Workflow start was cancelled");
}
private async abortCancelledStart(
runId: string,
idempotencyKey: string,
): Promise<WorkflowView | undefined> {
while (true) {
const run = await this.options.store.loadRun(runId).catch(() => undefined);
if (!run) return undefined;
if (isTerminalStatus(run.status)) return view(run);
try {
return await this.execute({
action: "abort",
runId,
expectedRevision: run.revision,
idempotencyKey,
});
} catch (error) {
const latest = await this.options.store.loadRun(runId).catch(() => undefined);
if (!latest) return undefined;
if (isTerminalStatus(latest.status)) return view(latest);
if (latest.revision !== run.revision) continue;
throw error;
}
}
}
private async abort(run: WorkflowRun, command: AbortWorkflowCommand) { private async abort(run: WorkflowRun, command: AbortWorkflowCommand) {
const duplicate = this.commandReceipt(run, command); const duplicate = this.commandReceipt(run, command);
if (duplicate) { if (duplicate) {
@@ -4132,7 +4360,9 @@ export class WorkflowEngine {
(assignment) => (assignment) =>
!localAssignmentIds.has(assignment.id) && !localAssignmentIds.has(assignment.id) &&
Boolean(assignment.executionToken) && Boolean(assignment.executionToken) &&
(assignment.status === "queued" || assignment.status === "running"), (assignment.status === "queued" ||
assignment.status === "running" ||
assignment.status === "interrupted"),
); );
for (const assignment of externallyOwned) { for (const assignment of externallyOwned) {
const lease = await this.options.store.loadAssignmentLease( const lease = await this.options.store.loadAssignmentLease(
@@ -4429,6 +4659,9 @@ export class WorkflowEngine {
branch: workspace.branch, branch: workspace.branch,
baseHead: workspace.baseHead, baseHead: workspace.baseHead,
branchExists: false, branchExists: false,
sourceChanges: structuredClone(workspace.sourceChanges ?? []),
sourceChangesOmitted: workspace.sourceChangesOmitted ?? 0,
dirtySourceAllowed: workspace.dirtySourceAllowed ?? false,
}; };
} }
@@ -4554,19 +4787,30 @@ export class WorkflowEngine {
command.decision.resolution !== "retry" || command.decision.resolution !== "retry" ||
activeGate(run)?.id !== command.gateId || activeGate(run)?.id !== command.gateId ||
!run.parentSessionIds.includes(this.options.parentSessionId) || !run.parentSessionIds.includes(this.options.parentSessionId) ||
!run.lease || !this.ownershipIsRecoverable(run)
this.isProcessAlive(run.lease.processId)
) { ) {
return run; return run;
} }
const claimed = structuredClone(run); const claimed = structuredClone(run);
claimed.lease = { this.rotateOwnership(claimed);
return claimed;
}
private rotateOwnership(run: WorkflowRun) {
run.lease = {
processId: this.processId, processId: this.processId,
activeParentSessionId: this.options.parentSessionId, activeParentSessionId: this.options.parentSessionId,
heartbeatAt: this.now(), heartbeatAt: this.now(),
worktreeIdentity: claimed.workspace.root, worktreeIdentity: run.workspace.root,
}; };
return claimed; if (run.implementationContext) {
run.implementationContext.activeParentSessionId =
this.options.parentSessionId;
run.implementationContext.runRevision = run.revision + 1;
}
run.parentSessionIds = [
...new Set([...run.parentSessionIds, this.options.parentSessionId]),
];
} }
private assertActiveParent(run: WorkflowRun) { private assertActiveParent(run: WorkflowRun) {
@@ -4642,26 +4886,20 @@ export class WorkflowEngine {
lease.processId === this.processId lease.processId === this.processId
? ACTIVE_ASSIGNMENT_TOKENS.has(lease.token) ? ACTIVE_ASSIGNMENT_TOKENS.has(lease.token)
: this.isProcessAlive(lease.processId); : this.isProcessAlive(lease.processId);
const heartbeatIsFresh = if (ownerIsRunning) return true;
this.now() - lease.heartbeatAt <= this.leaseStaleMs;
if (ownerIsRunning && (heartbeatIsFresh || assignment.mutates))
return true;
} }
return false; return false;
} }
private leaseIsStale(run: WorkflowRun) { private ownershipIsRecoverable(run: WorkflowRun) {
if (!run.lease) return true;
if ( if (
run.lease?.processId === this.processId && run.lease.processId === this.processId &&
run.lease.activeParentSessionId === this.options.parentSessionId run.lease.activeParentSessionId === this.options.parentSessionId
) { ) {
return false; return false;
} }
return ( return !this.isProcessAlive(run.lease.processId);
!run.lease ||
this.now() - run.lease.heartbeatAt > this.leaseStaleMs ||
!this.isProcessAlive(run.lease.processId)
);
} }
private validateStart(command: StartWorkflowCommand) { private validateStart(command: StartWorkflowCommand) {
+3
View File
@@ -809,6 +809,9 @@ test("production executor drives Discover and Design through Plan Approval", asy
branch: `feature/${options.featureName}`, branch: `feature/${options.featureName}`,
baseHead: "abc123", baseHead: "abc123",
branchExists: false, branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
}; };
}, },
async create(plan): Promise<WorkflowRun["workspace"]> { async create(plan): Promise<WorkflowRun["workspace"]> {
+9 -1
View File
@@ -24,6 +24,10 @@ import {
import { runScriptSandbox } from "./script-runtime.ts"; import { runScriptSandbox } from "./script-runtime.ts";
import { WorkflowStore } from "./store.ts"; import { WorkflowStore } from "./store.ts";
import { registerPiWorkflowTool } from "./tool.ts"; import { registerPiWorkflowTool } from "./tool.ts";
import {
refreshWorkflowProgress,
registerWorkflowProgress,
} from "./progress.ts";
import { import {
ApprovedHostCommandExecutor, ApprovedHostCommandExecutor,
FilesystemPrototypeValidator, FilesystemPrototypeValidator,
@@ -111,7 +115,10 @@ export default function piWorkflowExtension(pi: ExtensionAPI) {
let runtime: ContextTransitionRuntime; let runtime: ContextTransitionRuntime;
runtime = createWorkflowRuntime({ runtime = createWorkflowRuntime({
afterRunPersist: async (ctx) => { afterRunPersist: async (ctx) => {
await reconcileWorkflowDeliveries(pi, runtime, ctx); await Promise.all([
reconcileWorkflowDeliveries(pi, runtime, ctx),
refreshWorkflowProgress(runtime, ctx),
]);
}, },
}); });
registerWorkflowContextTransitions(pi, runtime, (args, ctx) => registerWorkflowContextTransitions(pi, runtime, (args, ctx) =>
@@ -120,4 +127,5 @@ export default function piWorkflowExtension(pi: ExtensionAPI) {
registerPiWorkflowTool(pi, runtime); registerPiWorkflowTool(pi, runtime);
registerWorkflowRecovery(pi, runtime); registerWorkflowRecovery(pi, runtime);
registerWorkflowNotifications(pi, runtime); registerWorkflowNotifications(pi, runtime);
registerWorkflowProgress(pi, runtime);
} }
+142
View File
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { visibleWidth } from "@earendil-works/pi-tui";
import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts";
import {
projectWorkflowProgress,
refreshWorkflowProgress,
registerWorkflowProgress,
renderWorkflowProgress,
} from "./progress.ts";
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
return {
runId: "run-1",
revision: 3,
mode: "feature",
status: "waiting",
background: false,
request: "Build dashboard",
featureName: "dashboard",
parentSessionIds: ["session-1"],
lease: {
processId: 1,
activeParentSessionId: "session-1",
heartbeatAt: 10,
},
workspace: { state: "ready", sourceRoot: "/repo" },
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
pinnedProfiles: [],
preflightEligibility: [],
facts: {
stage: "design",
taskKind: "feature",
requestTerms: [],
candidatePaths: [],
languages: [],
tags: [],
riskTags: [],
},
stages: [
{ id: "d", kind: "discover", status: "completed" },
{ id: "p", kind: "design", status: "waiting" },
{ id: "pt", kind: "prototype", status: "pending" },
{ id: "i", kind: "implement", status: "pending" },
{ id: "v", kind: "verify", status: "pending" },
{ id: "r", kind: "review", status: "pending" },
],
gates: [
{
id: "gate",
kind: "plan-approval",
status: "open",
revision: 3,
openedAt: 12,
},
],
decisions: [],
assignments: [],
prototypeCandidates: [],
prototypeCleanupCompleted: false,
deliveries: [],
commandReceipts: [],
uiImpactEvidence: [],
affectedUiSurfaces: [],
mutatorProfileNames: [],
reviewCycles: 0,
verificationCycles: 0,
...overrides,
};
}
test("progress projects the newest attached active run", () => {
const older = run({ runId: "older", featureName: "older", lease: { processId: 1, activeParentSessionId: "session-1", heartbeatAt: 5 } });
const newest = run({ runId: "newest", featureName: "newest", lease: { processId: 1, activeParentSessionId: "session-1", heartbeatAt: 20 } });
const terminal = run({ runId: "terminal", status: "completed", lease: undefined });
const historical = run({ runId: "historical", parentSessionIds: ["session-2"] });
const progress = projectWorkflowProgress([older, terminal, newest, historical], "session-1");
assert.deepEqual(progress, {
runId: "newest",
label: "newest",
stage: "DESIGN",
completed: 1,
total: 6,
gate: "Plan Approval",
});
});
test("progress clears when no attached run remains active", () => {
assert.equal(
projectWorkflowProgress([run({ status: "failed", lease: undefined })], "session-1"),
undefined,
);
assert.equal(projectWorkflowProgress([run()], "session-2"), undefined);
});
test("progress lifecycle refreshes from persistence and clears on terminal state and shutdown", async () => {
let runs = [run()];
let widget: unknown;
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void>>();
const pi = {
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void>) {
handlers.set(event, handler);
},
} as unknown as ExtensionAPI;
const runtime = {
store: { listRuns: async () => runs },
} as never;
const ctx = {
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
setWidget(key: string, value: unknown) {
assert.equal(key, "pi-workflow-active-run");
widget = value;
},
},
} as unknown as ExtensionContext;
registerWorkflowProgress(pi, runtime);
await handlers.get("session_start")?.({}, ctx);
assert.equal(typeof widget, "function");
runs = [run({ status: "completed", lease: undefined })];
await refreshWorkflowProgress(runtime, ctx);
assert.equal(widget, undefined);
runs = [run()];
await refreshWorkflowProgress(runtime, ctx);
assert.equal(typeof widget, "function");
await handlers.get("session_shutdown")?.({}, ctx);
assert.equal(widget, undefined);
});
test("progress rendering is one cell-width-safe line", () => {
const progress = projectWorkflowProgress([run()], "session-1");
assert.ok(progress);
const wide = renderWorkflowProgress(progress, 80);
assert.match(wide, /workflow dashboard DESIGN 1\/6 Gate: Plan Approval/);
const narrow = renderWorkflowProgress(progress, 28);
assert.ok(visibleWidth(narrow) <= 28);
assert.equal(narrow.includes("\n"), false);
});
+124
View File
@@ -0,0 +1,124 @@
import type {
ExtensionAPI,
ExtensionContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import { truncateToWidth } from "@earendil-works/pi-tui";
import type { ContextTransitionRuntime } from "./context-transition.ts";
import type { WorkflowRun } from "./domain.ts";
import { deriveRunLabels } from "./run-label.ts";
const WIDGET_KEY = "pi-workflow-active-run";
const CORE_STAGES = new Set([
"discover",
"design",
"prototype",
"implement",
"verify",
"review",
]);
export interface WorkflowProgress {
runId: string;
label: string;
stage: string;
completed: number;
total: number;
gate?: string;
}
function activityTime(run: WorkflowRun) {
return Math.max(
run.lease?.heartbeatAt ?? 0,
...run.gates.map((gate) => gate.openedAt),
...run.stages.flatMap((stage) => [stage.startedAt ?? 0, stage.finishedAt ?? 0]),
);
}
function readableGate(kind: string) {
return kind
.split("-")
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
export function newestAttachedActiveRun(
runs: WorkflowRun[],
sessionId: string | undefined,
) {
if (!sessionId) return undefined;
return runs
.filter(
(run) =>
(run.status === "running" || run.status === "waiting") &&
run.parentSessionIds.includes(sessionId),
)
.sort((left, right) => activityTime(right) - activityTime(left))[0];
}
export function projectWorkflowProgress(
runs: WorkflowRun[],
sessionId: string | undefined,
): WorkflowProgress | undefined {
const run = newestAttachedActiveRun(runs, sessionId);
if (!run) return undefined;
const active = [...run.stages]
.reverse()
.find((stage) => stage.status === "running" || stage.status === "waiting");
const gate = [...run.gates].reverse().find((entry) => entry.status === "open");
const label = deriveRunLabels(runs)[runs.indexOf(run)] ?? run.runId.slice(0, 8);
const stages = run.stages.filter((stage) => CORE_STAGES.has(stage.kind));
return {
runId: run.runId,
label,
stage: String(active?.kind ?? run.status).toUpperCase(),
completed: stages.filter((stage) => stage.status === "completed").length,
total: stages.length,
...(gate ? { gate: readableGate(gate.kind) } : {}),
};
}
export function renderWorkflowProgress(
progress: WorkflowProgress,
width: number,
theme?: Pick<Theme, "fg">,
) {
const paint = (token: "accent" | "dim" | "muted" | "text", value: string) =>
theme ? theme.fg(token, value) : value;
const base = `${paint("accent", "workflow")} ${paint("text", progress.label)} ${paint("muted", progress.stage)} ${progress.completed}/${progress.total}`;
const full = progress.gate ? `${base} ${paint("dim", `Gate: ${progress.gate}`)}` : base;
return truncateToWidth(full, Math.max(1, width), "");
}
export async function refreshWorkflowProgress(
runtime: ContextTransitionRuntime,
ctx: ExtensionContext,
) {
if (ctx.mode !== "tui") return;
const progress = projectWorkflowProgress(
await runtime.store.listRuns(),
ctx.sessionManager.getSessionId(),
);
if (!progress) {
ctx.ui.setWidget(WIDGET_KEY, undefined);
return;
}
ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
render(width: number) {
return [renderWorkflowProgress(progress, width, theme)];
},
invalidate() {},
}));
}
export function registerWorkflowProgress(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
) {
pi.on("session_start", async (_event, ctx) => {
await refreshWorkflowProgress(runtime, ctx);
});
pi.on("session_shutdown", async (_event, ctx) => {
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
});
}
+393
View File
@@ -10,6 +10,7 @@ import {
registerPiWorkflowTool, registerPiWorkflowTool,
workflowCommandFromTool, workflowCommandFromTool,
} from "./tool.ts"; } from "./tool.ts";
import { WorkspaceValidationError } from "./workspace.ts";
function workflowView(overrides: Partial<WorkflowView> = {}): WorkflowView { function workflowView(overrides: Partial<WorkflowView> = {}): WorkflowView {
return { return {
@@ -79,6 +80,25 @@ test("tool commands derive active tools for feature and analysis scripts", () =>
}, },
["read", "ls"], ["read", "ls"],
); );
assert.deepEqual(
workflowCommandFromTool(
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-feature-key",
allowDirtySource: true,
},
["read"],
),
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-feature-key",
allowDirtySource: true,
},
);
assert.equal(analysis.action, "start-analysis"); assert.equal(analysis.action, "start-analysis");
assert.deepEqual( assert.deepEqual(
analysis.action === "start-analysis" ? analysis.activeToolNames : [], analysis.action === "start-analysis" ? analysis.activeToolNames : [],
@@ -189,6 +209,379 @@ test("compact tool details expose only valid continuation intents", () => {
); );
}); });
test("TUI dirty-source starts distinguish omitted, refused, and approved flags", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerPiWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "start" && !command.allowDirtySource) {
throw new WorkspaceValidationError(
"dirty-source",
"source changes are omitted",
{
sourceChanges: [{ status: "??", path: "local-notes.md" }],
sourceChangesOmitted: 2,
},
);
}
return workflowView();
},
} as never;
},
});
assert.ok(definition);
let confirmation = "";
await definition.execute(
"dirty-call",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key",
},
new AbortController().signal,
undefined,
{
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async (factory: (
tui: { requestRender(): void; terminal: { rows: number } },
theme: { fg(color: string, text: string): string; bg(color: string, text: string): string },
keybindings: object,
done: (value: boolean) => void,
) => { render(width: number): string[] }) => {
const component = factory(
{ requestRender() {}, terminal: { rows: 24 } },
{
fg: (_color, text) => text,
bg: (_color, text) => text,
},
{},
() => {},
);
confirmation = component.render(100).join("\n");
return true;
},
},
} as unknown as ExtensionContext,
);
assert.match(confirmation, /local-notes\.md/);
assert.match(confirmation, /2 additional paths omitted/);
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[false, true],
);
const explicitContext = {
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
confirmation = "unexpected prompt";
return false;
},
},
} as unknown as ExtensionContext;
confirmation = "";
await assert.rejects(
definition.execute(
"dirty-call-refused",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-refused",
allowDirtySource: false,
},
new AbortController().signal,
undefined,
explicitContext,
),
/source changes are omitted/,
);
assert.equal(confirmation, "");
await definition.execute(
"dirty-call-approved",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-approved",
allowDirtySource: true,
},
new AbortController().signal,
undefined,
explicitContext,
);
assert.equal(confirmation, "");
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[false, true, false, true],
);
});
test("RPC dirty-source starts never open TUI overlays and require the flag", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerPiWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "start" && !command.allowDirtySource) {
throw new WorkspaceValidationError("dirty-source", "explicit approval required");
}
return workflowView();
},
} as never;
},
});
assert.ok(definition);
const ctx = {
mode: "rpc",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
throw new Error("RPC must not open a custom overlay");
},
},
} as unknown as ExtensionContext;
await assert.rejects(
definition.execute(
"rpc-dirty-rejected",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-rejected",
},
new AbortController().signal,
undefined,
ctx,
),
/explicit approval required/,
);
await assert.rejects(
definition.execute(
"rpc-dirty-refused",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-refused",
allowDirtySource: false,
},
new AbortController().signal,
undefined,
ctx,
),
/explicit approval required/,
);
await definition.execute(
"rpc-dirty-approved",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-approved",
allowDirtySource: true,
},
new AbortController().signal,
undefined,
ctx,
);
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[undefined, false, true],
);
});
test("start tool cancellation waits for engine settlement before rejecting", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
let markStarted = () => {};
let releaseStart = () => {};
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const blocked = new Promise<void>((resolve) => {
releaseStart = resolve;
});
let cancelled = false;
let cancellationReason = new Error("cancelled before mutation");
let laterMutation = false;
let engineOperation: Promise<WorkflowView> | undefined;
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerPiWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async cancelStart(
_idempotencyKey: string,
_abortIdempotencyKey: string,
reason: Error,
) {
cancelled = true;
cancellationReason = reason;
await engineOperation?.catch(() => {});
},
execute() {
engineOperation = (async () => {
markStarted();
await blocked;
if (cancelled) throw cancellationReason;
laterMutation = true;
return workflowView();
})();
return engineOperation;
},
} as never;
},
});
assert.ok(definition);
const controller = new AbortController();
const operation = definition.execute(
"cancel-slow-start",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "cancel-slow-start-key",
},
controller.signal,
undefined,
{
mode: "rpc",
sessionManager: { getSessionId: () => "session-1" },
} as unknown as ExtensionContext,
);
await started;
let toolSettled = false;
void operation.then(
() => {
toolSettled = true;
},
() => {
toolSettled = true;
},
);
controller.abort(new Error("cancelled by tool caller"));
await Promise.resolve();
assert.equal(toolSettled, false);
releaseStart();
await assert.rejects(operation, /cancelled by tool caller/);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(laterMutation, false);
});
test("cancellation during dirty-source confirmation cannot start a retry", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
let worktreeCreations = 0;
let showConfirmation = () => {};
const confirmationShown = new Promise<void>((resolve) => {
showConfirmation = resolve;
});
let finishConfirmation = (_value: boolean) => {};
const confirmation = new Promise<boolean>((resolve) => {
finishConfirmation = resolve;
});
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerPiWorkflowTool(pi, {
store: {
async getStart() {
return { runId: "not-created" };
},
async loadRun() {
throw new Error("not created");
},
} as never,
async engineForContext() {
return {
async cancelStart() {},
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "start" && command.allowDirtySource === true) {
worktreeCreations++;
return workflowView();
}
throw new WorkspaceValidationError("dirty-source", "explicit approval required");
},
} as never;
},
});
assert.ok(definition);
const controller = new AbortController();
const operation = definition.execute(
"cancel-dirty-confirmation",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "cancel-dirty-key",
},
controller.signal,
undefined,
{
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
showConfirmation();
return confirmation;
},
},
} as unknown as ExtensionContext,
);
await confirmationShown;
controller.abort(new Error("cancelled while confirming"));
await assert.rejects(operation, /cancelled while confirming/);
finishConfirmation(true);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[false],
);
assert.equal(worktreeCreations, 0);
});
test("tool cancellation aborts its active Workflow Run before rejecting", async () => { test("tool cancellation aborts its active Workflow Run before rejecting", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined; let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = []; const commands: WorkflowCommand[] = [];
+77 -28
View File
@@ -12,6 +12,8 @@ import {
} from "./context-transition.ts"; } from "./context-transition.ts";
import type { WorkflowCommand, WorkflowView } from "./engine.ts"; import type { WorkflowCommand, WorkflowView } from "./engine.ts";
import { deriveRunLabel } from "./run-label.ts"; import { deriveRunLabel } from "./run-label.ts";
import { workflowConfirm } from "./tui.ts";
import { WorkspaceValidationError } from "./workspace.ts";
const ACTIONS = ["start", "inspect", "decide", "abort"] as const; const ACTIONS = ["start", "inspect", "decide", "abort"] as const;
const WORKFLOW_MODES = ["feature", "feature-script", "analysis-script"] as const; const WORKFLOW_MODES = ["feature", "feature-script", "analysis-script"] as const;
@@ -79,6 +81,7 @@ export const PiWorkflowParameters = Type.Object(
workflow: Type.Optional(WorkflowDefinition), workflow: Type.Optional(WorkflowDefinition),
concurrency: Type.Optional(Type.Integer({ minimum: 1 })), concurrency: Type.Optional(Type.Integer({ minimum: 1 })),
background: Type.Optional(Type.Boolean()), background: Type.Optional(Type.Boolean()),
allowDirtySource: Type.Optional(Type.Boolean()),
runId: Type.Optional(Type.String()), runId: Type.Optional(Type.String()),
gateId: Type.Optional(Type.String()), gateId: Type.Optional(Type.String()),
expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })), expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })),
@@ -149,6 +152,9 @@ export function workflowCommandFromTool(
...(input.background !== undefined ...(input.background !== undefined
? { background: input.background } ? { background: input.background }
: {}), : {}),
...(input.allowDirtySource !== undefined
? { allowDirtySource: input.allowDirtySource }
: {}),
...(definition.mode === "feature-script" ...(definition.mode === "feature-script"
? { ? {
script: { script: {
@@ -397,17 +403,7 @@ async function cancelToolWorkflow(
input: PiWorkflowInput, input: PiWorkflowInput,
toolCallId: string, toolCallId: string,
) { ) {
let runId = input.runId; const runId = input.runId;
if (!runId && input.action === "start" && input.idempotencyKey) {
for (let attempt = 0; attempt < 50; attempt++) {
const start = await runtime.store.getStart(input.idempotencyKey);
if (start) {
runId = start.runId;
break;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
if (!runId) return; if (!runId) return;
const current = await runtime.store.loadRun(runId).catch(() => undefined); const current = await runtime.store.loadRun(runId).catch(() => undefined);
if ( if (
@@ -426,6 +422,25 @@ async function cancelToolWorkflow(
}).catch(() => {}); }).catch(() => {});
} }
function dirtySourceWarning(error: WorkspaceValidationError) {
const visibleChanges = error.sourceChanges.slice(0, 6);
const changes = visibleChanges.map(
(change) => `${change.status} ${change.path}`,
);
const omitted =
error.sourceChanges.length - visibleChanges.length +
error.sourceChangesOmitted;
if (omitted > 0) {
changes.push(`[${omitted} additional paths omitted]`);
}
return [
"The workflow worktree will start at the repository's current HEAD.",
"These staged, unstaged, or untracked source-only changes will be omitted:",
...changes,
"Continue only if the accepted plan should exclude those changes.",
].join("\n");
}
export function registerPiWorkflowTool( export function registerPiWorkflowTool(
pi: ExtensionAPI, pi: ExtensionAPI,
runtime: ContextTransitionRuntime, runtime: ContextTransitionRuntime,
@@ -434,7 +449,7 @@ export function registerPiWorkflowTool(
name: "pi_workflow", name: "pi_workflow",
label: "Pi Workflow", label: "Pi Workflow",
description: description:
"Start, inspect, decide, or abort a persisted gated feature or read-only analysis Workflow Run. Mutating actions require idempotency keys and expected revisions. Gate Decisions must come from the user; never infer approval.", "Start, inspect, decide, or abort a persisted gated feature or read-only analysis Workflow Run. Noninteractive starts from a dirty source require allowDirtySource=true. Mutating actions require idempotency keys and expected revisions. Gate Decisions must come from the user; never infer approval.",
promptSnippet: promptSnippet:
"Plan and build features through persisted Gates, worktrees, verification, and independent review", "Plan and build features through persisted Gates, worktrees, verification, and independent review",
promptGuidelines: [ promptGuidelines: [
@@ -447,29 +462,63 @@ export function registerPiWorkflowTool(
const engine = await runtime.engineForContext(ctx); const engine = await runtime.engineForContext(ctx);
const command = workflowCommandFromTool(params, pi.getActiveTools()); const command = workflowCommandFromTool(params, pi.getActiveTools());
const executionSignal = signal ?? new AbortController().signal; const executionSignal = signal ?? new AbortController().signal;
const operation = const cancellationError = () =>
command.action === "decide" executionSignal.reason instanceof Error
? executeHostDecision(pi, engine, command) ? executionSignal.reason
: engine.execute(command); : new Error("pi_workflow tool call was cancelled");
const operation = (async () => {
if (executionSignal.aborted) throw cancellationError();
if (command.action === "decide") {
return executeHostDecision(pi, engine, command);
}
const initial =
command.action === "start" &&
ctx.mode === "tui" &&
command.allowDirtySource === undefined
? { ...command, allowDirtySource: false }
: command;
try {
return await engine.execute(initial);
} catch (error) {
if (
command.action !== "start" ||
ctx.mode !== "tui" ||
command.allowDirtySource !== undefined ||
!(error instanceof WorkspaceValidationError) ||
error.code !== "dirty-source"
) {
throw error;
}
const confirmed = await workflowConfirm(
ctx,
"Dirty source checkout",
dirtySourceWarning(error),
);
if (executionSignal.aborted) throw cancellationError();
if (!confirmed) {
throw new Error("Workflow start cancelled because source changes would be omitted");
}
return engine.execute({ ...command, allowDirtySource: true });
}
})();
let rejectCancellation = (_error: unknown) => {}; let rejectCancellation = (_error: unknown) => {};
const cancellation = new Promise<never>((_resolve, reject) => { const cancellation = new Promise<never>((_resolve, reject) => {
rejectCancellation = reject; rejectCancellation = reject;
}); });
let cancellationWork: Promise<void> | undefined; let cancellationWork: Promise<void> | undefined;
const cancellationError = () =>
executionSignal.reason instanceof Error
? executionSignal.reason
: new Error("pi_workflow tool call was cancelled");
const onAbort = () => { const onAbort = () => {
cancellationWork = cancelToolWorkflow( cancellationWork =
runtime, command.action === "start" || command.action === "start-analysis"
engine, ? engine.cancelStart(
params, command.idempotencyKey,
toolCallId, `tool-cancel-${toolCallId}`,
cancellationError(),
)
: cancelToolWorkflow(runtime, engine, params, toolCallId);
void cancellationWork.then(
() => rejectCancellation(cancellationError()),
() => rejectCancellation(cancellationError()),
); );
void cancellationWork.finally(() => {
rejectCancellation(cancellationError());
});
}; };
executionSignal.addEventListener("abort", onAbort, { once: true }); executionSignal.addEventListener("abort", onAbort, { once: true });
if (executionSignal.aborted) onAbort(); if (executionSignal.aborted) onAbort();
+304
View File
@@ -0,0 +1,304 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { CURSOR_MARKER, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from "@earendil-works/pi-tui";
import {
createPlanReaderModel,
createPlanReaderState,
reducePlanReaderState,
renderPlanReader,
sanitizeTerminalText,
workflowEditor,
workflowInput,
WorkflowSelectionOverlay,
} from "./tui.ts";
const theme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const plan = [
"# Workflow plan",
"intro",
"## First section",
"first body",
"### Detail",
"detail body",
"## Last section",
"last body",
].join("\n");
test("selection overlay windows long lists, stays width-safe, and returns stable values", () => {
let selected: string | undefined;
const overlay = new WorkflowSelectionOverlay(
"Workflow Runs",
Array.from({ length: 30 }, (_, index) => ({
value: `run-${index}`,
label: `feature-${index}`,
description: index === 0 ? "waiting" : undefined,
})),
theme as never,
(value) => { selected = value; },
);
const initial = overlay.render(48);
assert.ok(initial.some((line) => line.includes("1-12 of 30")));
assert.ok(initial.every((line) => visibleWidth(line) <= 48));
overlay.handleInput("\x1b[F");
const last = overlay.render(48);
assert.ok(last.some((line) => line.includes("19-30 of 30")));
overlay.handleInput("\r");
assert.equal(selected, "run-29");
});
test("selection overlay budgets wrapped descriptions for short narrow terminals", () => {
let selected: string | undefined;
const overlay = new WorkflowSelectionOverlay(
"Short terminal",
Array.from({ length: 20 }, (_, index) => ({
value: `choice-${index}`,
label: `choice-${index}`,
description:
index === 19
? "A long selected description that wraps repeatedly without hiding navigation controls"
: undefined,
})),
theme as never,
(value) => { selected = value; },
8,
);
overlay.handleInput("\x1b[F");
const lines = overlay.render(28);
assert.ok(lines.length <= 8);
assert.ok(lines.every((line) => visibleWidth(line) <= 28));
assert.ok(lines.some((line) => line.includes("> choice-19")));
assert.ok(lines.some((line) => line.includes("enter select")));
overlay.handleInput("\x1b[A");
assert.ok(overlay.render(28).some((line) => line.includes("> choice-18")));
overlay.handleInput("\r");
assert.equal(selected, "choice-18");
});
type InteractiveOverlay = Component & Partial<Focusable>;
type OverlayFactory = (
tui: TUI,
overlayTheme: Theme,
keybindings: object,
done: (value: unknown) => void,
) => InteractiveOverlay;
function interactionContext(
interact: (component: InteractiveOverlay) => void,
rows = 24,
) {
const tui = {
requestRender() {},
terminal: { columns: 80, rows },
} as unknown as TUI;
return {
mode: "tui",
ui: {
async custom(factory: OverlayFactory) {
let completed: unknown;
const component = factory(
tui,
theme as unknown as Theme,
{},
(value) => { completed = value; },
);
interact(component);
return completed;
},
},
} as unknown as ExtensionContext;
}
test("input overlay propagates focus, renders a cursor marker, and handles input", async () => {
let unfocused = "";
let focused = "";
const result = await workflowInput(
interactionContext((component) => {
unfocused = component.render(48).join("\n");
component.focused = true;
component.handleInput?.("approved");
focused = component.render(48).join("\n");
component.handleInput?.("\r");
}),
"Answer",
"Provide a value",
);
assert.equal(unfocused.includes(CURSOR_MARKER), false);
assert.equal(focused.includes(CURSOR_MARKER), true);
assert.match(focused, /approved/);
assert.equal(result, "approved");
});
test("input overlay keeps focused input and controls visible below a long prompt", async () => {
const prompt = Array.from(
{ length: 20 },
(_, index) => `long prompt segment ${index}`,
).join(" ");
let unfocused = "";
let focused = "";
const result = await workflowInput(
interactionContext((component) => {
unfocused = component.render(40).join("\n");
component.focused = true;
component.handleInput?.("yes");
focused = component.render(40).join("\n");
component.handleInput?.("\r");
}, 8),
"Short terminal",
prompt,
);
assert.ok(unfocused.split("\n").length <= 5);
assert.equal(unfocused.includes(CURSOR_MARKER), false);
assert.ok(focused.split("\n").length <= 5);
assert.ok(focused.split("\n").every((line) => visibleWidth(line) <= 40));
assert.equal(focused.includes(CURSOR_MARKER), true);
assert.match(focused, /yes/);
assert.match(focused, /enter submit esc back/);
assert.match(focused, /…/);
assert.equal(result, "yes");
});
test("editor overlay propagates focus and Escape resolves through the wrapper", async () => {
let focused = "";
const result = await workflowEditor(
interactionContext((component) => {
component.focused = true;
component.handleInput?.("x");
focused = component.render(56).join("\n");
component.handleInput?.("\x1b");
}),
"Feedback",
"Revise the plan",
"draft",
);
assert.equal(focused.includes(CURSOR_MARKER), true);
assert.match(focused, /draft/);
assert.equal(result, undefined);
});
test("plan reader parses H1-H3 and tracks bounded navigation state", () => {
const model = createPlanReaderModel("example", plan);
assert.deepEqual(
model.headings.map(({ level, text, line }) => ({ level, text, line })),
[
{ level: 1, text: "Workflow plan", line: 0 },
{ level: 2, text: "First section", line: 2 },
{ level: 3, text: "Detail", line: 4 },
{ level: 2, text: "Last section", line: 6 },
],
);
let state = createPlanReaderState();
state = reducePlanReaderState(model, state, "up", { width: 60, height: 24 });
assert.equal(state.topRow, 0);
state = reducePlanReaderState(model, state, "page-down", { width: 60, height: 12 });
assert.equal(state.topRow, 7);
state = reducePlanReaderState(model, state, "home", { width: 60, height: 12 });
assert.equal(state.topRow, 0);
state = reducePlanReaderState(model, state, "end", { width: 60, height: 24 });
assert.equal(state.topRow, 7);
state = reducePlanReaderState(model, state, "down", { width: 60, height: 24 });
assert.equal(state.topRow, 7);
});
test("plan reader renders outline at 80 columns and one pane below 80", () => {
const model = createPlanReaderModel("example", plan);
const wide = renderPlanReader(model, { topRow: 4 }, { width: 80, height: 24 });
assert.ok(wide.some((line) => line.includes("> Detail")));
assert.ok(wide.some((line) => line.includes("detail body")));
assert.ok(wide.every((line) => visibleWidth(line) <= 80));
assert.ok(wide.length <= 24);
const narrow = renderPlanReader(model, { topRow: 4 }, { width: 60, height: 12 });
assert.match(narrow[0] ?? "", /Detail/);
assert.equal(narrow.some((line) => line.includes("> Detail")), false);
assert.ok(narrow.every((line) => visibleWidth(line) <= 60));
assert.ok(narrow.length <= 12);
});
test("wide outline windows enough headings to keep the current section visible", () => {
const contents = Array.from(
{ length: 40 },
(_, index) => `## Section ${index}\nbody ${index}`,
).join("\n");
const model = createPlanReaderModel("many sections", contents);
const lines = renderPlanReader(
model,
{ topRow: 70 },
{ width: 80, height: 12 },
);
assert.ok(lines.some((line) => />\s+Section 35/.test(line)));
assert.equal(lines.some((line) => line.includes("Section 0")), false);
});
test("plan reader reports truthful minimum size below either boundary", () => {
const model = createPlanReaderModel("example", plan);
for (const dimensions of [
{ width: 47, height: 11 },
{ width: 47, height: 24 },
{ width: 80, height: 11 },
]) {
const lines = renderPlanReader(model, createPlanReaderState(), dimensions);
assert.ok(lines.some((line) => line.includes("needs 48x12")));
assert.ok(
lines.some((line) =>
line.includes(`current terminal is ${dimensions.width}x${dimensions.height}`),
),
);
assert.ok(lines.every((line) => visibleWidth(line) <= dimensions.width));
assert.ok(lines.length <= dimensions.height);
}
});
test("reader sanitizes terminal control sequences before rendering", () => {
const unsafe = "# Safe\n\x1b[31mred\x1b[0m\n\x1b]8;;https://bad.invalid\x07link\x1b]8;;\x07\nend\u0000";
const sanitized = sanitizeTerminalText(unsafe);
assert.equal(sanitized.includes("\x1b"), false);
assert.equal(sanitized.includes("\u0000"), false);
const model = createPlanReaderModel("unsafe", unsafe);
const lines = renderPlanReader(model, createPlanReaderState(), { width: 48, height: 12 });
assert.equal(lines.join("\n").includes("\x1b"), false);
assert.ok(lines.every((line) => visibleWidth(line) <= 48));
});
test("plan reader reaches every wrapped display row before following content", () => {
const longLine = Array.from(
{ length: 40 },
(_, index) => `chunk-${String(index).padStart(2, "0")}`,
).join(" ");
const model = createPlanReaderModel(
"wrapped rows",
`# Wrapped section\n${longLine}\nfollowing-content`,
);
const expectedTopRows = [
"# Wrapped section",
...wrapTextWithAnsi(longLine, 46),
"following-content",
];
let state = createPlanReaderState();
for (const expected of expectedTopRows) {
const frame = renderPlanReader(model, state, { width: 48, height: 12 });
assert.equal(frame[2]?.trim(), expected);
assert.match(frame[0] ?? "", /Wrapped sect/);
state = reducePlanReaderState(
model,
state,
"down",
{ width: 48, height: 12 },
);
}
assert.ok(expectedTopRows.length > 5);
});
test("reader only includes the active viewport near document end", () => {
const contents = Array.from({ length: 200 }, (_, index) => `line-${index}`).join("\n");
const model = createPlanReaderModel("large", contents);
const lines = renderPlanReader(model, { topRow: 190 }, { width: 60, height: 12 });
assert.ok(lines.some((line) => line.includes("line-190")));
assert.equal(lines.some((line) => line.includes("line-20")), false);
assert.ok(lines.length <= 12);
});
+702
View File
@@ -0,0 +1,702 @@
import type {
ExtensionContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import {
Editor,
type EditorTheme,
Input,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
type Component,
type Focusable,
type TUI,
} from "@earendil-works/pi-tui";
export interface OverlayChoice<T> {
value: T;
label: string;
description?: string;
}
function sanitizeLine(value: string) {
return value
.replace(
/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)?|[PX^_][\s\S]*?\x1b\\|.)/g,
"",
)
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
.replaceAll("\t", " ");
}
export function sanitizeTerminalText(value: string) {
return value.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n").map(sanitizeLine).join("\n");
}
function fit(value: string, width: number) {
const fitted = truncateToWidth(value, Math.max(1, width), "");
return value.includes("\x1b") ? fitted : sanitizeLine(fitted);
}
function pad(value: string, width: number) {
const bounded = fit(value, width);
return `${bounded}${" ".repeat(Math.max(0, width - visibleWidth(bounded)))}`;
}
function card(
theme: Theme,
title: string,
contents: string[],
width: number,
) {
const innerWidth = Math.max(1, width - 2);
const safeTitle = fit(` ${sanitizeLine(title)} `, innerWidth);
const titleWidth = visibleWidth(safeTitle);
const trailing = Math.max(0, innerWidth - titleWidth);
return [
`${theme.fg("border", "╭")}${theme.fg("accent", safeTitle)}${theme.fg("border", `${"─".repeat(trailing)}╮`)}`,
...contents.map(
(line) => `${theme.fg("border", "│")}${pad(line, innerWidth)}${theme.fg("border", "│")}`,
),
theme.fg("border", `╰${"─".repeat(innerWidth)}╯`),
].map((line) => fit(line, width));
}
export class WorkflowSelectionOverlay<T> implements Component {
private selected = 0;
private readonly title: string;
private readonly choices: OverlayChoice<T>[];
private readonly theme: Theme;
private readonly done: (value: T | undefined) => void;
private readonly maxRows: number;
constructor(
title: string,
choices: OverlayChoice<T>[],
theme: Theme,
done: (value: T | undefined) => void,
maxRows = 24,
) {
this.title = title;
this.choices = choices;
this.theme = theme;
this.done = done;
this.maxRows = Math.max(4, Math.floor(maxRows));
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) {
this.done(undefined);
} else if (matchesKey(data, Key.up)) {
this.selected = Math.max(0, this.selected - 1);
} else if (matchesKey(data, Key.down)) {
this.selected = Math.min(this.choices.length - 1, this.selected + 1);
} else if (matchesKey(data, Key.home)) {
this.selected = 0;
} else if (matchesKey(data, Key.end)) {
this.selected = Math.max(0, this.choices.length - 1);
} else if (matchesKey(data, Key.enter)) {
const choice = this.choices[this.selected];
if (choice) this.done(choice.value);
}
}
render(width: number) {
const bodyRows = Math.max(2, this.maxRows - 2);
const showLeadingSpace = bodyRows >= 5;
const showPagination = this.choices.length > 1 && bodyRows >= 3;
const selectedChoice = this.choices[this.selected];
const descriptionWidth = Math.max(1, width - 8);
const selectedDescription = selectedChoice?.description
? sanitizeTerminalText(selectedChoice.description)
.split("\n")
.flatMap((line) => {
const wrapped = wrapTextWithAnsi(line, descriptionWidth);
return wrapped.length > 0 ? wrapped : [""];
})
: [];
const fixedRows = 1 + Number(showLeadingSpace) + Number(showPagination);
const choiceRows = Math.max(1, bodyRows - fixedRows);
const descriptionRows = Math.min(
selectedDescription.length,
Math.max(0, Math.floor((choiceRows - 1) / 2)),
);
const maxVisible = Math.max(
1,
Math.min(12, this.choices.length || 1, choiceRows - descriptionRows),
);
const start = Math.max(
0,
Math.min(
this.selected - Math.floor(maxVisible / 2),
Math.max(0, this.choices.length - maxVisible),
),
);
const body: string[] = showLeadingSpace ? [""] : [];
for (const [offset, choice] of this.choices.slice(start, start + maxVisible).entries()) {
const index = start + offset;
const selected = index === this.selected;
const text = `${selected ? "> " : " "}${sanitizeLine(choice.label)}`;
body.push(
selected
? this.theme.bg("selectedBg", this.theme.fg("text", text))
: this.theme.fg("text", text),
);
if (selected) {
for (const line of selectedDescription.slice(0, descriptionRows)) {
body.push(this.theme.fg("muted", ` ${line}`));
}
}
}
if (this.choices.length === 0) {
body.push(this.theme.fg("muted", " No choices available"));
}
if (showPagination && this.choices.length > maxVisible) {
body.push(
this.theme.fg(
"dim",
` ${start + 1}-${Math.min(this.choices.length, start + maxVisible)} of ${this.choices.length}`,
),
);
}
body.push(this.theme.fg("dim", " ↑↓ navigate enter select esc back"));
return card(this.theme, this.title, body, width).slice(0, this.maxRows);
}
invalidate() {}
}
abstract class FocusedTextOverlay implements Component, Focusable {
private _focused = false;
abstract readonly child: Input | Editor;
get focused() {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.child.focused = value;
}
abstract render(width: number): string[];
abstract handleInput(data: string): void;
abstract invalidate(): void;
}
class WorkflowInputOverlay extends FocusedTextOverlay {
readonly child = new Input();
private readonly title: string;
private readonly prompt: string;
private readonly theme: Theme;
private readonly maxRows: number;
constructor(
title: string,
prompt: string,
theme: Theme,
done: (value: string | undefined) => void,
maxRows: number,
) {
super();
this.title = title;
this.prompt = prompt;
this.theme = theme;
this.maxRows = Math.max(4, Math.floor(maxRows));
this.child.onSubmit = (value) => done(value);
this.child.onEscape = () => done(undefined);
}
handleInput(data: string) {
this.child.handleInput(data);
}
render(width: number) {
const innerWidth = Math.max(1, width - 4);
const wrappedPrompt = sanitizeTerminalText(this.prompt)
.split("\n")
.flatMap((line) => {
const wrapped = wrapTextWithAnsi(line, innerWidth);
return wrapped.length > 0 ? wrapped : [""];
});
const bodyRows = this.maxRows - 2;
const showLeadingSpace = bodyRows >= 5;
const promptRows = Math.max(0, bodyRows - 2 - Number(showLeadingSpace));
const prompt = wrappedPrompt.slice(0, promptRows);
if (wrappedPrompt.length > promptRows && prompt.length > 0) {
const last = prompt.length - 1;
prompt[last] = fit(
`${truncateToWidth(prompt[last] ?? "", Math.max(1, innerWidth - 1), "")}…`,
innerWidth,
);
}
const body = [
...(showLeadingSpace ? [""] : []),
...prompt.map((line) => ` ${line}`),
` ${this.child.render(innerWidth)[0] ?? ""}`,
this.theme.fg("dim", " enter submit esc back"),
];
return card(this.theme, this.title, body, width).slice(0, this.maxRows);
}
invalidate() {
this.child.invalidate();
}
}
class WorkflowEditorOverlay extends FocusedTextOverlay {
readonly child: Editor;
private readonly title: string;
private readonly prompt: string;
private readonly theme: Theme;
constructor(
tui: TUI,
title: string,
prompt: string,
initial: string,
theme: Theme,
done: (value: string | undefined) => void,
) {
super();
this.title = title;
this.prompt = prompt;
this.theme = theme;
const editorTheme: EditorTheme = {
borderColor: (value) => theme.fg("borderAccent", value),
selectList: {
selectedPrefix: (value) => theme.fg("accent", value),
selectedText: (value) => theme.fg("accent", value),
description: (value) => theme.fg("muted", value),
scrollInfo: (value) => theme.fg("dim", value),
noMatch: (value) => theme.fg("warning", value),
},
};
this.child = new Editor(tui, editorTheme, { paddingX: 0 });
this.child.setText(initial);
this.child.onSubmit = (value) => done(value);
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) return;
this.child.handleInput(data);
}
render(width: number) {
const innerWidth = Math.max(1, width - 4);
const prompt = wrapTextWithAnsi(sanitizeLine(this.prompt), innerWidth);
return card(
this.theme,
this.title,
["", ...prompt.map((line) => ` ${line}`), "", ...this.child.render(innerWidth).map((line) => ` ${line}`), "", this.theme.fg("dim", " enter submit shift+enter newline esc back")],
width,
);
}
invalidate() {
this.child.invalidate();
}
}
function assertTui(ctx: ExtensionContext) {
if (ctx.mode !== "tui") {
throw new Error("pi-workflow overlays require TUI mode");
}
}
export async function workflowSelect<T>(
ctx: ExtensionContext,
title: string,
choices: OverlayChoice<T>[],
) {
assertTui(ctx);
return ctx.ui.custom<T | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowSelectionOverlay(
title,
choices,
theme,
done,
Math.floor(tui.terminal.rows * 0.8),
);
return {
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: {
anchor: "center",
width: "70%",
minWidth: 36,
maxHeight: "80%",
margin: 1,
},
},
);
}
export function workflowConfirm(
ctx: ExtensionContext,
title: string,
message: string,
) {
return workflowSelect(ctx, title, [
{ value: true, label: "Confirm", description: message },
{ value: false, label: "Cancel" },
]).then((value) => value === true);
}
export async function workflowInput(
ctx: ExtensionContext,
title: string,
prompt: string,
) {
assertTui(ctx);
return ctx.ui.custom<string | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowInputOverlay(
title,
prompt,
theme,
done,
Math.max(4, Math.floor(tui.terminal.rows * 0.7)),
);
return {
get focused() {
return overlay.focused;
},
set focused(value: boolean) {
overlay.focused = value;
},
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: { anchor: "center", width: "70%", minWidth: 36, maxHeight: "70%", margin: 1 },
},
);
}
export async function workflowEditor(
ctx: ExtensionContext,
title: string,
prompt: string,
initial = "",
) {
assertTui(ctx);
return ctx.ui.custom<string | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowEditorOverlay(tui, title, prompt, initial, theme, done);
return {
get focused() {
return overlay.focused;
},
set focused(value: boolean) {
overlay.focused = value;
},
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
if (matchesKey(data, Key.escape)) done(undefined);
else overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: { anchor: "center", width: "80%", minWidth: 40, maxHeight: "80%", margin: 1 },
},
);
}
export interface PlanHeading {
level: 1 | 2 | 3;
text: string;
line: number;
}
export interface PlanReaderModel {
title: string;
lines: string[];
headings: PlanHeading[];
}
export interface PlanReaderState {
topRow: number;
}
export type PlanReaderAction =
| "up"
| "down"
| "page-up"
| "page-down"
| "home"
| "end";
interface PlanDisplayRow {
text: string;
sourceLine: number;
}
export function createPlanReaderModel(title: string, contents: string): PlanReaderModel {
const lines = sanitizeTerminalText(contents).split("\n");
const headings = lines.flatMap((line, index): PlanHeading[] => {
const match = /^(#{1,3})\s+(.+?)\s*$/.exec(line);
return match
? [{ level: match[1]!.length as 1 | 2 | 3, text: match[2]!, line: index }]
: [];
});
return { title: sanitizeLine(title), lines, headings };
}
export function createPlanReaderState(): PlanReaderState {
return { topRow: 0 };
}
function readerBodyHeight(height: number) {
return Math.max(1, height - 4);
}
function readerOutlineWidth(width: number) {
return Math.max(24, Math.min(32, Math.floor(width * 0.34)));
}
function readerDocumentWidth(width: number) {
return width < 80
? Math.max(1, width - 2)
: Math.max(1, width - readerOutlineWidth(width) - 3);
}
function displayRows(model: PlanReaderModel, width: number) {
return model.lines.flatMap((line, sourceLine): PlanDisplayRow[] => {
const wrapped = wrapTextWithAnsi(line, Math.max(1, width));
return (wrapped.length > 0 ? wrapped : [""]).map((text) => ({
text,
sourceLine,
}));
});
}
export function reducePlanReaderState(
model: PlanReaderModel,
state: PlanReaderState,
action: PlanReaderAction,
dimensions: { width: number; height: number },
): PlanReaderState {
const rows = displayRows(model, readerDocumentWidth(dimensions.width));
const last = Math.max(0, rows.length - 1);
const page = Math.max(1, readerBodyHeight(dimensions.height) - 1);
const delta =
action === "up"
? -1
: action === "down"
? 1
: action === "page-up"
? -page
: action === "page-down"
? page
: 0;
const topRow =
action === "home"
? 0
: action === "end"
? last
: Math.max(0, Math.min(last, state.topRow + delta));
return { topRow };
}
function currentHeading(model: PlanReaderModel, sourceLine: number) {
return [...model.headings].reverse().find((heading) => heading.line <= sourceLine);
}
function viewportRows(
rows: PlanDisplayRow[],
topRow: number,
height: number,
) {
return rows.slice(topRow, topRow + readerBodyHeight(height));
}
export function renderPlanReader(
model: PlanReaderModel,
state: PlanReaderState,
dimensions: { width: number; height: number },
theme?: Theme,
) {
const { width, height } = dimensions;
if (width < 48 || height < 12) {
const lines = [
"",
"terminal too small",
"",
"pi-workflow plan needs 48x12",
`current terminal is ${width}x${height}`,
"",
"resize the terminal or press esc",
];
const leftPad = Math.max(0, Math.floor((width - Math.max(...lines.map(visibleWidth))) / 2));
return lines.map((line) => fit(`${" ".repeat(leftPad)}${line}`, width)).slice(0, height);
}
const documentWidth = readerDocumentWidth(width);
const rows = displayRows(model, documentWidth);
const topRow = Math.max(0, Math.min(rows.length - 1, state.topRow));
const sourceLine = rows[topRow]?.sourceLine ?? 0;
const heading = currentHeading(model, sourceLine);
const position = `row ${Math.min(rows.length, topRow + 1)}/${rows.length}`;
const headingLabel = width < 80 && heading ? ` · ${heading.text}` : "";
const header = fit(` pi-workflow plan · ${model.title}${headingLabel} · ${position}`, width);
const footer = fit(" ↑↓ scroll pgup/pgdn page home/end document esc back", width);
const bodyHeight = readerBodyHeight(height);
const paint = (token: "accent" | "dim" | "mdHeading" | "muted" | "text", value: string) =>
theme ? theme.fg(token, value) : value;
const viewport = viewportRows(rows, topRow, height);
if (width < 80) {
const body = viewport.map((row) => {
const token = /^#{1,3}\s/.test(row.text) ? "mdHeading" as const : "text" as const;
return fit(` ${paint(token, row.text)}`, width);
});
while (body.length < bodyHeight) body.push("");
return [paint("accent", header), "", ...body.slice(0, bodyHeight), paint("dim", footer)].slice(0, height);
}
const outlineWidth = readerOutlineWidth(width);
const gap = 3;
const body = viewport.map((row) => row.text);
const selectedHeading = heading?.line;
const selectedHeadingIndex = Math.max(
0,
model.headings.findIndex((entry) => entry.line === selectedHeading),
);
const outlineStart = Math.max(
0,
Math.min(
selectedHeadingIndex - Math.floor(bodyHeight / 2),
Math.max(0, model.headings.length - bodyHeight),
),
);
const outline = model.headings
.slice(outlineStart, outlineStart + bodyHeight)
.map((entry) => {
const prefix = entry.line === selectedHeading ? "> " : " ";
const indent = " ".repeat(entry.level - 1);
const text = `${prefix}${indent}${entry.text}`;
return entry.line === selectedHeading
? paint("accent", text)
: paint(entry.level === 1 ? "text" : "muted", text);
});
const renderedRows = Array.from({ length: bodyHeight }, (_, index) => {
const left = pad(outline[index] ?? "", outlineWidth);
const right = fit(body[index] ?? "", documentWidth);
return fit(`${left}${" ".repeat(gap)}${right}`, width);
});
return [paint("accent", header), "", ...renderedRows, paint("dim", footer)].slice(0, height);
}
class PlanReaderComponent implements Component {
private state = createPlanReaderState();
private width: number;
private readonly tui: TUI;
private readonly model: PlanReaderModel;
private readonly theme: Theme;
private readonly done: () => void;
constructor(
tui: TUI,
model: PlanReaderModel,
theme: Theme,
done: () => void,
) {
this.tui = tui;
this.width = tui.terminal.columns;
this.model = model;
this.theme = theme;
this.done = done;
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) {
this.done();
return;
}
const action = matchesKey(data, Key.up)
? "up"
: matchesKey(data, Key.down)
? "down"
: matchesKey(data, Key.pageUp)
? "page-up"
: matchesKey(data, Key.pageDown)
? "page-down"
: matchesKey(data, Key.home)
? "home"
: matchesKey(data, Key.end)
? "end"
: undefined;
if (action) {
this.state = reducePlanReaderState(
this.model,
this.state,
action,
{ width: this.width, height: this.tui.terminal.rows },
);
}
}
render(width: number) {
this.width = width;
return renderPlanReader(
this.model,
this.state,
{ width, height: this.tui.terminal.rows },
this.theme,
);
}
invalidate() {}
}
export async function workflowPlanReader(
ctx: ExtensionContext,
title: string,
contents: string,
) {
assertTui(ctx);
const model = createPlanReaderModel(title, contents);
await ctx.ui.custom<void>(
(tui, theme, _keybindings, done) => {
const reader = new PlanReaderComponent(tui, model, theme, done);
return {
render: (width) => reader.render(width),
invalidate: () => reader.invalidate(),
handleInput: (data) => {
reader.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: {
anchor: "top-left",
width: "100%",
maxHeight: "100%",
margin: 0,
},
},
);
}
+126 -9
View File
@@ -20,11 +20,13 @@ import {
FilesystemPrototypeValidator, FilesystemPrototypeValidator,
GitStateProbe, GitStateProbe,
GitWorkspaceManager, GitWorkspaceManager,
INTERNAL_GIT_TIMEOUT_MS,
MutationScope, MutationScope,
WorkspaceSafetyError, WorkspaceSafetyError,
WorkspaceValidationError, WorkspaceValidationError,
type CommandResult, type CommandResult,
type CommandRunner, type CommandRunner,
type CommandRunOptions,
} from "./workspace.ts"; } from "./workspace.ts";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -80,16 +82,25 @@ async function withTempDir(run: (directory: string) => Promise<void>) {
} }
class FakeRunner implements CommandRunner { class FakeRunner implements CommandRunner {
readonly calls: Array<{ cwd: string; executable: string; args: string[] }> = readonly calls: Array<{
[]; cwd: string;
executable: string;
args: string[];
options?: CommandRunOptions;
}> = [];
private readonly respond: (args: string[]) => CommandResult; private readonly respond: (args: string[]) => CommandResult;
constructor(respond: (args: string[]) => CommandResult) { constructor(respond: (args: string[]) => CommandResult) {
this.respond = respond; this.respond = respond;
} }
async run(cwd: string, executable: string, args: string[]) { async run(
this.calls.push({ cwd, executable, args: [...args] }); cwd: string,
executable: string,
args: string[],
options?: CommandRunOptions,
) {
this.calls.push({ cwd, executable, args: [...args], options });
return this.respond(args); return this.respond(args);
} }
} }
@@ -100,14 +111,23 @@ function result(code = 0, stdout = "", stderr = ""): CommandResult {
function prepareResponses( function prepareResponses(
repository: string, repository: string,
options: { dirty?: boolean; ignored?: boolean; branchExists?: boolean } = {}, options: {
dirty?: boolean;
unmerged?: boolean;
ignored?: boolean;
branchExists?: boolean;
} = {},
) { ) {
return (args: string[]) => { return (args: string[]) => {
const command = args.join(" "); const command = args.join(" ");
if (command === "rev-parse --show-toplevel") if (command === "rev-parse --show-toplevel")
return result(0, `${repository}\n`); return result(0, `${repository}\n`);
if (command === "status --porcelain --untracked-files=normal") { if (command === "status --porcelain=v1 -z --untracked-files=all") {
return result(0, options.dirty ? " M src/file.ts\n" : ""); if (options.unmerged) return result(0, "UU src/conflict.ts\0");
return result(
0,
options.dirty ? " M src/file.ts\0?? src/new-file.ts\0" : "",
);
} }
if (command.startsWith("check-ignore ")) { if (command.startsWith("check-ignore ")) {
return result(options.ignored === false ? 1 : 0); return result(options.ignored === false ? 1 : 0);
@@ -481,6 +501,59 @@ test("Git probe and approved host commands enforce identity and ownership", asyn
}); });
}); });
test("command runner reports bounded termination failure as exit 125", async () => {
await withTempDir(async (directory) => {
const runner = new ExecFileCommandRunner({
async terminateProcessTree(processId) {
if (process.platform !== "win32") {
process.kill(-processId, "SIGKILL");
} else {
process.kill(processId, "SIGKILL");
}
throw new Error("injected termination failure");
},
});
const started = Date.now();
const result = await runner.run(
directory,
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ timeoutMs: 20 },
);
assert.equal(result.code, 125);
assert.match(result.stderr, /injected termination failure/);
assert.ok(Date.now() - started < 2_000);
});
});
test("command runner force-kills a child when the termination helper never settles", async () => {
await withTempDir(async (directory) => {
let childProcessId: number | undefined;
const runner = new ExecFileCommandRunner({
terminateProcessTree(processId) {
childProcessId = processId;
return new Promise<void>(() => {});
},
});
const started = Date.now();
const result = await runner.run(
directory,
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ timeoutMs: 20 },
);
assert.equal(result.code, 125);
assert.match(result.stderr, /termination did not finish within its deadline/);
assert.ok(Date.now() - started < 2_000);
const terminatedProcessId = childProcessId;
assert.ok(terminatedProcessId);
assert.throws(
() => process.kill(terminatedProcessId, 0),
(error: unknown) => (error as NodeJS.ErrnoException).code === "ESRCH",
);
});
});
test("Git change detection excludes unchanged pre-existing dirty paths", async () => { test("Git change detection excludes unchanged pre-existing dirty paths", async () => {
await withTempDir(async (directory) => { await withTempDir(async (directory) => {
await execFileAsync("git", ["init", "-q", directory]); await execFileAsync("git", ["init", "-q", directory]);
@@ -557,6 +630,11 @@ test("workspace preparation validates Git state and builds the exact feature bra
"abc123", "abc123",
]); ]);
assert.ok(runner.calls.every((call) => call.executable === "git")); assert.ok(runner.calls.every((call) => call.executable === "git"));
assert.ok(
runner.calls.every(
(call) => call.options?.timeoutMs === INTERNAL_GIT_TIMEOUT_MS,
),
);
}); });
}); });
@@ -602,7 +680,7 @@ test("real Git creation and reconciliation preserve branch and HEAD identity", a
}); });
}); });
test("dirty sources and unignored worktree roots fail before creation", async () => { test("dirty sources require approval with bounded evidence while conflicts remain blocked", async () => {
await withTempDir(async (repository) => { await withTempDir(async (repository) => {
const dirty = new GitWorkspaceManager( const dirty = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { dirty: true })), new FakeRunner(prepareResponses(repository, { dirty: true })),
@@ -614,9 +692,42 @@ test("dirty sources and unignored worktree roots fail before creation", async ()
featureName: "feature", featureName: "feature",
runId: "run-1", runId: "run-1",
}), }),
(error) => {
assert.ok(error instanceof WorkspaceValidationError);
assert.equal(error.code, "dirty-source");
assert.deepEqual(error.sourceChanges, [
{ path: "src/file.ts", status: " M" },
{ path: "src/new-file.ts", status: "??" },
]);
assert.equal(error.sourceChangesOmitted, 0);
return true;
},
);
const allowed = await dirty.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
allowDirtySource: true,
});
assert.equal(allowed.baseHead, "abc123");
assert.equal(allowed.dirtySourceAllowed, true);
assert.equal(allowed.sourceChanges.length, 2);
const unmerged = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { unmerged: true })),
);
await assert.rejects(
unmerged.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
allowDirtySource: true,
}),
(error) => (error) =>
error instanceof WorkspaceValidationError && error instanceof WorkspaceValidationError &&
error.code === "dirty-source", error.code === "unmerged-source",
); );
const unignored = new GitWorkspaceManager( const unignored = new GitWorkspaceManager(
@@ -743,6 +854,9 @@ test("uncertain worktree-add results are reconciled before returning", async ()
branch: "feature/feature", branch: "feature/feature",
baseHead: "abc123", baseHead: "abc123",
branchExists: false, branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
}; };
const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`; const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`;
const partial = new GitWorkspaceManager( const partial = new GitWorkspaceManager(
@@ -786,6 +900,9 @@ test("retry adopts an exact orphan branch left by interrupted creation", async (
branch: "feature/feature", branch: "feature/feature",
baseHead: "abc123", baseHead: "abc123",
branchExists: false, branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
}; };
assert.equal((await manager.create(plan)).state, "ready"); assert.equal((await manager.create(plan)).state, "ready");
assert.deepEqual(runner.calls.at(-1)?.args, [ assert.deepEqual(runner.calls.at(-1)?.args, [
+172 -24
View File
@@ -1,4 +1,4 @@
import { execFile, spawn } from "node:child_process"; import { execFile, spawn, type ChildProcess } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { constants } from "node:fs"; import { constants } from "node:fs";
import { import {
@@ -13,7 +13,10 @@ import {
import * as path from "node:path"; import * as path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { inflateSync } from "node:zlib"; import { inflateSync } from "node:zlib";
import type { WorkflowWorkspace } from "./domain.ts"; import type {
SourceChangeEvidence,
WorkflowWorkspace,
} from "./domain.ts";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -470,7 +473,9 @@ export class GitStateProbe {
} }
private async git(cwd: string, args: string[]) { private async git(cwd: string, args: string[]) {
const result = await this.runner.run(cwd, "git", args); const result = await this.runner.run(cwd, "git", args, {
timeoutMs: INTERNAL_GIT_TIMEOUT_MS,
});
if (result.code !== 0) { if (result.code !== 0) {
throw new Error(`Git state probe failed: ${result.stderr.trim()}`); throw new Error(`Git state probe failed: ${result.stderr.trim()}`);
} }
@@ -505,6 +510,7 @@ const PACKAGE_EXEC_SUBCOMMANDS = new Map<string, Set<string>>([
["bun", new Set(["x"])], ["bun", new Set(["x"])],
]); ]);
export const INTERNAL_GIT_TIMEOUT_MS = 30_000;
const DEFAULT_HOST_COMMAND_TIMEOUT_MS = 3 * 60 * 1_000; const DEFAULT_HOST_COMMAND_TIMEOUT_MS = 3 * 60 * 1_000;
export class ApprovedHostCommandExecutor { export class ApprovedHostCommandExecutor {
@@ -745,22 +751,21 @@ export class GitDiffProvider {
async read(worktreeRoot: string, baseHead = "HEAD") { async read(worktreeRoot: string, baseHead = "HEAD") {
const root = await realpath(path.resolve(worktreeRoot)); const root = await realpath(path.resolve(worktreeRoot));
const diff = await this.runner.run(root, "git", [ const diff = await this.runner.run(
"diff", root,
"--binary", "git",
"--no-ext-diff", ["diff", "--binary", "--no-ext-diff", baseHead, "--"],
baseHead, { timeoutMs: INTERNAL_GIT_TIMEOUT_MS },
"--", );
]);
if (diff.code !== 0) { if (diff.code !== 0) {
throw new Error(`Unable to read review diff: ${diff.stderr.trim()}`); throw new Error(`Unable to read review diff: ${diff.stderr.trim()}`);
} }
const untracked = await this.runner.run(root, "git", [ const untracked = await this.runner.run(
"ls-files", root,
"--others", "git",
"--exclude-standard", ["ls-files", "--others", "--exclude-standard", "-z"],
"-z", { timeoutMs: INTERNAL_GIT_TIMEOUT_MS },
]); );
if (untracked.code !== 0) { if (untracked.code !== 0) {
throw new Error(`Unable to list untracked review files: ${untracked.stderr.trim()}`); throw new Error(`Unable to list untracked review files: ${untracked.stderr.trim()}`);
} }
@@ -796,6 +801,9 @@ export interface WorkspacePlan {
branch: string; branch: string;
baseHead: string; baseHead: string;
branchExists: boolean; branchExists: boolean;
sourceChanges: SourceChangeEvidence[];
sourceChangesOmitted: number;
dirtySourceAllowed: boolean;
} }
export interface PrepareWorkspaceOptions { export interface PrepareWorkspaceOptions {
@@ -803,23 +811,57 @@ export interface PrepareWorkspaceOptions {
configuredRoot: string; configuredRoot: string;
featureName: string; featureName: string;
runId: string; runId: string;
allowDirtySource?: boolean;
} }
export class WorkspaceValidationError extends Error { export class WorkspaceValidationError extends Error {
readonly code: readonly code:
| "not-repository" | "not-repository"
| "dirty-source" | "dirty-source"
| "unmerged-source"
| "unignored-root" | "unignored-root"
| "existing-worktree-path" | "existing-worktree-path"
| "git-failed"; | "git-failed";
readonly sourceChanges: SourceChangeEvidence[];
readonly sourceChangesOmitted: number;
constructor(code: WorkspaceValidationError["code"], message: string) { constructor(
code: WorkspaceValidationError["code"],
message: string,
evidence: {
sourceChanges?: SourceChangeEvidence[];
sourceChangesOmitted?: number;
} = {},
) {
super(message); super(message);
this.name = "WorkspaceValidationError"; this.name = "WorkspaceValidationError";
this.code = code; this.code = code;
this.sourceChanges = evidence.sourceChanges ?? [];
this.sourceChangesOmitted = evidence.sourceChangesOmitted ?? 0;
} }
} }
const MAX_SOURCE_CHANGE_EVIDENCE = 20;
function sourceChangeEvidence(status: string) {
const entries = Object.entries(changedPathStatuses(status)).sort(([left], [right]) =>
left.localeCompare(right),
);
const sourceChanges = entries
.slice(0, MAX_SOURCE_CHANGE_EVIDENCE)
.map(([changedPath, changedStatus]) => ({
path: changedPath.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?"),
status: changedStatus,
}));
return {
sourceChanges,
sourceChangesOmitted: Math.max(0, entries.length - sourceChanges.length),
hasUnmerged: entries.some(([, changedStatus]) =>
["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(changedStatus),
),
};
}
const PROCESS_TERMINATION_GRACE_MS = 100; const PROCESS_TERMINATION_GRACE_MS = 100;
const PROCESS_TERMINATION_POLL_MS = 10; const PROCESS_TERMINATION_POLL_MS = 10;
const PROCESS_TERMINATION_TIMEOUT_MS = 1_000; const PROCESS_TERMINATION_TIMEOUT_MS = 1_000;
@@ -845,9 +887,29 @@ function signalProcessGroup(processId: number, signal: NodeJS.Signals) {
} }
} }
async function forceKillAndReap(child: ChildProcess) {
if (child.exitCode !== null || child.signalCode !== null) return;
if (process.platform !== "win32" && child.pid !== undefined) {
signalProcessGroup(child.pid, "SIGKILL");
}
child.kill("SIGKILL");
if (child.exitCode !== null || child.signalCode !== null) return;
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, PROCESS_TERMINATION_GRACE_MS);
child.once("close", () => {
clearTimeout(timer);
resolve();
});
});
}
async function terminateProcessTree(processId: number) { async function terminateProcessTree(processId: number) {
if (process.platform === "win32") { if (process.platform === "win32") {
await execFileAsync("taskkill", ["/pid", String(processId), "/T", "/F"]); await execFileAsync(
"taskkill",
["/pid", String(processId), "/T", "/F"],
{ timeout: PROCESS_TERMINATION_TIMEOUT_MS },
);
return; return;
} }
signalProcessGroup(processId, "SIGTERM"); signalProcessGroup(processId, "SIGTERM");
@@ -865,6 +927,14 @@ async function terminateProcessTree(processId: number) {
const COMMAND_OUTPUT_LIMIT_BYTES = 4 * 1024 * 1024; const COMMAND_OUTPUT_LIMIT_BYTES = 4 * 1024 * 1024;
export class ExecFileCommandRunner implements CommandRunner { export class ExecFileCommandRunner implements CommandRunner {
private readonly terminate: (processId: number) => Promise<void>;
constructor(options: {
terminateProcessTree?: (processId: number) => Promise<void>;
} = {}) {
this.terminate = options.terminateProcessTree ?? terminateProcessTree;
}
async run( async run(
cwd: string, cwd: string,
executable: string, executable: string,
@@ -880,7 +950,12 @@ export class ExecFileCommandRunner implements CommandRunner {
let timedOut = false; let timedOut = false;
let outputExceeded = false; let outputExceeded = false;
let termination: Promise<void> | undefined; let termination: Promise<void> | undefined;
let terminationFinished = false;
let terminationFailure: string | undefined;
let timeout: NodeJS.Timeout | undefined; let timeout: NodeJS.Timeout | undefined;
let terminationDeadline: NodeJS.Timeout | undefined;
let terminationDeadlineReached = false;
let settled = false;
const child = spawn(executable, args, { const child = spawn(executable, args, {
cwd, cwd,
detached: process.platform !== "win32", detached: process.platform !== "win32",
@@ -889,7 +964,48 @@ export class ExecFileCommandRunner implements CommandRunner {
}); });
const terminate = () => { const terminate = () => {
if (!termination && child.pid !== undefined) { if (!termination && child.pid !== undefined) {
termination = terminateProcessTree(child.pid); termination = this.terminate(child.pid);
void termination.then(
() => { terminationFinished = true; },
(failure: unknown) => {
terminationFinished = true;
terminationFailure = failure instanceof Error
? failure.message
: String(failure);
},
);
terminationDeadline = setTimeout(() => {
if (settled) return;
terminationDeadlineReached = true;
const terminationError =
terminationFailure ??
(terminationFinished
? "process did not exit after process tree termination"
: "process tree termination did not finish within its deadline");
void forceKillAndReap(child).then(
() => {
if (settled) return;
settled = true;
resolve({
code: 125,
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
stderr: `Host command termination failed: ${terminationError}`,
});
},
(failure: unknown) => {
if (settled) return;
settled = true;
const detail = failure instanceof Error
? failure.message
: String(failure);
resolve({
code: 125,
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
stderr: `Host command termination failed: ${terminationError}; final force-kill failed: ${detail}`,
});
},
);
}, PROCESS_TERMINATION_TIMEOUT_MS + PROCESS_TERMINATION_GRACE_MS + 100);
} }
}; };
const capture = (chunks: Buffer[], chunk: Buffer, stream: "stdout" | "stderr") => { const capture = (chunks: Buffer[], chunk: Buffer, stream: "stdout" | "stderr") => {
@@ -909,6 +1025,7 @@ export class ExecFileCommandRunner implements CommandRunner {
spawnError = error; spawnError = error;
}); });
child.on("close", async (code) => { child.on("close", async (code) => {
if (settled) return;
if (timeout !== undefined) clearTimeout(timeout); if (timeout !== undefined) clearTimeout(timeout);
let terminationError: string | undefined; let terminationError: string | undefined;
try { try {
@@ -916,6 +1033,9 @@ export class ExecFileCommandRunner implements CommandRunner {
} catch (failure) { } catch (failure) {
terminationError = failure instanceof Error ? failure.message : String(failure); terminationError = failure instanceof Error ? failure.message : String(failure);
} }
if (settled || terminationDeadlineReached) return;
settled = true;
if (terminationDeadline !== undefined) clearTimeout(terminationDeadline);
const stdout = Buffer.concat(stdoutChunks).toString("utf8"); const stdout = Buffer.concat(stdoutChunks).toString("utf8");
const stderr = Buffer.concat(stderrChunks).toString("utf8"); const stderr = Buffer.concat(stderrChunks).toString("utf8");
if (timedOut) { if (timedOut) {
@@ -980,8 +1100,9 @@ export class GitWorkspaceManager {
const status = await this.git(sourceRoot, [ const status = await this.git(sourceRoot, [
"status", "status",
"--porcelain", "--porcelain=v1",
"--untracked-files=normal", "-z",
"--untracked-files=all",
]); ]);
if (status.code !== 0) { if (status.code !== 0) {
throw new WorkspaceValidationError( throw new WorkspaceValidationError(
@@ -989,10 +1110,19 @@ export class GitWorkspaceManager {
`Unable to inspect source state: ${status.stderr.trim()}`, `Unable to inspect source state: ${status.stderr.trim()}`,
); );
} }
if (status.stdout.trim().length > 0) { const evidence = sourceChangeEvidence(status.stdout);
if (evidence.hasUnmerged) {
throw new WorkspaceValidationError(
"unmerged-source",
"Source checkout has unmerged paths; resolve conflicts before creating a workflow worktree",
evidence,
);
}
if (evidence.sourceChanges.length > 0 && !options.allowDirtySource) {
throw new WorkspaceValidationError( throw new WorkspaceValidationError(
"dirty-source", "dirty-source",
"Source checkout must be clean before creating a workflow worktree", "Source checkout has changes that are not included in the workflow worktree created from HEAD",
evidence,
); );
} }
@@ -1058,6 +1188,10 @@ export class GitWorkspaceManager {
branch, branch,
baseHead: head.stdout.trim(), baseHead: head.stdout.trim(),
branchExists: branchCheck.code === 0, branchExists: branchCheck.code === 0,
sourceChanges: evidence.sourceChanges,
sourceChangesOmitted: evidence.sourceChangesOmitted,
dirtySourceAllowed:
evidence.sourceChanges.length > 0 && options.allowDirtySource === true,
}; };
} }
@@ -1075,6 +1209,9 @@ export class GitWorkspaceManager {
root: plan.worktreePath, root: plan.worktreePath,
branch: plan.branch, branch: plan.branch,
baseHead: plan.baseHead, baseHead: plan.baseHead,
sourceChanges: structuredClone(plan.sourceChanges),
sourceChangesOmitted: plan.sourceChangesOmitted,
dirtySourceAllowed: plan.dirtySourceAllowed,
error: `Unable to inspect partial branch creation: ${branch.stderr.trim()}`, error: `Unable to inspect partial branch creation: ${branch.stderr.trim()}`,
}; };
} }
@@ -1098,6 +1235,9 @@ export class GitWorkspaceManager {
root: plan.worktreePath, root: plan.worktreePath,
branch: plan.branch, branch: plan.branch,
baseHead: plan.baseHead, baseHead: plan.baseHead,
sourceChanges: structuredClone(plan.sourceChanges),
sourceChangesOmitted: plan.sourceChangesOmitted,
dirtySourceAllowed: plan.dirtySourceAllowed,
error: `Existing partial branch ${plan.branch} does not match persisted base HEAD ${plan.baseHead}`, error: `Existing partial branch ${plan.branch} does not match persisted base HEAD ${plan.baseHead}`,
}; };
} }
@@ -1111,6 +1251,9 @@ export class GitWorkspaceManager {
root: plan.worktreePath, root: plan.worktreePath,
branch: plan.branch, branch: plan.branch,
baseHead: plan.baseHead, baseHead: plan.baseHead,
sourceChanges: structuredClone(plan.sourceChanges),
sourceChangesOmitted: plan.sourceChangesOmitted,
dirtySourceAllowed: plan.dirtySourceAllowed,
error: `Git worktree creation returned ${result.code}: ${result.stderr.trim()}`, error: `Git worktree creation returned ${result.code}: ${result.stderr.trim()}`,
}); });
} }
@@ -1120,6 +1263,9 @@ export class GitWorkspaceManager {
root: plan.worktreePath, root: plan.worktreePath,
branch: plan.branch, branch: plan.branch,
baseHead: plan.baseHead, baseHead: plan.baseHead,
sourceChanges: structuredClone(plan.sourceChanges),
sourceChangesOmitted: plan.sourceChangesOmitted,
dirtySourceAllowed: plan.dirtySourceAllowed,
}; };
} }
@@ -1240,6 +1386,8 @@ export class GitWorkspaceManager {
} }
private git(cwd: string, args: string[]) { private git(cwd: string, args: string[]) {
return this.runner.run(cwd, "git", args); return this.runner.run(cwd, "git", args, {
timeoutMs: INTERNAL_GIT_TIMEOUT_MS,
});
} }
} }