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.
- package/README.md +19 -2
- package/README.zh-CN.md +396 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/package.json +5 -4
- package/packages/server/mock/agent.mjs +35 -0
- package/packages/server/src/fs.ts +216 -2
- package/packages/server/src/index.ts +87 -5
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/term.ts +69 -28
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-B52l55_4.css +0 -1
- package/packages/web/dist/assets/index-BRgC7oYt.js +0 -246
|
@@ -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).
|