todo: what tonight's deploy work left behind, and two items it closed

Closed:

- fleet/an-orchestrator-that-acts-on-every-server-at — built, and as none of the three shapes it
  proposed. Not a script, not a screen, not a chat capability: an agent handed a written procedure and a
  server list it has to read rather than one baked into it. All three verified facts in the item earned
  their place; two things it did not anticipate are now new items.
- fleet/a-way-to-mark-a-commit-this-one-ships-so-a — built as a TAG, which the item explicitly ruled out.
  The owner reversed it, and the argument that changed his mind is recorded: a codeword can be written by
  accident, because every document about deploying contains it, so the trigger needs rules about where in
  a subject line it sits and a rule like that is one somebody eventually gets wrong.

Opened, all from tonight and all with the evidence attached:

- a deploy verifies pm2 and not that the app serves anything — the commander raised this against itself
- the commander cannot finish its own host, so one machine is always a build behind
- the watcher loop is rewritten by an agent every arming, so its correctness is re-rolled each time
- plugins:update restarts every sidecar, so a report naming only officer understates what moved
- an agent spawned by a background job is invisible in the UI while being perfectly readable
- AskUserQuestion can fail to parse its own call
- a commander opened from /chat loses its commander controls, because isCommander comes from the screen
  rather than the session

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 20:27:00 +00:00
co-authored by Claude Opus 5
parent 887749722c
commit 530e5880e1
11 changed files with 323 additions and 36 deletions
+34 -34
View File
@@ -20,38 +20,38 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**78 open, 20 closed**, across 28 themes.
**84 open, 22 closed**, across 28 themes.
| Theme | Open | Closed |
| ------------------------------------------------------------------------------------------ | ---- | ------ |
| [Affordances](todo/affordances/) | 1 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
| [Backburner](todo/backburner/) | 2 | 0 |
| [Background jobs](todo/background-jobs/) | 1 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 3 | 0 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 6 | 0 |
| [Known bugs](todo/known-bugs/) | 2 | 1 |
| [Mobile apps](todo/mobile-apps/) | 2 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 3 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
| [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
| Theme | Open | Closed |
| --- | --- | --- |
| [Affordances](todo/affordances/) | 1 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
| [Backburner](todo/backburner/) | 2 | 0 |
| [Background jobs](todo/background-jobs/) | 2 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 6 | 0 |
| [Known bugs](todo/known-bugs/) | 4 | 1 |
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 3 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
| [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
## Records — no items, kept for the reasoning
@@ -65,10 +65,10 @@ the owner's OS user and can never be granted. Indirection there really is accide
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
- `2026-08-21` **'An agent spawned by a background job is invisible in the interface'** — [background-jobs/a-spawned-agent-is-invisible-in-the-interface](todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.md)
- `2026-08-21` **Show how long a turn took, beside its cost and tokens** — [chat/show-how-long-a-turn-took-beside-its-cost](todo/chat/show-how-long-a-turn-took-beside-its-cost.md)
- `2026-08-21` **Map every custom component, centralise it, and let a theme override it** — [components/map-every-custom-component-and-centralise-it](todo/components/map-every-custom-component-and-centralise-it.md)
- `2026-08-21` **Watch a directory, and attach actions to what lands in it** — [file-watchers/watch-a-directory-and-attach-actions-to-what](todo/file-watchers/watch-a-directory-and-attach-actions-to-what.md)
- `2026-08-21` **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
- `2026-08-21` **A way to mark a commit "this one ships", so a release is not just `master`'s tip** — [fleet/a-way-to-mark-a-commit-this-one-ships-so-a](todo/fleet/a-way-to-mark-a-commit-this-one-ships-so-a.md)
- `2026-08-21` **OPM — Officer's own process manager, or at least its own PM2** — [infra/opm-officer-s-own-process-manager](todo/infra/opm-officer-s-own-process-manager.md)
- `2026-08-21` **iPhone Action Button → run an Officer task from a locked phone** — [mobile-apps/iphone-action-button-run-an-officer-task-from](todo/mobile-apps/iphone-action-button-run-an-officer-task-from.md)
- `2026-08-21` **'A deploy verifies PM2, not that the app still works'** — [fleet/a-deploy-report-verifies-pm2-not-that-the-app](todo/fleet/a-deploy-report-verifies-pm2-not-that-the-app.md)
- `2026-08-21` **'The deploy commander cannot finish its own host'** — [fleet/the-deploy-commander-cannot-finish-its-own-host](todo/fleet/the-deploy-commander-cannot-finish-its-own-host.md)
@@ -0,0 +1,32 @@
---
id: background-jobs/a-spawned-agent-is-invisible-in-the-interface
title: 'An agent spawned by a background job is invisible in the interface'
theme: background-jobs
status: open
created: 2026-08-21
date_source: stated
order: 1
---
**An agent spawned by a background job is invisible in the interface, even though it is fully readable.**
A watcher loop that fires spawns `claude -p`, and that child writes an ordinary transcript under the
project directory for its cwd. So it opens in `/chat/<its-id>` like any other session — verified on
2026-08-21 while the deploy was mid-flight: fourteen messages, the right cwd, rendered live in the normal
chat view with no work needed.
Nothing in the UI tells you that id exists. The parent's job panel shows the child's `stream-json` piped
through `jq`, which is genuinely good — you see its tool calls as they happen — but it is a text stream in
a small box, and there is no link. You can only find the session by knowing to look for it.
Two fixes, and the small one is most of the value:
- **Have the loop print the transcript id as its first line.** One `echo`, no platform change, and the id
becomes clickable-by-copy in the panel the owner is already watching.
- **A real row for it.** The child is a genuine session with a genuine cost; Command HQ could file it
against the commander that spawned it, which would also fix cost attribution — today the child's tokens
land nowhere, so a commander's cost line reads `$0.28` for a night that spent several dollars.
The second one needs a decision about what the child *is*. A commander is defined by its directory
(`isCommandersDir`), and the child runs in the platform checkout, so it does not qualify — deliberately.
Making it visible probably means a different relationship than "another commander".
@@ -0,0 +1,33 @@
---
id: fleet/a-deploy-report-verifies-pm2-not-that-the-app
title: 'A deploy verifies PM2, not that the app still works'
theme: fleet
status: open
created: 2026-08-21
date_source: stated
order: 4
---
**A deploy verifies PM2, not that the app still works.** The deploy commander raised this against itself,
unprompted, in its own report: _"Nothing was verified beyond `pm2`."_ That is the most valuable line in
either report so far and it should not be true twice more.
What step 6 checks today is that the process it restarted is `online` and that `restart_time` moved. That
catches a crash on boot. It does not catch a server that starts, listens, and serves a 500 — or one that
serves a stale bundle, which is the exact failure that cost an hour on 2026-08-21 when a rebuilt frontend
was never picked up because the running process still held the previous `index.gen.html`.
The check that would have caught all of it is one line and needs nothing built:
```bash
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:<port>/
```
Two things to settle rather than assume. **The port differs per host** — take it from that host's `.env`
(`OFFICERDEV_PORT`), not from a constant; `officer-dev` is 9000 and nothing guarantees the others are.
And **a 200 on `/` is a low bar**: it proves the SPA shell is served. An endpoint that touches Postgres
would prove more, and choosing one is the actual design question here — probably `/api/health` if it
exists, and if it does not, that is the smaller item to do first.
Worth adding to the plan format too, so a release can name its own smoke test rather than relying on a
generic one.
@@ -2,7 +2,7 @@
id: fleet/a-way-to-mark-a-commit-this-one-ships-so-a
title: "A way to mark a commit \"this one ships\", so a release is not just `master`'s tip"
theme: fleet
status: open
status: done
created: 2026-08-21
date_source: stated
order: 2
@@ -29,3 +29,22 @@ Shape to settle when it is built:
be a chosen one.
- The updater then does a checkout at that SHA, not a `pull` — same discipline the plugin-pinning
item asks for: **verify `rev-parse HEAD` matches, or refuse.**
---
**Done 2026-08-21 — as a tag, which is what this item ruled out.** The owner reversed it on being shown
the trade: "we don't even need a code word if we tag with deploy/date and the version". `deploy/<version>`,
e.g. `deploy/2026.08.21.02`.
The argument that changed it was not about refs at all. A codeword **can be written by accident**: every
document about deploying contains it, this file included, and so does every conversation planning one — so
the trigger needs rules about where in the subject line it sits, and a rule like that is one somebody
eventually gets wrong. A tag cannot be typed into prose. It also names its own version, which a codeword
does not, and `git ls-remote --tags` answers "is there one" without fetching.
The cost this item correctly identified is real and was accepted: a tag is a second ref to mint and push.
It is two commands in the release recipe.
What replaced the "un-marking is forward-only" worry: the deploy is idempotent by a **local** record, not
by the ref. `scripts/deploy-claim.sh` takes one claim per version and never gives it back to a machine, so
a tag being moved or deleted cannot cause a second rollout.
@@ -2,7 +2,7 @@
id: fleet/an-orchestrator-that-acts-on-every-server-at
title: "An orchestrator that acts on every server at once"
theme: fleet
status: open
status: done
created: 2026-08-20
date_source: assumed
order: 1
@@ -23,3 +23,24 @@ Open: whether this is a script over SSH, an Officer screen, or a chat capability
`headscale` and `omega` — checkout present, nothing running — are targets at all. Note the
platform already has an **Update button** for a single server (`03abdeb2`); this is that, fanned
out, and it should probably share the code rather than reimplement the pull.
---
**Done 2026-08-21, and not as any of the three shapes this item proposed.** It is not a script, not a
screen and not a chat capability — it is an **agent** given a written procedure
(`prompts/deploy-commander.md`) and a list it has to read rather than a list baked into it. Tonight it
rolled `2026.08.21.02` and `2026.08.21.03` across six hosts unattended and wrote its own reports.
All three verified facts in this item survived contact and all three earned their place: it takes each
checkout from PM2's `pm_cwd`, so `alpha`'s stale second checkout was never touched; it pulls in place
rather than assuming a remote; and it takes the login user per host, so `deadpool` was reached as `br`.
Two things this item did not anticipate, both now in the procedure. It must **skip its own host entirely**
— restarting `officer` there kills the rollout mid-flight — which leaves that one machine needing a
deliberate restart afterwards (`todo/fleet/the-deploy-commander-cannot-finish-its-own-host`). And a
release needs **one attempt only**: the first watcher retried a failed deploy every two minutes, so that
rule is now `scripts/deploy-claim.sh` rather than a paragraph anyone re-implements.
It does **not** share code with the single-server Update button. Worth revisiting, but the overlap turned
out to be smaller than expected: the button restarts a process, and almost all of this is the reading,
skipping and reporting around that.
@@ -0,0 +1,28 @@
---
id: fleet/plugins-update-restarts-every-sidecar-so-the
title: '`plugins:update` restarts every sidecar, so the report understates what moved'
theme: fleet
status: open
created: 2026-08-21
date_source: stated
order: 7
---
**`bun run plugins:update` restarts sidecars itself**, printing `sidecar: started` per plugin. On `alpha`
that is twelve processes bouncing during step 3 — before the plan's own restart step, and not something
any plan asked for.
Nothing is broken and this is not new behaviour. The problem is honesty: a deploy report whose "restarted"
column reads `officer` is describing one restart when thirteen happened, and the whole value of these
reports is that they can be trusted about what moved.
Two ways out, and they are not the same decision:
- **Make the report accurate** — the plan format grows a note, or the commander reads `restart_time` for
every process rather than only the ones it restarted. Cheap, and it is the honest minimum.
- **Make the script not do it** — a `--no-restart` flag, with the deploy restarting what the plan names.
That is the better shape (a plan that says "restart `officer` only" should mean it) but it is a change
to something that works, and a plugin whose sidecar is not restarted after an update may well be broken
in a way nobody notices until later.
Do the first regardless. The second is a real design call.
@@ -0,0 +1,34 @@
---
id: fleet/the-deploy-commander-cannot-finish-its-own-host
title: 'The deploy commander cannot finish its own host'
theme: fleet
status: open
created: 2026-08-21
date_source: stated
order: 5
---
**The deploy commander cannot finish its own host, so one machine is always a build behind until somebody
restarts it by hand.** After `2026.08.21.03` that skip was two releases deep on `officer-dev` —
`restart_time` unchanged at 155 — with the code for both sitting on disk.
The skip itself is correct and should not be removed. The commander is a session inside
`officer-claude-code` on that machine; restarting `officer` there ends the rollout mid-flight, and it is
not hypothetical — a manual deploy on 2026-08-21 had to leave that one sidecar alone to survive writing
its own report. Restarting only `officer` and leaving the sidecar would technically work and is worse: it
puts one host in a state nobody chose, a step behind the rest, discovered later.
So the question is what finishes it, and there are three honest answers:
- **A deferred restart the platform runs after the session ends.** Cheapest. The commander writes a
one-shot request, something outside the agent's process honours it, and the report says a restart is
pending rather than outstanding. Needs a thing that is not the agent and not `officer` — `at`, a systemd
transient unit, or PM2 itself.
- **A second machine deploys this one.** Cleanest and needs no new mechanism at all: any other host's
commander has no reason to skip `officer-dev`. Costs a second armed watcher and raises "who deploys the
deployer" — two watchers must not both claim a release, which `scripts/deploy-claim.sh` already prevents
per host but **not across hosts**, since the claim is local by design.
- **Leave it manual and make it loud.** The report already says "outstanding". A notification would close
most of the gap for none of the work.
Not a decision to make from the code — it is the owner's call which of those three shapes he wants.
@@ -0,0 +1,34 @@
---
id: fleet/the-watcher-loop-is-rewritten-by-an-agent-every
title: 'The watcher loop is rewritten by an agent every time it arms'
theme: fleet
status: open
created: 2026-08-21
date_source: stated
order: 6
---
**The watcher loop is rewritten from scratch by an agent every time one arms**, so its correctness is
re-rolled on every arming. The first one on 2026-08-21 was careful, logged honestly, set a `timeout` on
its network call, routed stderr away from the JSON it was parsing — and still contained an unbounded
retry, because the obvious way to ask "have I done this release" is to test the `.done` file, and that
file only exists on **success**.
`scripts/deploy-claim.sh` fixed that one instance by moving the rule into code. The general problem is
untouched: the next loop can get something else subtly wrong, and nothing will notice until it costs
money or misses a release.
Things a platform-owned loop would make constant rather than re-derived, all of them learned by an agent
getting them wrong once:
- Claim the version before spawning anything.
- `OFFICER_ROOT` set explicitly — it is **empty** in the sidecar's environment, so the lock path resolves
to `/data/deploys` and every release looks permanently undeployed.
- stderr kept out of the `stream-json` stream, or the `claude.ai connectors are disabled` warning corrupts
the first `jq` parse.
- `jq --unbuffered`, or the job panel shows nothing for twenty minutes and reads as hung.
The tension worth naming: an agent writing its own loop is also the reason this worked at all on the first
night, from a prose description, with no watcher feature built. A fixed script is more correct and less
general. The likely answer is a **script the loop calls** rather than a script that replaces it — which is
what the claim rule already is, and it could absorb the four items above the same way.
@@ -0,0 +1,30 @@
---
id: known-bugs/a-commander-opened-from-chat-loses-its-context
title: 'A commander opened from /chat loses its commander controls'
theme: known-bugs
status: open
created: 2026-08-21
date_source: stated
order: 4
---
**A commander opened from `/chat` reports the ordinary chat context, so the composer hides every control
that only exists for a commander.** The visible symptom is the background-job dialog offering "Stop when
it fires" and "Keep going" but not **"Exit and hand back to the agent, then re-arm"** — the one a watcher
actually wants, and the dialog's own default inside a commander.
Introduced 2026-08-21 by a deliberate UX change: a commander in the Live list opens in `/chat` rather than
navigating away to Command HQ, because being thrown out of the page you are reading costs more than the
metadata you did not ask for. That change is right and should stay.
The defect is that `isCommander` is derived from **the screen** rather than from **the session**:
`isCommander={manager.context === 'command-hq'}`, and `/chat` passes no such context. A commander is a
commander wherever it is rendered.
The fix is to carry it on the session. `ClaudeSessionSummary` and `/chat/live` both already have
`isCommander`, derived server-side from the cwd; `GET /chat/sessions/:id` does not, and that is the one the
chat detail reads. Adding it there and letting the composer take it from the session rather than the route
closes this and makes the same thing work in any future surface that renders a chat.
Related: the same `context` name means two different things either side of `EmbeddableChat` — a `useChat`
option and a component prop, neither reading the other's — which is what hid the original bug twice.
@@ -0,0 +1,26 @@
---
id: known-bugs/askuserquestion-can-fail-to-parse-its-own-call
title: 'AskUserQuestion can fail to parse its own call'
theme: known-bugs
status: open
created: 2026-08-21
date_source: stated
order: 3
---
**`AskUserQuestion` rejected a well-formed call with `InputValidationError: called with input that could
not be parsed as JSON`.** Seen 2026-08-21 in the deploy commander's first turn, on Sonnet.
The tell is in the transcript: the failed call arrives as `{"__unparsedToolInput": {"raw": "{\"questions\":
[…"}}` — the arguments reached the harness as a **string** rather than an object, and the harness reported
that as the model's fault. The retry, byte-identical in intent, went through and the question rendered
normally.
So it is recoverable and it self-corrected, which is why it is filed here rather than treated as urgent.
What makes it worth fixing anyway: this is the same tool as the stall bug fixed earlier the same day, on
the path where an agent asks a question and waits. A parse failure there burns a turn, and if it happened
on the **second** attempt too the agent would most likely give up asking and guess — which is the failure
mode the question mechanism exists to prevent.
Worth capturing the raw payload when this fires rather than only the first 200 bytes, since the message
truncates exactly where the interesting part would be.
@@ -0,0 +1,30 @@
---
id: mobile-apps/the-live-chat-swipe-order-should-exclude
title: 'The live-chat swipe order should exclude commanders'
theme: mobile-apps
status: open
created: 2026-08-21
date_source: stated
order: 3
---
**The iPhone app swipes left and right between live chats, and commanders should not be in that order.**
The reason it needs the server's help is that the phone has nowhere to put a tab. The web app solved the
same problem on 2026-08-21 with two tabs inside Live — `Agents (n)` / `Commanders (n)` — and a phone
swiping through a flat sequence cannot do that, so it can only filter if the payload says which rows are
commanders.
**The server side is done and shipped in `2026.08.21.02`.** `GET /chat/live` rows now carry
`isCommander: boolean`, derived server-side from the session's cwd rather than from a database lookup:
every commander shares one directory, so the directory is the answer, it costs no query, and it is already
correct during a commander's first turn before any row exists. `GET /chat/sessions` carries it too.
So the app work is: read the field, drop `isCommander` rows from the swipe sequence, and decide whether
they are reachable at all on mobile or simply absent. A deploy commander appearing between two
conversations you are having is the thing to prevent.
**Owed: a short document for the mobile developer** — the field, where it comes from, and the one caveat
worth stating, which is that a row is `isCommander` from its first turn even before it has a title. The
owner asked for this on 2026-08-21 and said explicitly it should be written against a shipped version
rather than an intended one; that version is `2026.08.21.02`.