Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .codex/skills/qa-night-shift/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,19 @@ 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 <task-id> --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`
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:

Expand Down
41 changes: 37 additions & 4 deletions docs/run-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,47 @@ 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 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:

```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. 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:

- 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`
Expand All @@ -73,6 +102,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`:
Expand Down
32 changes: 26 additions & 6 deletions src/night_shift/app.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
{
Expand Down
61 changes: 60 additions & 1 deletion src/night_shift/cli.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub fn usage() -> String {
<> " provenance [--run <id>|latest] [--task <task-id>] [--format <json|md>]\n"
<> " doctor [--run <id>|latest]\n"
<> " resolve [--run <id>|latest]\n"
<> " resolve [--run <id>|latest] --task <task-id> [--inspect|--continue|--complete|--abandon]\n"
<> " resume [--run <id>|latest] [--ui|--explain]\n"
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 <task-id>` requires exactly one of `--inspect`, `--continue`, `--complete`, or `--abandon`.",
)
None, Some(_) ->
Error(
"`night-shift resolve` action flags require `--task <task-id>`.",
)
}
["--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 <task-id>` 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,
Expand Down
83 changes: 83 additions & 0 deletions src/night_shift/codec/journal.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: [],
),
Expand Down Expand Up @@ -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)
Expand Down
Loading