Skip to content

feat: flow lint, structured findings handoff, and mid-step resume - #53

Merged
DavidWells merged 63 commits into
masterfrom
feat/lint-findings-resume
Sep 26, 2026
Merged

DavidWells merged 63 commits into
masterfrom
feat/lint-findings-resume

Conversation

@DavidWells

Copy link
Copy Markdown
Contributor

Implements three epics: flow lint + fail-fast (nax-cusl), structured findings + handoff targets (nax-fw4s), and mid-step resume (nax-2hq7). Plans: docs/ai/plans/flow-lint-and-fail-fast.md, structured-findings-and-handoff-targets.md, mid-step-resume.md, sdk-presend-checkpoint.md.

Flow lint + fail-fast

  • nax lint [flows...] [--json] [--strict]; new checks (follow-up source, transport/lineup conflict, prompt files, findings source); every diagnostic code documented under "Validate your flow".
  • One broken flow no longer breaks the catalog: invalid flows are skipped and shadow their id; invalid_flow / flow_load_failed return 422 with diagnostics in the dashboard and MCP.
  • Plans and runs pin a flow digest; starting a stale plan fails with recoverable flow_changed_since_plan.
  • check:flows (nax lint --strict) runs in release:verify.

Structured findings + handoff

  • Terminal runs write artifacts/findings.json (schema v1) from the review consensus.
  • nax handoff --findings, and nax handoff --to github-issues | pr-review | beads: plan first, --dry, idempotent via markers / external refs.
  • Findings in the dashboard run details and in MCP (run_get view findings, findings resource).

Mid-step resume

  • nax run --resume <run-id> [--dry] [--force] [--include-cancelled] [--force-unlock]: keeps completed agents, polls running ones, resubmits only failed or never-sent agents with their saved prompt. A per-agent preview shows the plan first.
  • Refuses before submitting on flow_changed_since_run, resume_ambiguous_submission, resume_plan_unavailable, resume_auth_failure, branch_moved_since_run, run_locked.
  • Attempt model: superseded attempts kept in step.attempts; costs count every attempt once; lineage-aware state merge.
  • Run lock (run.lock): one executor per run across processes and in-process (dashboard/MCP).
  • Dashboard Resume panel + GET /api/runs/:id/resume-preview / POST /api/runs/:id/resume; MCP run_resume (idempotent by request_id).
  • SDK onSubmitCheckpoint (additive): the exact request is saved before each create, so resume adopts a runner whose response was lost instead of duplicating it.

Review

Two Codex review rounds via nax run agent codex; all valid findings fixed (retry checkpoints, reconcile-error stop, atomic lock takeover, lock release on errors, orphaned takeover mutex, stale-snapshot status regression).

Verification (clean worktree)

npm run check, npm test (1224 pass), check:cli-help, check:flows, sdk:ci, dashboard:build + dashboard e2e (23/23), npm pack --dry-run.

Follow-ups (human)

  • Bump + publish nax-agent-runner-sdk 0.4.0 (npm run release:minor). Revenue Engine needs no change (hook is optional).

One invalid flow no longer breaks every flow. Discovery loads each
candidate in isolation, resolves one winner per id in source priority
order (invalid overrides still shadow), and loadFlow throws structured
invalid_flow / flow_load_failed errors with details. Adds a
flow_id_mismatch warning when raw.id differs from the directory.

Closes nax-cusl.1, nax-cusl.2
@davidwells/box-logger sniffs process.argv at require time and disables
colors on --json; its disabled chalk returns strings from hex(), so the
root help box threw 'borderFn is not a function' for every --json
command (e.g. nax list --json). Load the CLI modules with --json hidden
from argv, matching the existing --verbose workaround.
listFlowCatalog discovers once and returns runnable flows plus invalid
summaries. nax list prints a stderr skip line per broken flow, the picker
shows invalid flows (selecting one prints diagnostics), and dashboard
listWorkflows / MCP workflow_list include invalid entries.

Closes nax-cusl.3
Flow-loading mutation routes are served by the Hono API, which already
forwards error.details; loadFlow now throws invalid_flow with 422 and
diagnostics. The legacy server catch is unreachable for these routes,
so it is left unchanged.

Closes nax-cusl.4
invalid_flow and flow_load_failed errors now tell agents to fix the
listed diagnostics (or syntax) and run nax lint <flow> --json, with
diagnostics passed through structured details.

Closes nax-cusl.6
New checks: followup_without_input, followup_source_not_agent_step,
invalid_default_transport, transport_lineup_conflict (lineups validated
with the declared transport), plus warnings empty_prompt_file and
unused_prompt_file. Lineup warnings (catalog_passthrough, effort
clamping) are carried into flow warnings instead of dropped, and
printFlowPlan no longer prints them twice. TRANSPORT_ALIASES moves to
core constants as the single source for accepted transports.

Closes nax-cusl.7
nax lint [flows...] [--json] [--strict] reports every error and warning
per flow with step index, code, message and fix hint, lists shadowed
candidates, and exits 1 on errors (or warnings with --strict). JSON goes
to stdout only.

Closes nax-cusl.12
flowManifest/flowDigest hash normalized steps, defaults and exact prompt
bytes. Workflow plans store flowDigest and include it in requestHash.
startStoredPlan calls executionBackend.validatePlan after the replay,
starting and ambiguous branches and right before claimStart, so a flow
edited after planning fails with recoverable flow_changed_since_plan
(409) and the plan stays prepared; replays never revalidate. MCP guides
agents to re-plan.

Closes nax-cusl.9
createRunState stores the digest of the loaded flow as runState.flowDigest.
Plan-started runs pass the plan digest through dashboard execution and
the engine refuses with flow_changed_since_plan before creating any run
state when the loaded flow no longer matches.

Closes nax-cusl.10
The dashboard plan service now passes promptBytesByStep (prompt file
bytes plus explicit context) so prompt_near_inline_limit and
prompt_offload_required warnings fire during workflow_plan. Documented
as a lower bound: auto-context and prior-round results are unknown at
plan time.

Closes nax-cusl.11
The dry-run/run output alert now preserves line breaks, so an
invalid_flow error renders one diagnostic and fix hint per line. Adds a
Playwright spec that breaks a flow while the dashboard is open and runs
it in dashboard:smoke.

Closes nax-cusl.5
Synthesize outputs with 3/7/10 consensus findings, round-1 review
outputs per provider, a codex output with no structured heading, a
hand-broken JSON sample, and a security-audit synthesize sample, copied
from this repo's .nax runs with paths, URLs and tokens redacted.

Closes nax-fw4s.1
findStructuredBlocks locates '## Structured <Kind>' headings (numbered
or not), parses the fenced JSON payload (or first balanced JSON span)
and reports parse errors. round-results keeps its numbered-heading
prompt-shrinking behavior by calling it with numberedOnly.

Closes nax-fw4s.2
normalizeFlow keeps findings: { step, adapter } (null when absent) so it
survives loading and enters the flow manifest digest. The validator
reports invalid_findings_source for unregistered adapters, unknown
steps, and human-review steps. Adapter ids live in the dependency-free
FINDINGS_ADAPTER_IDS constant.

Closes nax-fw4s.3
Maps consensus/contested/merge-dependent findings to a stable shape:
1-based rank for consensus items, titles from agent title or first
claim sentence (100 chars, word boundary), severity normalization
(words and P-levels, unknown -> info + severityRaw), line ranges,
camelCase fields, and validated agents attribution. Deviations become
diagnostics, never exceptions.

Closes nax-fw4s.4
buildFindings derives schema v1 (stable <runId>/<stepId>/<localId> keys,
source, target incl. pullRequest, findings, diagnostics) purely from run
state and current attempts only. Fan-out source steps merge per run with
encoded instance-prefixed localIds. readFindings never writes;
writeFindings persists atomically. TS mirror in contracts/workflow.ts.

Closes nax-fw4s.5
completeRun marks a run completed and writes findings.json; every
engine/CLI completion path now uses it, and the failure path writes
findings for partial results. saveRunState never writes findings, and a
findings write error only warns, never changing run status or exit code.

Closes nax-fw4s.6
resolvePullRequestTarget requests number and url and returns one
pullRequest object; normalizeTarget carries it, and all three
resolveTarget paths (GitHub advisory, generic, Netlify verified) plus
legacy re-normalization preserve it. Non-PR targets omit it.

Closes nax-fw4s.7
The bundled review flow declares findings: { step: synthesize, adapter:
review-consensus }, and the consensus schema gains an agents list naming
the agents whose findings each item merges (same field as the
security-audit synthesis prompt).

Closes nax-fw4s.8
resolveFindingsRun returns an explicit run id, or the newest run that is
completed, completed_with_failures, or failed, declares findings, and has
a completed source-step run. Independent of handoff-sources, which only
offers fully completed runs.

Closes nax-fw4s.9
nax handoff [run-id] --findings shows a ranked table (rank, severity,
title, location, bucket/status/agents, diagnostics) for the given or
latest eligible run; --json prints the findings.json artifact. Read-only.

Closes nax-fw4s.10
nax handoff --to github-issues selects findings (--select, --limit, or
an interactive picker preselecting top-ranked consensus items), filters
by severity/bucket/status, plans issues, and applies them sequentially.
Each issue carries a nax-finding marker; a label-scoped issue scan skips
findings already filed, so reruns create nothing new. --dry previews,
non-interactive apply requires --force, missing labels degrade to a
warning, and a mid-list failure stops and reports partial progress.

Tests run the real CLI with a fake gh executable on PATH.

Closes nax-fw4s.11, nax-fw4s.12
The interactive handoff menu offers 'Create GitHub issues from findings'
when the latest source is a workflow with consensus findings, and routes
into the github-issues target picker.

Closes nax-fw4s.13
The dashboard serves GET /api/runs/:id/findings (computed in memory,
never written). MCP run details now include a findings summary (count,
severity histogram, top ten ranked findings), run_get accepts
view: findings for the full artifact, and a new
nax://scopes/{scope}/runs/{id}/findings resource template exposes it.
Read-only; no mutation tools.

Closes nax-fw4s.14
nax handoff --to beads creates one bead per selected finding via
argv-only br create calls: bug for defects else task, priority mapped
from severity, nax-finding/severity labels, and external ref
nax:<finding key>. Existing refs are skipped, so reruns create nothing.
Fails with guidance when br or the .beads workspace is missing.
Targets now declare whether they need a repo and their own confirmation
text; issue and bead bodies share one finding markdown builder.

Closes nax-fw4s.17
nax handoff --to pr-review resolves the PR (run target, --pr, then the
open PR for the run branch), inlines findings only on right-side diff
lines parsed from the PR file patches, and lists everything else under
'Outside this diff'. Reviews are always event COMMENT. When the PR head
moved, commit_id pins the run SHA if it is in the PR history, otherwise
the review is summary-only. A nax-review marker refuses reposting the
same run unless --force.

Closes nax-fw4s.16
Run details show a collapsible Findings table (rank, severity, title,
location, agents, copy button, diagnostics) fed by
GET /api/runs/:id/findings, which the legacy server now delegates to the
Hono API. Renders nothing for flows without a findings declaration.
Playwright spec added to dashboard:smoke.

Closes nax-fw4s.18
New Hand Off Findings guide (findings declaration, findings.json schema,
--findings, github-issues/pr-review/beads targets and idempotency, the
dashboard panel and MCP views). Commands, workflow-files and MCP
references cover the new flags, findings key, run_get view and resource.
Unreleased changelog entry for the findings and lint work.

Closes nax-fw4s.15
…-running it

Forward execution proceeds past completed_with_failures steps, but resume
treated them as unfinished and re-ran (and re-billed) the whole step.
A shared stepAllowsContinuation predicate in core/status now drives
forward execution, the resume completed-step map and start index, and
unfinished-run detection; a partial final step is still resumed because
a workflow cannot finish on one. resumeLocalFlow accepts the Agent
Runner boundary for tests.

Closes nax-2hq7.1, nax-2hq7.2
…ting it

executeLocalFlow pushed a second stepState with the same id when a step
was re-executed, so first-match readers saw the stale record and the
step's artifacts moved to a new NN- directory. startStepState resets an
existing record in place (keeping blobRefs for cleanup), so each step id
appears once with a stable position.

Closes nax-2hq7.3
resubmissionRun creates a fresh attempt (new attemptId, lineage) from a
saved run's prompt text (compact when asked), keeps its instance and
model settings, and drops every remote handle and delivery ref so the
Agent Runner SDK re-chooses inline or blob delivery and uploads under a
new key. Expired or cleaned blob refs are never reused.

Closes nax-2hq7.12
…ly failures

Keeps completed results, polls in-flight runs, resubmits failed/never-sent
instances with their saved prompt. Refuses on flow change, ambiguous send,
or moved branch head (--force). Failed runs record failureCode; runs that
stopped on failed instances are explicitly resumable.
--dry prints the keep/poll/resubmit/skip table; non-TTY needs --force;
--force also allows a moved branch or ambiguous resubmission;
--include-cancelled resubmits cancelled agents. The TTY resume offer
shows the same preview. GitHub runs get an explicit step-level error.
run.lock/owner.json records pid, host, nonce and command. Stale only when
the owner ran on this host and its pid is gone; release is owner-checked.
Held while a run is tracked (fresh run, resume, retry), released on clear
or exit. Contended -> run_locked; --force-unlock takes over. A failed
resume records failureCode so it can be resumed again.
… failure

Submission-retry test proves the SDK retries only pre-send network
failures (backoff, attempt budget) and never retries 401, 5xx or resets
after send. Those ambiguous creates were recorded as plain failures, so
resume would resubmit blindly. Record the SDK error code and have
reconcile stop on create-ambiguous unless --force.
…tting into the step

Dashboard and MCP run workflows in-process, so a second execution of the
same run in one process now fails with run_locked. A moved branch only
blocks resume when new agents would join kept results in the resumed
step, so review-gate approvals are not blocked by pushes during review.
GET /api/runs/:id/resume-preview returns the per-instance plan (or why a
run cannot resume, including a live run lock). POST /api/runs/:id/resume
refuses with 409 before starting anything, then resumes in-process.
Eligibility, flow digest and preview move into a shared prepareResume
used by the CLI, the TTY offer and the dashboard.
Failed and interrupted runs show the keep/poll/resubmit/skip preview in
run details and can be resumed (optionally including cancelled agents).
Blocked runs explain why and keep the button disabled.
Resumes a failed or interrupted run through the dashboard resume API and
returns the per-agent preview. The dashboard stores the outcome by
request_id, so a replay never starts a second resume. Resume refusals
(run_locked, flow_changed_since_run, ...) are recoverable MCP errors
with guidance.
The GitHub transport comments with a follow-up step's own agents, so the
declaration is only ignored on flows pinned to netlify-api. Bundled flows
now lint clean under --strict.
Covers nax lint usage and exit codes, discovery shadowing, 422 refusals,
and plan digests. A test keeps the table in sync with the codes the
validator and lineup resolver can emit.
…econcile

Also syncs bead state for the lint, findings and resume epics.
SDK: optional onSubmitCheckpoint hands the exact effective input and
sentAt to the caller before every runner/session create (incl. capacity
retries); a throwing checkpoint sends nothing.
nax saves the checkpoint before sending and keeps the SDK request window
on ambiguous creates. Resume reconciles those runs by request marker:
one match is adopted and polled, no match stops for sends that may have
landed and resubmits otherwise, several matches stop and are listed.
SDK version bump and npm publish are a human handoff.
Codex review: capacity and prompt-shrink retries created runners without
a saved request, so a crash after the retry create was resubmitted on
resume. The poller now saves raw.retrySubmitCheckpoint before each retry
create; resume adopts a matching runner and otherwise stops.
A reconcile error on a checkpointed submit failure fell through to a
blind resubmit; it now stops (auth errors as resume_auth_failure).
Codex review: two processes seeing the same stale lock could each remove
it, deleting the other's fresh lock. Removal now requires run.lock.takeover
(atomic mkdir) and a re-check that the lock still has the stale owner;
mkdir stays the only way to acquire. An 8-process race test asserts one
winner.
Codex review: handleRetry, resumeGithubFlow and the start of
handleRunEngine could throw after taking the lock and keep the run locked
in long-lived processes. They now release it in finally, keeping the run
tracked so a process exit still marks it interrupted.
Codex review round 2: a crash between creating run.lock.takeover and
writing its owner file left a mutex nothing could clear, not even
--force-unlock. A takeover lasts milliseconds, so an ownerless one older
than 60s is reclaimed.
… is active

Codex review round 2 (also on master): when the merge keeps a newer disk
attempt that is still active, the stale writer's finished step status and
workflow status no longer overwrite the current ones.
@github-actions

Copy link
Copy Markdown

NAX review completed with status: success

Final results

Review · 2026-09-26T05-09-00-077Z-review

  • Run ID: 2026-09-26T05-09-00-077Z-review
  • Flow: review
  • Transport: netlify-api
  • Status: completed
  • Usage: 899.61 credits, 254 steps, 16,259,300 tokens
  • Target: feat/lint-findings-resume (explicit-branch, verified)
  • Target SHA: b147c1d0921d1371996772dd6cda0dd093de3598
  • Files: usage

Contents

  1. Review · summary · metadata · usage
  2. Cross Review · summary · metadata · usage
  3. Summarize Consensus · summary · metadata · usage

Review

  • Status: completed
  • Usage: 556.1 credits, 199 steps, 12,992,645 tokens
  • Files: step metadata, usage

Claude Results


Repository State
  • pinned_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • checked_out_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • state_match: yes
  • drift_commits: 0
  • drift_acceptable: yes
  • git_status_clean: yes

Structured Findings

[
  {
    "id": "R1",
    "category": "defect",
    "severity": "high",
    "status": "confirmed",
    "file": "src/integrations/netlify/local-runner.js",
    "line": 1449,
    "claim": "A single transient error while finalizing or auto-retrying one runner stops polling for every other runner in the step.",
    "evidence": "Inside the poll loop, only handleFor/getSnapshot are wrapped in try/catch (1404-1432). terminalRun() (getRunner + listSessions, which throws when the current session is missing) and retryFailedRun() (retryClient.retry/followUp + getSession) run with no catch at 1449 and 1462. Any rejection escapes waitForLocalAgentRuns. The other pending runners are left unfinished and are never stopped or marked timeout (the cleanup loop at 1481 is skipped), and the step fails even though its siblings may still succeed remotely.",
    "suggested_fix": "Catch errors per run around terminalRun/retryFailedRun. Treat transient errors like a poll failure (report progress and `continue`) with a bounded retry count, and finalize that one run as failed after repeated errors. The retry checkpoint saved through onSubmitCheckpoint already covers a retry request that was sent before the error.",
    "confidence": "high"
  },
  {
    "id": "R2",
    "category": "defect",
    "severity": "medium",
    "status": "confirmed",
    "file": "src/workflows/engine/local-executor.js",
    "line": 1386,
    "claim": "resumeLocalFlow reconciles submissions, plans the resume and may call completeRun before it takes the run lock.",
    "evidence": "recoverSubmissions (1386), planResume, the `reconciled.stop` throw (1402) and assertBranchUnmoved all run before trackRunState (1405). The plan.complete branch calls completeRun (1396), which writes state with no lock. While another process is live and mid automatic retry (retrySubmitCheckpoint saved), a second `nax run --resume` makes Netlify API reconcile calls and can fail with resume_ambiguous_submission ('rerun with --force') instead of run_locked. That points the user at a duplicate-risk flag while the real owner is still running. The lock still prevents a duplicate submission, because --force then hits run_locked.",
    "suggested_fix": "Call trackRunState(runState, { forceUnlock }) first, wrap everything after it in the existing try/finally, and move recovery, planning and the completeRun early return inside the lock.",
    "confidence": "high"
  },
  {
    "id": "R3",
    "category": "defect",
    "severity": "low",
    "status": "likely",
    "file": "src/storage/local/run-lock.js",
    "line": 111,
    "claim": "Two processes that both clear a stale or orphaned takeover mutex can each end up holding it, and one can then delete the other's newly acquired run lock.",
    "evidence": "acquireTakeover decides a takeover is stale (dead pid or orphaned >60s), then does rmSync + createLockDir with no guard. Process A can create the new takeover between B's staleness read and B's rmSync, so B deletes A's mutex and both proceed. In acquireRunLock, B's `existsSync(lockDir)` check (159) and its rmSync (160) are not atomic. If A's createLockDir (164) lands between them, B removes A's live lock and then creates its own, so both processes believe they are the executor. The window is narrow and needs a crashed takeover, but this is exactly the case the mutex exists to handle.",
    "suggested_fix": "Clear a stale takeover by renaming it to a unique tombstone (fs.renameSync to `${takeover}.${nonce}.stale`) and then mkdir, so only one remover succeeds. Or re-read the takeover owner after mkdir and back off if it is not ours. Likewise, rename the run lock aside instead of rmSync, and only when its owner still matches the owner judged stale.",
    "confidence": "medium"
  },
  {
    "id": "P1",
    "category": "polish",
    "severity": "medium",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 145,
    "claim": "The run_locked message always suggests --force-unlock, but only `nax run --resume` accepts that flag.",
    "evidence": "--force-unlock is registered only in addResumeOptions (src/cli/commands/nax.js:253) and passed only by src/cli/commands/resume.js:89. These call sites cannot force-unlock at all: nax retry (src/cli/main.js:2564), the interactive resume prompt (main.js:2848-2850), dashboard/MCP resumeRunById (main.js:2870-2872) and resumeGithubFlow (github-executor.js:509). A lock left behind with no owner.json (a crash between mkdir and the write in createLockDir) is never treated as stale, so on those paths it is a dead end.",
    "suggested_fix": "Word the hint per caller (for example, pass a `recoveryHint` option into acquireRunLock), or thread forceUnlock through retry, interactive resume and GitHub resume. Consider applying the ORPHANED_TAKEOVER_MS age rule to a run lock with no owner file.",
    "confidence": "high"
  },
  {
    "id": "P2",
    "category": "polish",
    "severity": "low",
    "status": "confirmed",
    "file": "src/cli/main.js",
    "line": 2862,
    "claim": "Dashboard/MCP resume approves the human-review gate and saves state before acquiring the run lock.",
    "evidence": "approveHumanReviewGate runs at 2862, before resumeGithubFlow/resumeLocalFlow calls trackRunState. If another process already owns the run, the approval is still persisted and the resume then fails with run_locked.",
    "suggested_fix": "Acquire the lock first (or check runLockHolder and refuse early), then approve and resume under it.",
    "confidence": "medium"
  },
  {
    "id": "P3",
    "category": "polish",
    "severity": "low",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 173,
    "claim": "release() re-implements the owner comparison that sameOwner() already provides.",
    "evidence": "Line 173 compares nonce, pid and hostname inline, while sameOwner (116-119) performs the same comparison for takeover.",
    "suggested_fix": "Use `if (!current || !sameOwner(current, owner))` so the two identity checks cannot drift apart.",
    "confidence": "high"
  },
  {
    "id": "P4",
    "category": "polish",
    "severity": "low",
    "status": "confirmed",
    "file": "src/integrations/netlify/local-runner.js",
    "line": 1461,
    "claim": "A run that is about to be auto-retried is first reported and persisted as a terminal failure.",
    "evidence": "onTerminalRun(normalized) persists the failed artifact, emits agentStatus terminal 'failed' and reports a terminal failure before retryFailedRun decides to retry, so the dashboard or CLI briefly shows a failure that then turns into 'retrying'. The new checkpoint logic depends on this ordering, because the saved record is 'failed' plus retrySubmitCheckpoint.",
    "suggested_fix": "Decide whether to retry before emitting terminal UI events, or mark that event as non-final (for example `retryPending: true`) so the UI does not flash a failure.",
    "confidence": "medium"
  }
]

Explore / Review Summary

The project is netlify-agent-executor (the nax CLI, v3.1.1), a CommonJS Node ≥20 tool that runs multi-step agent workflows through the Netlify Agent Runner API or GitHub. It includes a Vite dashboard, an MCP server, an SDK workspace (packages/nax-agent-runner-sdk) and MDX docs in site/content. JavaScript is typed with JSDoc and checked by tsc plus scripts/check-jsdoc-types.js.

The most recent six commits harden mid-step resume. They take over stale run locks under a takeover mutex, reclaim orphaned takeovers, release locks on failure (for long-lived dashboard and MCP processes), checkpoint automatic capacity and argument-limit retries so resume can adopt a replacement runner instead of duplicating it, and add a merge guard in run-state.js so a stale writer cannot settle a step whose newer attempt is still active. These are well scoped and each has a focused test.

Flows traced

  1. Fresh run: runWorkflow (main.js:3016) → trackRunState/lock → executeLocalFlow → submit → waitForLocalAgentRuns poll loop → onTerminalRun persistence → completeRun → lock release.
  2. Resume: nax run --resume → handleResumeCommand → prepareResume preview → resumeLocalFlow → recoverSubmissions (reconciles saved checkpoints) → planResume/reconcileStepInstances → lock → resumeStepInstances → completeLocalStep.
  3. Automatic retry checkpoint: SDK onSubmitCheckpoint → local-executor saves raw.retrySubmitCheckpoint → a successful retry replaces the record wholesale through onProgress (clearing it). After a crash, checkpointToRecover adopts the matched runner. Results of none or ambiguous stop resume on purpose (the test at resume-submission-recovery.test.js:267).

Defects (by severity)

  • High, R1: one runner's finalize or retry error aborts the whole step's polling. The cause is that error handling was added around the snapshot fetch only. The finalize and retry calls, which also hit the network, came later and sit outside it.
  • Medium, R2: resume does its remote reconciliation and early state writes before taking the lock. The cause is that the lock was added at the point where the first submit happens, not where the first side effect happens.
  • Low, R3: the takeover mutex's own stale-clear path is a check-then-act race.

Positive observations

  • mkdir as the only way to acquire the lock, owner nonces, and the re-check under the mutex are sound design choices.
  • resubmissionRun rebuilds raw from scratch, so stale checkpoints cannot leak into new attempts.
  • The error messages for resume refusals are specific and actionable, and they are documented in site/content/guides/resume-runs.mdx.

Improve (ranked)

Quick wins: R2 (reorder so the lock is taken first, a small change), P3 (reuse sameOwner), and the per-caller wording from P1.

Robustness: R1 is the most important fix. Also add a test where getRunner, listSessions or retry throw while a sibling run is still pending. For R3, prefer rename-based removal over rmSync.

UX polish: P1 (a --force-unlock hint that works on every path, or a stale rule for run locks with no owner file), P2 and P4.

Test gaps: no test covers a failing terminalRun or retryFailedRun inside a multi-run step. No test covers two resumes racing against a live owner (the error code the user gets). No test covers concurrent takeovers of an orphaned mutex.

Verification note: I could not run the targeted unit tests because dependencies are not installed in this environment (tsx is missing and there is no node_modules). I did not install them, to keep the working tree untouched.

Items Considered And Rejected

  1. Retry reconcile returning none still stops resume (resume.js:379). This looks like a stuck state, but it is intentional and tested (resume-submission-recovery.test.js:267): --force resubmits, and resubmissionRun resets raw, which clears the checkpoint.
  2. The step-level merge guard in mergeExistingStateForWrite does not require the existing step to be unsettled (run-state.js:353). This is harmless, because restoring a settled status to the same settled value is a no-op.
  3. withoutRetryCheckpoint in local-runner.js:1273 looks like dead code, because the executor writes the checkpoint to its own copy of the record. It still acts as a guard when syncRefreshedRuns swaps in the on-disk record, so it is not worth removing.

Gemini Results


Repository State
  • pinned_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • checked_out_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • state_match: yes
  • drift_commits: 0
  • drift_acceptable: yes
  • git_status_clean: yes

Structured Findings

[
  {
    "id": "R1",
    "category": "defect",
    "severity": "high",
    "status": "confirmed",
    "file": "src/storage/local/run-state.js",
    "line": 356,
    "claim": "Stale writer merge reverts workflow status to running but leaves stale terminal completedAt and failure fields.",
    "evidence": "In mergeExistingStateForWrite, when keptActiveNewerAttempt is true and incomingState is settled while existingState is not, incomingState.status is reverted to existingState.status ('running'). However, incomingState.completedAt, failureCode, and failureReason (set by stale completion/failure routines) are not cleared or synced from existingState, leaving an active running workflow record on disk with a terminal completedAt timestamp.",
    "suggested_fix": "When reverting incomingState.status because a newer active attempt was kept, also reset or delete incomingState.completedAt, incomingState.failureCode, and incomingState.failureReason (and incomingStep.completedAt for affected steps) if existingState does not have them.",
    "confidence": "high"
  },
  {
    "id": "R2",
    "category": "defect",
    "severity": "medium",
    "status": "confirmed",
    "file": "src/storage/local/graceful-run-state.js",
    "line": 12,
    "claim": "In-process runs clobber singleton activeRunState, preventing graceful interruption on process shutdown for concurrently or interleaved runs.",
    "evidence": "heldRunLocks tracks locks for multiple in-process runs in a Map, but activeRunState and activeInterruptHandler remain single global references. When multiple runs execute in-process via Dashboard or MCP, a newer run overwrites activeRunState; furthermore, when that newer run completes, clearTrackedRunState resets activeRunState to null. Consequently, on shutdown or exit, earlier in-flight runs in heldRunLocks have their locks released without being marked interrupted on disk.",
    "suggested_fix": "Store active tracked runs in a collection or iterate over heldRunLocks values in persistInterruptedState / onAnyExit so every currently held in-flight run is marked interrupted upon process termination.",
    "confidence": "high"
  },
  {
    "id": "R3",
    "category": "defect",
    "severity": "low",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 83,
    "claim": "describeRunLockOwner formats empty object owners as 'pid undefined on undefined' instead of reporting unreadable ownership.",
    "evidence": "runLockHolder returns {} when a lock directory exists but readRunLockOwner yields null or empty data. Because {} is truthy, describeRunLockOwner skips the '!owner' check and interpolates owner.pid and owner.hostname, generating 'pid undefined on undefined' which surfaces in dashboard 409 error messages and UI alerts.",
    "suggested_fix": "Update describeRunLockOwner to check if (!owner || (!owner.pid && !owner.hostname)) return 'an unknown owner (owner.json unreadable)'.",
    "confidence": "high"
  },
  {
    "id": "R4",
    "category": "polish",
    "severity": "low",
    "status": "confirmed",
    "file": "site/content/reference/commands.mdx",
    "line": 14,
    "claim": "Top-level CLI command 'nax lint' is documented in guides but missing from reference/commands.mdx.",
    "evidence": "The 'nax lint' command was added in the CLI suite to validate workflow flows and outputs with diagnostic flags (--json, --strict), and is referenced in custom workflow guides, but has no corresponding section in site/content/reference/commands.mdx.",
    "suggested_fix": "Add a dedicated '## nax lint' section to site/content/reference/commands.mdx documenting arguments, flags (--json, --strict), and return codes.",
    "confidence": "high"
  },
  {
    "id": "R5",
    "category": "polish",
    "severity": "low",
    "status": "confirmed",
    "file": "src/workflows/engine/resume.js",
    "line": 442,
    "claim": "Ambiguous retry submissions without run.sentAt render as 'sent undefined' in CLI ambiguous resume messages.",
    "evidence": "When maybeCreatedSubmission matches an automatic retry due to raw.retrySubmitCheckpoint, run.sentAt may not be populated on the top-level run object, causing ambiguous.push(`${instanceId} (sent ${run.sentAt}${candidates})`) to output '(sent undefined)'.",
    "suggested_fix": "Fallback to raw.retrySubmitCheckpoint.sentAt formatted as an ISO timestamp when run.sentAt is falsy.",
    "confidence": "high"
  }
]

Explore

Project Overview

netlify-agent-executor (nax) is a multi-step orchestration CLI, SDK, and dashboard for executing coordinated AI agent workflows across leading model providers (Claude, Gemini, Codex, OpenCode). Workflows execute against Netlify Agent Runner via API or GitHub Actions, managing execution state, attempt lineage, locks, artifact delivery, finding synthesis, and interactive dashboard inspections.

Architecture

  • Control Plane & CLI (src/cli/): Commander-based CLI entry points for workflow planning, step execution, resume/retry, handoffs, and catalog linting.
  • Workflow Engine (src/workflows/engine/): Handles multi-step flow execution (local-executor.js, github-executor.js), attempt lineage, prompt budget tracking, and resume reconciliation (resume.js, submission-recovery.js).
  • Findings & Artifacts (src/workflows/findings/, src/workflows/handoff-targets/): Parses structured model responses, adapts consensus reviews, and exports findings to GitHub issues, PR reviews, or .beads.
  • Storage & State (src/storage/local/): Implements filesystem-based monotonic run state storage, attempt lineage merging, process lock contention (run-lock.js), and graceful interrupt handling (graceful-run-state.js).
  • Agent Runner SDK (packages/agent-runner-sdk/): Core transport and runtime engine handling runner/session lifecycles, prompt delivery, and create/session reconciliation.
  • Docs Site (site/): Nextra-based documentation portal documenting guides, references, and concepts.

Key Execution Flows Traced

  1. Resume & Lineage Lifecycle:
    • nax run --resume <run-id> -> prepareResume() -> recoverSubmissions() -> planResume() -> resumeLocalFlow() -> trackRunState() (acquires run.lock) -> resumeStepInstances() -> completeRun().
  2. State Merge on Poller Completion:
    • Writer executes saveRunState() -> mergeExistingStateForWrite(). Superseded attempts are archived into step.attempts, while active disk attempts are preserved over stale incoming records.
  3. Lock Acquisition & Stale Takeover:
    • acquireRunLock() attempts atomic mkdir of run.lock. On collision, checks PID liveness on current host; if stale or --force-unlock is supplied, enters atomic .takeover mutex before removing stale directory.

Test Coverage Gaps

  • Lineage merge tests in tests/unit/run-state-lineage.test.js verify status preservation when a newer attempt is active on disk, but do not assert that timestamps (completedAt) and failure metadata (failureCode, failureReason) from the stale writer are removed.
  • tests/unit/graceful-run-state.test.js tests singleton tracking and re-entry locking, but does not test multi-run concurrent execution in long-lived server processes (e.g. Dashboard/MCP).

Conventions

  • Strict CommonJS typing with explicit JSDoc annotations; zero any usage.
  • Pure functions separated from side-effecting orchestrators (e.g., planResume is pure; resumeLocalFlow handles side-effects).
  • Atomic directory creation (mkdirSync) for OS-level lock guarantees.

Review

Recent Changes Summary

Commits in feat/lint-findings-resume introduced:

  • Resumable workflow engine supporting per-instance reconciliation and attempt replacement.
  • Mutual exclusion run lock with host/PID stale detection and .takeover mutex.
  • Automatic retry checkpointing (retrySubmitCheckpoint) and ambiguous submission adoption.
  • Monotonic attempt lineage merge preventing stale snapshots from marking active steps settled.
  • Workflow linting (nax lint) and findings extraction (findings.json).

Defects & Root Causes

  1. Stale Completion Timestamp Bleed (src/storage/local/run-state.js:356):
    • Root Cause: Commit b147c1d prevented stale step/run status from overwriting active disk attempts, but did not clean auxiliary completion/failure fields (completedAt, failureCode) attached to the stale incoming state.
  2. Concurrent In-Process Interrupt Tracking (src/storage/local/graceful-run-state.js:12):
    • Root Cause: While heldRunLocks was refactored into a Map to support multiple in-process runs, activeRunState remained a single variable, causing subsequent in-process runs to overwrite or nullify the shutdown interrupt hook.
  3. Unformatted Lock Owner in Error Reporting (src/storage/local/run-lock.js:83):
    • Root Cause: runLockHolder returns an empty object {} when a lock exists without valid owner metadata, causing describeRunLockOwner to format pid undefined on undefined.

Positive Observations

  • Lock takeover via a secondary .takeover directory avoids TOCTOU race conditions during stale lock removal.
  • The resume engine avoids re-running already completed agent instances, guaranteeing idempotency and cost efficiency.
  • Pure planning routines (planResume, reviewConsensusAdapter) make reconciliation logic easily testable and predictable.

Improve

Quick Wins

  • In src/storage/local/run-lock.js:83, guard against empty owner objects to present clean diagnostic messages.
  • In site/content/reference/commands.mdx:14, document nax lint alongside existing command references.
  • In src/workflows/engine/resume.js:442, format timestamps using raw.retrySubmitCheckpoint.sentAt when run.sentAt is undefined.

Polish Items

  • In site/content/guides/use-the-dashboard.mdx:195, describe the Findings and Resume run modal panels added to the dashboard UI.
  • In src/workflows/handoff-targets/pr-review.js:100, normalize file paths by stripping leading ./ before constructing GitHub blob URLs.

Refactoring Opportunities

  • Consolidate active run state tracking in src/storage/local/graceful-run-state.js directly into heldRunLocks (which already pairs { lock, runState }) so exit handlers operate uniformly over all active runs.

Items Considered And Rejected

  1. Race condition during takeover mutex cleanup in src/storage/local/run-lock.js:101:
    • Consideration: Multiple processes encountering a stale .takeover directory simultaneously could attempt concurrent removal.
    • Resolution: fs.rmSync(takeover) followed by createLockDir(takeover, owner) relies on atomic mkdir. Only one process succeeds in acquiring the directory, while the other receives null and safely yields.
  2. Missing runnerId on session retries in packages/agent-runner-sdk/src/engine.ts:1000:
    • Consideration: retry() on a session handle delegates to createFollowUp().
    • Resolution: createFollowUp() already explicitly passes handle.runnerId to checkpointSubmit('session', ..., handle.runnerId), so session context is fully preserved.
  3. Code block trailing newline requirements in src/workflows/findings/extract.js:63:
    • Consideration: FENCED_JSON_PATTERN expects a newline before closing backticks.
    • Resolution: When not in numberedOnly mode, the parser falls back to firstBalancedJsonSpan(sectionBody), correctly extracting valid JSON objects or arrays regardless of fence format anomalies.

Codex Results


Repository State
  • pinned_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • checked_out_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • state_match: yes
  • drift_commits: 0
  • drift_acceptable: yes
  • git_status_clean: yes

Structured Findings

[
  {"id":"R1","category":"defect","severity":"warning","status":"confirmed","file":"src/storage/local/run-state.js","line":337,"claim":"A stale snapshot can regress the step and workflow status after a newer replacement attempt has already finished.","evidence":"The merge marks a kept replacement only when it is non-terminal; when the newer disk attempt is terminal, lines 353-357 do not preserve the newer step/workflow statuses, so the older writer's failed status is saved beside the newer completed run.","suggested_fix":"Treat any kept different attempt as newer for status reconciliation, and add a regression test where a stale failed a1 snapshot arrives after completed a2.","confidence":"high"},
  {"id":"R2","category":"defect","severity":"warning","status":"confirmed","file":"src/storage/local/run-lock.js","line":39,"claim":"A lock whose owner metadata write fails is acquired successfully but cannot later be released or reclaimed automatically.","evidence":"createLockDir intentionally swallows owner.json write failures and returns true, while release at line 173 refuses deletion when readLockOwner returns null; staleness checks also cannot identify an ownerless run lock.","suggested_fix":"Fail acquisition and remove the just-created directory if owner metadata cannot be written, or retain sufficient in-process ownership evidence to release it safely; cover the write-failure path.","confidence":"high"}
]

Explore

Project overview and architecture

NAX is a CommonJS Node.js CLI for multi-step agent workflows across Netlify Agent Runner and GitHub Actions. Workflow execution is split between transport-specific executors, durable JSON state under .nax/, artifact writers, an MCP/dashboard layer, and the separate Nextra documentation site. JavaScript is JSDoc-typed and TypeScript checks those annotations.

Execution flows traced

  1. A Netlify API run builds per-agent submissions, checkpoints requests before sending, polls terminal state, persists artifacts, and can automatically retry transient capacity or argument-limit failures.
  2. Resume first reconciles checkpointed submissions against remote runners, plans keep/poll/resubmit/skip actions, acquires a per-run lock, and continues remaining steps.
  3. GitHub resume acquires the same lock, repairs or polls the first runnable step, then resumes normal step execution.

Findings and coverage gaps

The two defects above sit at concurrency/failure boundaries. Existing tests cover an active replacement resisting a stale terminal writer, but not a completed replacement resisting that writer (tests/unit/run-state-lineage.test.js:87). Lock tests cover takeover races and replaced owners, but not owner-file write failure (tests/unit/run-lock.test.js:31).

Conventions

The code consistently uses JSDoc shapes, dependency injection for network-facing tests, durable checkpoints before ambiguous requests, atomic directory locks, and focused node:test regressions. User documentation correctly lives under site/content.

Review

Recent changes summary

The last five commits hardened resume behavior: checkpointing automatic retries, releasing locks on retry/resume failures, serializing stale-lock takeover, reclaiming crashed takeover mutexes, and preventing an active replacement attempt from being settled by stale state.

Defects and root causes

  • Warning — src/storage/local/run-state.js:337: status protection is conditional on the replacement still being active. Attempt identity is the real freshness signal, so terminal replacements fall outside the guard and permit stale aggregate statuses.
  • Warning — src/storage/local/run-lock.js:39: acquisition treats owner metadata as optional, but safe release and stale detection treat it as mandatory. Those two invariants conflict.

Positive observations

The takeover mutex rechecks ownership before removal (src/storage/local/run-lock.js:158), automatic retry requests are checkpointed before sending (src/integrations/netlify/local-runner.js:1151), and reconciliation avoids silent duplicate submissions (src/workflows/engine/resume.js:440). Tests are narrowly targeted and explain the failure modes well.

Improve

Quick wins

  1. Extend lineage tests with a completed replacement followed by a stale failed snapshot (tests/unit/run-state-lineage.test.js:87).
  2. Make lock acquisition transactional with owner metadata and simulate writeFileSync failure (src/storage/local/run-lock.js:31).

Polish and refactoring

Centralize aggregate status reconciliation around attempt lineage rather than separate active/settled predicates (src/storage/local/run-state.js:314). This makes the intended monotonic rule easier to audit. Also isolate filesystem operations behind injected callbacks in the lock module (src/storage/local/run-lock.js:31) so permission and disk errors are testable without process-level fixtures.

Performance and design

No material performance bottleneck was found in the reviewed paths. Synchronous filesystem operations are appropriate for short state transitions, but correctness depends on keeping lock acquisition and metadata creation one logical transaction.

Validation

The focused dependency-free lineage and basic lock tests passed. npm test could not execute because the root install is incomplete (tsx and other root dependencies are missing); this is an environment limitation, not evidence of product failure. No build was run, consistent with the review-only contract and runner restriction.

Items Considered And Rejected

  • src/storage/local/run-lock.js:164 — recursive acquisition after losing a race looks suspicious, but it re-enters normal atomic mkdir arbitration and does not itself permit two owners.
  • src/workflows/engine/resume.js:464 — stopping after reconciliation returns no match is intentionally conservative because a delayed or unlisted remote submission could otherwise be duplicated.
  • src/integrations/netlify/local-runner.js:1117 — the 50-second initial poll delay is a UX tradeoff for remote startup latency, not a demonstrated defect.

Opencode Results


Context reviewed. This is the nax repo (netlify-agent-executor v3.1.1) — a monorepo with a CLI (src/cli/nax.js), workspaces (packages/*), a React/Vite dashboard (src/dashboard/web), and docs in site/content. Key rules I'll follow:

  • JSDoc-typed JS, no any
  • Run npm run dashboard:build after UI changes
  • Update site/content MDX for user-facing docs
  • No publishing without explicit confirmation

What would you like to work on?


Cross Review

  • Status: completed_with_failures
  • Usage: 306.24 credits, 46 steps, 3,172,922 tokens
  • Files: step metadata, usage

Claude Results

@github-actions

Copy link
Copy Markdown

NAX review completed with status: success

Final results

Review · 2026-09-26T05-09-15-216Z-review

  • Run ID: 2026-09-26T05-09-15-216Z-review
  • Flow: review
  • Transport: netlify-api
  • Status: completed
  • Usage: 817.8 credits, 258 steps, 14,417,962 tokens
  • Target: feat/lint-findings-resume (explicit-branch, verified)
  • Target SHA: b147c1d0921d1371996772dd6cda0dd093de3598
  • Files: usage

Contents

  1. Review · summary · metadata · usage
  2. Cross Review · summary · metadata · usage
  3. Summarize Consensus · summary · metadata · usage

Review

  • Status: completed
  • Usage: 467.36 credits, 184 steps, 9,735,710 tokens
  • Files: step metadata, usage

Claude Results


Repository State
  • pinned_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • checked_out_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • state_match: yes
  • drift_commits: 0
  • drift_acceptable: yes
  • git_status_clean: yes (checked with git status --short before and after the review; this report file sits under the untracked site/.netlify/ output path)

Structured Findings

[
  {
    "id": "R1",
    "category": "defect",
    "severity": "high",
    "status": "likely",
    "file": "src/storage/local/graceful-run-state.js",
    "line": 11,
    "claim": "SETTLED_RUN_STATUSES leaves out 'completed_with_failures' and 'cancelled', so the exit handler can overwrite those settled statuses with 'interrupted'.",
    "evidence": "persistInterruptedState (line 14) only skips completed/failed/awaiting_review. handleRetry (src/cli/main.js:2564) tracks the run and never changes runState.status up front. When a retry of a partial step fails, it throws at main.js:2683. The finally block at main.js:2711 releases the lock but keeps the run tracked on purpose, so onAnyExit -> persistActiveRunState('process-exit') rewrites status 'completed_with_failures' to 'interrupted'. A run that server.js:1037 set to 'cancelled' gets the same treatment.",
    "suggested_fix": "Base the check on isTerminalRunStatus(status) || status === 'completed_with_failures' || status === 'awaiting_review' (the same idea as isSettledStatus in run-state.js), and add a test where a retry of a partial step fails and the process then exits.",
    "confidence": "high"
  },
  {
    "id": "R2",
    "category": "defect",
    "severity": "medium",
    "status": "likely",
    "file": "src/workflows/engine/github-executor.js",
    "line": 585,
    "claim": "Releasing the lock while keeping the run tracked lets a long-lived process (dashboard/MCP) later write 'interrupted' over a run that another process has since locked and is running.",
    "evidence": "releaseTrackedRunLock (graceful-run-state.js:131) drops the lock, but activeRunState still points at the stale snapshot. If another process acquires the lock and resumes, the dashboard's later shutdown/exit calls saveRunState with status 'interrupted'. The merge guard in run-state.js only restores the existing status when the incoming status is *settled*, and 'interrupted' is not settled, so the stale write wins. The same applies to handleRetry at main.js:2711.",
    "suggested_fix": "After releasing the lock in the failure path, also clear activeRunState. Alternatively, have persistInterruptedState skip runs whose lock this process no longer holds (heldRunLocks has no entry for runState.dir).",
    "confidence": "medium"
  },
  {
    "id": "R3",
    "category": "defect",
    "severity": "medium",
    "status": "confirmed",
    "file": "src/workflows/engine/resume.js",
    "line": 379,
    "claim": "When reconcile finds no replacement for an automatic retry, resume stops with an error saying nax 'never saved a runner id', and --force then resubmits from scratch instead of polling the runner it already has.",
    "evidence": "maybeCreatedSubmission returns true whenever raw.retrySubmitCheckpoint exists, even when run.runnerId is set. submission-recovery.js:92 records kind 'none' but never clears the checkpoint, so every resume ends in resume_ambiguous_submission (resume.js:465). That message says no runner id was saved, which is false here. The test at tests/unit/resume-submission-recovery.test.js:276 locks in the stop but not the message or what --force does.",
    "suggested_fix": "Give retry checkpoints their own message that names the existing runnerId, and on --force consider polling the original runner (or clearing the checkpoint) instead of a full resubmit.",
    "confidence": "medium"
  },
  {
    "id": "R4",
    "category": "polish",
    "severity": "low",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 173,
    "claim": "release() repeats the owner-identity comparison inline instead of calling sameOwner().",
    "evidence": "sameOwner (line 116) checks nonce/pid/hostname. release checks the same three fields by hand, so the two could drift apart.",
    "suggested_fix": "Replace the check with `if (!current || !sameOwner(current, owner))`.",
    "confidence": "high"
  },
  {
    "id": "R5",
    "category": "polish",
    "severity": "low",
    "status": "confirmed",
    "file": "src/storage/local/run-state.js",
    "line": 324,
    "claim": "Core merge helpers (mergeExistingStateForWrite, mergeRunDurableFields, matchingExistingRun, hasValue) have no JSDoc, which goes against the AGENTS.md typing rule; check:jsdoc-types does not catch it.",
    "evidence": "scripts/check-jsdoc-types.js only requires type info somewhere in each file (typeInfoPattern), not on each function, so a file with one annotated helper passes. graceful-run-state.js (persistInterruptedState, persistActiveRunState, installGracefulRunStateHandlers) has the same gap.",
    "suggested_fix": "Add @param/@returns to these helpers. Optionally tighten the checker to flag exported or top-level functions that have no JSDoc block.",
    "confidence": "high"
  },
  {
    "id": "R6",
    "category": "polish",
    "severity": "low",
    "status": "confirmed",
    "file": "src/storage/local/run-state.js",
    "line": 356,
    "claim": "When the step/workflow status is restored because a newer attempt is still active, related settle fields (for example completedAt or failureCode) stay as the stale writer set them.",
    "evidence": "Only `status` is restored from existingState/existingStep. The other fields the stale writer set pass through, so the record can end up 'running' with a completedAt value.",
    "suggested_fix": "Restore or drop the paired settle fields together with status, and add an assertion to run-state-lineage.test.js.",
    "confidence": "medium"
  }
]

Explore (condensed)

netlify-agent-executor (the nax CLI, v3.1.1) is a CommonJS Node ≥20 CLI with JSDoc-checked JS (tsc checkJs). It also includes a Vite dashboard (src/dashboard/web), an MCP server (src/mcp), a control plane, and a workspace SDK (packages/nax-agent-runner-sdk). Run state is stored on local disk under .nax/workflows/<runId>/workflow.json. Writes are atomic (tmp + fsync + rename) and go through a per-write state lock. There is also a new process-level run lock (run.lock/ directory plus a run.lock.takeover mutex).

Flows traced:

  1. nax run → handleRunEngine (main.js ~3016) → trackRunState (takes the run lock) → executeLocalFlow/executeGithubFlow. On failure it sets failed and calls clearTrackedRunState.
  2. nax retry → handleRetry (main.js 2534) → trackRunState. On success it calls clearTrackedRunState; on throw, the finally block only calls releaseTrackedRunLock (the source of R1/R2).
  3. Resume (dashboard/MCP run it in-process via runner.js:321) → recoverSubmissions → reconcileStepInstances → poll, resubmit, or stop (R3).

Review

Recent changes: The last ~10 commits harden the run lock (stale takeover under a mutex, orphaned-mutex reclaim, release on failure), checkpoint automatic retries for resume, and stop a stale state snapshot from settling a step whose newer attempt is still active. Each commit was a response to a Codex review round, and the fixes are narrow and come with tests.

Root cause behind R1/R2: Lock ownership and "active run" tracking live in two separate module-level registries (heldRunLocks and the activeRunState singleton), and each has its own settled-status rule. Commit 4620867 split lock release off from untracking so that a crash still marks the run interrupted. That makes the exit handler's status check load-bearing, and the check uses a narrower status set than core/status.js and run-state.js#isSettledStatus. There are now three definitions of "settled" (graceful-run-state, run-state, core/status). Merging them into one would remove this whole class of bug.

Positive:

  • The takeover protocol is careful: mkdir is still the only way to acquire, the owner is re-checked under the mutex, and the recursive retry on a lost race does not use force.
  • There is an 8-process race test.
  • Attempt merging is monotonic and compare-and-swap based.
  • Resume stops safely instead of resubmitting blindly when the outcome is uncertain.

Tests run: node --test tests/unit/run-lock.test.js tests/unit/run-state-lineage.test.js passed 15 of 15. The other suites need the tsx loader and @davidwells/graceful-exit, which are not installed in this environment. I did not install them, to keep the tree clean, so R1/R2 are confirmed by reading the code, not by running it.

Improve (ranked)

Defects (fix first)

  1. R1: widen the exit handler's settled-status check. This is a one-line fix with high impact, because a status that was set on purpose gets silently corrupted.
  2. R2: stop the exit handler from writing to runs whose lock this process gave up.
  3. R3: make the error message for a retry checkpoint accurate, and make --force poll the existing runner instead of resubmitting.

Polish

  • R4: reuse sameOwner in release().
  • R5: add the missing JSDoc, and consider making check:jsdoc-types check each function.
  • R6: restore the paired settle fields when status is restored.
  • run-lock.js:145: the run_locked message is good, but add the lock path so users can inspect owner.json directly.

Design concern: Move the "settled status" predicate into src/core/status.js and use it in graceful-run-state.js, run-state.js, and local-executor.js. Today it is defined separately in each of those files.

Items Considered And Rejected

  1. The takeover race after removing the lock (run-lock.js:164): if another process wins the mkdir, the recursive call without force correctly throws run_locked. This is sound.
  2. The step-level status restore at run-state.js:353 has no !isSettledStatus(existingStep.status) guard, unlike the workflow-level one: a disk step that is settled while holding an active newer attempt is a contradictory state that the writers never produce. Not worth the backlog.
  3. The retry checkpoint lingering after a failed retry create: onTerminalRun/onProgress(retry) replace the whole run object with the poller's copy, which never carried the checkpoint, so it is cleared once the run settles.

Gemini Results


Repository State
  • pinned_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • checked_out_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • state_match: yes
  • drift_commits: 0
  • drift_acceptable: yes
  • git_status_clean: yes

Structured Findings

[
  {
    "id": "R1",
    "category": "defect",
    "severity": "critical",
    "status": "confirmed",
    "file": "src/storage/local/run-state.js",
    "line": 337,
    "claim": "Stale snapshot writer overwrites settled step and workflow status when a newer disk attempt has completed.",
    "evidence": "In mergeExistingStateForWrite, line 337 gates setting stepKeptActiveNewerAttempt on !isTerminalRunStatus(merged.status). If the newer attempt on disk has already completed (terminal), this evaluates to false. Lines 353 and 356 then fail to preserve existingStep.status and existingState.status, allowing a delayed snapshot from a superseded attempt to overwrite completed workflow and step statuses with failed.",
    "suggested_fix": "Set stepKeptActiveNewerAttempt = true whenever merged.attemptId !== incomingRun.attemptId regardless of whether merged.status is terminal, and in line 357 preserve existingState.status whenever keptActiveNewerAttempt is true.",
    "confidence": "high"
  },
  {
    "id": "R2",
    "category": "defect",
    "severity": "warning",
    "status": "confirmed",
    "file": "src/cli/main.js",
    "line": 2860,
    "claim": "Human review gate is approved and persisted to disk before acquiring the run lock in resumeRunById.",
    "evidence": "In resumeRunById, approveHumanReviewGate modifies and saves workflow.json to disk before resumeLocalFlow or resumeGithubFlow calls trackRunState to acquire the run lock. If another process already holds run.lock, the disk state is mutated to running/approved before trackRunState throws run_locked.",
    "suggested_fix": "Acquire the run lock with trackRunState(runState) at the orchestration boundary before calling approveHumanReviewGate, and ensure releaseTrackedRunLock is invoked in a finally block.",
    "confidence": "high"
  },
  {
    "id": "R3",
    "category": "defect",
    "severity": "warning",
    "status": "confirmed",
    "file": "src/dashboard/server.js",
    "line": 1802,
    "claim": "Dashboard and workflow runner record an invalid 'nax resume' CLI command instead of 'nax run --resume'.",
    "evidence": "startResumeRun in src/dashboard/server.js:1802 and resumeWorkflow in src/workflows/engine/runner.js:313 record command: ['nax', 'resume', runId, ...]. Because nax has no top-level resume command (only nax run --resume), running the recorded command directly in a terminal fails with commander error unknown command 'resume'.",
    "suggested_fix": "Change the command array in src/dashboard/server.js:1802 and src/workflows/engine/runner.js:313 to ['nax', 'run', '--resume', runId, ...].",
    "confidence": "high"
  },
  {
    "id": "R4",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "site/content/reference/mcp.mdx",
    "line": 10,
    "claim": "MCP reference documentation states 13 entity-first tools instead of 14 after the addition of run_resume.",
    "evidence": "site/content/reference/mcp.mdx:10 states 'NAX exposes 13 entity-first tools', but lines 19-33 list 14 tools including run_resume, and line 177 documents ### run_resume.",
    "suggested_fix": "Update the tool count from 13 to 14 in site/content/reference/mcp.mdx:10.",
    "confidence": "high"
  },
  {
    "id": "R5",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/workflows/handoff-targets/pr-review.js",
    "line": 125,
    "claim": "PR review handoff fails to match diff lines when finding file paths include a leading './'.",
    "evidence": "pr-review.js:125 looks up diff lines with diffLines.get(finding.file). GitHub PR file paths never have a leading './', so findings produced with paths like ./src/index.js fail the map lookup and are placed in 'Outside this diff'.",
    "suggested_fix": "Normalize finding.file by stripping any leading ./ before diff line lookup, e.g. finding.file.replace(/^\\.\\//, '').",
    "confidence": "high"
  },
  {
    "id": "R6",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/workflows/engine/resume.js",
    "line": 445,
    "claim": "Ambiguous submission stop message renders 'sent undefined' when reconciling automatic retries.",
    "evidence": "src/workflows/engine/resume.js:445 constructs ambiguous.push(`${instanceId} (sent ${run.sentAt}${candidates})`). When maybeCreatedSubmission is true due to raw.retrySubmitCheckpoint, run.sentAt is often undefined on the run record.",
    "suggested_fix": "Fall back to raw.retrySubmitCheckpoint?.sentAt || raw.submitCheckpoint?.sentAt or omit the sent clause if absent.",
    "confidence": "high"
  }
]

Explore

Project Overview

NAX (netlify-agent-executor) is an agent orchestration engine and CLI tool designed to run multi-step, multi-agent AI workflows (Claude, OpenAI Codex, Gemini, OpenCode) targeting GitHub repositories and the Netlify Agent Runner platform. The workspace consists of:

  • CLI & Core Engine (src/): CommonJS with JSDoc annotations implementing command dispatch, YAML workflow resolution, remote execution via Netlify Agent Runner SDK or GitHub Actions, local state storage in .nax/workflows/, and an in-process MCP server.
  • Dashboard (src/dashboard/): Hono HTTP server with SSE event streaming and Vite/React SPA frontend.
  • Documentation Site (site/): Next.js with Nextra 4 and Turbopack rendering canonical guides, concepts, and command references.

Architecture

  • Workflow State & Storage (src/storage/local/run-state.js, run-lock.js, graceful-run-state.js): Persists workflow execution trees to .nax/workflows/<run-id>/workflow.json. Implements optimistic per-write locking, PID-liveness stale lock takeovers via run.lock, and graceful exit signal tracking.
  • Execution Engine (src/workflows/engine/): Handles local/remote lifecycle (local-executor.js, github-executor.js), mid-step resume reconciliation (resume.js, submission-recovery.js), and artifact extraction.
  • Findings & Handoff (src/workflows/findings/, src/workflows/handoff-targets/): Structured consensus normalization (findings.json) with outbound targets for Beads (.beads), GitHub Issues (gh issue create), and PR Reviews (gh api .../reviews).
  • Control Plane & MCP (src/mcp/): Exposes 14 entity-first tools over stdio for agent interaction with project routing and scope isolation.

Execution Flows Traced

  1. Mid-Step Resume (nax run --resume <run-id>):
    src/cli/commands/resume.js -> prepareResume() -> recoverSubmissions() (queries Agent Runner SDK for checkpointed requests without runner IDs) -> reconcileStepInstances() (determines keep, poll, resubmit, submit, skip) -> formatResumePreview() -> confirms TTY -> resumeLocalFlow() -> trackRunState() (acquires run.lock) -> resubmits uncompleted agents and continues remaining steps.
  2. Review Consensus & Findings Handoff (nax handoff --to <target>):
    Workflow runs review flow -> extractStructuredBlocks() parses ## 2. Structured Consensus -> reviewConsensusAdapter() normalizes findings -> nax handoff --to beads|github-issues|pr-review invokes target plan() and idempotent apply().
  3. Flow Linting (nax lint):
    src/cli/commands/lint.js -> discoverFlowEntries() -> loads YAML manifests and prompts -> validates structure, lineages, agent configurations, and transports in one pass -> reports categorized errors/warnings with actionable fix hints.

Test Coverage & Conventions

  • Test suite uses node:test (npm test, npm run check:jsdoc-types, npm run check:import-direction).
  • Excellent coverage for lock contention, stale takeover, attempt lineage merging, and MCP tools.
  • Strict conventions: CommonJS without semicolons, JSDoc annotations, no any, labelled box logs, and no mock logic for internal domain functions.

Review

Recent Changes Summary

Branch feat/lint-findings-resume introduced:

  • Diagnostic workflow linter (nax lint) covering over 35 structural, prompt, and transport checks.
  • Mid-step resume (nax run --resume <run-id>), including SDK pre-send request checkpoints, ambiguous submission reconciliation, and attempt history lineage.
  • Stale process run locks (run.lock) with owner metadata, PID-liveness detection, and takeover mutexes.
  • Outbound findings handoff to Beads, GitHub Issues, and GitHub PR reviews.
  • Canonical user guides for resume, findings handoff, and MCP tools.

Defects

[Critical] src/storage/local/run-state.js:337
  • Defect: Stale writer snapshot overwrites completed step and workflow status when a newer attempt on disk has already settled.
  • Root Cause: Commit b147c1d intended to prevent a stale snapshot from marking an active step as completed/failed. However, line 337 added && !isTerminalRunStatus(merged.status). When the newer attempt on disk has already completed (status: 'completed'), this check evaluates to false, so stepKeptActiveNewerAttempt is not set. As a result, lines 353 and 356 skip preserving disk statuses, and the stale snapshot's failed status overwrites completed on disk.
  • Recommendation: Set stepKeptActiveNewerAttempt = true whenever merged.attemptId !== incomingRun.attemptId. Also in line 357, remove !isSettledStatus(existingState.status) so that an existing completed workflow status is never downgraded by a stale writer.
[Warning] src/cli/main.js:2860
  • Defect: Human review gate is approved and persisted to disk before acquiring the run lock.
  • Root Cause: In resumeRunById, approveHumanReviewGate directly calls saveRunState before trackRunState is called inside resumeLocalFlow / resumeGithubFlow. If another process holds the run lock, disk state is prematurely updated to running/approved before run_locked is thrown.
  • Recommendation: Acquire the run lock with trackRunState(runState) prior to invoking approveHumanReviewGate, ensuring release in a finally block if gate approval or subsequent resume fails.
[Warning] src/dashboard/server.js:1802 & src/workflows/engine/runner.js:313
  • Defect: Stored/emitted command is ['nax', 'resume', ...] which is an invalid root CLI command.
  • Root Cause: The CLI surface exposes resume under nax run --resume <run-id>. Emitting ['nax', 'resume', runId] causes users copying command strings from dashboard events or logs to encounter commander unknown command 'resume'.
  • Recommendation: Update the command array in both locations to ['nax', 'run', '--resume', runId, ...].

Positive Observations

  • Robust Takeover Protocol: run-lock.js provides reliable process concurrency control with PID liveness checks and takeover mutexes without arbitrary timeouts.
  • Lossless Attempt History: mergeAttemptRun accurately preserves prior attempts in step.attempts while tracking superseding attempt lineage.
  • Consistent Documentation: MDX documentation in site/content is thoroughly verified by automated drift tests (tests/unit/lint-docs.test.js).

Improve

Quick Wins

  1. MCP Reference Tool Count (site/content/reference/mcp.mdx:10): Update "13 entity-first tools" to "14 entity-first tools" to match the table and run_resume documentation.
  2. PR Review Path Matching (src/workflows/handoff-targets/pr-review.js:125): Strip leading ./ from finding.file before checking diffLines so that relative finding paths match diff patch lines.
  3. Ambiguous Error Formatting (src/workflows/engine/resume.js:445): Use raw.retrySubmitCheckpoint?.sentAt || raw.submitCheckpoint?.sentAt to prevent printing (sent undefined).

Polish Items

  • CLI Alias: Consider adding nax resume <run-id> as a root alias pointing to nax run --resume <run-id> in src/cli/commands/nax.js for CLI ergonomics.
  • Terminal Color Configuration: Ensure CLI colored diagnostics (src/cli/commands/lint.js:93) honor NO_COLOR environment variable standards in addition to TTY detection.

Refactoring Opportunities

  • Run Lock Acquisition Boundary: Unify lock acquisition across CLI resume, CLI retry, dashboard resume, and MCP resume into a single helper (executeWithRunLock) to prevent lock lifecycle drift.

Items Considered And Rejected

  1. src/storage/local/run-lock.js:125 - Auto-expiring ownerless run.lock directories:
    Investigated whether an ownerless run.lock (from a process crashing between mkdirSync and owner.json write) should automatically expire after a timeout. Rejected because multi-agent workflow runs can legitimately run for 30-45 minutes; unlike the sub-second takeover mutex (run.lock.takeover), applying an age-based heuristic to run.lock risks race conditions on long runs. The existing requirement for --force-unlock is intentional and safe.

  2. src/cli/commands/resume.js:42 - Recommending nax handoff to list runs:
    Investigated whether referring users to nax handoff when a run is not found is misleading. Rejected because nax handoff without arguments opens an interactive picker of all previous workflow runs, making it an effective run discovery mechanism.

  3. src/integrations/github/gh-cli.js - Replacing gh CLI spawns with Octokit:
    Investigated replacing CLI spawns with the Octokit REST library. Rejected because NAX deliberately relies on the authenticated system gh binary for zero-configuration authentication and PR/issue creation across environments.

Codex Results


Repository State
  • pinned_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • checked_out_sha: b147c1d0921d1371996772dd6cda0dd093de3598
  • state_match: yes
  • drift_commits: 0
  • drift_acceptable: yes
  • git_status_clean: yes

Structured Findings

[
  {
    "id": "R1",
    "category": "defect",
    "severity": "warning",
    "status": "confirmed",
    "file": "src/storage/local/run-state.js",
    "line": 353,
    "claim": "A stale snapshot can regress a completed step and workflow to running even though its completed agent record is preserved.",
    "evidence": "mergeAttemptRun keeps the terminal disk run, but the enclosing statuses are protected only when a newer attempt was retained. A direct state-file probe produced workflow=running, step=running, run=completed.",
    "suggested_fix": "Recompute enclosing statuses after merging, or preserve terminal step/workflow statuses whenever merged current runs cannot justify regression; add a regression test.",
    "confidence": "high"
  },
  {
    "id": "R2",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "tests/unit/run-lock-processes.test.js",
    "line": 31,
    "claim": "Process-test setup can hang without an actionable failure when a worker exits before emitting REPORT.",
    "evidence": "startWorker resolves only from parsed stdout and has no early-exit or error rejection path. Missing runtime dependencies caused two tests to be cancelled with pending promises instead of reporting the child failure.",
    "suggested_fix": "Reject startWorker on child error or premature exit and include captured stderr.",
    "confidence": "high"
  }
]

Explore

Project overview

NAX is a CommonJS Node CLI and dashboard for orchestrating multi-agent workflows through Netlify Agent Runner and GitHub Actions. The documentation site is Next.js 16, React 19, Nextra, and MDX.

Architecture

  • CLI command registration: src/cli/commands/nax.js
  • Workflow execution: src/workflows/engine/
  • Netlify submission and polling: src/integrations/netlify/local-runner.js
  • Durable local state and locks: src/storage/local/
  • Dashboard: src/dashboard/
  • Canonical user documentation: site/content/

Execution flows traced

  1. Fresh workflow creation through CLI dispatch, run-state persistence, submission, polling, and artifact completion.
  2. Interrupted Netlify API resume through preparation, ambiguous-submission reconciliation, attempt replacement, and continued execution.
  3. Concurrent resume protection through process-level run locks, takeover mutexes, ownership checks, and release paths.

Test coverage gaps

The lineage tests verify that terminal agent records survive stale writes, but do not assert the enclosing step and workflow statuses in that scenario: tests/unit/run-state-lineage.test.js:34.

Conventions

JavaScript generally uses precise JSDoc types. Persistence uses atomic rename and explicit locking. Recent concurrency work favors attempt IDs and compare-and-swap lineage rather than positional merging.

Review

Recent changes summary

The last five commits introduced automatic-retry checkpoints, submission reconciliation, run-lock release on failures, takeover serialization, and protection against stale snapshots settling newer attempts.

Defects

R1 is the substantive defect. Its root cause is that status reconciliation is conditional on retaining a different, newer attempt. The same-attempt terminal-preservation branch does not inform the enclosing status merge.

Positive observations

The run-lock nonce checks, takeover mutex, durable submission checkpoints, and focused crash-recovery tests are thoughtful and materially reduce duplicate execution risk.

Focused tests could not be fully executed because this checkout lacks installed root dependencies (tsx and @davidwells/box-logger). The dependency-independent lock and lineage tests that ran passed, aside from process tests whose workers required the missing package.

Improve

Quick wins

  • Add a regression case asserting workflow, step, and run statuses together after a stale same-attempt save: tests/unit/run-state-lineage.test.js:34.
  • Make worker startup reject on premature exit: tests/unit/run-lock-processes.test.js:31.

Polish items

R2 would turn opaque cancelled tests into immediate failures containing the worker’s stderr.

Refactoring opportunities

Centralize status derivation after state merging instead of selectively repairing enclosing statuses inside individual merge branches: src/storage/local/run-state.js:319.

Performance notes

No material performance regression was identified. Synchronous state-lock waiting is bounded and state writes remain atomic.

Design concerns

Persisted run, step, and agent statuses can currently disagree. Treating enclosing statuses as derived state would reduce this class of concurrency bug.

Items Considered And Rejected

  • The ownerless takeover lock’s 60-second recovery window is deliberate protection for the mkdir/write crash gap: src/storage/local/run-lock.js:108.

…iled runs

PR review: resume approved a review gate, reconciled and planned before
taking the run lock, and failed retry/GitHub resume paths released the
lock but stayed tracked, so a later exit could write a stale snapshot
over state another process owned. Resume now locks first (refusals leave
the run untouched) and failed paths save the run as interrupted under the
lock, then stop tracking.
…t and nax lint

PR review: automatic-retry ambiguity showed 'sent undefined', the
run_locked hint suggested --force-unlock on commands without it, the MCP
reference said 13 tools (14), and the command reference lacked nax lint.
@DavidWells
DavidWells merged commit f20220c into master Sep 26, 2026
7 of 9 checks passed
@github-actions

Copy link
Copy Markdown

NAX review completed with status: cancelled

Final results

Review · 2026-09-26T15-59-04-667Z-review

  • Run ID: 2026-09-26T15-59-04-667Z-review
  • Flow: review
  • Transport: netlify-api
  • Status: running
  • Usage: 250.95 credits, 41 steps, 1,837,271 tokens
  • Target: feat/lint-findings-resume (explicit-branch, verified)
  • Target SHA: a13ef4b19f71bdc77ebd58d060d6bcea815814af
  • Files: usage

Contents

  1. Review · summary · metadata · usage

Review

  • Status: running
  • Usage: 250.95 credits, 41 steps, 1,837,271 tokens
  • Files: step metadata, usage

Claude Results


Repository State
  • pinned_sha: a13ef4b
  • checked_out_sha: a13ef4b
  • state_match: yes
  • drift_commits: 0
  • drift_acceptable: yes
  • git_status_clean: yes (git status --short was empty before and after the review; no files were changed)

Structured Findings

[
  {
    "id": "R1",
    "category": "defect",
    "severity": "warning",
    "status": "likely",
    "file": "src/cli/commands/resume.js",
    "line": 55,
    "claim": "Every resume entry point loads the run state before it takes the run lock and never reloads it afterwards, so 'take the run lock before resume reads' (05c9ec5) still leaves a stale-snapshot window.",
    "evidence": "handleResumeCommand, resumeRunById (src/cli/main.js:2857-2862) and the dashboard all call listRunStates()/durableRunStateForId() first. resumeLocalFlow (src/workflows/engine/local-executor.js:1388) then locks that same in-memory object and runs recoverSubmissions/planResume on it without reading the file again. If another executor saves progress or settles between the load and the lock, resume plans from the old snapshot and can resubmit instances that already ran. The write-merge in run-state.js only fixes this up after the submissions have gone out.",
    "suggested_fix": "Right after trackRunState() succeeds in resumeLocalFlow/resumeGithubFlow/resumeRunById, reload state from runState.dir, Object.assign it onto the tracked object, and check the run is still resumable before recovery and planning.",
    "confidence": "medium"
  },
  {
    "id": "R2",
    "category": "defect",
    "severity": "warning",
    "status": "confirmed",
    "file": "src/storage/local/graceful-run-state.js",
    "line": 140,
    "claim": "settleTrackedRun marks a failed run 'interrupted' only if it is the most recently tracked run, so the dashboard and MCP, which run several runs in the same process, leave earlier failed runs stuck as 'running'.",
    "evidence": "activeRunState is a single global (line 5) but heldRunLocks is a per-directory Map (line 9). If run A starts, then run B starts (activeRunState is now B), and A then throws, settleTrackedRun(A) skips persistInterruptedState because activeRunState !== A. It only releases A's lock, so A's state file still says 'running' with no lock or live process. Shutdown persistence (persistActiveRunState) has the same limit and saves only the last run.",
    "suggested_fix": "Store runState/onInterrupt per directory in heldRunLocks, then settle and persist on shutdown by looping over that map instead of the activeRunState singleton.",
    "confidence": "high"
  },
  {
    "id": "R3",
    "category": "defect",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 129,
    "claim": "When owner.json is missing, runLockHolder returns {}, and the dashboard shows it as 'already being executed by pid undefined on undefined'.",
    "evidence": "runLockHolder returns `owner || {}`. Because {} is truthy, describeRunLockOwner skips its `!owner` branch. Running `node -e` with describeRunLockOwner({}) prints 'pid undefined on undefined'. The callers are src/dashboard/server.js:1388 and src/dashboard/storage/local-runs.js:322.",
    "suggested_fix": "Have describeRunLockOwner treat an owner without a pid or hostname as unknown, or return null from runLockHolder with a separate 'held' flag.",
    "confidence": "high"
  },
  {
    "id": "R4",
    "category": "defect",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/workflows/engine/resume-preparation.js",
    "line": 83,
    "claim": "prepareResume now calls the Agent Runner API on every preview. That includes the dashboard's GET /resume-preview, which re-runs whenever the run query is invalidated and runs even while another process holds the run lock.",
    "evidence": "recoverSubmissions → reconcileLocalSubmission → client.reconcileCreate/reconcileSession (lists runners/sessions). getResumePreview (src/dashboard/storage/local-runs.js:319) calls prepareResume before it checks runLockHolder. The resumePreview query key sits under run(runId), so each invalidateQueries(run(runId)) refetches it (dashboard-cache.ts:85). The CLI also reconciles twice per resume, once in the preview and once in resumeLocalFlow.",
    "suggested_fix": "Check runLockHolder before prepareResume in getResumePreview and short-circuit when the run is locked. Cache reconcile results per checkpoint requestId for a short TTL, or add a `reconcile: false` option for the dashboard's passive previews.",
    "confidence": "high"
  },
  {
    "id": "R5",
    "category": "defect",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/integrations/netlify/local-runner.js",
    "line": 947,
    "claim": "A session checkpoint with no source handle is reported as reconcile result 'none', even though nothing was checked.",
    "evidence": "`if (!sourceHandle) return { kind: 'none' }`. submission-recovery.js:92 then stores submitReconcile {kind:'none'}. Resume still stops as ambiguous, so this is safe, but the stored reconcile table claims nax looked and found no runner. For a retry session with no run.sdkHandle, the sourceRun fallback (submission-recovery.js:67) searches completed steps for the run's own runnerId and can never match.",
    "suggested_fix": "Return or record a distinct outcome (for example {kind:'unchecked', reason:'no source handle'}) and handle it like 'error' without the auth check.",
    "confidence": "high"
  },
  {
    "id": "R6",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/workflows/engine/resume.js",
    "line": 468,
    "claim": "The resume_ambiguous_submission message tells the user to check the Netlify agent runs page even when nax already reconciled and found no matching runner.",
    "evidence": "Only reconciled.candidates is added to the message (line 443). A reconcile result of 'none' (which still stops for retry and pending runs, as the test at tests/unit/resume-submission-recovery.test.js:276 expects) is not mentioned. Users can't tell whether the automatic check ran.",
    "suggested_fix": "Add per-instance reconcile context, for example '; nax checked: no runner found' or '; check failed: <message>', so users can decide on --force with confidence.",
    "confidence": "high"
  },
  {
    "id": "R7",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/workflows/engine/local-executor.js",
    "line": 1410,
    "claim": "If resume is refused after recovery has already adopted a runner or recorded none/ambiguous, those recovery results are thrown away.",
    "evidence": "recoverSubmissions changes runState in place, but the preflight catch calls clearTrackedRunState without saveRunState. Examples are a moved branch or an ambiguous stop after a match on another instance. The next resume, the dashboard reconcile table and `nax handoff` don't see the adopted runner or the candidate list.",
    "suggested_fix": "Save run state inside the preflight catch while the lock is still held, or at least when recovery changed any run.",
    "confidence": "medium"
  },
  {
    "id": "R8",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 173,
    "claim": "release() repeats the sameOwner() comparison inline, so the two ownership checks can drift apart.",
    "evidence": "Line 173 re-implements the nonce/pid/hostname comparison already defined at line 116.",
    "suggested_fix": "Use `if (!current || !sameOwner(current, owner))`.",
    "confidence": "high"
  },
  {
    "id": "R9",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/workflows/engine/github-executor.js",
    "line": 519,
    "claim": "resumeGithubFlow calls clearTrackedRunState on every success path and then settleTrackedRun again in finally, and repeats the 'Resuming/Flow/State/Repair' banner in two branches.",
    "evidence": "Lines 526/548/570/582 clear tracking, then the finally block (585) settles again. That second call is a harmless no-op but hides the real contract. The four console.log lines are duplicated at 533-536 and 555-558.",
    "suggested_fix": "Let finally be the only settle/clear point and factor the banner into a helper.",
    "confidence": "high"
  },
  {
    "id": "X1",
    "category": "rejected",
    "severity": "suggestion",
    "status": "rejected",
    "file": "src/storage/local/run-lock.js",
    "line": 111,
    "claim": "The rmSync+mkdir reclaim of an orphaned takeover mutex is a TOCTOU race.",
    "evidence": "This is real but already tracked as open bead nax-v9p9.",
    "suggested_fix": "n/a",
    "confidence": "high"
  }
]

Explore (condensed)

  • Project: netlify-agent-executor (nax CLI, v3.1.1) is a CommonJS Node ≥20 tool, typed with JSDoc. It runs multi-step AI agent workflows over the GitHub or Netlify Agent Runner API. It includes a Vite dashboard (src/dashboard/web), an MCP server (src/mcp), a workspace SDK (packages/agent-runner-sdk, TypeScript) and an MDX docs site (site/content).
  • Flows traced:
    1. nax run --resume <id> → handleResumeCommand → prepareResume (preview on a cloned state, including submission recovery) → resumeLocalFlow → lock → recoverSubmissions → planResume → branch guard → resumeStepInstances/executeLocalFlow.
    2. Dashboard POST /runs/:id/resume → prepareResume → runLockHolder check → startResumeRun → resumeRunById.
    3. Run lock lifecycle: trackRunState → acquireRunLock (mkdir lock, stale-owner takeover under a .takeover mutex) → settleTrackedRun/clearTrackedRunState → release() (nonce-checked).
  • Conventions: no semicolons, JSDoc casts everywhere, tests inject only network and process boundaries, and issues are tracked in .beads/issues.jsonl.

Review

Recent changes: The last ~15 commits add submit checkpoints and lost-response recovery (submission-recovery.js), a run-lock takeover mutex, run-lock release on failure, lock-before-resume ordering, protection against a stale snapshot settling a newer attempt (run-state.js merge), and an idempotent MCP run_resume tool. Test coverage for these changes is strong (resume-submission-recovery.test.js has 354 lines, and the run-lock tests include real multi-process workers).

Critical: none found.

Warnings:

  • R1, the lock is taken after the read. Commit 05c9ec5 moved trackRunState ahead of recovery and planning, but the state object was already read from disk by the caller. The cause is that the lock API is object-based (it locks "this runState object") rather than directory-based (lock, then load). Every caller loads first because listRunStates is how runs are found.
  • R2, in-process multi-run settling. graceful-run-state.js was designed for one run per CLI process. The dashboard and MCP later added concurrent in-process runs; heldRunLocks was made per-directory, but activeRunState and the new settleTrackedRun still assume a single run.

Suggestions: R3 (bad lock-holder text), R4 (network calls inside a GET preview), R5 (none reported when nothing was checked).

Positive: The lock is still acquired only by mkdir, even during takeover (with a recheck under the mutex). Checkpoints are written before sending. Adopted runners strip stale error fields. Auth failures during reconcile produce a specific resume_auth_failure stop, and a reconcile failure is conservatively treated as "maybe created".

Improve (ranked)

Quick wins

  1. R3: fix the pid undefined on undefined text (one line).
  2. R6: say in the ambiguous-submission message what nax already checked.
  3. R8: reuse sameOwner in release().

Robustness
4. R1: reload state after taking the lock. This is the single most valuable hardening of the resume path.
5. R2: settle and persist per locked run, not per process.
6. R7: save recovery results even when resume is refused.

Performance
7. R4: avoid Agent Runner API calls from passive dashboard previews (check the lock first, add a short cache or a no-reconcile mode), and drop the duplicate reconcile on CLI resume.

Refactoring / design
8. R9: make finally the only settle point in resumeGithubFlow.
9. The maybeCreatedSubmission / checkpointToRecover pair spreads the "might exist remotely" rules across resume.js and submission-recovery.js, with a cross-require in between. Merging them into one module with an explicit state table (checkpoint kind × reconcile result × status → action) would make future cases easier to review.
10. R5: give "unchecked" its own reconcile outcome so the reconcile table can be trusted.

Items Considered And Rejected

  1. Orphaned takeover-mutex reclaim race (run-lock.js:111): real, but already tracked as open bead nax-v9p9.
  2. A run lock with no owner.json can never be reclaimed without --force-unlock (run-lock.js:152): already tracked as nax-i97i. The error message does point users to --force-unlock.
  3. A reconcile result of none still blocks retry and pending submissions: intentional and covered by tests (resume-submission-recovery.test.js:276), since runner listing can lag. Only the wording in the message needs work (R6).

Gemini Results

Codex Results


Repository State
  • pinned_sha: a13ef4b19f71bdc77ebd58d060d6bcea815814af
  • checked_out_sha: a13ef4b19f71bdc77ebd58d060d6bcea815814af
  • state_match: yes
  • drift_commits: 0
  • drift_acceptable: yes
  • git_status_clean: yes

Structured Findings

[
  {
    "id": "R1",
    "category": "defect",
    "severity": "warning",
    "status": "confirmed",
    "file": "src/storage/local/run-state.js",
    "line": 337,
    "claim": "A stale writer can mark a step and workflow failed after a newer replacement attempt has completed.",
    "evidence": "Status protection only activates when the retained newer attempt is non-terminal. A reproduction retained completed attempt a2 but persisted both workflowStatus and stepStatus as failed from stale attempt a1.",
    "suggested_fix": "Treat any retained different attempt as authoritative and derive or preserve enclosing statuses from the retained current runs; add a completed-replacement regression test.",
    "confidence": "high"
  },
  {
    "id": "R2",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "README.md",
    "line": 225,
    "claim": "The installation prerequisites promise Node 18 support although the package requires Node 20 or newer.",
    "evidence": "README.md says Node 18+ while package.json:22 declares node >=20 and canonical site documentation also requires Node 20+.",
    "suggested_fix": "Update the root README prerequisite to Node 20+ and keep runtime support claims generated or checked against package.json.",
    "confidence": "high"
  }
]

Explore

Project overview

nax is a Node.js CLI and local dashboard for orchestrating multi-step AI-agent workflows through Netlify Agent Runner or GitHub Actions. It includes:

  • CommonJS CLI/runtime code under src/
  • A React/Vite dashboard under src/dashboard/web
  • A TypeScript Agent Runner SDK under packages/agent-runner-sdk
  • Nextra documentation under site/content
  • Node test suites under tests/

No CLAUDE.md exists. Repository conventions are defined in AGENTS.md.

Architecture and execution flows

Three principal flows were traced:

  1. CLI command registration → src/cli/main.js → workflow selection/planning → local or GitHub executor → durable .nax/workflows/*/workflow.json state.
  2. Resume request → submission reconciliation → attempt-lineage merge → polling/resubmission → completion and artifact persistence.
  3. Dashboard/MCP request → local adapters and control plane → shared orchestration functions → streamed run state and artifacts.

The separation among core decisions, transports, storage, dashboard services, and presentation is generally strong.

Conventions

  • JavaScript uses JSDoc typing.
  • TypeScript is isolated primarily to the SDK and dashboard.
  • Durable writes use atomic replacement and state-file locks.
  • Agent retries use attempt IDs and explicit supersession lineage.
  • User-facing documentation correctly lives in site/content.

Review

Recent changes

The last five commits strengthened:

  • Run-lock acquisition and cleanup
  • Resume preflight ordering
  • Stale snapshot protection
  • Orphaned takeover-lock recovery
  • Ambiguous-submission messaging and documentation

Defect and root cause

R1 originates in the distinction between “newer active attempt” and “newer attempt.” At run-state.js, the enclosing-status guard is enabled only when the retained attempt is non-terminal. Once that newer attempt completes, a stale snapshot can retain its obsolete failed step/workflow statuses while the merge correctly retains the newer completed run.

This creates internally contradictory durable state and may make a successful replacement look failed or resumable.

Positive observations

The locking changes acquire ownership before review-gate or resume mutations, use owner nonces when releasing, and clear process-global tracking on failure. Regression coverage for active-attempt races and lock release is focused and readable.

Improve

Quick wins

  • Fix R1 and test both newer-success/stale-failure and newer-failure/stale-success combinations.
  • Align README.md with package.json.

Test coverage gaps

  • No regression test covers a stale enclosing status after the newer attempt is already terminal; existing coverage at run-state-lineage.test.js covers only an active newer attempt.
  • Add invariant tests ensuring step/workflow status agrees with current attempt lineage after every merge.

Performance and design

No material performance regression was found. Atomic state writes and bounded dashboard pagination are sensible. The central remaining design concern is that enclosing status is merged independently from the current run records instead of being recalculated from authoritative attempt state.

Verification

  • 15 dependency-free focused tests passed.
  • Three focused suites could not start because root dependencies, including tsx and @davidwells/box-logger, are not installed.
  • No build was run, consistent with the review-only contract.
  • No files were modified; .netlify/results.md was not written because doing so would violate the clean-tree requirement.

Items Considered And Rejected

  • Double locking in dashboard resume: rejected because retracking the identical state object intentionally preserves the existing lock.
  • Orphan takeover timeout: rejected because the 60-second owner-file grace period appropriately avoids racing lock creation.
  • Force-unlocking a live remote owner: rejected because this is explicit operator-requested recovery behavior.

Opencode Results

@github-actions

Copy link
Copy Markdown

NAX review completed with status: cancelled

Final results

Review · 2026-09-26T15-59-04-646Z-review

  • Run ID: 2026-09-26T15-59-04-646Z-review
  • Flow: review
  • Transport: netlify-api
  • Status: running
  • Usage: 189.1 credits, 34 steps, 1,155,595 tokens
  • Target: feat/lint-findings-resume (explicit-branch, verified)
  • Target SHA: a13ef4b19f71bdc77ebd58d060d6bcea815814af
  • Files: usage

Contents

  1. Review · summary · metadata · usage

Review

  • Status: running
  • Usage: 189.1 credits, 34 steps, 1,155,595 tokens
  • Files: step metadata, usage

Claude Results


Repository State
  • pinned_sha: a13ef4b19f71bdc77ebd58d060d6bcea815814af
  • checked_out_sha: a13ef4b19f71bdc77ebd58d060d6bcea815814af
  • state_match: yes
  • drift_commits: 0
  • drift_acceptable: yes
  • git_status_clean: yes

Structured Findings

[
  {
    "id": "R1",
    "category": "defect",
    "severity": "warning",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 145,
    "claim": "The run_locked error always tells the user to run `nax run --resume <id> --force-unlock`, but that command refuses GitHub-transport runs, and `nax retry` and the interactive resume offer have no force-unlock option.",
    "evidence": "resume-preparation.js:71-72 throws resume_unsupported_transport for any run that is not netlify-api. resumeGithubFlow (github-executor.js:509), handleRetry (main.js:2564) and resumeRunById (main.js:2862) call trackRunState without forceUnlock. A lock held by another host, or one with an unreadable owner.json, is never treated as stale (run-lock.js:80-82), so for GitHub runs the suggested fix leads nowhere.",
    "suggested_fix": "Only print the --force-unlock hint for netlify-api runs, or add a transport-neutral takeover (for example `nax unlock <run-id>`) and point the message at it.",
    "confidence": "high"
  },
  {
    "id": "R2",
    "category": "defect",
    "severity": "warning",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 40,
    "claim": "If owner.json fails to write, the run lock is still acquired, but release() then refuses to remove it, so the lock is stuck until someone forces it open.",
    "evidence": "createLockDir ignores the write error (lines 38-42). release() reads the owner back and returns false when it is null (lines 172-175). Later acquirers read null, isStaleOwner(null) is false, and they get 'unknown owner'. A missing owner file also leaves a crash between mkdir and write unrecoverable. The takeover mutex has an age-based orphan check for this case (line 109); the run lock does not.",
    "suggested_fix": "For the run lock, treat a failed owner write as a failed acquire: remove the directory and throw. Otherwise, apply the same age-based orphan rule used for the takeover mutex.",
    "confidence": "high"
  },
  {
    "id": "R3",
    "category": "defect",
    "severity": "warning",
    "status": "likely",
    "file": "src/storage/local/graceful-run-state.js",
    "line": 114,
    "claim": "The process tracks many run locks but only one active run state, so when runs execute at the same time in one process, only the last one tracked is saved as interrupted.",
    "evidence": "heldRunLocks is a Map (line 9) but activeRunState is a single value that each trackRunState call overwrites (line 114). settleTrackedRun(A) only saves when activeRunState === A (line 140), and the exit handler saves only activeRunState but releases every lock (lines 63-66). The dashboard only blocks duplicates per flow (server.js:1784-1795), so two flows can run at once. The older run's lock is released while its state still says 'running'.",
    "suggested_fix": "Keep each run's state and interrupt handler in the heldRunLocks entry, and save or settle each held run on exit and in settleTrackedRun.",
    "confidence": "medium"
  },
  {
    "id": "R4",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 156,
    "claim": "When two processes race to take over a stale lock, the loser gets an error naming the dead pid as the current owner.",
    "evidence": "When acquireTakeover returns null, the code throws locked(current), where current is the stale owner read before the race, not whoever holds the lock now.",
    "suggested_fix": "Read the owner again before throwing, or word the message as 'another process is taking over this run'.",
    "confidence": "high"
  },
  {
    "id": "R5",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/dashboard/server.js",
    "line": 1387,
    "claim": "The dashboard's run-lock check before resuming is advisory, so if a lock is taken between the check and the start, the resume fails after the API has already returned 202.",
    "evidence": "runLockHolder is checked, then startResumeRun sets up a live run, and trackRunState in resumeRunById (main.js:2862) acquires the lock later. If another process gets the lock in between, the run fails inside the live run instead of returning 409.",
    "suggested_fix": "Acquire the lock (trackRunState) inside the request before returning 202, then hand the held state to the background run.",
    "confidence": "medium"
  },
  {
    "id": "R6",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 81,
    "claim": "The stale check is by pid only, so if the OS reuses a dead owner's pid, the lock looks live forever.",
    "evidence": "processIsAlive(pid) only checks that some process has that pid. The startedAt field is recorded but never compared with the process start time.",
    "suggested_fix": "Where the platform allows (for example /proc/<pid>/stat on Linux), compare the process start time with owner.startedAt.",
    "confidence": "medium"
  },
  {
    "id": "R7",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 173,
    "claim": "release() repeats the owner-identity check inline instead of reusing sameOwner().",
    "evidence": "Line 173 re-implements the nonce, pid and hostname comparison that sameOwner (line 116) already does.",
    "suggested_fix": "Use `if (!current || !sameOwner(current, owner))`.",
    "confidence": "high"
  }
]

Explore / Review summary

This branch (feat/lint-findings-resume, 63 commits ahead of master) hardens mid-step resume. It adds a per-run directory lock with owner metadata, a stale-lock takeover mutex, submission reconcile for runners whose submit response was lost, and lock handling around retry and GitHub resume. The last 8 commits touch run-lock.js, graceful-run-state.js, submission-recovery.js, the executors, main.js, and add tests for the lock and resume.

What works well: mkdir as the only way to acquire the lock, and checking the owner again under the takeover mutex, is a sound design. resumeLocalFlow takes the lock before reconcile or planning and releases it on any refusal before submit (local-executor.js:1387-1414). Reconcile is idempotent: an adopted runner is only saved after the checks pass, so a refused resume just reconciles again next time. Comments explain why decisions were made, and the JSDoc types follow the AGENTS.md rules.

Defects (ranked):

  1. R1: The --force-unlock remediation in the error message is wrong for GitHub runs and for nax retry. A lock held by another host, or with no owner file, can't be recovered from those paths.
  2. R2: A failed owner.json write gives a lock that can't be released or reclaimed without --force-unlock. The root cause: owner metadata is documented as "diagnostic", but release and staleness actually depend on it.
  3. R3: The single activeRunState value dates from the one-run-per-process CLI. The newer per-directory lock map assumes the dashboard and MCP can run several runs in one process, and the interrupt and settle logic wasn't updated for that.

Polish: R4–R7 cover a misleading message when two takeovers race, the dashboard's advisory lock check before 202, pid reuse, and duplicated owner-comparison code.

Improve

  • Quick wins: R7 (one line). R4 (read the owner again before throwing). Make the R1 message depend on transport.
  • Robustness: R2, plus a small nax unlock <run-id> command (it would cover R1 and the no-owner-file case). Mention it in site/content/reference/commands.mdx.
  • Design: R3. Move interrupt and settle state into the per-run lock entry so a process can run several runs safely.
  • Test gaps: No test covers a failed owner.json write (R2), two concurrently tracked runs in one process being settled or interrupted (R3), or the run_locked message for a GitHub-transport run (R1).

Verification note: The full test suite couldn't run because dependencies (tsx, @davidwells/box-logger) aren't installed in this environment. Running node --test on the lock, resume and lineage tests gave 18 passing and 6 failing, and every failure was a missing module, not an assertion.

Items Considered And Rejected

  1. Race in acquireRunLock with --force-unlock while another process is between mkdir and the owner write (run-lock.js:158-160, where sameOwner(null, null) is true). The window is microseconds and only reachable with an explicit force flag. Fixing R2 closes it anyway.
  2. Recursion at run-lock.js:164. The retry passes forceUnlock: false and each call has to lose a real mkdir race, so it can't recurse without bound.
  3. In-place mutation in recoverSubmissions without saving (submission-recovery.js:79-95). This is intended: the caller saves after preflight, and reconcile is idempotent, so a lost adoption is found again on the next resume.

Gemini Results

Codex Results


Repository State
  • pinned_sha: a13ef4b19f71bdc77ebd58d060d6bcea815814af
  • checked_out_sha: a13ef4b19f71bdc77ebd58d060d6bcea815814af
  • state_match: yes
  • drift_commits: 0
  • drift_acceptable: yes
  • git_status_clean: yes

Structured Findings

[
  {
    "id": "R1",
    "category": "defect",
    "severity": "warning",
    "status": "confirmed",
    "file": "src/cli/main.js",
    "line": 3088,
    "claim": "Fresh-run failure handling releases the run lock before persisting the failed state.",
    "evidence": "The catch block calls clearTrackedRunState at line 3088, then performs cleanup and saveRunState at line 3099; another process can acquire the same run lock and begin recovery during that unlocked interval.",
    "suggested_fix": "Persist failure state and terminal artifacts while holding the lock, then release it in a finally block; add a concurrent failure/resume regression test.",
    "confidence": "high"
  },
  {
    "id": "R2",
    "category": "defect",
    "severity": "warning",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 152,
    "claim": "forceUnlock can remove a lock belonging to a process that is still alive.",
    "evidence": "When forceUnlock is true, the live-owner and stale-owner check is bypassed, after which the existing lock is removed; this permits two executors to mutate the same run despite documentation describing takeover only after the owner is gone.",
    "suggested_fix": "Refuse takeover of a verifiably live local PID, or introduce a separately named hazardous override with an explicit warning and confirmation.",
    "confidence": "high"
  },
  {
    "id": "R3",
    "category": "defect",
    "severity": "warning",
    "status": "confirmed",
    "file": "src/storage/local/run-lock.js",
    "line": 39,
    "claim": "A failed owner.json write still reports successful lock acquisition and can leave an unreleasable lock.",
    "evidence": "createLockDir suppresses every metadata-write error and returns true, but release requires matching owner metadata; an absent or unreadable owner therefore makes release return false and leaves run.lock behind.",
    "suggested_fix": "Treat owner metadata creation as part of acquisition: remove the newly created directory and rethrow if writing owner.json fails.",
    "confidence": "high"
  },
  {
    "id": "R4",
    "category": "polish",
    "severity": "suggestion",
    "status": "confirmed",
    "file": "src/cli/main.js",
    "line": 2855,
    "claim": "Resume, retry, fresh-run orchestration, UI prompting, and presentation remain concentrated in one oversized CLI module.",
    "evidence": "The module owns transport execution, lock lifecycle, resume selection, dashboard entry points, formatting, and command handlers, making lifecycle ordering bugs such as R1 harder to detect and test independently.",
    "suggested_fix": "Move the shared locked-run lifecycle into a focused orchestration service and keep CLI functions responsible for argument and presentation concerns.",
    "confidence": "high"
  }
]

Explore

Project overview

NAX is a Node.js CLI and local dashboard for orchestrating multi-agent workflows through Netlify Agent Runner or GitHub Actions. It uses CommonJS/JSDoc for the CLI and services, TypeScript/React for dashboard UI and contracts, and Next.js/Nextra for documentation.

Key areas:

  • src/cli: command parsing and terminal interaction.
  • src/workflows: workflow discovery, execution, resume, artifacts, and findings.
  • src/storage/local: durable run state and filesystem locking.
  • src/dashboard: API, runtime, storage, and React dashboard.
  • src/mcp: MCP tools and local-dashboard adapters.
  • packages/agent-runner-sdk: typed SDK and independent tests.
  • site/content: canonical user documentation.

Execution flows traced

  1. Fresh workflow: CLI configuration → run-state creation → run lock → local/GitHub executor → terminal state and artifacts.
  2. Resume: saved state → recovery and plan preview → branch/digest validation → run lock → poll/resubmit → remaining steps.
  3. Dashboard/MCP resume: API mutation → idempotency store → preview and lock check → in-process execution → durable state projection.

Conventions

The code generally uses dependency injection at network boundaries, atomic filesystem writes, structured error codes, precise JSDoc shapes, and extensive unit coverage. Recent attempt-lineage safeguards are particularly careful.

Review

Recent changes

The last five commits strengthened:

  • Run-lock acquisition and release across resume/retry failures.
  • Takeover mutex recovery.
  • Protection against stale snapshots settling newer attempts.
  • Ambiguous Agent Runner submission handling.
  • Resume and lint documentation.

The changes are focused and accompanied by regression tests. The remaining defects are adjacent lifecycle gaps rather than failures in the principal resume algorithm.

Root causes

  • R1: lock ownership is managed manually across several command paths, allowing release and persistence ordering to diverge.
  • R2: forceUnlock is implemented as an unconditional bypass instead of a stale-owner recovery mechanism.
  • R3: lock-directory creation and ownership recording are treated as separable despite release depending on both.

Positive observations

Attempt lineage prevents stale writers from regressing newer active attempts. Resume takes the run lock before recovery reads and writes. Submission ambiguity is conservatively handled to avoid duplicate paid work.

Improve

Quick wins

  • Save fresh-run failure state before releasing its lock: src/cli/main.js.
  • Reject forced takeover of a verifiably live local owner: run-lock.js.
  • Roll back lock creation when owner metadata cannot be written: run-lock.js.

Refactoring opportunity

Centralize “acquire → execute → settle/persist → release” so fresh runs, retries, local resumes, and GitHub resumes share one lifecycle abstraction: src/cli/main.js.

Test coverage gaps

Add regression tests for:

  • A fresh-run failure racing with resume between unlock and persistence.
  • forceUnlock against a demonstrably live local PID.
  • Owner metadata write failure and lock-directory cleanup.

Targeted lock and lineage tests produced 15 passes. The test importing the full CLI could not start because checkout dependencies, including tsx and @davidwells/box-logger, are not installed. No build was run.

Items Considered And Rejected

  • Ownerless takeover mutex recovery: adequately guarded by age and atomic reacquisition.
  • Stale snapshot overwriting a newer attempt: recent lineage checks and tests address it.
  • Dashboard preview-to-start race: actual execution reacquires the authoritative run lock, so the precheck is advisory rather than the safety boundary.

Opencode Results

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant