feat: hand the update runs to core #1

Merged
julian merged 5 commits from feat/task-runs into main 2026-08-20 10:51:32 +02:00
Owner

Migrates the last module still on the deprecated progressTopic / async-route pattern (core#21).

Why this module was the worst offender

It was a miniature task system in its own right: a running boolean guarding one run at a time, hand-rolled startedMs/endedMs, an ok flag, a log buffer, a followers set, and an end-of-run signalled by the wording of a log line.

That last one is the deprecation in miniature. Because a progress message starting done or error ended the client's stream, dpkg's own Errors were encountered while processing: would cut the log off mid-run — so the module had to prefix anything from apt that looked like a terminator:

const safe = head.startsWith("done") || head.startsWith("error") ? ${text}` : text;

What changes

The three apt operations become ctx.tasks.define actions; the routes only start one and answer { runId }.

Answering the verdict instead — the start-and-wait pattern module_systemd and module_ipmi use on main — is not an option at this length. A dist-upgrade has a 30-minute timeout and no HTTP client will hold the connection for it.

Beyond satisfying the deprecation, what actually improves:

  • A run survives the process. running went with whatever killed it, so an upgrade interrupted by a restart came back reading idle. It is now an interrupted row that says so.
  • The outcome is a status, not a sentence. No more guessing from log text.
  • Runs record who asked for them.
  • A failed run says what failed. notifyOn: ["failed"] with expose naming reasonData, so the failure notification carries apt's exit line instead of an empty body. (Round three: no schedule declares these actions, so today every run is one a human just clicked — the notification is for the operator who navigated away before apt finished, not an unattended 03:00 run. The comment that oversold this is fixed.)
  • apt output is withheld from non-admins — through all four doors it can leave by, not one. expose names result and reasonData, so core's projection withholds the run's line tail; GET /run withholds its text; the runlog provider takes role: "admin"; the progress topic is declared ctx.events.adminOnly. (The last three were added in review — the first commit closed only the projection and the description claimed the lot. See the review thread.)

Design notes for review

Shared lock: "apt". dpkg takes one lock on the host anyway; a second apt landing mid-upgrade fails on the lock file. A queue is the honest version of what the running boolean was pretending to be. Side benefit: a security upgrade queued behind a refresh now re-simulates against lists that refresh just updated.

Shared target: "apt". Not for collision handling (identity already includes the action id) but so /summary can call ctx.tasks.isBusy("apt") synchronously — it is a decision taken mid-render, and an await there means keeping a second copy of core's state.

Two ways to ask twice, two answers. The old boolean refused both; only one is worth refusing.

  • The same action while one of its runs is live is onSelfConflict: drop → a skipped row → 409. Asking twice for a refresh is a double click.
  • A different action lands on the shared lock → a waiting row → 200 { queued: true }, and it starts when the lock frees. Refusing it would only mean the operator retrying by hand what the lock already sequences.

canRun is host access alone. isBusy is !isTerminal, so gating the buttons on it as well hid all three the moment anything was live or merely queued — which left the second answer above reachable only by curl (review round two). The refusal happens where it is decided, at the start; each confirmation says the run may queue.

The simulation runs behind the lock — the guard is gone (round three). Rounds one and two asserted that apt-get -s fails on the dpkg lock during a live run; round three measured it and the premise did not survive: state.ts passes -o Debug::NoLocking=true, and probing a real dist-upgrade in flight answers rc=0 with counts tracking the upgrade as it lands. The guard also had a hole its comment did not admit — with an empty cache it simulated anyway and cached that one answer for the rest of the run. So /summary, /packages and the 5-minute collector now follow an upgrade live, and load(true) means exactly "bypass the 10-second cache".

No console on any action, including refresh. Core refuses console on an action that declares a target (tasks/service.ts:195 — "console" means startable with no arguments, so it will not call a target callback), and it refuses it at registration, which disables the module at load. deno check cannot see that; the first commit here shipped console: true on refresh and the module did not load at all. The target is what /summary's synchronous isBusy("apt") needs, so console is what gives way — the Activity view offers no generic Run for the refresh. The two upgrades would not have been offered there regardless: its Run button carries no confirmation, and the schema actions deliberately do.

No onInterrupted retry — and no one-tap Retry either (round three). Everything here mutates host package state and core cannot know how far dpkg got. Re-running a dist-upgrade that died mid-unpack is the module deciding on the operator's behalf to touch a half-configured system. The row stands as the record. The same reasoning now closes Activity's Retry button: retry: false on both upgrades, because that button is one tap with no confirmation, offered for any terminal run an admin owns (interrupted and skipped included), and tasks.retry checks capabilities.retry, not console. refresh — args-free, non-destructive — keeps retry: "new-run".

One log entry per 500 ms, not per line. Every h.log() is a store write plus a frame on core:task:<id> and a dist-upgrade prints thousands of lines. Time-coalesced only — apt's burstiness is what makes the frames. The consequence is that an entry is not a line, which holds because both renderers join the tail with newlines; a client that ever renders an entry as a row breaks it. The buffer, the splitting and the flush ordering live in runlog.ts so a fake handle can test them.

The one thing to push back on if you disagree

progressTopic stays, marked transitional, alongside the { runId } response.

The shell drops the topic the moment it sees a runId, so on web this is dead weight. It is there purely for an Android build that predates task runs: without it, such a client treats the 2xx that merely created the run as the work having succeeded, and reports nothing at all when a dist-upgrade later fails.

That also means the · prefix hack survives this PR. Both go when the deployed app understands core:task:<runId>mobile#4, which is the companion to this one. (Confirmed in review: SchemaViewModel.kt never reads runId from the response, so this is the current source, not only the deployed build.)

Not fixable in this repo

A structured TaskFailure — which is what would put a reason slug and its data on a failed run, and let Activity offer a remedy instead of a message. It is a runtime export of the SDK, and an external module is imported from /data/modules/src/<slug>/, outside the workspace, where a bare specifier does not resolve (Import "@opsdeck/sdk" not a dependency); that is why every SDK import here is type-only. A locally restated class fails core's instanceof check. Core would have to duck-type the throw or hand one out on ctx.tasks. Until then the failures are plain Errors and expose: ["reasonData"] is what carries their message into the notification.

Verification

  • deno check packages/modules/updates/backend/mod.ts clean, deno lint clean
  • deno test --allow-read backend/ — 28 passed: 11 parser tests (untouched by this PR), 12 in runview_test.ts (what GET /run answers over the run shapes that caused trouble — a run queued behind the lock, a live run the buffer does not own yet — alone and with a queue behind it, a live run buried under refused double clicks, a skipped row newer than the succeeded run it was refused for, a log lost to a restart, a non-admin reader) and 5 in runlog_test.ts (nothing pending outlives the handle, an unterminated partial line still lands, a second run binding the buffer is reported)
  • Exercised against a real Debian host root: a debian:bookworm rootfs bind-mounted at /host/root in a privileged container, which is enough for the probe to pick chroot and for apt to be real — and, unlike deno check, enough to prove the module loads at all. Refresh runs; a second refresh is 409/skipped; a dist-upgrade queues behind it and starts on release; twelve refused double clicks do not evict the live run from /run, which keeps agreeing with /summary; a broken sources.list.d entry fails the run with apt-get update exited 100 and the admin notification carries that line; the tail arrives batched (9 entries for a run that printed dozens of lines).
  • The mid-upgrade simulation behavior is taken from the round-three measurement in review (probes against a live dist-upgrade, rc=0, counts tracking it) rather than re-derived on the rig for this push.

Not exercised: an interrupted run across a real restart, and a non-admin reader end-to-end (the rig runs OPSDECK_AUTH=disabled, which is a static admin — the withholding has unit coverage only). Worth a look on the live server before this reaches main, since main deploys prod.

🤖 Generated with Claude Code

Migrates the last module still on the deprecated `progressTopic` / async-route pattern (core#21). ## Why this module was the worst offender It was a miniature task system in its own right: a `running` boolean guarding one run at a time, hand-rolled `startedMs`/`endedMs`, an `ok` flag, a log buffer, a `followers` set, and an end-of-run signalled by the **wording** of a log line. That last one is the deprecation in miniature. Because a progress message starting `done` or `error` ended the client's stream, dpkg's own `Errors were encountered while processing:` would cut the log off mid-run — so the module had to prefix anything from apt that *looked* like a terminator: ```ts const safe = head.startsWith("done") || head.startsWith("error") ? `· ${text}` : text; ``` ## What changes The three apt operations become `ctx.tasks.define` actions; the routes only start one and answer `{ runId }`. Answering the verdict instead — the start-and-wait pattern `module_systemd` and `module_ipmi` use on main — is **not** an option at this length. A dist-upgrade has a 30-minute timeout and no HTTP client will hold the connection for it. Beyond satisfying the deprecation, what actually improves: - **A run survives the process.** `running` went with whatever killed it, so an upgrade interrupted by a restart came back reading `idle`. It is now an `interrupted` row that says so. - **The outcome is a status, not a sentence.** No more guessing from log text. - **Runs record who asked for them.** - **A failed run says what failed.** `notifyOn: ["failed"]` with `expose` naming `reasonData`, so the failure notification carries apt's exit line instead of an empty body. (Round three: no schedule declares these actions, so today every run is one a human just clicked — the notification is for the operator who navigated away before apt finished, not an unattended 03:00 run. The comment that oversold this is fixed.) - **apt output is withheld from non-admins** — through all four doors it can leave by, not one. `expose` names `result` and `reasonData`, so core's projection withholds the run's line tail; `GET /run` withholds its `text`; the `runlog` provider takes `role: "admin"`; the `progress` topic is declared `ctx.events.adminOnly`. (The last three were added in review — the first commit closed only the projection and the description claimed the lot. See the review thread.) ## Design notes for review **Shared `lock: "apt"`.** dpkg takes one lock on the host anyway; a second apt landing mid-upgrade fails on the lock file. A queue is the honest version of what the `running` boolean was pretending to be. Side benefit: a security upgrade queued behind a refresh now re-simulates against lists that refresh just updated. **Shared `target: "apt"`.** Not for collision handling (identity already includes the action id) but so `/summary` can call `ctx.tasks.isBusy("apt")` **synchronously** — it is a decision taken mid-render, and an await there means keeping a second copy of core's state. **Two ways to ask twice, two answers.** The old boolean refused both; only one is worth refusing. - The **same** action while one of its runs is live is `onSelfConflict: drop` → a `skipped` row → **409**. Asking twice for a refresh is a double click. - A **different** action lands on the shared lock → a `waiting` row → **200 `{ queued: true }`**, and it starts when the lock frees. Refusing it would only mean the operator retrying by hand what the lock already sequences. **`canRun` is host access alone.** `isBusy` is `!isTerminal`, so gating the buttons on it as well hid all three the moment anything was live *or merely queued* — which left the second answer above reachable only by curl (review round two). The refusal happens where it is decided, at the start; each confirmation says the run may queue. **The simulation runs behind the lock — the guard is gone (round three).** Rounds one and two asserted that `apt-get -s` fails on the dpkg lock during a live run; round three measured it and the premise did not survive: `state.ts` passes `-o Debug::NoLocking=true`, and probing a real dist-upgrade in flight answers rc=0 with counts tracking the upgrade as it lands. The guard also had a hole its comment did not admit — with an empty cache it simulated anyway and cached that one answer for the rest of the run. So `/summary`, `/packages` and the 5-minute collector now follow an upgrade live, and `load(true)` means exactly "bypass the 10-second cache". **No `console` on any action, including `refresh`.** Core refuses `console` on an action that declares a `target` (`tasks/service.ts:195` — "console" means startable with no arguments, so it will not call a target callback), and it refuses it at *registration*, which disables the module at load. `deno check` cannot see that; the first commit here shipped `console: true` on `refresh` and the module did not load at all. The target is what `/summary`'s synchronous `isBusy("apt")` needs, so `console` is what gives way — the Activity view offers no generic Run for the refresh. The two upgrades would not have been offered there regardless: its Run button carries no confirmation, and the schema actions deliberately do. **No `onInterrupted` retry — and no one-tap Retry either (round three).** Everything here mutates host package state and core cannot know how far dpkg got. Re-running a dist-upgrade that died mid-unpack is the module deciding on the operator's behalf to touch a half-configured system. The row stands as the record. The same reasoning now closes Activity's Retry button: `retry: false` on both upgrades, because that button is one tap with **no** confirmation, offered for any terminal run an admin owns (`interrupted` and `skipped` included), and `tasks.retry` checks `capabilities.retry`, not `console`. `refresh` — args-free, non-destructive — keeps `retry: "new-run"`. **One log entry per 500 ms, not per line.** Every `h.log()` is a store write plus a frame on `core:task:<id>` and a dist-upgrade prints thousands of lines. Time-coalesced only — apt's burstiness is what makes the frames. The consequence is that an entry is not a line, which holds because both renderers join the tail with newlines; a client that ever renders an entry as a row breaks it. The buffer, the splitting and the flush ordering live in `runlog.ts` so a fake handle can test them. ## The one thing to push back on if you disagree **`progressTopic` stays**, marked transitional, alongside the `{ runId }` response. The shell drops the topic the moment it sees a `runId`, so on web this is dead weight. It is there purely for an Android build that predates task runs: without it, such a client treats the 2xx that merely **created** the run as the work having succeeded, and reports nothing at all when a dist-upgrade later fails. That also means the `· ` prefix hack survives this PR. Both go when the deployed app understands `core:task:<runId>` — **mobile#4**, which is the companion to this one. (Confirmed in review: `SchemaViewModel.kt` never reads `runId` from the response, so this is the current source, not only the deployed build.) ## Not fixable in this repo A structured `TaskFailure` — which is what would put a `reason` slug and its data on a failed run, and let Activity offer a remedy instead of a message. It is a **runtime** export of the SDK, and an external module is imported from `/data/modules/src/<slug>/`, outside the workspace, where a bare specifier does not resolve (`Import "@opsdeck/sdk" not a dependency`); that is why every SDK import here is type-only. A locally restated class fails core's `instanceof` check. Core would have to duck-type the throw or hand one out on `ctx.tasks`. Until then the failures are plain `Error`s and `expose: ["reasonData"]` is what carries their message into the notification. ## Verification - `deno check packages/modules/updates/backend/mod.ts` clean, `deno lint` clean - `deno test --allow-read backend/` — 28 passed: 11 parser tests (untouched by this PR), 12 in `runview_test.ts` (what `GET /run` answers over the run shapes that caused trouble — a run queued behind the lock, a live run the buffer does not own yet — alone and with a queue behind it, a live run buried under refused double clicks, a `skipped` row newer than the succeeded run it was refused for, a log lost to a restart, a non-admin reader) and 5 in `runlog_test.ts` (nothing pending outlives the handle, an unterminated partial line still lands, a second run binding the buffer is reported) - Exercised against a real Debian host root: a `debian:bookworm` rootfs bind-mounted at `/host/root` in a privileged container, which is enough for the probe to pick `chroot` and for apt to be real — and, unlike `deno check`, enough to prove the module loads at all. Refresh runs; a second refresh is 409/`skipped`; a dist-upgrade queues behind it and starts on release; twelve refused double clicks do not evict the live run from `/run`, which keeps agreeing with `/summary`; a broken `sources.list.d` entry fails the run with `apt-get update exited 100` and the admin notification carries that line; the tail arrives batched (9 entries for a run that printed dozens of lines). - The mid-upgrade simulation behavior is taken from the round-three measurement in review (probes against a live dist-upgrade, rc=0, counts tracking it) rather than re-derived on the rig for this push. Not exercised: an interrupted run across a real restart, and a non-admin reader end-to-end (the rig runs `OPSDECK_AUTH=disabled`, which is a static admin — the withholding has unit coverage only). Worth a look on the live server before this reaches main, since main deploys prod. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This module was its own miniature task system: a `running` boolean guarding
one run at a time, hand-rolled start/end timestamps, an ok flag, a log buffer,
and an end-of-run signalled by the WORDING of a log line. Core owns all of
that now (core#21), including the parts that were never right here.

The three apt operations become ctx.tasks.define actions; the routes only
start one and answer { runId }. Answering the verdict instead — the pattern
the fast host modules use — is not an option at this length: a dist-upgrade
has a 30-minute timeout and no HTTP client will hold the connection for it.

What actually improves, beyond the deprecation:

- A run survives the process. `running` went with whatever killed it, so an
  upgrade interrupted by a restart came back reading "idle"; it is now an
  `interrupted` row that says so. It is deliberately NOT retried — core cannot
  know how far dpkg got, and re-running a dist-upgrade that died mid-unpack is
  the module deciding on the operator's behalf to touch a half-configured
  system.
- The outcome is a status, not a sentence. The old contract inferred the end
  of a run from a log line starting "done" or "error", which is why dpkg's own
  "Errors were encountered while processing:" had to be disguised before being
  published.
- The runs record who asked for them.
- apt output is withheld from non-admins. `expose` names `result` only: the
  line tail is package names, repository URLs and configuration paths, and the
  projection withholds what an action does not opt in.

All three share `lock: "apt"`, because dpkg takes one lock on the host anyway
— a second apt landing mid-upgrade fails on the lock file, so a queue is the
honest version of what the `running` boolean was pretending to be. A security
upgrade queued behind a refresh is strictly better than before: it re-simulates
against lists that refresh just updated. They also share `target: "apt"` so
/summary can answer ctx.tasks.isBusy("apt") synchronously, which is what it
needs mid-render to decide whether the buttons apply.

The Activity view may offer `refresh` (args-free and non-destructive) but not
the two upgrades: its generic Run button carries no confirmation, and
installing packages on someone's host is not a one-tap affordance.

`progressTopic` stays on the schema actions, marked transitional. The routes
answer { runId } and a current client follows that instead — the shell drops
the topic the moment it sees one — but an Android build that predates task
runs would otherwise treat the 2xx that merely CREATED the run as the work
having succeeded, and report nothing when a dist-upgrade later failed. It goes
when the deployed app understands core:task:<runId> (mobile#4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thisilike left a comment

Reviewed the code against core's task contracts rather than the description. Re-ran the verification: deno check packages/modules/updates/backend/mod.ts is clean and apt_test.ts is 11 passed. Worth stating plainly that those 11 tests are the parsers, unchanged by this PR — the run lifecycle this PR is about has no test coverage at all.

The design calls I would sign off on as written: the shared lock: "apt", no onInterrupted retry, console: true on refresh only, and keeping progressTopic as a transitional field.

Blocker

1. "apt output is withheld from non-admins" is not true

expose: ["result"] governs the run's lines. The same apt output still reaches any authenticated user through three doors this PR does not touch:

  • ctx.router.get("/run", ...) — no requireAdmin, returns runLog.text verbatim
  • ctx.events.provide("runlog", ...) — no role: "admin", so any viewer can subscribe to the live chunks
  • ctx.events.publish("progress", { msg }) — no ctx.events.adminOnly("progress")

The SDK says exactly this, in the doc for adminOnly (packages/sdk/mod.ts:221):

Gating the REST route that returns the same data is not enough — a progress stream carrying host command lines or registry errors is the same disclosure through a different door.

All three predate this PR. The problem is that the PR body lists the withholding as one of the things that "actually improves", which will read to the next person as done. Either close the other three doors (requireAdmin on /run, role: "admin" on the provider, adminOnly on the topic) or drop the claim.

Should fix

2. The cross-action 409 disappeared silently

On main, begin() refused if any run was live, so a second update run of any kind got a 409. onSelfConflict is scoped to (module, action, target), so it only covers a repeat of the same action. POST /upgrade/all while refresh is live now creates a waiting run and answers 200; BUSY never fires.

Queueing may well be what you want — it is the stated point of the shared lock. But the description says "the 409 that used to come from a hand-rolled boolean now comes from the policy the action declared", and that only holds same-action. canUpgradeAll is a 10-second-cached UI hint, not a guard, so nothing else covers the gap.

3. /run misreports a queued run

withRunLog resets runLog.text when the handler starts, not when the run is created. While a run sits waiting on the lock, /run takes label, running and statusText from the new row but text from the previous run — so the pane reads "Install all updates waiting" over the refresh's output.

4. starter has no failure path

ctx.tasks.start throwing gives a 500 with a stack trace, which is the thing hostRequired was written to avoid two functions up. refusalStatus() in the SDK exists for this mapping.

5. Every apt line is now a WebSocket frame

output() calls logHandle?.log(line) per line, and each log() emits an ev on core:task:<id>, pushes into the 512-entry retain buffer, and sets state.dirty. That is on top of the runlog chunks and the progress bus publish, so a dist-upgrade ships its output three ways, one of them line-at-a-time. Worth chunking the run log, or dropping the progress publish once a client has shown it understands runId.

Notes

  • The 5-minute collect tick calls load(true), which runs apt-get -s dist-upgrade on the host outside the "apt" lock. During a 30-minute upgrade every tick will fail on the dpkg lock and record s.error. Pre-existing, but now there is a synchronous ctx.tasks.isBusy("apt") sitting right there to guard it with.
  • expose: ["result"] opts result in, but nothing renders it — the summary sentence only reaches a client as a log line, which is the withheld field.
  • On the question you flagged for pushback: keeping progressTopic and the · prefix until mobile#4 is deployed is the right call. Deleting both now means an Android regression with no upside, and the shim is three lines with a delete condition written next to it.
Reviewed the code against core's task contracts rather than the description. Re-ran the verification: `deno check packages/modules/updates/backend/mod.ts` is clean and `apt_test.ts` is 11 passed. Worth stating plainly that those 11 tests are the parsers, unchanged by this PR — the run lifecycle this PR is about has no test coverage at all. The design calls I would sign off on as written: the shared `lock: "apt"`, no `onInterrupted` retry, `console: true` on `refresh` only, and keeping `progressTopic` as a transitional field. ## Blocker ### 1. "apt output is withheld from non-admins" is not true `expose: ["result"]` governs the *run's* `lines`. The same apt output still reaches any authenticated user through three doors this PR does not touch: - `ctx.router.get("/run", ...)` — no `requireAdmin`, returns `runLog.text` verbatim - `ctx.events.provide("runlog", ...)` — no `role: "admin"`, so any viewer can subscribe to the live chunks - `ctx.events.publish("progress", { msg })` — no `ctx.events.adminOnly("progress")` The SDK says exactly this, in the doc for `adminOnly` (`packages/sdk/mod.ts:221`): > Gating the REST route that returns the same data is not enough — a progress stream carrying host command lines or registry errors is the same disclosure through a different door. All three predate this PR. The problem is that the PR body lists the withholding as one of the things that "actually improves", which will read to the next person as done. Either close the other three doors (`requireAdmin` on `/run`, `role: "admin"` on the provider, `adminOnly` on the topic) or drop the claim. ## Should fix ### 2. The cross-action 409 disappeared silently On main, `begin()` refused if *any* run was live, so a second update run of any kind got a 409. `onSelfConflict` is scoped to `(module, action, target)`, so it only covers a repeat of the *same* action. POST `/upgrade/all` while `refresh` is live now creates a `waiting` run and answers 200; `BUSY` never fires. Queueing may well be what you want — it is the stated point of the shared lock. But the description says "the 409 that used to come from a hand-rolled boolean now comes from the policy the action declared", and that only holds same-action. `canUpgradeAll` is a 10-second-cached UI hint, not a guard, so nothing else covers the gap. ### 3. `/run` misreports a queued run `withRunLog` resets `runLog.text` when the handler starts, not when the run is created. While a run sits `waiting` on the lock, `/run` takes `label`, `running` and `statusText` from the new row but `text` from the previous run — so the pane reads "Install all updates waiting" over the refresh's output. ### 4. `starter` has no failure path `ctx.tasks.start` throwing gives a 500 with a stack trace, which is the thing `hostRequired` was written to avoid two functions up. `refusalStatus()` in the SDK exists for this mapping. ### 5. Every apt line is now a WebSocket frame `output()` calls `logHandle?.log(line)` per line, and each `log()` emits an `ev` on `core:task:<id>`, pushes into the 512-entry retain buffer, and sets `state.dirty`. That is on top of the `runlog` chunks and the `progress` bus publish, so a `dist-upgrade` ships its output three ways, one of them line-at-a-time. Worth chunking the run log, or dropping the `progress` publish once a client has shown it understands `runId`. ## Notes - The 5-minute `collect` tick calls `load(true)`, which runs `apt-get -s dist-upgrade` on the host *outside* the `"apt"` lock. During a 30-minute upgrade every tick will fail on the dpkg lock and record `s.error`. Pre-existing, but now there is a synchronous `ctx.tasks.isBusy("apt")` sitting right there to guard it with. - `expose: ["result"]` opts `result` in, but nothing renders it — the summary sentence only reaches a client as a log line, which is the withheld field. - On the question you flagged for pushback: keeping `progressTopic` and the `· ` prefix until mobile#4 is deployed is the right call. Deleting both now means an Android regression with no upside, and the shim is three lines with a delete condition written next to it.
Review of #1. The claim that apt output is withheld from non-admins was true
of the run's line tail and of nothing else: `GET /run` returned the same text
verbatim, the "runlog" provider served the live chunks to any subscriber, and
the "progress" topic published every line to the SSE broadcast. The SDK says
so in the doc for adminOnly — gating the REST route is not enough, and neither
is gating one of four doors. All four now agree:

- `provide("runlog", …, { role: "admin" })`
- `ctx.events.adminOnly("progress")`, declared before anything publishes
- `/run` withholds `text` for non-admins rather than refusing the request: a
  schema client renders a refused block as "failed to load", and "not for you"
  is a different fact from "this module is broken". A non-admin gets the run,
  its status and its `result` summary — the one field the actions expose.

`/run` also reported a queued run wrongly. `withRunLog` resets the buffer when
the handler starts, not when the run is created, so while a run sat `waiting`
on the apt lock the pane read "Install all updates waiting" over the refresh's
output. The buffer now records WHICH run it holds, and the route picks the run
holding the lock over the newer queued row, reporting the queue as a count.
That decision is `runview.ts`, pure and unit-tested — the run lifecycle had no
coverage at all, and this is the part of it that can lie without failing.

Also from the review:

- `starter` answers a refusal instead of a stack trace. `refusalStatus` is
  restated locally rather than imported, for the same reason TERMINAL is: the
  SDK is imported type-only here.
- The 409 is honest about which case it covers. The same action while one of
  its runs is live is refused (`skipped`); a different action is queued behind
  the shared lock and answers `{ queued: true }`, which is the point of the
  lock. The old boolean refused both; only the first is worth refusing.
- Run log lines are batched (40 lines or 500 ms). Every h.log() is a store
  write plus a frame on core:task:<id>, and a dist-upgrade prints thousands.
  The tail is joined with newlines wherever it is rendered, so one entry per
  batch reads exactly like the lines it holds.
- The 5-minute collector skips a tick while `isBusy("apt")`. Its
  `apt-get -s dist-upgrade` needs the lock the run is holding, so ticking
  through a 30-minute upgrade recorded "could not get lock" as the host's
  package state.

Unrelated to the review and the reason the module never would have run:
core refuses `console` on an action that declares a target, so `console: true`
on `refresh` disabled the whole module at load ("module disabled: failed to
load"). `deno check` cannot see it. The target is what makes /summary's
synchronous isBusy("apt") work, so `console` is what gives way; the Activity
view no longer offers a generic Run for the refresh.

Verified against a real Debian host root (a bookworm rootfs bind-mounted into
a privileged container, chroot strategy): refresh runs, a second refresh is
409/skipped, a dist-upgrade queues behind it and starts on release, /run
follows the live run and counts the queue, a broken sources.list fails the run
with "apt-get update exited 100", and the tail arrives batched (7 lines, 3
entries).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

All five addressed in d9d934a, plus one thing the review could not have seen because deno check cannot either — see the last section.

1. Blocker — the withholding claim

You were right that it covered one door of four. All four now agree:

  • ctx.events.provide("runlog", …, { role: "admin" })
  • ctx.events.adminOnly("progress"), declared at load before anything publishes
  • GET /run withholds text for non-admins — not requireAdmin. A schema logText block whose source refuses renders as "failed to load" (SchemaPage.svelte falls through to EmptyState on a source error), and "not for you" is a different fact from "this module is broken". A non-admin now gets Update output is admin-only. <status> — <summary>, which is also what makes expose: ["result"] reach a client for the first time (your last note).
  • the run's line tail, unchanged: expose still names result only.

2. The cross-action 409

Kept the queue — as you say, it is the point of the lock — and made the two cases say what they are instead of the description claiming one rule for both:

  • same action while one of its runs is live → skipped409. Asking twice for a refresh is a double click.
  • different action → waiting behind the lock → 200 { queued: true, reason: "waiting for run #1" }. dpkg takes one lock anyway; the alternative to a queue is a refusal followed by the operator retrying by hand.

The PR description has been rewritten to say that rather than "the 409 now comes from the policy the action declared".

3. /run misreporting a queued run

The buffer now records which run it holds, and the route no longer takes the newest row: while one run waits on the lock, the output being produced belongs to the run holding it, and that is what someone watching wants to see. The queue is reported as a count — "Refresh package lists running (1 queued)".

That decision is now backend/runview.ts, a pure function, with runview_test.ts covering it: queued-run-does-not-claim-the-live-log, stale-buffer-after-the-owner-finished, log-lost-to-a-restart, and both non-admin cases. Which is the smaller half of your preamble point — the lifecycle still has no end-to-end test, but the part of it that can lie without failing now does.

4. starter has no failure path

Wrapped; refusalStatus restated locally rather than imported, for the same reason TERMINAL was (SDK is type-only here — importing the SDK's runtime refusalStatus would give this backend a host dependency). A start that throws logs and answers { error, reason }, never a stack.

5. Every apt line a frame

Batched: 40 lines or 500 ms, whichever comes first, flushed on close before the handle is released. The tail is joined with newlines wherever it renders (activity/backend/ui.ts does run.lines.join("\n"), RunFeed the same), so one entry per batch reads exactly like the lines it holds — and the 512-entry retain buffer now holds 512 batches. Measured below: 7 lines arrived as 3 entries.

Left the progress publish alone; it is the shim's whole purpose, and it goes with mobile#4.

Notes

  • Collector during an upgrade — taken: if (ctx.tasks.isBusy("apt")) return; at the top of the tick, with the reasoning you gave. Six wrong samples and a "could not get lock" in Package data was the old behaviour.
  • expose: ["result"] renders nowhere — now it does, in the non-admin /run text above. Activity still does not render result for anyone; that is core's to fix, not this module's.
  • progressTopic and the · prefix — agreed, they stay until mobile#4 is deployed.

The thing neither of us checked

The module did not load at all:

ERROR [opsdeck:mod:updates] module disabled: failed to load
{"stage":"backend","error":"Error: action updates/refresh: console actions cannot declare a target — …"}

service.ts:195 refuses console: true on an action that declares a target, and deno check cannot see a runtime registration check. So the PR as reviewed would have disabled the updates module on the next deploy — with main deploying prod, that is the whole feature gone, not a regression in it.

console is what gives way: the target is what makes /summary's synchronous isBusy("apt") work, and that decides whether the buttons apply on every client. The Activity view no longer offers a generic Run for refresh. If you would rather keep that button, the alternative is dropping target and having /summary await ctx.tasks.list({ status: ["running", "waiting"] }) — the handler is already async, so "synchronous on purpose" is a preference there, not a constraint. Say the word and I will swap it.

Verification

Not just deno check this time. A debian:bookworm rootfs exported to a directory, bind-mounted at /host/root in a privileged container running core — which is enough for the probe to pick chroot and for apt to be real:

what result
POST /refresh {"ok":true,"runId":1,"status":"running","queued":false}
POST /refresh again, live 409 {"error":"an update run is already in progress","runId":2,"reason":"#1 is still live"}
POST /upgrade/all during it 200 {"runId":3,"status":"waiting","queued":true,"reason":"waiting for run #1"}
GET /run with both refresh's own output, "Refresh package lists running (1 queued)"
after the lock frees run 3 ran and succeeded on its own; /run followed it
POST /upgrade/security done — no security updates pending
broken sources.list.d run failed, error: apt-get update exited 100
run 3's tail 7 lines, 3 entries

Plus deno check clean, deno lint clean, and deno test backend/ — 19 passed (11 parser, 8 new).

Still not exercised: an interrupted run across a real restart, and a non-admin reader (the rig runs OPSDECK_AUTH=disabled, which is a static admin — the withholding is covered by unit test only).

All five addressed in d9d934a, plus one thing the review could not have seen because `deno check` cannot either — see the last section. ## 1. Blocker — the withholding claim You were right that it covered one door of four. All four now agree: - `ctx.events.provide("runlog", …, { role: "admin" })` - `ctx.events.adminOnly("progress")`, declared at load before anything publishes - `GET /run` withholds `text` for non-admins — **not** `requireAdmin`. A schema `logText` block whose source refuses renders as "failed to load" (`SchemaPage.svelte` falls through to `EmptyState` on a source error), and "not for you" is a different fact from "this module is broken". A non-admin now gets `Update output is admin-only. <status> — <summary>`, which is also what makes `expose: ["result"]` reach a client for the first time (your last note). - the run's line tail, unchanged: `expose` still names `result` only. ## 2. The cross-action 409 Kept the queue — as you say, it is the point of the lock — and made the two cases say what they are instead of the description claiming one rule for both: - same action while one of its runs is live → `skipped` → **409**. Asking twice for a refresh is a double click. - different action → `waiting` behind the lock → **200 `{ queued: true, reason: "waiting for run #1" }`**. dpkg takes one lock anyway; the alternative to a queue is a refusal followed by the operator retrying by hand. The PR description has been rewritten to say that rather than "the 409 now comes from the policy the action declared". ## 3. `/run` misreporting a queued run The buffer now records **which** run it holds, and the route no longer takes the newest row: while one run waits on the lock, the output being produced belongs to the run *holding* it, and that is what someone watching wants to see. The queue is reported as a count — `"Refresh package lists running (1 queued)"`. That decision is now [`backend/runview.ts`](backend/runview.ts), a pure function, with [`runview_test.ts`](backend/runview_test.ts) covering it: queued-run-does-not-claim-the-live-log, stale-buffer-after-the-owner-finished, log-lost-to-a-restart, and both non-admin cases. Which is the smaller half of your preamble point — the lifecycle still has no end-to-end test, but the part of it that can lie *without failing* now does. ## 4. `starter` has no failure path Wrapped; `refusalStatus` restated locally rather than imported, for the same reason `TERMINAL` was (SDK is type-only here — importing the SDK's runtime `refusalStatus` would give this backend a host dependency). A start that throws logs and answers `{ error, reason }`, never a stack. ## 5. Every apt line a frame Batched: 40 lines or 500 ms, whichever comes first, flushed on close before the handle is released. The tail is joined with newlines wherever it renders (`activity/backend/ui.ts` does `run.lines.join("\n")`, `RunFeed` the same), so one entry per batch reads exactly like the lines it holds — and the 512-entry retain buffer now holds 512 *batches*. Measured below: 7 lines arrived as 3 entries. Left the `progress` publish alone; it is the shim's whole purpose, and it goes with mobile#4. ## Notes - **Collector during an upgrade** — taken: `if (ctx.tasks.isBusy("apt")) return;` at the top of the tick, with the reasoning you gave. Six wrong samples and a "could not get lock" in Package data was the old behaviour. - **`expose: ["result"]` renders nowhere** — now it does, in the non-admin `/run` text above. Activity still does not render `result` for anyone; that is core's to fix, not this module's. - **`progressTopic` and the `· ` prefix** — agreed, they stay until mobile#4 is deployed. ## The thing neither of us checked The module **did not load at all**: ``` ERROR [opsdeck:mod:updates] module disabled: failed to load {"stage":"backend","error":"Error: action updates/refresh: console actions cannot declare a target — …"} ``` `service.ts:195` refuses `console: true` on an action that declares a `target`, and `deno check` cannot see a runtime registration check. So the PR as reviewed would have disabled the updates module on the next deploy — with `main` deploying prod, that is the whole feature gone, not a regression in it. `console` is what gives way: the target is what makes `/summary`'s synchronous `isBusy("apt")` work, and that decides whether the buttons apply on every client. The Activity view no longer offers a generic Run for `refresh`. If you would rather keep that button, the alternative is dropping `target` and having `/summary` `await ctx.tasks.list({ status: ["running", "waiting"] })` — the handler is already async, so "synchronous on purpose" is a preference there, not a constraint. Say the word and I will swap it. ## Verification Not just `deno check` this time. A `debian:bookworm` rootfs exported to a directory, bind-mounted at `/host/root` in a privileged container running core — which is enough for the probe to pick `chroot` and for apt to be real: | what | result | |---|---| | `POST /refresh` | `{"ok":true,"runId":1,"status":"running","queued":false}` | | `POST /refresh` again, live | `409 {"error":"an update run is already in progress","runId":2,"reason":"#1 is still live"}` | | `POST /upgrade/all` during it | `200 {"runId":3,"status":"waiting","queued":true,"reason":"waiting for run #1"}` | | `GET /run` with both | refresh's own output, `"Refresh package lists running (1 queued)"` | | after the lock frees | run 3 ran and succeeded on its own; `/run` followed it | | `POST /upgrade/security` | `done — no security updates pending` | | broken `sources.list.d` | run `failed`, `error: apt-get update exited 100` | | run 3's tail | 7 lines, **3** entries | Plus `deno check` clean, `deno lint` clean, and `deno test backend/` — 19 passed (11 parser, 8 new). Still not exercised: an interrupted run across a real restart, and a non-admin reader (the rig runs `OPSDECK_AUTH=disabled`, which is a static admin — the withholding is covered by unit test only).
thisilike left a comment

Reviewed at d9d934a, checked against core at f3426be rather than against the diff alone: the branch staged into a core checkout, deno check clean, deno test --allow-read backend/ 19/19 as claimed, plus five probes driven straight at runView.

The migration itself is right, and the four-door withholding claim holds up — I verified each one: SchemaPage.svelte:170 really does render a refused source as "failed to load" (so /run withholding text rather than 403-ing is the correct call), live.ts:437 answers a non-admin sub-err forbidden on the runlog provider, and mobile's startLogFollows ignores subscription errors rather than showing a broken pane. Three blockers below, one design question, and the batching contract is worth a second look.

Blockers

  1. /run loses the live run behind five skipped rows. ctx.tasks.list({ limit: 5 }) — every refused double-click persists a row (your own verification: 409 … "runId":2), so five of them push the running row to number six. runView then answers running: false and "it ran before the last restart" while /summary says running: true in the same second.
  2. A live run whose handler has not written yet reads as a pre-restart corpse. runText falls through to the restart message for any status that is not waiting, including running.
  3. notifyOn: ["failed"] fires an empty notification. service.ts:1233 takes run.reason, which is only ever set from TaskFailure.reason (service.ts:991). Every throw here is a plain Error, so the admin gets a title and a blank body — and this is the module's only push path for an upgrade that dies at 03:00.

The queue no shipped client can reach

isBusy is !isTerminal (service.ts:621), so a merely waiting run also makes canRun false, and all three buttons carry when: … equals true. The moment anything is live or queued, all three vanish; the web shell additionally disables every button while one action is in flight (ActionsBlock.svelte:167). So the 200 / { queued: true } branch — the longest design note in the PR — is reachable by curl, or by a click landing inside the 120 s /summary poll window. The skipped → 409 case is genuinely reachable; the cross-action queue is not. Worth resolving one way or the other before this lands.

On the push-back item

progressTopic stays — your reasoning is stronger than the PR states. mobile/…/SchemaViewModel.kt:740-780 never inspects the response body for runId at all, so this is not only the deployed build, it is the current source. Keep the topic and the · prefix until mobile#4.

Everything else is inline.

Reviewed at `d9d934a`, checked against core at `f3426be` rather than against the diff alone: the branch staged into a core checkout, `deno check` clean, `deno test --allow-read backend/` 19/19 as claimed, plus five probes driven straight at `runView`. The migration itself is right, and the four-door withholding claim holds up — I verified each one: `SchemaPage.svelte:170` really does render a refused source as "failed to load" (so `/run` withholding text rather than 403-ing is the correct call), `live.ts:437` answers a non-admin `sub-err forbidden` on the `runlog` provider, and mobile's `startLogFollows` ignores subscription errors rather than showing a broken pane. Three blockers below, one design question, and the batching contract is worth a second look. ## Blockers 1. **`/run` loses the live run behind five `skipped` rows.** `ctx.tasks.list({ limit: 5 })` — every refused double-click persists a row (your own verification: `409 … "runId":2`), so five of them push the running row to number six. `runView` then answers `running: false` and "it ran before the last restart" while `/summary` says `running: true` in the same second. 2. **A live run whose handler has not written yet reads as a pre-restart corpse.** `runText` falls through to the restart message for any status that is not `waiting`, including `running`. 3. **`notifyOn: ["failed"]` fires an empty notification.** `service.ts:1233` takes `run.reason`, which is only ever set from `TaskFailure.reason` (`service.ts:991`). Every throw here is a plain `Error`, so the admin gets a title and a blank body — and this is the module's only push path for an upgrade that dies at 03:00. ## The queue no shipped client can reach `isBusy` is `!isTerminal` (`service.ts:621`), so a merely `waiting` run also makes `canRun` false, and all three buttons carry `when: … equals true`. The moment anything is live *or queued*, all three vanish; the web shell additionally disables every button while one action is in flight (`ActionsBlock.svelte:167`). So the 200 / `{ queued: true }` branch — the longest design note in the PR — is reachable by curl, or by a click landing inside the 120 s `/summary` poll window. The `skipped` → 409 case is genuinely reachable; the cross-action queue is not. Worth resolving one way or the other before this lands. ## On the push-back item `progressTopic` stays — your reasoning is stronger than the PR states. `mobile/…/SchemaViewModel.kt:740-780` never inspects the response body for `runId` at all, so this is not only the deployed build, it is the current source. Keep the topic and the `· ` prefix until mobile#4. Everything else is inline.
@ -30,0 +38,4 @@
* module or the store being broken, which is a 500 and not the operator's
* fault to fix.
*/
function refusalStatus(err: unknown): 403 | 404 | 409 | 500 {
Owner

The 403 and 404 branches are dead. ctx.tasks.start is the module-facing path; TaskStartRefused is thrown by startAsUser / the observer (service.ts:490,504), which is what the Activity view goes through, not this. The comment already says as much — worth following it to its conclusion and dropping the mapping, since a 404 here would answer "not found" for a route that plainly exists, on what the comment itself calls "this module or the store being broken", i.e. a 500.

The 403 and 404 branches are dead. `ctx.tasks.start` is the module-facing path; `TaskStartRefused` is thrown by `startAsUser` / the observer (`service.ts:490,504`), which is what the Activity view goes through, not this. The comment already says as much — worth following it to its conclusion and dropping the mapping, since a 404 here would answer "not found" for a route that plainly exists, on what the comment itself calls "this module or the store being broken", i.e. a 500.
@ -90,0 +120,4 @@
// per flush: the run's tail is joined with newlines wherever it is rendered,
// so a batch reads exactly like the lines it holds — and 512 retained
// entries now buy 512 batches rather than 512 lines.
const LOG_BATCH_LINES = 40;
Owner

The batching is the right call for the store write, but what reaches h.log() is no longer a line.

SDK: "append one plain text line". The wire event is { type: "log", runId, line }. I checked both renderers and they do join with \nactivity/backend/ui.ts:308 and RunFeed.svelte:163 — so this renders correctly today, and the MAX_LINES trim (service.ts:1112) really does become 512 batches as the description says.

The cost is a coupling this module cannot enforce: the moment any client renders a line as a row, a count, or a virtualized item, a 40-line blob is one row. If you keep it, the constraint belongs in the SDK doc for log(), not only in this comment.

Cheaper alternative with most of the win: coalesce on time only (500 ms) and drop LOG_BATCH_LINES. apt's bursty output is what generates the frames, and a 500 ms window catches nearly all of it without ever claiming a batch is a line.

The batching is the right call for the store write, but what reaches `h.log()` is no longer a line. SDK: *"append one plain text line"*. The wire event is `{ type: "log", runId, line }`. I checked both renderers and they do join with `\n` — `activity/backend/ui.ts:308` and `RunFeed.svelte:163` — so this renders correctly today, and the `MAX_LINES` trim (`service.ts:1112`) really does become 512 batches as the description says. The cost is a coupling this module cannot enforce: the moment any client renders a line as a row, a count, or a virtualized item, a 40-line blob is one row. If you keep it, the constraint belongs in the SDK doc for `log()`, not only in this comment. Cheaper alternative with most of the win: coalesce on time only (500 ms) and drop `LOG_BATCH_LINES`. apt's bursty output is what generates the frames, and a 500 ms window catches nearly all of it without ever claiming a batch is a line.
@ -90,0 +137,4 @@
}
function recordLine(line: string): void {
if (logHandle === null || !line.trim()) return;
Owner

recordLine drops blank lines; output writes them into runLog.text unchanged. So the pane and the run's own tail are not the same text — apt's blank-line separators survive in one and not the other. Harmless, but it means the tail cannot be used to verify what an admin saw.

`recordLine` drops blank lines; `output` writes them into `runLog.text` unchanged. So the pane and the run's own tail are not the same text — apt's blank-line separators survive in one and not the other. Harmless, but it means the tail cannot be used to verify what an admin saw.
@ -166,0 +194,4 @@
* whatever apt printed last the same fact core records as the run's
* status, written once and used for both.
*/
async function withRunLog<T>(h: TaskHandle, label: string, body: () => Promise<T>): Promise<T> {
Owner

runLog, lineBuf, logPending and logHandle are module-scoped and shared by all three actions. The only thing preventing two runs from interleaving into one buffer is that all three declare lock: "apt" — an invariant nothing checks, and one a fourth action would break silently (the symptom would be one run's apt output filed under another run's id, which is exactly the bug runview.ts was extracted to prevent).

One line makes it loud:

if (logHandle !== null) {
  ctx.logger.error("two runs writing one log buffer", { incoming: h.runId, holder: logHandle.runId });
}
`runLog`, `lineBuf`, `logPending` and `logHandle` are module-scoped and shared by all three actions. The only thing preventing two runs from interleaving into one buffer is that all three declare `lock: "apt"` — an invariant nothing checks, and one a fourth action would break silently (the symptom would be one run's apt output filed under another run's id, which is exactly the bug `runview.ts` was extracted to prevent). One line makes it loud: ```ts if (logHandle !== null) { ctx.logger.error("two runs writing one log buffer", { incoming: h.runId, holder: logHandle.runId }); } ```
@ -168,0 +267,4 @@
// dpkg database in a state someone has to repair by hand
capabilities: { cancel: false as const, retry: "new-run" as const },
expose: ["result" as const],
notifyOn: ["failed" as const],
Owner

Blocker. This notification arrives empty.

service.ts:1233:

message: run.expose.includes("reasonData") ? detail : run.reason ?? "",

run.reason is populated only from TaskFailure.reason (service.ts:991). Every failure path in this module throws a plain Error, so reason stays null and an admin gets the title Install all updates failed with no body at all. The exit code lives in the run's line tail, which the notification does not carry — so the one alert that fires while nobody is looking says nothing about what happened.

Add "reasonData" here and throw TaskFailure (next comment). Safe against your own disclosure rule: notifications are admin-only (notifications/store.ts:58).

**Blocker.** This notification arrives empty. `service.ts:1233`: ```ts message: run.expose.includes("reasonData") ? detail : run.reason ?? "", ``` `run.reason` is populated only from `TaskFailure.reason` (`service.ts:991`). Every failure path in this module throws a plain `Error`, so `reason` stays null and an admin gets the title `Install all updates failed` with no body at all. The exit code lives in the run's line tail, which the notification does not carry — so the one alert that fires while nobody is looking says nothing about what happened. Add `"reasonData"` here and throw `TaskFailure` (next comment). Safe against your own disclosure rule: notifications are admin-only (`notifications/store.ts:58`).
@ -168,0 +350,4 @@
withRunLog(h, "Install all updates", async () => {
const code = await h.step("apt-get dist-upgrade", () =>
apt([...APT_OPTS, "dist-upgrade"], UPGRADE_TIMEOUT_MS));
if (code !== 0) throw new Error(`apt-get dist-upgrade exited ${code}`);
Owner

Pairs with the notifyOn comment above — this is the throw that produces the blank alert. All three actions have the same shape.

if (code !== 0) {
  throw new TaskFailure("apt-exit", { code, step: "dist-upgrade" }, `apt-get dist-upgrade exited ${code}`);
}

TaskFailure is a runtime export, so this is the one place the type-only SDK import has to give — or restate the class locally the way TERMINAL and refusalStatus already are. The structured reason is also what lets Activity offer a matching remedy later instead of a stack trace.

Pairs with the `notifyOn` comment above — this is the throw that produces the blank alert. All three actions have the same shape. ```ts if (code !== 0) { throw new TaskFailure("apt-exit", { code, step: "dist-upgrade" }, `apt-get dist-upgrade exited ${code}`); } ``` `TaskFailure` is a runtime export, so this is the one place the type-only SDK import has to give — or restate the class locally the way `TERMINAL` and `refusalStatus` already are. The structured reason is also what lets Activity offer a matching remedy later instead of a stack trace.
@ -343,1 +547,3 @@
const canRun = host.available && !runState.running;
// synchronous on purpose: this is a decision taken mid-render, and an
// await here would mean keeping a second copy of core's own state
const running = ctx.tasks.isBusy("apt");
Owner

The collector guard below (isBusy("apt") at the top of the tick) is right, and it closes one of three doors onto the same lock. load() is the other two: /summary and /packages both call it, and both are polled every 120 s by the page and the dashboard widget.

During a dist-upgrade the 10 s cache expires, apt-get -s dist-upgrade fails on the dpkg lock, and state.ts:107 turns that into error: "E: Could not get lock…" with packages: []. The page then shows Pending 0 / Security 0 with Package data: E: Could not get lock…, and OverviewWidget reads "up to date" in the middle of an upgrade.

Same fix one level down — in load(), keep serving the last known state while isBusy("apt") instead of re-simulating. The run already invalidates the cache when it ends.

The collector guard below (`isBusy("apt")` at the top of the tick) is right, and it closes one of three doors onto the same lock. `load()` is the other two: `/summary` and `/packages` both call it, and both are polled every 120 s by the page *and* the dashboard widget. During a dist-upgrade the 10 s cache expires, `apt-get -s dist-upgrade` fails on the dpkg lock, and `state.ts:107` turns that into `error: "E: Could not get lock…"` with `packages: []`. The page then shows Pending 0 / Security 0 with `Package data: E: Could not get lock…`, and `OverviewWidget` reads "up to date" in the middle of an upgrade. Same fix one level down — in `load()`, keep serving the last known state while `isBusy("apt")` instead of re-simulating. The run already invalidates the cache when it ends.
@ -344,0 +547,4 @@
// synchronous on purpose: this is a decision taken mid-render, and an
// await here would mean keeping a second copy of core's own state
const running = ctx.tasks.isBusy("apt");
const canRun = host.available && !running;
Owner

This is where the queue becomes unreachable.

isBusy is !isTerminal (service.ts:621), so a run that is merely waiting also makes canRun false. All three buttons carry when: { … equals: true }, so as soon as anything is live or queued all three disappear from the page — and the web shell separately disables every button while one action is in flight (ActionsBlock.svelte:167).

So the 200 / { queued: true } branch can only be hit by curl, or by a click that lands inside the /summary poll window. The skipped → 409 case survives (a double-click beats the poll); the cross-action queue does not, which is the case the design note is actually about.

Two honest options:

  • gate on host.available alone, let the buttons stay, and let the response say "queued behind the refresh" — the confirm dialogs already exist, so nothing lands by accident; or
  • drop the two-answer machinery, keep lock: "apt" as pure safety, and answer 409 for both.

What is here now is a queue whose only user has a shell.

This is where the queue becomes unreachable. `isBusy` is `!isTerminal` (`service.ts:621`), so a run that is merely `waiting` also makes `canRun` false. All three buttons carry `when: { … equals: true }`, so as soon as anything is live *or queued* all three disappear from the page — and the web shell separately disables every button while one action is in flight (`ActionsBlock.svelte:167`). So the 200 / `{ queued: true }` branch can only be hit by curl, or by a click that lands inside the `/summary` poll window. The `skipped` → 409 case survives (a double-click beats the poll); the cross-action queue does not, which is the case the design note is actually about. Two honest options: - gate on `host.available` alone, let the buttons stay, and let the response say "queued behind the refresh" — the confirm dialogs already exist, so nothing lands by accident; or - drop the two-answer machinery, keep `lock: "apt"` as pure safety, and answer 409 for both. What is here now is a queue whose only user has a shell.
backend/mod.ts Outdated
@ -406,0 +600,4 @@
// Five, not one: while a run waits on the apt lock the newest row is not
// the one producing output. `runView` decides which is; it is a pure
// function so that decision has a test (`runview_test.ts`).
const recent = await ctx.tasks.list({ limit: 5 });
Owner

Blocker. Five is too few, and the rows that evict the live run are ones this module creates on purpose.

Every onSelfConflict: drop refusal persists a skipped row — your own verification shows 409 {"runId":2}. Five double-clicks and the run actually holding the lock is row six, so runView never sees it:

recent = 5 x skipped, log.runId = 42 (running)
-> text  : "No log for this run - it ran before the last restart. Status: skipped."
-> status: "Refresh package lists - skipped" | running: false

Meanwhile /summary answers running: true, because isBusy reads core's live map rather than the list. The two routes on the same page disagree.

store.ts:305 clamps limit to 100 by default, so the 5 buys nothing. Ask for the live set by status, and only fall back to history when there is none:

const live = await ctx.tasks.list({ status: ["waiting", "running", "cancelling"] });
const recent = live.length > 0 ? live : await ctx.tasks.list({ limit: 1 });
**Blocker.** Five is too few, and the rows that evict the live run are ones this module creates on purpose. Every `onSelfConflict: drop` refusal persists a `skipped` row — your own verification shows `409 {"runId":2}`. Five double-clicks and the run actually holding the lock is row six, so `runView` never sees it: ``` recent = 5 x skipped, log.runId = 42 (running) -> text : "No log for this run - it ran before the last restart. Status: skipped." -> status: "Refresh package lists - skipped" | running: false ``` Meanwhile `/summary` answers `running: true`, because `isBusy` reads core's live map rather than the list. The two routes on the same page disagree. `store.ts:305` clamps `limit` to 100 by default, so the 5 buys nothing. Ask for the live set by status, and only fall back to history when there is none: ```ts const live = await ctx.tasks.list({ status: ["waiting", "running", "cancelling"] }); const recent = live.length > 0 ? live : await ctx.tasks.list({ limit: 1 }); ```
@ -0,0 +25,4 @@
text: string;
label: string;
running: boolean;
queued: number;
Owner

queued is a count here and a boolean in the POST responses (queued: status === "waiting"). One module, one word, two types — a client that reads both routes has to know which is which. queuedCount here, or queued: boolean plus queueDepth.

`queued` is a count here and a boolean in the POST responses (`queued: status === "waiting"`). One module, one word, two types — a client that reads both routes has to know which is which. `queuedCount` here, or `queued: boolean` plus `queueDepth`.
@ -0,0 +36,4 @@
* and this backend imports the SDK type-only. Kept in step with `RunStatus`
* in core's `packages/sdk/tasks.ts`.
*/
export const TERMINAL = [
Owner

Currently in sync with core's TERMINAL_STATUSES, so this is about drift direction rather than a bug today.

Enumerating the terminal set means a status core adds later reads as live forever/run would show a finished run as running until the process restarts. Enumerating the live set fails the other way: a new live status reads as terminal, which self-corrects on the next poll once it ends.

export const LIVE = ["waiting", "running", "cancelling"];
export const isLive = (status: string): boolean => LIVE.includes(status);

Same three statuses, opposite failure mode.

Currently in sync with core's `TERMINAL_STATUSES`, so this is about drift direction rather than a bug today. Enumerating the terminal set means a status core adds later reads as **live forever** — `/run` would show a finished run as running until the process restarts. Enumerating the live set fails the other way: a new live status reads as terminal, which self-corrects on the next poll once it ends. ```ts export const LIVE = ["waiting", "running", "cancelling"]; export const isLive = (status: string): boolean => LIVE.includes(status); ``` Same three statuses, opposite failure mode.
@ -0,0 +107,4 @@
if (run.status === "waiting") {
return "Queued behind another update run — the log starts when apt does.";
}
return `No log for this run — it ran before the last restart. Status: ${run.status}.`;
Owner

Blocker. This branch is reached by live runs too, and then it contradicts its own sentence.

recent = [9 running, 7 succeeded], log.runId = 7
-> "No log for this run - it ran before the last restart. Status: running."

The window is core flipping the row to running before withRunLog assigns runLog.runId — every start crosses it, and any /run poll landing inside prints this. waiting is not the only live status that can arrive here; running and cancelling can too.

Test isLive first, and keep the restart message for terminal rows only:

if (isLive(run.status)) {
  return run.status === "waiting"
    ? "Queued behind another update run - the log starts when apt does."
    : `${label} - starting.`;
}
return `No log for this run - it ran before the last restart. Status: ${run.status}.`;
**Blocker.** This branch is reached by live runs too, and then it contradicts its own sentence. ``` recent = [9 running, 7 succeeded], log.runId = 7 -> "No log for this run - it ran before the last restart. Status: running." ``` The window is core flipping the row to `running` before `withRunLog` assigns `runLog.runId` — every start crosses it, and any `/run` poll landing inside prints this. `waiting` is not the only live status that can arrive here; `running` and `cancelling` can too. Test `isLive` first, and keep the restart message for terminal rows only: ```ts if (isLive(run.status)) { return run.status === "waiting" ? "Queued behind another update run - the log starts when apt does." : `${label} - starting.`; } return `No log for this run - it ran before the last restart. Status: ${run.status}.`; ```
@ -0,0 +133,4 @@
eq(done("failed"), "Install all updates — failed", "failed");
eq(done("skipped"), "Install all updates — skipped", "skipped");
eq(done("interrupted"), "Install all updates — interrupted", "interrupted");
});
Owner

The file is the right idea and covers the shapes it names. It misses exactly the two that broke under review — both fit the existing style in a few lines each:

Deno.test("a live run the buffer does not own yet is not a pre-restart corpse", () => {
  const v = runView([{ id: 9, status: "running", title: "Install all updates" }, REFRESH], log(), true);
  lacks(v.text, "before the last restart", "text");
});

Deno.test("skipped rows do not evict the live run", () => {
  // whatever /run ends up querying, this is the shape that must not answer "idle"
});

Also untested: recordLine/flushRunLog, whose invariant — flush before the handle is released — breaks silently if the ordering in withRunLog's finally ever changes. A fake TaskHandle collecting log() calls covers it without touching apt.

The file is the right idea and covers the shapes it names. It misses exactly the two that broke under review — both fit the existing style in a few lines each: ```ts Deno.test("a live run the buffer does not own yet is not a pre-restart corpse", () => { const v = runView([{ id: 9, status: "running", title: "Install all updates" }, REFRESH], log(), true); lacks(v.text, "before the last restart", "text"); }); Deno.test("skipped rows do not evict the live run", () => { // whatever /run ends up querying, this is the shape that must not answer "idle" }); ``` Also untested: `recordLine`/`flushRunLog`, whose invariant — flush before the handle is released — breaks silently if the ordering in `withRunLog`'s `finally` ever changes. A fake `TaskHandle` collecting `log()` calls covers it without touching apt.
Review round two. Every item is a claim this module made that a client could
catch it out on.

`/run` asked for the newest five rows, and this module manufactures rows that
are newer than the run it cares about: every refused double click persists a
`skipped` one. Five of them and the run holding the apt lock is row six, so
`/run` answered "idle" in the same second `/summary` answered `running: true`.
It asks for the LIVE runs now, and falls back to a single row of history only
when nothing is live. `runView` prefers a live row for the same reason.

Its text fell through to "it ran before the last restart" for any status that
was not `waiting` — including `running`, which every start crosses on its way
to `runLog.begin`. Live statuses are tested first, and the restart message is
for terminal rows only.

`notifyOn: ["failed"]` fired with an empty body: core's outcome notification is
`run.reason ?? run.error` when the action exposes `reasonData` and a bare
`run.reason` otherwise, and `reason` is only ever set from a structured
`TaskFailure` — which an external module cannot throw. It is a runtime export,
and a bare specifier does not resolve at load ("Import \"@opsdeck/sdk\" not a
dependency"), while a locally restated class fails core's `instanceof`. So
`expose` names `reasonData` and the alert now carries apt's exit line.

The queue nothing could reach: `isBusy` is "not terminal", so a merely waiting
run made `canRun` false and all three buttons vanished — the shared lock's
queue was enterable only by curl. `canRun` is host access alone; the two
answers a second ask gets are decided at the start, where they were already,
and each confirmation says the run may queue.

Also from the review:

- `load()` serves the last known state while `isBusy("apt")`. Behind a live
  run its `apt-get -s dist-upgrade` fails on the dpkg lock, and the empty list
  that comes back is read as the host's package state — Pending 0 with an
  error in "Package data", and a dashboard card reading "up to date" in the
  middle of an upgrade.
- Log lines are coalesced on time alone; the 40-line cap is gone. Blank lines
  now reach the run's tail as well as the pane, so the two are the same text.
- The buffer moves to runlog.ts, where a fake handle can test the ordering its
  release depends on, and a second run binding to it is logged rather than
  interleaved in silence.
- `refusalStatus` drops its dead 403/404 branches: `ctx.tasks.start` trusts its
  caller, so a 404 there would answer "not found" for a route that exists.
- `TERMINAL` becomes `LIVE`: a status core adds later then reads as terminal,
  which the next poll corrects, rather than as live forever.
- `/run`'s `queued` count is `queuedCount`; `queued` stays the boolean the POST
  routes answer.

Verified on the rig (Debian rootfs bind-mounted at /host/root in a privileged
container, chroot strategy): 26 tests pass, `deno check` clean, module loads,
twelve refused double clicks do not evict the live run from `/run`, `/summary`
keeps its counts through the run, an upgrade queues behind a failing refresh
and starts when the lock frees, and the failure alert reads "apt-get update
exited 100".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Pushed dec796a. All three blockers fixed, the queue question resolved in favour of keeping it, and one item I have to push back on for a reason the review could not have known.

Blockers

1. /run loses the live run behind skipped rows. Fixed as suggested — the live set first, one row of history only when nothing is live:

const live = await ctx.tasks.list({ status: [...LIVE] });
const recent = live.length > 0 ? live : await ctx.tasks.list({ limit: 1 });

runView also prefers a live row over a newer terminal one (live.find(id === log.runId) ?? live[0] ?? recent[0]), so the shape cannot come back through a different caller. Rig: twelve refused double clicks with a refresh live, /run still answers Refresh package lists running (1 queued) while /summary answers running: true.

2. A live run the buffer does not own yet. isLive is tested first; the restart message is for terminal rows only. Test added — the one where the buffer still holds the finished refresh and run 9 is running.

3. The empty notification. expose gains reasonData, so core's run.reason ?? run.error branch is taken and the alert reads apt-get update exited 100 (verified on the rig, notification body quoted in the commit).

But TaskFailure cannot be thrown from here, and neither can it be restated locally:

  • It is a runtime export. An external module is import()ed from /data/modules/src/<slug>/, outside the workspace, and a bare specifier there does not resolve — TypeError: Import "@opsdeck/sdk" not a dependency, which I reproduced rather than inferred. That is what the type-only-imports constraint in CLAUDE.md is about; type-only survives because it is erased before resolution.
  • A locally restated class does not satisfy service.ts:988's e instanceof TaskFailure, so reason would still be null and the notification still blank.

So the failures stay plain Errors and reasonData is what carries their message. The structured reason and the matching remedy are worth having, but they need core to reach external modules some other way — duck-typing the throw (name === "TaskFailure" plus reason/data), or handing one out on ctx.tasks. Happy to open that against core if you agree it belongs there; it is not fixable in this repo.

The queue no shipped client can reach

Kept, made reachable: canRun is host.available alone. isBusy is !isTerminal, so gating on it hid all three buttons whenever anything was live or merely waiting — which is exactly your point. The refusal happens where it is decided (the start: same action → skipped → 409, different → waiting → 200), and each confirmation now ends "…it queues if another update run is live", so nothing lands by accident. The web shell still disables its own buttons while it follows a run, so within one tab the queue is entered after a reload — but a second admin, a second tab, mobile and curl all reach it, and the operator's natural sequence (refresh, then install) no longer requires watching for the poll.

The alternative — drop the two answers and refuse both — throws away the thing the shared lock exists for, and the re-simulation a security upgrade queued behind a refresh gets.

Everything else

  • The batching contract. Took the cheaper option: 500 ms window, LOG_BATCH_LINES gone. A batch is still not a line, so no client may render an entry as a row — but with the cap gone there is no second knob claiming otherwise, and the constraint is written where it can be read (CLAUDE.md, runlog.ts).
  • The shared buffer. Moved to runlog.ts with the conflict report you wrote, and runlog_test.ts covers what a fake handle can: nothing pending outlives release(), an unterminated partial line still reaches the tail, a released buffer takes no more lines, a second begin is reported and does not steal the first run's lines.
  • Blank lines. Kept in both now — the tail and the pane are the same text, which was the point.
  • refusalStatus. 403/404 branches dropped; TaskStartRefused → 409, everything else 500.
  • TERMINALLIVE. Same three statuses, the failure direction you argued for.
  • queued the count is queuedCount; queued stays the boolean on the POST responses.
  • load() behind the lock. Serves the last known state while isBusy("apt"); only a run holding the lock passes load(true). That closes the /summary and /packages doors onto the same dpkg lock the collector tick already skipped. Rig: /summary kept statusText: "ok" and its counts through a run that was failing on a blackholed mirror.
  • expose: ["result"] renders nowhere. Still true; result reaches a non-admin as the summary in /run's text, which is what runview_test.ts asserts. Not going further here.

Verification

Same rig as before (a debian:bookworm rootfs bind-mounted at /host/root in a privileged container, chroot strategy, module loaded — not just deno checked): 26 tests pass (11 parser, 10 runview, 5 runlog), deno check clean, deno lint clean. Driven end to end: refresh runs; a second refresh is 409 with a skipped row; upgrade/security queues at 200 {queued: true} and starts when the lock frees; twelve refusals do not evict the live run; a blackholed sources.list.d entry fails the run with apt-get update exited 100 and the admin notification carries that line; the run's tail arrives batched (9 entries for a run that printed dozens of lines).

Still not exercised: an interrupted run across a real restart, and a non-admin reader end to end — the rig is OPSDECK_AUTH=disabled, i.e. a static admin, so the withholding still has unit coverage only.

🤖 Generated with Claude Code

Pushed `dec796a`. All three blockers fixed, the queue question resolved in favour of keeping it, and one item I have to push back on for a reason the review could not have known. ## Blockers **1. `/run` loses the live run behind `skipped` rows.** Fixed as suggested — the live set first, one row of history only when nothing is live: ```ts const live = await ctx.tasks.list({ status: [...LIVE] }); const recent = live.length > 0 ? live : await ctx.tasks.list({ limit: 1 }); ``` `runView` also prefers a live row over a newer terminal one (`live.find(id === log.runId) ?? live[0] ?? recent[0]`), so the shape cannot come back through a different caller. Rig: twelve refused double clicks with a refresh live, `/run` still answers `Refresh package lists running (1 queued)` while `/summary` answers `running: true`. **2. A live run the buffer does not own yet.** `isLive` is tested first; the restart message is for terminal rows only. Test added — the one where the buffer still holds the finished refresh and run 9 is `running`. **3. The empty notification.** `expose` gains `reasonData`, so core's `run.reason ?? run.error` branch is taken and the alert reads `apt-get update exited 100` (verified on the rig, notification body quoted in the commit). But **`TaskFailure` cannot be thrown from here**, and neither can it be restated locally: - It is a runtime export. An external module is `import()`ed from `/data/modules/src/<slug>/`, outside the workspace, and a bare specifier there does not resolve — `TypeError: Import "@opsdeck/sdk" not a dependency`, which I reproduced rather than inferred. That is what the type-only-imports constraint in `CLAUDE.md` is about; type-only survives because it is erased before resolution. - A locally restated class does not satisfy `service.ts:988`'s `e instanceof TaskFailure`, so `reason` would still be null and the notification still blank. So the failures stay plain `Error`s and `reasonData` is what carries their message. The structured reason and the matching remedy are worth having, but they need core to reach external modules some other way — duck-typing the throw (`name === "TaskFailure"` plus `reason`/`data`), or handing one out on `ctx.tasks`. Happy to open that against core if you agree it belongs there; it is not fixable in this repo. ## The queue no shipped client can reach Kept, made reachable: `canRun` is `host.available` alone. `isBusy` is `!isTerminal`, so gating on it hid all three buttons whenever anything was live *or merely waiting* — which is exactly your point. The refusal happens where it is decided (the start: same action → `skipped` → 409, different → `waiting` → 200), and each confirmation now ends "…it queues if another update run is live", so nothing lands by accident. The web shell still disables its own buttons while it follows a run, so within one tab the queue is entered after a reload — but a second admin, a second tab, mobile and curl all reach it, and the operator's natural sequence (refresh, then install) no longer requires watching for the poll. The alternative — drop the two answers and refuse both — throws away the thing the shared lock exists for, and the re-simulation a security upgrade queued behind a refresh gets. ## Everything else - **The batching contract.** Took the cheaper option: 500 ms window, `LOG_BATCH_LINES` gone. A batch is still not a line, so no client may render an entry as a row — but with the cap gone there is no second knob claiming otherwise, and the constraint is written where it can be read (`CLAUDE.md`, `runlog.ts`). - **The shared buffer.** Moved to `runlog.ts` with the conflict report you wrote, and `runlog_test.ts` covers what a fake handle can: nothing pending outlives `release()`, an unterminated partial line still reaches the tail, a released buffer takes no more lines, a second `begin` is reported and does not steal the first run's lines. - **Blank lines.** Kept in both now — the tail and the pane are the same text, which was the point. - **`refusalStatus`.** 403/404 branches dropped; `TaskStartRefused` → 409, everything else 500. - **`TERMINAL` → `LIVE`.** Same three statuses, the failure direction you argued for. - **`queued` the count** is `queuedCount`; `queued` stays the boolean on the POST responses. - **`load()` behind the lock.** Serves the last known state while `isBusy("apt")`; only a run holding the lock passes `load(true)`. That closes the `/summary` and `/packages` doors onto the same dpkg lock the collector tick already skipped. Rig: `/summary` kept `statusText: "ok"` and its counts through a run that was failing on a blackholed mirror. - **`expose: ["result"]` renders nowhere.** Still true; `result` reaches a non-admin as the summary in `/run`'s text, which is what `runview_test.ts` asserts. Not going further here. ## Verification Same rig as before (a `debian:bookworm` rootfs bind-mounted at `/host/root` in a privileged container, `chroot` strategy, module loaded — not just `deno check`ed): 26 tests pass (11 parser, 10 `runview`, 5 `runlog`), `deno check` clean, `deno lint` clean. Driven end to end: refresh runs; a second refresh is 409 with a `skipped` row; `upgrade/security` queues at 200 `{queued: true}` and starts when the lock frees; twelve refusals do not evict the live run; a blackholed `sources.list.d` entry fails the run with `apt-get update exited 100` and the admin notification carries that line; the run's tail arrives batched (9 entries for a run that printed dozens of lines). Still not exercised: an interrupted run across a real restart, and a non-admin reader end to end — the rig is `OPSDECK_AUTH=disabled`, i.e. a static admin, so the withholding still has unit coverage only. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
thisilike left a comment

Third pass at dec796a, staged into a core checkout at main (db57f4d) rather than the f3426be of the last round, and checked against core's source rather than against the description. deno check clean, deno lint clean, deno test --allow-read backend/ 26 passed (11 parser, 10 runview, 5 runlog) — every number in the verification section holds.

The three blockers from round two are genuinely fixed, and I re-derived each rather than taking the commit message for it:

  • Empty notification. service.ts:1229-1233 — a plain Error lands as error: message with reason null (service.ts:999), and expose.includes("reasonData") is exactly what makes the notification fall through to run.error. So apt-get update exited 100 really does reach the alert body. reasonData itself stays null, so nothing extra is disclosed.
  • The live run behind skipped rows. Fixed, and stronger than the fix needed to be — see the note on runview.ts about why the limit: 1 fallback is also safe, which is not obvious and is not this repo's to keep true.
  • A live run the buffer does not own yet. Fixed, tested.

The withholding claim now holds through five doors, not four — I checked each in current core rather than trusting the last round: the projection (expose has no lines), /run's own text, the provider role (live.ts:436-437), adminOnly on both fan-outs (sse.ts:35 and live.ts:643), and one this PR does not mention because core closes it: the core:task:<id> log frames are gated on expose.includes("lines") in tasks/routes.ts:199.

The batching contract holds too: core stores a multi-line batch as one entry without splitting (service.ts:1109), and both renderers join with \n (activity/backend/ui.ts:308, RunFeed.svelte:163). And console: false does clear the registration check that disabled the module last time (service.ts:195) — I re-ran that path. No waitTimeout is declared, so a queued run has no deadline and the queue cannot silently turn into a skip; worth knowing, because with one it would.

Three things below. One is a safety regression the PR argues against elsewhere in its own text; one is a hole in an invariant this PR just wrote into CLAUDE.md; the third is that the invariant's premise does not survive measurement.

1. retry: "new-run" is the one-tap host mutation console: false exists to refuse

console: false is justified twice in this diff — "its Run button carries no confirmation, and the schema action deliberately does — installing packages on someone's host is not a one-tap affordance". Then RUN_DEFAULTS declares capabilities.retry: "new-run" for all three actions, and Activity's Retry button (activity/backend/ui.ts:123-132) has no confirm — unlike Cancel and Force release, which both have one.

projection.ts:66 offers it for any terminal run an admin owns. Terminal includes interrupted and skipped. So wherever Activity is enabled:

  • a dist-upgrade that died mid-unpack gets a one-tap "run it again", which is the exact decision the PR's onInterrupted note refuses to make on the operator's behalf — core does not resume it, and then hands out a button that does;
  • every refused double click leaves a skipped row that also carries Retry;
  • tasks.retry (service.ts:567) goes straight to startRun — it re-checks capabilities.retry, not console, so nothing else stops it.

retry: false on the two upgrade actions (keep it on refresh, which is harmless and args-free) costs one line. If you want to keep it, it needs saying out loud in CLAUDE.md next to the console reasoning, because as written the two paragraphs contradict each other.

2. load() runs apt-get -s behind the lock exactly when the cache is empty

CLAUDE.md now carries "Nothing runs apt-get -s while isBusy("apt")" as a hard constraint. The guard is inside if (!force && cache), so with cache === null the function skips the check and simulates anyway — and this module opens that window itself:

  1. the refresh's withRunLog finally sets cache = null and publishes updates, so every open page and the dashboard card refetch /summary;
  2. #finishqueueMicrotask(#sweep) starts the queued upgrade-all immediately;
  3. the refetch lands: cache is null, isBusy("apt") is true, and load() runs apt-get -s dist-upgrade on the host anyway — then caches whatever it got for the rest of the run, because every later call takes the isBusy branch.

That is the advertised queue sequence (refresh, then the upgrade behind it), not an exotic one. Hoisting the check out of the cache && guard is the fix; it needs an answer for "busy and nothing known yet", and E: Could not get lock is the one answer it must not be.

3. The premise under §2 is not established — and state.ts already argues against it

state.ts:104 passes -o Debug::NoLocking=true, with a comment saying it is there precisely so the simulation survives another apt holding the dpkg lock. So the guard, the collector skip, and the CLAUDE.md constraint rest on a failure mode the code below them already handles.

I measured it instead of arguing about it — debian:bookworm-20230612, a real apt-get -y dist-upgrade running, simulating against it every two seconds:

upgradable: 44
probe 1: plain rc=0 inst=38 err=  | nolock rc=0 inst=32 err=
probe 2: plain rc=0 inst=17 err=  | nolock rc=0 inst=12 err=

No E: Could not get lock, with or without NoLocking, and the counts track the upgrade as it lands. My own round-two note asserted the opposite ("during a dist-upgrade the 10 s cache expires, apt-get -s dist-upgrade fails on the dpkg lock") — that was asserted, not tested, and it is now a hard constraint in a file the next person will trust.

What the guard costs, if the premise is wrong: /summary, /packages and the dashboard card freeze for up to 30 minutes, and updates.pending / updates.security lose their 5-minute samples over the only window in which they move. Either demonstrate the failure on the rig (apt-get -s -o Debug::NoLocking=true dist-upgrade during a real one, on the host, through the chroot) and keep the guard with the evidence next to it, or drop it and let the page show the counts falling.

Notes

  • README.md:55 still says expose "names result only". dec796a added reasonData; CLAUDE.md was updated and the README was not — and this is the paragraph that tells a reader what is withheld.
  • No schedule declares any of these actions, so "the alert that fires at 03:00" cannot happen today: every run is one a human just clicked. notifyOn: ["failed"] is still worth having (the operator may have navigated away), but it is not the unattended-alert path the description sells.
  • frontend/OverviewWidget.svelte is untouched, and running changed meaning under it: isBusy is "not terminal", so the "upgrade running" badge now also shows while a run is merely waiting — and for a plain refresh.
  • Inline: one on runview.ts about the live[0] pick and about the load-bearing core detail that makes limit: 1 safe.

Everything else — the shared lock, the two answers, canRun as host access alone, keeping progressTopic and the · prefix until mobile#4, no onInterrupted retry, the runlog/runview extraction and its tests — I would sign off as written.

Third pass at `dec796a`, staged into a core checkout at **`main` (db57f4d)** rather than the `f3426be` of the last round, and checked against core's source rather than against the description. `deno check` clean, `deno lint` clean, `deno test --allow-read backend/` **26 passed (11 parser, 10 runview, 5 runlog)** — every number in the verification section holds. The three blockers from round two are genuinely fixed, and I re-derived each rather than taking the commit message for it: - **Empty notification.** `service.ts:1229-1233` — a plain `Error` lands as `error: message` with `reason` null (`service.ts:999`), and `expose.includes("reasonData")` is exactly what makes the notification fall through to `run.error`. So `apt-get update exited 100` really does reach the alert body. `reasonData` itself stays null, so nothing extra is disclosed. - **The live run behind `skipped` rows.** Fixed, and stronger than the fix needed to be — see the note on `runview.ts` about *why* the `limit: 1` fallback is also safe, which is not obvious and is not this repo's to keep true. - **A live run the buffer does not own yet.** Fixed, tested. The withholding claim now holds through **five** doors, not four — I checked each in current core rather than trusting the last round: the projection (`expose` has no `lines`), `/run`'s own text, the provider role (`live.ts:436-437`), `adminOnly` on **both** fan-outs (`sse.ts:35` and `live.ts:643`), and one this PR does not mention because core closes it: the `core:task:<id>` log frames are gated on `expose.includes("lines")` in `tasks/routes.ts:199`. The batching contract holds too: core stores a multi-line batch as **one** entry without splitting (`service.ts:1109`), and both renderers join with `\n` (`activity/backend/ui.ts:308`, `RunFeed.svelte:163`). And `console: false` does clear the registration check that disabled the module last time (`service.ts:195`) — I re-ran that path. No `waitTimeout` is declared, so a queued run has no deadline and the queue cannot silently turn into a `skip`; worth knowing, because with one it would. Three things below. One is a safety regression the PR argues against elsewhere in its own text; one is a hole in an invariant this PR just wrote into `CLAUDE.md`; the third is that the invariant's premise does not survive measurement. ## 1. `retry: "new-run"` is the one-tap host mutation `console: false` exists to refuse `console: false` is justified twice in this diff — "its Run button carries no confirmation, and the schema action deliberately does — installing packages on someone's host is not a one-tap affordance". Then `RUN_DEFAULTS` declares `capabilities.retry: "new-run"` for all three actions, and Activity's Retry button (`activity/backend/ui.ts:123-132`) has **no `confirm`** — unlike Cancel and Force release, which both have one. `projection.ts:66` offers it for any **terminal** run an admin owns. Terminal includes `interrupted` and `skipped`. So wherever Activity is enabled: - a dist-upgrade that died mid-unpack gets a one-tap "run it again", which is the exact decision the PR's `onInterrupted` note refuses to make on the operator's behalf — core does not resume it, and then hands out a button that does; - every refused double click leaves a `skipped` row that also carries Retry; - `tasks.retry` (`service.ts:567`) goes straight to `startRun` — it re-checks `capabilities.retry`, not `console`, so nothing else stops it. `retry: false` on the two upgrade actions (keep it on `refresh`, which is harmless and args-free) costs one line. If you want to keep it, it needs saying out loud in `CLAUDE.md` next to the `console` reasoning, because as written the two paragraphs contradict each other. ## 2. `load()` runs `apt-get -s` behind the lock exactly when the cache is empty `CLAUDE.md` now carries "**Nothing runs `apt-get -s` while `isBusy("apt")`**" as a hard constraint. The guard is inside `if (!force && cache)`, so with `cache === null` the function skips the check and simulates anyway — and this module opens that window itself: 1. the refresh's `withRunLog` finally sets `cache = null` and publishes `updates`, so every open page and the dashboard card refetch `/summary`; 2. `#finish` → `queueMicrotask(#sweep)` starts the queued `upgrade-all` immediately; 3. the refetch lands: `cache` is null, `isBusy("apt")` is true, and `load()` runs `apt-get -s dist-upgrade` on the host anyway — then **caches whatever it got for the rest of the run**, because every later call takes the `isBusy` branch. That is the advertised queue sequence (refresh, then the upgrade behind it), not an exotic one. Hoisting the check out of the `cache &&` guard is the fix; it needs an answer for "busy and nothing known yet", and `E: Could not get lock` is the one answer it must not be. ## 3. The premise under §2 is not established — and `state.ts` already argues against it `state.ts:104` passes `-o Debug::NoLocking=true`, with a comment saying it is there *precisely* so the simulation survives another apt holding the dpkg lock. So the guard, the collector skip, and the `CLAUDE.md` constraint rest on a failure mode the code below them already handles. I measured it instead of arguing about it — `debian:bookworm-20230612`, a real `apt-get -y dist-upgrade` running, simulating against it every two seconds: ``` upgradable: 44 probe 1: plain rc=0 inst=38 err= | nolock rc=0 inst=32 err= probe 2: plain rc=0 inst=17 err= | nolock rc=0 inst=12 err= ``` No `E: Could not get lock`, with **or** without `NoLocking`, and the counts track the upgrade as it lands. My own round-two note asserted the opposite ("during a dist-upgrade the 10 s cache expires, `apt-get -s dist-upgrade` fails on the dpkg lock") — that was asserted, not tested, and it is now a hard constraint in a file the next person will trust. What the guard costs, if the premise is wrong: `/summary`, `/packages` and the dashboard card freeze for up to 30 minutes, and `updates.pending` / `updates.security` lose their 5-minute samples over the only window in which they move. Either demonstrate the failure on the rig (`apt-get -s -o Debug::NoLocking=true dist-upgrade` during a real one, on the host, through the chroot) and keep the guard with the evidence next to it, or drop it and let the page show the counts falling. ## Notes - `README.md:55` still says `expose` "names `result` only". `dec796a` added `reasonData`; `CLAUDE.md` was updated and the README was not — and this is the paragraph that tells a reader what is withheld. - No schedule declares any of these actions, so "the alert that fires at 03:00" cannot happen today: every run is one a human just clicked. `notifyOn: ["failed"]` is still worth having (the operator may have navigated away), but it is not the unattended-alert path the description sells. - `frontend/OverviewWidget.svelte` is untouched, and `running` changed meaning under it: `isBusy` is "not terminal", so the "upgrade running" badge now also shows while a run is merely `waiting` — and for a plain refresh. - Inline: one on `runview.ts` about the `live[0]` pick and about the load-bearing core detail that makes `limit: 1` safe. Everything else — the shared lock, the two answers, `canRun` as host access alone, keeping `progressTopic` and the `· ` prefix until mobile#4, no `onInterrupted` retry, the `runlog`/`runview` extraction and its tests — I would sign off as written.
@ -28,0 +52,4 @@
Non-admins see that a run happened and how it ended, but never its output.
apt and dpkg print package names, repository URLs and configuration paths, so
all four ways out are closed together: the run's line tail (core's projection
`expose` names `result` only), `GET /run`, the `runlog` live topic and the
Owner

Stale as of dec796a: expose now names result and reasonData (mod.ts:247) — which is what makes the failure notification carry apt's exit line, so it is not a detail. CLAUDE.md was updated for it; this paragraph, which is the one telling a reader what is withheld, was not.

Stale as of `dec796a`: `expose` now names `result` **and** `reasonData` (`mod.ts:247`) — which is what makes the failure notification carry apt's exit line, so it is not a detail. `CLAUDE.md` was updated for it; this paragraph, which is the one telling a reader what is withheld, was not.
@ -60,0 +84,4 @@
// data", and the dashboard card reads "up to date" mid-upgrade. Serving
// the last known state is stale by minutes; the alternative is wrong.
// The run invalidates this cache when it ends.
if (ctx.tasks.isBusy("apt")) return cache.state;
Owner

The invariant CLAUDE.md now states as a hard constraint is false when cache is null — the check sits inside if (!force && cache), so an empty cache skips it and simulates anyway.

This module opens that window itself, in the sequence the PR advertises:

  1. the refresh's withRunLog finally sets cache = null and publishes updates → every page and the dashboard card refetch /summary;
  2. #finish queues #sweep, which starts the waiting dist-upgrade immediately;
  3. the refetch arrives with cache === null and isBusy("apt") true, runs apt-get -s dist-upgrade on the host, and caches the result for the whole run — every later call now takes the isBusy branch and serves it.

If the premise holds (see the collector tick), that is "Pending 0 / up to date" and E: Could not get lock in Package data for the next half hour — the exact page this guard was written to prevent.

if (!force && ctx.tasks.isBusy("apt")) return cache?.state ?? busyState();
if (!force && cache && Date.now() - cache.at < CACHE_MS) return cache.state;

busyState() has to answer "not measured while an update run is live". The one thing it must not be is a lock error, because statusText renders it verbatim.

**The invariant `CLAUDE.md` now states as a hard constraint is false when `cache` is null** — the check sits inside `if (!force && cache)`, so an empty cache skips it and simulates anyway. This module opens that window itself, in the sequence the PR advertises: 1. the refresh's `withRunLog` finally sets `cache = null` and publishes `updates` → every page and the dashboard card refetch `/summary`; 2. `#finish` queues `#sweep`, which starts the `waiting` dist-upgrade immediately; 3. the refetch arrives with `cache === null` and `isBusy("apt")` true, runs `apt-get -s dist-upgrade` on the host, and **caches the result for the whole run** — every later call now takes the `isBusy` branch and serves it. If the premise holds (see the collector tick), that is "Pending 0 / up to date" and `E: Could not get lock` in Package data for the next half hour — the exact page this guard was written to prevent. ```ts if (!force && ctx.tasks.isBusy("apt")) return cache?.state ?? busyState(); if (!force && cache && Date.now() - cache.at < CACHE_MS) return cache.state; ``` `busyState()` has to answer "not measured while an update run is live". The one thing it must not be is a lock error, because `statusText` renders it verbatim.
@ -168,0 +231,4 @@
lock: () => "apt",
// apt is not interruptible halfway through unpacking without leaving the
// dpkg database in a state someone has to repair by hand
capabilities: { cancel: false as const, retry: "new-run" as const },
Owner

This is the affordance console: false refuses, through a different door.

activity/backend/ui.ts:123-132 renders Retry with no confirm — Cancel and Force release both have one, this does not. projection.ts:66 offers it for any terminal run an admin owns, and terminal includes interrupted and skipped.

So a dist-upgrade that died mid-unpack gets a one-tap re-run in Activity — the decision the onInterrupted comment forty lines below deliberately declines to make for the operator — and so does every skipped row a double click left behind. tasks.retry (service.ts:567) re-checks capabilities.retry and calls startRun; it never looks at console.

capabilities: { cancel: false as const, retry: false as const },

Keep retry: "new-run" on refresh if you like — args-free, non-destructive, and the same reasoning that made it a fine console candidate applies. On the two upgrades it wants to go, or the console paragraph above needs to stop arguing the opposite.

**This is the affordance `console: false` refuses, through a different door.** `activity/backend/ui.ts:123-132` renders Retry with **no `confirm`** — Cancel and Force release both have one, this does not. `projection.ts:66` offers it for any *terminal* run an admin owns, and terminal includes `interrupted` and `skipped`. So a dist-upgrade that died mid-unpack gets a one-tap re-run in Activity — the decision the `onInterrupted` comment forty lines below deliberately declines to make for the operator — and so does every `skipped` row a double click left behind. `tasks.retry` (`service.ts:567`) re-checks `capabilities.retry` and calls `startRun`; it never looks at `console`. ```ts capabilities: { cancel: false as const, retry: false as const }, ``` Keep `retry: "new-run"` on `refresh` if you like — args-free, non-destructive, and the same reasoning that made it a fine `console` candidate applies. On the two upgrades it wants to go, or the `console` paragraph above needs to stop arguing the opposite.
@ -476,0 +687,4 @@
// samples and a "could not get lock" in the Package data field. The run
// publishes "updates" when it ends and invalidates the cache with it, so
// nothing is lost by waiting for it.
if (ctx.tasks.isBusy("apt")) return;
Owner

The failure this guard prevents does not reproduce.

state.ts:104 already passes -o Debug::NoLocking=true, with a comment saying it is there so the simulation survives another apt holding the dpkg lock. I measured it rather than reasoning about it — debian:bookworm-20230612, a real apt-get -y dist-upgrade in flight, probing every two seconds:

upgradable: 44
probe 1: plain rc=0 inst=38 err=  | nolock rc=0 inst=32 err=
probe 2: plain rc=0 inst=17 err=  | nolock rc=0 inst=12 err=

Exit 0 both ways, no E: Could not get lock, and the counts follow the upgrade as it lands. My round-two note claimed the opposite; it was asserted, not tested, and it is now a hard constraint in CLAUDE.md.

What the skip costs if the premise is wrong: six missing updates.pending / updates.security samples across the only window in which those series move, plus a frozen page for the same 30 minutes.

The rig can settle it — run chroot /host/root apt-get -s -o Debug::NoLocking=true dist-upgrade during a real dist-upgrade and paste what it answers. If it fails there, keep the guard and put the output next to it; if it succeeds, this line and the one in load() should go, and the page can show the counts falling live.

**The failure this guard prevents does not reproduce.** `state.ts:104` already passes `-o Debug::NoLocking=true`, with a comment saying it is there so the simulation survives another apt holding the dpkg lock. I measured it rather than reasoning about it — `debian:bookworm-20230612`, a real `apt-get -y dist-upgrade` in flight, probing every two seconds: ``` upgradable: 44 probe 1: plain rc=0 inst=38 err= | nolock rc=0 inst=32 err= probe 2: plain rc=0 inst=17 err= | nolock rc=0 inst=12 err= ``` Exit 0 both ways, no `E: Could not get lock`, and the counts follow the upgrade as it lands. My round-two note claimed the opposite; it was asserted, not tested, and it is now a hard constraint in `CLAUDE.md`. What the skip costs if the premise is wrong: six missing `updates.pending` / `updates.security` samples across the only window in which those series move, plus a frozen page for the same 30 minutes. The rig can settle it — run `chroot /host/root apt-get -s -o Debug::NoLocking=true dist-upgrade` during a real dist-upgrade and paste what it answers. If it fails there, keep the guard and put the output next to it; if it succeeds, this line and the one in `load()` should go, and the page can show the counts falling live.
@ -0,0 +71,4 @@
// run holding the lock — that is what somebody watching wants to see. And a
// refused double click leaves a `skipped` row that is newer than the run it
// was refused for, so ANY live run outranks the newest terminal one.
const run = live.find((r) => r.id === log.runId) ?? live[0] ?? recent[0];
Owner

Two things, neither blocking.

live[0] is the newest live row, which is the wrong one when two are live. With a running run and a queued one, live is [waiting, running]; if the buffer does not own the running row yet (the start window this file's own test covers), live[0] picks the waiting row — so the pane reads "Queued behind another update run" while queuedCount counts the run that is actually executing. It self-corrects on the first apt line, so it is a flicker rather than a lie that persists. live.find((r) => r.status !== "waiting") ?? live[0] closes it.

Why the limit: 1 fallback in /run is safe is worth a comment, because it is not this repo's decision. I went looking for the terminal version of the round-two blocker — a skipped row outliving the run it was refused for — and it does not happen, but only because of core: store.ts:310 orders by COALESCE(finished_at_ms, updated_at_ms) DESC, and #insertResolved stamps a skipped row's finishedAtMs at the moment of refusal (service.ts:819), i.e. before the run it was refused for finishes. So the completed run still wins the single row.

Change that ORDER BY in core to created_at_ms — a plausible thing for someone to do to a history query — and this pane silently starts reporting a finished dist-upgrade as "Install all updates — skipped" with "it ran before the last restart" under it. Since runview.ts exists to make exactly that class of mistake testable, the dependency belongs in a comment here (and, if you want it caught, in a test that hands runView a [skipped, succeeded] pair and asserts the succeeded one owns the pane).

Two things, neither blocking. **`live[0]` is the newest live row, which is the wrong one when two are live.** With a running run and a queued one, `live` is `[waiting, running]`; if the buffer does not own the running row yet (the start window this file's own test covers), `live[0]` picks the *waiting* row — so the pane reads "Queued behind another update run" while `queuedCount` counts the run that is actually executing. It self-corrects on the first apt line, so it is a flicker rather than a lie that persists. `live.find((r) => r.status !== "waiting") ?? live[0]` closes it. **Why the `limit: 1` fallback in `/run` is safe is worth a comment, because it is not this repo's decision.** I went looking for the terminal version of the round-two blocker — a `skipped` row outliving the run it was refused for — and it does not happen, but only because of core: `store.ts:310` orders by `COALESCE(finished_at_ms, updated_at_ms) DESC`, and `#insertResolved` stamps a skipped row's `finishedAtMs` at the moment of refusal (`service.ts:819`), i.e. *before* the run it was refused for finishes. So the completed run still wins the single row. Change that `ORDER BY` in core to `created_at_ms` — a plausible thing for someone to do to a history query — and this pane silently starts reporting a finished dist-upgrade as "Install all updates — skipped" with "it ran before the last restart" under it. Since `runview.ts` exists to make exactly that class of mistake testable, the dependency belongs in a comment here (and, if you want it caught, in a test that hands `runView` a `[skipped, succeeded]` pair and asserts the succeeded one owns the pane).
Round three of review:

- retry: false on both upgrade actions. Activity's Retry is one tap with
  no confirmation, offered for any terminal run an admin owns (interrupted
  and skipped included), and tasks.retry checks capabilities.retry, not
  console — the exact affordance console: false refuses, through a
  different door. refresh, args-free and non-destructive, keeps
  retry: "new-run".
- drop the isBusy("apt") guards in load() and the collector tick. The
  simulation runs under -o Debug::NoLocking=true (state.ts) and the
  reviewer measured it against a real dist-upgrade in flight: rc=0 with
  and without the flag, counts tracking the upgrade as it lands. The
  guard's premise was asserted, not tested — and it simulated anyway
  whenever the cache was empty, then served that one result for the rest
  of the run. load(true) now means "bypass the 10-second cache", nothing
  more; the page and both metric series follow an upgrade live.
- runview: within the live set prefer the run that is not waiting — in
  the start window before the buffer binds, with a queued run also live,
  the pane captioned itself "Queued behind another update run" while
  queuedCount counted the run that was executing. In history prefer a
  non-skipped row: a skipped row is the record of a refusal, not a run.
  Core's history ordering already answers /run's limit: 1 with the real
  run (skipped rows stamp finishedAtMs at refusal); that dependency is
  now written down at the call site, and the runview preference is the
  tested defence should the ORDER BY ever change. Two new tests.
- README: expose names result and reasonData, not "result only".
- dashboard badge: "update run active" — running is isBusy("apt"), which
  is also a merely queued run, and also a plain refresh.
- the "03:00 alert" comment: no schedule declares these actions, so every
  run is one a human just clicked; the failure notification is for the
  operator who navigated away, and the comment now says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Round three addressed in ddbda2a. What changed, per item:

1. Retry. retry: false on both upgrades; refresh keeps retry: "new-run" — exactly the split you proposed, for exactly your reason: Activity's Retry is one tap with no confirmation and tasks.retry checks capabilities.retry, not console. The console paragraph and the retry line now argue the same thing, in mod.ts and in CLAUDE.md.

2 + 3. The guard is gone. I took your measurement over my assertion — it was asserted twice and tested never, and state.ts had been carrying Debug::NoLocking=true against the same failure mode the whole time. Both skips are deleted (load() and the collector tick), which also deletes the cache-null hole in §2 rather than patching it: there is no busyState() because there is no guard to need one. load(true) now means "bypass the 10-second cache", nothing more. The CLAUDE.md constraint is inverted — it now says do not add an isBusy guard, with your measurement as the evidence — and the comment on the NoLocking line in state.ts says the same at the point someone would delete it. Honesty note: I did not re-run the probe on the rig for this push; the claim rests on your round-three measurement, and the PR's verification section says so.

Inline on runview.ts. Took both: the pick is now buffer-owner → first non-waiting live row → live[0] → first non-skipped terminal row → recent[0], so the start-window flicker with a queue behind it shows the executing run. The core ORDER BY COALESCE(finished_at_ms, updated_at_ms) / finishedAtMs-at-refusal dependency is written down at the /run call site, and the [skipped, succeeded] case is a test (plus one for the two-live-rows window) — 12 runview tests now, 28 total.

README paragraph updated (result and reasonData, never lines), the badge now reads "update run active" (it was "upgrade running" over a queued refresh), and the 03:00 comment now says what is true: no schedule declares these actions, the notification is for the operator who navigated away.

PR description updated to match all of the above. deno check clean, deno lint clean, 28/28.

Round three addressed in `ddbda2a`. What changed, per item: **1. Retry.** `retry: false` on both upgrades; `refresh` keeps `retry: "new-run"` — exactly the split you proposed, for exactly your reason: Activity's Retry is one tap with no confirmation and `tasks.retry` checks `capabilities.retry`, not `console`. The `console` paragraph and the retry line now argue the same thing, in `mod.ts` and in `CLAUDE.md`. **2 + 3. The guard is gone.** I took your measurement over my assertion — it was asserted twice and tested never, and `state.ts` had been carrying `Debug::NoLocking=true` against the same failure mode the whole time. Both skips are deleted (`load()` and the collector tick), which also deletes the cache-null hole in §2 rather than patching it: there is no `busyState()` because there is no guard to need one. `load(true)` now means "bypass the 10-second cache", nothing more. The `CLAUDE.md` constraint is inverted — it now says *do not* add an `isBusy` guard, with your measurement as the evidence — and the comment on the NoLocking line in `state.ts` says the same at the point someone would delete it. Honesty note: I did not re-run the probe on the rig for this push; the claim rests on your round-three measurement, and the PR's verification section says so. **Inline on `runview.ts`.** Took both: the pick is now buffer-owner → first non-`waiting` live row → `live[0]` → first non-`skipped` terminal row → `recent[0]`, so the start-window flicker with a queue behind it shows the executing run. The core `ORDER BY COALESCE(finished_at_ms, updated_at_ms)` / `finishedAtMs`-at-refusal dependency is written down at the `/run` call site, and the `[skipped, succeeded]` case is a test (plus one for the two-live-rows window) — 12 runview tests now, 28 total. **README** paragraph updated (`result` **and** `reasonData`, never `lines`), the **badge** now reads "update run active" (it was "upgrade running" over a queued refresh), and the **03:00 comment** now says what is true: no schedule declares these actions, the notification is for the operator who navigated away. PR description updated to match all of the above. `deno check` clean, `deno lint` clean, 28/28.
thisilike left a comment

Fourth pass, at ddbda2a, against core main (db57f4d). Everything from round three is fixed and I verified each on a running system rather than in the diff: core + this branch in a privileged container, a real debian:bookworm-20230612 rootfs bind-mounted at /host/root, probe picking chroot, apt real. deno check clean, deno lint clean, 28/28.

  • Retry. Live rows, not the declaration: {"id":2,"action":"upgrade-all","status":"succeeded","capabilities":{"cancel":false,"retry":false}}, same for upgrade-security, and {"id":1,"action":"refresh",…,"retry":"new-run"}. The split is exactly right.
  • The guard is gone, and the page now does what you say it does: a dist-upgrade in flight, /summary answering {"pending":42,"security":0,"statusText":"ok","running":true} and /packages 42 rows while apt was unpacking, no lock error anywhere. Inverting the CLAUDE.md constraint and putting the reason on the NoLocking line in state.ts — where someone would delete it — is the right place for it.
  • runView pick order, the /run comment recording core's ORDER BY COALESCE(finished_at_ms, updated_at_ms) dependency, README, badge text, the 03:00 comment: all as described.
  • Queue still behaves: upgrade/all → running, upgrade/security{"status":"waiting","queued":true,"reason":"waiting for run #2"}, /run reading "Install all updates running (1 queued)", and the queued run started by itself.

Four things below. Two of them are mine from a rig session that never made it onto the PR (the forge was unreachable for the day), so this is the first you are seeing of them — one is the item I would hold the merge on. One is new, and is a consequence of removing the guard.

1. A refresh that fetched nothing reports succeeded

apt-get update exits 0 when repositories fail to download. Only a malformed sources entry gives 100 — which is the case your rig line tests. So if (code !== 0) throw never fires for the failure operators actually get: a mirror that went away, DNS that broke, a suite past EOL.

At ddbda2a, with every source unresolvable:

POST /refresh          → {"ok":true,"runId":4,"status":"running"}
core run 4             → {"status":"succeeded","result":{"summary":"package lists refreshed"},"error":null}
GET /run               → {"statusText":"Refresh package lists — ok"}
notifications          → 0
run tail               → Err:1 http://no-such-host.invalid/debian bookworm InRelease
                         W: Some index files failed to download. They have been ignored, or old ones used instead.

"The outcome is a status, not a sentence" is the PR's second headline claim, and for this action the status says ok while nothing was fetched, notifyOn: ["failed"] stays silent, and /summary keeps serving counts computed from lists that were never refreshed. In a module whose whole job is to tell an operator the host is behind, that is the failure that matters most.

Measured fix, one option on the command you already run:

$ chroot /host/root apt-get update -qq                                 ; echo $?   → 0
$ chroot /host/root apt-get -o APT::Update::Error-Mode=any update -qq  ; echo $?   → 100

Scanning the output for ^Err: / ^W: Failed to fetch and failing the step does the same job if you prefer not to change apt's error mode. What must not stand is "ok".

2. A failed simulation is recorded as pending: 0

New this round, and a direct consequence of dropping the guard — which I still think was the right call. The tick writes its three metrics before the if (s.error !== "") return line, and a failed simulation yields packages: [], i.e. pending: 0. So an unreadable package state is indistinguishable, in the series and on the dashboard, from a host with nothing pending.

Rig, no upgrade run anywhere between the two samples — I broke the sources file and restarted, which fires the tick immediately:

GET /api/core/metrics/query?series=updates.pending&step=1
  → [[…783000, 93], […804000, 0]]

GET /summary → {"pending":0,"statusText":"E: Unable to parse package file /etc/apt/sources.list.d/debian.sources (1)"}
notifications → 0

93 → 0, and the dashboard card renders hint="up to date" because it reads pending, not statusText. Under the old guard the tick was skipped during runs; now it runs in every situation, so this is reachable for ordinary reasons — a bad sources file, a host mount that went away, lost exec access — not only mid-upgrade.

Move the three metrics.write calls below the error check (a gap in the series is honest; a zero is not). Worth considering an edge-triggered notification for "package data unreadable" too — it is the one state where every number on the page is silently meaningless, and the module currently alerts on unattended-upgrades failing but not on this.

3. The new second line of defence in runView cannot fire

The skipped-filter is a good idea, but /run asks for limit: 1 when nothing is live, so recent holds exactly one row. In the scenario the comment names — core's ORDER BY changing to created-at — that one row is the newest refusal, recent.find((r) => r.status !== "skipped") finds nothing, and the chain falls through to recent[0]: the skipped row, which is what the defence exists to avoid. The test passes because it hands runView a two-row history the route never produces.

ctx.tasks.list({ limit: 3 }) on the fallback makes it real for two extra rows, and the test then matches the shape the route can actually deliver.

4. /packages publishes what the four doors withhold

Also from the unposted session. The withholding is justified — in mod.ts, CLAUDE.md and README — as: apt output "is package names, repository URLs and the occasional configuration path — none of it a non-admin's business". Measured as a plain viewer (auth-disabled dev user patched down to ["viewer"] in my core copy; the module untouched):

GET /packages → 93 rows: {"name":"adduser","currentText":"3.134","candidate":"3.152","origin":"Debian:13.6/stable"} …
GET /summary  → counts, plus hostAccessText "available (chroot)"
GET /run      → "Update output is admin-only. …"          ← withheld

No role gate on the first two, and the code is unchanged at ddbda2a. It predates this PR and it is clearly deliberate, but the sentence justifying four doors cannot be "package names are not a viewer's business" while a route in the same file serves 93 of them. Either narrow the rationale to what is genuinely sensitive in the log — host command lines, config paths, registry errors — or gate /packages and /summary the same way. As written the module contradicts itself on the only question a reader of that comment will have.

Verified, for the record

The withholding claim itself holds, end to end, which nobody had shown before — admin and viewer against the same server, one refresh streaming:

SSE  updates:progress   admin 19 events, viewer 0
WS   updates:runlog     viewer {"t":"sub-err","id":2,"error":"forbidden"}, admin 33 ev frames
GET  /run (viewer)      "Update output is admin-only. Refresh package lists — failed"
POST /refresh (viewer)  403
core projection         withheld ["args","entities","lines","checkpoint","error","stepErrors"], result exposed

Also exercised, both listed "not exercised" in your verification section: an interrupted run across a real restart ({"status":"interrupted","reason":"process restarted"}, nothing retried, /run → "No log for this run — it ran before the last restart. Status: interrupted.") and the failure notification ({"level":"error","title":"Refresh package lists failed","message":"apt-get update exited 100"}). Batching under real load: a 44-package dist-upgrade produced 253 lines in 11 entries.

One aside that is core's, not yours: every run that declares a lock logs WARN run is terminal but its work has not returned and frees the lock ~5 ms later — 4 runs, 4 warnings. #finish reads state.handlerLive, which #execute clears only in its outer finally, so the normal return path always trips it. Harmless, but this module declares lock on every action, so it is the one turning a wedge detector into background noise. Worth an issue against core.

Fix 1 and 2 and I will approve; 3 and 4 I would take as follow-ups if you disagree, as long as the disagreement is written down.

Fourth pass, at `ddbda2a`, against core `main` (db57f4d). Everything from round three is fixed and I verified each on a running system rather than in the diff: core + this branch in a privileged container, a real `debian:bookworm-20230612` rootfs bind-mounted at `/host/root`, probe picking `chroot`, apt real. `deno check` clean, `deno lint` clean, **28/28**. - **Retry.** Live rows, not the declaration: `{"id":2,"action":"upgrade-all","status":"succeeded","capabilities":{"cancel":false,"retry":false}}`, same for `upgrade-security`, and `{"id":1,"action":"refresh",…,"retry":"new-run"}`. The split is exactly right. - **The guard is gone**, and the page now does what you say it does: a dist-upgrade in flight, `/summary` answering `{"pending":42,"security":0,"statusText":"ok","running":true}` and `/packages` 42 rows *while* apt was unpacking, no lock error anywhere. Inverting the `CLAUDE.md` constraint and putting the reason on the `NoLocking` line in `state.ts` — where someone would delete it — is the right place for it. - **`runView`** pick order, the `/run` comment recording core's `ORDER BY COALESCE(finished_at_ms, updated_at_ms)` dependency, README, badge text, the 03:00 comment: all as described. - Queue still behaves: `upgrade/all` → running, `upgrade/security` → `{"status":"waiting","queued":true,"reason":"waiting for run #2"}`, `/run` reading `"Install all updates running (1 queued)"`, and the queued run started by itself. Four things below. Two of them are mine from a rig session that never made it onto the PR (the forge was unreachable for the day), so this is the first you are seeing of them — one is the item I would hold the merge on. One is new, and is a consequence of removing the guard. ## 1. A refresh that fetched nothing reports `succeeded` `apt-get update` exits **0** when repositories fail to download. Only a malformed sources entry gives 100 — which is the case your rig line tests. So `if (code !== 0) throw` never fires for the failure operators actually get: a mirror that went away, DNS that broke, a suite past EOL. At `ddbda2a`, with **every** source unresolvable: ``` POST /refresh → {"ok":true,"runId":4,"status":"running"} core run 4 → {"status":"succeeded","result":{"summary":"package lists refreshed"},"error":null} GET /run → {"statusText":"Refresh package lists — ok"} notifications → 0 run tail → Err:1 http://no-such-host.invalid/debian bookworm InRelease W: Some index files failed to download. They have been ignored, or old ones used instead. ``` "**The outcome is a status, not a sentence**" is the PR's second headline claim, and for this action the status says ok while nothing was fetched, `notifyOn: ["failed"]` stays silent, and `/summary` keeps serving counts computed from lists that were never refreshed. In a module whose whole job is to tell an operator the host is behind, that is the failure that matters most. Measured fix, one option on the command you already run: ``` $ chroot /host/root apt-get update -qq ; echo $? → 0 $ chroot /host/root apt-get -o APT::Update::Error-Mode=any update -qq ; echo $? → 100 ``` Scanning the output for `^Err:` / `^W: Failed to fetch` and failing the step does the same job if you prefer not to change apt's error mode. What must not stand is "ok". ## 2. A failed simulation is recorded as `pending: 0` New this round, and a direct consequence of dropping the guard — which I still think was the right call. The tick writes its three metrics **before** the `if (s.error !== "") return` line, and a failed simulation yields `packages: []`, i.e. `pending: 0`. So an unreadable package state is indistinguishable, in the series and on the dashboard, from a host with nothing pending. Rig, no upgrade run anywhere between the two samples — I broke the sources file and restarted, which fires the tick immediately: ``` GET /api/core/metrics/query?series=updates.pending&step=1 → [[…783000, 93], […804000, 0]] GET /summary → {"pending":0,"statusText":"E: Unable to parse package file /etc/apt/sources.list.d/debian.sources (1)"} notifications → 0 ``` 93 → 0, and the dashboard card renders `hint="up to date"` because it reads `pending`, not `statusText`. Under the old guard the tick was skipped during runs; now it runs in every situation, so this is reachable for ordinary reasons — a bad sources file, a host mount that went away, lost exec access — not only mid-upgrade. Move the three `metrics.write` calls below the error check (a gap in the series is honest; a zero is not). Worth considering an edge-triggered notification for "package data unreadable" too — it is the one state where every number on the page is silently meaningless, and the module currently alerts on unattended-upgrades failing but not on this. ## 3. The new second line of defence in `runView` cannot fire The `skipped`-filter is a good idea, but `/run` asks for `limit: 1` when nothing is live, so `recent` holds exactly one row. In the scenario the comment names — core's `ORDER BY` changing to created-at — that one row *is* the newest refusal, `recent.find((r) => r.status !== "skipped")` finds nothing, and the chain falls through to `recent[0]`: the skipped row, which is what the defence exists to avoid. The test passes because it hands `runView` a two-row history the route never produces. `ctx.tasks.list({ limit: 3 })` on the fallback makes it real for two extra rows, and the test then matches the shape the route can actually deliver. ## 4. `/packages` publishes what the four doors withhold Also from the unposted session. The withholding is justified — in `mod.ts`, `CLAUDE.md` and `README` — as: apt output "is package names, repository URLs and the occasional configuration path — none of it a non-admin's business". Measured as a plain viewer (auth-disabled dev user patched down to `["viewer"]` in my **core** copy; the module untouched): ``` GET /packages → 93 rows: {"name":"adduser","currentText":"3.134","candidate":"3.152","origin":"Debian:13.6/stable"} … GET /summary → counts, plus hostAccessText "available (chroot)" GET /run → "Update output is admin-only. …" ← withheld ``` No role gate on the first two, and the code is unchanged at `ddbda2a`. It predates this PR and it is clearly deliberate, but the sentence justifying four doors cannot be "package names are not a viewer's business" while a route in the same file serves 93 of them. Either narrow the rationale to what is genuinely sensitive in the log — host command lines, config paths, registry errors — or gate `/packages` and `/summary` the same way. As written the module contradicts itself on the only question a reader of that comment will have. ## Verified, for the record The withholding claim itself holds, end to end, which nobody had shown before — admin and viewer against the same server, one refresh streaming: ``` SSE updates:progress admin 19 events, viewer 0 WS updates:runlog viewer {"t":"sub-err","id":2,"error":"forbidden"}, admin 33 ev frames GET /run (viewer) "Update output is admin-only. Refresh package lists — failed" POST /refresh (viewer) 403 core projection withheld ["args","entities","lines","checkpoint","error","stepErrors"], result exposed ``` Also exercised, both listed "not exercised" in your verification section: an interrupted run across a real restart (`{"status":"interrupted","reason":"process restarted"}`, nothing retried, `/run` → "No log for this run — it ran before the last restart. Status: interrupted.") and the failure notification (`{"level":"error","title":"Refresh package lists failed","message":"apt-get update exited 100"}`). Batching under real load: a 44-package dist-upgrade produced **253 lines in 11 entries**. One aside that is core's, not yours: every run that declares a lock logs `WARN run is terminal but its work has not returned` and frees the lock ~5 ms later — 4 runs, 4 warnings. `#finish` reads `state.handlerLive`, which `#execute` clears only in its outer `finally`, so the normal return path always trips it. Harmless, but this module declares `lock` on every action, so it is the one turning a wedge detector into background noise. Worth an issue against core. Fix 1 and 2 and I will approve; 3 and 4 I would take as follow-ups if you disagree, as long as the disagreement is written down.
@ -168,0 +277,4 @@
run: (h) =>
withRunLog(h, "Refresh package lists", async () => {
const code = await h.step("apt-get update", () => apt(["update"], REFRESH_TIMEOUT_MS));
if (code !== 0) throw new Error(`apt-get update exited ${code}`);
Owner

This can only fail for a malformed sources file. apt-get update exits 0 when repositories fail to download — an unresolvable host, a dead mirror, a suite past EOL — and prints the failure as Err: plus W: Some index files failed to download. They have been ignored, or old ones used instead.

Measured on the rig at this head, every source unresolvable:

core run 4 → {"status":"succeeded","result":{"summary":"package lists refreshed"},"error":null}
GET /run   → {"statusText":"Refresh package lists — ok"}
notifications → 0

So the action reports "package lists refreshed" over lists that were not refreshed, notifyOn: ["failed"] never fires, and /summary keeps computing counts from whatever is on disk. That is the PR's "the outcome is a status, not a sentence" failing on the most common real refresh failure.

One option, measured side by side in the same rootfs:

apt-get update -qq                                 → 0
apt-get -o APT::Update::Error-Mode=any update -qq  → 100
const code = await h.step(
  "apt-get update",
  () => apt(["-o", "APT::Update::Error-Mode=any", "update"], REFRESH_TIMEOUT_MS),
);

Scanning the stream for ^Err: / ^W: Failed to fetch and failing the step is equally fine. A partially-fetched refresh reported as a partial success would also be fine. "ok" is not.

**This can only fail for a malformed sources file.** `apt-get update` exits 0 when repositories fail to *download* — an unresolvable host, a dead mirror, a suite past EOL — and prints the failure as `Err:` plus `W: Some index files failed to download. They have been ignored, or old ones used instead.` Measured on the rig at this head, every source unresolvable: ``` core run 4 → {"status":"succeeded","result":{"summary":"package lists refreshed"},"error":null} GET /run → {"statusText":"Refresh package lists — ok"} notifications → 0 ``` So the action reports "package lists refreshed" over lists that were not refreshed, `notifyOn: ["failed"]` never fires, and `/summary` keeps computing counts from whatever is on disk. That is the PR's "the outcome is a status, not a sentence" failing on the most common real refresh failure. One option, measured side by side in the same rootfs: ``` apt-get update -qq → 0 apt-get -o APT::Update::Error-Mode=any update -qq → 100 ``` ```ts const code = await h.step( "apt-get update", () => apt(["-o", "APT::Update::Error-Mode=any", "update"], REFRESH_TIMEOUT_MS), ); ``` Scanning the stream for `^Err:` / `^W: Failed to fetch` and failing the step is equally fine. A partially-fetched refresh reported as a partial success would also be fine. "ok" is not.
Owner

No role gate here, and none on /summary — while mod.ts, CLAUDE.md and README all justify the four-door withholding with "apt and dpkg output is package names, repository URLs, the occasional configuration path — none of it a non-admin's business".

Measured as a viewer (I patched the auth-disabled dev user down to ["viewer"] in my core copy; this module untouched):

GET /packages → 93 rows: {"name":"adduser","currentText":"3.134","candidate":"3.152","origin":"Debian:13.6/stable"} …
GET /summary  → counts + hostAccessText "available (chroot)"
GET /run      → "Update output is admin-only. …"

So the same package names and repository origins the log is guarded for go out ungated one route above it. This predates the PR and is plainly deliberate — the table is the module's whole point — but then the reason given for the doors is wrong, and it is now written in three places. Narrow it to what is actually sensitive in a run's log (host command lines, --env-file-style paths, registry errors, whatever apt prints about a private mirror), or put the same requireAdmin on these two routes. Either is defensible; disagreeing with itself is not.

No role gate here, and none on `/summary` — while `mod.ts`, `CLAUDE.md` and `README` all justify the four-door withholding with "apt and dpkg output is package names, repository URLs, the occasional configuration path — none of it a non-admin's business". Measured as a viewer (I patched the auth-disabled dev user down to `["viewer"]` in my **core** copy; this module untouched): ``` GET /packages → 93 rows: {"name":"adduser","currentText":"3.134","candidate":"3.152","origin":"Debian:13.6/stable"} … GET /summary → counts + hostAccessText "available (chroot)" GET /run → "Update output is admin-only. …" ``` So the same package names and repository origins the log is guarded for go out ungated one route above it. This predates the PR and is plainly deliberate — the table is the module's whole point — but then the *reason* given for the doors is wrong, and it is now written in three places. Narrow it to what is actually sensitive in a run's log (host command lines, `--env-file`-style paths, registry errors, whatever apt prints about a private mirror), or put the same `requireAdmin` on these two routes. Either is defensible; disagreeing with itself is not.
@ -476,3 +708,4 @@
// falling mid-upgrade is real data over the only window it moves.
if (!host.available && !await host.probe()) return;
const s = await load(true);
Owner

A failed simulation is written into the series as pending: 0. These three writes sit above the if (s.error !== "") return on line 723, and a failed apt-get -s returns packages: [] — so "I could not read the host's package state" and "this host has nothing pending" are the same two points on the chart, and the same dashboard card.

Rig, with no upgrade run at all between the samples — broken sources file, restart to fire the tick:

metrics updates.pending (step=1) → [[…783000, 93], […804000, 0]]
GET /summary → {"pending":0,"statusText":"E: Unable to parse package file …"}
notifications → 0

OverviewWidget reads pending, not statusText, so its hint says "up to date".

This is newly reachable because the isBusy guard went (rightly): the tick now runs in situations it used to skip, and a broken sources file or a host mount that vanished is not exotic.

if (s.error !== "") return; // no package data: a zero here is a lie, not a sample

ctx.metrics.write("updates.pending", s.pending);
ctx.metrics.write("updates.security", s.security);
ctx.metrics.write("reboot.daysPending", s.rebootDays);

reboot.daysPending comes from a file read rather than apt, so keeping that one above the guard is defensible — the two apt-derived series are the ones that must not fabricate a zero. And since this state makes every number on the page meaningless, it is a better notification candidate than most: edge-triggered, keyed packagedata, cleared when a collect succeeds.

**A failed simulation is written into the series as `pending: 0`.** These three writes sit above the `if (s.error !== "") return` on line 723, and a failed `apt-get -s` returns `packages: []` — so "I could not read the host's package state" and "this host has nothing pending" are the same two points on the chart, and the same dashboard card. Rig, with no upgrade run at all between the samples — broken sources file, restart to fire the tick: ``` metrics updates.pending (step=1) → [[…783000, 93], […804000, 0]] GET /summary → {"pending":0,"statusText":"E: Unable to parse package file …"} notifications → 0 ``` `OverviewWidget` reads `pending`, not `statusText`, so its hint says "up to date". This is newly reachable *because* the `isBusy` guard went (rightly): the tick now runs in situations it used to skip, and a broken sources file or a host mount that vanished is not exotic. ```ts if (s.error !== "") return; // no package data: a zero here is a lie, not a sample ctx.metrics.write("updates.pending", s.pending); ctx.metrics.write("updates.security", s.security); ctx.metrics.write("reboot.daysPending", s.rebootDays); ``` `reboot.daysPending` comes from a file read rather than apt, so keeping that one above the guard is defensible — the two apt-derived series are the ones that must not fabricate a zero. And since this state makes every number on the page meaningless, it is a better notification candidate than most: edge-triggered, keyed `packagedata`, cleared when a collect succeeds.
@ -406,0 +616,4 @@
// would win it instead; `runView` prefers a non-skipped row as a second
// line of defence (tested), but it can only pick from what arrives here.
const live = await ctx.tasks.list({ status: [...LIVE] });
const recent = live.length > 0 ? live : await ctx.tasks.list({ limit: 1 });
Owner

The skipped filter added to runView this round cannot fire on this path: limit: 1 means recent holds exactly one row, so in the scenario the comment above names — core's ORDER BY changing to created-at — that single row is the newest refusal, recent.find((r) => r.status !== "skipped") finds nothing, and the chain falls through to recent[0], the skipped row.

The new test passes because it hands runView a two-row history, which this call never produces.

const recent = live.length > 0 ? live : await ctx.tasks.list({ limit: 3 });

Two extra rows, and the defence becomes one. Worth adjusting the test's fixture to the shape the route can actually deliver, too.

The `skipped` filter added to `runView` this round cannot fire on this path: `limit: 1` means `recent` holds exactly one row, so in the scenario the comment above names — core's `ORDER BY` changing to created-at — that single row *is* the newest refusal, `recent.find((r) => r.status !== "skipped")` finds nothing, and the chain falls through to `recent[0]`, the skipped row. The new test passes because it hands `runView` a two-row history, which this call never produces. ```ts const recent = live.length > 0 ? live : await ctx.tasks.list({ limit: 3 }); ``` Two extra rows, and the defence becomes one. Worth adjusting the test's fixture to the shape the route can actually deliver, too.
`apt-get update` exits 0 when a repository fails to DOWNLOAD — a dead
mirror, DNS that broke, a suite past EOL. Only a malformed sources entry
gives 100. So the one failure this module exists to catch was recorded
`succeeded`, `notifyOn: ["failed"]` stayed silent, and every count on the
page went on describing a snapshot nobody refreshed.

`parseRefreshFailures` scans the output for what apt says it could not
fetch and the run fails on it. Scanning rather than
`-o APT::Update::Error-Mode=any`, which promotes any WARNING to a failure:
the rig's own healthy refresh prints a keyring warning that option would
have failed the run on. Every `apt-get` is now pinned to `LC_ALL=C.UTF-8`
— apt translates those lines ("Err:" is "Fehl:" under de_DE), so without
it the scan finds nothing on a host with a locale set.

Also, from the same review round:

- A failed simulation is no longer written as `pending: 0`. It yields
  `packages: []`, so the two apt metrics recorded "I cannot read this
  host" and "this host is up to date" as the same point — on the chart
  and on the dashboard card, which reads `pending`, not `statusText`.
  Both writes move below the error guard; `reboot.daysPending` is a file
  read and stays above it. A gap is honest, a zero is not. The state also
  raises one edge-triggered alert, on the first tick too, because it is
  the one state nothing else on the page reports.

- `/run` asks history for three rows, not one. `runView`'s skipped-row
  filter could not fire on a single row: that row IS the newest refusal,
  so the filter found nothing and the fallback returned it. The test was
  passing on a two-row fixture the route never produced.

- The withholding rationale no longer contradicts `/packages`, which
  serves the whole pending table to any viewer ungated. What a run's log
  adds on top of that table is the admin-only part: this module's own
  `$ apt-get …` command lines, dpkg diagnostics, configuration paths and
  full repository URLs. Restated in mod.ts, runview.ts, CLAUDE.md, README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Fourth-round items at 7703979. All four are addressed — 1 and 2 fixed, 3 fixed (it was one word), 4 resolved by narrowing the rationale rather than gating the routes, with the reasoning written down where you asked for it.

Rig: core main (db57f4d) plus this branch loaded as an external module (a local clone through OPSDECK_EXTERNAL_MODULES, so the manifest, frontend build and load path are all real), in a privileged container with a debian:bookworm rootfs bind-mounted at /host/root, probe picking chroot. deno check clean, deno lint clean, deno test --allow-read backend/ 35 passed (17 parser — 11 unchanged plus 6 new — 13 runview, 5 runlog).

1. A refresh that fetched nothing reports succeeded — fixed

I measured your premise across four sources states before touching anything, same rootfs, LC_ALL=C.UTF-8:

healthy                       rc=0
every source unresolvable     rc=0    Err: … / W: Failed to fetch … / W: Some index files failed to download
suite 404, no Release file    rc=100
malformed sources entry       rc=100

The exit code catches the bottom two and nothing else — exactly as you had it. Your rig line was the last row.

parseRefreshFailures (pure, in apt.ts, six tests) scans for Err: / W: Failed to fetch / W: Some index files failed to download, and the run throws on it.

Scanning rather than -o APT::Update::Error-Mode=any — and the rig decided that, not taste. A healthy refresh on this rootfs prints:

W: http://deb.debian.org/debian-security/dists/bookworm-security/InRelease: The key(s) in the keyring /dev/null are ignored as the file has an unsupported filename extension.

Error-Mode=any promotes any warning to a failure, so it would have failed a refresh that fetched everything. That is the same lie pointing the other way, and it is the one that teaches an operator to ignore the alert. The scan ignores that line and fails only on what apt says it could not fetch.

Err: and W: Failed to fetch restate one event in two spellings of the same URL, so the second is a fallback for output carrying no Err: lines, never merged with the first — merging reports every dead source twice.

Every apt-get now runs under LC_ALL=C.UTF-8, which is load-bearing, not tidiness: apt translates the lines being scanned, and a host with a locale set would have gone back to reporting ok.

LANG=de_DE.UTF-8   Fehl:1 http://no-such-host.invalid/debian bookworm InRelease
                   W: Fehlschlag beim Holen von …
LC_ALL=C.UTF-8     Err:1  http://no-such-host.invalid/debian bookworm InRelease
                   W: Failed to fetch …

End to end, the module's own run, DNS unreachable from the chroot:

POST /refresh             → {"ok":true,"runId":1,"status":"running"}
chroot … apt-get update   → rc=0                       ← what used to make this "ok"
GET /run                  → "Refresh package lists — failed"
notification              → error | Refresh package lists failed
                            | apt-get update could not fetch 3 sources: http://deb.debian.org/debian bookworm InRelease, …

and the negative, sources restored — the keyring W: above did not fail it:

GET /run  → "Refresh package lists — ok"

2. A failed simulation recorded as pending: 0 — fixed

Both apt-derived writes moved below the s.error !== "" guard. reboot.daysPending stays above it, as you suggested — it is a file read and is still true when the simulation is not.

Measured the split rather than asserting it. Broke the sources file, waited one tick, no upgrade run anywhere:

before tick   updates.pending 2   updates.security 2   reboot.daysPending 2
after  tick   updates.pending 2   updates.security 2   reboot.daysPending 3
GET /summary  {"pending":0,"statusText":"E: Type 'this' is not known on line 1 in source list /etc/apt/sources.list"}
notification  error | Package data unreadable | E: Type 'this' is not known on line 1 in source list …

A gap where the samples were, not a zero — and the tick demonstrably ran, because reboot.daysPending moved.

And the gap closes again on its own — sources restored, next tick:

updates.pending 3   updates.security 3   reboot.daysPending 4

I took your notification suggestion. It is edge-triggered and keyed packagedata, and it fires on the first tick as well — which departs from the convention the other two checks in that tick follow, so the reason is in the comment: those describe state the page shows plainly, whereas OverviewWidget reads pending and not statusText, so an unreadable host renders as "up to date". It is the one state nothing else on the page reports.

3. The second line of defence in runView cannot fire — fixed

ctx.tasks.list({ limit: 3 }), and the test fixture is now a shape the route can produce (three rows: two skipped newer than the succeeded run). I also added the case that shows the defence's reach — one skipped row and nothing else, which still shows the refusal — because the real guarantee remains core's ORDER BY, not this filter, and a test that implied otherwise would be the same mistake in a new place.

Also exercised: four refused double clicks against a live refresh (409, runs 4–7 skipped), /run reading the live run throughout and "Refresh package lists — ok" after it landed.

4. /packages publishes what the four doors withhold — rationale narrowed, routes left open

I went with your first option. Gating /packages and /summary would take the module's only reason to exist away from viewers; the sentence justifying the doors is what was wrong, and it was wrong in three places.

It now says the log is withheld for what it carries beyond that table: this module's own $ apt-get … command lines (which spell out how it reaches the host), dpkg maintainer-script and conffile diagnostics, configuration paths, and full repository URLs — credentials and all on a private mirror — where /packages carries only apt's origin description. Restated in mod.ts (both places), runview.ts, README.md and CLAUDE.md, and CLAUDE.md now carries "the reason is NOT package names" as an explicit invariant with the route named, so the next person does not re-derive the old sentence.

Not exercised

The interrupted-run path and the non-admin reader end to end — both of which you verified at ddbda2a, and nothing in this push touches either. The packagedata notification's dismissal survival across ticks is reasoned from the existing keyed-notification behaviour, not measured.

Aside, confirming yours

WARN run is terminal but its work has not returned … lock freed fired on every run here too (runs 2 and 3 in the log). Your reading of #finish / state.handlerLive matches what I see. Happy to open the core issue if you have not.

Fourth-round items at `7703979`. All four are addressed — 1 and 2 fixed, 3 fixed (it was one word), 4 resolved by narrowing the rationale rather than gating the routes, with the reasoning written down where you asked for it. Rig: core `main` (db57f4d) plus this branch loaded as an **external** module (a local clone through `OPSDECK_EXTERNAL_MODULES`, so the manifest, frontend build and load path are all real), in a privileged container with a `debian:bookworm` rootfs bind-mounted at `/host/root`, probe picking `chroot`. `deno check` clean, `deno lint` clean, `deno test --allow-read backend/` **35 passed** (17 parser — 11 unchanged plus 6 new — 13 runview, 5 runlog). ## 1. A refresh that fetched nothing reports `succeeded` — fixed I measured your premise across four sources states before touching anything, same rootfs, `LC_ALL=C.UTF-8`: ``` healthy rc=0 every source unresolvable rc=0 Err: … / W: Failed to fetch … / W: Some index files failed to download suite 404, no Release file rc=100 malformed sources entry rc=100 ``` The exit code catches the bottom two and nothing else — exactly as you had it. Your rig line was the last row. `parseRefreshFailures` (pure, in `apt.ts`, six tests) scans for `Err:` / `W: Failed to fetch` / `W: Some index files failed to download`, and the run throws on it. **Scanning rather than `-o APT::Update::Error-Mode=any`** — and the rig decided that, not taste. A *healthy* refresh on this rootfs prints: ``` W: http://deb.debian.org/debian-security/dists/bookworm-security/InRelease: The key(s) in the keyring /dev/null are ignored as the file has an unsupported filename extension. ``` `Error-Mode=any` promotes any warning to a failure, so it would have failed a refresh that fetched everything. That is the same lie pointing the other way, and it is the one that teaches an operator to ignore the alert. The scan ignores that line and fails only on what apt says it could not fetch. `Err:` and `W: Failed to fetch` restate one event in two spellings of the same URL, so the second is a **fallback** for output carrying no `Err:` lines, never merged with the first — merging reports every dead source twice. **Every `apt-get` now runs under `LC_ALL=C.UTF-8`,** which is load-bearing, not tidiness: apt translates the lines being scanned, and a host with a locale set would have gone back to reporting ok. ``` LANG=de_DE.UTF-8 Fehl:1 http://no-such-host.invalid/debian bookworm InRelease W: Fehlschlag beim Holen von … LC_ALL=C.UTF-8 Err:1 http://no-such-host.invalid/debian bookworm InRelease W: Failed to fetch … ``` End to end, the module's own run, DNS unreachable from the chroot: ``` POST /refresh → {"ok":true,"runId":1,"status":"running"} chroot … apt-get update → rc=0 ← what used to make this "ok" GET /run → "Refresh package lists — failed" notification → error | Refresh package lists failed | apt-get update could not fetch 3 sources: http://deb.debian.org/debian bookworm InRelease, … ``` and the negative, sources restored — the keyring `W:` above did **not** fail it: ``` GET /run → "Refresh package lists — ok" ``` ## 2. A failed simulation recorded as `pending: 0` — fixed Both apt-derived writes moved below the `s.error !== ""` guard. `reboot.daysPending` stays above it, as you suggested — it is a file read and is still true when the simulation is not. Measured the split rather than asserting it. Broke the sources file, waited one tick, no upgrade run anywhere: ``` before tick updates.pending 2 updates.security 2 reboot.daysPending 2 after tick updates.pending 2 updates.security 2 reboot.daysPending 3 GET /summary {"pending":0,"statusText":"E: Type 'this' is not known on line 1 in source list /etc/apt/sources.list"} notification error | Package data unreadable | E: Type 'this' is not known on line 1 in source list … ``` A gap where the samples were, not a zero — and the tick demonstrably ran, because `reboot.daysPending` moved. And the gap closes again on its own — sources restored, next tick: ``` updates.pending 3 updates.security 3 reboot.daysPending 4 ``` I took your notification suggestion. It is edge-triggered and keyed `packagedata`, and it fires on the **first** tick as well — which departs from the convention the other two checks in that tick follow, so the reason is in the comment: those describe state the page shows plainly, whereas `OverviewWidget` reads `pending` and not `statusText`, so an unreadable host renders as "up to date". It is the one state nothing else on the page reports. ## 3. The second line of defence in `runView` cannot fire — fixed `ctx.tasks.list({ limit: 3 })`, and the test fixture is now a shape the route can produce (three rows: two `skipped` newer than the succeeded run). I also added the case that shows the defence's *reach* — one skipped row and nothing else, which still shows the refusal — because the real guarantee remains core's `ORDER BY`, not this filter, and a test that implied otherwise would be the same mistake in a new place. Also exercised: four refused double clicks against a live refresh (409, runs 4–7 `skipped`), `/run` reading the live run throughout and `"Refresh package lists — ok"` after it landed. ## 4. `/packages` publishes what the four doors withhold — rationale narrowed, routes left open I went with your first option. Gating `/packages` and `/summary` would take the module's only reason to exist away from viewers; the sentence justifying the doors is what was wrong, and it was wrong in three places. It now says the log is withheld for what it carries **beyond** that table: this module's own `$ apt-get …` command lines (which spell out how it reaches the host), dpkg maintainer-script and conffile diagnostics, configuration paths, and full repository URLs — credentials and all on a private mirror — where `/packages` carries only apt's `origin` description. Restated in `mod.ts` (both places), `runview.ts`, `README.md` and `CLAUDE.md`, and `CLAUDE.md` now carries "the reason is NOT package names" as an explicit invariant with the route named, so the next person does not re-derive the old sentence. ## Not exercised The interrupted-run path and the non-admin reader end to end — both of which **you** verified at `ddbda2a`, and nothing in this push touches either. The `packagedata` notification's dismissal survival across ticks is reasoned from the existing keyed-notification behaviour, not measured. ## Aside, confirming yours `WARN run is terminal but its work has not returned … lock freed` fired on every run here too (runs 2 and 3 in the log). Your reading of `#finish` / `state.handlerLive` matches what I see. Happy to open the core issue if you have not.
thisilike approved these changes 2026-08-20 08:23:55 +02:00
thisilike left a comment

Fifth pass, at 7703979, against core main (now 2844b43). Both round-four blockers are genuinely fixed and I drove each on the rig rather than reading the diff for it — this time under a German server locale, because your own fix says that is where it breaks. deno check clean, deno lint clean, 35/35.

1 — the refresh that reported ok. Unreachable source, module's own run:

core run 2 → {"status":"failed","error":"apt-get update could not fetch 1 source: http://no-such-host.invalid/debian bookworm InRelease"}
GET /run   → "Refresh package lists — failed"
notification → error | Refresh package lists failed | apt-get update could not fetch 1 source: …

And the negative, which matters more: on a rootfs that prints W: GPG error … and two E: gpgv, gpgv2 or gpgv1 required for verification lines on a complete refresh, the run came back succeeded. No false positive against noisy-but-healthy output.

The locale pin is load-bearing and it works. Server env LC_ALL=de_DE.UTF-8, and the run above still produced English and failed correctly, while the same command by hand in the same chroot:

Fehl:1 http://no-such-host.invalid/debian bookworm InRelease
W: Fehlschlag beim Holen von … »no-such-host.invalid« konnte nicht aufgelöst werden.

— which parseRefreshFailures would have found nothing in. Catching that before it shipped is the good catch of this round.

2 — the fabricated zero. Broke the sources file, one tick, no run anywhere:

updates.pending      [[…792000, 40], […866000, 40]]   ← no new sample
reboot.daysPending   [[…792000, 0], […866000, 0], […885000, 0]]   ← the tick DID run
GET /summary         {"pending":0,"statusText":"E: Paketdatei … konnte nicht verarbeitet werden (1)."}
notification         error | Package data unreadable | …

A gap where the samples were, reboot.daysPending proving the tick ran, and the alert that makes the state visible at all. Exactly the split I asked for.

3 — limit: 3. Four refusals against a live refresh (runs 4–7 skipped), pane read "Refresh package lists running" throughout and "Refresh package lists — ok" with the log after it landed. The fixture now matches what the route produces, and the extra test for the defence's reach is the right instinct.

4 — the rationale. Narrowing it rather than gating the routes is the call I would have made, and naming what the log adds on top of /packages — your own $ apt-get … command lines, dpkg maintainer-script and conffile diagnostics, full repository URLs with credentials — makes the doors defensible instead of decorative.

Approving. Two follow-ups, both small, neither worth holding a merge for — but the second one is a fact about apt that is now written into the code and is not true.

A. The locale pin skips the one apt run whose output feeds every number on the page

apt.ts says every apt-get this module runs is pinned to LC_ALL=C.UTF-8. The simulation is not: state.ts:103 goes through deps.host.run("apt-get", ["-s", …]) directly, not through apt() in mod.ts, so it inherits whatever the OpsDeck process has. On the rig that showed up immediately — in the notification you just added:

GET /summary → statusText "E: Paketdatei /etc/apt/sources.list.d/debian.sources konnte nicht verarbeitet werden (1)."
notification → "Package data unreadable" | same German string

parseSimulation itself survives today only because Inst/Conf are not translated — I checked, 42 Inst lines under both C.UTF-8 and de_DE.UTF-8. That is luck, not the rule this PR just wrote down. Pin it the same way apt() does and the rule holds for both parsers, plus the module's only user-facing error string stops depending on the server's environment.

B. Error-Mode=any does not do what the comment says it does

The comment argues the option "promotes any WARNING to a failure, so a duplicated-source or deprecated-key warning would fail an otherwise complete refresh". Measured — including in your rootfs, the one whose warnings the argument rests on:

case plain Error-Mode=any
healthy refresh, rootfs printing W: GPG error + E: gpgv … required 0 0 (2 Packages lists fetched)
duplicated source (W: Target Packages … configured multiple times) 0 0
Signed-By: /dev/null keyring warning 0 0
unresolvable source 0 100

It promotes acquire failures, and nothing else I could find. So the premise is right — a healthy refresh on that rootfs really does print warnings — and the conclusion drawn from it is not.

I would still keep the scanner: it names the sources, which is what makes the notification worth reading, and it is testable in a way an apt option is not. But the comment should say why it was actually chosen, not assert an apt behaviour that does not reproduce — and since the scan is a text contract with apt, the option is worth adding underneath it as a belt (scan first so the good message survives, then let a non-zero exit fail the run anyway). That way the hole does not silently reopen the day apt renames Err:.

Notes

  • packagedata firing on the first tick means a restart loop recreates a dismissed alert — which is precisely when a broken sources file restarts a lot. You documented the deviation and the reason is sound; just noting the cost is real.
  • Nothing in this push touches the interrupted path, the four doors or the queue, and I re-smoked the queue and the refusal path anyway: unchanged.
  • I have not opened the core issue for the WARN run is terminal but its work has not returned / state.handlerLive thing. Go ahead and open it — you have the reproduction in your own log now, and it is core's to fix.
Fifth pass, at `7703979`, against core `main` (now 2844b43). Both round-four blockers are genuinely fixed and I drove each on the rig rather than reading the diff for it — this time under a **German server locale**, because your own fix says that is where it breaks. `deno check` clean, `deno lint` clean, **35/35**. **1 — the refresh that reported ok.** Unreachable source, module's own run: ``` core run 2 → {"status":"failed","error":"apt-get update could not fetch 1 source: http://no-such-host.invalid/debian bookworm InRelease"} GET /run → "Refresh package lists — failed" notification → error | Refresh package lists failed | apt-get update could not fetch 1 source: … ``` And the negative, which matters more: on a rootfs that prints `W: GPG error …` **and** two `E: gpgv, gpgv2 or gpgv1 required for verification` lines on a *complete* refresh, the run came back `succeeded`. No false positive against noisy-but-healthy output. **The locale pin is load-bearing and it works.** Server env `LC_ALL=de_DE.UTF-8`, and the run above still produced English and failed correctly, while the same command by hand in the same chroot: ``` Fehl:1 http://no-such-host.invalid/debian bookworm InRelease W: Fehlschlag beim Holen von … »no-such-host.invalid« konnte nicht aufgelöst werden. ``` — which `parseRefreshFailures` would have found nothing in. Catching that before it shipped is the good catch of this round. **2 — the fabricated zero.** Broke the sources file, one tick, no run anywhere: ``` updates.pending [[…792000, 40], […866000, 40]] ← no new sample reboot.daysPending [[…792000, 0], […866000, 0], […885000, 0]] ← the tick DID run GET /summary {"pending":0,"statusText":"E: Paketdatei … konnte nicht verarbeitet werden (1)."} notification error | Package data unreadable | … ``` A gap where the samples were, `reboot.daysPending` proving the tick ran, and the alert that makes the state visible at all. Exactly the split I asked for. **3 — `limit: 3`.** Four refusals against a live refresh (runs 4–7 `skipped`), pane read `"Refresh package lists running"` throughout and `"Refresh package lists — ok"` with the log after it landed. The fixture now matches what the route produces, and the extra test for the defence's *reach* is the right instinct. **4 — the rationale.** Narrowing it rather than gating the routes is the call I would have made, and naming what the log adds on top of `/packages` — your own `$ apt-get …` command lines, dpkg maintainer-script and conffile diagnostics, full repository URLs with credentials — makes the doors defensible instead of decorative. Approving. Two follow-ups, both small, neither worth holding a merge for — but the second one is a fact about apt that is now written into the code and is not true. ## A. The locale pin skips the one apt run whose output feeds every number on the page `apt.ts` says every `apt-get` this module runs is pinned to `LC_ALL=C.UTF-8`. The simulation is not: `state.ts:103` goes through `deps.host.run("apt-get", ["-s", …])` directly, not through `apt()` in mod.ts, so it inherits whatever the OpsDeck process has. On the rig that showed up immediately — in the notification you just added: ``` GET /summary → statusText "E: Paketdatei /etc/apt/sources.list.d/debian.sources konnte nicht verarbeitet werden (1)." notification → "Package data unreadable" | same German string ``` `parseSimulation` itself survives today only because `Inst`/`Conf` are not translated — I checked, 42 `Inst ` lines under both `C.UTF-8` and `de_DE.UTF-8`. That is luck, not the rule this PR just wrote down. Pin it the same way `apt()` does and the rule holds for both parsers, plus the module's only user-facing error string stops depending on the server's environment. ## B. `Error-Mode=any` does not do what the comment says it does The comment argues the option "promotes any WARNING to a failure, so a duplicated-source or deprecated-key warning would fail an otherwise complete refresh". Measured — including in **your** rootfs, the one whose warnings the argument rests on: | case | plain | `Error-Mode=any` | |---|---|---| | healthy refresh, rootfs printing `W: GPG error` + `E: gpgv … required` | 0 | **0** (2 Packages lists fetched) | | duplicated source (`W: Target Packages … configured multiple times`) | 0 | **0** | | `Signed-By: /dev/null` keyring warning | 0 | **0** | | unresolvable source | 0 | **100** | It promotes *acquire failures*, and nothing else I could find. So the premise is right — a healthy refresh on that rootfs really does print warnings — and the conclusion drawn from it is not. I would still keep the scanner: it names the sources, which is what makes the notification worth reading, and it is testable in a way an apt option is not. But the comment should say why it was actually chosen, not assert an apt behaviour that does not reproduce — and since the scan is a text contract with apt, the option is worth adding underneath it as a belt (scan first so the good message survives, then let a non-zero exit fail the run anyway). That way the hole does not silently reopen the day apt renames `Err:`. ## Notes - `packagedata` firing on the first tick means a restart loop recreates a dismissed alert — which is precisely when a broken sources file restarts a lot. You documented the deviation and the reason is sound; just noting the cost is real. - Nothing in this push touches the interrupted path, the four doors or the queue, and I re-smoked the queue and the refusal path anyway: unchanged. - I have not opened the core issue for the `WARN run is terminal but its work has not returned` / `state.handlerLive` thing. Go ahead and open it — you have the reproduction in your own log now, and it is core's to fix.
@ -166,0 +180,4 @@
* refreshed" over lists that were never refreshed, which is the one failure a
* module whose whole job is "the host is behind" must not swallow.
*
* Scanning the output rather than passing `-o APT::Update::Error-Mode=any`:
Owner

The choice is right; this reason for it is not, and it is now recorded as fact.

APT::Update::Error-Mode=any does not promote any warning — it promotes acquire failures. Measured, including in the rootfs whose warnings this argument rests on:

case plain Error-Mode=any
healthy refresh, rootfs printing W: GPG error + E: gpgv … required 0 0 (2 Packages lists fetched)
duplicated source (W: Target Packages … configured multiple times) 0 0
Signed-By: /dev/null keyring warning 0 0
unresolvable source 0 100

So "a duplicated-source or deprecated-key warning would fail an otherwise complete refresh" does not reproduce.

Keep the scan — it names the sources, which is what makes the notification worth reading, and it has tests an apt option cannot have. But say that, rather than an apt behaviour that is not real: the next person reading this will believe it and rule the option out for the wrong reason.

Worth considering as a belt underneath the scan, since this function is a text contract with apt's output and apt renames things:

const code = await h.step(
  "apt-get update",
  () => apt(["-o", "APT::Update::Error-Mode=any", "update"], REFRESH_TIMEOUT_MS, (t) => text += t),
);
const failed = parseRefreshFailures(text);   // scan FIRST, so the good message wins
if (failed.sources.length > 0) throw new Error(`apt-get update could not fetch …`);
if (failed.staleLists) throw new Error("apt-get update kept the old package lists — some sources failed");
if (code !== 0) throw new Error(`apt-get update exited ${code}`);
The choice is right; this reason for it is not, and it is now recorded as fact. `APT::Update::Error-Mode=any` does not promote *any* warning — it promotes acquire failures. Measured, including in the rootfs whose warnings this argument rests on: | case | plain | `Error-Mode=any` | |---|---|---| | healthy refresh, rootfs printing `W: GPG error` + `E: gpgv … required` | 0 | **0** (2 Packages lists fetched) | | duplicated source (`W: Target Packages … configured multiple times`) | 0 | **0** | | `Signed-By: /dev/null` keyring warning | 0 | **0** | | unresolvable source | 0 | **100** | So "a duplicated-source or deprecated-key warning would fail an otherwise complete refresh" does not reproduce. Keep the scan — it names the sources, which is what makes the notification worth reading, and it has tests an apt option cannot have. But say that, rather than an apt behaviour that is not real: the next person reading this will believe it and rule the option out for the wrong reason. Worth considering as a belt underneath the scan, since this function is a text contract with apt's output and apt renames things: ```ts const code = await h.step( "apt-get update", () => apt(["-o", "APT::Update::Error-Mode=any", "update"], REFRESH_TIMEOUT_MS, (t) => text += t), ); const failed = parseRefreshFailures(text); // scan FIRST, so the good message wins if (failed.sources.length > 0) throw new Error(`apt-get update could not fetch …`); if (failed.staleLists) throw new Error("apt-get update kept the old package lists — some sources failed"); if (code !== 0) throw new Error(`apt-get update exited ${code}`); ```
@ -99,1 +100,4 @@
// assumed: rc=0 with and without the flag, counts tracking the upgrade as
// it lands. This line is what makes it safe for load() and the collector to
// simulate mid-run; do not add an `isBusy("apt")` guard upstream of it.
const res = await deps.host.run(
Owner

This is the apt-get the pin misses. apt() in mod.ts pins LC_ALL=C.UTF-8; this one goes through deps.host.run directly and inherits the OpsDeck process environment — so the doc in apt.ts ("every apt-get this module runs is pinned") is not true of the run whose output feeds pending, security, /packages and the dashboard card.

Rig, server started with LC_ALL=de_DE.UTF-8, sources file broken:

GET /summary → {"pending":0,"statusText":"E: Paketdatei /etc/apt/sources.list.d/debian.sources konnte nicht verarbeitet werden (1)."}
notification → "Package data unreadable" | same German string

That string is this module's only user-facing error text, and it is currently whatever locale the server happens to run under.

parseSimulation survives today only because apt does not translate Inst/Conf — measured, 42 Inst lines under both C.UTF-8 and de_DE.UTF-8. Fine today; it is exactly the assumption the refresh scanner just proved dangerous, and nothing here pins it.

const res = await deps.host.run(
  "env",
  ["LC_ALL=C.UTF-8", "apt-get", "-s", "-o", "Debug::NoLocking=true", "dist-upgrade"],
  { timeoutMs: 120_000 },
);

(uname -r on line 146 is in the same boat and does not matter — but the comment in apt.ts should say "every apt-get whose output is parsed", and then be true of both.)

**This is the `apt-get` the pin misses.** `apt()` in mod.ts pins `LC_ALL=C.UTF-8`; this one goes through `deps.host.run` directly and inherits the OpsDeck process environment — so the doc in `apt.ts` ("every `apt-get` this module runs is pinned") is not true of the run whose output feeds `pending`, `security`, `/packages` and the dashboard card. Rig, server started with `LC_ALL=de_DE.UTF-8`, sources file broken: ``` GET /summary → {"pending":0,"statusText":"E: Paketdatei /etc/apt/sources.list.d/debian.sources konnte nicht verarbeitet werden (1)."} notification → "Package data unreadable" | same German string ``` That string is this module's only user-facing error text, and it is currently whatever locale the server happens to run under. `parseSimulation` survives today only because apt does not translate `Inst`/`Conf` — measured, 42 `Inst ` lines under both `C.UTF-8` and `de_DE.UTF-8`. Fine today; it is exactly the assumption the refresh scanner just proved dangerous, and nothing here pins it. ```ts const res = await deps.host.run( "env", ["LC_ALL=C.UTF-8", "apt-get", "-s", "-o", "Debug::NoLocking=true", "dist-upgrade"], { timeoutMs: 120_000 }, ); ``` (`uname -r` on line 146 is in the same boat and does not matter — but the comment in `apt.ts` should say "every apt-get whose output is parsed", and then be true of both.)
julian merged commit eb70f7b196 into main 2026-08-20 10:51:32 +02:00
julian deleted branch feat/task-runs 2026-08-20 10:52:24 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OpsDeck/module_updates!1
No description provided.