agentus 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ # Verifying a change
2
+
3
+ This doc answers: how do I prove a change to Agentus actually works, and which check is the
4
+ right size for which change?
5
+
6
+ ## Pick the cheapest evidence that can fail
7
+
8
+ Not every change needs a browser. Reach for the smallest check that can actually go red:
9
+
10
+ | change | evidence |
11
+ |---|---|
12
+ | a pure formatter / decision function | a `*.mts` unit sweep (runs under `npx tsx`, needs no build, no browser) |
13
+ | server logic / a REST contract | a `node scripts/*.mjs` suite against a scratch server |
14
+ | rendered UI behaviour or geometry | a CDP page sweep (`scripts/qa/*.mjs`) |
15
+ | a release | the promote log + the served bundle (see [03-release.md](./03-release.md)) |
16
+ | a doc / config edit | it is done when it is written — no verification run |
17
+
18
+ A change with no runtime effect is finished when it is written; an unasked-for sweep just
19
+ spends the round.
20
+
21
+ ## Unit suites and smoke suites
22
+
23
+ ### `npm test`
24
+
25
+ `package.json` maps `test` to `npm run test -ws --if-present`, i.e. "run each workspace's
26
+ own `test` script if it has one". As of this writing **no workspace defines a `test`
27
+ script**, so `npm test` runs nothing. The runnable suites are the scripted ones below —
28
+ run them directly. `TODO(verify)`: this placeholder is expected to gain real suites.
29
+
30
+ ### Server / API smoke suites (each boots its own scratch server on a random port `8900+`)
31
+
32
+ ```bash
33
+ npm run typecheck # 0 errors before any commit
34
+ npm run build # web bundle + shared types
35
+ node scripts/auth-smoke.mjs # the lock, both credentials (~48 assertions)
36
+ node scripts/backend-registry-smoke.mjs # backend registry, spawn env, health (~42)
37
+ node scripts/workspace-smoke.mjs # workspace, file API, shell, attachments (~85)
38
+ node scripts/voice-smoke.mjs # settings/theme guards, voice router (~47)
39
+ npm run tls-smoke # the TLS listener, real sockets + real certs
40
+ npm run package-smoke # pack + install into a throwaway HOME + boot
41
+ node scripts/smoke.mjs mock "hello" # end-to-end against the mock agent
42
+ ```
43
+
44
+ `backend-registry-smoke` is shaped as three layers so it passes on a machine without the
45
+ operator's CLI: always-on (registry CRUD, spawn env, session health via the in-repo mock),
46
+ local-only checks guarded by "does this command resolve / does that tree exist" and
47
+ announced as `skip`, and a summary line reporting the skip count. A suite that needs a
48
+ local binary must **skip**, never fail.
49
+
50
+ ### `*.mts` unit sweeps (importable TS, no build, no browser)
51
+
52
+ ```bash
53
+ npm run transcript-fold # tsx scripts/qa/transcript-fold.mts
54
+ npm run store-fold # tsx scripts/qa/store-fold.mts
55
+ npm run live-stream # tsx scripts/qa/live-stream.mts real server frames -> real store
56
+ npm run resume-tail
57
+ npm run rail-recent
58
+ npm run turn-pulse # the silence->amber decision, with injected `now`
59
+ npm run sentence-units
60
+ ```
61
+
62
+ Plus non-aliased ones run with `npx tsx scripts/qa/<name>.mts`: `accent-text.mts` (the
63
+ accent-as-text derivation, 61 checks), `presence-rule.mts` (`watchingNow`), `open-target.mts`,
64
+ `plan-v2-sweep.mts`, `asr-idle-wedge.mts`. These are the right home for a **time- or
65
+ boundary-driven decision**: extract it into a pure function with an injected `now` and
66
+ assert either side of the boundary (59s vs 60s, `2 < 10 < 100`), because a browser cannot
67
+ be made to wait for a 60-second window reliably.
68
+
69
+ ## The CDP browser sweeps
70
+
71
+ `scripts/qa/*.mjs` drive a **real browser** over the Chrome DevTools Protocol and read
72
+ computed styles and geometry from the rendered page — never assert on internals. They run
73
+ by hand and are not in CI.
74
+
75
+ ### Start the debug browser
76
+
77
+ Launch Edge or Chrome with remote debugging on `127.0.0.1:9222`:
78
+
79
+ ```bash
80
+ open -a "Microsoft Edge" --args --remote-debugging-port=9222
81
+ # or: /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
82
+ ```
83
+
84
+ ### Run a sweep
85
+
86
+ ```bash
87
+ cd worktrees/agentus-dev
88
+ PORT=8901 node scripts/qa/layout-sweep.mjs
89
+ PORT=8901 W=390 H=844 node scripts/qa/popover-sweep.mjs
90
+ ```
91
+
92
+ Each sweep opens its **own tab** (`PUT http://127.0.0.1:9222/json/new?<BASE>`), builds its
93
+ own page state, and defaults to the dev port (`8901`, overridable with `PORT` / `BASE`).
94
+ Sweeps that post REST calls want the QA data dir: `AGENTUS_DATA=/tmp/agentus-qa-account`.
95
+
96
+ **Rebuild before you sweep a web change.** The dev server serves `packages/web/dist`, so
97
+ after editing web sources run `npm run build` (or `npm run build -w @agentus/web`) first, or
98
+ the sweep measures the previous bundle.
99
+
100
+ **Page-driving sweeps on `:8901` must log in themselves.** The operator's Edge profile
101
+ holds the cookie for the live port, not the dev one, so an unattended tab renders the login
102
+ wall and every step reads as a 401-induced "product failure". Standard opening:
103
+ `POST /api/auth/login` as `scratch` / `scratch-pass-1` (the `DEV_USER`/`DEV_PASS` defaults),
104
+ then reload. Assert the page is authenticated before measuring.
105
+
106
+ **Run one browser sweep at a time.** Two sweeps share this box's CPU and the one CDP
107
+ browser; the starved one fails in a cluster of unrelated places. Re-run a red suite alone
108
+ before believing it. And do not run a sweep in the same breath as a branch switch or any
109
+ server-source edit — the dev watcher restarts, the page's WebSocket drops mid-run, and one
110
+ assertion fails at random.
111
+
112
+ ### The exit-code discipline (or node hangs)
113
+
114
+ End every sweep with:
115
+
116
+ ```js
117
+ ws.close();
118
+ process.exit(fail ? 1 : 0);
119
+ ```
120
+
121
+ A sweep that only sets `process.exitCode` prints its summary and then **hangs forever on
122
+ the open CDP WebSocket** — and a shell chaining two sweeps with `;` never reaches the
123
+ second one. Measured: three sweeps sat "still running" 8–25 minutes after their last check,
124
+ each holding a tab, which then made the next sweep time out. When a suite "hangs after
125
+ passing", look for the missing exit before hunting a product bug.
126
+
127
+ ### Traps worth knowing before you write one
128
+
129
+ - **`Runtime.evaluate` needs `awaitPromise: true`**, or a `fetch(...)` expression comes back
130
+ as a serialised pending Promise (`{}`) — truthy, so a step "succeeds" while nothing was
131
+ created.
132
+ - **`Runtime.evaluate` must return something serialisable.** `returnByValue: true` on an
133
+ expression that evaluates to a DOM node fails the whole call (`Object reference chain is
134
+ too long`). Wrap it: `Boolean(document.querySelector('.sidebar'))`.
135
+ - **Enter needs `text:'\r'`.** `Input.dispatchKeyEvent` with only `key:'Enter'` / `code`
136
+ delivers the event but the app's `keydown` handler never sees it. Add `text:'\r'`.
137
+ - **A trusted click lands where the viewport says.** If the target is scrolled out of the
138
+ viewport the event is silently swallowed — `scrollIntoView({block:'center'})`, re-measure,
139
+ assert `top >= 0 && bottom <= innerHeight`, then click.
140
+ - **The oracle comes from the page, not from node.** The cockpit is authenticated, so a
141
+ node-side `fetch('<BASE>/api/sessions')` returns 401 and every "UI value == server value"
142
+ comparison silently becomes "== undefined". Fetch from inside the page:
143
+ `ev("fetch('/api/sessions').then(r=>r.json())")`.
144
+ - **The app consumes `?session=` and strips it** (`state.ts #openTarget` → `replaceState`),
145
+ so a later `location.search` read is always null — keep the id in a variable. And
146
+ `Page.reload` wipes injected page helpers; re-inject after every reload.
147
+ - **Never wrap `window.WebSocket`.** Installed via `Page.addScriptToEvaluateOnNewDocument`
148
+ it replaces the constructor the app uses and the cockpit's own socket never connects.
149
+ Patch `WebSocket.prototype.send` after the app is up instead.
150
+ - **Token-injection cannot reach the WS handshake.** Injecting the machine token as an HTTP
151
+ header (via `Fetch.continueRequest` with `headers` as an **array** of `{name,value}`)
152
+ authenticates REST but not `/ws`, and the app's WS URL carries no token. That is enough
153
+ to prove REST-fed surfaces on the live instance but **not** WS-fed ones like the session
154
+ rail's live rows — say which you proved and which you did not.
155
+
156
+ ### Why a visual rule needs an injected fixture row
157
+
158
+ The dev instance's mock emits **no tool calls for an ordinary prompt** (see
159
+ [01-dev-loop.md](./01-dev-loop.md)). A sweep whose subject is a `.tool-card` will therefore
160
+ never produce one and spends its time failing. When the assertion is about the CSS, inject a
161
+ probe with the **real class names** into the real container, measure it, remove it, and
162
+ assert the count is unchanged (`0 → 0`) so a leaked probe cannot pass:
163
+
164
+ ```js
165
+ const mk = (html) => { const d = document.createElement('div'); d.innerHTML = html;
166
+ const el = d.firstElementChild; stream.appendChild(el); return el; };
167
+ const row = mk('<div class="msg"><div class="tool-card completed">…</div></div>');
168
+ const box = row.querySelector('.tool-card').getBoundingClientRect();
169
+ row.remove();
170
+ ```
171
+
172
+ Say in the check's label which rows are real and which are probes. An operator who judges a
173
+ layout by eye still needs a screenshot; a probe that only the script saw is not verified.
174
+
175
+ ### Writing a new sweep from an existing one
176
+
177
+ Copy the closest existing sweep and keep its scaffolding (identical in every file):
178
+
179
+ 1. the CDP client (`send` / `ev` / `until` helpers over a `WebSocket`),
180
+ 2. the `check(ok, what, detail)` counter and the pass/fail summary,
181
+ 3. `PUT /json/new?<BASE>` to open the tab, `sleep(~2600)` to let the app boot,
182
+ 4. login-as-scratch if the surface needs a live socket,
183
+ 5. build the page state you need (create a **fresh** mock session rather than adopting one),
184
+ and reset any preference you depend on (both `localStorage` and the server setting),
185
+ 6. `ws.close(); process.exit(fail ? 1 : 0)`.
186
+
187
+ Prefer a new mock `[trigger]` prompt over editing a shared fixture constant — the mock is
188
+ spawned per session, so a new session picks up the edit with no server restart, and the
189
+ assertion then drives a real agent bubble instead of HTML the sweep injects.
190
+
191
+ ## Table of existing sweeps
192
+
193
+ The QA directory is large; these are the ones that carry each surface, with what each covers.
194
+
195
+ | sweep (`scripts/qa/`) | covers |
196
+ |---|---|
197
+ | `layout-sweep.mjs` | rail indent ladder + folder icon + directory colour, accent-neutral split, three-pane widths with real pointer drags, chat column edge alignment, content-width rows (short tool card not stretched), phone 390px — 58 checks |
198
+ | `rail-archive-sweep.mjs` | the 已归档 section: built like 工作空间 (same row shape, folder icons, grouped by directory), a fold flag that survives a reload, and the two sections keeping separate fold state for the same path — 15 checks |
199
+ | `terminal-sweep.mjs` | the workspace panel's terminal is a real pty, not a text box: xterm mounted, a prompt only a tty prints, the shell's own echo of what was typed, an SGR colour surviving to the screen, and a resize that reaches the kernel (`stty size` + SIGWINCH) — 15 checks |
200
+ | `queue-sweep.mjs` | the message queue: enqueue while a turn runs, the row is not in the transcript, the three buttons' order, delete/edit, automatic ordered drain on `turn-end` — 28 checks |
201
+ | `table-sweep.mjs` | table headers sort three ways (numeric, not string), filter, full-screen reader with content rotation on a phone, a file chip in the reply, a relative image — real agent bubble via `[table]` |
202
+ | `rail-contrast-sweep.mjs` | sidebar text contrast computed per element against its actual background, in both palettes with the operator's blue and the built-in amber; the accent-vs-neutral chroma gap |
203
+ | `render-files-sweep.mjs` | local file links / images in a reply (all four forms), the workspace panel previewing and saving by type, the raw endpoint behind auth |
204
+ | `popover-sweep.mjs` | every popover's geometry at both viewports (a popover can pass every DOM text assertion while being off-screen) |
205
+ | `rail-sweep.mjs`, `rail-live-sweep.mjs`, `rail-recent-sweep.mjs` | the session rail: one title per row, row geometry, the ⋯ menu, inline rename landing in DOM *and* API, the phone bottom sheet, rail ordering |
206
+ | `message-actions-sweep.mjs` | per-message copy (text compared character-for-character), fork-on-tail |
207
+ | `permission-sweep.mjs` | a permission request reaches the operator's view (card + dialog), names the file, shows the diff, the answer reaches the agent, ≥32px tap targets |
208
+ | `voice-stop-sweep.mjs`, `call-sweep.mjs` | the speech layer and the full-screen call: deliberate stop vs real failure, the hero's real animation, phases, barge-in, controls on screen at 390×844 |
209
+ | `session-title-sweep.mjs`, `session-restart-sweep.mjs` | naming over API + browser; swapping an agent child under a live slot without it leaving the live list |
210
+ | `transcript-sweep.mjs`, `window-ui-sweep.mjs`, `turn-line-sweep.mjs`, `turn-pulse-sweep.mjs` | transcript rendering/paging/gestures, the usage strip, the turn timer and the silence→amber state |
211
+ | `config-sweep.mjs`, `dismiss-sweep.mjs`, `model-switch-sweep.mjs`, `plan-*-sweep.mjs` | the config/plan/model surfaces |
212
+ | `live-endpoints-check.mjs` | the file endpoints over the machine token; same source runs against live **and** dev |
213
+ | `think-cadence.mjs`, `render-lag-sweep.mjs`, `replay-window.mjs` | streaming cadence, render lag, replay windows |
214
+
215
+ Unit (`*.mts`) sweeps: `accent-text`, `transcript-fold`, `store-fold`, `live-stream`,
216
+ `resume-tail`, `rail-recent`, `turn-pulse`, `sentence-units`, `presence-rule`, `open-target`,
217
+ `plan-v2-sweep`, `asr-idle-wedge`. There are also `notify-smoke.mts` (the notify contract,
218
+ 65 → 72 checks) and `notify-cockpit-e2e.mts` (a real agent turn end-to-end) for the phone
219
+ channel; see [`docs/android-notify-contract.md`](../android-notify-contract.md).
220
+
221
+ ## Assertion discipline
222
+
223
+ Every sweep assertion must be able to prove **this run** caused the observed state:
224
+
225
+ - use a water mark (`createdAt >= runStart`) rather than "the newest row in that directory";
226
+ - establish `cwd` for any seeded fixture (`POST /api/sessions` refuses a `cwd` that is not a
227
+ directory) and assert the fixture exists before asserting behaviour;
228
+ - set what you depend on first — a leftover server preference from an earlier run turns
229
+ unrelated checks red;
230
+ - judge the effect on the far side ("the button cleared our state" is not "the answer
231
+ reached the agent" — read the agent's own tool output);
232
+ - when a fix is claimed, re-run the suite whose surface you touched: a change to the harness
233
+ invalidates every green it produced.
234
+
235
+ Cross-links: [01-dev-loop.md](./01-dev-loop.md) (ports, mock triggers) ·
236
+ [03-release.md](./03-release.md) (proving a release) ·
237
+ [04-recover.md](./04-recover.md) (stray tabs, leaked processes).
@@ -0,0 +1,218 @@
1
+ # Releasing to the live instance
2
+
3
+ This doc answers: how do I move a verified change from the dev tree onto the live instance
4
+ the operator uses, and how do I undo it?
5
+
6
+ ## The rule that governs every release
7
+
8
+ `promote` **restarts the live server when server-side code changed**, and a restart SIGTERMs
9
+ every live `hermes acp` child — every session the operator has open, including his current
10
+ conversation. It is a release, not a hot reload. So:
11
+
12
+ - releases happen only after the sweeps are green and, unless the change is strictly
13
+ web-only, **only when the operator says so** in that round;
14
+ - a strictly web-only change may be published without asking first — but "web-only" is
15
+ decided by what `promote` prints, not by which files you remember editing (below).
16
+
17
+ ## Branches
18
+
19
+ Work happens on a branch cut in the dev tree:
20
+
21
+ | branch | use |
22
+ |---|---|
23
+ | `feat/<slug>` | a feature |
24
+ | `fix/<slug>` | a fix (e.g. `fix/rail-readable-tokens`) |
25
+ | `run/<slug>` | created **by `promote` on the live tree** — never check it out yourself |
26
+ | `main` | the integration line; **not** what live runs from |
27
+
28
+ `TODO(verify)`: the project also refers to a `fix/<base>/<desc>` convention; the branches
29
+ observed in the tree are `feat/<slug>` and `fix/<slug>`.
30
+
31
+ Keep the dev tree on its own branch. `promote` publishes the dev tree's **current HEAD**,
32
+ whatever branch that is — leaving it parked on a stale branch publishes a rollback with no
33
+ warning. `promote` guards against the common case (HEAD strictly behind `origin/main` → it
34
+ refuses; override with `DEV_PROMOTE_FORCE=1` for a deliberate rollback), but it will not
35
+ know that you meant to be on another branch.
36
+
37
+ ## Committing
38
+
39
+ Commit and push the dev branch **before** promoting: `promote` refuses an uncommitted tree
40
+ (`the dev tree has uncommitted changes — commit them first`), and the pushed branch is what
41
+ makes the round recoverable if the release goes wrong.
42
+
43
+ **Write the commit message to a file and use `-F`.** A multi-line message with inline
44
+ backticks (`` `--text-dim` ``) or `$`/`!` triggers shell command substitution inside
45
+ `-m "…"`, and the substitutions silently vanish from the message:
46
+
47
+ ```bash
48
+ $EDITOR /tmp/msg.txt # backticks, $ and ! are literal here
49
+ git commit -F /tmp/msg.txt
50
+ ```
51
+
52
+ ## Promoting
53
+
54
+ ```bash
55
+ bash scripts/dev.sh promote [<slug>]
56
+ ```
57
+
58
+ If `<slug>` is omitted, the branch name is used with `/` → `-`. What it does:
59
+
60
+ 1. **Refuses** an uncommitted dev tree, and refuses HEAD-behind-`origin/main` (unless
61
+ `DEV_PROMOTE_FORCE=1`).
62
+ 2. Runs **detached** — because the restart it may cause kills the agent that ran it — and
63
+ writes its log to `/tmp/agentus-qa-account/promote.log`. Follow it with:
64
+ ```bash
65
+ bash scripts/dev.sh promote-log # follows the log (it hangs until the run ends)
66
+ ```
67
+ or read `/tmp/agentus-qa-account/promote.log` directly.
68
+ 3. Checks the **live** tree out onto `run/<slug>` at the dev tip and rebuilds the web bundle.
69
+ 4. Decides whether the live process must restart, from the diff between the live tree's old
70
+ tip and the promoted HEAD:
71
+
72
+ | trigger | files |
73
+ |---|---|
74
+ | server graph | `packages/server/src/**`, `packages/shared/src/**` |
75
+ | start shape | `scripts/start.sh`, `package.json` |
76
+ | stale launcher | the live process is still running under `tsx watch` |
77
+
78
+ 5. If a restart is needed, calls `scripts/relaunch.sh --port 8787 --tree <live>
79
+ --launcher <live launcher>`, which TERMs the process group, waits for the port to free,
80
+ starts the launcher, polls `/healthz`, and **asserts the pid changed** (a failure exits
81
+ non-zero, so "built fine, still serving old code" cannot pass).
82
+ 6. Resumes every live session it captured **before** the restart
83
+ (`POST /api/sessions/<id>/resume`, expected HTTP 200 each).
84
+
85
+ ### Web-only release (no restart)
86
+
87
+ When nothing in the trigger list moved, `promote` prints `no server-side change` and the
88
+ running server keeps its process. The new bundle is served on reload, so **no session is
89
+ interrupted at all** — the operator only hard-refreshes his tab. The three success criteria
90
+ are all required:
91
+
92
+ - `no server-side change` printed,
93
+ - the **asset hash moved** (`web asset: <before> -> <after>`; `promote` warns if web sources
94
+ changed but the hash did not),
95
+ - the **pid did NOT move** (`lsof -ti:8787` unchanged — a moved pid means it took the
96
+ restart branch and the release was not the kind it claimed to be).
97
+
98
+ `package.json` is a restart trigger, so **any** edit to it — even one new npm alias for a QA
99
+ sweep — turns a web-only round into a restarting one. When a web change rides on a dev
100
+ branch that also carries unreleased server work, cut the release from the live tip instead:
101
+
102
+ ```bash
103
+ cd worktrees/agentus-dev
104
+ git checkout -B feat/<slug>-live <live-tip> # live tip = git -C ../agentus rev-parse HEAD
105
+ git cherry-pick <web-commit>
106
+ git diff --name-only <live-tip> HEAD # MUST list only packages/web/** (+ scripts/qa/**)
107
+ env -u AGENTUS_DATA -u AGENTUS_BASE -u AGENTUS_PORT -u DEV_* bash scripts/dev.sh promote <slug>
108
+ ```
109
+
110
+ Also drop `package.json` back to the live tip's version before promoting (`git checkout
111
+ <live-tip> -- package.json && git commit --amend --no-edit`) so the alias stays out of the
112
+ promoted tree, and run the sweep directly (`npx tsx scripts/qa/x.mts`) where the alias is
113
+ absent.
114
+
115
+ ### Never fire the release from the dev environment
116
+
117
+ The shell that runs `promote` usually carries the QA instance's variables
118
+ (`AGENTUS_DATA=/tmp/agentus-qa-account`, `AGENTUS_BASE=…:8901`, `DEV_*`), and
119
+ `promote → relaunch.sh → launch.py` inherits them. The launcher now pins its own identity,
120
+ but wrap it anyway to keep the environment (and the log) honest:
121
+
122
+ ```bash
123
+ env -u AGENTUS_DATA -u AGENTUS_BASE -u AGENTUS_PORT \
124
+ -u DEV_LOG_PATH -u DEV_PROMOTE_DETACHED -u DEV_PROMOTE_SELF -u DEV_PROMOTE_SLUG \
125
+ bash scripts/dev.sh promote <slug>
126
+ ```
127
+
128
+ ## Verifying what actually got deployed (with no password)
129
+
130
+ The promote log is one leg; prove the *served* instance independently. Everything below
131
+ needs no browser and no operator password.
132
+
133
+ **1. The machine token.** Each instance mints one at `<DATA_DIR>/auth.token` (0600). The
134
+ server accepts it as `Authorization: Bearer <token>` **or** `?token=<token>`. Read it inside
135
+ a script and never print it — and do not write the literal `Bearer <value>` shape into a
136
+ file, because the write-time secret masker rewrites it to `***` and breaks the syntax.
137
+ Assemble the header at runtime.
138
+
139
+ ```bash
140
+ TOK=$(cat /tmp/agentus-qa-account/auth.token) # dev; live: worktrees/agentus/packages/server/.data/auth.token
141
+ curl -s --noproxy '*' -H "Authorization: Bearer $TOK" http://127.0.0.1:8901/api/sessions | head -c 300
142
+ ```
143
+
144
+ **2. The served asset hash.** `GET /api/version` is public and returns the served bundle
145
+ hash. `promote` echoes it; you can also read it directly:
146
+
147
+ ```bash
148
+ curl -s --noproxy '*' http://127.0.0.1:8787/api/version
149
+ ```
150
+
151
+ **3. Grep the served bundle for the new markers.** Fetch the real vite-named file
152
+ (`assets/index-<hash>.js`) and look for a string only the new code has:
153
+
154
+ ```bash
155
+ HASH=$(curl -s --noproxy '*' http://127.0.0.1:8787/api/version | ...)
156
+ curl -s --noproxy '*' "http://127.0.0.1:8787/assets/$HASH.js" | grep -c '<new-marker>'
157
+ ```
158
+
159
+ Two traps: the file is `index-<hash>.js`, so a check that fetches `/assets/<hash>.js`
160
+ misses and hits the SPA fallback (a body opening with `<!doctype` means the wrong URL, not a
161
+ broken build); and `echo "$B" | grep -q` under `set -o pipefail` reports a **hit as a miss**
162
+ (grep exits on first match, `echo` takes SIGPIPE) — use `grep -q "$lit" <<<"$B"` or
163
+ `grep -c` against a saved file.
164
+
165
+ **4. Rebuild locally and compare the emitted filename.** Stronger than the hash alone and
166
+ needs no credentials: rebuild the same source in the live tree and confirm the produced
167
+ asset filename is **identical** to the one the live instance is serving.
168
+
169
+ ```bash
170
+ cd worktrees/agentus && npm run build
171
+ ls packages/web/dist/assets/ # index-<hash>.js must match what /api/version reports
172
+ ```
173
+
174
+ Same source in the two trees legitimately differs by ~5 KB (the dev build bakes absolute
175
+ source paths into its debug info) and therefore by hash — compare **strings/paths**, not
176
+ sizes, and expect the only differences to be tree-name fragments.
177
+
178
+ After a **restarting** release, the operator's open tab keeps the old bundle until a hard
179
+ refresh — say so in the hand-off. A web-only release needs only the hard refresh.
180
+
181
+ ## Merging to main
182
+
183
+ Merging is a separate step from releasing, and is the standing convention for this repo once
184
+ a release is done. `main` is not what live runs from (`promote` puts live on `run/<slug>`),
185
+ so fast-forward the ref without checking `main` out anywhere:
186
+
187
+ ```bash
188
+ cd worktrees/agentus-dev
189
+ git fetch origin
190
+ git push origin <dev-branch>:main # fast-forward only
191
+ git branch -f main origin/<dev-branch>
192
+ git branch --set-upstream-to=origin/main main
193
+ ```
194
+
195
+ Judge the outcome by identity, not by "the push succeeded": `git ls-remote origin
196
+ refs/heads/main` must equal the commit live is actually serving.
197
+
198
+ ## Rollback
199
+
200
+ The release path has no separate rollback mechanism — you revert the change and promote
201
+ again:
202
+
203
+ 1. On the dev tree, revert or fix the offending commit.
204
+ 2. Promote. If HEAD is now behind `origin/main` and you mean to roll back deliberately,
205
+ `promote` refuses; override with `DEV_PROMOTE_FORCE=1 bash scripts/dev.sh promote <slug>`.
206
+ If the release restarted live, the new (reverted) server starts and `promote` resumes the
207
+ sessions it captured before the restart.
208
+
209
+ The promote log is the record of what happened and where to look:
210
+
211
+ - **log file:** `/tmp/agentus-qa-account/promote.log`
212
+ - **follow:** `bash scripts/dev.sh promote-log`
213
+
214
+ Relay-side incident recovery (a live instance that came up on the wrong store, a slot that
215
+ did not resume) is in [04-recover.md](./04-recover.md).
216
+
217
+ Cross-links: [01-dev-loop.md](./01-dev-loop.md) · [02-verify.md](./02-verify.md) ·
218
+ [04-recover.md](./04-recover.md).
@@ -0,0 +1,199 @@
1
+ # Recovering when it breaks
2
+
3
+ This doc answers: the instance is broken or misbehaving — where do I look first, and how do
4
+ I fix it without making the box worse?
5
+
6
+ ## Read the boot log first
7
+
8
+ The boot log is the authoritative account of what the server resolved. It prints **which
9
+ data directory won and why**, the auth state, and the TLS listener:
10
+
11
+ ```
12
+ data dir: /Users/…/packages/server/.data (AGENTUS_DATA was set) ← the dir + the reason
13
+ auth on: user "admin"
14
+ https://0.0.0.0:8443 (self-signed …) ← present = TLS listener up
15
+ tls off: no cert at <DATA_DIR>/tls/cert.pem ← absent/off = no TLS
16
+ ```
17
+
18
+ If the directory it names is not the one you expected, stop here — a server serving the
19
+ wrong store looks healthy (200s, valid `/api/version`) while the operator's session list is
20
+ empty. Triage the identity, not the liveness (see "Is it serving the wrong store?" below).
21
+
22
+ ## Ports
23
+
24
+ | port | who |
25
+ |---|---|
26
+ | `8787` | live HTTP |
27
+ | `8443` | live TLS (public tunnel points here) |
28
+ | `8901` | dev / QA instance |
29
+ | `8648` | **Hermes Studio** — not Agentus, do not touch |
30
+
31
+ ```bash
32
+ lsof -nP -iTCP:8787 -sTCP:LISTEN # who owns the port (returns the CHILD, not the supervisor)
33
+ ps -o pid,ppid,pgid,command -p <pid> # its process group and command line
34
+ ```
35
+
36
+ ## "Address already in use"
37
+
38
+ `EADDRINUSE` means another process holds the port — often a previous instance that never
39
+ died, or (this box) an ambient `PORT=8648` pointed at Studio. The Agentus server reads
40
+ `AGENTUS_PORT`, never bare `PORT`, so a script that reads the ambient value will fight for
41
+ Studio's port. Do not just relaunch: identify the current holder, decide whether it is the
42
+ one you want, then stop it through its own launcher.
43
+
44
+ ```bash
45
+ # clean stop of the dev instance (kills the process GROUP on :8901)
46
+ bash scripts/dev.sh stop
47
+ ```
48
+
49
+ For live, stop it only via the release path ([03-release.md](./03-release.md)) — a bare kill
50
+ of the live listener ends the operator's sessions.
51
+
52
+ ## Orphaned agent and shell child processes
53
+
54
+ Every agent child is spawned detached and its pid recorded, and the server reaps orphans on
55
+ next boot — so a crashed server's leftovers are cleaned up automatically. Shell sessions
56
+ (the workspace terminal) are killed with their socket. Problems appear when a **supervisor**
57
+ (the `tsx watch` wrapper) survives a crash: the listener it spawned may be gone while the
58
+ supervisor sits in a restart loop, or a leaked listener keeps holding the port.
59
+
60
+ **Never kill by a substring match.** A cleanup filtered on `packages/server/src/index.ts`
61
+ once ended 249 processes in one shot — the operator's own `hermes-studio` checkout has a
62
+ file at that same path, and its server was the only way into the machine. Rules:
63
+
64
+ 1. **Enumerate first and print the full command line and cwd** of every candidate:
65
+ ```bash
66
+ ps -eo pid,ppid,pgid,command | grep <pattern>
67
+ lsof -a -p <pid> -d cwd -Fn # where it is actually running
68
+ ```
69
+ 2. **Keep the list inside this worktree's path.** The operator's services are never in
70
+ scope. A `ps | grep` list also contains your own tool shell — exclude the current pid and
71
+ its ancestors, or an unfiltered kill ends your own command mid-flight.
72
+ 3. **Prove the target is its own process group, then kill the group:**
73
+ ```bash
74
+ ps -o pid,pgid,sess -p <pid>
75
+ kill -9 -- -<pgid> # negative pid = the group
76
+ ```
77
+ Under a supervisor the LISTEN pid is the supervisor's child, whose pgid is the
78
+ supervisor's — killing only the child leaves the supervisor (which respawns and fights
79
+ for the port). This is why `dev.sh`/`relaunch.sh` kill `-$pgid` and, when `pgid != pid`,
80
+ the pid too.
81
+ 4. **Fix the leak, not the puddle.** New scripts must use the shipped pattern —
82
+ `spawn(..., { detached: true })` and kill the process group — rather than cleaning up by
83
+ name afterwards.
84
+ 5. **Re-check the neighbours afterwards:** one `curl` per resident service
85
+ (`:8648` Studio, `:8787`/`:8443` live, `:8901` dev). A cleanup that leaves the box worse
86
+ than it found it is the whole failure mode.
87
+
88
+ ## The SQLite store, WAL, and the `*.bak` files
89
+
90
+ State lives in `<DATA_DIR>/agentus.sqlite` (the transcript and every other row), accessed
91
+ through Node's built-in `node:sqlite` (hence Node `>= 22.5`). Files beside it: `credentials.json`,
92
+ `auth.token`, `auth.secret`, `sessions.json`, `settings.json`, `tls/` — several of them
93
+ `0600`. The server creates the directory `0700` on first boot.
94
+
95
+ **Back up before you touch the store.** A single write against the operator's real data gets
96
+ a backup and a record of the old values:
97
+
98
+ ```bash
99
+ cp -p packages/server/.data/agentus.sqlite \
100
+ ~/.hermes/cache/scratch/agentus.sqlite.bak-$(date +%H%M%S)
101
+ ```
102
+
103
+ The one migration path in the project that rewrites rows in place — the legacy transcript
104
+ fold — takes its own backup first with `VACUUM INTO <db>.pre-fold.bak` and gates itself on
105
+ `pragma user_version`, so it runs once and is idempotent. If you see a `*.pre-fold.bak`
106
+ beside the store, that fold ran; the original is inside it.
107
+
108
+ Two cautions that come from real incidents:
109
+
110
+ - **Two independent writers on one WAL database** corrupted the operator's live `state.db`
111
+ while the runtime linked a SQLite in the WAL-reset bug range. The fix is the interpreter
112
+ (link SQLite ≥ 3.51.3), not a symlink, and it belongs to the Hermes Studio runtime, not to
113
+ this repo. Check it with `hermes doctor` and read the ⚠ lines — a warning is real, not
114
+ imaginary, but the shipped `hermes doctor` is warn-only. Never add a second writer to the
115
+ live home on a vulnerable build.
116
+ - `DELETE /api/sessions/<id>` **closes a live session and purges a cold one** — for an
117
+ already-dead row it deletes the record. So "archive this dead session" is not that route;
118
+ it takes a direct, backed-up, single-row transaction (`WHERE id=? AND status='error'`), no
119
+ live row touched, and no restart needed (a cold row is not in the server's in-memory map).
120
+
121
+ ## Is it serving the wrong store?
122
+
123
+ Symptoms that look like separate bugs but share one cause: the operator's session list came
124
+ back empty, TLS silently turned off (`tls off: no cert at <QA_DIR>/tls/cert.pem`), and a
125
+ running slot was reaped — yet the port answers 200 and `/api/version` looks fine. Cause:
126
+ `relaunch → launch.py` inherited the QA environment. The launcher now pins its own
127
+ `AGENTUS_DATA`/`AGENTUS_PORT` and pops the QA/DEV keys, but verify identity directly:
128
+
129
+ ```bash
130
+ PID=$(lsof -nP -iTCP:8787 -sTCP:LISTEN -t)
131
+ ps eww -p "$PID" | tr ' ' '\n' | grep AGENTUS # the process's own identity vars
132
+ lsof -p "$PID" | grep sqlite # which store it actually opened
133
+ ```
134
+
135
+ Recovery:
136
+
137
+ ```bash
138
+ cd worktrees/agentus
139
+ env -u AGENTUS_DATA -u AGENTUS_BASE -u DEV_LOG_PATH -u DEV_PROMOTE_DETACHED -u DEV_PROMOTE_SELF \
140
+ bash scripts/relaunch.sh --port 8787 --launcher ~/.hermes/cache/agentus/launch.py
141
+ TOK=$(cat packages/server/.data/auth.token)
142
+ curl -s -X POST -H "Authorization: Bearer $TOK" http://127.0.0.1:8787/api/sessions/<id>/resume
143
+ ```
144
+
145
+ After any restart, put the operator back on the air: `POST /api/sessions/<id>/resume` with the
146
+ machine token → `status: ready` and a fresh pid. **Capture the slot ids to resume before the
147
+ restart** — in the window right after a restart a slot has dropped from `live` into
148
+ `archived` wearing `error`, so a resume loop that reads the current `live` list finds
149
+ nothing. `resume` works on an `archived('error')` row; that is the cold-slot entry point.
150
+
151
+ ## Auth lockout
152
+
153
+ The login limiter is visible and reversible without a restart, so locking yourself out is a
154
+ one-click problem. Endpoints behind the login:
155
+
156
+ ```bash
157
+ TOK=$(cat <DATA_DIR>/auth.token)
158
+ curl -s -H "Authorization: Bearer $TOK" http://127.0.0.1:8787/api/auth/locked-ips
159
+ curl -s -X DELETE -H "Authorization: Bearer $TOK" http://127.0.0.1:8787/api/auth/locked-ips
160
+ ```
161
+
162
+ In the UI it is Settings → 登录失败锁定 (which IPs are locked, for how long, unlock / unlock
163
+ all). Tunables: `AGENTUS_LOGIN_MAX_FAILS` (default 5) / `AGENTUS_LOGIN_LOCK_MS` (default
164
+ 30000). Login sessions are stateless signed cookies indexed at `<DATA_DIR>/sessions.json`
165
+ (0600) — advisory for display, authoritative for existence. A password change invalidates
166
+ every other session; `AGENTUS_AUTH=off` disables the login entirely and the boot log scolds
167
+ you (do not leave it off — the listener is `0.0.0.0` and this cockpit is tunnelled).
168
+
169
+ ## TLS certificate regeneration
170
+
171
+ The TLS listener runs on a **second port** (8443 live), pointed at by a plain-TCP tunnel.
172
+ Cert material lives at `<DATA_DIR>/tls/`. Regenerate with:
173
+
174
+ ```bash
175
+ scripts/make-cert.sh [name] # default name: the DDNS name; SAN = that name + localhost + 127.0.0.1
176
+ scripts/make-cert.sh --leaf-only # rotate the leaf only; installed devices are untouched
177
+ ```
178
+
179
+ `make-cert.sh` issues **two** certs on purpose: a 10-year root (`ca.pem`, what devices install
180
+ once, served at `/cert.crt`) and a 390-day leaf (`cert.pem`/`key.pem`, what the listener
181
+ serves, under Apple's 398-day cap). Keep the SAN to the DDNS name you actually type, not the
182
+ dynamic public IP. It writes into the same directory the server reads from (mirroring the
183
+ server's own resolution in `packages/server/src/index.ts`), and if that resolution and the
184
+ server's ever diverge the listener stays silently off with a cert sitting next to it — after
185
+ regenerating, confirm the boot log still prints `https://…:8443`.
186
+
187
+ ## The process-exit safety net for children
188
+
189
+ The server installs `SIGINT`/`SIGTERM`/`SIGHUP` handlers that terminate the child agent
190
+ processes, so Ctrl-C (or a deliberate restart) takes its agents with it. The
191
+ `unhandledRejection`/`uncaughtException` guards are installed **only after `listen()`
192
+ succeeds** — installing them earlier would turn a startup failure (`EADDRINUSE`) into a
193
+ zombie that is "alive but not listening". Combined with the on-boot orphan reap, this is
194
+ what keeps a crashed server from leaving runaway children behind. If you find runaway
195
+ children anyway, hunt them by the process-group rule above — never by name.
196
+
197
+ Cross-links: [01-dev-loop.md](./01-dev-loop.md) (ports, data dirs) ·
198
+ [03-release.md](./03-release.md) (release + rollback) ·
199
+ [02-verify.md](./02-verify.md) (stray CDP tabs, leaked servers).