feat(workers): show real startup progress, and let a failed provision retry #99
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lz/agent-nexus!99
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/startup-visibility"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Stacked on #98 — targets
fix/mobile-sidebar-scroll, notmain. Merge #98 first. Second of the four PRs in the issue #96 spec (design D2); independent of the sidebar work.The gap
finishProvisioningclearedprovision_phasethe instantcontainer.start()returned. The entrypoint then spent another 30–90s on git identity, clone,npm install -g @anthropic-ai/claude-code@latestand finallytmux new-session— during which the card read running · healthy and every session action failed with no explanation on screen.What's in
A
bootingphase that survivesfinishProvisioningand is cleared bycheckWorkerHealthon the first successfultmux has-sessionprobe — tmux answering is the definition of usable. While booting, the card shows the last==>marker the entrypoint printed.entrypoint.shalready emits those, so no worker-side change was needed.Pull progress.
pullWorkerImagealready yielded per-layer{id, current, total};provisionWorkerdiscarded all of it except as a boolean "has anything happened yet" flag.PullProgressfolds those into a percentage and a layer/byte line, throttled to one DB write per 750ms (docker emits per layer per chunk) with a forced final write so the bar cannot freeze mid-percentage. Percent isnull— an indeterminate bar — whenever any layer lacks a known total, rather than a number that races upward and jumps back as sizes arrive.Retry on a failed card. It cannot restart anything:
provisionWorkertears the container down on every failure path andcontainer_idwas never written. SoretryWorkerre-enters the pipeline fromqueuedon the same row, keeping the frozenmounts_jsonsnapshot it was created with (the operator retried this workspace, not a new one). It needs the master key to rebuild the sealed secret block, so it is an operator route and never somethingconvergeSessionscould do. The provider is resolved before the row is claimed, so a retry that fails early leaves the row inerrorand still retryable.Two traps worth calling out in review
failStuckProvisioningmust SPAREbooting. Nothing in Nexus drives that phase — the container is started and its entrypoint runs to completion whether or not Nexus is up. Sweeping it would errored-out every workspace that happened to be booting across a Nexus restart, when the next health poll would have cleared it correctly. Verified live: on a harness restart the twopullingrows failed and thebootingrow was untouched.Session controls are gated on the phase, not just on
running. Sessions, the shell agent and the view panels all need tmux; leaving them live during boot just moves the failure one click later.isReady = isRunning && provision_phase !== 'booting'.Verification
Browser-driven against a seeded instance, one card per state:
pulling, all sizes known4 of 7 layers · 218.0 MB / 351.0 MBpulling, a size unknownbootingStarting up…, entrypoint marker, no "start agent", "Sessions appear once the workspace finishes starting."errorAlso exercised the route directly:
POST /api/workers/w3/retryon abootingrow →400 only a failed workspace can be retried; a retry that failed at provider resolution left the row's originalimage pull stalled…message intact.pnpm test109 files / 1028 tests pass (+15; the 5 pre-existingprovisioning.test.tscases are preserved, 2 amended where the behaviour genuinely changed) ·pnpm run typecheck4859 files, 0 errors ·pnpm run lintclean.Adding
'booting'toProvisionPhasemadeprovision-label.ts's exhaustive switch and two DTO fixtures fail to compile until updated — the guard working as intended.Migration
0017_provision_detail.tsadds two nullable display-only columns. No backfill: a null pair is exactly the "nothing reported" state the card already renders.Added after the spec audit
A five-agent pass over the shipped code against the spec found two D2 gaps:
creatingandstartingshare a segment on purpose — both are sub-second docker calls, and a segment that lights for 40ms reads as a glitch. Onerrorthe strip renders nothing rather than guessing which step failed; the row does not record it.retry.test.ts, named in the spec's testing plan, did not exist. Its cases now sit where each rule lives: the vault gate and the 400-mapping beside the route, the failed-state guard and the frozen-mounts reuse beside the service. The mounts case seeds a repo default that differs from the row's snapshot and asserts which one reacheddocker.createContainer, so it fails with the real regression — verified by pointingretryWorkeratresolveSpawnMountsand watching it go red naming/mnt/newer-default.The card used to claim "running · healthy" the instant container.start() returned, while the entrypoint spent another 30-90s cloning the repo and running `npm install -g @anthropic-ai/claude-code@latest`. Every session action failed in that window with no explanation on screen. Adds a `booting` phase that survives finishProvisioning and is cleared by checkWorkerHealth on the first successful `tmux has-session` probe -- tmux answering IS the definition of usable. While booting, the card shows the last `==> ` marker the entrypoint printed; entrypoint.sh already emits those, so no worker-side change was needed. Adds pull progress. pullWorkerImage already yielded per-layer {id, current, total} and provisionWorker discarded all of it except as a boolean "has anything happened yet" flag. PullProgress folds those into a percentage and a layer/byte line, throttled to one DB write per 750ms (docker emits per layer per chunk) with a forced final write so the bar cannot freeze mid-percentage. Percent is null -- an indeterminate bar -- whenever any layer lacks a known total, rather than showing a number that races upward and jumps back as sizes arrive. Adds Retry on a failed card. It cannot restart anything: provisionWorker tears the container down on every failure path and container_id was never written, so retryWorker re-enters the pipeline from `queued` on the same row, keeping the frozen mounts_json snapshot it was created with. It needs the master key to rebuild the sealed secret block, so it is an operator route and never something convergeSessions could do. The provider is resolved BEFORE the row is claimed, so a retry that fails early leaves the row in `error` and retryable. Two traps handled: - failStuckProvisioning must SPARE `booting`. Nothing in Nexus drives that phase -- the container is started and its entrypoint runs to completion whether or not Nexus is up -- so sweeping it would errored-out every workspace booting across a Nexus restart. Verified live: on a harness restart the two `pulling` rows failed and the `booting` row was untouched. - Session controls are gated on the phase, not just on `running`. Sessions, the shell agent and the view panels all need tmux, so leaving them live during boot just moves the failure one click later. Verified in a browser against a seeded instance: determinate bar at 62% with "4 of 7 layers · 218.0 MB / 351.0 MB", indeterminate bar when a layer size is unknown, the booting card showing its entrypoint marker with no "start agent" button, POST /retry rejecting a non-errored row with 400, and a failed retry leaving the row's original error message intact.Both found by running the real worker image through a real dind daemon, not by the suite. 1. The tag was counted as a layer. Docker's stream-level "Pulling from <repo>" event carries an `id` -- the TAG, not a layer -- so observe() registered it alongside the real layers. Measured against docker 29.6.2: a 5-layer image reported 6. Stream-level statuses are now filtered out. 2. Completion could not be inferred from bytes. A cached layer reports "Already exists" and transfers nothing, so it never reaches a total and was counted as incomplete forever. Layer completion now comes from the terminal statuses ("Pull complete" / "Already exists"), which is what docker actually tells us. A fully cached pull previously read "0 of 6 layers · 0.0 MB"; it now reads "5 of 5 layers", and the byte clause is dropped entirely when nothing was transferred rather than shouting "0.0 MB". 3. Phase detail outlived its phase. advanceProvisionPhase left provision_detail and provision_percent in place, so the card showed "Creating container… 100%" with the pull's byte count under it. Observed live at the pulling→creating transition. Advancing now clears both; the new phase writes its own. Live trace after the fixes, against x.lck.sh/lz/agent-nexus-worker:latest (318 MB, 20 layers) pulled from an empty local store: queued pulling pct=null 1 of 20 layers · 5.3 MB pulling pct=38 15 of 20 layers · 122.5 MB / 318.1 MB pulling pct=100 19 of 20 layers · 318.1 MB / 318.1 MB creating pct=null (no detail) booting Cloning https://x.lck.sh/lz/agent-nexus.git booting Registering forgejo-mcp booting Updating @anthropic-ai/claude-code to latest null (ready -- healthy, tmux up, repo cloned on main) Percent is null only for the first ~10s, while docker is still announcing layer sizes; once every total is known it rises monotonically to 100. The earlier worry that the determinate bar would rarely appear did not hold up.