From 169016d8796fe32cd6c6da546dccbc132e5c3dd4 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Tue, 14 Apr 2026 09:08:07 -0700 Subject: [PATCH 1/3] feat: PRD --- night-shift-dash-prd.md | 480 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 night-shift-dash-prd.md diff --git a/night-shift-dash-prd.md b/night-shift-dash-prd.md new file mode 100644 index 0000000..3707b02 --- /dev/null +++ b/night-shift-dash-prd.md @@ -0,0 +1,480 @@ +# Night Shift Dash PRD + +## Summary + +`night-shift dash` will become the new human-first front door for Night Shift. +Instead of a monitor-only `--ui` mode attached to `start` and `resume`, Dash +will start a localhost web application that lets an operator initialize a repo, +plan work, inspect the task DAG before execution, start and resume runs, resolve +planning decisions, and audit the resulting artifacts in realtime. + +This is a functional product surface, not a visual design exercise. The v1 goal +is a solid interaction shape and a trustworthy execution/audit story. + +### Locked decisions + +- Dash is a full front door, not a read-only monitor. +- The UI stack is pure Gleam/Lustre by default. +- Realtime updates use Server-Sent Events (SSE), not polling-first or + websocket-first transport. +- The `dash` process owns execution of Night Shift workflows directly rather + than shelling out to separate CLI subprocesses or introducing a new daemon. +- v1 is localhost-only, single-user, and current-repo-only. +- `start --ui` and `resume --ui` are removed in favor of `night-shift dash`. +- Dash includes guided init, browser-based planning input, browser-based + decision resolution, DAG visualization, and audit/report/provenance surfaces. + +## Problem + +The current `--ui` flag is too narrow to justify being a first-class product +surface: + +- it is monitor-only +- it is attached to `start` and `resume` rather than being a front door +- it relies on polling rather than a proper realtime model +- it does not help with init, planning, decision resolution, or resume +- it does not present the DAG as a primary object before execution begins +- it does not make audit, reports, provenance, and PR review links feel like a + coherent operator experience + +Night Shift already persists enough structured state to support a richer GUI: +run journals, events, reports, provenance artifacts, review-driven repo-state +snapshots, task state, worktree/runtime metadata, and delivered PR information. +The missing piece is a proper UI/control-plane surface that is shaped around how +an operator actually uses the tool. + +## Goals + +- Make Night Shift operable primarily from the browser. +- Show repo state, run state, and DAG state before execution starts and while a + run is live. +- Make status, report, provenance, and raw artifacts easy to inspect and audit. +- Link directly to task PRs and review-relevant context. +- Preserve the repo-local, inspectable, durable character of Night Shift. +- Keep the first version functionally solid rather than visually polished. + +## Non-Goals + +- Multi-repo orchestration or a cross-repo control plane. +- Remote access, shared sessions, or multi-user coordination. +- Authentication/session hardening beyond the localhost-only model. +- A separate persistent daemon or background service shared with the CLI. +- Pixel-perfect design-system work, advanced theming, or a polished brand pass. + +## Primary User + +The primary user is the local operator running Night Shift inside one checked-out +repository on their own machine. This operator needs a reliable way to: + +- initialize and configure Night Shift for the repo +- create or refresh a plan +- understand the DAG before starting it +- watch execution progress live +- inspect repo-state drift and review-driven lineage +- answer blocked planning questions +- audit reports, provenance, logs, and PR handoff after the run + +## Product Shape + +Dash is a repo-local web app started by: + +```sh +night-shift dash +``` + +The command launches a localhost server bound to `127.0.0.1`, opens or prints a +browser URL, and hosts a single-repo workspace for the current repository. The +browser is the primary control surface for operator actions. Existing read-only +CLI surfaces such as `status`, `report`, and `provenance` remain available and +must continue to work against runs created or driven through Dash. + +The workspace should be organized around the following persistent areas: + +- Repo/workspace header +- Current run summary +- DAG graph plus synchronized task list/detail pane +- Repo-state/review context panel when available +- Live event timeline +- Status/report/provenance/artifact inspection area + +## Operator Journeys + +### 1. Uninitialized repo -> guided init + +When the repo has not been initialized, Dash should detect the missing +`.night-shift` control plane and present an onboarding flow instead of a broken +empty workspace. + +The init flow must support: + +- selecting the default provider +- selecting a model available to that provider +- choosing whether to generate `./.night-shift/worktree-setup.toml` +- confirming resulting configuration + +On successful init, Dash transitions directly into the normal workspace without +requiring the operator to restart the command. + +### 2. Notes planning via browser + +Dash should provide a planning surface with: + +- a notes textarea for pasted planning input +- an optional brief path field +- a plan action +- a `Plan From Reviews` action + +The operator can plan from pasted notes, reviews, or both. After planning, Dash +must render the resulting pending DAG before execution begins. + +### 3. Inspect pending DAG before start + +After planning, the operator must be able to inspect: + +- task dependency shape +- task kind and execution mode +- readiness/blocked/manual-attention state +- acceptance/demo details +- review-driven replacement lineage when present + +The DAG should be viewable both as a graph and as a structured list so that the +operator can understand both the topology and the exact details. + +### 4. Start run and watch live execution + +The operator starts execution from Dash. Once started, the UI must live-update: + +- run status +- task state transitions +- follow-up task insertion or DAG refreshes +- event timeline +- PR delivery updates, including direct PR links +- report/provenance availability as artifacts land + +The operator should not need to refresh manually to track an active run. + +### 5. Resolve blocked planning decisions in browser + +If a run blocks on manual-attention planning decisions, Dash must present those +decision requests as browser-native forms, including: + +- question +- rationale +- structured options when available +- recommended option when available +- freeform answer input when allowed + +Submitting decisions should trigger replanning inside Dash and update the +displayed DAG without dropping the current workspace context. + +### 6. Resume interrupted run + +If a run was interrupted, Dash should expose resume affordances and recovery +context. The operator should be able to inspect recovery signals and then resume +the run from the browser with live updates continuing over the same Dash +session. + +### 7. Audit completed, blocked, or failed runs + +After a run finishes or blocks, Dash must remain useful as an audit surface. It +should make it easy to inspect: + +- final status +- timeline of events +- rendered report +- provenance artifact +- raw logs/artifacts +- task-level worktree/runtime context +- delivered PRs and lineage +- review-driven repo-state drift when applicable + +Refreshing the page must not destroy access to this audit surface. + +## UX Requirements + +### Workspace shell + +Dash should present a single-repo workspace with enough context to orient the +operator immediately: + +- repo root +- initialization state +- active or latest run +- high-level next action +- connection status for the realtime stream + +### DAG visualization + +The minimum acceptable DAG visualization is a graph-plus-list hybrid: + +- a node-edge graph that shows dependency shape +- a synchronized task list/detail pane for exact inspection +- selecting a task in either surface highlights it in the other +- task state is visible in both surfaces + +The list/detail pane is not a fallback; it is a required first-class part of the +experience for accessibility, precision, and auditability. + +### Repo-state panel + +For review-driven runs, Dash must show repo-state context including: + +- captured open PR count +- captured actionable PR count +- snapshot capture time +- current open/actionable counts when live inspection is available +- drift status and drift details when known +- actionable PR list +- impacted PR list + +### Timeline + +Dash must present a first-class event timeline that updates live during active +runs and remains readable afterward. Timeline events should be filterable at +least by run-wide vs task-scoped events. + +### Report, status, provenance, and artifacts + +Dash should expose status, report, provenance, and raw artifacts as primary +surfaces, not as buried debug links. The operator should be able to inspect: + +- human-readable status summary +- rendered report +- provenance path and rendered provenance view +- raw artifact links/downloads for report, provenance, and logs + +### Task detail + +Per-task detail should include, when available: + +- title and task id +- description, acceptance criteria, and demo plan +- task kind and execution mode +- dependencies and dependents +- current state +- branch name +- PR number +- PR URL +- worktree path +- runtime context summary +- summary/output text +- replacement lineage context for review-driven work + +## Realtime Model + +Dash uses Server-Sent Events as the default realtime transport. + +### Realtime requirements + +- The browser connects to an SSE stream after loading the workspace. +- The SSE stream is the primary source of live updates during an active Dash + session. +- Browser commands use plain HTTP actions rather than long-lived bidirectional + sockets. +- Refreshing the page reconnects to the SSE stream and reloads the current + repo/run state from durable storage. +- Polling is not the primary model in v1. + +### Event categories + +The SSE contract must support structured events for: + +- repo/run bootstrap state +- task state transitions +- timeline events +- DAG refreshes after planning or replanning +- delivery updates, including PR URLs +- run completion, blockage, or failure + +The implementation does not need to freeze final endpoint paths in this PRD, +but it must commit to a structured command API plus a structured SSE event +model. + +## Architecture + +### Topology + +- `night-shift dash` starts a localhost web server bound to `127.0.0.1`. +- The Dash process owns command execution for init, plan, resolve, start, and + resume. +- The web app is implemented with Lustre in a Gleam-native stack. +- Existing journal and artifact persistence remain the source of truth. +- Live session state augments persisted run state for realtime rendering but + does not replace the journal as the durable record. + +### Reuse strategy + +The implementation should bias toward reusing the existing Night Shift domain +and usecase layers rather than bypassing them: + +- configuration and repo initialization logic +- planning and replanning flows +- resolve flow and decision model +- start and resume orchestration +- status/report/provenance rendering +- journal and artifact persistence +- repo-state and review-lineage projections + +The current minimal dashboard/server may be evolved or replaced, but the new +Dash architecture should preserve existing durable state and existing business +rules wherever possible. + +### Execution ownership + +Dash owns execution directly inside its process. This means: + +- browser actions do not shell out to `night-shift` subprocesses +- CLI output parsing is not the integration strategy +- a new daemon/control-plane service is not introduced for v1 + +This keeps the architecture closer to a pure application runtime and avoids the +usual class of bugs produced by treating stringly subprocess output as an API. + +## Public Interface Changes + +### CLI changes + +Add: + +- `night-shift dash` + +Remove: + +- `night-shift start --ui` +- `night-shift resume --ui` + +Keep: + +- `night-shift init` +- `night-shift plan` +- `night-shift resolve` +- `night-shift start` +- `night-shift resume` +- `night-shift status` +- `night-shift report` +- `night-shift provenance` +- other existing read/repair flows + +The CLI remains a valid automation and escape-hatch surface, but Dash becomes +the intended human-first GUI entrypoint. + +### Browser actions + +Dash must expose browser-driven actions for: + +- init +- plan with notes textarea and optional doc path +- plan from reviews +- resolve decisions +- start +- resume + +### API contract shape + +The implementation must define: + +- a structured command API for browser actions +- a structured SSE stream for live updates +- a state bootstrap endpoint or equivalent mechanism for initial page load + +Exact endpoint names are intentionally left open, but the contract must support +the workflows described in this document without scraping CLI text. + +## Data And State Requirements + +Dash must be able to render the following from current state plus live updates: + +- repo initialization status +- latest and active run identity +- run status and timestamps +- task DAG, task metadata, and task state +- decision requests and recorded decisions +- repo-state review snapshot and drift +- lineage for superseded PR work +- timeline events +- report and provenance locations/content +- PR delivery information, including URL +- task worktree and runtime context + +Where data already exists in persisted Night Shift artifacts, Dash should +consume it rather than inventing a parallel store. + +## Audit Requirements + +Dash is not only an execution view; it is an audit surface. + +v1 must include: + +- a first-class event timeline +- provenance visibility +- links to report, provenance, and raw log artifacts +- PR links from delivered tasks +- review-driven repo snapshot, actionable/impacted PR lists, and drift display +- durable post-refresh access to completed, blocked, and failed runs + +The audit story should privilege inspectability over clever animation. + +## Error Handling And Recovery + +Dash must handle the following cleanly: + +- local server startup failures +- inability to bind the localhost port +- malformed or failed command submissions +- SSE disconnect/reconnect +- browser refresh during active execution +- blocked runs +- failed runs +- interrupted runs that can be resumed +- runs that are no longer safe to resume + +The UI should always preserve or restore enough context that the operator can +understand what happened and what to do next. + +## Success Criteria + +Dash is successful in v1 if a Night Shift operator can do the complete common +workflow from the browser: + +1. initialize the repo if needed +2. plan work from notes or reviews +3. inspect the DAG before execution +4. start the run +5. watch it update live +6. resolve blocked planning decisions in the browser if necessary +7. resume if interrupted +8. audit the result, artifacts, and PRs afterward + +## Acceptance Scenarios + +The implementation must satisfy the following acceptance scenarios: + +1. Running `night-shift dash` on an uninitialized repo opens a guided init flow + and can complete initialization without restarting Dash. +2. Planning from pasted notes creates a pending run and renders the planned DAG + before execution begins. +3. Planning from reviews renders repo-state snapshot data and + actionable/impacted PR context. +4. Starting from Dash streams live task and timeline updates and eventually + shows delivered PR links. +5. A blocked run can be resolved fully from the browser and replans without + losing workspace context. +6. An interrupted run can be resumed from Dash with live updates. +7. Completed, blocked, and failed runs all remain auditable after refresh via + report, provenance, timeline, and artifact links. +8. The DAG view remains synchronized between graph and list/detail panes. +9. Dash binds only to localhost and operates only on the current repo. +10. CLI read commands still work against runs created and driven through Dash. + +## Assumptions And Defaults + +- Output artifact: `night-shift-dash-prd.md` at the repo root. +- v1 is local-only, single-user, and repo-local. +- Realtime means SSE-driven live updates, not websocket-first and not + polling-first. +- “Full front door” includes guided init and browser-based decision resolution, + not merely `start` and `resume`. +- Visual polish is explicitly secondary to information architecture, + interaction shape, and auditability. +- Lustre is the intended UI framework, and a pure Gleam/Lustre bias is a + strategic constraint rather than an implementation afterthought. From c41505aedf82c3fca4d0a3671cf741edfab9bfe6 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Tue, 14 Apr 2026 17:33:59 -0700 Subject: [PATCH 2/3] Add resolve-first recovery for blocked setup failures --- .codex/skills/qa-night-shift/SKILL.md | 11 +- docs/run-lifecycle.md | 29 +- src/night_shift/app.gleam | 32 +- src/night_shift/cli.gleam | 61 +- src/night_shift/codec/journal.gleam | 83 ++ src/night_shift/dashboard.gleam | 145 +++ src/night_shift/domain/confidence.gleam | 65 +- src/night_shift/domain/decisions.gleam | 23 +- src/night_shift/domain/report.gleam | 81 +- src/night_shift/domain/status.gleam | 77 +- src/night_shift/infra/run_store.gleam | 1 + .../orchestrator/execution_phase.gleam | 250 +++-- src/night_shift/types.gleam | 101 +- src/night_shift/usecase/doctor.gleam | 48 +- src/night_shift/usecase/resolve.gleam | 876 ++++++++++++++++-- src/night_shift/usecase/resume.gleam | 96 +- src/night_shift/usecase/support/runs.gleam | 274 +++++- src/night_shift_dashboard_server.erl | 20 + test/domain_pr_handoff_test.gleam | 1 + test/domain_pull_request_test.gleam | 1 + test/domain_report_test.gleam | 1 + test/night_shift_cli_config_test.gleam | 44 +- .../night_shift_execution_delivery_test.gleam | 140 ++- test/night_shift_lifecycle_test.gleam | 496 ++++++++++ ...ight_shift_persistence_provider_test.gleam | 127 +++ test/night_shift_resume_test.gleam | 109 +++ test/trust_surface_test.gleam | 206 ++++ 27 files changed, 3111 insertions(+), 287 deletions(-) diff --git a/.codex/skills/qa-night-shift/SKILL.md b/.codex/skills/qa-night-shift/SKILL.md index 1532e0c..3506c0c 100644 --- a/.codex/skills/qa-night-shift/SKILL.md +++ b/.codex/skills/qa-night-shift/SKILL.md @@ -130,7 +130,16 @@ Typical flow: 9. use `night-shift doctor` or `night-shift resume --explain` before any real resume attempt when the run was interrupted 10. use `night-shift resolve` or `night-shift resume` only if the run actually - requires it + requires it; if an interrupted implementation task already landed in manual + attention with a retained worktree, inspect the report and worktree first, + then use `night-shift resolve --task --inspect|--continue|--complete|--abandon` + instead of expecting `resume` alone to clear it +11. if a review-driven or setup-heavy run blocks before implementation during + environment preflight or task setup, treat that as a recoverable blocker: + inspect the failed gate/logs, confirm no PRs were updated yet, and use + `night-shift resolve` to inspect, continue with the one-shot waiver, or + abandon the run instead of assuming the user must edit + `worktree-setup.toml` For review-driven investigations, replace steps 3-4 with: diff --git a/docs/run-lifecycle.md b/docs/run-lifecycle.md index 3d6842d..fb6e8b0 100644 --- a/docs/run-lifecycle.md +++ b/docs/run-lifecycle.md @@ -39,18 +39,35 @@ run a pending plan that has newer planning inputs than its saved task graph. ## Blocked Runs and `resolve` -Night Shift blocks when the planner emits manual-attention tasks or unresolved -decision requests. `resolve` is the interactive command that records answers -for those decisions and immediately replans. +Night Shift blocks when the planner emits manual-attention tasks, unresolved +decision requests, or interrupted implementation work that was recovered into +manual attention. `resolve` is the command that discharges those blockers. Use it like this: ```sh night-shift resolve night-shift resolve --run run-123 +night-shift resolve --task task-123 --inspect +night-shift resolve --task task-123 --continue +night-shift resolve --task task-123 --complete +night-shift resolve --task task-123 --abandon ``` -If `resolve` clears the decisions successfully, the run returns to `pending` +Interactive `resolve` walks blockers in order: + +1. interrupted implementation recovery +2. unresolved planning decisions +3. planning-sync replans + +For interrupted implementation recovery, `resolve` can: + +- inspect the retained worktree and logs without mutating the run +- continue the task from the retained worktree +- mark the retained work complete and run verification +- abandon the partial work and replan + +If `resolve` clears the blockers successfully, the run returns to `pending` and the next action becomes `night-shift start`. ## Interrupted Runs and `resume` @@ -73,6 +90,10 @@ inspect the saved run, active lock, worktrees, logs, review drift, and interrupted task states, then classify each task as `safe_to_resume`, `resume_with_warning`, `manual_attention`, or `irrecoverable`. +`resume` is still the gate for stale `active` runs. Once `resume` has recovered +an interrupted implementation task into `manual_attention`, use `resolve` to +inspect, continue, complete, or abandon that retained work inside Night Shift. + ## Review-Driven Replanning Review feedback re-enters Night Shift through `plan --from-reviews`: diff --git a/src/night_shift/app.gleam b/src/night_shift/app.gleam index 1a56d8e..ed9be9a 100644 --- a/src/night_shift/app.gleam +++ b/src/night_shift/app.gleam @@ -6,7 +6,7 @@ import gleam/int import gleam/io import gleam/list -import gleam/option.{type Option} +import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import night_shift/agent_config @@ -136,7 +136,8 @@ fn run_initialized_command( types.Provenance(run, task_id, format) -> io.println(provenance(repo_root, run, task_id, format, config)) types.Doctor(run) -> io.println(doctor(repo_root, run, config)) - types.Resolve(run) -> io.println(resolve(repo_root, run, config)) + types.Resolve(run, task_id, action) -> + io.println(resolve(repo_root, run, task_id, action, config)) types.Resume(run, False, False) -> io.println(resume(repo_root, run, config)) types.Resume(run, True, False) -> resume_with_ui(repo_root, run, config) @@ -238,16 +239,35 @@ fn status( fn resolve( repo_root: String, selector: types.RunSelector, - _config: types.Config, + task_id: Option(String), + action: Option(types.ResolveAction), + config: types.Config, ) -> String { - case terminal_ui.can_prompt_interactively() { - False -> + case action, terminal_ui.can_prompt_interactively() { + Some(_), _ -> + case + resolve_usecase.execute( + repo_root, + selector, + task_id, + action, + config, + decision_prompt.collect_recorded_decisions, + ) + { + Ok(view) -> usecase_render.render_resolve(view) + Error(message) -> message + } + None, False -> "night-shift resolve requires an interactive terminal so it can capture decision answers." - True -> + None, True -> case resolve_usecase.execute( repo_root, selector, + task_id, + action, + config, decision_prompt.collect_recorded_decisions, ) { diff --git a/src/night_shift/cli.gleam b/src/night_shift/cli.gleam index f9cc45b..0e77764 100644 --- a/src/night_shift/cli.gleam +++ b/src/night_shift/cli.gleam @@ -21,6 +21,7 @@ pub fn usage() -> String { <> " provenance [--run |latest] [--task ] [--format ]\n" <> " doctor [--run |latest]\n" <> " resolve [--run |latest]\n" + <> " resolve [--run |latest] --task [--inspect|--continue|--complete|--abandon]\n" <> " resume [--run |latest] [--ui|--explain]\n" } @@ -52,7 +53,7 @@ pub fn parse(args: List(String)) -> Result(types.Command, String) { ["report", ..rest] -> parse_run_lookup(rest, types.Report) ["provenance", ..rest] -> parse_provenance(rest) ["doctor", ..rest] -> parse_run_lookup(rest, types.Doctor) - ["resolve", ..rest] -> parse_run_lookup(rest, types.Resolve) + ["resolve", ..rest] -> parse_resolve(rest) ["resume", ..rest] -> parse_resume(rest) ["review", ..] -> Error( @@ -263,6 +264,64 @@ fn parse_resume(args: List(String)) -> Result(types.Command, String) { parse_resume_flags(args, types.LatestRun, False, False) } +fn parse_resolve(args: List(String)) -> Result(types.Command, String) { + parse_resolve_flags(args, types.LatestRun, None, None) +} + +fn parse_resolve_flags( + args: List(String), + run: types.RunSelector, + task_id: Option(String), + action: Option(types.ResolveAction), +) -> Result(types.Command, String) { + case args { + [] -> + case task_id, action { + None, None -> Ok(types.Resolve(run, None, None)) + Some(_), Some(_) -> Ok(types.Resolve(run, task_id, action)) + Some(_), None -> + Error( + "`night-shift resolve --task ` requires exactly one of `--inspect`, `--continue`, `--complete`, or `--abandon`.", + ) + None, Some(_) -> + Error( + "`night-shift resolve` action flags require `--task `.", + ) + } + ["--run", "latest", ..rest] -> + parse_resolve_flags(rest, types.LatestRun, task_id, action) + ["--run", run_id, ..rest] -> + parse_resolve_flags(rest, types.RunId(run_id), task_id, action) + ["--task", next_task_id, ..rest] -> + parse_resolve_flags(rest, run, Some(next_task_id), action) + ["--inspect", ..rest] -> + parse_resolve_action(rest, run, task_id, action, types.ResolveInspect) + ["--continue", ..rest] -> + parse_resolve_action(rest, run, task_id, action, types.ResolveContinue) + ["--complete", ..rest] -> + parse_resolve_action(rest, run, task_id, action, types.ResolveComplete) + ["--abandon", ..rest] -> + parse_resolve_action(rest, run, task_id, action, types.ResolveAbandon) + [flag, ..] -> Error("Unsupported resolve flag: " <> flag) + } +} + +fn parse_resolve_action( + args: List(String), + run: types.RunSelector, + task_id: Option(String), + action: Option(types.ResolveAction), + next_action: types.ResolveAction, +) -> Result(types.Command, String) { + case action { + Some(_) -> + Error( + "`night-shift resolve --task ` accepts exactly one of `--inspect`, `--continue`, `--complete`, or `--abandon`.", + ) + None -> parse_resolve_flags(args, run, task_id, Some(next_action)) + } +} + fn parse_resume_flags( args: List(String), run: types.RunSelector, diff --git a/src/night_shift/codec/journal.gleam b/src/night_shift/codec/journal.gleam index 78e423d..f7c06c1 100644 --- a/src/night_shift/codec/journal.gleam +++ b/src/night_shift/codec/journal.gleam @@ -43,6 +43,10 @@ pub fn encode_run(run: types.RunRecord) -> String { #("status", json.string(types.run_status_to_string(run.status))), #("created_at", json.string(run.created_at)), #("updated_at", json.string(run.updated_at)), + #( + "recovery_blocker", + json.nullable(from: run.recovery_blocker, of: encode_recovery_blocker), + ), #("tasks", json.array(run.tasks, encode_task)), #( "handoff_states", @@ -235,6 +239,26 @@ fn encode_task_handoff_state(state: types.TaskHandoffState) -> json.Json { ]) } +fn encode_recovery_blocker(blocker: types.RecoveryBlocker) -> json.Json { + json.object([ + #("kind", json.string(types.recovery_blocker_kind_to_string(blocker.kind))), + #( + "phase", + json.string(types.recovery_blocker_phase_to_string(blocker.phase)), + ), + #("task_id", json.nullable(from: blocker.task_id, of: json.string)), + #("message", json.string(blocker.message)), + #("log_path", json.string(blocker.log_path)), + #("no_changes_produced", json.bool(blocker.no_changes_produced)), + #( + "disposition", + json.string(types.recovery_blocker_disposition_to_string( + blocker.disposition, + )), + ), + ]) +} + fn encode_decision_request(request: types.DecisionRequest) -> json.Json { json.object([ #("key", json.string(request.key)), @@ -303,6 +327,11 @@ fn run_decoder() -> decode.Decoder(types.RunRecord) { use status <- decode.field("status", run_status_decoder()) use created_at <- decode.field("created_at", decode.string) use updated_at <- decode.field("updated_at", decode.string) + use recovery_blocker <- decode.optional_field( + "recovery_blocker", + None, + decode.optional(recovery_blocker_decoder()), + ) use tasks <- decode.field("tasks", decode.list(task_decoder())) use handoff_states <- decode.optional_field( "handoff_states", @@ -347,6 +376,7 @@ fn run_decoder() -> decode.Decoder(types.RunRecord) { status: status, created_at: created_at, updated_at: updated_at, + recovery_blocker: recovery_blocker, tasks: tasks, handoff_states: case handoff_states { Some(entries) -> entries @@ -394,6 +424,7 @@ fn legacy_run_decoder() -> decode.Decoder(types.RunRecord) { status: status, created_at: created_at, updated_at: updated_at, + recovery_blocker: None, tasks: tasks, handoff_states: [], ), @@ -556,6 +587,58 @@ fn runtime_context_decoder() -> decode.Decoder(types.RuntimeContext) { )) } +fn recovery_blocker_decoder() -> decode.Decoder(types.RecoveryBlocker) { + use kind <- decode.field("kind", recovery_blocker_kind_decoder()) + use phase <- decode.field("phase", recovery_blocker_phase_decoder()) + use task_id <- decode.field("task_id", decode.optional(decode.string)) + use message <- decode.field("message", decode.string) + use log_path <- decode.field("log_path", decode.string) + use no_changes_produced <- decode.field("no_changes_produced", decode.bool) + use disposition <- decode.field( + "disposition", + recovery_blocker_disposition_decoder(), + ) + decode.success(types.RecoveryBlocker( + kind: kind, + phase: phase, + task_id: task_id, + message: message, + log_path: log_path, + no_changes_produced: no_changes_produced, + disposition: disposition, + )) +} + +fn recovery_blocker_kind_decoder() -> decode.Decoder(types.RecoveryBlockerKind) { + use raw <- decode.then(decode.string) + case types.recovery_blocker_kind_from_string(raw) { + Ok(kind) -> decode.success(kind) + Error(_) -> + decode.failure(types.EnvironmentPreflightBlocker, "RecoveryBlockerKind") + } +} + +fn recovery_blocker_phase_decoder() -> decode.Decoder( + types.RecoveryBlockerPhase, +) { + use raw <- decode.then(decode.string) + case types.recovery_blocker_phase_from_string(raw) { + Ok(phase) -> decode.success(phase) + Error(_) -> decode.failure(types.PreflightPhase, "RecoveryBlockerPhase") + } +} + +fn recovery_blocker_disposition_decoder() -> decode.Decoder( + types.RecoveryBlockerDisposition, +) { + use raw <- decode.then(decode.string) + case types.recovery_blocker_disposition_from_string(raw) { + Ok(disposition) -> decode.success(disposition) + Error(_) -> + decode.failure(types.RecoveryBlocking, "RecoveryBlockerDisposition") + } +} + fn runtime_port_decoder() -> decode.Decoder(types.RuntimePort) { use name <- decode.field("name", decode.string) use value <- decode.field("value", decode.int) diff --git a/src/night_shift/dashboard.gleam b/src/night_shift/dashboard.gleam index 03cbd2b..6840875 100644 --- a/src/night_shift/dashboard.gleam +++ b/src/night_shift/dashboard.gleam @@ -13,6 +13,7 @@ import night_shift/project import night_shift/repo_state_runtime import night_shift/report import night_shift/types +import night_shift/usecase/resolve as resolve_usecase /// A running local dashboard session. pub type Session { @@ -52,6 +53,37 @@ pub fn stop_session(session: Session) -> Nil @external(erlang, "night_shift_dashboard_server", "http_get") pub fn http_get(url: String) -> Result(String, String) +pub fn apply_recovery_action( + repo_root: String, + run_id: String, + action_name: String, +) -> Result(String, String) { + use loaded_config <- result.try(load_dashboard_config(repo_root)) + let action = case action_name { + "inspect" -> Ok(types.ResolveInspect) + "continue" -> Ok(types.ResolveContinue) + "abandon" -> Ok(types.ResolveAbandon) + _ -> Error("Unsupported dashboard recovery action: " <> action_name) + } + use resolved_action <- result.try(action) + use resolved <- result.try( + resolve_usecase.execute( + repo_root, + types.RunId(run_id), + None, + Some(resolved_action), + loaded_config, + fn(_, _) { + Error("Dashboard recovery does not collect planning decisions.") + }, + ), + ) + Ok(case resolved.summary { + Some(summary) -> summary <> "\nNext action: " <> resolved.next_action + None -> "Next action: " <> resolved.next_action + }) +} + /// Render the self-contained dashboard HTML shell. pub fn index_html(initial_run_id: String) -> String { let initial_run_json = json.string(initial_run_id) |> json.to_string @@ -81,6 +113,11 @@ pub fn index_html(initial_run_id: String) -> String { <> " .label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.7; }\n" <> " .value { margin-top: 4px; font-size: 0.98rem; word-break: break-word; }\n" <> " .task-row, .event-row { padding: 12px 14px; border-radius: 14px; background: #f9f3e9; }\n" + <> " .recovery-panel { display: grid; gap: 12px; }\n" + <> " .recovery-panel[hidden] { display: none; }\n" + <> " .recovery-actions { display: flex; flex-wrap: wrap; gap: 10px; }\n" + <> " button.action { padding: 10px 12px; border-radius: 12px; border: 1px solid rgba(79, 56, 35, 0.18); background: #fffaf2; cursor: pointer; }\n" + <> " button.action.primary { background: #2f5a4a; color: #fff9ef; border-color: #2f5a4a; }\n" <> " .task-header, .event-header { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; justify-content: space-between; }\n" <> " .state { display: inline-flex; align-items: center; padding: 4px 9px; border-radius: 999px; background: #e6dcc9; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; }\n" <> " pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-family: 'SFMono-Regular', 'Menlo', monospace; font-size: 0.88rem; }\n" @@ -114,6 +151,16 @@ pub fn index_html(initial_run_id: String) -> String { <> "

Report

\n" <> "
Loading report...
\n" <> " \n" + <> " \n" <> " \n" <> " \n" <> " \n" @@ -129,6 +176,12 @@ pub fn index_html(initial_run_id: String) -> String { <> " if (!response.ok) throw new Error('Request failed: ' + response.status);\n" <> " return response.json();\n" <> " }\n" + <> " async function requestText(path, options) {\n" + <> " const response = await fetch(path, Object.assign({ cache: 'no-store' }, options || {}));\n" + <> " const text = await response.text();\n" + <> " if (!response.ok) throw new Error(text || ('Request failed: ' + response.status));\n" + <> " return text;\n" + <> " }\n" <> " function renderMeta(run) {\n" <> " const fields = [\n" <> " ['Run ID', run.run_id], ['Status', run.status], ['Planning profile', run.planning_agent.profile_name], ['Planning provider', run.planning_agent.provider],\n" @@ -146,6 +199,24 @@ pub fn index_html(initial_run_id: String) -> String { <> " Array.from(document.querySelectorAll('#run-meta .value')).forEach((node, index) => { node.textContent = fields[index][1] || '—'; });\n" <> " document.getElementById('status-line').textContent = `Viewing run ${run.run_id} (${run.status}).`;\n" <> " }\n" + <> " function renderRecovery(run) {\n" + <> " const panel = document.getElementById('recovery-panel');\n" + <> " const summary = document.getElementById('recovery-summary');\n" + <> " const output = document.getElementById('recovery-output');\n" + <> " const blocker = run.recovery_blocker;\n" + <> " if (!blocker || blocker.disposition !== 'blocking') {\n" + <> " panel.hidden = true;\n" + <> " summary.textContent = 'No blocked-before-implementation recovery is active.';\n" + <> " output.textContent = 'Recovery guidance will appear here.';\n" + <> " return;\n" + <> " }\n" + <> " const replacements = (run.replacement_pr_numbers || []).length > 0\n" + <> " ? ` Intended replacements remain pending for PRs ${run.replacement_pr_numbers.join(', ')}.`\n" + <> " : ' No new commits or PR updates were produced yet.';\n" + <> " panel.hidden = false;\n" + <> " summary.textContent = `Blocked before implementation during ${blocker.phase} ${blocker.kind}. ${blocker.message}${replacements}`;\n" + <> " output.textContent = `Log: ${blocker.log_path}` + (blocker.task_id ? `\\nTask: ${blocker.task_id}` : '');\n" + <> " }\n" <> " function renderHistory(runs) {\n" <> " const container = document.getElementById('history');\n" <> " container.innerHTML = '';\n" @@ -184,6 +255,18 @@ pub fn index_html(initial_run_id: String) -> String { <> " if (refreshTimer) clearTimeout(refreshTimer);\n" <> " refreshTimer = setTimeout(() => loadRun(false), isActive ? 2000 : 10000);\n" <> " }\n" + <> " async function performRecovery(action) {\n" + <> " if (!selectedRunId) return;\n" + <> " const output = document.getElementById('recovery-output');\n" + <> " output.textContent = 'Working...';\n" + <> " try {\n" + <> " const text = await requestText(`/api/runs/${encodeURIComponent(selectedRunId)}/recovery/${action}`, { method: 'POST' });\n" + <> " output.textContent = text;\n" + <> " await loadRun(true);\n" + <> " } catch (error) {\n" + <> " output.textContent = error.message;\n" + <> " }\n" + <> " }\n" <> " async function loadRun(forceHistoryRefresh) {\n" <> " const runs = await requestJson('/api/runs');\n" <> " if (!selectedRunId && runs.length > 0) selectedRunId = runs[0].run_id;\n" @@ -202,11 +285,15 @@ pub fn index_html(initial_run_id: String) -> String { <> " renderHistory(runs);\n" <> " const payload = await requestJson('/api/runs/' + encodeURIComponent(selectedRunId));\n" <> " renderMeta(payload.run);\n" + <> " renderRecovery(payload.run);\n" <> " renderTasks(payload.run.tasks);\n" <> " renderEvents(payload.events);\n" <> " document.getElementById('report').textContent = payload.report;\n" <> " scheduleRefresh(!terminalStates.has(payload.run.status));\n" <> " }\n" + <> " document.getElementById('recovery-inspect').addEventListener('click', () => performRecovery('inspect'));\n" + <> " document.getElementById('recovery-continue').addEventListener('click', () => performRecovery('continue'));\n" + <> " document.getElementById('recovery-abandon').addEventListener('click', () => performRecovery('abandon'));\n" <> " loadRun(true).catch((error) => { document.getElementById('status-line').textContent = error.message; scheduleRefresh(false); });\n" <> " \n" <> "\n" @@ -282,6 +369,14 @@ fn run_detail_json( #("status", json.string(types.run_status_to_string(run.status))), #("created_at", json.string(run.created_at)), #("updated_at", json.string(run.updated_at)), + #( + "recovery_blocker", + json.nullable(from: run.recovery_blocker, of: recovery_blocker_json), + ), + #( + "replacement_pr_numbers", + json.array(replacement_pr_numbers(run.tasks), json.int), + ), #( "repo_state", json.nullable( @@ -305,6 +400,27 @@ fn task_json(task: types.Task) -> json.Json { ]) } +fn recovery_blocker_json(blocker: types.RecoveryBlocker) -> json.Json { + json.object([ + #("kind", json.string(types.recovery_blocker_kind_to_string(blocker.kind))), + #( + "phase", + json.string(types.recovery_blocker_phase_to_string(blocker.phase)), + ), + #("task_id", json.nullable(from: blocker.task_id, of: json.string)), + #("message", json.string(blocker.message)), + #("log_path", json.string(blocker.log_path)), + #("no_changes_produced", json.bool(blocker.no_changes_produced)), + #( + "disposition", + json.string(case blocker.disposition { + types.RecoveryBlocking -> "blocking" + types.RecoveryWaivedOnce -> "waived_once" + }), + ), + ]) +} + fn load_repo_state_view( run: types.RunRecord, ) -> Option(repo_state_runtime.RepoStateView) { @@ -377,3 +493,32 @@ fn event_json(event: types.RunEvent) -> json.Json { fn identity(value: json.Json) -> json.Json { value } + +fn replacement_pr_numbers(tasks: List(types.Task)) -> List(Int) { + unique_pr_numbers( + tasks + |> list.flat_map(fn(task) { task.superseded_pr_numbers }), + [], + ) +} + +fn unique_pr_numbers(values: List(Int), acc: List(Int)) -> List(Int) { + case values { + [] -> list.reverse(acc) + [value, ..rest] -> + case list.contains(acc, value) { + True -> unique_pr_numbers(rest, acc) + False -> unique_pr_numbers(rest, [value, ..acc]) + } + } +} + +fn load_dashboard_config(repo_root: String) -> Result(types.Config, String) { + case config.load(project.config_path(repo_root)) { + Ok(loaded_config) -> Ok(loaded_config) + Error(message) -> + Error( + "Unable to load Night Shift config for dashboard recovery: " <> message, + ) + } +} diff --git a/src/night_shift/domain/confidence.gleam b/src/night_shift/domain/confidence.gleam index e715ebd..c19ea2d 100644 --- a/src/night_shift/domain/confidence.gleam +++ b/src/night_shift/domain/confidence.gleam @@ -2,6 +2,7 @@ import gleam/int import gleam/list import gleam/option.{type Option, None, Some} import gleam/string +import night_shift/domain/decisions as decision_domain import night_shift/repo_state_runtime import night_shift/types import simplifile @@ -51,12 +52,22 @@ fn severe_reasons( run: types.RunRecord, events: List(types.RunEvent), ) -> List(String) { - let manual_attention_count = + let planning_manual_attention_count = run.tasks |> list.filter(fn(task) { types.task_requires_manual_attention(run.decisions, task) }) |> list.length + let implementation_blocker_count = + decision_domain.implementation_blocking_task_count(run) + let setup_blocker_count = case run.recovery_blocker { + Some(blocker) -> + case blocker.disposition == types.RecoveryBlocking { + True -> 1 + False -> 0 + } + _ -> 0 + } let failed_count = run.tasks |> list.filter(fn(task) { task.state == types.Failed }) @@ -71,12 +82,20 @@ fn severe_reasons( let run_failed = event_count(events, "run_failed") [ - latest_environment_preflight_failure(events) - |> option_reason("Environment bootstrap failed."), count_reason( - manual_attention_count, - "manual-attention task is still unresolved.", - "manual-attention tasks are still unresolved.", + setup_blocker_count, + "blocked-before-implementation setup recovery is still unresolved.", + "blocked-before-implementation setup recoveries are still unresolved.", + ), + count_reason( + planning_manual_attention_count, + "manual-attention planning task is still unresolved.", + "manual-attention planning tasks are still unresolved.", + ), + count_reason( + implementation_blocker_count, + "interrupted implementation task requires manual recovery.", + "interrupted implementation tasks require manual recovery.", ), count_reason( unresolved_decision_requests_count(run), @@ -177,7 +196,10 @@ fn positive_reasons( True -> Some("Delivered pull requests are recorded in the journal.") False -> None }, - case unresolved_decision_requests_count(run) == 0 { + case + unresolved_decision_requests_count(run) == 0 + && setup_blocker_count(run) == 0 + { True -> Some("No outstanding operator decisions remain.") False -> None }, @@ -207,22 +229,14 @@ fn event_count(events: List(types.RunEvent), kind: String) -> Int { |> list.length } -fn latest_environment_preflight_failure( - events: List(types.RunEvent), -) -> Option(String) { - latest_environment_preflight_failure_loop(list.reverse(events)) -} - -fn latest_environment_preflight_failure_loop( - events: List(types.RunEvent), -) -> Option(String) { - case events { - [] -> None - [event, ..rest] -> - case event.kind == "environment_preflight_failed" { - True -> Some(event.message) - False -> latest_environment_preflight_failure_loop(rest) +fn setup_blocker_count(run: types.RunRecord) -> Int { + case run.recovery_blocker { + Some(blocker) -> + case blocker.disposition == types.RecoveryBlocking { + True -> 1 + False -> 0 } + _ -> 0 } } @@ -233,13 +247,6 @@ fn directory_exists(path: String) -> Bool { } } -fn option_reason(value: Option(a), message: String) -> Option(String) { - case value { - Some(_) -> Some(message) - None -> None - } -} - fn count_reason(count: Int, singular: String, plural: String) -> Option(String) { case count { 0 -> None diff --git a/src/night_shift/domain/decisions.gleam b/src/night_shift/domain/decisions.gleam index 98718a0..16604e5 100644 --- a/src/night_shift/domain/decisions.gleam +++ b/src/night_shift/domain/decisions.gleam @@ -102,6 +102,21 @@ pub fn unresolved_manual_attention_tasks( }) } +pub fn implementation_blocking_tasks(run: types.RunRecord) -> List(types.Task) { + run.tasks + |> list.filter(fn(task) { + task.kind == types.ImplementationTask + && { task.state == types.Blocked || task.state == types.ManualAttention } + && !types.task_requires_manual_attention(run.decisions, task) + && task.worktree_path != "" + }) +} + +pub fn implementation_blocking_task_count(run: types.RunRecord) -> Int { + implementation_blocking_tasks(run) + |> list.length +} + pub fn outstanding_decision_count(run: types.RunRecord) -> Int { unresolved_manual_attention_tasks(run) |> list.map(types.unresolved_decision_requests(run.decisions, _)) @@ -113,13 +128,7 @@ pub fn blocked_task_count(run: types.RunRecord) -> Int { let unresolved_blockers = unresolved_manual_attention_tasks(run) |> list.length - let implementation_blockers = - run.tasks - |> list.filter(fn(task) { - task.kind == types.ImplementationTask - && { task.state == types.Blocked || task.state == types.ManualAttention } - }) - |> list.length + let implementation_blockers = implementation_blocking_task_count(run) case run.planning_dirty && unresolved_blockers == 0 diff --git a/src/night_shift/domain/report.gleam b/src/night_shift/domain/report.gleam index 326df09..1dd1b9c 100644 --- a/src/night_shift/domain/report.gleam +++ b/src/night_shift/domain/report.gleam @@ -39,7 +39,7 @@ pub fn render( <> types.confidence_posture_to_string(confidence_assessment.posture), "- Confidence reasons: " <> confidence.reasons_summary(confidence_assessment), - render_summary(run.decisions, run.planning_dirty, run.tasks, events), + render_summary(run, run.decisions, run.planning_dirty, run.tasks, events), render_planning_validation_summary(events), render_failure_summary(run, events), render_review_replacement_section(run, events), @@ -127,6 +127,7 @@ fn render_repo_snapshot_group(title: String, entries: List(String)) -> String { } fn render_summary( + run: types.RunRecord, decisions: List(types.RecordedDecision), planning_dirty: Bool, tasks: List(types.Task), @@ -150,22 +151,19 @@ fn render_summary( task.kind == types.ImplementationTask && task.state == types.Blocked }) |> list.length - let derived_blocked_count = case - planning_dirty && manual_attention_count == 0 && blocked_count == 0 - { - True -> 1 - False -> manual_attention_count + blocked_count + let derived_blocked_count = case active_recovery_blocker(run) { + Some(_) -> 1 + None -> + case planning_dirty && manual_attention_count == 0 && blocked_count == 0 { + True -> 1 + False -> manual_attention_count + blocked_count + } } let failed_count = tasks |> list.filter(fn(task) { task.state == types.Failed }) |> list.length - let run_level_failure_count = case - latest_environment_preflight_failure(events) - { - Some(_) -> 1 - None -> 0 - } + let run_level_failure_count = 0 let queued_count = tasks |> list.filter(fn(task) { @@ -465,9 +463,17 @@ fn render_failure_summary( run: types.RunRecord, events: List(types.RunEvent), ) -> String { - case latest_environment_preflight_failure(events) { - Some(message) -> - "\n## Failure\n- Type: environment bootstrap\n- Details: " <> message + case active_recovery_blocker(run) { + Some(blocker) -> + "\n## Blocked Before Implementation\n- Failed gate: " + <> types.recovery_blocker_phase_to_string(blocker.phase) + <> " " + <> types.recovery_blocker_kind_to_string(blocker.kind) + <> "\n- Details: " + <> blocker.message + <> "\n- Log: " + <> blocker.log_path + <> replacement_fragment(run) None -> case run.status, latest_run_failed_message(events) { types.RunFailed, Some(message) -> @@ -495,21 +501,40 @@ fn task_requires_manual_attention( || types.task_requires_manual_attention(decisions, task) } -fn latest_environment_preflight_failure( - events: List(types.RunEvent), -) -> Option(String) { - latest_environment_preflight_failure_loop(list.reverse(events)) +fn active_recovery_blocker( + run: types.RunRecord, +) -> Option(types.RecoveryBlocker) { + case run.recovery_blocker { + Some(blocker) -> + case blocker.disposition == types.RecoveryBlocking { + True -> Some(blocker) + False -> None + } + _ -> None + } } -fn latest_environment_preflight_failure_loop( - events: List(types.RunEvent), -) -> Option(String) { - case events { - [] -> None - [event, ..rest] -> - case event.kind == "environment_preflight_failed" { - True -> Some(event.message) - False -> latest_environment_preflight_failure_loop(rest) +fn replacement_fragment(run: types.RunRecord) -> String { + let pr_numbers = + run.tasks + |> list.flat_map(fn(task) { task.superseded_pr_numbers }) + |> unique_pr_numbers([]) + case pr_numbers { + [] -> "\n- No new commits or PR updates were produced yet." + _ -> + "\n- Intended replacement PRs remain pending: #" + <> string.join(pr_numbers |> list.map(int.to_string), with: ", #") + <> "\n- Existing reviewed PRs remain unchanged until replacement delivery succeeds." + } +} + +fn unique_pr_numbers(values: List(Int), acc: List(Int)) -> List(Int) { + case values { + [] -> list.reverse(acc) + [value, ..rest] -> + case list.contains(acc, value) { + True -> unique_pr_numbers(rest, acc) + False -> unique_pr_numbers(rest, [value, ..acc]) } } } diff --git a/src/night_shift/domain/status.gleam b/src/night_shift/domain/status.gleam index 5c9ab34..711afc2 100644 --- a/src/night_shift/domain/status.gleam +++ b/src/night_shift/domain/status.gleam @@ -1,6 +1,7 @@ import gleam/int import gleam/list import gleam/option.{type Option, None, Some} +import gleam/string import night_shift/domain/decisions import night_shift/types @@ -9,11 +10,19 @@ pub fn summary( events: List(types.RunEvent), next_action: String, ) -> String { - case latest_environment_preflight_failure(events) { - Some(message) -> - "Environment bootstrap blocker: yes\n" + case active_recovery_blocker(run) { + Some(blocker) -> + "Blocked before implementation: yes\n" + <> "Failed gate: " + <> types.recovery_blocker_phase_to_string(blocker.phase) + <> " " + <> types.recovery_blocker_kind_to_string(blocker.kind) + <> "\n" <> "Failure: " - <> message + <> blocker.message + <> "\nLog: " + <> blocker.log_path + <> replacement_fragment(run) <> "\n" <> "Ready implementation tasks: " <> int.to_string(ready_implementation_task_count(run.tasks)) @@ -21,7 +30,8 @@ pub fn summary( <> "Queued tasks: " <> int.to_string(queued_task_count(run.tasks)) <> "\n" - <> "Next action: fix the worktree environment, then rerun `night-shift start` or `night-shift reset`" + <> "\nNext action: " + <> next_action None -> case run.status { types.RunFailed -> @@ -142,6 +152,44 @@ pub fn summary( } } +fn active_recovery_blocker( + run: types.RunRecord, +) -> Option(types.RecoveryBlocker) { + case run.recovery_blocker { + Some(blocker) -> + case blocker.disposition == types.RecoveryBlocking { + True -> Some(blocker) + False -> None + } + _ -> None + } +} + +fn replacement_fragment(run: types.RunRecord) -> String { + let pr_numbers = + run.tasks + |> list.flat_map(fn(task) { task.superseded_pr_numbers }) + |> unique_pr_numbers([]) + case pr_numbers { + [] -> "\nNo new commits or PR updates were produced yet." + _ -> + "\nIntended replacement PRs remain pending: #" + <> string.join(pr_numbers |> list.map(int.to_string), with: ", #") + <> "\nExisting reviewed PRs remain unchanged until replacement delivery succeeds." + } +} + +fn unique_pr_numbers(values: List(Int), acc: List(Int)) -> List(Int) { + case values { + [] -> list.reverse(acc) + [value, ..rest] -> + case list.contains(acc, value) { + True -> unique_pr_numbers(rest, acc) + False -> unique_pr_numbers(rest, [value, ..acc]) + } + } +} + fn completed_task_count(tasks: List(types.Task)) -> Int { tasks |> list.filter(fn(task) { task.state == types.Completed }) @@ -199,25 +247,6 @@ fn bool_label(value: Bool) -> String { } } -fn latest_environment_preflight_failure( - events: List(types.RunEvent), -) -> Option(String) { - latest_environment_preflight_failure_loop(list.reverse(events)) -} - -fn latest_environment_preflight_failure_loop( - events: List(types.RunEvent), -) -> Option(String) { - case events { - [] -> None - [event, ..rest] -> - case event.kind == "environment_preflight_failed" { - True -> Some(event.message) - False -> latest_environment_preflight_failure_loop(rest) - } - } -} - fn latest_run_failed_message(events: List(types.RunEvent)) -> String { case latest_run_failed_message_loop(list.reverse(events)) { Some(message) -> message diff --git a/src/night_shift/infra/run_store.gleam b/src/night_shift/infra/run_store.gleam index 403033b..afd9319 100644 --- a/src/night_shift/infra/run_store.gleam +++ b/src/night_shift/infra/run_store.gleam @@ -105,6 +105,7 @@ pub fn create_pending_run_with_context( status: types.RunPending, created_at: timestamp, updated_at: timestamp, + recovery_blocker: None, tasks: [], handoff_states: [], ) diff --git a/src/night_shift/orchestrator/execution_phase.gleam b/src/night_shift/orchestrator/execution_phase.gleam index 8972b11..18afa89 100644 --- a/src/night_shift/orchestrator/execution_phase.gleam +++ b/src/night_shift/orchestrator/execution_phase.gleam @@ -49,41 +49,64 @@ pub fn prepare_run(run: types.RunRecord) -> Result(PreparedExecution, String) { |> result.map(fn(updated_run) { PreparedExecution(run: updated_run, proceed: True) }) - _ -> { - let preflight_log = - filepath.join( - refreshed_run.run_path, - "logs/environment-preflight.log", + _ -> prepare_run_after_preflight(refreshed_run) + } + } +} + +fn prepare_run_after_preflight( + run: types.RunRecord, +) -> Result(PreparedExecution, String) { + let preflight_log = + filepath.join(run.run_path, "logs/environment-preflight.log") + + case should_skip_environment_preflight(run) { + True -> + Ok(PreparedExecution( + run: maybe_consume_environment_preflight_bypass(run), + proceed: True, + )) + False -> + case + worktree_setup.preflight_environment( + run.repo_root, + run.environment_name, + project.worktree_setup_path(run.repo_root), + preflight_log, + ) + { + Ok(_) -> Ok(PreparedExecution(run: run, proceed: True)) + Error(message) -> { + let blocker = + types.RecoveryBlocker( + kind: types.EnvironmentPreflightBlocker, + phase: types.PreflightPhase, + task_id: None, + message: message, + log_path: preflight_log, + no_changes_produced: True, + disposition: types.RecoveryBlocking, ) - case - worktree_setup.preflight_environment( - refreshed_run.repo_root, - refreshed_run.environment_name, - project.worktree_setup_path(refreshed_run.repo_root), - preflight_log, + let event = + types.RunEvent( + kind: "environment_preflight_blocked", + at: system.timestamp(), + message: message, + task_id: None, ) - { - Ok(_) -> Ok(PreparedExecution(run: refreshed_run, proceed: True)) - Error(message) -> { - let event = - types.RunEvent( - kind: "environment_preflight_failed", - at: system.timestamp(), - message: message, - task_id: None, - ) - use failed_run <- result.try(journal.append_event( - refreshed_run, - event, - )) - use marked_run <- result.try(journal.mark_status( - failed_run, - types.RunFailed, - message, - )) - Ok(PreparedExecution(run: marked_run, proceed: False)) - } - } + let blocked_run = + types.RunRecord( + ..run, + status: types.RunBlocked, + recovery_blocker: Some(blocker), + ) + use evented_run <- result.try(journal.append_event(blocked_run, event)) + use marked_run <- result.try(journal.mark_status( + evented_run, + types.RunBlocked, + "Night Shift is blocked before implementation could begin.", + )) + Ok(PreparedExecution(run: marked_run, proceed: False)) } } } @@ -206,14 +229,14 @@ fn launch_batch_loop( launch_batch_loop(config, started_run, rest, [task_run, ..acc]) Error(message) -> { - use failed_run <- result.try(mark_task_with_event( + use blocked_run <- result.try(block_task_for_setup_failure( announced_run, running_task, - types.Failed, + bootstrap_phase, + env_log, message, - "task_failed", )) - launch_batch_loop(config, failed_run, rest, acc) + launch_batch_loop(config, blocked_run, rest, acc) } } } @@ -567,6 +590,11 @@ fn start_task_run( env_log: String, worktree_origin: provider.WorktreeOrigin, ) -> Result(#(types.RunRecord, provider.TaskRun), String) { + let skip_setup = should_skip_task_setup(run, task.id, bootstrap_phase) + let prepared_run = case skip_setup { + True -> consume_task_setup_bypass(run, task.id, bootstrap_phase) + False -> run + } use runtime_context <- result.try(require_runtime_context(task)) use _ <- result.try(runtime_identity.ensure_artifacts( runtime_context, @@ -574,27 +602,31 @@ fn start_task_run( worktree_path, branch_name, )) - use _ <- result.try(worktree_setup.prepare_worktree( - run.repo_root, - run.environment_name, - project.worktree_setup_path(run.repo_root), - worktree_path, - branch_name, - bootstrap_phase, - env_log, - Some(runtime_context), - )) + use _ <- result.try(case skip_setup { + True -> Ok(Nil) + False -> + worktree_setup.prepare_worktree( + prepared_run.repo_root, + prepared_run.environment_name, + project.worktree_setup_path(prepared_run.repo_root), + worktree_path, + branch_name, + bootstrap_phase, + env_log, + Some(runtime_context), + ) + }) use env_vars <- result.try(worktree_setup.env_vars_for( - run.repo_root, - run.environment_name, - project.worktree_setup_path(run.repo_root), + prepared_run.repo_root, + prepared_run.environment_name, + project.worktree_setup_path(prepared_run.repo_root), Some(runtime_context), )) use start_head <- result.try(git.head_commit(worktree_path, git_log)) use task_run <- result.try(provider.start_task( - run.execution_agent, - run.repo_root, - run.run_path, + prepared_run.execution_agent, + prepared_run.repo_root, + prepared_run.run_path, task, worktree_path, env_vars, @@ -603,7 +635,7 @@ fn start_task_run( base_ref, worktree_origin, )) - Ok(#(run, task_run)) + Ok(#(prepared_run, task_run)) } fn ensure_runtime_context( @@ -718,6 +750,116 @@ fn mark_task_with_event( ) } +fn maybe_consume_environment_preflight_bypass( + run: types.RunRecord, +) -> types.RunRecord { + case run.recovery_blocker { + Some(blocker) + if blocker.kind == types.EnvironmentPreflightBlocker + && blocker.phase == types.PreflightPhase + && blocker.disposition == types.RecoveryWaivedOnce + -> types.RunRecord(..run, recovery_blocker: None) + _ -> run + } +} + +fn should_skip_environment_preflight(run: types.RunRecord) -> Bool { + case run.recovery_blocker { + Some(blocker) -> + blocker.kind == types.EnvironmentPreflightBlocker + && blocker.phase == types.PreflightPhase + && blocker.disposition == types.RecoveryWaivedOnce + None -> False + } +} + +fn consume_task_setup_bypass( + run: types.RunRecord, + task_id: String, + bootstrap_phase: worktree_setup.BootstrapPhase, +) -> types.RunRecord { + let phase = recovery_phase_for_bootstrap(bootstrap_phase) + case run.recovery_blocker { + Some(blocker) -> + case + blocker.kind == types.TaskSetupBlocker + && blocker.task_id == Some(task_id) + && blocker.phase == phase + && blocker.disposition == types.RecoveryWaivedOnce + { + True -> types.RunRecord(..run, recovery_blocker: None) + False -> run + } + _ -> run + } +} + +fn should_skip_task_setup( + run: types.RunRecord, + task_id: String, + bootstrap_phase: worktree_setup.BootstrapPhase, +) -> Bool { + let phase = recovery_phase_for_bootstrap(bootstrap_phase) + case run.recovery_blocker { + Some(blocker) -> + blocker.kind == types.TaskSetupBlocker + && blocker.task_id == Some(task_id) + && blocker.phase == phase + && blocker.disposition == types.RecoveryWaivedOnce + None -> False + } +} + +fn recovery_phase_for_bootstrap( + bootstrap_phase: worktree_setup.BootstrapPhase, +) -> types.RecoveryBlockerPhase { + case bootstrap_phase { + worktree_setup_model.SetupPhase -> types.SetupPhase + worktree_setup_model.MaintenancePhase -> types.MaintenancePhase + } +} + +fn block_task_for_setup_failure( + run: types.RunRecord, + task: types.Task, + bootstrap_phase: worktree_setup.BootstrapPhase, + env_log: String, + message: String, +) -> Result(types.RunRecord, String) { + let blocker = + types.RecoveryBlocker( + kind: types.TaskSetupBlocker, + phase: recovery_phase_for_bootstrap(bootstrap_phase), + task_id: Some(task.id), + message: message, + log_path: env_log, + no_changes_produced: True, + disposition: types.RecoveryBlocking, + ) + let blocked_task = types.Task(..task, state: types.Blocked, summary: message) + let blocked_run = + types.RunRecord( + ..run, + status: types.RunBlocked, + recovery_blocker: Some(blocker), + tasks: task_graph.replace_task(run.tasks, blocked_task), + ) + use evented_run <- result.try(journal.append_event( + blocked_run, + types.RunEvent( + kind: "task_setup_blocked", + at: system.timestamp(), + message: message, + task_id: Some(task.id), + ), + )) + journal.mark_status( + evented_run, + types.RunBlocked, + "Night Shift is blocked before implementation could begin.", + ) +} + fn validate_follow_up_tasks( run: types.RunRecord, task_run: provider.TaskRun, diff --git a/src/night_shift/types.gleam b/src/night_shift/types.gleam index bf9a222..da38a03 100644 --- a/src/night_shift/types.gleam +++ b/src/night_shift/types.gleam @@ -452,6 +452,93 @@ pub type RunEvent { RunEvent(kind: String, at: String, message: String, task_id: Option(String)) } +pub type RecoveryBlockerKind { + EnvironmentPreflightBlocker + TaskSetupBlocker +} + +pub fn recovery_blocker_kind_to_string(kind: RecoveryBlockerKind) -> String { + case kind { + EnvironmentPreflightBlocker -> "environment_preflight" + TaskSetupBlocker -> "task_setup" + } +} + +pub fn recovery_blocker_kind_from_string( + value: String, +) -> Result(RecoveryBlockerKind, String) { + case value { + "environment_preflight" -> Ok(EnvironmentPreflightBlocker) + "task_setup" -> Ok(TaskSetupBlocker) + _ -> Error("Unsupported recovery blocker kind: " <> value) + } +} + +pub type RecoveryBlockerPhase { + PreflightPhase + SetupPhase + MaintenancePhase +} + +pub fn recovery_blocker_phase_to_string(phase: RecoveryBlockerPhase) -> String { + case phase { + PreflightPhase -> "preflight" + SetupPhase -> "setup" + MaintenancePhase -> "maintenance" + } +} + +pub fn recovery_blocker_phase_from_string( + value: String, +) -> Result(RecoveryBlockerPhase, String) { + case value { + "preflight" -> Ok(PreflightPhase) + "setup" -> Ok(SetupPhase) + "maintenance" -> Ok(MaintenancePhase) + _ -> Error("Unsupported recovery blocker phase: " <> value) + } +} + +pub type RecoveryBlockerDisposition { + RecoveryBlocking + RecoveryWaivedOnce +} + +pub fn recovery_blocker_disposition_to_string( + disposition: RecoveryBlockerDisposition, +) -> String { + case disposition { + RecoveryBlocking -> "blocking" + RecoveryWaivedOnce -> "waived_once" + } +} + +pub fn recovery_blocker_disposition_from_string( + value: String, +) -> Result(RecoveryBlockerDisposition, String) { + case value { + "blocking" -> Ok(RecoveryBlocking) + "waived_once" -> Ok(RecoveryWaivedOnce) + _ -> Error("Unsupported recovery blocker disposition: " <> value) + } +} + +pub type RecoveryBlocker { + RecoveryBlocker( + kind: RecoveryBlockerKind, + phase: RecoveryBlockerPhase, + task_id: Option(String), + message: String, + log_path: String, + no_changes_produced: Bool, + disposition: RecoveryBlockerDisposition, + ) +} + +pub fn recovery_blocker_is_active(blocker: RecoveryBlocker) -> Bool { + blocker.disposition == RecoveryBlocking +} + /// Persistent record for one Night Shift run. pub type RunRecord { RunRecord( @@ -475,6 +562,7 @@ pub type RunRecord { status: RunStatus, created_at: String, updated_at: String, + recovery_blocker: Option(RecoveryBlocker), tasks: List(Task), handoff_states: List(TaskHandoffState), ) @@ -704,8 +792,19 @@ pub type Command { format: ProvenanceFormat, ) Doctor(run: RunSelector) - Resolve(run: RunSelector) + Resolve( + run: RunSelector, + task_id: Option(String), + action: Option(ResolveAction), + ) Resume(run: RunSelector, ui_enabled: Bool, explain_only: Bool) Demo(ui_enabled: Bool) Help } + +pub type ResolveAction { + ResolveInspect + ResolveContinue + ResolveComplete + ResolveAbandon +} diff --git a/src/night_shift/usecase/doctor.gleam b/src/night_shift/usecase/doctor.gleam index ebbe363..83b8973 100644 --- a/src/night_shift/usecase/doctor.gleam +++ b/src/night_shift/usecase/doctor.gleam @@ -8,6 +8,7 @@ import night_shift/journal import night_shift/project import night_shift/repo_state_runtime import night_shift/types +import night_shift/usecase/support/runs import simplifile pub fn execute( @@ -22,7 +23,7 @@ pub fn execute( let assessments = run.tasks |> list.map(diagnose_task(repo_root, run.run_path, _)) let recommendation = - recommend_next_action(run.status, events, active_lock, assessments) + recommend_next_action(run, events, active_lock, assessments) Ok(render_doctor( run, @@ -272,16 +273,22 @@ fn diagnose_clean_running_task( } fn recommend_next_action( - status: types.RunStatus, - events: List(types.RunEvent), + run: types.RunRecord, + _events: List(types.RunEvent), active_lock: ActiveLockState, assessments: List(TaskAssessment), ) -> String { - case latest_environment_preflight_failure(events) { - Some(_) -> - "Fix the worktree environment first, then rerun `night-shift start` instead of resuming blindly." + case active_recovery_blocker(run) { + Some(blocker) -> + "Inspect the blocked-before-implementation setup gate first: " + <> types.recovery_blocker_phase_to_string(blocker.phase) + <> " " + <> types.recovery_blocker_kind_to_string(blocker.kind) + <> ". Review " + <> blocker.log_path + <> " and use `night-shift resolve` to inspect, continue, or abandon the run." None -> - case status { + case run.status { types.RunCompleted -> "This run is already completed; inspect the report and retained worktrees instead of resuming." _ -> @@ -292,8 +299,7 @@ fn recommend_next_action( case has_classification(assessments, types.RecoveryManualAttention) { - True -> - "Resolve the manual-attention tasks first; `resume` would not safely clear them." + True -> runs.recovery_recommendation_for_run(run) False -> case active_lock { ActiveLockMismatch(other_run_id) -> @@ -323,22 +329,16 @@ fn has_classification( list.any(assessments, fn(assessment) { assessment.classification == target }) } -fn latest_environment_preflight_failure( - events: List(types.RunEvent), -) -> Option(String) { - latest_environment_preflight_failure_loop(list.reverse(events)) -} - -fn latest_environment_preflight_failure_loop( - events: List(types.RunEvent), -) -> Option(String) { - case events { - [] -> None - [event, ..rest] -> - case event.kind == "environment_preflight_failed" { - True -> Some(event.message) - False -> latest_environment_preflight_failure_loop(rest) +fn active_recovery_blocker( + run: types.RunRecord, +) -> Option(types.RecoveryBlocker) { + case run.recovery_blocker { + Some(blocker) -> + case blocker.disposition == types.RecoveryBlocking { + True -> Some(blocker) + False -> None } + _ -> None } } diff --git a/src/night_shift/usecase/resolve.gleam b/src/night_shift/usecase/resolve.gleam index 7b8d46e..a5e9d99 100644 --- a/src/night_shift/usecase/resolve.gleam +++ b/src/night_shift/usecase/resolve.gleam @@ -1,99 +1,863 @@ -import gleam/option.{None, Some} +import filepath +import gleam/int +import gleam/io +import gleam/list +import gleam/option.{type Option, None, Some} import gleam/result +import gleam/string import night_shift/domain/decisions as decision_domain +import night_shift/domain/summary as domain_summary +import night_shift/domain/task_graph +import night_shift/git +import night_shift/infra/terminal_ui import night_shift/journal import night_shift/orchestrator +import night_shift/project +import night_shift/shell import night_shift/system import night_shift/types import night_shift/usecase/result as workflow import night_shift/usecase/support/runs +import night_shift/worktree_setup +import simplifile + +type InteractiveBlocker { + SetupBlocker(blocker: types.RecoveryBlocker) + ImplementationBlocker(task: types.Task) + DecisionBlocker(tasks: List(types.Task)) + PlanningSyncBlocker +} pub fn execute( repo_root: String, selector: types.RunSelector, + task_id: Option(String), + action: Option(types.ResolveAction), + config: types.Config, collect_decisions: fn(types.RunRecord, List(types.Task)) -> Result(#(List(types.RecordedDecision), List(types.RunEvent)), String), ) -> Result(workflow.ResolveResult, String) { use run <- result.try(runs.load_resolvable_run(repo_root, selector)) - resolve_loop(run, collect_decisions) + case task_id, action { + Some(requested_task_id), Some(requested_action) -> + resolve_action(run, requested_task_id, requested_action, config) + None, None -> resolve_interactively(run, config, collect_decisions) + None, Some(requested_action) -> + resolve_run_recovery_action(run, requested_action, config) + Some(_), None -> + Error( + "`night-shift resolve --task ` requires exactly one action flag.", + ) + } +} + +fn resolve_interactively( + run: types.RunRecord, + config: types.Config, + collect_decisions: fn(types.RunRecord, List(types.Task)) -> + Result(#(List(types.RecordedDecision), List(types.RunEvent)), String), +) -> Result(workflow.ResolveResult, String) { + case next_interactive_blocker(run) { + Some(SetupBlocker(blocker)) -> + prompt_for_setup_recovery(run, blocker, config, collect_decisions) + Some(ImplementationBlocker(task)) -> + prompt_for_implementation_recovery(run, task, config, collect_decisions) + Some(DecisionBlocker(tasks)) -> + collect_decision_blockers(run, tasks, config, collect_decisions) + Some(PlanningSyncBlocker) -> + continue_resolve_run(run, config, collect_decisions) + None -> + Ok(workflow.ResolveResult( + run: run, + warnings: [], + next_action: runs.next_action_for_run(run), + summary: Some( + "Run " + <> run.run_id + <> " has no remaining blockers for `night-shift resolve` to discharge.", + ), + )) + } } -fn resolve_loop( +fn next_interactive_blocker(run: types.RunRecord) -> Option(InteractiveBlocker) { + case runs.active_recovery_blocker(run) { + Some(blocker) -> Some(SetupBlocker(blocker)) + None -> + case decision_domain.implementation_blocking_tasks(run) { + [task, ..] -> Some(ImplementationBlocker(task)) + [] -> + case decision_domain.unresolved_manual_attention_tasks(run) { + [_, ..] as tasks -> Some(DecisionBlocker(tasks)) + [] -> + case run.planning_dirty { + True -> Some(PlanningSyncBlocker) + False -> None + } + } + } + } +} + +fn prompt_for_setup_recovery( run: types.RunRecord, + blocker: types.RecoveryBlocker, + config: types.Config, collect_decisions: fn(types.RunRecord, List(types.Task)) -> Result(#(List(types.RecordedDecision), List(types.RunEvent)), String), ) -> Result(workflow.ResolveResult, String) { - let blocked_tasks = decision_domain.unresolved_manual_attention_tasks(run) - - case blocked_tasks, run.planning_dirty { - [], True -> continue_resolve_run(run, collect_decisions) - [], False -> - Ok( - workflow.ResolveResult( - run: run, - warnings: [], - next_action: runs.next_action_for_run(run), - summary: case run.status { - types.RunPending -> None - _ -> - Some( - "Run " - <> run.run_id - <> " is blocked but has no unresolved decisions left to collect. Inspect " - <> run.report_path - <> " or rerun `night-shift plan --notes ...`.", - ) - }, + let inspection = render_setup_inspection(run, blocker) + io.println(inspection) + let selection = + terminal_ui.select_from_labels( + "Choose a recovery action:", + [ + "Inspect and stop", + "Continue this run with a one-shot waiver of the failed gate", + "Abandon this run", + "Stop without changing the run", + ], + 0, + ) + + case selection { + 0 -> + Ok(workflow.ResolveResult( + run: run, + warnings: [], + next_action: runs.next_action_for_run(run), + summary: Some(inspection), + )) + 1 -> { + use resolved <- result.try(apply_setup_continue(run, blocker)) + resolve_interactively(resolved, config, collect_decisions) + } + 2 -> + apply_setup_abandon(run, blocker) + |> result.map(as_resolve_result( + _, + Some( + "Operator abandoned the blocked run before implementation resumed.", ), + )) + _ -> + Ok(workflow.ResolveResult( + run: run, + warnings: [], + next_action: runs.next_action_for_run(run), + summary: Some( + "Stopped without changing the blocked-before-implementation recovery state.", + ), + )) + } +} + +fn prompt_for_implementation_recovery( + run: types.RunRecord, + task: types.Task, + config: types.Config, + collect_decisions: fn(types.RunRecord, List(types.Task)) -> + Result(#(List(types.RecordedDecision), List(types.RunEvent)), String), +) -> Result(workflow.ResolveResult, String) { + let inspection = render_implementation_inspection(run, task) + io.println(inspection) + let selection = + terminal_ui.select_from_labels( + "Choose a recovery action:", + [ + "Inspect and stop", + "Continue this task from the retained worktree", + "Mark this task complete and run verification", + "Abandon this partial work and replan", + "Stop without changing the run", + ], + 0, + ) + + case selection { + 0 -> + Ok(workflow.ResolveResult( + run: run, + warnings: [], + next_action: runs.next_action_for_run(run), + summary: Some(inspection), + )) + 1 -> { + use resolved <- result.try(apply_continue(run, task)) + resolve_interactively(resolved, config, collect_decisions) + } + 2 -> { + use resolved <- result.try(apply_complete(run, task, config)) + resolve_interactively(resolved, config, collect_decisions) + } + 3 -> { + use resolved <- result.try(apply_abandon(run, task, config)) + resolve_interactively(resolved, config, collect_decisions) + } + _ -> + Ok(workflow.ResolveResult( + run: run, + warnings: [], + next_action: runs.next_action_for_run(run), + summary: Some( + "Stopped without changing blocked implementation recovery for task `" + <> task.id + <> "`.", + ), + )) + } +} + +fn resolve_action( + run: types.RunRecord, + task_id: String, + action: types.ResolveAction, + config: types.Config, +) -> Result(workflow.ResolveResult, String) { + use task <- result.try(find_implementation_task(run, task_id)) + case action { + types.ResolveInspect -> + Ok(workflow.ResolveResult( + run: run, + warnings: [], + next_action: runs.next_action_for_run(run), + summary: Some(render_implementation_inspection(run, task)), + )) + types.ResolveContinue -> + apply_continue(run, task) + |> result.map(as_resolve_result(_, None)) + types.ResolveComplete -> + apply_complete(run, task, config) + |> result.map(as_resolve_result(_, None)) + types.ResolveAbandon -> + apply_abandon(run, task, config) + |> result.map(as_resolve_result(_, None)) + } +} + +fn resolve_run_recovery_action( + run: types.RunRecord, + action: types.ResolveAction, + _config: types.Config, +) -> Result(workflow.ResolveResult, String) { + use blocker <- result.try(case runs.active_recovery_blocker(run) { + Some(active) -> Ok(active) + None -> + Error( + "`night-shift resolve` action flags without `--task` are only valid for blocked setup recovery.", ) - _, _ -> { - use #(new_decisions, warning_events) <- result.try(collect_decisions( - run, - blocked_tasks, + }) + + case action { + types.ResolveInspect -> + Ok(workflow.ResolveResult( + run: run, + warnings: [], + next_action: runs.next_action_for_run(run), + summary: Some(render_setup_inspection(run, blocker)), + )) + types.ResolveContinue -> + apply_setup_continue(run, blocker) + |> result.map(as_resolve_result(_, None)) + types.ResolveAbandon -> + apply_setup_abandon(run, blocker) + |> result.map(as_resolve_result(_, None)) + types.ResolveComplete -> + Error( + "Blocked setup recovery does not support `--complete`; use `--continue` or `--abandon` instead.", + ) + } +} + +fn collect_decision_blockers( + run: types.RunRecord, + blocked_tasks: List(types.Task), + config: types.Config, + collect_decisions: fn(types.RunRecord, List(types.Task)) -> + Result(#(List(types.RecordedDecision), List(types.RunEvent)), String), +) -> Result(workflow.ResolveResult, String) { + use #(new_decisions, warning_events) <- result.try(collect_decisions( + run, + blocked_tasks, + )) + let updated_run = + types.RunRecord( + ..run, + decisions: decision_domain.merge_recorded_decisions( + run.decisions, + new_decisions, + ), + planning_dirty: True, + ) + use rewritten_run <- result.try(journal.rewrite_run(updated_run)) + use warned_run <- result.try(append_run_events(rewritten_run, warning_events)) + use signaled_run <- result.try(append_decision_recorded_events( + warned_run, + new_decisions, + )) + use dirty_run <- result.try( + append_run_events(signaled_run, [ + planning_sync_pending_event(), + ]), + ) + resolve_interactively(dirty_run, config, collect_decisions) +} + +fn continue_resolve_run( + run: types.RunRecord, + config: types.Config, + collect_decisions: fn(types.RunRecord, List(types.Task)) -> + Result(#(List(types.RecordedDecision), List(types.RunEvent)), String), +) -> Result(workflow.ResolveResult, String) { + use replanned_run <- result.try(orchestrator.replan(run)) + case next_interactive_blocker(replanned_run) { + Some(SetupBlocker(_)) + | Some(DecisionBlocker(_)) + | Some(ImplementationBlocker(_)) + | Some(PlanningSyncBlocker) -> + resolve_interactively(replanned_run, config, collect_decisions) + None -> Ok(as_resolve_result(replanned_run, None)) + } +} + +fn apply_continue( + run: types.RunRecord, + task: types.Task, +) -> Result(types.RunRecord, String) { + use _ <- result.try(require_retained_worktree(task)) + let updated_task = types.Task(..task, state: types.Ready, summary: "") + let updated_run = + types.RunRecord( + ..run, + tasks: task_graph.replace_task(run.tasks, updated_task) + |> task_graph.refresh_ready_states, + ) + use rewritten_run <- result.try(journal.rewrite_run(updated_run)) + use evented_run <- result.try(journal.append_event( + rewritten_run, + types.RunEvent( + kind: "task_progress", + at: system.timestamp(), + message: "Operator approved continuation from the retained worktree.", + task_id: Some(task.id), + ), + )) + persist_resolved_run(evented_run) +} + +fn apply_setup_continue( + run: types.RunRecord, + blocker: types.RecoveryBlocker, +) -> Result(types.RunRecord, String) { + let updated_tasks = case blocker.task_id { + Some(task_id) -> mark_task_ready_for_retry(run.tasks, task_id) + None -> run.tasks + } + let updated_run = + types.RunRecord( + ..run, + status: types.RunPending, + recovery_blocker: Some( + types.RecoveryBlocker(..blocker, disposition: types.RecoveryWaivedOnce), + ), + tasks: task_graph.refresh_ready_states(updated_tasks), + ) + use rewritten_run <- result.try(journal.rewrite_run(updated_run)) + use evented_run <- result.try(journal.append_event( + rewritten_run, + types.RunEvent( + kind: "setup_recovery_approved", + at: system.timestamp(), + message: "Operator approved a one-shot waiver of the failed " + <> types.recovery_blocker_phase_to_string(blocker.phase) + <> " " + <> types.recovery_blocker_kind_to_string(blocker.kind) + <> " gate.", + task_id: blocker.task_id, + ), + )) + journal.mark_status( + evented_run, + types.RunPending, + "Night Shift is ready to retry after operator-approved setup recovery.", + ) +} + +fn apply_setup_abandon( + run: types.RunRecord, + blocker: types.RecoveryBlocker, +) -> Result(types.RunRecord, String) { + let abandoned_run = types.RunRecord(..run, recovery_blocker: None) + use rewritten_run <- result.try(journal.rewrite_run(abandoned_run)) + use evented_run <- result.try(journal.append_event( + rewritten_run, + types.RunEvent( + kind: "run_abandoned", + at: system.timestamp(), + message: "Operator abandoned the blocked run after " + <> types.recovery_blocker_phase_to_string(blocker.phase) + <> " " + <> types.recovery_blocker_kind_to_string(blocker.kind) + <> " stopped execution before implementation.", + task_id: blocker.task_id, + ), + )) + journal.mark_status( + evented_run, + types.RunFailed, + "Operator abandoned the blocked run before implementation could continue.", + ) +} + +fn apply_complete( + run: types.RunRecord, + task: types.Task, + config: types.Config, +) -> Result(types.RunRecord, String) { + use worktree_path <- result.try(require_retained_worktree(task)) + let changed_files = + git.changed_files( + worktree_path, + filepath.join( + run.run_path, + "logs/" <> task.id <> ".resolve.complete.git.log", + ), + ) + let verification_log = + filepath.join(run.run_path, "logs/" <> task.id <> ".verify.log") + + case verify_retained_worktree(config, run, task, verification_log) { + Ok(output) -> { + let updated_task = + types.Task( + ..task, + state: types.Completed, + summary: "Operator completed retained work and verification passed." + <> changed_files_summary(changed_files), + ) + let updated_run = + types.RunRecord( + ..run, + tasks: task_graph.replace_task(run.tasks, updated_task) + |> task_graph.refresh_ready_states, + ) + use rewritten_run <- result.try(journal.rewrite_run(updated_run)) + use progressed_run <- result.try(journal.append_event( + rewritten_run, + types.RunEvent( + kind: "task_progress", + at: system.timestamp(), + message: "Operator marked retained work complete and verification passed.", + task_id: Some(task.id), + ), )) + use verified_run <- result.try(journal.append_event( + progressed_run, + types.RunEvent( + kind: "task_verified", + at: system.timestamp(), + message: "Verification passed for " <> task.title, + task_id: Some(task.id), + ), + )) + let _ = output + persist_resolved_run(verified_run) + } + Error(output) -> { + let updated_task = + types.Task( + ..task, + state: types.ManualAttention, + summary: "Primary blocker: verification failed.\n\nEnvironment notes:\nVerification log: " + <> verification_log + <> "\n" + <> output, + ) let updated_run = types.RunRecord( ..run, - decisions: decision_domain.merge_recorded_decisions( - run.decisions, - new_decisions, - ), - planning_dirty: True, + tasks: task_graph.replace_task(run.tasks, updated_task), ) use rewritten_run <- result.try(journal.rewrite_run(updated_run)) - use warned_run <- result.try(append_run_events( + use attention_run <- result.try(journal.append_event( rewritten_run, - warning_events, + types.RunEvent( + kind: "task_manual_attention", + at: system.timestamp(), + message: updated_task.summary, + task_id: Some(task.id), + ), )) - use signaled_run <- result.try(append_decision_recorded_events( - warned_run, - new_decisions, + persist_resolved_run(attention_run) + } + } +} + +fn apply_abandon( + run: types.RunRecord, + task: types.Task, + _config: types.Config, +) -> Result(types.RunRecord, String) { + use _ <- result.try(append_recovery_note(run, task)) + use rewritten_run <- result.try(journal.rewrite_run( + types.RunRecord(..run, planning_dirty: True), + )) + use evented_run <- result.try(journal.append_event( + rewritten_run, + types.RunEvent( + kind: "task_progress", + at: system.timestamp(), + message: "Operator abandoned retained partial work and requested replanning.", + task_id: Some(task.id), + ), + )) + case orchestrator.replan(evented_run) { + Ok(replanned_run) -> Ok(replanned_run) + Error(message) -> { + use failed_replan_run <- result.try(journal.append_event( + evented_run, + types.RunEvent( + kind: "planning_recovery_failed", + at: system.timestamp(), + message: "Night Shift could not replan after abandoning retained work: " + <> message, + task_id: Some(task.id), + ), )) - use dirty_run <- result.try( - append_run_events(signaled_run, [ - planning_sync_pending_event(), - ]), + journal.mark_status( + failed_replan_run, + types.RunBlocked, + "Night Shift could not replan after abandoning retained work. Inspect the report and rerun `night-shift resolve`.", ) - continue_resolve_run(dirty_run, collect_decisions) } } } -fn continue_resolve_run( +fn find_implementation_task( run: types.RunRecord, - collect_decisions: fn(types.RunRecord, List(types.Task)) -> - Result(#(List(types.RecordedDecision), List(types.RunEvent)), String), -) -> Result(workflow.ResolveResult, String) { - use replanned_run <- result.try(orchestrator.replan(run)) - case replanned_run.status { - types.RunBlocked -> resolve_loop(replanned_run, collect_decisions) + task_id: String, +) -> Result(types.Task, String) { + case + list.find(decision_domain.implementation_blocking_tasks(run), fn(task) { + task.id == task_id + }) + { + Ok(task) -> Ok(task) + Error(_) -> + Error( + "Task `" + <> task_id + <> "` is not a blocked implementation-recovery task for run " + <> run.run_id + <> ".", + ) + } +} + +fn require_retained_worktree(task: types.Task) -> Result(String, String) { + case task.worktree_path { + "" -> + Error( + "Task `" + <> task.id + <> "` has no retained worktree path recorded for recovery.", + ) + path -> + case simplifile.is_directory(path) { + Ok(True) -> Ok(path) + Ok(False) -> + Error( + "Task `" + <> task.id + <> "` retained worktree is missing from disk: " + <> path, + ) + Error(error) -> + Error( + "Unable to inspect retained worktree for task `" + <> task.id + <> "`: " + <> simplifile.describe_error(error), + ) + } + } +} + +fn append_recovery_note( + run: types.RunRecord, + task: types.Task, +) -> Result(Nil, String) { + use existing <- result.try(case simplifile.read(run.brief_path) { + Ok(contents) -> Ok(contents) + Error(error) -> + Error( + "Unable to read " + <> run.brief_path + <> ": " + <> simplifile.describe_error(error), + ) + }) + let separator = case string.ends_with(existing, "\n") { + True -> "" + False -> "\n" + } + let note = + separator + <> "\n## Recovery Note: Abandoned Retained Work\n" + <> "- Task: `" + <> task.id + <> "`\n" + <> "- Decision: discard the retained partial work for this task during recovery.\n" + <> "- Planner instruction: replace or omit this task when replanning remaining work; do not assume the discarded partial work was completed.\n" + case simplifile.write(existing <> note, to: run.brief_path) { + Ok(_) -> Ok(Nil) + Error(error) -> + Error( + "Unable to append recovery note to " + <> run.brief_path + <> ": " + <> simplifile.describe_error(error), + ) + } +} + +fn render_implementation_inspection( + run: types.RunRecord, + task: types.Task, +) -> String { + let git_log = + filepath.join( + run.run_path, + "logs/" <> task.id <> ".resolve.inspect.git.log", + ) + let changed_files = case task.worktree_path { + "" -> [] + worktree_path -> git.changed_files(worktree_path, git_log) + } + let changed_files_lines = case changed_files { + [] -> "- Changed files: (none detected)" _ -> - Ok(workflow.ResolveResult( - run: replanned_run, - warnings: [], - next_action: runs.next_action_for_run(replanned_run), - summary: None, - )) + "- Changed files:\n" + <> string.join( + changed_files |> list.map(fn(path) { " - " <> path }), + with: "\n", + ) + } + + "Task `" + <> task.id + <> "` is blocked by interrupted implementation work." + <> "\nReport: " + <> run.report_path + <> "\nWorktree: " + <> case task.worktree_path { + "" -> "(missing)" + path -> path + } + <> "\nTask log: " + <> filepath.join(run.run_path, "logs/" <> task.id <> ".log") + <> "\n" + <> changed_files_lines +} + +fn verify_retained_worktree( + config: types.Config, + run: types.RunRecord, + task: types.Task, + verification_log: String, +) -> Result(String, String) { + use env_vars <- result.try(worktree_setup.env_vars_for( + run.repo_root, + run.environment_name, + project.worktree_setup_path(run.repo_root), + task.runtime_context, + )) + verify_commands( + config.verification_commands, + task.worktree_path, + env_vars, + verification_log, + [], + ) +} + +fn verify_commands( + commands: List(String), + cwd: String, + env_vars: List(#(String, String)), + log_path: String, + acc: List(String), +) -> Result(String, String) { + case commands { + [] -> + case acc { + [] -> Ok("No verification commands configured.") + _ -> Ok(string.join(list.reverse(acc), with: "\n\n")) + } + [command, ..rest] -> { + let output = shell.run(shell.with_env(command, env_vars), cwd, log_path) + let transcript = "$ " <> command <> "\n" <> output.output + case shell.succeeded(output) { + True -> + verify_commands(rest, cwd, env_vars, log_path, [transcript, ..acc]) + False -> + Error(string.join(list.reverse([transcript, ..acc]), with: "\n\n")) + } + } + } +} + +fn persist_resolved_run(run: types.RunRecord) -> Result(types.RunRecord, String) { + let status = resolved_run_status(run) + journal.mark_status(run, status, resolved_run_message(run, status)) +} + +fn resolved_run_status(run: types.RunRecord) -> types.RunStatus { + case runs.active_recovery_blocker(run) { + Some(_) -> types.RunBlocked + None -> + case list.any(run.tasks, fn(task) { task.state == types.Failed }) { + True -> types.RunFailed + False -> + case + list.any(run.tasks, fn(task) { + task.state == types.Blocked || task.state == types.ManualAttention + }) + { + True -> types.RunBlocked + False -> + case + list.all(run.tasks, fn(task) { task.state == types.Completed }) + { + True -> types.RunCompleted + False -> types.RunPending + } + } + } + } +} + +fn resolved_run_message(run: types.RunRecord, status: types.RunStatus) -> String { + case status { + types.RunPending -> + decision_domain.planning_status_message(run.decisions, run.tasks) + types.RunCompleted -> "Night Shift completed all queued work." + types.RunBlocked -> + case runs.active_recovery_blocker(run) { + Some(_) -> "Night Shift is blocked before implementation could begin." + None -> domain_summary.blocked_run_message(run.tasks) + } + types.RunFailed -> "Night Shift encountered failed tasks." + types.RunActive -> "Night Shift stopped." + } +} + +fn as_resolve_result( + run: types.RunRecord, + summary: Option(String), +) -> workflow.ResolveResult { + workflow.ResolveResult( + run: run, + warnings: [], + next_action: runs.next_action_for_run(run), + summary: summary, + ) +} + +fn changed_files_summary(files: List(String)) -> String { + case files { + [] -> "" + _ -> " Changed files: " <> string.join(files, with: ", ") + } +} + +fn mark_task_ready_for_retry( + tasks: List(types.Task), + task_id: String, +) -> List(types.Task) { + tasks + |> list.map(fn(task) { + case task.id == task_id { + True -> + types.Task( + ..task, + state: types.Ready, + summary: "Operator waived the failed setup gate once; retry pending.", + ) + False -> task + } + }) +} + +fn render_setup_inspection( + run: types.RunRecord, + blocker: types.RecoveryBlocker, +) -> String { + let task_fragment = case blocker.task_id { + Some(task_id) -> + case find_task(run.tasks, task_id) { + Some(task) -> + "\nTask: `" + <> task.id + <> "`" + <> "\nWorktree: " + <> case task.worktree_path { + "" -> "(none recorded)" + path -> path + } + None -> "\nTask: `" <> task_id <> "`" + } + None -> "" + } + let replacement_lines = render_replacement_targets(run) + + "Review-driven planning succeeded, but execution stopped before implementation." + <> "\nFailed gate: " + <> types.recovery_blocker_phase_to_string(blocker.phase) + <> " " + <> types.recovery_blocker_kind_to_string(blocker.kind) + <> "\nReason: " + <> blocker.message + <> "\nLog: " + <> blocker.log_path + <> task_fragment + <> "\nNo new commits or PR updates were produced." + <> replacement_lines +} + +fn render_replacement_targets(run: types.RunRecord) -> String { + case replacement_pr_numbers(run.tasks) { + [] -> "" + numbers -> + "\nIntended replacements remain pending for: " + <> string.join(numbers |> list.map(int.to_string), with: ", ") + <> "\nExisting reviewed PRs remain unchanged until replacement delivery succeeds." + } +} + +fn replacement_pr_numbers(tasks: List(types.Task)) -> List(Int) { + unique_pr_numbers( + tasks + |> list.flat_map(fn(task) { task.superseded_pr_numbers }), + [], + ) +} + +fn unique_pr_numbers(values: List(Int), acc: List(Int)) -> List(Int) { + case values { + [] -> list.reverse(acc) + [value, ..rest] -> + case list.contains(acc, value) { + True -> unique_pr_numbers(rest, acc) + False -> unique_pr_numbers(rest, [value, ..acc]) + } + } +} + +fn find_task(tasks: List(types.Task), task_id: String) -> Option(types.Task) { + case list.find(tasks, fn(task) { task.id == task_id }) { + Ok(task) -> Some(task) + Error(_) -> None } } diff --git a/src/night_shift/usecase/resume.gleam b/src/night_shift/usecase/resume.gleam index c88766c..2b3a07c 100644 --- a/src/night_shift/usecase/resume.gleam +++ b/src/night_shift/usecase/resume.gleam @@ -1,6 +1,7 @@ import filepath +import gleam/int import gleam/list -import gleam/option.{None} +import gleam/option.{type Option, None, Some} import gleam/result import night_shift/domain/run_state import night_shift/domain/task_graph @@ -48,15 +49,10 @@ pub fn prepare_resumed_run( |> task_graph.refresh_ready_states let resumed_run = types.RunRecord(..run, tasks: resumed_tasks) - let event = - types.RunEvent( - kind: "task_progress", - at: system.timestamp(), - message: "Run resumed; interrupted workers were requeued or marked for manual attention.", - task_id: None, - ) - - journal.append_event(resumed_run, event) + case recovery_event(run.tasks, resumed_tasks) { + Some(event) -> journal.append_event(resumed_run, event) + None -> Ok(resumed_run) + } } fn recover_task(run_path: String, task: types.Task) -> types.Task { @@ -87,3 +83,83 @@ fn append_events( } } } + +fn recovery_event( + original_tasks: List(types.Task), + resumed_tasks: List(types.Task), +) -> Option(types.RunEvent) { + let #(requeued_count, manual_attention_count) = + recovery_counts(original_tasks, resumed_tasks, 0, 0) + + case requeued_count, manual_attention_count { + 0, 0 -> None + _, 0 -> + Some(types.RunEvent( + kind: "task_progress", + at: system.timestamp(), + message: "Recovery requeued " + <> int.to_string(requeued_count) + <> " interrupted task" + <> plural_suffix(requeued_count) + <> ".", + task_id: None, + )) + 0, _ -> + Some(types.RunEvent( + kind: "task_progress", + at: system.timestamp(), + message: "Recovery marked " + <> int.to_string(manual_attention_count) + <> " interrupted task" + <> plural_suffix(manual_attention_count) + <> " for manual attention; no tasks were requeued.", + task_id: None, + )) + _, _ -> + Some(types.RunEvent( + kind: "task_progress", + at: system.timestamp(), + message: "Recovery requeued " + <> int.to_string(requeued_count) + <> " interrupted task" + <> plural_suffix(requeued_count) + <> " and marked " + <> int.to_string(manual_attention_count) + <> " for manual attention.", + task_id: None, + )) + } +} + +fn recovery_counts( + original_tasks: List(types.Task), + resumed_tasks: List(types.Task), + requeued_count: Int, + manual_attention_count: Int, +) -> #(Int, Int) { + case original_tasks, resumed_tasks { + [], _ -> #(requeued_count, manual_attention_count) + _, [] -> #(requeued_count, manual_attention_count) + [original, ..original_rest], [resumed, ..resumed_rest] -> { + let next_counts = case original.state == types.Running { + False -> #(requeued_count, manual_attention_count) + True -> + case resumed.state { + types.ManualAttention -> #( + requeued_count, + manual_attention_count + 1, + ) + _ -> #(requeued_count + 1, manual_attention_count) + } + } + recovery_counts(original_rest, resumed_rest, next_counts.0, next_counts.1) + } + } +} + +fn plural_suffix(count: Int) -> String { + case count == 1 { + True -> "" + False -> "s" + } +} diff --git a/src/night_shift/usecase/support/runs.gleam b/src/night_shift/usecase/support/runs.gleam index 0259999..5a8c6dc 100644 --- a/src/night_shift/usecase/support/runs.gleam +++ b/src/night_shift/usecase/support/runs.gleam @@ -1,6 +1,7 @@ import filepath import gleam/int import gleam/list +import gleam/option.{type Option, None, Some} import gleam/result import night_shift/domain/decisions as decision_domain import night_shift/domain/run_state @@ -8,6 +9,14 @@ import night_shift/git import night_shift/journal import night_shift/types +type BlockedRunReason { + BlockedOnSetupRecovery(blocker: types.RecoveryBlocker) + BlockedOnOutstandingDecisions(count: Int) + BlockedOnPlanningSync + BlockedOnImplementationRecovery(count: Int) + BlockedOther +} + pub fn load_start_run( repo_root: String, selector: types.RunSelector, @@ -43,7 +52,7 @@ pub fn load_display_run( pub fn next_action_for_run(run: types.RunRecord) -> String { case run.status { - types.RunBlocked -> "night-shift resolve" + types.RunBlocked -> blocked_next_action(run) types.RunPending -> case run.planning_dirty { True -> "night-shift resolve" @@ -114,11 +123,27 @@ fn validate_resolvable_run( case run.status, run.planning_dirty, - decision_domain.outstanding_decision_count(run) + decision_domain.outstanding_decision_count(run), + decision_domain.implementation_blocking_task_count(run), + active_recovery_blocker(run) { - types.RunBlocked, _, _ -> Ok(run) - types.RunPending, True, _ -> Ok(run) - types.RunPending, False, _ -> + types.RunBlocked, + planning_dirty, + outstanding, + implementation_blockers, + blocker + -> + case + outstanding > 0 + || planning_dirty + || implementation_blockers > 0 + || blocker != None + { + True -> Ok(run) + False -> Error(resolve_guidance_for_run(run)) + } + types.RunPending, True, _, _, _ -> Ok(run) + types.RunPending, False, _, _, _ -> Error( "Run " <> run.run_id @@ -126,13 +151,13 @@ fn validate_resolvable_run( <> run.run_id <> "`.", ) - types.RunActive, _, _ -> + types.RunActive, _, _, _, _ -> Error( "Run " <> run.run_id <> " is active and cannot be resolved right now.", ) - types.RunCompleted, _, _ -> + types.RunCompleted, _, _, _, _ -> Error("Run " <> run.run_id <> " is already completed.") - types.RunFailed, _, _ -> + types.RunFailed, _, _, _, _ -> Error("Run " <> run.run_id <> " failed and cannot be resolved in place.") } } @@ -193,10 +218,32 @@ fn recover_in_flight_tasks(run: types.RunRecord) -> types.RunRecord { types.RunRecord(..run, tasks: recovered_tasks) } +fn blocked_next_action(run: types.RunRecord) -> String { + case blocked_run_reason(run) { + BlockedOnSetupRecovery(_) -> "night-shift resolve" + BlockedOnOutstandingDecisions(_) -> "night-shift resolve" + BlockedOnPlanningSync -> "night-shift resolve" + BlockedOnImplementationRecovery(_) -> + "inspect the report and retained worktree" + BlockedOther -> "inspect the report" + } +} + fn start_guidance_for_run(run: types.RunRecord) -> String { - let outstanding = decision_domain.outstanding_decision_count(run) - case outstanding > 0 { - True -> + case blocked_run_reason(run) { + BlockedOnSetupRecovery(blocker) -> + "Run " + <> run.run_id + <> " is blocked before implementation could begin. Review-driven planning succeeded, but Night Shift stopped during " + <> types.recovery_blocker_phase_to_string(blocker.phase) + <> " " + <> types.recovery_blocker_kind_to_string(blocker.kind) + <> ". Inspect " + <> blocker.log_path + <> " and run `night-shift resolve --run " + <> run.run_id + <> "`." + BlockedOnOutstandingDecisions(outstanding) -> "Run " <> run.run_id <> " is blocked on " @@ -204,20 +251,199 @@ fn start_guidance_for_run(run: types.RunRecord) -> String { <> " unresolved decision(s). Run `night-shift resolve --run " <> run.run_id <> "` first." - False -> - case run.planning_dirty { - True -> - "Run " - <> run.run_id - <> " recorded new planning answers or notes but has not been replanned yet. Run `night-shift resolve --run " - <> run.run_id - <> "` first." + BlockedOnPlanningSync -> + "Run " + <> run.run_id + <> " recorded new planning answers or notes but has not been replanned yet. Run `night-shift resolve --run " + <> run.run_id + <> "` first." + BlockedOnImplementationRecovery(count) -> + "Run " + <> run.run_id + <> " is blocked because " + <> int.to_string(count) + <> " interrupted implementation task" + <> plural_suffix(count) + <> " now require" + <> verb_suffix(count) + <> " manual recovery. Inspect " + <> run.report_path + <> " and the retained worktree before replanning or continuing manually." + BlockedOther -> + "Run " + <> run.run_id + <> " is blocked. Inspect " + <> run.report_path + <> " before deciding whether to replan or recover manually." + } +} + +fn resolve_guidance_for_run(run: types.RunRecord) -> String { + case blocked_run_reason(run) { + BlockedOnSetupRecovery(_) -> + "Run " + <> run.run_id + <> " still needs `night-shift resolve` before it can continue past the saved setup blocker." + BlockedOnOutstandingDecisions(_) | BlockedOnPlanningSync -> + "Run " + <> run.run_id + <> " still needs `night-shift resolve` before it can start." + BlockedOnImplementationRecovery(_) -> + "Run " + <> run.run_id + <> " is blocked by interrupted implementation work, not unresolved planning questions. Inspect " + <> run.report_path + <> " and the retained worktree before replanning or continuing manually." + BlockedOther -> + "Run " + <> run.run_id + <> " is blocked, but `night-shift resolve` cannot clear it. Inspect " + <> run.report_path + <> " before deciding whether to replan or recover manually." + } +} + +pub fn recovery_recommendation_for_run(run: types.RunRecord) -> String { + case blocked_run_reason(run) { + BlockedOnSetupRecovery(_) -> + "Inspect the saved setup blocker first; `resolve` can explain the failed gate and either continue with a one-shot waiver or abandon the run." + BlockedOnOutstandingDecisions(_) -> + "Resolve the outstanding planning decisions first; `resume` would not safely clear them." + BlockedOnPlanningSync -> + "Run `night-shift resolve` first so Night Shift can replan with the saved answers or notes." + BlockedOnImplementationRecovery(_) -> + "Inspect the report and retained worktree for the interrupted implementation task before replanning or continuing manually." + BlockedOther -> + "Inspect the report and task logs before deciding whether to replan or recover manually." + } +} + +fn blocked_run_reason(run: types.RunRecord) -> BlockedRunReason { + let outstanding = decision_domain.outstanding_decision_count(run) + let implementation_blockers = + decision_domain.implementation_blocking_task_count(run) + + case active_recovery_blocker(run) { + Some(blocker) -> BlockedOnSetupRecovery(blocker) + None -> + case outstanding > 0 { + True -> BlockedOnOutstandingDecisions(outstanding) False -> - "Run " - <> run.run_id - <> " is blocked. Run `night-shift resolve --run " - <> run.run_id - <> "` first." + case run.planning_dirty { + True -> BlockedOnPlanningSync + False -> + case implementation_blockers > 0 { + True -> BlockedOnImplementationRecovery(implementation_blockers) + False -> BlockedOther + } + } + } + } +} + +pub fn active_recovery_blocker( + run: types.RunRecord, +) -> Option(types.RecoveryBlocker) { + case run.recovery_blocker { + Some(blocker) -> + case blocker.disposition == types.RecoveryBlocking { + True -> Some(blocker) + False -> None } + _ -> None + } +} + +pub fn run_has_pending_recovery_bypass(run: types.RunRecord) -> Bool { + case run.recovery_blocker { + Some(blocker) -> blocker.disposition == types.RecoveryWaivedOnce + None -> False + } +} + +pub fn recovery_blocker_task_id(run: types.RunRecord) -> Option(String) { + case run.recovery_blocker { + Some(blocker) -> blocker.task_id + None -> None + } +} + +pub fn recovery_blocker_for_task( + run: types.RunRecord, + task_id: String, +) -> Option(types.RecoveryBlocker) { + case run.recovery_blocker { + Some(blocker) if blocker.task_id == Some(task_id) -> Some(blocker) + _ -> None + } +} + +pub fn clear_recovery_blocker(run: types.RunRecord) -> types.RunRecord { + types.RunRecord(..run, recovery_blocker: None) +} + +pub fn with_recovery_blocker( + run: types.RunRecord, + blocker: types.RecoveryBlocker, +) -> types.RunRecord { + types.RunRecord(..run, recovery_blocker: Some(blocker)) +} + +pub fn with_pending_recovery_bypass( + run: types.RunRecord, + blocker: types.RecoveryBlocker, +) -> types.RunRecord { + types.RunRecord( + ..run, + recovery_blocker: Some( + types.RecoveryBlocker(..blocker, disposition: types.RecoveryWaivedOnce), + ), + ) +} + +pub fn consume_recovery_bypass( + run: types.RunRecord, + kind: types.RecoveryBlockerKind, + phase: types.RecoveryBlockerPhase, + task_id: Option(String), +) -> types.RunRecord { + case run.recovery_blocker { + Some(blocker) + if blocker.kind == kind + && blocker.phase == phase + && blocker.task_id == task_id + && blocker.disposition == types.RecoveryWaivedOnce + -> types.RunRecord(..run, recovery_blocker: None) + _ -> run + } +} + +pub fn recovery_bypass_matches( + run: types.RunRecord, + kind: types.RecoveryBlockerKind, + phase: types.RecoveryBlockerPhase, + task_id: Option(String), +) -> Bool { + case run.recovery_blocker { + Some(blocker) -> + blocker.kind == kind + && blocker.phase == phase + && blocker.task_id == task_id + && blocker.disposition == types.RecoveryWaivedOnce + None -> False + } +} + +fn plural_suffix(count: Int) -> String { + case count == 1 { + True -> "" + False -> "s" + } +} + +fn verb_suffix(count: Int) -> String { + case count == 1 { + True -> "s" + False -> "" } } diff --git a/src/night_shift_dashboard_server.erl b/src/night_shift_dashboard_server.erl index 7455d4f..19fd99a 100644 --- a/src/night_shift_dashboard_server.erl +++ b/src/night_shift_dashboard_server.erl @@ -108,6 +108,18 @@ handle_client(Socket, RepoRoot, InitialRunId) -> {error, Message} -> reply(Socket, 500, <<"text/plain; charset=utf-8">>, Message) end; + {ok, <<"POST">>, <<"/api/runs/", Rest/binary>>} -> + case parse_recovery_path(Rest) of + {ok, RunId, Action} -> + case night_shift@dashboard:apply_recovery_action(RepoRoot, uri_string:unquote(RunId), Action) of + {ok, Payload} -> + reply(Socket, 200, <<"text/plain; charset=utf-8">>, Payload); + {error, Message} -> + reply(Socket, 400, <<"text/plain; charset=utf-8">>, Message) + end; + error -> + reply(Socket, 404, <<"text/plain; charset=utf-8">>, <<"Not found">>) + end; {ok, <<"GET">>, <<"/api/runs/", RunId/binary>>} -> case night_shift@dashboard:run_json(RepoRoot, uri_string:unquote(RunId)) of {ok, Payload} -> @@ -156,6 +168,14 @@ strip_query(Path) -> [] -> Path end. +parse_recovery_path(Rest) -> + case binary:split(Rest, <<"/recovery/">>) of + [RunId, Action] when RunId =/= <<>>, Action =/= <<>> -> + {ok, RunId, Action}; + _ -> + error + end. + reply(Socket, StatusCode, ContentType, Body) -> StatusLine = status_line(StatusCode), Response = diff --git a/test/domain_pr_handoff_test.gleam b/test/domain_pr_handoff_test.gleam index 5507fc0..b16f766 100644 --- a/test/domain_pr_handoff_test.gleam +++ b/test/domain_pr_handoff_test.gleam @@ -124,6 +124,7 @@ fn sample_run() -> types.RunRecord { status: types.RunPending, created_at: "", updated_at: "", + recovery_blocker: None, tasks: [], handoff_states: [], ) diff --git a/test/domain_pull_request_test.gleam b/test/domain_pull_request_test.gleam index 878f1b2..5c66265 100644 --- a/test/domain_pull_request_test.gleam +++ b/test/domain_pull_request_test.gleam @@ -124,6 +124,7 @@ fn sample_run() -> types.RunRecord { status: types.RunPending, created_at: "", updated_at: "", + recovery_blocker: None, tasks: [], handoff_states: [], ) diff --git a/test/domain_report_test.gleam b/test/domain_report_test.gleam index 27b552d..e50535a 100644 --- a/test/domain_report_test.gleam +++ b/test/domain_report_test.gleam @@ -100,6 +100,7 @@ fn review_run() -> types.RunRecord { status: types.RunCompleted, created_at: "2026-04-13T17:30:00Z", updated_at: "2026-04-13T18:02:00Z", + recovery_blocker: None, tasks: [ replacement_task( "rewrite-root", diff --git a/test/night_shift_cli_config_test.gleam b/test/night_shift_cli_config_test.gleam index 59ab55a..df7b314 100644 --- a/test/night_shift_cli_config_test.gleam +++ b/test/night_shift_cli_config_test.gleam @@ -81,7 +81,49 @@ pub fn parse_plan_requires_notes_test() { } pub fn parse_resolve_defaults_to_latest_test() { - let assert Ok(types.Resolve(types.LatestRun)) = cli.parse(["resolve"]) + let assert Ok(types.Resolve(types.LatestRun, None, None)) = + cli.parse(["resolve"]) +} + +pub fn parse_resolve_task_continue_command_test() { + let assert Ok(types.Resolve( + types.RunId("run-123"), + Some("task-1"), + Some(types.ResolveContinue), + )) = + cli.parse([ + "resolve", + "--run", + "run-123", + "--task", + "task-1", + "--continue", + ]) +} + +pub fn parse_resolve_rejects_missing_action_for_task_test() { + let assert Error(message) = cli.parse(["resolve", "--task", "task-1"]) + assert message + == "`night-shift resolve --task ` requires exactly one of `--inspect`, `--continue`, `--complete`, or `--abandon`." +} + +pub fn parse_resolve_rejects_action_without_task_test() { + let assert Error(message) = cli.parse(["resolve", "--inspect"]) + assert message + == "`night-shift resolve` action flags require `--task `." +} + +pub fn parse_resolve_rejects_multiple_actions_test() { + let assert Error(message) = + cli.parse([ + "resolve", + "--task", + "task-1", + "--inspect", + "--continue", + ]) + assert message + == "`night-shift resolve --task ` accepts exactly one of `--inspect`, `--continue`, `--complete`, or `--abandon`." } pub fn parse_resume_command_with_ui_test() { diff --git a/test/night_shift_execution_delivery_test.gleam b/test/night_shift_execution_delivery_test.gleam index a2a94f5..24cc773 100644 --- a/test/night_shift_execution_delivery_test.gleam +++ b/test/night_shift_execution_delivery_test.gleam @@ -14,6 +14,7 @@ import night_shift/provider import night_shift/shell import night_shift/system import night_shift/types +import night_shift/usecase/resolve as resolve_usecase import night_shift/usecase/resume import night_shift/worktree_setup import night_shift_test_support as support @@ -1935,7 +1936,7 @@ pub fn orchestrator_start_blocks_manual_attention_before_bootstrap_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } -pub fn orchestrator_start_fails_environment_preflight_before_task_launch_test() { +pub fn orchestrator_start_blocks_environment_preflight_before_task_launch_test() { let unique = system.unique_id() let base_dir = support.absolute_path(filepath.join( @@ -1992,34 +1993,37 @@ pub fn orchestrator_start_fails_environment_preflight_before_task_launch_test() "default", 1, ) - let assert Ok(failed_run) = orchestrator.start(run, config) + let assert Ok(blocked_run) = orchestrator.start(run, config) system.set_env("PATH", old_path) support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) support.restore_env("XDG_STATE_HOME", old_state_home) - let assert Ok(events) = simplifile.read(failed_run.events_path) + let assert Ok(events) = simplifile.read(blocked_run.events_path) let preflight_log = - filepath.join(failed_run.run_path, "logs/environment-preflight.log") + filepath.join(blocked_run.run_path, "logs/environment-preflight.log") let assert Ok(preflight_contents) = simplifile.read(preflight_log) - let assert Ok(report_contents) = simplifile.read(failed_run.report_path) + let assert Ok(report_contents) = simplifile.read(blocked_run.report_path) - assert failed_run.status == types.RunFailed + let assert Some(blocker) = blocked_run.recovery_blocker + assert blocked_run.status == types.RunBlocked + assert blocker.kind == types.EnvironmentPreflightBlocker + assert blocker.phase == types.PreflightPhase assert string.contains( does: events, - contain: "\"kind\":\"environment_preflight_failed\"", + contain: "\"kind\":\"environment_preflight_blocked\"", ) assert string.contains(does: events, contain: "\"kind\":\"task_started\"") == False assert string.contains(does: preflight_contents, contain: "missing-tool") + assert string.contains(does: report_contents, contain: "- Blocked tasks: 1") assert string.contains( does: report_contents, - contain: "- Run-level failures: 1", + contain: "## Blocked Before Implementation", ) - assert string.contains(does: report_contents, contain: "## Failure") assert string.contains( does: report_contents, - contain: "environment bootstrap", + contain: "No new commits or PR updates were produced yet.", ) let _ = simplifile.delete(file_or_dir_at: base_dir) @@ -2108,7 +2112,7 @@ pub fn environment_preflight_defaults_to_first_setup_executable_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } -pub fn orchestrator_start_reports_setup_phase_failures_after_preflight_test() { +pub fn orchestrator_start_blocks_setup_phase_failures_after_preflight_test() { let unique = system.unique_id() let base_dir = support.absolute_path(filepath.join( @@ -2166,17 +2170,17 @@ pub fn orchestrator_start_reports_setup_phase_failures_after_preflight_test() { "default", 1, ) - let assert Ok(failed_run) = orchestrator.start(run, config) + let assert Ok(blocked_run) = orchestrator.start(run, config) system.set_env("PATH", old_path) support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) support.restore_env("XDG_STATE_HOME", old_state_home) - let assert Ok(events) = simplifile.read(failed_run.events_path) - let env_log = filepath.join(failed_run.run_path, "logs/demo-task.env.log") + let assert Ok(events) = simplifile.read(blocked_run.events_path) + let env_log = filepath.join(blocked_run.run_path, "logs/demo-task.env.log") let assert Ok(env_contents) = simplifile.read(env_log) let failed_task = - failed_run.tasks + blocked_run.tasks |> list.find(fn(task) { task.id == "demo-task" }) |> result.unwrap(or: types.Task( id: "", @@ -2197,9 +2201,15 @@ pub fn orchestrator_start_reports_setup_phase_failures_after_preflight_test() { runtime_context: None, )) - assert failed_run.status == types.RunFailed + let assert Some(blocker) = blocked_run.recovery_blocker + assert blocked_run.status == types.RunBlocked + assert blocker.kind == types.TaskSetupBlocker + assert blocker.phase == types.SetupPhase assert string.contains(does: events, contain: "\"kind\":\"task_started\"") - assert string.contains(does: events, contain: "\"kind\":\"task_failed\"") + assert string.contains( + does: events, + contain: "\"kind\":\"task_setup_blocked\"", + ) assert string.contains(does: env_contents, contain: "(exit 127)") assert string.contains(does: env_contents, contain: "$ missing-tool install") assert string.contains( @@ -2210,6 +2220,102 @@ pub fn orchestrator_start_reports_setup_phase_failures_after_preflight_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn resolve_continue_waives_environment_preflight_once_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-preflight-continue-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let bin_dir = filepath.join(base_dir, "bin") + let brief_path = filepath.join(base_dir, "brief.md") + let fake_provider = filepath.join(bin_dir, "fake-provider") + let state_home = filepath.join(base_dir, "state") + let old_path = system.get_env("PATH") + let old_fake_provider = system.get_env("NIGHT_SHIFT_FAKE_PROVIDER") + let old_state_home = system.get_env("XDG_STATE_HOME") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let _ = + simplifile.delete(file_or_dir_at: journal.repo_state_path_for(repo_root)) + let assert Ok(_) = simplifile.create_directory_all(base_dir) + let assert Ok(_) = simplifile.create_directory_all(bin_dir) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + let assert Ok(_) = support.initialize_project_home(repo_root) + let assert Ok(_) = support.write_fake_provider(fake_provider) + let assert Ok(_) = + support.write_test_worktree_setup_with_preflight( + project.worktree_setup_path(repo_root), + ["missing-tool setup"], + [], + [], + ) + let _ = + shell.run( + "chmod +x " <> shell.quote(fake_provider), + base_dir, + filepath.join(base_dir, "chmod.log"), + ) + support.seed_git_repo(repo_root, base_dir) + + system.set_env("NIGHT_SHIFT_FAKE_PROVIDER", fake_provider) + system.set_env("PATH", bin_dir <> ":" <> old_path) + system.set_env("XDG_STATE_HOME", state_home) + + let config = + types.Config( + ..types.default_config(), + verification_commands: [], + max_workers: 1, + ) + + let assert Ok(run) = + support.planned_run_in_environment( + repo_root, + brief_path, + types.Codex, + "default", + 1, + ) + let assert Ok(blocked_run) = orchestrator.start(run, config) + let assert Ok(resolved) = + resolve_usecase.execute( + repo_root, + types.RunId(run.run_id), + None, + Some(types.ResolveContinue), + config, + fn(_, _) { Error("not used") }, + ) + let assert Ok(retried_run) = orchestrator.start(resolved.run, config) + + system.set_env("PATH", old_path) + support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) + support.restore_env("XDG_STATE_HOME", old_state_home) + + let assert Some(blocker) = blocked_run.recovery_blocker + let assert Ok(events) = simplifile.read(retried_run.events_path) + + assert blocker.kind == types.EnvironmentPreflightBlocker + assert resolved.run.status == types.RunPending + assert resolved.run.recovery_blocker != None + assert resolved.next_action == "night-shift start" + assert retried_run.status != types.RunBlocked + assert retried_run.recovery_blocker == None + assert string.contains( + does: events, + contain: "\"kind\":\"setup_recovery_approved\"", + ) + assert string.contains(does: events, contain: "\"kind\":\"task_started\"") + assert string.contains( + does: events, + contain: "\"kind\":\"environment_preflight_blocked\"", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + pub fn orchestrator_start_uses_setup_phase_for_new_worktrees_test() { let unique = system.unique_id() let base_dir = diff --git a/test/night_shift_lifecycle_test.gleam b/test/night_shift_lifecycle_test.gleam index 3452e11..4ca3339 100644 --- a/test/night_shift_lifecycle_test.gleam +++ b/test/night_shift_lifecycle_test.gleam @@ -3,12 +3,14 @@ import gleam/list import gleam/option.{None, Some} import gleam/result import gleam/string +import night_shift/config import night_shift/journal import night_shift/project import night_shift/provider import night_shift/shell import night_shift/system import night_shift/types +import night_shift/usecase/resolve as resolve_usecase import night_shift/worktree_setup import night_shift_test_support as support import simplifile @@ -897,6 +899,500 @@ pub fn stale_blocked_run_status_and_start_guidance_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn interrupted_implementation_block_guides_to_inspection_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-interrupted-implementation-block-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = support.initialize_project_home(repo_root) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(_) = simplifile.write("# Brief\n", to: brief_path) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let interrupted_task = + types.Task( + id: "interrupted-ui-task", + title: "Interrupted UI task", + description: "Inspect retained implementation work before continuing.", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.ManualAttention, + worktree_path: repo_root, + branch_name: "night-shift/interrupted-ui-task", + pr_number: "", + summary: "Interrupted run left changes in the worktree.", + runtime_context: None, + ) + let blocked_run = + types.RunRecord(..run, status: types.RunBlocked, tasks: [interrupted_task]) + let assert Ok(_) = journal.rewrite_run(blocked_run) + let assert Ok(#(_before_run, before_events)) = + journal.load(repo_root, types.RunId(run.run_id)) + + let status_result = + support.run_local_cli_command( + ["status"], + repo_root, + filepath.join(base_dir, "status.log"), + ) + let resolve_result = + support.run_local_cli_command( + ["resolve", "--task", interrupted_task.id, "--inspect"], + repo_root, + filepath.join(base_dir, "resolve.log"), + ) + let assert Ok(#(after_inspect_run, after_inspect_events)) = + journal.load(repo_root, types.RunId(run.run_id)) + let start_result = + support.run_local_cli_command( + ["start"], + repo_root, + filepath.join(base_dir, "start.log"), + ) + let resume_result = + support.run_local_cli_command( + ["resume"], + repo_root, + filepath.join(base_dir, "resume.log"), + ) + + let assert Ok(status_output) = status_result + let assert Ok(resolve_output) = resolve_result + let assert Ok(start_output) = start_result + let assert Ok(resume_output) = resume_result + + assert string.contains( + does: status_output, + contain: "Outstanding decisions: 0", + ) + assert string.contains( + does: status_output, + contain: "Next action: inspect the report and retained worktree", + ) + assert string.contains( + does: resolve_output, + contain: "Task `interrupted-ui-task` is blocked by interrupted implementation work.", + ) + assert string.contains( + does: resolve_output, + contain: "Worktree: " <> repo_root, + ) + assert string.contains( + does: start_output, + contain: "interrupted implementation task now requires manual recovery", + ) + assert string.contains(does: start_output, contain: blocked_run.report_path) + assert string.contains( + does: resume_output, + contain: "Next action: inspect the report and retained worktree", + ) + assert after_inspect_run.status == types.RunBlocked + assert list.length(after_inspect_events) == list.length(before_events) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn resolve_continue_recovers_interrupted_implementation_task_to_pending_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-resolve-continue-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = support.initialize_project_home(repo_root) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(_) = simplifile.write("# Brief\n", to: brief_path) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let assert Ok(_) = + simplifile.write( + "retained draft\n", + to: filepath.join(repo_root, "DRAFT.md"), + ) + let interrupted_task = + types.Task( + ..interrupted_implementation_task("resume-ui-task", repo_root), + branch_name: "night-shift/resume-ui-task", + runtime_context: Some(types.RuntimeContext( + worktree_id: "resume-ui-task", + compose_project: "resume-ui-task", + port_base: 3000, + named_ports: [types.RuntimePort(name: "app", value: 3000)], + runtime_dir: filepath.join(base_dir, "runtime"), + env_file_path: filepath.join(filepath.join(base_dir, "runtime"), ".env"), + manifest_path: filepath.join( + filepath.join(base_dir, "runtime"), + "manifest.json", + ), + handoff_path: filepath.join( + filepath.join(base_dir, "runtime"), + "handoff.md", + ), + )), + ) + let blocked_run = + types.RunRecord(..run, status: types.RunBlocked, tasks: [interrupted_task]) + let assert Ok(_) = journal.rewrite_run(blocked_run) + + let resolve_result = + support.run_local_cli_command( + ["resolve", "--task", interrupted_task.id, "--continue"], + repo_root, + filepath.join(base_dir, "resolve.log"), + ) + let assert Ok(resolve_output) = resolve_result + let assert Ok(#(updated_run, events)) = + journal.load(repo_root, types.RunId(run.run_id)) + let assert [updated_task] = updated_run.tasks + let assert [latest_event, ..] = list.reverse(events) + + assert updated_run.status == types.RunPending + assert updated_task.state == types.Ready + assert updated_task.summary == "" + assert updated_task.worktree_path == repo_root + assert updated_task.branch_name == interrupted_task.branch_name + assert updated_task.runtime_context == interrupted_task.runtime_context + assert latest_event.kind == "run_pending" + assert string.contains( + does: resolve_output, + contain: "finished with status pending", + ) + assert string.contains( + does: resolve_output, + contain: "Next action: night-shift start", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn resolve_complete_marks_task_completed_and_unblocks_dependents_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-resolve-complete-success-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = support.initialize_project_home(repo_root) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(_) = simplifile.write("# Brief\n", to: brief_path) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let assert Ok(_) = + simplifile.write( + "retained draft\n", + to: filepath.join(repo_root, "DRAFT.md"), + ) + let blocked_task = + types.Task( + ..interrupted_implementation_task("complete-ui-task", repo_root), + branch_name: "night-shift/complete-ui-task", + ) + let dependent_task = + types.Task( + id: "ship-docs-task", + title: "Ship docs task", + description: "Continue after recovery completes.", + dependencies: [blocked_task.id], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Queued, + worktree_path: "", + branch_name: "", + pr_number: "", + summary: "", + runtime_context: None, + ) + let blocked_run = + types.RunRecord(..run, status: types.RunBlocked, tasks: [ + blocked_task, + dependent_task, + ]) + let assert Ok(_) = journal.rewrite_run(blocked_run) + + let resolve_result = + support.run_local_cli_command( + ["resolve", "--task", blocked_task.id, "--complete"], + repo_root, + filepath.join(base_dir, "resolve.log"), + ) + let assert Ok(resolve_output) = resolve_result + let assert Ok(#(updated_run, events)) = + journal.load(repo_root, types.RunId(run.run_id)) + let assert [completed_task, ready_task] = updated_run.tasks + + assert updated_run.status == types.RunPending + assert completed_task.state == types.Completed + assert string.contains( + does: completed_task.summary, + contain: "Operator completed retained work and verification passed.", + ) + assert ready_task.state == types.Ready + assert list.any(events, fn(event) { event.kind == "task_verified" }) + assert string.contains( + does: resolve_output, + contain: "finished with status pending", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn resolve_complete_keeps_recovery_blocked_when_verification_fails_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-resolve-complete-failure-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = support.initialize_project_home(repo_root) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(_) = + simplifile.write( + config.render( + types.Config(..types.default_config(), verification_commands: ["false"]), + ), + to: project.config_path(repo_root), + ) + let assert Ok(_) = simplifile.write("# Brief\n", to: brief_path) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let assert Ok(_) = + simplifile.write( + "retained draft\n", + to: filepath.join(repo_root, "DRAFT.md"), + ) + let blocked_task = + types.Task( + ..interrupted_implementation_task("failed-verify-task", repo_root), + branch_name: "night-shift/failed-verify-task", + ) + let blocked_run = + types.RunRecord(..run, status: types.RunBlocked, tasks: [blocked_task]) + let assert Ok(_) = journal.rewrite_run(blocked_run) + + let resolve_result = + support.run_local_cli_command( + ["resolve", "--task", blocked_task.id, "--complete"], + repo_root, + filepath.join(base_dir, "resolve.log"), + ) + let assert Ok(resolve_output) = resolve_result + let assert Ok(#(updated_run, events)) = + journal.load(repo_root, types.RunId(run.run_id)) + let assert [updated_task] = updated_run.tasks + let verification_log = + filepath.join(run.run_path, "logs/" <> blocked_task.id <> ".verify.log") + let assert Ok(verification_contents) = simplifile.read(verification_log) + + assert updated_run.status == types.RunBlocked + assert updated_task.state == types.ManualAttention + assert string.contains( + does: updated_task.summary, + contain: "Primary blocker: verification failed.", + ) + assert string.contains(does: updated_task.summary, contain: verification_log) + assert string.contains(does: updated_task.summary, contain: "$ false") + assert verification_contents == "" + assert list.any(events, fn(event) { + event.kind == "task_manual_attention" + && event.task_id == Some(blocked_task.id) + }) + assert string.contains( + does: resolve_output, + contain: "finished with status blocked", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn resolve_abandon_replans_after_discarding_retained_work_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-resolve-abandon-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let bin_dir = filepath.join(base_dir, "bin") + let brief_path = filepath.join(base_dir, "brief.md") + let fake_provider = filepath.join(bin_dir, "fake-provider") + let state_home = filepath.join(base_dir, "state") + let old_fake_provider = system.get_env("NIGHT_SHIFT_FAKE_PROVIDER") + let old_state_home = system.get_env("XDG_STATE_HOME") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = support.initialize_project_home(repo_root) + let assert Ok(_) = simplifile.create_directory_all(bin_dir) + let assert Ok(_) = support.write_fake_provider(fake_provider) + let _ = + shell.run( + "chmod +x " <> shell.quote(fake_provider), + base_dir, + filepath.join(base_dir, "chmod.log"), + ) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(_) = simplifile.write("# Brief\n", to: brief_path) + system.set_env("NIGHT_SHIFT_FAKE_PROVIDER", fake_provider) + system.set_env("XDG_STATE_HOME", state_home) + + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let assert Ok(_) = + simplifile.write( + "retained draft\n", + to: filepath.join(repo_root, "DRAFT.md"), + ) + let blocked_task = + types.Task( + ..interrupted_implementation_task("abandon-ui-task", repo_root), + branch_name: "night-shift/abandon-ui-task", + ) + let blocked_run = + types.RunRecord(..run, status: types.RunBlocked, tasks: [blocked_task]) + let assert Ok(_) = journal.rewrite_run(blocked_run) + + let resolve_result = + support.run_local_cli_command( + ["resolve", "--task", blocked_task.id, "--abandon"], + repo_root, + filepath.join(base_dir, "resolve.log"), + ) + let assert Ok(resolve_output) = resolve_result + let assert Ok(#(updated_run, _events)) = + journal.load(repo_root, types.RunId(run.run_id)) + let assert Ok(brief_contents) = simplifile.read(run.brief_path) + let assert [replacement_task] = updated_run.tasks + + support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) + support.restore_env("XDG_STATE_HOME", old_state_home) + + assert updated_run.status == types.RunPending + assert updated_run.planning_dirty == False + assert replacement_task.id == "demo-task" + assert string.contains( + does: brief_contents, + contain: "## Recovery Note: Abandoned Retained Work", + ) + assert string.contains( + does: brief_contents, + contain: "Planner instruction: replace or omit this task when replanning remaining work; do not assume the discarded partial work was completed.", + ) + assert string.contains( + does: resolve_output, + contain: "finished with status pending", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn resolve_rejects_active_runs_until_resume_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-resolve-active-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = support.initialize_project_home(repo_root) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(_) = simplifile.write("# Brief\n", to: brief_path) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let running_task = + types.Task( + id: "active-ui-task", + title: "Active UI task", + description: "Still running.", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Running, + worktree_path: repo_root, + branch_name: "night-shift/active-ui-task", + pr_number: "", + summary: "", + runtime_context: None, + ) + let active_run = + types.RunRecord(..run, status: types.RunActive, tasks: [running_task]) + let assert Ok(_) = journal.rewrite_run(active_run) + + let resolve_result = + resolve_usecase.execute( + repo_root, + types.RunId(run.run_id), + None, + None, + types.default_config(), + fn(_, _) { Ok(#([], [])) }, + ) + + let assert Error(message) = resolve_result + assert string.contains( + does: message, + contain: "is active and cannot be resolved right now", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +fn interrupted_implementation_task( + id: String, + worktree_path: String, +) -> types.Task { + types.Task( + id: id, + title: "Interrupted UI task", + description: "Inspect retained implementation work before continuing.", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.ManualAttention, + worktree_path: worktree_path, + branch_name: "", + pr_number: "", + summary: "Interrupted run left changes in the worktree.", + runtime_context: None, + ) +} + pub fn start_dirty_night_shift_control_files_do_not_block_test() { let unique = system.unique_id() let base_dir = diff --git a/test/night_shift_persistence_provider_test.gleam b/test/night_shift_persistence_provider_test.gleam index b875f37..c8c939f 100644 --- a/test/night_shift_persistence_provider_test.gleam +++ b/test/night_shift_persistence_provider_test.gleam @@ -237,6 +237,133 @@ pub fn dashboard_payloads_include_run_data_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn dashboard_payloads_include_setup_recovery_context_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-dashboard-recovery-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo-" <> unique) + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = + simplifile.delete(file_or_dir_at: journal.repo_state_path_for(repo_root)) + let assert Ok(_) = simplifile.create_directory_all(base_dir) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let blocked_run = + types.RunRecord( + ..run, + status: types.RunBlocked, + planning_provenance: Some(types.ReviewsOnly), + recovery_blocker: Some(types.RecoveryBlocker( + kind: types.EnvironmentPreflightBlocker, + phase: types.PreflightPhase, + task_id: None, + message: "missing-tool setup", + log_path: filepath.join(run.run_path, "logs/environment-preflight.log"), + no_changes_produced: True, + disposition: types.RecoveryBlocking, + )), + tasks: [ + types.Task( + ..list.first(run.tasks) + |> result.unwrap(or: types.Task( + id: "demo-task", + title: "Demo task", + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [36, 37], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Queued, + worktree_path: "", + branch_name: "", + pr_number: "", + summary: "", + runtime_context: None, + )), + superseded_pr_numbers: [36, 37], + ), + ], + ) + let assert Ok(_) = journal.rewrite_run(blocked_run) + let assert Ok(run_payload) = dashboard.run_json(repo_root, run.run_id) + + assert string.contains(does: run_payload, contain: "\"recovery_blocker\"") + assert string.contains( + does: run_payload, + contain: "\"kind\":\"environment_preflight\"", + ) + assert string.contains( + does: run_payload, + contain: "\"replacement_pr_numbers\":[36,37]", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn dashboard_recovery_action_continue_updates_run_state_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-dashboard-recovery-action-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo-" <> unique) + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = + simplifile.delete(file_or_dir_at: journal.repo_state_path_for(repo_root)) + let assert Ok(_) = simplifile.create_directory_all(base_dir) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + let assert Ok(_) = support.initialize_project_home(repo_root) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let blocked_run = + types.RunRecord( + ..run, + status: types.RunBlocked, + recovery_blocker: Some(types.RecoveryBlocker( + kind: types.EnvironmentPreflightBlocker, + phase: types.PreflightPhase, + task_id: None, + message: "missing-tool setup", + log_path: filepath.join(run.run_path, "logs/environment-preflight.log"), + no_changes_produced: True, + disposition: types.RecoveryBlocking, + )), + ) + let assert Ok(_) = journal.rewrite_run(blocked_run) + let assert Ok(summary) = + dashboard.apply_recovery_action(repo_root, run.run_id, "continue") + let assert Ok(#(updated_run, events)) = + journal.load(repo_root, types.RunId(run.run_id)) + + assert string.contains( + does: summary, + contain: "Next action: night-shift start", + ) + assert updated_run.status == types.RunPending + assert updated_run.recovery_blocker + == Some(types.RecoveryBlocker( + kind: types.EnvironmentPreflightBlocker, + phase: types.PreflightPhase, + task_id: None, + message: "missing-tool setup", + log_path: filepath.join(run.run_path, "logs/environment-preflight.log"), + no_changes_produced: True, + disposition: types.RecoveryWaivedOnce, + )) + assert list.any(events, fn(event) { event.kind == "setup_recovery_approved" }) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + pub fn dashboard_server_serves_run_data_test() { let unique = system.unique_id() let base_dir = diff --git a/test/night_shift_resume_test.gleam b/test/night_shift_resume_test.gleam index 3273b37..57a74fd 100644 --- a/test/night_shift_resume_test.gleam +++ b/test/night_shift_resume_test.gleam @@ -1,4 +1,5 @@ import filepath +import gleam/list import gleam/option.{None} import night_shift/git import night_shift/journal @@ -68,3 +69,111 @@ pub fn resume_keeps_clean_interrupted_worktree_requeueable_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } + +pub fn resume_marks_dirty_interrupted_worktree_manual_attention_with_truthful_event_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "resume-dirty-worktree-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = support.initialize_project_home(repo_root) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(_) = simplifile.write("# Brief\n", to: brief_path) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let assert Ok(_) = + simplifile.write("dirty\n", to: filepath.join(repo_root, "DIRTY.md")) + let running_task = + types.Task( + id: "resume-dirty-task", + title: "Resume dirty task", + description: "Verify resume reports manual attention honestly.", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Running, + worktree_path: repo_root, + branch_name: "night-shift/resume-dirty-task", + pr_number: "", + summary: "", + runtime_context: None, + ) + let interrupted_run = types.RunRecord(..run, tasks: [running_task]) + let assert Ok(_) = journal.rewrite_run(interrupted_run) + + let assert Ok(resumed) = resume.prepare_resumed_run(interrupted_run) + let assert [task] = resumed.tasks + let assert Ok(#(_loaded_run, events)) = + journal.load(repo_root, types.RunId(run.run_id)) + let assert [latest_event, ..] = list.reverse(events) + + assert task.state == types.ManualAttention + assert task.summary == "Interrupted run left changes in the worktree." + assert latest_event.kind == "task_progress" + assert latest_event.message + == "Recovery marked 1 interrupted task for manual attention; no tasks were requeued." + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn resume_does_not_append_recovery_event_without_running_tasks_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "resume-no-running-tasks-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = support.initialize_project_home(repo_root) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(_) = simplifile.write("# Brief\n", to: brief_path) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let manual_attention_task = + types.Task( + id: "resume-manual-attention-task", + title: "Resume manual attention task", + description: "Verify repeated resume does not fake new recovery work.", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.ManualAttention, + worktree_path: repo_root, + branch_name: "night-shift/resume-manual-attention-task", + pr_number: "", + summary: "Interrupted run left changes in the worktree.", + runtime_context: None, + ) + let blocked_run = + types.RunRecord(..run, status: types.RunBlocked, tasks: [ + manual_attention_task, + ]) + let assert Ok(_) = journal.rewrite_run(blocked_run) + let assert Ok(#(_before_run, before_events)) = + journal.load(repo_root, types.RunId(run.run_id)) + + let assert Ok(resumed) = resume.prepare_resumed_run(blocked_run) + let assert Ok(#(_after_run, after_events)) = + journal.load(repo_root, types.RunId(run.run_id)) + + assert resumed.tasks == blocked_run.tasks + assert list.length(after_events) == list.length(before_events) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} diff --git a/test/trust_surface_test.gleam b/test/trust_surface_test.gleam index f890030..e8e21ea 100644 --- a/test/trust_surface_test.gleam +++ b/test/trust_surface_test.gleam @@ -1,7 +1,10 @@ import filepath +import gleam/list import gleam/option.{None, Some} +import gleam/result import gleam/string import night_shift/dashboard +import night_shift/domain/confidence import night_shift/domain/provenance as provenance_domain import night_shift/domain/repo_state import night_shift/git @@ -12,6 +15,7 @@ import night_shift/shell import night_shift/system import night_shift/types import night_shift/usecase/doctor +import night_shift/usecase/status as status_usecase import night_shift_test_support as support import simplifile @@ -105,6 +109,114 @@ pub fn dashboard_payload_includes_confidence_and_provenance_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn status_summary_calls_out_setup_recovery_and_replacements_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-status-setup-recovery-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(base_dir) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let blocked_run = + types.RunRecord( + ..run, + status: types.RunBlocked, + planning_provenance: Some(types.ReviewsOnly), + recovery_blocker: Some(types.RecoveryBlocker( + kind: types.EnvironmentPreflightBlocker, + phase: types.PreflightPhase, + task_id: None, + message: "missing-tool setup", + log_path: filepath.join(run.run_path, "logs/environment-preflight.log"), + no_changes_produced: True, + disposition: types.RecoveryBlocking, + )), + tasks: [ + types.Task( + ..list.first(run.tasks) + |> result.unwrap(or: types.Task( + id: "review-fix", + title: "Review fix", + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [36, 37], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Queued, + worktree_path: "", + branch_name: "", + pr_number: "", + summary: "", + runtime_context: None, + )), + superseded_pr_numbers: [36, 37], + ), + ], + ) + let assert Ok(_) = journal.rewrite_run(blocked_run) + let assert Ok(status_result) = + status_usecase.execute(repo_root, types.LatestRun, types.default_config()) + + assert status_result.confidence.posture == types.ConfidenceLow + assert string.contains( + does: status_result.summary, + contain: "Blocked before implementation: yes", + ) + assert string.contains( + does: status_result.summary, + contain: "Existing reviewed PRs remain unchanged until replacement delivery succeeds.", + ) + assert string.contains( + does: status_result.summary, + contain: "Next action: night-shift resolve", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn confidence_is_low_for_interrupted_implementation_manual_attention_test() { + let missing_worktree = + filepath.join(review_run().repo_root, "missing-blocked-impl") + let blocked_run = + types.RunRecord(..review_run(), status: types.RunBlocked, tasks: [ + types.Task( + id: "blocked-impl", + title: "Blocked implementation task", + description: "Needs manual recovery after an interrupted run.", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.ManualAttention, + worktree_path: missing_worktree, + branch_name: "", + pr_number: "", + summary: "Interrupted run left changes in the worktree.", + runtime_context: None, + ), + ]) + let assessment = confidence.assess(blocked_run, [], None) + + assert assessment.posture == types.ConfidenceLow + assert string.contains( + does: confidence.reasons_summary(assessment), + contain: "manual recovery", + ) +} + pub fn doctor_flags_dirty_and_missing_worktrees_test() { let unique = system.unique_id() let base_dir = @@ -178,6 +290,99 @@ pub fn doctor_flags_dirty_and_missing_worktrees_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn doctor_recommends_inspection_for_interrupted_implementation_manual_attention_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-doctor-interrupted-impl-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let blocked_run = + types.RunRecord(..run, status: types.RunBlocked, tasks: [ + types.Task( + id: "dirty-task", + title: "Dirty task", + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.ManualAttention, + worktree_path: repo_root, + branch_name: "night-shift/dirty-task", + pr_number: "", + summary: "Interrupted run left changes in the worktree.", + runtime_context: None, + ), + ]) + let assert Ok(_) = journal.rewrite_run(blocked_run) + let assert Ok(rendered) = + doctor.execute(repo_root, types.LatestRun, types.default_config()) + + assert string.contains( + does: rendered, + contain: "Inspect the report and retained worktree for the interrupted implementation task", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn doctor_recommends_resolve_for_blocked_before_implementation_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-doctor-setup-recovery-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let blocked_run = + types.RunRecord( + ..run, + status: types.RunBlocked, + recovery_blocker: Some(types.RecoveryBlocker( + kind: types.TaskSetupBlocker, + phase: types.SetupPhase, + task_id: Some("demo-task"), + message: "Worktree setup phase failed while running `missing-tool install`.", + log_path: filepath.join(run.run_path, "logs/demo-task.env.log"), + no_changes_produced: True, + disposition: types.RecoveryBlocking, + )), + ) + let assert Ok(_) = journal.rewrite_run(blocked_run) + let assert Ok(rendered) = + doctor.execute(repo_root, types.LatestRun, types.default_config()) + + assert string.contains( + does: rendered, + contain: "Inspect the blocked-before-implementation setup gate first:", + ) + assert string.contains( + does: rendered, + contain: "use `night-shift resolve` to inspect, continue, or abandon the run.", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + pub fn doctor_does_not_write_probe_log_into_worktree_test() { let unique = system.unique_id() let base_dir = @@ -266,6 +471,7 @@ fn review_run() -> types.RunRecord { status: types.RunCompleted, created_at: "2026-04-13T17:30:00Z", updated_at: "2026-04-13T18:02:00Z", + recovery_blocker: None, tasks: [ types.Task( id: "rewrite-root", From dc069129a92ce3f3ee789d5609f184eda180ab25 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Tue, 14 Apr 2026 17:47:29 -0700 Subject: [PATCH 3/3] Refine setup recovery UX and drop unrelated dash PRD --- .codex/skills/qa-night-shift/SKILL.md | 3 + docs/run-lifecycle.md | 24 +- night-shift-dash-prd.md | 480 ------------------ src/night_shift/dashboard.gleam | 45 +- src/night_shift/domain/confidence.gleam | 21 +- src/night_shift/domain/report.gleam | 59 ++- src/night_shift/domain/status.gleam | 272 +++++----- src/night_shift/domain/summary.gleam | 49 ++ src/night_shift/usecase/doctor.gleam | 63 ++- src/night_shift/usecase/resolve.gleam | 60 +-- src/night_shift/usecase/support/runs.gleam | 25 +- .../night_shift_execution_delivery_test.gleam | 4 + ...ight_shift_persistence_provider_test.gleam | 53 ++ test/trust_surface_test.gleam | 61 +++ 14 files changed, 513 insertions(+), 706 deletions(-) delete mode 100644 night-shift-dash-prd.md diff --git a/.codex/skills/qa-night-shift/SKILL.md b/.codex/skills/qa-night-shift/SKILL.md index 3506c0c..a6af37d 100644 --- a/.codex/skills/qa-night-shift/SKILL.md +++ b/.codex/skills/qa-night-shift/SKILL.md @@ -140,6 +140,9 @@ Typical flow: `night-shift resolve` to inspect, continue with the one-shot waiver, or abandon the run instead of assuming the user must edit `worktree-setup.toml` +12. after `resolve -> continue` on a setup blocker, confirm `status`, + `report`, and Dash still show the retry-armed state and keep confidence + below `high` until the next `night-shift start` consumes the waiver For review-driven investigations, replace steps 3-4 with: diff --git a/docs/run-lifecycle.md b/docs/run-lifecycle.md index fb6e8b0..7e0652f 100644 --- a/docs/run-lifecycle.md +++ b/docs/run-lifecycle.md @@ -39,9 +39,10 @@ run a pending plan that has newer planning inputs than its saved task graph. ## Blocked Runs and `resolve` -Night Shift blocks when the planner emits manual-attention tasks, unresolved -decision requests, or interrupted implementation work that was recovered into -manual attention. `resolve` is the command that discharges those blockers. +Night Shift blocks when setup or preflight fails before implementation, +when the planner emits manual-attention tasks, when decision requests stay +unanswered, or when interrupted implementation work is recovered into manual +attention. `resolve` is the command that discharges those blockers. Use it like this: @@ -56,9 +57,20 @@ night-shift resolve --task task-123 --abandon Interactive `resolve` walks blockers in order: -1. interrupted implementation recovery -2. unresolved planning decisions -3. planning-sync replans +1. blocked-before-implementation setup or preflight recovery +2. interrupted implementation recovery +3. unresolved planning decisions +4. planning-sync replans + +For blocked-before-implementation setup recovery, `resolve` can: + +- inspect the failed gate and saved logs without mutating the run +- continue by arming a one-shot waiver for that exact gate +- abandon the run if you want to start fresh instead + +After `resolve -> continue`, the run returns to `pending` with a retry-armed +note in `status`, `report`, and Dash until the next `night-shift start` +consumes that one-shot waiver. For interrupted implementation recovery, `resolve` can: diff --git a/night-shift-dash-prd.md b/night-shift-dash-prd.md deleted file mode 100644 index 3707b02..0000000 --- a/night-shift-dash-prd.md +++ /dev/null @@ -1,480 +0,0 @@ -# Night Shift Dash PRD - -## Summary - -`night-shift dash` will become the new human-first front door for Night Shift. -Instead of a monitor-only `--ui` mode attached to `start` and `resume`, Dash -will start a localhost web application that lets an operator initialize a repo, -plan work, inspect the task DAG before execution, start and resume runs, resolve -planning decisions, and audit the resulting artifacts in realtime. - -This is a functional product surface, not a visual design exercise. The v1 goal -is a solid interaction shape and a trustworthy execution/audit story. - -### Locked decisions - -- Dash is a full front door, not a read-only monitor. -- The UI stack is pure Gleam/Lustre by default. -- Realtime updates use Server-Sent Events (SSE), not polling-first or - websocket-first transport. -- The `dash` process owns execution of Night Shift workflows directly rather - than shelling out to separate CLI subprocesses or introducing a new daemon. -- v1 is localhost-only, single-user, and current-repo-only. -- `start --ui` and `resume --ui` are removed in favor of `night-shift dash`. -- Dash includes guided init, browser-based planning input, browser-based - decision resolution, DAG visualization, and audit/report/provenance surfaces. - -## Problem - -The current `--ui` flag is too narrow to justify being a first-class product -surface: - -- it is monitor-only -- it is attached to `start` and `resume` rather than being a front door -- it relies on polling rather than a proper realtime model -- it does not help with init, planning, decision resolution, or resume -- it does not present the DAG as a primary object before execution begins -- it does not make audit, reports, provenance, and PR review links feel like a - coherent operator experience - -Night Shift already persists enough structured state to support a richer GUI: -run journals, events, reports, provenance artifacts, review-driven repo-state -snapshots, task state, worktree/runtime metadata, and delivered PR information. -The missing piece is a proper UI/control-plane surface that is shaped around how -an operator actually uses the tool. - -## Goals - -- Make Night Shift operable primarily from the browser. -- Show repo state, run state, and DAG state before execution starts and while a - run is live. -- Make status, report, provenance, and raw artifacts easy to inspect and audit. -- Link directly to task PRs and review-relevant context. -- Preserve the repo-local, inspectable, durable character of Night Shift. -- Keep the first version functionally solid rather than visually polished. - -## Non-Goals - -- Multi-repo orchestration or a cross-repo control plane. -- Remote access, shared sessions, or multi-user coordination. -- Authentication/session hardening beyond the localhost-only model. -- A separate persistent daemon or background service shared with the CLI. -- Pixel-perfect design-system work, advanced theming, or a polished brand pass. - -## Primary User - -The primary user is the local operator running Night Shift inside one checked-out -repository on their own machine. This operator needs a reliable way to: - -- initialize and configure Night Shift for the repo -- create or refresh a plan -- understand the DAG before starting it -- watch execution progress live -- inspect repo-state drift and review-driven lineage -- answer blocked planning questions -- audit reports, provenance, logs, and PR handoff after the run - -## Product Shape - -Dash is a repo-local web app started by: - -```sh -night-shift dash -``` - -The command launches a localhost server bound to `127.0.0.1`, opens or prints a -browser URL, and hosts a single-repo workspace for the current repository. The -browser is the primary control surface for operator actions. Existing read-only -CLI surfaces such as `status`, `report`, and `provenance` remain available and -must continue to work against runs created or driven through Dash. - -The workspace should be organized around the following persistent areas: - -- Repo/workspace header -- Current run summary -- DAG graph plus synchronized task list/detail pane -- Repo-state/review context panel when available -- Live event timeline -- Status/report/provenance/artifact inspection area - -## Operator Journeys - -### 1. Uninitialized repo -> guided init - -When the repo has not been initialized, Dash should detect the missing -`.night-shift` control plane and present an onboarding flow instead of a broken -empty workspace. - -The init flow must support: - -- selecting the default provider -- selecting a model available to that provider -- choosing whether to generate `./.night-shift/worktree-setup.toml` -- confirming resulting configuration - -On successful init, Dash transitions directly into the normal workspace without -requiring the operator to restart the command. - -### 2. Notes planning via browser - -Dash should provide a planning surface with: - -- a notes textarea for pasted planning input -- an optional brief path field -- a plan action -- a `Plan From Reviews` action - -The operator can plan from pasted notes, reviews, or both. After planning, Dash -must render the resulting pending DAG before execution begins. - -### 3. Inspect pending DAG before start - -After planning, the operator must be able to inspect: - -- task dependency shape -- task kind and execution mode -- readiness/blocked/manual-attention state -- acceptance/demo details -- review-driven replacement lineage when present - -The DAG should be viewable both as a graph and as a structured list so that the -operator can understand both the topology and the exact details. - -### 4. Start run and watch live execution - -The operator starts execution from Dash. Once started, the UI must live-update: - -- run status -- task state transitions -- follow-up task insertion or DAG refreshes -- event timeline -- PR delivery updates, including direct PR links -- report/provenance availability as artifacts land - -The operator should not need to refresh manually to track an active run. - -### 5. Resolve blocked planning decisions in browser - -If a run blocks on manual-attention planning decisions, Dash must present those -decision requests as browser-native forms, including: - -- question -- rationale -- structured options when available -- recommended option when available -- freeform answer input when allowed - -Submitting decisions should trigger replanning inside Dash and update the -displayed DAG without dropping the current workspace context. - -### 6. Resume interrupted run - -If a run was interrupted, Dash should expose resume affordances and recovery -context. The operator should be able to inspect recovery signals and then resume -the run from the browser with live updates continuing over the same Dash -session. - -### 7. Audit completed, blocked, or failed runs - -After a run finishes or blocks, Dash must remain useful as an audit surface. It -should make it easy to inspect: - -- final status -- timeline of events -- rendered report -- provenance artifact -- raw logs/artifacts -- task-level worktree/runtime context -- delivered PRs and lineage -- review-driven repo-state drift when applicable - -Refreshing the page must not destroy access to this audit surface. - -## UX Requirements - -### Workspace shell - -Dash should present a single-repo workspace with enough context to orient the -operator immediately: - -- repo root -- initialization state -- active or latest run -- high-level next action -- connection status for the realtime stream - -### DAG visualization - -The minimum acceptable DAG visualization is a graph-plus-list hybrid: - -- a node-edge graph that shows dependency shape -- a synchronized task list/detail pane for exact inspection -- selecting a task in either surface highlights it in the other -- task state is visible in both surfaces - -The list/detail pane is not a fallback; it is a required first-class part of the -experience for accessibility, precision, and auditability. - -### Repo-state panel - -For review-driven runs, Dash must show repo-state context including: - -- captured open PR count -- captured actionable PR count -- snapshot capture time -- current open/actionable counts when live inspection is available -- drift status and drift details when known -- actionable PR list -- impacted PR list - -### Timeline - -Dash must present a first-class event timeline that updates live during active -runs and remains readable afterward. Timeline events should be filterable at -least by run-wide vs task-scoped events. - -### Report, status, provenance, and artifacts - -Dash should expose status, report, provenance, and raw artifacts as primary -surfaces, not as buried debug links. The operator should be able to inspect: - -- human-readable status summary -- rendered report -- provenance path and rendered provenance view -- raw artifact links/downloads for report, provenance, and logs - -### Task detail - -Per-task detail should include, when available: - -- title and task id -- description, acceptance criteria, and demo plan -- task kind and execution mode -- dependencies and dependents -- current state -- branch name -- PR number -- PR URL -- worktree path -- runtime context summary -- summary/output text -- replacement lineage context for review-driven work - -## Realtime Model - -Dash uses Server-Sent Events as the default realtime transport. - -### Realtime requirements - -- The browser connects to an SSE stream after loading the workspace. -- The SSE stream is the primary source of live updates during an active Dash - session. -- Browser commands use plain HTTP actions rather than long-lived bidirectional - sockets. -- Refreshing the page reconnects to the SSE stream and reloads the current - repo/run state from durable storage. -- Polling is not the primary model in v1. - -### Event categories - -The SSE contract must support structured events for: - -- repo/run bootstrap state -- task state transitions -- timeline events -- DAG refreshes after planning or replanning -- delivery updates, including PR URLs -- run completion, blockage, or failure - -The implementation does not need to freeze final endpoint paths in this PRD, -but it must commit to a structured command API plus a structured SSE event -model. - -## Architecture - -### Topology - -- `night-shift dash` starts a localhost web server bound to `127.0.0.1`. -- The Dash process owns command execution for init, plan, resolve, start, and - resume. -- The web app is implemented with Lustre in a Gleam-native stack. -- Existing journal and artifact persistence remain the source of truth. -- Live session state augments persisted run state for realtime rendering but - does not replace the journal as the durable record. - -### Reuse strategy - -The implementation should bias toward reusing the existing Night Shift domain -and usecase layers rather than bypassing them: - -- configuration and repo initialization logic -- planning and replanning flows -- resolve flow and decision model -- start and resume orchestration -- status/report/provenance rendering -- journal and artifact persistence -- repo-state and review-lineage projections - -The current minimal dashboard/server may be evolved or replaced, but the new -Dash architecture should preserve existing durable state and existing business -rules wherever possible. - -### Execution ownership - -Dash owns execution directly inside its process. This means: - -- browser actions do not shell out to `night-shift` subprocesses -- CLI output parsing is not the integration strategy -- a new daemon/control-plane service is not introduced for v1 - -This keeps the architecture closer to a pure application runtime and avoids the -usual class of bugs produced by treating stringly subprocess output as an API. - -## Public Interface Changes - -### CLI changes - -Add: - -- `night-shift dash` - -Remove: - -- `night-shift start --ui` -- `night-shift resume --ui` - -Keep: - -- `night-shift init` -- `night-shift plan` -- `night-shift resolve` -- `night-shift start` -- `night-shift resume` -- `night-shift status` -- `night-shift report` -- `night-shift provenance` -- other existing read/repair flows - -The CLI remains a valid automation and escape-hatch surface, but Dash becomes -the intended human-first GUI entrypoint. - -### Browser actions - -Dash must expose browser-driven actions for: - -- init -- plan with notes textarea and optional doc path -- plan from reviews -- resolve decisions -- start -- resume - -### API contract shape - -The implementation must define: - -- a structured command API for browser actions -- a structured SSE stream for live updates -- a state bootstrap endpoint or equivalent mechanism for initial page load - -Exact endpoint names are intentionally left open, but the contract must support -the workflows described in this document without scraping CLI text. - -## Data And State Requirements - -Dash must be able to render the following from current state plus live updates: - -- repo initialization status -- latest and active run identity -- run status and timestamps -- task DAG, task metadata, and task state -- decision requests and recorded decisions -- repo-state review snapshot and drift -- lineage for superseded PR work -- timeline events -- report and provenance locations/content -- PR delivery information, including URL -- task worktree and runtime context - -Where data already exists in persisted Night Shift artifacts, Dash should -consume it rather than inventing a parallel store. - -## Audit Requirements - -Dash is not only an execution view; it is an audit surface. - -v1 must include: - -- a first-class event timeline -- provenance visibility -- links to report, provenance, and raw log artifacts -- PR links from delivered tasks -- review-driven repo snapshot, actionable/impacted PR lists, and drift display -- durable post-refresh access to completed, blocked, and failed runs - -The audit story should privilege inspectability over clever animation. - -## Error Handling And Recovery - -Dash must handle the following cleanly: - -- local server startup failures -- inability to bind the localhost port -- malformed or failed command submissions -- SSE disconnect/reconnect -- browser refresh during active execution -- blocked runs -- failed runs -- interrupted runs that can be resumed -- runs that are no longer safe to resume - -The UI should always preserve or restore enough context that the operator can -understand what happened and what to do next. - -## Success Criteria - -Dash is successful in v1 if a Night Shift operator can do the complete common -workflow from the browser: - -1. initialize the repo if needed -2. plan work from notes or reviews -3. inspect the DAG before execution -4. start the run -5. watch it update live -6. resolve blocked planning decisions in the browser if necessary -7. resume if interrupted -8. audit the result, artifacts, and PRs afterward - -## Acceptance Scenarios - -The implementation must satisfy the following acceptance scenarios: - -1. Running `night-shift dash` on an uninitialized repo opens a guided init flow - and can complete initialization without restarting Dash. -2. Planning from pasted notes creates a pending run and renders the planned DAG - before execution begins. -3. Planning from reviews renders repo-state snapshot data and - actionable/impacted PR context. -4. Starting from Dash streams live task and timeline updates and eventually - shows delivered PR links. -5. A blocked run can be resolved fully from the browser and replans without - losing workspace context. -6. An interrupted run can be resumed from Dash with live updates. -7. Completed, blocked, and failed runs all remain auditable after refresh via - report, provenance, timeline, and artifact links. -8. The DAG view remains synchronized between graph and list/detail panes. -9. Dash binds only to localhost and operates only on the current repo. -10. CLI read commands still work against runs created and driven through Dash. - -## Assumptions And Defaults - -- Output artifact: `night-shift-dash-prd.md` at the repo root. -- v1 is local-only, single-user, and repo-local. -- Realtime means SSE-driven live updates, not websocket-first and not - polling-first. -- “Full front door” includes guided init and browser-based decision resolution, - not merely `start` and `resume`. -- Visual polish is explicitly secondary to information architecture, - interaction shape, and auditability. -- Lustre is the intended UI framework, and a pure Gleam/Lustre bias is a - strategic constraint rather than an implementation afterthought. diff --git a/src/night_shift/dashboard.gleam b/src/night_shift/dashboard.gleam index 6840875..6f00ca4 100644 --- a/src/night_shift/dashboard.gleam +++ b/src/night_shift/dashboard.gleam @@ -8,6 +8,7 @@ import night_shift/config import night_shift/domain/confidence import night_shift/domain/provenance import night_shift/domain/review_run_projection +import night_shift/domain/summary as domain_summary import night_shift/journal import night_shift/project import night_shift/repo_state_runtime @@ -204,18 +205,28 @@ pub fn index_html(initial_run_id: String) -> String { <> " const summary = document.getElementById('recovery-summary');\n" <> " const output = document.getElementById('recovery-output');\n" <> " const blocker = run.recovery_blocker;\n" - <> " if (!blocker || blocker.disposition !== 'blocking') {\n" + <> " const inspectButton = document.getElementById('recovery-inspect');\n" + <> " const continueButton = document.getElementById('recovery-continue');\n" + <> " const abandonButton = document.getElementById('recovery-abandon');\n" + <> " if (!blocker) {\n" <> " panel.hidden = true;\n" <> " summary.textContent = 'No blocked-before-implementation recovery is active.';\n" <> " output.textContent = 'Recovery guidance will appear here.';\n" <> " return;\n" <> " }\n" - <> " const replacements = (run.replacement_pr_numbers || []).length > 0\n" - <> " ? ` Intended replacements remain pending for PRs ${run.replacement_pr_numbers.join(', ')}.`\n" - <> " : ' No new commits or PR updates were produced yet.';\n" + <> " const outcome = (run.recovery_outcome_lines || []).join(' ');\n" + <> " const intro = run.recovery_intro || 'Planning succeeded, but execution stopped before implementation.';\n" + <> " const retryArmed = blocker.disposition === 'waived_once';\n" <> " panel.hidden = false;\n" - <> " summary.textContent = `Blocked before implementation during ${blocker.phase} ${blocker.kind}. ${blocker.message}${replacements}`;\n" - <> " output.textContent = `Log: ${blocker.log_path}` + (blocker.task_id ? `\\nTask: ${blocker.task_id}` : '');\n" + <> " inspectButton.disabled = retryArmed;\n" + <> " continueButton.disabled = retryArmed;\n" + <> " abandonButton.disabled = retryArmed;\n" + <> " summary.textContent = retryArmed\n" + <> " ? `Retry armed for ${blocker.phase} ${blocker.kind}. ${intro} The failed gate was waived once; night-shift start will retry from there. ${outcome}`\n" + <> " : `Blocked before implementation during ${blocker.phase} ${blocker.kind}. ${intro} ${blocker.message} ${outcome}`;\n" + <> " output.textContent = retryArmed\n" + <> " ? `Log: ${blocker.log_path}\\nNext action: night-shift start` + (blocker.task_id ? `\\nTask: ${blocker.task_id}` : '')\n" + <> " : `Log: ${blocker.log_path}` + (blocker.task_id ? `\\nTask: ${blocker.task_id}` : '');\n" <> " }\n" <> " function renderHistory(runs) {\n" <> " const container = document.getElementById('history');\n" @@ -373,6 +384,14 @@ fn run_detail_json( "recovery_blocker", json.nullable(from: run.recovery_blocker, of: recovery_blocker_json), ), + #( + "recovery_intro", + json.nullable(from: recovery_intro(run), of: json.string), + ), + #( + "recovery_outcome_lines", + json.array(recovery_outcome_lines(run), json.string), + ), #( "replacement_pr_numbers", json.array(replacement_pr_numbers(run.tasks), json.int), @@ -421,6 +440,20 @@ fn recovery_blocker_json(blocker: types.RecoveryBlocker) -> json.Json { ]) } +fn recovery_intro(run: types.RunRecord) -> Option(String) { + case run.recovery_blocker { + Some(_) -> Some(domain_summary.setup_recovery_intro(run)) + None -> None + } +} + +fn recovery_outcome_lines(run: types.RunRecord) -> List(String) { + case run.recovery_blocker { + Some(_) -> domain_summary.setup_recovery_outcome_lines(run) + None -> [] + } +} + fn load_repo_state_view( run: types.RunRecord, ) -> Option(repo_state_runtime.RepoStateView) { diff --git a/src/night_shift/domain/confidence.gleam b/src/night_shift/domain/confidence.gleam index c19ea2d..4ad61c5 100644 --- a/src/night_shift/domain/confidence.gleam +++ b/src/night_shift/domain/confidence.gleam @@ -13,7 +13,7 @@ pub fn assess( repo_state_view: Option(repo_state_runtime.RepoStateView), ) -> types.ConfidenceAssessment { let severe = severe_reasons(run, events) - let moderate = moderate_reasons(events, repo_state_view) + let moderate = moderate_reasons(run, events, repo_state_view) let positive = positive_reasons(run, events) case severe { @@ -123,6 +123,7 @@ fn severe_reasons( } fn moderate_reasons( + run: types.RunRecord, events: List(types.RunEvent), repo_state_view: Option(repo_state_runtime.RepoStateView), ) -> List(String) { @@ -131,6 +132,7 @@ fn moderate_reasons( event_count(events, "execution_payload_repair_succeeded") let prune_warnings = event_count(events, "worktree_prune_warning") let supersession_warnings = event_count(events, "review_supersession_warning") + let pending_setup_retry_count = pending_setup_retry_count(run) let repo_state_reason = case repo_state_view { Some(view) -> case view.drift { @@ -144,6 +146,11 @@ fn moderate_reasons( } [ + count_reason( + pending_setup_retry_count, + "operator-approved setup retry is armed.", + "operator-approved setup retries are armed.", + ), count_reason( payload_warnings, "recovered execution payload was accepted.", @@ -199,6 +206,7 @@ fn positive_reasons( case unresolved_decision_requests_count(run) == 0 && setup_blocker_count(run) == 0 + && pending_setup_retry_count(run) == 0 { True -> Some("No outstanding operator decisions remain.") False -> None @@ -240,6 +248,17 @@ fn setup_blocker_count(run: types.RunRecord) -> Int { } } +fn pending_setup_retry_count(run: types.RunRecord) -> Int { + case run.recovery_blocker { + Some(blocker) -> + case blocker.disposition == types.RecoveryWaivedOnce { + True -> 1 + False -> 0 + } + _ -> 0 + } +} + fn directory_exists(path: String) -> Bool { case simplifile.read_directory(at: path) { Ok(_) -> True diff --git a/src/night_shift/domain/report.gleam b/src/night_shift/domain/report.gleam index 1dd1b9c..556b6ce 100644 --- a/src/night_shift/domain/report.gleam +++ b/src/night_shift/domain/report.gleam @@ -7,6 +7,7 @@ import night_shift/domain/confidence import night_shift/domain/provenance import night_shift/domain/repo_state import night_shift/domain/review_run_projection +import night_shift/domain/summary as domain_summary import night_shift/repo_state_runtime import night_shift/types @@ -41,6 +42,7 @@ pub fn render( <> confidence.reasons_summary(confidence_assessment), render_summary(run, run.decisions, run.planning_dirty, run.tasks, events), render_planning_validation_summary(events), + render_retry_armed_summary(run), render_failure_summary(run, events), render_review_replacement_section(run, events), render_worktree_hygiene_section(run, events), @@ -466,9 +468,9 @@ fn render_failure_summary( case active_recovery_blocker(run) { Some(blocker) -> "\n## Blocked Before Implementation\n- Failed gate: " - <> types.recovery_blocker_phase_to_string(blocker.phase) - <> " " - <> types.recovery_blocker_kind_to_string(blocker.kind) + <> domain_summary.recovery_gate_label(blocker) + <> "\n- Setup recovery: " + <> domain_summary.setup_recovery_intro(run) <> "\n- Details: " <> blocker.message <> "\n- Log: " @@ -486,6 +488,23 @@ fn render_failure_summary( } } +fn render_retry_armed_summary(run: types.RunRecord) -> String { + case pending_recovery_bypass(run) { + Some(blocker) -> + "\n## Retry Armed\n- Failed gate: " + <> domain_summary.recovery_gate_label(blocker) + <> "\n- Setup recovery: " + <> domain_summary.setup_recovery_intro(run) + <> "\n- Waiver: The failed gate was waived once; `night-shift start` will retry from there." + <> "\n- Details: " + <> blocker.message + <> "\n- Log: " + <> blocker.log_path + <> replacement_fragment(run) + None -> "" + } +} + fn render_planning_validation_summary(events: List(types.RunEvent)) -> String { case latest_event_message(events, "planning_validation_failed") { Some(message) -> "\n## Planning\n- Validation: " <> message @@ -514,29 +533,23 @@ fn active_recovery_blocker( } } -fn replacement_fragment(run: types.RunRecord) -> String { - let pr_numbers = - run.tasks - |> list.flat_map(fn(task) { task.superseded_pr_numbers }) - |> unique_pr_numbers([]) - case pr_numbers { - [] -> "\n- No new commits or PR updates were produced yet." - _ -> - "\n- Intended replacement PRs remain pending: #" - <> string.join(pr_numbers |> list.map(int.to_string), with: ", #") - <> "\n- Existing reviewed PRs remain unchanged until replacement delivery succeeds." +fn pending_recovery_bypass( + run: types.RunRecord, +) -> Option(types.RecoveryBlocker) { + case run.recovery_blocker { + Some(blocker) -> + case blocker.disposition == types.RecoveryWaivedOnce { + True -> Some(blocker) + False -> None + } + _ -> None } } -fn unique_pr_numbers(values: List(Int), acc: List(Int)) -> List(Int) { - case values { - [] -> list.reverse(acc) - [value, ..rest] -> - case list.contains(acc, value) { - True -> unique_pr_numbers(rest, acc) - False -> unique_pr_numbers(rest, [value, ..acc]) - } - } +fn replacement_fragment(run: types.RunRecord) -> String { + domain_summary.setup_recovery_outcome_lines(run) + |> list.map(fn(line) { "\n- " <> line }) + |> string.join(with: "") } fn latest_run_failed_message(events: List(types.RunEvent)) -> Option(String) { diff --git a/src/night_shift/domain/status.gleam b/src/night_shift/domain/status.gleam index 711afc2..2c36781 100644 --- a/src/night_shift/domain/status.gleam +++ b/src/night_shift/domain/status.gleam @@ -3,6 +3,7 @@ import gleam/list import gleam/option.{type Option, None, Some} import gleam/string import night_shift/domain/decisions +import night_shift/domain/summary as domain_summary import night_shift/types pub fn summary( @@ -13,10 +14,11 @@ pub fn summary( case active_recovery_blocker(run) { Some(blocker) -> "Blocked before implementation: yes\n" + <> "Setup recovery: " + <> domain_summary.setup_recovery_intro(run) + <> "\n" <> "Failed gate: " - <> types.recovery_blocker_phase_to_string(blocker.phase) - <> " " - <> types.recovery_blocker_kind_to_string(blocker.kind) + <> domain_summary.recovery_gate_label(blocker) <> "\n" <> "Failure: " <> blocker.message @@ -33,120 +35,152 @@ pub fn summary( <> "\nNext action: " <> next_action None -> - case run.status { - types.RunFailed -> - "Completed tasks: " - <> int.to_string(completed_task_count(run.tasks)) + case pending_recovery_bypass(run) { + Some(blocker) -> + "Retry armed: yes\n" + <> "Setup recovery: " + <> domain_summary.setup_recovery_intro(run) <> "\n" - <> "Opened PRs: " - <> int.to_string(opened_pr_count(run.tasks)) + <> "Failed gate: " + <> domain_summary.recovery_gate_label(blocker) <> "\n" - <> "Failed tasks: " - <> int.to_string(failed_task_count(run.tasks)) + <> "Waiver: The failed gate was waived once; `night-shift start` will retry from there.\n" + <> "Failure: " + <> blocker.message + <> "\nLog: " + <> blocker.log_path + <> replacement_fragment(run) <> "\n" - <> "Outstanding decisions: " - <> int.to_string(decisions.outstanding_decision_count(run)) + <> "Ready tasks: " + <> int.to_string(ready_task_count(run.tasks)) <> "\n" <> "Queued tasks: " <> int.to_string(queued_task_count(run.tasks)) <> "\n" - <> "Failure: " - <> latest_run_failed_message(events) - <> "\n" - <> "Next action: inspect the report, then rerun `night-shift plan --notes ...` when you're ready for the next pass." - _ -> - case run.status == types.RunBlocked || run.planning_dirty { - True -> - "Blocked tasks: " - <> int.to_string(decisions.blocked_task_count(run)) - <> "\n" - <> "Outstanding decisions: " - <> int.to_string(decisions.outstanding_decision_count(run)) - <> "\n" - <> "Planning sync pending: " - <> bool_label(run.planning_dirty) - <> planning_validation_fragment(events) - <> "\n" - <> "Retained worktrees: " - <> int.to_string(retained_worktree_count(run.tasks)) - <> "\n" - <> "Runtime identities: " - <> int.to_string(runtime_identity_count(run.tasks)) + <> "Next action: " + <> next_action + None -> + case run.status { + types.RunFailed -> + "Completed tasks: " + <> int.to_string(completed_task_count(run.tasks)) <> "\n" - <> "Pruned superseded worktrees: " - <> int.to_string(event_count(events, "worktree_pruned")) + <> "Opened PRs: " + <> int.to_string(opened_pr_count(run.tasks)) <> "\n" - <> "Execution recovery warnings: " - <> int.to_string(event_count(events, "execution_payload_warning")) + <> "Failed tasks: " + <> int.to_string(failed_task_count(run.tasks)) <> "\n" - <> "Payload repair attempts: " - <> int.to_string(event_count( - events, - "execution_payload_repair_started", - )) - <> "\n" - <> "Payload repair successes: " - <> int.to_string(event_count( - events, - "execution_payload_repair_succeeded", - )) - <> "\n" - <> "Payload repair failures: " - <> int.to_string(event_count( - events, - "execution_payload_repair_failed", - )) - <> "\n" - <> "Ready implementation tasks: " - <> int.to_string(ready_implementation_task_count(run.tasks)) - <> "\n" - <> "Queued tasks: " - <> int.to_string(queued_task_count(run.tasks)) - <> "\n" - <> "Next action: " - <> next_action - False -> - "Outstanding decisions: " + <> "Outstanding decisions: " <> int.to_string(decisions.outstanding_decision_count(run)) <> "\n" - <> "Retained worktrees: " - <> int.to_string(retained_worktree_count(run.tasks)) - <> "\n" - <> "Runtime identities: " - <> int.to_string(runtime_identity_count(run.tasks)) - <> "\n" - <> "Pruned superseded worktrees: " - <> int.to_string(event_count(events, "worktree_pruned")) - <> "\n" - <> "Execution recovery warnings: " - <> int.to_string(event_count(events, "execution_payload_warning")) - <> "\n" - <> "Payload repair attempts: " - <> int.to_string(event_count( - events, - "execution_payload_repair_started", - )) - <> "\n" - <> "Payload repair successes: " - <> int.to_string(event_count( - events, - "execution_payload_repair_succeeded", - )) - <> "\n" - <> "Payload repair failures: " - <> int.to_string(event_count( - events, - "execution_payload_repair_failed", - )) - <> "\n" - <> "Ready tasks: " - <> int.to_string(ready_task_count(run.tasks)) - <> "\n" <> "Queued tasks: " <> int.to_string(queued_task_count(run.tasks)) <> "\n" - <> "Next action: " - <> next_action + <> "Failure: " + <> latest_run_failed_message(events) + <> "\n" + <> "Next action: inspect the report, then rerun `night-shift plan --notes ...` when you're ready for the next pass." + _ -> + case run.status == types.RunBlocked || run.planning_dirty { + True -> + "Blocked tasks: " + <> int.to_string(decisions.blocked_task_count(run)) + <> "\n" + <> "Outstanding decisions: " + <> int.to_string(decisions.outstanding_decision_count(run)) + <> "\n" + <> "Planning sync pending: " + <> bool_label(run.planning_dirty) + <> planning_validation_fragment(events) + <> "\n" + <> "Retained worktrees: " + <> int.to_string(retained_worktree_count(run.tasks)) + <> "\n" + <> "Runtime identities: " + <> int.to_string(runtime_identity_count(run.tasks)) + <> "\n" + <> "Pruned superseded worktrees: " + <> int.to_string(event_count(events, "worktree_pruned")) + <> "\n" + <> "Execution recovery warnings: " + <> int.to_string(event_count( + events, + "execution_payload_warning", + )) + <> "\n" + <> "Payload repair attempts: " + <> int.to_string(event_count( + events, + "execution_payload_repair_started", + )) + <> "\n" + <> "Payload repair successes: " + <> int.to_string(event_count( + events, + "execution_payload_repair_succeeded", + )) + <> "\n" + <> "Payload repair failures: " + <> int.to_string(event_count( + events, + "execution_payload_repair_failed", + )) + <> "\n" + <> "Ready implementation tasks: " + <> int.to_string(ready_implementation_task_count(run.tasks)) + <> "\n" + <> "Queued tasks: " + <> int.to_string(queued_task_count(run.tasks)) + <> "\n" + <> "Next action: " + <> next_action + False -> + "Outstanding decisions: " + <> int.to_string(decisions.outstanding_decision_count(run)) + <> "\n" + <> "Retained worktrees: " + <> int.to_string(retained_worktree_count(run.tasks)) + <> "\n" + <> "Runtime identities: " + <> int.to_string(runtime_identity_count(run.tasks)) + <> "\n" + <> "Pruned superseded worktrees: " + <> int.to_string(event_count(events, "worktree_pruned")) + <> "\n" + <> "Execution recovery warnings: " + <> int.to_string(event_count( + events, + "execution_payload_warning", + )) + <> "\n" + <> "Payload repair attempts: " + <> int.to_string(event_count( + events, + "execution_payload_repair_started", + )) + <> "\n" + <> "Payload repair successes: " + <> int.to_string(event_count( + events, + "execution_payload_repair_succeeded", + )) + <> "\n" + <> "Payload repair failures: " + <> int.to_string(event_count( + events, + "execution_payload_repair_failed", + )) + <> "\n" + <> "Ready tasks: " + <> int.to_string(ready_task_count(run.tasks)) + <> "\n" + <> "Queued tasks: " + <> int.to_string(queued_task_count(run.tasks)) + <> "\n" + <> "Next action: " + <> next_action + } } } } @@ -165,29 +199,23 @@ fn active_recovery_blocker( } } -fn replacement_fragment(run: types.RunRecord) -> String { - let pr_numbers = - run.tasks - |> list.flat_map(fn(task) { task.superseded_pr_numbers }) - |> unique_pr_numbers([]) - case pr_numbers { - [] -> "\nNo new commits or PR updates were produced yet." - _ -> - "\nIntended replacement PRs remain pending: #" - <> string.join(pr_numbers |> list.map(int.to_string), with: ", #") - <> "\nExisting reviewed PRs remain unchanged until replacement delivery succeeds." +fn pending_recovery_bypass( + run: types.RunRecord, +) -> Option(types.RecoveryBlocker) { + case run.recovery_blocker { + Some(blocker) -> + case blocker.disposition == types.RecoveryWaivedOnce { + True -> Some(blocker) + False -> None + } + _ -> None } } -fn unique_pr_numbers(values: List(Int), acc: List(Int)) -> List(Int) { - case values { - [] -> list.reverse(acc) - [value, ..rest] -> - case list.contains(acc, value) { - True -> unique_pr_numbers(rest, acc) - False -> unique_pr_numbers(rest, [value, ..acc]) - } - } +fn replacement_fragment(run: types.RunRecord) -> String { + domain_summary.setup_recovery_outcome_lines(run) + |> list.map(fn(line) { "\n" <> line }) + |> string.join(with: "") } fn completed_task_count(tasks: List(types.Task)) -> Int { diff --git a/src/night_shift/domain/summary.gleam b/src/night_shift/domain/summary.gleam index 984c2cc..b5f4e18 100644 --- a/src/night_shift/domain/summary.gleam +++ b/src/night_shift/domain/summary.gleam @@ -21,6 +21,36 @@ pub fn pluralize(count: Int, noun: String) -> String { } } +pub fn setup_recovery_intro(run: types.RunRecord) -> String { + case run.planning_provenance { + Some(provenance) -> + case types.planning_provenance_uses_reviews(provenance) { + True -> + "Review-driven planning succeeded, but execution stopped before implementation." + False -> + "Planning succeeded, but execution stopped before implementation." + } + None -> "Planning succeeded, but execution stopped before implementation." + } +} + +pub fn recovery_gate_label(blocker: types.RecoveryBlocker) -> String { + types.recovery_blocker_phase_to_string(blocker.phase) + <> " " + <> types.recovery_blocker_kind_to_string(blocker.kind) +} + +pub fn setup_recovery_outcome_lines(run: types.RunRecord) -> List(String) { + case replacement_pr_numbers(run.tasks) { + [] -> ["No new commits or PR updates were produced yet."] + pr_numbers -> [ + "Intended replacement PRs remain pending: #" + <> string.join(pr_numbers |> list.map(int.to_string), with: ", #"), + "Existing reviewed PRs remain unchanged until replacement delivery succeeds.", + ] + } +} + pub fn manual_attention_summary(task: types.Task) -> String { "Primary blocker: " <> task.description @@ -83,6 +113,25 @@ pub fn planning_validation_summary( ) } +fn replacement_pr_numbers(tasks: List(types.Task)) -> List(Int) { + unique_pr_numbers( + tasks + |> list.flat_map(fn(task) { task.superseded_pr_numbers }), + [], + ) +} + +fn unique_pr_numbers(values: List(Int), acc: List(Int)) -> List(Int) { + case values { + [] -> list.reverse(acc) + [value, ..rest] -> + case list.contains(acc, value) { + True -> unique_pr_numbers(rest, acc) + False -> unique_pr_numbers(rest, [value, ..acc]) + } + } +} + pub fn follow_up_validation_summary( task: types.Task, issues: List(task_validation.ValidationIssue), diff --git a/src/night_shift/usecase/doctor.gleam b/src/night_shift/usecase/doctor.gleam index 83b8973..b62545d 100644 --- a/src/night_shift/usecase/doctor.gleam +++ b/src/night_shift/usecase/doctor.gleam @@ -288,32 +288,49 @@ fn recommend_next_action( <> blocker.log_path <> " and use `night-shift resolve` to inspect, continue, or abandon the run." None -> - case run.status { - types.RunCompleted -> - "This run is already completed; inspect the report and retained worktrees instead of resuming." - _ -> - case has_classification(assessments, types.RecoveryIrrecoverable) { - True -> - "At least one task is irrecoverable from saved state; inspect the journal and replan rather than resuming." - False -> + case runs.pending_recovery_bypass(run) { + Some(blocker) -> + "A one-shot setup retry is armed for " + <> types.recovery_blocker_phase_to_string(blocker.phase) + <> " " + <> types.recovery_blocker_kind_to_string(blocker.kind) + <> ". Run `night-shift start` to retry from the waived gate." + None -> + case run.status { + types.RunCompleted -> + "This run is already completed; inspect the report and retained worktrees instead of resuming." + _ -> case - has_classification(assessments, types.RecoveryManualAttention) + has_classification(assessments, types.RecoveryIrrecoverable) { - True -> runs.recovery_recommendation_for_run(run) + True -> + "At least one task is irrecoverable from saved state; inspect the journal and replan rather than resuming." False -> - case active_lock { - ActiveLockMismatch(other_run_id) -> - "Another run lock is active (" - <> other_run_id - <> "); clear that ambiguity before resuming." - _ -> - case - has_classification(assessments, types.ResumeWithWarning) - { - True -> - "Resume is possible, but review the warnings above before you let Night Shift continue." - False -> - "Resume should be safe from the saved run state." + case + has_classification( + assessments, + types.RecoveryManualAttention, + ) + { + True -> runs.recovery_recommendation_for_run(run) + False -> + case active_lock { + ActiveLockMismatch(other_run_id) -> + "Another run lock is active (" + <> other_run_id + <> "); clear that ambiguity before resuming." + _ -> + case + has_classification( + assessments, + types.ResumeWithWarning, + ) + { + True -> + "Resume is possible, but review the warnings above before you let Night Shift continue." + False -> + "Resume should be safe from the saved run state." + } } } } diff --git a/src/night_shift/usecase/resolve.gleam b/src/night_shift/usecase/resolve.gleam index a5e9d99..acffa13 100644 --- a/src/night_shift/usecase/resolve.gleam +++ b/src/night_shift/usecase/resolve.gleam @@ -1,5 +1,4 @@ import filepath -import gleam/int import gleam/io import gleam/list import gleam/option.{type Option, None, Some} @@ -102,8 +101,8 @@ fn next_interactive_blocker(run: types.RunRecord) -> Option(InteractiveBlocker) fn prompt_for_setup_recovery( run: types.RunRecord, blocker: types.RecoveryBlocker, - config: types.Config, - collect_decisions: fn(types.RunRecord, List(types.Task)) -> + _config: types.Config, + _collect_decisions: fn(types.RunRecord, List(types.Task)) -> Result(#(List(types.RecordedDecision), List(types.RunEvent)), String), ) -> Result(workflow.ResolveResult, String) { let inspection = render_setup_inspection(run, blocker) @@ -130,7 +129,12 @@ fn prompt_for_setup_recovery( )) 1 -> { use resolved <- result.try(apply_setup_continue(run, blocker)) - resolve_interactively(resolved, config, collect_decisions) + Ok(workflow.ResolveResult( + run: resolved, + warnings: [], + next_action: runs.next_action_for_run(resolved), + summary: Some(render_setup_continue_confirmation(blocker)), + )) } 2 -> apply_setup_abandon(run, blocker) @@ -258,7 +262,10 @@ fn resolve_run_recovery_action( )) types.ResolveContinue -> apply_setup_continue(run, blocker) - |> result.map(as_resolve_result(_, None)) + |> result.map(as_resolve_result( + _, + Some(render_setup_continue_confirmation(blocker)), + )) types.ResolveAbandon -> apply_setup_abandon(run, blocker) |> result.map(as_resolve_result(_, None)) @@ -809,49 +816,26 @@ fn render_setup_inspection( } None -> "" } - let replacement_lines = render_replacement_targets(run) + let replacement_lines = + domain_summary.setup_recovery_outcome_lines(run) + |> list.map(fn(line) { "\n" <> line }) + |> string.join(with: "") - "Review-driven planning succeeded, but execution stopped before implementation." + domain_summary.setup_recovery_intro(run) <> "\nFailed gate: " - <> types.recovery_blocker_phase_to_string(blocker.phase) - <> " " - <> types.recovery_blocker_kind_to_string(blocker.kind) + <> domain_summary.recovery_gate_label(blocker) <> "\nReason: " <> blocker.message <> "\nLog: " <> blocker.log_path <> task_fragment - <> "\nNo new commits or PR updates were produced." <> replacement_lines } -fn render_replacement_targets(run: types.RunRecord) -> String { - case replacement_pr_numbers(run.tasks) { - [] -> "" - numbers -> - "\nIntended replacements remain pending for: " - <> string.join(numbers |> list.map(int.to_string), with: ", ") - <> "\nExisting reviewed PRs remain unchanged until replacement delivery succeeds." - } -} - -fn replacement_pr_numbers(tasks: List(types.Task)) -> List(Int) { - unique_pr_numbers( - tasks - |> list.flat_map(fn(task) { task.superseded_pr_numbers }), - [], - ) -} - -fn unique_pr_numbers(values: List(Int), acc: List(Int)) -> List(Int) { - case values { - [] -> list.reverse(acc) - [value, ..rest] -> - case list.contains(acc, value) { - True -> unique_pr_numbers(rest, acc) - False -> unique_pr_numbers(rest, [value, ..acc]) - } - } +fn render_setup_continue_confirmation(blocker: types.RecoveryBlocker) -> String { + "One-shot retry armed for " + <> domain_summary.recovery_gate_label(blocker) + <> ".\nThe failed gate was waived once; `night-shift start` will retry from there." } fn find_task(tasks: List(types.Task), task_id: String) -> Option(types.Task) { diff --git a/src/night_shift/usecase/support/runs.gleam b/src/night_shift/usecase/support/runs.gleam index 5a8c6dc..4967351 100644 --- a/src/night_shift/usecase/support/runs.gleam +++ b/src/night_shift/usecase/support/runs.gleam @@ -5,6 +5,7 @@ import gleam/option.{type Option, None, Some} import gleam/result import night_shift/domain/decisions as decision_domain import night_shift/domain/run_state +import night_shift/domain/summary as domain_summary import night_shift/git import night_shift/journal import night_shift/types @@ -234,10 +235,10 @@ fn start_guidance_for_run(run: types.RunRecord) -> String { BlockedOnSetupRecovery(blocker) -> "Run " <> run.run_id - <> " is blocked before implementation could begin. Review-driven planning succeeded, but Night Shift stopped during " - <> types.recovery_blocker_phase_to_string(blocker.phase) - <> " " - <> types.recovery_blocker_kind_to_string(blocker.kind) + <> " is blocked before implementation could begin. " + <> domain_summary.setup_recovery_intro(run) + <> " Night Shift stopped during " + <> domain_summary.recovery_gate_label(blocker) <> ". Inspect " <> blocker.log_path <> " and run `night-shift resolve --run " @@ -354,13 +355,23 @@ pub fn active_recovery_blocker( } } -pub fn run_has_pending_recovery_bypass(run: types.RunRecord) -> Bool { +pub fn pending_recovery_bypass( + run: types.RunRecord, +) -> Option(types.RecoveryBlocker) { case run.recovery_blocker { - Some(blocker) -> blocker.disposition == types.RecoveryWaivedOnce - None -> False + Some(blocker) -> + case blocker.disposition == types.RecoveryWaivedOnce { + True -> Some(blocker) + False -> None + } + None -> None } } +pub fn run_has_pending_recovery_bypass(run: types.RunRecord) -> Bool { + pending_recovery_bypass(run) != None +} + pub fn recovery_blocker_task_id(run: types.RunRecord) -> Option(String) { case run.recovery_blocker { Some(blocker) -> blocker.task_id diff --git a/test/night_shift_execution_delivery_test.gleam b/test/night_shift_execution_delivery_test.gleam index 24cc773..4df1ccc 100644 --- a/test/night_shift_execution_delivery_test.gleam +++ b/test/night_shift_execution_delivery_test.gleam @@ -2301,6 +2301,10 @@ pub fn resolve_continue_waives_environment_preflight_once_test() { assert resolved.run.status == types.RunPending assert resolved.run.recovery_blocker != None assert resolved.next_action == "night-shift start" + assert resolved.summary + == Some( + "One-shot retry armed for preflight environment_preflight.\nThe failed gate was waived once; `night-shift start` will retry from there.", + ) assert retried_run.status != types.RunBlocked assert retried_run.recovery_blocker == None assert string.contains( diff --git a/test/night_shift_persistence_provider_test.gleam b/test/night_shift_persistence_provider_test.gleam index c8c939f..f9f147a 100644 --- a/test/night_shift_persistence_provider_test.gleam +++ b/test/night_shift_persistence_provider_test.gleam @@ -303,6 +303,59 @@ pub fn dashboard_payloads_include_setup_recovery_context_test() { does: run_payload, contain: "\"replacement_pr_numbers\":[36,37]", ) + assert string.contains( + does: run_payload, + contain: "\"recovery_intro\":\"Review-driven planning succeeded, but execution stopped before implementation.\"", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn dashboard_payloads_keep_retry_armed_context_visible_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-dashboard-retry-armed-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo-" <> unique) + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = + simplifile.delete(file_or_dir_at: journal.repo_state_path_for(repo_root)) + let assert Ok(_) = simplifile.create_directory_all(base_dir) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let pending_run = + types.RunRecord( + ..run, + status: types.RunPending, + planning_provenance: Some(types.NotesOnly(types.NotesFile(brief_path))), + recovery_blocker: Some(types.RecoveryBlocker( + kind: types.EnvironmentPreflightBlocker, + phase: types.PreflightPhase, + task_id: None, + message: "missing-tool setup", + log_path: filepath.join(run.run_path, "logs/environment-preflight.log"), + no_changes_produced: True, + disposition: types.RecoveryWaivedOnce, + )), + ) + let assert Ok(_) = journal.rewrite_run(pending_run) + let assert Ok(run_payload) = dashboard.run_json(repo_root, run.run_id) + + assert string.contains( + does: run_payload, + contain: "\"disposition\":\"waived_once\"", + ) + assert string.contains( + does: run_payload, + contain: "\"recovery_intro\":\"Planning succeeded, but execution stopped before implementation.\"", + ) + assert string.contains( + does: run_payload, + contain: "\"recovery_outcome_lines\":[\"No new commits or PR updates were produced yet.\"]", + ) let _ = simplifile.delete(file_or_dir_at: base_dir) } diff --git a/test/trust_surface_test.gleam b/test/trust_surface_test.gleam index e8e21ea..de97390 100644 --- a/test/trust_surface_test.gleam +++ b/test/trust_surface_test.gleam @@ -184,6 +184,67 @@ pub fn status_summary_calls_out_setup_recovery_and_replacements_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn status_summary_keeps_retry_armed_setup_recovery_visible_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-status-retry-armed-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(base_dir) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let pending_run = + types.RunRecord( + ..run, + status: types.RunPending, + planning_provenance: Some(types.NotesOnly(types.NotesFile(brief_path))), + recovery_blocker: Some(types.RecoveryBlocker( + kind: types.EnvironmentPreflightBlocker, + phase: types.PreflightPhase, + task_id: None, + message: "missing-tool setup", + log_path: filepath.join(run.run_path, "logs/environment-preflight.log"), + no_changes_produced: True, + disposition: types.RecoveryWaivedOnce, + )), + ) + let assert Ok(_) = journal.rewrite_run(pending_run) + let assert Ok(status_result) = + status_usecase.execute(repo_root, types.LatestRun, types.default_config()) + let rendered_report = report.render(pending_run, [], None) + + assert status_result.confidence.posture == types.ConfidenceGuarded + assert string.contains( + does: status_result.summary, + contain: "Retry armed: yes", + ) + assert string.contains( + does: status_result.summary, + contain: "Planning succeeded, but execution stopped before implementation.", + ) + assert !string.contains( + does: status_result.summary, + contain: "Review-driven planning succeeded", + ) + assert string.contains( + does: status_result.summary, + contain: "Next action: night-shift start", + ) + assert string.contains(does: rendered_report, contain: "## Retry Armed") + assert string.contains( + does: rendered_report, + contain: "Planning succeeded, but execution stopped before implementation.", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + pub fn confidence_is_low_for_interrupted_implementation_manual_attention_test() { let missing_worktree = filepath.join(review_run().repo_root, "missing-blocked-impl")