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
- 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:
@@ -30,14 +30,16 @@ The managed flow is:
4. Choose a fresh plan-seeded session, compacted current session, or unchanged current context.
5. Implement with guarded file ownership.
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.
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
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:
@@ -46,7 +48,7 @@ Use `/workflow continue` to select a run with a pending fresh-session handoff or
/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
@@ -70,7 +72,7 @@ An analysis run uses `action: "start"` with `workflow: { mode: "analysis-script"
## 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
+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
pi-workflow 118 cols
Runs feature/search-page · waiting
> search-page waiting plan-approval · revision 12
auth-cleanup running Worktree .worktrees/pi-workflow-search-page-r1
cache-audit done Branch feature/search-page
Plan artifacts/plan-11-a31f.md
Stages Report pending
✓ Discover
✓ Design Open Gate · Plan Approval
· Prototype UI impact: non-trivial
· Implement Review the plan and UI evidence before deciding.
· Verify
· Review [a] approve [r] revise [o] open plan
↑↓ run tab pane enter action esc close
┌──────────────────────────────────────────────────────────────────────┐
│ │
│ ╭─ pi-workflow ──────────────────────────────╮ │
│ │ pi-workflow-usability-resume │ │
│ │ WAITING · Plan Approval · revision 12 │ │
│ │ │ │
│ │ ✓ Discover ✓ Design · Prototype │ │
│ │ · Implement · Verify · Review │ │
│ │ │ │
│ │ > Review plan │ │
│ │ Approve plan │ │
│ │ Request revision │ │
│ │ Close │ │
│ │ │ │
│ │ ↑↓ navigate enter select 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
pi-workflow · search-page
waiting · plan-approval · r12
✓ Discover ✓ Design · Prototype
· Implement · Verify · Review
Plan Approval
UI impact: non-trivial
[o] plan [enter] actions
╭─ pi-workflow ───────────────────────╮
│ usability-resume · WAITING │
│ Plan Approval · r12 │
│ │
│ > Review plan │
│ Approve plan │
│ Request revision │
│ Close │
│ │
│ ↑↓ 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
pi-workflow
> search-page WAITING Plan Approval r12
auth-cleanup RUNNING Implement r31
cache-audit DONE report available r18
────────────────────────────────────────────────────────────
search-page
feature/search-page
Progress Discover ✓ Design ✓ Prototype · Implement ·
Verify · Review ·
Gate Plan Approval
non-trivial UI · 3 prototype candidates required
Artifacts plan-11-a31f.md
Worktree .worktrees/pi-workflow-search-page-r1
[enter] actions [o] artifact [esc] close
╭─ pi-workflow ────────────────────────────────────────────────────────────────╮
│ Runs │ pi-workflow-usability-resume WAITING │
│ │ feature/pi-workflow-usability-resume · r12 │
│ > usability-resume │ │
│ auth-cleanup │ Progress │
│ cache-audit │ ✓ Discover ✓ Design · Prototype · Implement │
│ │ · Verify · Review │
│ │ │
│ │ Open Gate │
│ │ Plan Approval │
│ │ Review the implementation plan before work begins. │
│ │ │
│ │ > Review plan │
│ │ Approve plan │
│ │ Request revision │
│ │ Browse artifacts │
│ │ Close │
│ │ │
│ ↑↓ run tab focus │ ↑↓ action enter select esc back │
╰──────────────────────────────────────────────────────────────────────────────╯
```
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
search-page · Plan Approval
> Inspect plan
Record approval
Request revision
Copy worktree path
Close
╭─ pi-workflow ────────────────────────────────────────────────────────────────╮
│ Runs │ usability-resume · WAITING · r12 │
│ > usability │ ✓ Discover ✓ Design · Prototype │
│ auth-cleanup │ · Implement · Verify · Review │
│ cache-audit │ │
│ │ Plan Approval │
│ │ > 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
search-page · Prototype Acceptance
1 Sidebar navigation /prototype?variant=1
mobile ✓ desktop ✓ browser evidence retained
2 Command palette /prototype?variant=2
mobile ✓ desktop ✓ browser evidence retained
3 Inline workspace /prototype?variant=3
mobile ✓ desktop ✓ browser evidence retained
> Open candidate 1
Accept candidate 1
Request refinement
Abort run
╭─ pi-workflow ─────────────────────────────╮
│ > usability-resume WAITING Plan Approval│
│ auth-cleanup RUNNING Implement │
│ cache-audit DONE report ready │
│ │
│ ↑↓ navigate enter open esc close │
╰────────────────────────────────────────────╯
```
Narrow terminal uses the same vertical card and hides paths until `o`.
## C. Event timeline
Best for recovery and auditability. The primary view is an immutable history; actions belong to the current event.
### C2. Run detail popup
```text
pi-workflow · search-page WAITING
12:04 ✓ Discover 2 assignments
12:09 ✓ Questions decision recorded
12:15 ✓ Design plan revision 11
12:16 ● Plan Approval waiting for Decision
Current
UI impact non-trivial
Plan plan-11-a31f.md
Worktree feature/search-page
[enter] current actions [p] prototypes [r] report [esc]
╭─ usability-resume ───────────────────────────────────╮
│ WAITING · Plan Approval · revision 12 │
│ │
│ ✓ Discover ✓ Design · Prototype · Implement │
│ · Verify · Review │
│ │
│ > Review plan │
│ Record Gate Decision │
│ Browse prototype evidence │
│ Browse Assignment transcripts │
│ Show worktree path │
│ │
│ ↑↓ 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
Recent workflow events
pi-workflow plan · usability-resume line 42/216
> search-page Plan Approval 2m
auth-cleanup Implement 8m
cache-audit Completed 31m
Outline Plan
───────────────────────────── ─────────────────────────────────────────────
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
> search-page · 12345678a Plan Approval
search-page · 12345678b Implement
cache-audit Completed
pi-workflow plan · usability-resume row 42/216
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.
- 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.
- 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.
- Keep run status, revision, branch, worktree, plan, report, and prototype evidence discoverable.
- Remain usable from narrow terminals through 4K-width terminals without continuous animation.
## Implementation phases
### 2. Session lifecycle and recovery
- Route the dashboard's fresh-session action through the
existing continuation path.
- 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 };
concurrency?: number;
background?: boolean;
allowDirtySource?: boolean;
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.
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.
@@ -346,7 +349,7 @@ Initialize
- Resolve effective configuration, validate the feature name, and validate the repository without mutation.
- 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.
- 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.
- 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.
@@ -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.
- 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.
- 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.
- 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.
@@ -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.
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.
@@ -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.
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.
@@ -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.
- `workspace.ts`: worktree creation, ownership conflict checks, and lifecycle.
- `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.
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.
- 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.
- 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.
- 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.
@@ -618,7 +624,7 @@ Extend the Phase 2 worktree scaffold with the Implementation Context Gate, `/wor
#### 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.
- 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.
@@ -655,7 +661,9 @@ Add the dashboard integration around the Phase 4 `/workflow continue` command, p
#### 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.
- 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.
+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
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;
input?: (title: string, placeholder?: string) => Promise<string | undefined>;
select?: (title: string, options: string[]) => Promise<string | undefined>;
customResults?: unknown[];
onCustomRender?: (lines: string[]) => void;
}) {
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 = {
cwd: "/repo",
sessionManager: {
@@ -145,6 +153,24 @@ function commandContext(options: {
notify(message: string) {
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),
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",
mode: "tui",
hasUI: true,
input: async (title) => {
prompt = title;
return "Add workflow lifecycle commands";
},
customResults: ["Add workflow lifecycle commands"],
onCustomRender: (lines) => { prompt = lines.join("\n"); },
}).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(
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: [],
}) as unknown as WorkflowRun;
const selectedTitles: string[] = [];
const selectedOptions: string[][] = [];
const renderedSelections: string[] = [];
const inspected: string[] = [];
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
@@ -325,29 +348,17 @@ test("/workflow continue lists only valid runs and resume is an alias", async ()
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
select: async (title, options) => {
selectedTitles.push(title);
selectedOptions.push(options);
return undefined;
},
onCustomRender: (lines) => renderedSelections.push(lines.join("\n")),
});
await registered.command()("continue", interactive.context);
await registered.command()("resume", interactive.context);
assert.deepEqual(selectedTitles, [
"Continue Workflow Run",
"Continue Workflow Run",
]);
assert.equal(selectedOptions[0]?.length, 2);
assert.equal(selectedOptions[1]?.length, 2);
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")),
),
);
assert.equal(renderedSelections.length, 2);
for (const rendered of renderedSelections) {
assert.match(rendered, /Continue Workflow Run/);
assert.match(rendered, /shared-feature · run-1/);
assert.match(rendered, /shared-feature · run-2/);
assert.doesNotMatch(rendered, /historical/);
}
await registered.command()("resume run-2", interactive.context);
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 unknown as ContextTransitionRuntime);
let choices: string[] = [];
let choices = "";
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
select: async (title, options) => {
assert.equal(title, "Stop Workflow Run");
choices = options;
return options[1];
},
customResults: [run("run-2", "waiting", "session-1")],
onCustomRender: (lines) => { choices = lines.join("\n"); },
});
await registered.command()("stop", command.context);
assert.deepEqual(choices, [
"duplicate-label · run-1 · running",
"duplicate-label · run-2 · waiting",
]);
assert.match(choices, /Stop Workflow Run/);
assert.match(choices, /duplicate-label · run-1/);
assert.match(choices, /duplicate-label · run-2/);
assert.doesNotMatch(choices, /completed|historical/);
assert.deepEqual(
commands.map((entry) =>
`${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 () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");
+13 -6
View File
@@ -17,6 +17,7 @@ import {
import { isValidContextContinuation } from "./continuation.ts";
import { deriveRunLabel, deriveRunLabels } from "./run-label.ts";
import { readVerifiedPlanArtifact, type WorkflowStore } from "./store.ts";
import { workflowInput, workflowSelect } from "./tui.ts";
const MAX_HANDOFF_BYTES = 32 * 1024;
type NewSessionOptions = NonNullable<
@@ -259,11 +260,16 @@ async function selectRun(
title: string,
runs: WorkflowRun[],
) {
const labels = deriveRunLabels(runs).map(
(label, index) => `${label} · ${runs[index]!.status}`,
const labels = deriveRunLabels(runs);
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(
@@ -336,9 +342,10 @@ async function handleStart(
return;
}
initialRequest =
(await ctx.ui.input(
(await workflowInput(
ctx,
"Start Workflow Run",
"What do you want to start working on?",
"Describe the feature request",
))?.trim() ?? "";
if (!initialRequest) return;
}
+453 -214
View File
@@ -8,16 +8,12 @@ import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import { visibleWidth } from "@earendil-works/pi-tui";
import {
DEFAULT_WORKFLOW_CONFIG,
type WorkflowRun,
} from "./domain.ts";
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
import {
FocusedWorkflowDashboard,
openWorkflowDashboard,
} from "./dashboard.ts";
import { openWorkflowDashboard } from "./dashboard.ts";
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
return {
@@ -86,14 +82,30 @@ function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
};
}
const theme = {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
function prototypeCandidate() {
return {
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 {
return {
runId: workflow.runId,
featureName: workflow.featureName,
request: workflow.request,
revision: workflow.revision,
status: workflow.status,
background: workflow.background,
@@ -113,76 +125,141 @@ function view(workflow: WorkflowRun): WorkflowView {
};
}
test("focused dashboard renders compact and narrow cards within width", () => {
let selected: string | null | undefined;
const dashboard = new FocusedWorkflowDashboard(
[run()],
theme,
(value) => {
selected = value;
function tuiContext(results: unknown[], sessionId = "session-1") {
const notifications: string[] = [];
const renders: string[][] = [];
let customCalls = 0;
const theme = {
fg: (_color: string, text: string) => text,
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,
},
);
dashboard.handleInput("\r");
const wide = dashboard.render(100);
assert.ok(wide.some((line) => line.includes("Progress")));
assert.ok(wide.some((line) => line.includes("Worktree")));
assert.ok(wide.every((line) => visibleWidth(line) <= 100));
const narrow = dashboard.render(48);
assert.equal(narrow.some((line) => line.includes("Worktree")), false);
assert.ok(narrow.every((line) => visibleWidth(line) <= 48));
dashboard.handleInput("\r");
assert.equal(selected, "run-1");
});
ui: {
custom: async (
factory: (
tui: { requestRender(): void; terminal: { columns: number; rows: number } },
overlayTheme: typeof theme,
keybindings: object,
done: (value: unknown) => void,
) => { render(width: number): string[] },
) => {
customCalls++;
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", () => {
let selected: string | null | undefined;
const dashboard = new FocusedWorkflowDashboard(
[
run({ runId: "12345678-aaaa", featureName: "search-page" }),
run({ runId: "12345678-bbbb", featureName: "search-page" }),
function interactiveTuiContext(inputs: string[][], sessionId = "session-1") {
let customCalls = 0;
const theme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
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,
(value) => { selected = value; },
implementationContext: {
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.ok(lines.some((line) => line.includes("search-page · 12345678-a")));
assert.ok(lines.some((line) => line.includes("search-page · 12345678-b")));
dashboard.handleInput("\r");
dashboard.handleInput("\r");
assert.equal(selected, "12345678-aaaa");
assert.deepEqual(result, { continueRunId: "run-1" });
assert.equal(tui.customCalls(), 2);
});
test("dashboard disambiguates collisions outside its visible window at common widths", () => {
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 () => {
test("dashboard records Plan Approval using only custom TUI overlays", async () => {
const workflow = run({
planArtifact: {
path: "/state/artifacts/plan.md",
@@ -191,26 +268,14 @@ test("dashboard records explicit Plan Approval through the engine", async () =>
},
});
const commands: WorkflowCommand[] = [];
const selections = [
"Record Gate Decision",
"Approve plan · UI impact minor",
];
const ctx = {
hasUI: true,
mode: "tui",
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;
const tui = tuiContext([
"run-1",
"decide",
"approve",
true,
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
@@ -223,7 +288,7 @@ test("dashboard records explicit Plan Approval through the engine", async () =>
},
}) as never,
},
ctx,
tui.context,
);
const decision = commands[0];
assert.equal(decision?.action, "decide");
@@ -235,6 +300,188 @@ test("dashboard records explicit Plan Approval through the engine", async () =>
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 () => {
@@ -242,34 +489,22 @@ test("historical parent sessions receive inspection-only dashboard actions", asy
parentSessionIds: ["session-1", "session-2"],
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
});
let actions: string[] = [];
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;
const tui = tuiContext(["run-1", undefined, undefined], "session-2");
await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
tui.context,
);
assert.equal(actions.includes("Record Gate Decision"), false);
assert.equal(actions.includes("Continue context transition"), false);
assert.equal(actions.includes("Open plan"), true);
const actions = tui.renders[1]?.join("\n") ?? "";
assert.match(actions, /Review verified plan/);
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-"));
try {
const reportPath = path.join(directory, "report.md");
@@ -284,36 +519,65 @@ test("dashboard opens retained report content on demand", async () => {
revision: 12,
},
});
let opened = "";
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => "run-1",
select: async () => "Open report",
editor: async (_title: string, contents: string) => {
opened = contents;
return contents;
},
notify: () => {},
},
} as unknown as ExtensionCommandContext;
const tui = tuiContext([
"run-1",
"report",
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
pi,
{
store: { listRuns: async () => [workflow] } as never,
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 {
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-"));
try {
const transcriptPath = path.join(directory, "assignment.json");
@@ -344,107 +608,82 @@ test("dashboard verifies and opens Assignment transcripts on demand", async () =
},
],
});
const selections = [
"Browse Assignment transcripts",
"1. review/independent-reviewer · succeeded",
];
let opened = "";
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => "run-1",
select: async () => selections.shift(),
editor: async (_title: string, contents: string) => {
opened = contents;
return contents;
},
notify: () => {},
},
} as unknown as ExtensionCommandContext;
const assignment = workflow.assignments[0];
assert.ok(assignment);
const tui = tuiContext([
"run-1",
"transcripts",
assignment,
undefined,
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
pi,
{
store: { listRuns: async () => [workflow] } as never,
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 {
await rm(directory, { recursive: true, force: true });
}
});
test("dashboard reports the supplied run ID when direct inspection cannot resolve it", async () => {
const notifications: string[] = [];
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
throw new Error("missing runs must not open the dashboard");
test("RPC and print dashboards stay linear and never call custom", async () => {
for (const mode of ["rpc", "print", "json"] as const) {
const notifications: string[] = [];
const ctx = {
hasUI: mode === "rpc",
mode,
sessionManager: { getSessionId: () => "session-1" },
ui: {
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;
} as unknown as ExtensionCommandContext;
assert.deepEqual(
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(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
tui.context,
"missing-run-id",
);
assert.deepEqual(result, {});
assert.deepEqual(notifications, [
assert.deepEqual(tui.notifications, [
"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,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import {
Key,
matchesKey,
truncateToWidth,
type Component,
} from "@earendil-works/pi-tui";
import { isValidContextContinuation } from "./continuation.ts";
import type {
GateDecision,
@@ -27,36 +21,29 @@ import {
readVerifiedReportArtifact,
readVerifiedTranscriptArtifact,
} from "./store.ts";
import {
workflowConfirm,
workflowEditor,
workflowInput,
workflowPlanReader,
workflowSelect,
} from "./tui.ts";
interface DashboardTheme {
fg(
color:
| "accent"
| "dim"
| "error"
| "muted"
| "success"
| "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;
}
const CORE_STAGES = [
"discover",
"design",
"prototype",
"implement",
"verify",
"review",
] as const;
function activeStage(run: WorkflowRun) {
return (
[...run.stages]
.reverse()
.find((stage) =>
stage.status === "running" || stage.status === "waiting"
)?.kind ?? run.status
.find((stage) => stage.status === "running" || stage.status === "waiting")
?.kind ?? run.status
);
}
@@ -64,152 +51,29 @@ function selectedGate(run: WorkflowRun) {
return [...run.gates].reverse().find((gate) => gate.status === "open");
}
function stageLine(run: WorkflowRun, theme: DashboardTheme) {
function stageSummary(run: WorkflowRun) {
return run.stages
.filter((stage) =>
["discover", "design", "prototype", "implement", "verify", "review"].includes(
stage.kind,
),
)
.filter((stage) => CORE_STAGES.includes(stage.kind as (typeof CORE_STAGES)[number]))
.map((stage) => {
const marker =
stage.status === "completed"
? theme.fg("success", "✓")
? "✓"
: stage.status === "failed"
? theme.fg("error", "×")
? "×"
: stage.status === "running" || stage.status === "waiting"
? theme.fg("accent", "●")
: theme.fg("dim", "·");
? "●"
: "·";
return `${marker} ${stage.kind}`;
})
.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) {
const gate = selectedGate(run);
return [
`${label} · ${run.status} · revision ${run.revision}`,
`Stage: ${activeStage(run)}`,
`Progress: ${stageSummary(run)}`,
...(gate ? [`Gate: ${gate.kind} (${gate.id})`] : []),
...(run.workspace.branch ? [`Branch: ${run.workspace.branch}`] : []),
...(run.workspace.root ? [`Worktree: ${run.workspace.root}`] : []),
@@ -243,8 +107,7 @@ async function decide(
}
async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
const gate = selectedGate(run);
const payload = gate?.payload;
const payload = selectedGate(run)?.payload;
if (!payload || typeof payload !== "object") return undefined;
const questions = (payload as { questions?: unknown }).questions;
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 prompt = (item as { prompt?: unknown }).prompt;
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;
answers[id] = answer.trim();
}
@@ -263,24 +126,33 @@ async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
async function planDecision(ctx: ExtensionContext, run: WorkflowRun) {
if (!run.planArtifact || !run.uiImpact) return undefined;
const action = await ctx.ui.select("Plan Approval", [
"Inspect plan",
`Approve plan · UI impact ${run.uiImpact}`,
"Request revision",
]);
if (action === "Inspect plan") {
const plan = await readVerifiedPlanArtifact(run.planArtifact);
await ctx.ui.editor("Approved plan (read-only copy)", plan.slice(0, 32 * 1024));
return undefined;
}
if (action === "Request revision") {
const feedback = await ctx.ui.editor("Plan revision feedback", "");
return feedback?.trim()
? { kind: "revise-plan" as const, feedback: feedback.trim() }
: undefined;
}
if (action?.startsWith("Approve plan")) {
const approved = await ctx.ui.confirm(
while (true) {
const action = await workflowSelect(ctx, "Plan Approval", [
{ value: "inspect" as const, label: "Review verified plan" },
{
value: "approve" as const,
label: `Approve plan · UI impact ${run.uiImpact}`,
},
{ value: "revise" as const, label: "Request revision" },
]);
if (!action) return undefined;
if (action === "inspect") {
const plan = await readVerifiedPlanArtifact(run.planArtifact);
await workflowPlanReader(ctx, run.featureName ?? run.runId, plan);
continue;
}
if (action === "revise") {
const feedback = await workflowEditor(
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 revision ${run.planArtifact.revision} and explicitly accept UI impact ${run.uiImpact}?`,
);
@@ -292,78 +164,121 @@ async function planDecision(ctx: ExtensionContext, run: WorkflowRun) {
}
: undefined;
}
return undefined;
}
async function prototypeDecision(ctx: ExtensionContext, run: WorkflowRun) {
const candidates = run.prototypeCandidates.slice(-3);
const items = candidates.map(
(candidate, index) =>
`${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`,
);
const action = await ctx.ui.select("Prototype Acceptance", [
...items,
"Request refinement",
"Abort run",
]);
if (action === "Request refinement") {
const feedback = await ctx.ui.editor("Prototype refinement feedback", "");
return feedback?.trim()
? { kind: "refine-prototypes" as const, feedback: feedback.trim() }
: undefined;
while (true) {
const action = await workflowSelect<
| { kind: "candidate"; candidate: PrototypeCandidate }
| { kind: "refine" }
| { kind: "abort" }
>(ctx, "Prototype Acceptance", [
...candidates.map((candidate, index) => ({
value: { kind: "candidate" as const, candidate },
label: `${index + 1}. ${candidate.structuralRationale}`,
description: candidate.route,
})),
{ value: { kind: "refine" as const }, label: "Request refinement" },
{ value: { kind: "abort" as const }, label: "Abort run" },
]);
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) {
const strategy = await ctx.ui.select("Implementation Context", [
"Fresh plan-seeded session",
"Compact current session",
"Keep current context unchanged",
const strategy = await workflowSelect(ctx, "Implementation Context", [
{
value: "fresh-session" as const,
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 { kind: "select-implementation-context" as const, strategy: "fresh-session" as const };
}
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;
return strategy
? { kind: "select-implementation-context" as const, strategy }
: undefined;
}
async function recoveryDecision(ctx: ExtensionContext) {
const resolution = await ctx.ui.select("Recovery", ["Retry", "Inspect", "Abort"]);
if (!resolution) return undefined;
return {
kind: "recover" as const,
resolution: resolution.toLowerCase() as "retry" | "inspect" | "abort",
};
const resolution = await workflowSelect(ctx, "Recovery", [
{ value: "retry" as const, label: "Retry" },
{ value: "inspect" as const, label: "Inspect" },
{ value: "abort" as const, label: "Abort" },
]);
return resolution
? { kind: "recover" as const, resolution }
: undefined;
}
async function gateDecision(ctx: ExtensionContext, run: WorkflowRun) {
@@ -386,128 +301,161 @@ function validContinuation(run: WorkflowRun, sessionId?: string) {
}
function isActiveParent(run: WorkflowRun, sessionId?: string) {
return Boolean(
sessionId && run.lease?.activeParentSessionId === sessionId,
);
return Boolean(sessionId && run.lease?.activeParentSessionId === sessionId);
}
async function browsePrototypeArtifacts(
ctx: ExtensionContext,
run: WorkflowRun,
) {
const candidates = run.prototypeCandidates;
const labels = candidates.map(
(candidate, index) =>
`${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`,
);
const selected = await ctx.ui.select("Prototype evidence", labels);
const candidate = candidates[labels.indexOf(selected ?? "")];
if (!candidate) return;
ctx.ui.notify(
`${candidate.launchInstructions}\nRoute: ${candidate.route}`,
"info",
);
const artifacts = [candidate.artifactPath, ...candidate.retainedArtifacts];
const artifact = await ctx.ui.select("Retained artifact", artifacts);
if (!artifact) return;
if (artifact.endsWith(".json")) {
const contents = await readFile(artifact, "utf8");
await ctx.ui.editor("Retained prototype evidence", contents.slice(0, 32 * 1024));
} else {
ctx.ui.notify(artifact, "info");
async function browsePrototypeArtifacts(ctx: ExtensionContext, run: WorkflowRun) {
while (true) {
const candidate = await workflowSelect(
ctx,
"Prototype evidence",
run.prototypeCandidates.map((entry, index) => ({
value: entry,
label: `${index + 1}. ${entry.structuralRationale}`,
description: entry.route,
})),
);
if (!candidate) return;
while (true) {
const artifact = await workflowSelect(
ctx,
"Retained artifact",
[candidate.artifactPath, ...candidate.retainedArtifacts].map((entry) => ({
value: entry,
label: entry,
})),
);
if (!artifact) break;
if (artifact.endsWith(".json")) {
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(
ctx: ExtensionContext,
run: WorkflowRun,
) {
async function browseAssignmentTranscripts(ctx: ExtensionContext, run: WorkflowRun) {
const assignments = run.assignments.filter(
(assignment) => assignment.transcriptArtifact,
);
const labels = assignments.map(
(assignment, index) =>
`${index + 1}. ${assignment.role}/${assignment.profileName} · ${assignment.status}`,
);
const selected = await ctx.ui.select("Assignment transcripts", labels);
const assignment = assignments[labels.indexOf(selected ?? "")];
if (!assignment?.transcriptArtifact) return;
const transcript = await readVerifiedTranscriptArtifact(
assignment.transcriptArtifact,
);
await ctx.ui.editor(
`${assignment.role} transcript`,
transcript.slice(0, 32 * 1024),
);
while (true) {
const assignment = await workflowSelect(
ctx,
"Assignment transcripts",
assignments.map((entry, index) => ({
value: entry,
label: `${index + 1}. ${entry.role}/${entry.profileName}`,
description: entry.status,
})),
);
if (!assignment?.transcriptArtifact) return;
const transcript = await readVerifiedTranscriptArtifact(
assignment.transcriptArtifact,
);
await workflowPlanReader(ctx, `${assignment.role} transcript`, transcript);
}
}
type DashboardActionResult =
| { kind: "back" }
| { kind: "continue"; runId: string }
| { kind: "closed" };
async function actionSheet(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
ctx: ExtensionCommandContext,
run: WorkflowRun,
initialRun: WorkflowRun,
label: string,
) {
const actions = [
"Inspect run",
...(selectedGate(run) &&
isActiveParent(run, ctx.sessionManager.getSessionId())
? ["Record Gate Decision"]
: []),
...(validContinuation(run, ctx.sessionManager.getSessionId())
? ["Continue context transition"]
: []),
...(run.planArtifact ? ["Open plan"] : []),
...(run.reportArtifact ? ["Open report"] : []),
...(run.prototypeCandidates.length > 0 ? ["Browse prototype evidence"] : []),
...(run.assignments.some((assignment) => assignment.transcriptArtifact)
? ["Browse Assignment transcripts"]
: []),
...(run.workspace.root ? ["Show worktree path"] : []),
];
const action = await ctx.ui.select(label, actions);
if (action === "Inspect run") ctx.ui.notify(runSummary(run, label), "info");
else if (action === "Open plan") {
const plan = await readVerifiedPlanArtifact(run.planArtifact!);
await ctx.ui.editor("Workflow plan", plan.slice(0, 32 * 1024));
} else if (action === "Open report") {
const report = await readVerifiedReportArtifact(run.reportArtifact!);
await ctx.ui.editor("Workflow report", report.slice(0, 32 * 1024));
} else if (action === "Browse prototype evidence") {
await browsePrototypeArtifacts(ctx, run);
} else if (action === "Browse Assignment transcripts") {
await browseAssignmentTranscripts(ctx, run);
} else if (action === "Show worktree path") {
ctx.ui.notify(run.workspace.root!, "info");
}
else if (action === "Continue context transition") {
return { continueRunId: run.runId };
} else if (action === "Record Gate Decision") {
const decision = await gateDecision(ctx, run);
if (decision === "abort") {
const engine = await runtime.engineForContext(ctx);
await engine.execute({
action: "abort",
runId: run.runId,
expectedRevision: run.revision,
idempotencyKey: `dashboard-abort-${run.runId}-${run.revision}-${randomUUID()}`,
});
} else if (decision) {
const result = await decide(pi, runtime, ctx, run, decision);
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 {
ctx.ui.notify(`Workflow ${label}: ${result.status}`, "info");
): Promise<DashboardActionResult> {
let run = initialRun;
while (true) {
const actions = [
{ value: "inspect" as const, label: "Inspect run", description: runSummary(run, label) },
...(selectedGate(run) && isActiveParent(run, ctx.sessionManager.getSessionId())
? [{ value: "decide" as const, label: "Record Gate Decision", description: selectedGate(run)?.kind }]
: []),
...(validContinuation(run, ctx.sessionManager.getSessionId())
? [{ value: "continue" as const, label: "Continue context transition", description: "Enter the selected implementation context now." }]
: []),
...(run.planArtifact ? [{ value: "plan" as const, label: "Review verified plan" }] : []),
...(run.reportArtifact ? [{ value: "report" as const, label: "Open report" }] : []),
...(run.prototypeCandidates.length > 0
? [{ value: "prototypes" as const, label: "Browse prototype evidence" }]
: []),
...(run.assignments.some((assignment) => assignment.transcriptArtifact)
? [{ value: "transcripts" as const, label: "Browse Assignment transcripts" }]
: []),
...(run.workspace.root ? [{ value: "worktree" as const, label: "Show worktree path" }] : []),
];
const action = await workflowSelect(ctx, label, actions);
if (!action) return { kind: "back" };
if (action === "continue") return { kind: "continue", runId: run.runId };
if (action === "inspect") {
await workflowPlanReader(ctx, label, runSummary(run, label));
} else if (action === "plan") {
await workflowPlanReader(
ctx,
label,
await readVerifiedPlanArtifact(run.planArtifact!),
);
} else if (action === "report") {
await workflowPlanReader(
ctx,
`${label} report`,
await readVerifiedReportArtifact(run.reportArtifact!),
);
} else if (action === "prototypes") {
await browsePrototypeArtifacts(ctx, run);
} else if (action === "transcripts") {
await browseAssignmentTranscripts(ctx, run);
} else if (action === "worktree") {
await workflowPlanReader(ctx, "worktree", run.workspace.root!);
} else if (action === "decide") {
const decision = await gateDecision(ctx, run);
if (decision === "abort") {
const engine = await runtime.engineForContext(ctx);
await engine.execute({
action: "abort",
runId: run.runId,
expectedRevision: run.revision,
idempotencyKey: `dashboard-abort-${run.runId}-${run.revision}-${randomUUID()}`,
});
} else if (decision) {
const result = await decide(pi, runtime, ctx, run, decision);
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 {
@@ -523,12 +471,13 @@ export async function openWorkflowDashboard(
const sessionId = ctx.sessionManager.getSessionId();
const runs = (await runtime.store.listRuns())
.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)) {
ctx.ui.notify(
`Workflow Run ${initialRunId} was not found for this session.`,
"error",
);
ctx.ui.notify(`Workflow Run ${initialRunId} was not found for this session.`, "error");
return {};
}
const labels = deriveRunLabels(runs);
@@ -537,34 +486,33 @@ export async function openWorkflowDashboard(
const omitted = runs.length - visibleRuns.length;
ctx.ui.notify(
runs.length > 0
? `${visibleRuns.map((run, index) => runSummary(run, labels[index]!)).join("\n\n")}${
omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""
}`
? `${visibleRuns.map((run, index) => runSummary(run, labels[index]!)).join("\n\n")}${omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""}`
: "No Workflow Runs.",
"info",
);
return {};
}
const selected = await ctx.ui.custom<string | null>(
(tui, theme, _keybindings, done) => {
const dashboard = new FocusedWorkflowDashboard(
runs,
theme as DashboardTheme,
done,
initialRunId,
let selectedRunId = initialRunId;
while (true) {
if (!selectedRunId) {
selectedRunId = await workflowSelect(
ctx,
"pi-workflow",
runs.map((run, index) => ({
value: run.runId,
label: `${labels[index]} ${run.status.toUpperCase()} ${String(activeStage(run))}`,
description: selectedGate(run)?.kind,
})),
);
return {
render: (width) => dashboard.render(width),
invalidate: () => dashboard.invalidate(),
handleInput: (data) => {
dashboard.handleInput(data);
tui.requestRender();
},
};
},
);
if (!selected) return {};
const index = runs.findIndex((candidate) => candidate.runId === selected);
const run = runs[index];
return run ? actionSheet(pi, runtime, ctx, run, labels[index]!) : {};
if (!selectedRunId) return {};
}
const index = runs.findIndex((candidate) => candidate.runId === selectedRunId);
const run = runs[index];
if (!run) return {};
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;
}
}
+8
View File
@@ -154,12 +154,20 @@ export interface RunLease {
worktreeIdentity?: string;
}
export interface SourceChangeEvidence {
path: string;
status: string;
}
export interface WorkflowWorkspace {
state: "not-required" | "creating" | "ready" | "failed";
sourceRoot: string;
root?: string;
branch?: string;
baseHead?: string;
sourceChanges?: SourceChangeEvidence[];
sourceChangesOmitted?: number;
dirtySourceAllowed?: boolean;
error?: string;
}
+718 -17
View File
@@ -29,6 +29,7 @@ import {
type HostCommandExecutor,
type StartWorkflowCommand,
type WorkflowCommand,
type WorkflowView,
type WorkspaceManager,
} from "./engine.ts";
import { loadAgentProfiles } from "./profiles.ts";
@@ -113,10 +114,12 @@ class FakeWorkspace implements WorkspaceManager {
branchExists = false;
createError?: Error;
reconcileState: WorkflowRun["workspace"]["state"] = "ready";
beforePrepare?: () => Promise<void>;
beforeCreate?: (plan: WorkspacePlan) => Promise<void>;
async prepare(options: PrepareWorkspaceOptions): Promise<WorkspacePlan> {
this.prepareCalls++;
await this.beforePrepare?.();
return {
sourceRoot: options.sourceRoot,
worktreeRoot: path.join(options.sourceRoot, options.configuredRoot),
@@ -128,6 +131,9 @@ class FakeWorkspace implements WorkspaceManager {
branch: `feature/${options.featureName}`,
baseHead: "abc123",
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 () => {
await withTempDir(async (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 () => {
await withTempDir(async (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", {
now: () => 100_000,
leaseStaleMs: 1,
processId: 200,
isProcessAlive: () => false,
}).recover();
@@ -1602,7 +2142,7 @@ test("Recovery retry reruns failed verification after the remediation limit", as
hostCommands,
effectiveConfig,
processId: 200,
isProcessAlive: () => false,
isProcessAlive: (processId) => processId === 100,
});
assert.deepEqual(await transientRecovery.recover(), []);
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 () => {
await withTempDir(async (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.createCalls, 1);
assert.equal(setup.executor.requests.length, 1);
await assert.rejects(
engine.execute({ ...command, allowDirtySource: true }),
/another start command/,
);
const decide = {
action: "decide" as const,
@@ -2508,7 +3160,6 @@ test("live workspace creation cannot be stolen after its heartbeat ages", async
now: () => now,
createId,
processId,
leaseStaleMs: 10,
isProcessAlive: () => true,
});
const owner = makeEngine(
@@ -2823,7 +3474,6 @@ test("active assignment leases heartbeat and block concurrent recovery", async (
executor: blocking,
now: () => now,
processId: 100,
leaseStaleMs: 10,
assignmentHeartbeatMs: 2,
});
const start = engine.execute(startCommand());
@@ -2843,7 +3493,6 @@ test("active assignment leases heartbeat and block concurrent recovery", async (
.engine("session-2", {
now: () => now,
processId: 100,
leaseStaleMs: 10,
isProcessAlive: () => false,
})
.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) => {
const setup = await fixture(directory);
const blocking = new BlockingExecutor(discoveryWithQuestions());
@@ -2864,27 +3513,21 @@ test("a stale heartbeat fences a live read-only assignment during recovery", asy
executor: blocking,
now: () => now,
processId: 100,
leaseStaleMs: 10,
assignmentHeartbeatMs: 1_000,
})
.execute(startCommand());
const startOutcome = start.then(
() => undefined,
(error: unknown) => error,
);
await blocking.started;
now = 100_000;
const recovery = setup.engine("session-2", {
now: () => now,
processId: 200,
leaseStaleMs: 10,
isProcessAlive: () => true,
});
const [recovered] = await recovery.recover();
assert.equal(recovered?.openGate?.kind, "recovery");
assert.equal(recovered?.assignments[0]?.status, "interrupted");
assert.deepEqual(await recovery.recover(), []);
const [running] = await setup.store.listRuns();
assert.equal(running?.assignments[0]?.status, "running");
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", {
now: () => 100_000,
processId: 200,
leaseStaleMs: 10,
isProcessAlive: () => true,
});
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 () => {
await withTempDir(async (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 MAX_FEATURE_NAME_LENGTH = 80;
const DEFAULT_LEASE_STALE_MS = 30_000;
const DEFAULT_ASSIGNMENT_HEARTBEAT_MS = 10_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>();
interface ActiveAssignmentExecution {
runId: string;
@@ -59,6 +59,7 @@ const ACTIVE_STARTS = new Map<
string,
{
fingerprint: string;
controller: AbortController;
operation: Promise<WorkflowView>;
published: Promise<WorkflowView>;
}
@@ -78,6 +79,7 @@ export interface StartWorkflowCommand {
idempotencyKey: string;
concurrency?: number;
background?: boolean;
allowDirtySource?: boolean;
script?: FeatureScriptInput;
}
@@ -372,10 +374,10 @@ export interface WorkflowEngineOptions {
now?: () => number;
createId?: () => string;
processId?: number;
leaseStaleMs?: number;
isProcessAlive?: (processId: number) => boolean;
assignmentHeartbeatMs?: number;
assignmentAbortTimeoutMs?: number;
assignmentExecutionTimeoutMs?: number;
afterAssignmentPersist?: (
assignment: WorkflowAssignment,
run: WorkflowRun,
@@ -613,7 +615,7 @@ function validateWorkflowCommand(
assertKeys(
value,
["action", "request", "featureName", "idempotencyKey"],
["background", "concurrency", "script"],
["allowDirtySource", "background", "concurrency", "script"],
);
nonEmptyString(value.request, "request");
nonEmptyString(value.featureName, "featureName");
@@ -632,6 +634,12 @@ function validateWorkflowCommand(
) {
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);
return;
}
@@ -1243,22 +1251,76 @@ export class WorkflowEngine {
private readonly now: () => number;
private readonly createId: () => string;
private readonly processId: number;
private readonly leaseStaleMs: number;
private readonly isProcessAlive: (processId: number) => boolean;
private readonly assignmentHeartbeatMs: number;
private readonly assignmentAbortTimeoutMs: number;
private readonly assignmentExecutionTimeoutMs: number;
constructor(options: WorkflowEngineOptions) {
this.options = options;
this.now = options.now ?? Date.now;
this.createId = options.createId ?? randomUUID;
this.processId = options.processId ?? process.pid;
this.leaseStaleMs = options.leaseStaleMs ?? DEFAULT_LEASE_STALE_MS;
this.isProcessAlive = options.isProcessAlive ?? processAlive;
this.assignmentHeartbeatMs =
options.assignmentHeartbeatMs ?? DEFAULT_ASSIGNMENT_HEARTBEAT_MS;
this.assignmentAbortTimeoutMs =
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> {
@@ -1317,9 +1379,10 @@ export class WorkflowEngine {
}
if (
run.implementationContext?.status === "normal-compact-running" &&
this.leaseIsStale(run)
this.ownershipIsRecoverable(run)
) {
run = await this.transition(run, (next) => {
this.rotateOwnership(next);
const context = next.implementationContext;
if (!context) throw new Error("Implementation Context state is missing");
context.status = "failed";
@@ -1335,22 +1398,21 @@ export class WorkflowEngine {
const running = run.assignments.filter(
(assignment) =>
assignment.status === "running" ||
(assignment.status === "queued" && Boolean(assignment.executionToken)),
((assignment.status === "queued" || assignment.status === "interrupted") &&
Boolean(assignment.executionToken)),
);
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 (!this.leaseIsStale(run)) continue;
if (!this.ownershipIsRecoverable(run)) continue;
run = await this.transition(run, (next) => {
next.lease = {
processId: this.processId,
activeParentSessionId: this.options.parentSessionId,
heartbeatAt: this.now(),
worktreeIdentity: next.workspace.root,
};
next.parentSessionIds = [
...new Set([...next.parentSessionIds, this.options.parentSessionId]),
];
this.rotateOwnership(next);
});
if (running.length === 0) {
const claimedRevision = run.revision;
@@ -1374,15 +1436,7 @@ export class WorkflowEngine {
for (const assignment of next.assignments) {
if (interruptedIds.has(assignment.id)) assignment.status = "interrupted";
}
next.lease = {
processId: this.processId,
activeParentSessionId: this.options.parentSessionId,
heartbeatAt: this.now(),
worktreeIdentity: next.workspace.root,
};
next.parentSessionIds = [
...new Set([...next.parentSessionIds, this.options.parentSessionId]),
];
this.rotateOwnership(next);
next.status = "waiting";
if (!activeGate(next)) {
const gate: WorkflowGate = {
@@ -1428,11 +1482,13 @@ export class WorkflowEngine {
resolvePublished = resolve;
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 published.catch(() => {});
ACTIVE_STARTS.set(activeKey, {
fingerprint: commandFingerprint,
controller,
operation,
published,
});
@@ -1467,11 +1523,25 @@ export class WorkflowEngine {
}
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, {
fingerprint: commandFingerprint,
controller,
operation,
published: operation,
published,
});
void operation
.finally(() => {
@@ -1483,33 +1553,47 @@ export class WorkflowEngine {
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) {
throw new Error("Analysis scripts require the restricted script runtime");
}
const commandFingerprint = fingerprint(command);
const existing = await this.options.store.getStart(command.idempotencyKey);
this.throwIfStartCancelled(cancellationSignal);
if (existing) {
this.validateExistingStart(existing, commandFingerprint);
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();
this.throwIfStartCancelled(cancellationSignal);
const reservation = await this.options.store.reserveStart(
command.idempotencyKey,
commandFingerprint,
runId,
);
this.throwIfStartCancelled(cancellationSignal);
this.validateExistingStart(reservation.record, commandFingerprint);
let run: WorkflowRun;
try {
run = await this.options.store.loadRun(reservation.record.runId);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
this.throwIfStartCancelled(cancellationSignal);
run = this.initialAnalysisRun(reservation.record.runId, command);
this.throwIfStartCancelled(cancellationSignal);
run = (await this.options.store.createRun(run)).run;
}
onPublished?.(view(run));
this.throwIfStartCancelled(cancellationSignal);
if (run.status !== "completed" && run.status !== "failed") {
run = await this.runWorkflowScript(run);
}
@@ -1520,11 +1604,15 @@ export class WorkflowEngine {
private async startOwned(
command: StartWorkflowCommand,
cancellationSignal: AbortSignal,
onPublished?: (view: WorkflowView) => void,
) {
this.throwIfStartCancelled(cancellationSignal);
this.validateStart(command);
this.throwIfStartCancelled(cancellationSignal);
const commandFingerprint = fingerprint(command);
const existing = await this.options.store.getStart(command.idempotencyKey);
this.throwIfStartCancelled(cancellationSignal);
if (existing) {
this.validateExistingStart(existing, commandFingerprint);
if (existing.status === "completed") {
@@ -1546,6 +1634,7 @@ export class WorkflowEngine {
};
const preflightEligibility = this.preflightEligibility(initialFacts);
selectInitialDiscoveryProfile(this.options.profiles, initialFacts);
this.throwIfStartCancelled(cancellationSignal);
let persistedRun: WorkflowRun | undefined;
if (existing?.status === "pending") {
@@ -1556,6 +1645,7 @@ export class WorkflowEngine {
}
}
let runId = existing?.runId ?? this.createId();
this.throwIfStartCancelled(cancellationSignal);
let workspacePlan = persistedRun
? this.workspacePlanForRun(persistedRun)
: await this.options.workspace.prepare({
@@ -1563,11 +1653,14 @@ export class WorkflowEngine {
configuredRoot: this.options.effectiveConfig.worktrees.root,
featureName: command.featureName,
runId,
allowDirtySource: command.allowDirtySource,
});
this.throwIfStartCancelled(cancellationSignal);
if (workspacePlan.branchExists) {
const owner = await this.options.store.findRunByBranch(
workspacePlan.branch,
);
this.throwIfStartCancelled(cancellationSignal);
const resumable =
existing?.status === "pending" && owner?.runId === runId;
const uncertainPending = existing?.status === "pending" && !owner;
@@ -1583,11 +1676,13 @@ export class WorkflowEngine {
}
}
this.throwIfStartCancelled(cancellationSignal);
const reservation = await this.options.store.reserveStart(
command.idempotencyKey,
commandFingerprint,
runId,
);
this.throwIfStartCancelled(cancellationSignal);
this.validateExistingStart(reservation.record, commandFingerprint);
if (reservation.record.runId !== runId) {
runId = reservation.record.runId;
@@ -1597,6 +1692,7 @@ export class WorkflowEngine {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
persistedRun = undefined;
}
this.throwIfStartCancelled(cancellationSignal);
workspacePlan = persistedRun
? this.workspacePlanForRun(persistedRun)
: await this.options.workspace.prepare({
@@ -1604,28 +1700,34 @@ export class WorkflowEngine {
configuredRoot: this.options.effectiveConfig.worktrees.root,
featureName: command.featureName,
runId,
allowDirtySource: command.allowDirtySource,
});
this.throwIfStartCancelled(cancellationSignal);
}
{
this.throwIfStartCancelled(cancellationSignal);
let run: WorkflowRun;
let persistedBeforeThisAttempt = true;
try {
run = persistedRun ?? (await this.options.store.loadRun(runId));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
this.throwIfStartCancelled(cancellationSignal);
run = this.initialRun(
runId,
command,
workspacePlan,
preflightEligibility,
);
this.throwIfStartCancelled(cancellationSignal);
const publication = await this.options.store.createRun(run);
persistedBeforeThisAttempt = !publication.created;
run = publication.run;
}
onPublished?.(view(run));
this.throwIfStartCancelled(cancellationSignal);
if (persistedBeforeThisAttempt) {
const claimed = await this.claimPersistedStartRun(run);
@@ -1634,16 +1736,19 @@ export class WorkflowEngine {
}
if (run.workspace.state === "creating" && persistedBeforeThisAttempt) {
this.throwIfStartCancelled(cancellationSignal);
const reconciled = await this.options.workspace.reconcile(
run.workspace,
run.lease?.worktreeIdentity,
);
this.throwIfStartCancelled(cancellationSignal);
if (reconciled.state !== "creating") {
run = await this.persistWorkspaceOutcome(run, reconciled);
}
}
if (run.workspace.state === "creating") {
this.throwIfStartCancelled(cancellationSignal);
let workspace: WorkflowRun["workspace"];
try {
workspace = await this.options.workspace.create(workspacePlan);
@@ -1654,6 +1759,7 @@ export class WorkflowEngine {
error: error instanceof Error ? error.message : String(error),
};
}
this.throwIfStartCancelled(cancellationSignal);
run = await this.persistWorkspaceOutcome(run, workspace);
}
@@ -1661,12 +1767,14 @@ export class WorkflowEngine {
const discoverStatus = stage(run, "discover").status;
const designStatus = stage(run, "design").status;
if (discoverStatus === "pending" || discoverStatus === "running") {
run = await this.runDiscovery(run);
this.throwIfStartCancelled(cancellationSignal);
run = await this.runDiscovery(run, cancellationSignal);
} else if (
discoverStatus === "completed" &&
(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;
}
if (!this.leaseIsStale(run)) return undefined;
if (!this.ownershipIsRecoverable(run)) return undefined;
try {
return await this.transition(run, (next) => {
next.lease = {
processId: this.processId,
activeParentSessionId: this.options.parentSessionId,
heartbeatAt: this.now(),
worktreeIdentity: next.workspace.root,
};
next.parentSessionIds = [
...new Set([...next.parentSessionIds, this.options.parentSessionId]),
];
this.rotateOwnership(next);
});
} catch (error) {
if (
@@ -1787,6 +1887,9 @@ export class WorkflowEngine {
branch: "",
baseHead: "",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
},
[],
);
@@ -1880,6 +1983,9 @@ export class WorkflowEngine {
root: workspacePlan.worktreePath,
branch: workspacePlan.branch,
baseHead: workspacePlan.baseHead,
sourceChanges: structuredClone(workspacePlan.sourceChanges),
sourceChangesOmitted: workspacePlan.sourceChangesOmitted,
dirtySourceAllowed: workspacePlan.dirtySourceAllowed,
},
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(
run.pinnedProfiles,
{ ...run.facts, stage: "discover" },
@@ -2350,6 +2459,8 @@ export class WorkflowEngine {
run,
"discover",
selected,
false,
cancellationSignal,
);
run = execution.run;
if (!execution.output) return run;
@@ -2389,7 +2500,9 @@ export class WorkflowEngine {
});
if (output.questions.length > 0) return run;
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) {
@@ -2493,7 +2606,11 @@ export class WorkflowEngine {
return planned;
}
private async runDesign(run: WorkflowRun, forceNewAssignment = false) {
private async runDesign(
run: WorkflowRun,
forceNewAssignment = false,
cancellationSignal?: AbortSignal,
) {
const selected = selectionEvidence(
run.pinnedProfiles,
{ ...run.facts, stage: "design" },
@@ -2504,6 +2621,7 @@ export class WorkflowEngine {
"design",
selected,
forceNewAssignment,
cancellationSignal,
);
run = execution.run;
if (!execution.output) return run;
@@ -2606,6 +2724,7 @@ export class WorkflowEngine {
role: "discover" | "design",
selected: ReturnType<typeof selectionEvidence>,
forceNewAssignment = false,
cancellationSignal?: AbortSignal,
) {
const stageId = stage(run, role).id;
const existing = [...run.assignments]
@@ -2620,6 +2739,7 @@ export class WorkflowEngine {
output: existing.status === "succeeded" ? existing.result : undefined,
};
}
if (cancellationSignal) this.throwIfStartCancelled(cancellationSignal);
return this.runAssignment(run, role, selected);
}
@@ -2658,6 +2778,11 @@ export class WorkflowEngine {
const assignmentId = planned?.id ?? this.createId();
const executionToken = this.createId();
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 resolveResumeAfterAbort = () => {};
const activeExecution: ActiveAssignmentExecution = {
@@ -2740,6 +2865,17 @@ export class WorkflowEngine {
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();
try {
@@ -2748,7 +2884,7 @@ export class WorkflowEngine {
let transcriptArtifact: WorkflowAssignment["transcriptArtifact"];
try {
const evidence = await this.assignmentEvidence(run, role);
const executionResult = await this.options.executor.run({
executorExecution = this.options.executor.run({
runId: run.runId,
role,
request: planned
@@ -2771,6 +2907,31 @@ export class WorkflowEngine {
? { 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)
? executionResult.metadata
: undefined;
@@ -2799,13 +2960,30 @@ export class WorkflowEngine {
: undefined;
activeExecution.metadata = metadata;
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(
run.runId,
assignmentId,
executionToken,
);
if (abortController.signal.aborted) {
if (abortController.signal.aborted && !executionTimedOut) {
activeExecution.resolveSettled();
await activeExecution.resumeAfterAbort;
run = await this.options.store.loadRun(run.runId);
@@ -2816,7 +2994,7 @@ export class WorkflowEngine {
(item) => item.id === assignmentId,
);
if (assignment) {
assignment.status = "failed";
assignment.status = executionTimedOut ? "interrupted" : "failed";
assignment.error =
error instanceof Error ? error.message : String(error);
applyExecutionMetadata(assignment, metadata, transcriptArtifact);
@@ -2849,7 +3027,7 @@ export class WorkflowEngine {
} else {
next.status = "failed";
next.error = {
code: "assignment-failed",
code: executionTimedOut ? "assignment-timeout" : "assignment-failed",
message,
stageId: targetStage.id,
assignmentId,
@@ -2881,14 +3059,16 @@ export class WorkflowEngine {
);
return { run, output };
} finally {
if (executionTimeout) clearTimeout(executionTimeout);
abortController.abort();
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 (retainExecutionFence && executorExecution) {
void executorExecution.then(
() => cleanupExecutionFence(),
() => cleanupExecutionFence(),
);
} else {
await cleanupExecutionFence();
}
}
}
@@ -2998,6 +3178,23 @@ export class WorkflowEngine {
}
}
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 (
command.decision.kind === "recover" &&
command.decision.resolution === "retry" &&
@@ -4025,7 +4222,6 @@ export class WorkflowEngine {
command: RecoverContextIntentCommand,
) {
if (
command.reason !== "expired" &&
run.lease &&
run.lease.processId !== this.processId &&
this.isProcessAlive(run.lease.processId)
@@ -4101,6 +4297,38 @@ export class WorkflowEngine {
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) {
const duplicate = this.commandReceipt(run, command);
if (duplicate) {
@@ -4132,7 +4360,9 @@ export class WorkflowEngine {
(assignment) =>
!localAssignmentIds.has(assignment.id) &&
Boolean(assignment.executionToken) &&
(assignment.status === "queued" || assignment.status === "running"),
(assignment.status === "queued" ||
assignment.status === "running" ||
assignment.status === "interrupted"),
);
for (const assignment of externallyOwned) {
const lease = await this.options.store.loadAssignmentLease(
@@ -4429,6 +4659,9 @@ export class WorkflowEngine {
branch: workspace.branch,
baseHead: workspace.baseHead,
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" ||
activeGate(run)?.id !== command.gateId ||
!run.parentSessionIds.includes(this.options.parentSessionId) ||
!run.lease ||
this.isProcessAlive(run.lease.processId)
!this.ownershipIsRecoverable(run)
) {
return run;
}
const claimed = structuredClone(run);
claimed.lease = {
this.rotateOwnership(claimed);
return claimed;
}
private rotateOwnership(run: WorkflowRun) {
run.lease = {
processId: this.processId,
activeParentSessionId: this.options.parentSessionId,
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) {
@@ -4642,26 +4886,20 @@ export class WorkflowEngine {
lease.processId === this.processId
? ACTIVE_ASSIGNMENT_TOKENS.has(lease.token)
: this.isProcessAlive(lease.processId);
const heartbeatIsFresh =
this.now() - lease.heartbeatAt <= this.leaseStaleMs;
if (ownerIsRunning && (heartbeatIsFresh || assignment.mutates))
return true;
if (ownerIsRunning) return true;
}
return false;
}
private leaseIsStale(run: WorkflowRun) {
private ownershipIsRecoverable(run: WorkflowRun) {
if (!run.lease) return true;
if (
run.lease?.processId === this.processId &&
run.lease.processId === this.processId &&
run.lease.activeParentSessionId === this.options.parentSessionId
) {
return false;
}
return (
!run.lease ||
this.now() - run.lease.heartbeatAt > this.leaseStaleMs ||
!this.isProcessAlive(run.lease.processId)
);
return !this.isProcessAlive(run.lease.processId);
}
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}`,
baseHead: "abc123",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
},
async create(plan): Promise<WorkflowRun["workspace"]> {
+9 -1
View File
@@ -24,6 +24,10 @@ import {
import { runScriptSandbox } from "./script-runtime.ts";
import { WorkflowStore } from "./store.ts";
import { registerPiWorkflowTool } from "./tool.ts";
import {
refreshWorkflowProgress,
registerWorkflowProgress,
} from "./progress.ts";
import {
ApprovedHostCommandExecutor,
FilesystemPrototypeValidator,
@@ -111,7 +115,10 @@ export default function piWorkflowExtension(pi: ExtensionAPI) {
let runtime: ContextTransitionRuntime;
runtime = createWorkflowRuntime({
afterRunPersist: async (ctx) => {
await reconcileWorkflowDeliveries(pi, runtime, ctx);
await Promise.all([
reconcileWorkflowDeliveries(pi, runtime, ctx),
refreshWorkflowProgress(runtime, ctx),
]);
},
});
registerWorkflowContextTransitions(pi, runtime, (args, ctx) =>
@@ -120,4 +127,5 @@ export default function piWorkflowExtension(pi: ExtensionAPI) {
registerPiWorkflowTool(pi, runtime);
registerWorkflowRecovery(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,
workflowCommandFromTool,
} from "./tool.ts";
import { WorkspaceValidationError } from "./workspace.ts";
function workflowView(overrides: Partial<WorkflowView> = {}): WorkflowView {
return {
@@ -79,6 +80,25 @@ test("tool commands derive active tools for feature and analysis scripts", () =>
},
["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.deepEqual(
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 () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
+77 -28
View File
@@ -12,6 +12,8 @@ import {
} from "./context-transition.ts";
import type { WorkflowCommand, WorkflowView } from "./engine.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 WORKFLOW_MODES = ["feature", "feature-script", "analysis-script"] as const;
@@ -79,6 +81,7 @@ export const PiWorkflowParameters = Type.Object(
workflow: Type.Optional(WorkflowDefinition),
concurrency: Type.Optional(Type.Integer({ minimum: 1 })),
background: Type.Optional(Type.Boolean()),
allowDirtySource: Type.Optional(Type.Boolean()),
runId: Type.Optional(Type.String()),
gateId: Type.Optional(Type.String()),
expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })),
@@ -149,6 +152,9 @@ export function workflowCommandFromTool(
...(input.background !== undefined
? { background: input.background }
: {}),
...(input.allowDirtySource !== undefined
? { allowDirtySource: input.allowDirtySource }
: {}),
...(definition.mode === "feature-script"
? {
script: {
@@ -397,17 +403,7 @@ async function cancelToolWorkflow(
input: PiWorkflowInput,
toolCallId: string,
) {
let 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));
}
}
const runId = input.runId;
if (!runId) return;
const current = await runtime.store.loadRun(runId).catch(() => undefined);
if (
@@ -426,6 +422,25 @@ async function cancelToolWorkflow(
}).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(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
@@ -434,7 +449,7 @@ export function registerPiWorkflowTool(
name: "pi_workflow",
label: "Pi Workflow",
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:
"Plan and build features through persisted Gates, worktrees, verification, and independent review",
promptGuidelines: [
@@ -447,29 +462,63 @@ export function registerPiWorkflowTool(
const engine = await runtime.engineForContext(ctx);
const command = workflowCommandFromTool(params, pi.getActiveTools());
const executionSignal = signal ?? new AbortController().signal;
const operation =
command.action === "decide"
? executeHostDecision(pi, engine, command)
: engine.execute(command);
const cancellationError = () =>
executionSignal.reason instanceof Error
? executionSignal.reason
: 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) => {};
const cancellation = new Promise<never>((_resolve, reject) => {
rejectCancellation = reject;
});
let cancellationWork: Promise<void> | undefined;
const cancellationError = () =>
executionSignal.reason instanceof Error
? executionSignal.reason
: new Error("pi_workflow tool call was cancelled");
const onAbort = () => {
cancellationWork = cancelToolWorkflow(
runtime,
engine,
params,
toolCallId,
cancellationWork =
command.action === "start" || command.action === "start-analysis"
? engine.cancelStart(
command.idempotencyKey,
`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 });
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,
GitStateProbe,
GitWorkspaceManager,
INTERNAL_GIT_TIMEOUT_MS,
MutationScope,
WorkspaceSafetyError,
WorkspaceValidationError,
type CommandResult,
type CommandRunner,
type CommandRunOptions,
} from "./workspace.ts";
const execFileAsync = promisify(execFile);
@@ -80,16 +82,25 @@ async function withTempDir(run: (directory: string) => Promise<void>) {
}
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;
constructor(respond: (args: string[]) => CommandResult) {
this.respond = respond;
}
async run(cwd: string, executable: string, args: string[]) {
this.calls.push({ cwd, executable, args: [...args] });
async run(
cwd: string,
executable: string,
args: string[],
options?: CommandRunOptions,
) {
this.calls.push({ cwd, executable, args: [...args], options });
return this.respond(args);
}
}
@@ -100,14 +111,23 @@ function result(code = 0, stdout = "", stderr = ""): CommandResult {
function prepareResponses(
repository: string,
options: { dirty?: boolean; ignored?: boolean; branchExists?: boolean } = {},
options: {
dirty?: boolean;
unmerged?: boolean;
ignored?: boolean;
branchExists?: boolean;
} = {},
) {
return (args: string[]) => {
const command = args.join(" ");
if (command === "rev-parse --show-toplevel")
return result(0, `${repository}\n`);
if (command === "status --porcelain --untracked-files=normal") {
return result(0, options.dirty ? " M src/file.ts\n" : "");
if (command === "status --porcelain=v1 -z --untracked-files=all") {
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 ")) {
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 () => {
await withTempDir(async (directory) => {
await execFileAsync("git", ["init", "-q", directory]);
@@ -557,6 +630,11 @@ test("workspace preparation validates Git state and builds the exact feature bra
"abc123",
]);
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) => {
const dirty = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { dirty: true })),
@@ -614,9 +692,42 @@ test("dirty sources and unignored worktree roots fail before creation", async ()
featureName: "feature",
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 instanceof WorkspaceValidationError &&
error.code === "dirty-source",
error.code === "unmerged-source",
);
const unignored = new GitWorkspaceManager(
@@ -743,6 +854,9 @@ test("uncertain worktree-add results are reconciled before returning", async ()
branch: "feature/feature",
baseHead: "abc123",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`;
const partial = new GitWorkspaceManager(
@@ -786,6 +900,9 @@ test("retry adopts an exact orphan branch left by interrupted creation", async (
branch: "feature/feature",
baseHead: "abc123",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
assert.equal((await manager.create(plan)).state, "ready");
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 { constants } from "node:fs";
import {
@@ -13,7 +13,10 @@ import {
import * as path from "node:path";
import { promisify } from "node:util";
import { inflateSync } from "node:zlib";
import type { WorkflowWorkspace } from "./domain.ts";
import type {
SourceChangeEvidence,
WorkflowWorkspace,
} from "./domain.ts";
const execFileAsync = promisify(execFile);
@@ -470,7 +473,9 @@ export class GitStateProbe {
}
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) {
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"])],
]);
export const INTERNAL_GIT_TIMEOUT_MS = 30_000;
const DEFAULT_HOST_COMMAND_TIMEOUT_MS = 3 * 60 * 1_000;
export class ApprovedHostCommandExecutor {
@@ -745,22 +751,21 @@ export class GitDiffProvider {
async read(worktreeRoot: string, baseHead = "HEAD") {
const root = await realpath(path.resolve(worktreeRoot));
const diff = await this.runner.run(root, "git", [
"diff",
"--binary",
"--no-ext-diff",
baseHead,
"--",
]);
const diff = await this.runner.run(
root,
"git",
["diff", "--binary", "--no-ext-diff", baseHead, "--"],
{ timeoutMs: INTERNAL_GIT_TIMEOUT_MS },
);
if (diff.code !== 0) {
throw new Error(`Unable to read review diff: ${diff.stderr.trim()}`);
}
const untracked = await this.runner.run(root, "git", [
"ls-files",
"--others",
"--exclude-standard",
"-z",
]);
const untracked = await this.runner.run(
root,
"git",
["ls-files", "--others", "--exclude-standard", "-z"],
{ timeoutMs: INTERNAL_GIT_TIMEOUT_MS },
);
if (untracked.code !== 0) {
throw new Error(`Unable to list untracked review files: ${untracked.stderr.trim()}`);
}
@@ -796,6 +801,9 @@ export interface WorkspacePlan {
branch: string;
baseHead: string;
branchExists: boolean;
sourceChanges: SourceChangeEvidence[];
sourceChangesOmitted: number;
dirtySourceAllowed: boolean;
}
export interface PrepareWorkspaceOptions {
@@ -803,23 +811,57 @@ export interface PrepareWorkspaceOptions {
configuredRoot: string;
featureName: string;
runId: string;
allowDirtySource?: boolean;
}
export class WorkspaceValidationError extends Error {
readonly code:
| "not-repository"
| "dirty-source"
| "unmerged-source"
| "unignored-root"
| "existing-worktree-path"
| "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);
this.name = "WorkspaceValidationError";
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_POLL_MS = 10;
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) {
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;
}
signalProcessGroup(processId, "SIGTERM");
@@ -865,6 +927,14 @@ async function terminateProcessTree(processId: number) {
const COMMAND_OUTPUT_LIMIT_BYTES = 4 * 1024 * 1024;
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(
cwd: string,
executable: string,
@@ -880,7 +950,12 @@ export class ExecFileCommandRunner implements CommandRunner {
let timedOut = false;
let outputExceeded = false;
let termination: Promise<void> | undefined;
let terminationFinished = false;
let terminationFailure: string | undefined;
let timeout: NodeJS.Timeout | undefined;
let terminationDeadline: NodeJS.Timeout | undefined;
let terminationDeadlineReached = false;
let settled = false;
const child = spawn(executable, args, {
cwd,
detached: process.platform !== "win32",
@@ -889,7 +964,48 @@ export class ExecFileCommandRunner implements CommandRunner {
});
const terminate = () => {
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") => {
@@ -909,6 +1025,7 @@ export class ExecFileCommandRunner implements CommandRunner {
spawnError = error;
});
child.on("close", async (code) => {
if (settled) return;
if (timeout !== undefined) clearTimeout(timeout);
let terminationError: string | undefined;
try {
@@ -916,6 +1033,9 @@ export class ExecFileCommandRunner implements CommandRunner {
} catch (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 stderr = Buffer.concat(stderrChunks).toString("utf8");
if (timedOut) {
@@ -980,8 +1100,9 @@ export class GitWorkspaceManager {
const status = await this.git(sourceRoot, [
"status",
"--porcelain",
"--untracked-files=normal",
"--porcelain=v1",
"-z",
"--untracked-files=all",
]);
if (status.code !== 0) {
throw new WorkspaceValidationError(
@@ -989,10 +1110,19 @@ export class GitWorkspaceManager {
`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(
"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,
baseHead: head.stdout.trim(),
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,
branch: plan.branch,
baseHead: plan.baseHead,
sourceChanges: structuredClone(plan.sourceChanges),
sourceChangesOmitted: plan.sourceChangesOmitted,
dirtySourceAllowed: plan.dirtySourceAllowed,
error: `Unable to inspect partial branch creation: ${branch.stderr.trim()}`,
};
}
@@ -1098,6 +1235,9 @@ export class GitWorkspaceManager {
root: plan.worktreePath,
branch: plan.branch,
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}`,
};
}
@@ -1111,6 +1251,9 @@ export class GitWorkspaceManager {
root: plan.worktreePath,
branch: plan.branch,
baseHead: plan.baseHead,
sourceChanges: structuredClone(plan.sourceChanges),
sourceChangesOmitted: plan.sourceChangesOmitted,
dirtySourceAllowed: plan.dirtySourceAllowed,
error: `Git worktree creation returned ${result.code}: ${result.stderr.trim()}`,
});
}
@@ -1120,6 +1263,9 @@ export class GitWorkspaceManager {
root: plan.worktreePath,
branch: plan.branch,
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[]) {
return this.runner.run(cwd, "git", args);
return this.runner.run(cwd, "git", args, {
timeoutMs: INTERNAL_GIT_TIMEOUT_MS,
});
}
}