Skip to content

Fix node reaping and gate VM agent rollout by build version - #1758

Merged
simple-agent-manager[bot] merged 16 commits into
mainfrom
sam/recover-finish-failed-child-ebra1p
Aug 6, 2026
Merged

simple-agent-manager[bot] merged 16 commits into
mainfrom
sam/recover-finish-failed-child-ebra1p

Conversation

@simple-agent-manager

@simple-agent-manager simple-agent-manager Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Recover the node reaper/orphan reconciliation work from failed task 01KZBEJT3JJHH1WENA8PSV5S3Y and rebase it onto current main after Integrate strict CTO remediation fixes #1697/fix(vm-agent): disable Codex bwrap across all runtimes #1757.
  • Add a deployment-derived VM agent build contract: vm-agent reports agentVersion, API persists nodes.agent_version, and all reusable-node selection paths reject stale or missing builds when VM_AGENT_REQUIRED_VERSION is set.
  • Ensure deploy ordering uploads matching VM-agent binaries before the Worker starts requiring that exact github.sha; skip_agent leaves the requirement unset.
  • Drain legacy/busy VM nodes without assigning new work, retire idle incompatible nodes through bounded cleanup, and keep Cloudflare Instant container scheduling separate.

Validation

  • pnpm --filter @simple-agent-manager/api test -- --run tests/unit/durable-objects/task-runner-readiness.test.ts tests/unit/durable-objects/task-runner-node-selection.test.ts tests/unit/services/node-cleanup-idle-signal.test.ts tests/unit/routes/node-lifecycle-byo.test.ts
  • pnpm exec vitest run scripts/quality/deploy-reusable-workflow.test.ts scripts/quality/sync-wrangler-config.test.ts
  • cd packages/vm-agent && go test ./...
  • pnpm --filter @simple-agent-manager/shared build && pnpm --filter @simple-agent-manager/providers build && pnpm --filter @simple-agent-manager/api typecheck
  • pnpm --filter @simple-agent-manager/api lint (0 errors; existing warnings remain)
  • git diff --check origin/main...HEAD

Staging verification

  • Staging deploy run 31104130685 succeeded for commit b71a2601d2094da6eb8317c75edadda6221ef219.
  • Verified deploy order: D1 migration, VM-agent build/upload, then API Worker deploy.
  • Created fresh staging VM workspace 01KZBKK6V6SAHQA76B59HJ5ZQH on node 01KZBKK6DM2WBXB3VR36EGBCZS; live D1 showed agent_version='b71a2601d2094da6eb8317c75edadda6221ef219' and agent_ready_at='2026-08-06T13:21:31.761Z'.
  • Deleted the staging workspace and node; final staging active-node count was 0.

End-to-end verification

  • Deployment workflow sets VM_AGENT_REQUIRED_VERSION=${github.sha} for normal deploys and uploads VM-agent artifacts before the Worker deploys.
  • scripts/deploy/sync-wrangler-config.ts writes the derived Worker var into config.
  • packages/vm-agent/internal/server/health.go posts agentVersion on /ready and /heartbeat.
  • apps/api/src/routes/node-lifecycle.ts persists callback build identity to nodes.agent_version.
  • Reuse gates cover preferred node, warm node, generic capacity selector, TaskRunner readiness/selection, trial orchestrator, and manual workspace creation.
  • Cleanup drains busy incompatible VM nodes and retires idle incompatible VM nodes without treating Cloudflare Instant containers as VM-agent rollout candidates.

UI Compliance

N/A: no UI surfaces, layout, styling, or user interaction flows changed in this PR.

Post-mortem

The production rollout hole was that #1757 deployed Worker behavior before the fleet was forced onto matching VM-agent binaries; current workspace 01KZBE2BBHG4014XR022RA53N7 could therefore land on old node 01KZA098X9R3ZXX85XKKDAN0M3 without CODEX_CONFIG / INITIAL_AGENT_MODE. The archived task postmortem in tasks/archive/2026-08-06-fix-node-reaping-orphan-reconciliation.md records the original node reaping failure, the rollout addendum, and the validation evidence.

Specialist Review Evidence

Reviewer Status Evidence
cloudflare-specialist PASS Reviewed D1 migration, Worker deploy ordering, R2 artifact ordering, and staging zero-at-rest verification; no blocking findings after the route parsing fix.
go-specialist PASS Reviewed vm-agent health/heartbeat version payloads; go test ./... passed in packages/vm-agent.
env-validator PASS VM_AGENT_REQUIRED_VERSION is an optional Worker var, deployment-derived, documented, and not a GH_/GITHUB_ secret mapping.
constitution-validator PASS Required version is generated from github.sha, with no manual hardcoded rollout SHA; cleanup thresholds remain configurable.
test-engineer PASS Added route, selector/readiness, cleanup, deploy workflow, sync-config, and Go coverage; discriminating suites passed locally.
security-auditor PASS Callback auth remains intact; additive version metadata is non-secret and node-scoped lifecycle routes still require callback authorization.
doc-sync-validator PASS Updated env references, public config docs, .env.example, rule 54, and archived task postmortem.
task-completion-validator PASS Planned findings, checklist, acceptance criteria, git diff, and validation evidence matched; task archived.

Agent Preflight (Required)

  • Preflight completed before code changes

Classification

  • external-api-change
  • cross-component-change
  • business-logic-change
  • public-surface-change
  • docs-sync-change
  • security-sensitive-change
  • ui-change
  • infra-change

External References

N/A: recovery used repository code, failed child transcript/session 72f25472-3701-4880-a935-93d9b397c3c1, SAM task evidence, and live staging/Cloudflare state; no external API contract changed.

Codebase Impact Analysis

Impacted areas are apps/api D1 schema/migration, node lifecycle routes, scheduler cleanup, TaskRunner/trial/manual workspace selectors; packages/vm-agent health callbacks; .github/workflows/deploy-reusable.yml; scripts/deploy; apps/www docs; and .claude / .agents operational rules and references.

Documentation & Specs

Updated apps/www/src/content/docs/docs/reference/configuration.md, apps/api/.env.example, .claude/skills/env-reference/SKILL.md, .agents/skills/env-reference/SKILL.md, .claude/rules/54-vm-agent-rollout-compatibility.md, and archived task postmortem tasks/archive/2026-08-06-fix-node-reaping-orphan-reconciliation.md.

Constitution & Risk Check

Checked Principle XI / no hardcoded values: rollout required version is generated from github.sha and can be unset for skip_agent or local deploys. Main operational risk was stranding legacy busy nodes, handled by drain-not-destroy logic and bounded cleanup.

dependabot Bot and others added 14 commits August 6, 2026 12:37
Root-cause analysis of production node reaping failure. Documents a live
production outage: the 5-minute cron has no error isolation, so a throw in
runNodeCleanupSweep silently kills all 8 subsequent sweeps including user
cron triggers (21 active, 5h overdue).

Direct-to-main push for the task file is blocked by branch protection
(required status checks), so it ships in this branch.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…provider orphans

PRIMARY FIX — stops a live production outage.

The 5-minute cron ran 13 sweeps as a flat sequence of bare awaits with no error
isolation. A throw in runNodeCleanupSweep silently skipped every sweep after it.
In production this began 2026-08-05T20:25Z and was still active ~13h later:
node reaping, observability purge, trigger-execution cleanup, session-task repair,
setup-session sweep, compose-artifact cleanup, compute-usage cleanup and trial
expiry all stopped, along with runCronTriggerSweep -- so 21 active USER cron
triggers stopped firing (earliest next_fire_at was 5h overdue). Nodes accumulated
until the shared Hetzner account hit its 10-server limit and staging deploys 403'd.

Each sweep now runs inside sweep-isolation.ts: failures are contained, logged, and
persisted to observability, and cron.completed reports failedSweeps so the next
occurrence is visible instead of showing up only as an absence of effects.

SECONDARY FIXES

- Idle orphan reaping actually reaps. The phase previously only wrote an
  observability row. Its predicate was also unsatisfiable: it compared
  nodes.updated_at, which every heartbeat bumps (verified in production --
  updated_at is byte-identical to last_heartbeat_at on every running node), so a
  healthy idle node never aged into the window. recoveryType 'orphaned_node' had
  zero events across the sweep's entire lifetime. Idleness is now measured from
  COALESCE(MAX(workspaces.updated_at), nodes.created_at).

- Max lifetime is a real backstop. The 4h ceiling skips any node with an active
  workspace, so a workspace row wedged in 'running' made a node immortal -- two
  production nodes survived 1932h and 2135h and were cleared manually. A 24h
  absolute ceiling now applies when no workspace has reported ACTIVITY within the
  idle window, which distinguishes a busy node from one holding a stuck row.

- Rule 47: every candidate query is bounded by a configurable LIMIT (phases 1-5
  were unbounded), and sweep VM-agent calls use a new 5s background timeout
  instead of the interactive 30s -- unbounded candidates x 30s dead-node timeouts
  is the most likely cause of the original throw.

- Rule 51: deployment-environments.ts DELETE claim was missing
  node_class != 'user-owned' (its twin in deployment-environment-lifecycle.ts has
  it). Workspace phases 4 and 6 mutated workspaces with no join to nodes at all;
  both now exclude user-owned nodes.

- Phase 1's live-workspace guard counted only status='running' while phases 2/3
  counted ('running','creating','recovery'), so a node with a 'creating' workspace
  could be destroyed. Widened to match.

SAFETY

Deployment nodes are explicitly protected. node_role='deployment' machines host
live user applications and legitimately hold zero workspaces forever, so every
'running with no workspaces' heuristic matches them perfectly. Three such nodes
backing ACTIVE production deployment_environments were live during this work --
reaping on the zero-workspaces signal alone would have destroyed three users'
production applications. Every destroy query carries node_role='workspace'.

PROVIDER-SIDE ORPHAN RECONCILIATION

New bounded sweep reclaims servers that exist at the provider but which no D1 row
claims (provisionNode writes provider_instance_id only after createVM returns, and
deletes the node row outright on transient capacity failure).

This is the only code in SAM that destroys infrastructure based on absence rather
than presence, so it is fail-closed throughout: no ENVIRONMENT identity skips the
run; servers missing or mismatching the env label are skipped; servers younger
than a 1h floor are skipped; any D1 or provider read failure aborts without
destroying anything; a claiming row in any non-terminal state preserves the server.
A new 'env' provider label makes this possible at all -- SAM's staging and
production share one Hetzner project, so without it the two deployments' servers
are indistinguishable and a staging sweep could delete production servers.
Pre-existing servers carry no env label and are therefore permanently out of scope.

Rule 18: node-cleanup.ts (646 lines) split into node-cleanup/ modules.

Co-Authored-By: Claude <noreply@anthropic.com>
…tion

New behavioral suites (all run the real code against real SQLite where relevant):

- node-cleanup-deployment-node-exemption.test.ts: the safety gate. Verified
  DISCRIMINATING by deleting node_role='workspace' from all four phases -- all 5
  tests fail, and the lone-deployment-node case shows deleteCalls=['deployment-only'],
  reproducing in a test the exact scenario that would have destroyed three users'
  live production applications. Each test carries a workspace-role control node so
  'nothing was destroyed' cannot pass by nothing matching.

- node-cleanup-idle-signal.test.ts: proves the reaper is not defeated by heartbeat
  activity (node idle 7h with updated_at 2s old is reaped), does not touch busy
  nodes, respects the idle window and its env override, never reaps a
  freshly-provisioned node with no workspace yet, and includes the rule-47
  two-sweep zombie check for a permanently failing candidate.

- sweep-isolation.test.ts: a throwing sweep does not prevent later sweeps; failures
  are named and persisted; a failure while RECORDING a failure still cannot abort
  the cron; a crashed sweep yields undefined rather than a zero result so it stays
  distinguishable from one that ran and found nothing.

- provider-orphan-reconciliation.test.ts: 19 tests weighted toward proving nothing
  is destroyed on incomplete evidence -- unlabeled, foreign-env, too-young,
  malformed node label, unparseable creation time, non-terminal claiming row,
  failed D1 claim lookup, failed provider list, missing platform credential, no
  ENVIRONMENT identity. Verified DISCRIMINATING by removing the env re-check: the
  foreign-environment server is then destroyed.

- node-provider-labels.test.ts: pins the pre-existing label contract and proves the
  env label is OMITTED rather than defaulted when the environment is unknown.

Updated existing suites for intentional behavior changes rather than weakening them:

- node-role-exemption.test.ts: replaced four brittle source-string assertions (they
  sliced the sweep source by index and broke on any edit) with a pointer to the new
  behavioral suite. Source strings prove a filter is PRESENT, not that it WORKS.

- recovery-resilience.test.ts: the 'runs stuck-task recovery first' ordering
  assertion is replaced by one asserting real isolation. Ordering was only ever a
  workaround -- it protects whatever runs earliest and nothing else, which is
  precisely how stuck-task recovery kept working while everything downstream stayed
  dead for 13 hours. The orphan-node assertions now require the heartbeat-immune
  signal and explicitly assert 'AND n.updated_at < ?' is ABSENT.

- node-cleanup-user-owned-zombie.test.ts: the orphan case now asserts DESTRUCTION
  rather than flagging. Also added health_status to the harness schema -- without
  it every destroy's D1 write threw, and because the provider mock records the call
  before that write, asserting only on deleteCalls hid the failure entirely.

Full API suite: 485 files, 6589 tests, all green.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Verified against production D1: nodes.id is stored UPPERCASE (every live row
satisfies id = upper(id)), while the provider label is written lowercased
(node.id.toLowerCase() in services/nodes.ts). The claim lookup must normalise.

If it ever compares raw, the lookup returns zero rows for EVERY node, every live
server reads as unclaimed, and the reconciler deletes the entire fleet -- at the
time of writing all 9 production servers, three of them backing live customer
deployments.

The sibling suite stubs D1 by hand and ignores the SQL text, so it passes with or
without lower(). This suite runs against real SQLite with real uppercase ULIDs.
Verified DISCRIMINATING: replacing lower(id) IN (...) with id IN (...) makes the
live-node test fail with deleteVM called for 'srv-live'.

Also covers a mixed batch (live + deleted + unknown) to prove the batched IN-clause
maps each server to its own row rather than applying one verdict to all.

Co-Authored-By: Claude <noreply@anthropic.com>
Doc sync (rule 01): all nine new env vars were undocumented. Adds two sections to
the configuration reference -- idle/orphan node reaping and provider-side orphan
reconciliation -- including why idleness is measured from workspace activity rather
than nodes.updated_at, and why deployment nodes are never reaped. Also corrects the
MAX_AUTO_NODE_LIFETIME_MS description, which claimed to be an absolute ceiling while
actually skipping any node with an active workspace.

Process fix (rule 02 requires one per bug fix, targeting the CLASS of bug):
.claude/rules/53-scheduled-handler-isolation-and-liveness-signals.md.

The class is 'a control loop that fails silently, where the symptom is an absence
rather than an error', with two sub-classes both present in this incident:
unisolated sequential steps, and a liveness timestamp used as an idleness proxy.

Rule 53 also records that the previous mitigation for sub-class 1 was to REORDER one
sweep rather than isolate them -- and that reordering protected only the sweep that
ran first, which is precisely how stuck-task recovery kept working while everything
downstream stayed dead for 13 hours.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Reviewer verdict was PASS on Principle XI (0 violations; all 9 new env vars have a
DEFAULT_* constant, an env override, and an Env declaration). These are its four
improvement recommendations, all applied:

- Add PROVIDER_ORPHAN_RECONCILE_LAST_RUN_KV_KEY. The interval-gate KV key was the one
  value in the new sweep with no override, while its direct sibling
  compose-image-artifact-cleanup.ts has exactly that override for the same class of
  value in the same directory.
- Import parseMs/parsePositiveInt from node-cleanup/shared instead of re-declaring
  identical private copies (DRY).
- Direct unit tests for both parsers across undefined/empty/whitespace/non-numeric/
  negative/zero/Infinity/NaN. Previously they were only exercised indirectly through
  phase behaviour, where a broken fallback would be masked by whatever the phase did
  with the bad value.
- Warn when env overrides invert the intended threshold ordering. This one is more
  than tidiness: an inverted override does not throw, it silently makes a phase
  unreachable or trivially satisfiable -- the exact 'a guard that never fires looks
  identical to a guard with nothing to do' failure this whole change exists to fix
  (rule 53). Deliberately a warning rather than a hard failure, since degraded reaping
  precision beats refusing to sweep at all.

The reviewer also confirmed the earlier doc-sync commit resolved the missing-env-var
documentation gap, and judged the curated static data (label keys, terminal status
set, ULID pattern, Hetzner's 63-char limit) acceptable rather than hardcoded config.

Co-Authored-By: Claude <noreply@anthropic.com>
@codspeed

codspeed Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing sam/recover-finish-failed-child-ebra1p (cac9791) with main (1063bf0)

Open in CodSpeed

@sonarqubecloud

sonarqubecloud Bot commented Aug 6, 2026

Copy link
Copy Markdown

@simple-agent-manager
simple-agent-manager Bot merged commit 50af27f into main Aug 6, 2026
24 checks passed
@simple-agent-manager
simple-agent-manager Bot deleted the sam/recover-finish-failed-child-ebra1p branch August 6, 2026 13:57
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