@rubytech/create-maxy-code 0.1.208 → 0.1.212
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/package.json +1 -1
- package/payload/platform/plugins/admin/skills/investigate/SKILL.md +15 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +39 -2
- package/payload/platform/plugins/admin/skills/task/SKILL.md +31 -0
- package/payload/platform/plugins/docs/references/platform.md +38 -1
- package/payload/platform/plugins/prompt-optimiser/skills/prompt-optimiser/SKILL.md +1 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +118 -45
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +44 -4
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +84 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +187 -13
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
package/package.json
CHANGED
|
@@ -135,6 +135,21 @@ If no specific artifact was provided, proceed directly to Phase 1.
|
|
|
135
135
|
task for the observability fix. Resume after instrumentation is
|
|
136
136
|
deployed and the issue is reproduced with proper evidence.
|
|
137
137
|
|
|
138
|
+
**Test for the no-event blind spot explicitly.** Transition-only
|
|
139
|
+
logging (a line per action) is structurally blind to any failure that
|
|
140
|
+
emits *no event and does not reproduce on demand* — a leak, an
|
|
141
|
+
orphan, a stuck or idle resource, a silently-failed cleanup, slow
|
|
142
|
+
drift. There is no action to hang a log on, and re-running the code
|
|
143
|
+
will not surface it. For **every failure mode you can name**, ask the
|
|
144
|
+
forcing question: *"what signal reveals it, without reproducing the
|
|
145
|
+
failure and without waiting for unrelated future activity?"* If the
|
|
146
|
+
answer is "none until something else happens," the missing
|
|
147
|
+
instrumentation is a **standing check — an independent periodic audit
|
|
148
|
+
that reconciles expected state against actual** — not another action
|
|
149
|
+
log, and that gap is itself the finding. Verify post-conditions,
|
|
150
|
+
never log intentions: "called the cleanup" is not evidence the
|
|
151
|
+
resource was freed.
|
|
152
|
+
|
|
138
153
|
Output: **"Root cause hypothesis: ..."** — a specific, testable claim
|
|
139
154
|
supported by cited evidence, plus the narrowest module the fix would
|
|
140
155
|
touch (informational; investigation does not edit source).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: platform-architecture
|
|
3
3
|
description: Use when grounding any claim about what Maxy is, how the platform is built, how plugins/skills/specialists work, how install/deploy runs, or any other product-architecture fact. The body of this skill is the only permissible source for such claims. Cite the `Source:` URL inline.
|
|
4
|
-
content-hash: sha256:
|
|
4
|
+
content-hash: sha256:f26ae06dff1c5b82b674893120da0454280946709391d6f9f79051f48e6f40e0
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Platform architecture (reference)
|
|
@@ -206,7 +206,44 @@ Each session row also carries a small muted timestamp crumb under the name showi
|
|
|
206
206
|
|
|
207
207
|
**Download JSONL (Task 197).** `GET /<id>/log?download=1` is a one-shot byte-stream of the session's JSONL transcript with attachment-disposition headers, designed for the pane's **Download JSONL** button. Headers: `Content-Type: application/x-ndjson`, `Content-Disposition: attachment; filename="<sessionId>.jsonl"` (the basename is sanitised so any non-`[A-Za-z0-9._-]` character is replaced with underscore), `Cache-Control: no-store`. Four status branches: **200** with the byte-identical file body; **404** `{error: 'session-not-found'}` when the store has no row for the id; **202** `{pending: true, jsonlPath: null}` when the row exists but claude has not flushed the first turn yet; **404** `{error: 'jsonl-missing-on-disk'}` when the row carries a `jsonlPath` but the file has been removed under the manager (post-Purge race). The download branch is declared **before** the follow check, so `?download=1` always wins over `?follow=1` if both are set. The proxy at `app.get('/:sessionId/log')` rebuilds the upstream query from a fixed `follow|download` allowlist; inbound query keys outside that allowlist are dropped. Observability: `[claude-session-manager] log-download sessionId=<sid> bytes=<n> ms=<n>` lands per successful stream completion; the browser console emits `[admin-ui] pane-download-jsonl sessionId=<8> outcome=initiated` on click. `outcome=initiated` rather than `outcome=ok` is intentional — the handler resolves before the browser writes the bytes, so the log line names "the request was kicked off", not "the file landed". If the file does not appear in the operator's downloads folder, check the manager line for the bytes count and the browser's downloads UI for the suppression record. Auth is unchanged from the rest of the `/api/admin/claude-sessions` surface (cookie session via `requireAdminSession`); there is no new key surface.
|
|
208
208
|
|
|
209
|
-
**
|
|
209
|
+
**Two spawn methods coexist (Task 557).** The manager runs two separate on-device spawn surfaces:
|
|
210
|
+
|
|
211
|
+
- **`claude rc` daemon** — spawned at platform boot by `rc-daemon.ts`, **`systemdSpawn`** path. Owns the long-lived composer session that backs claude.ai/code Remote Control. The script(1) TTY wrap (Task 556) inside the scope keeps the daemon resident even though the manager holds no fd. The activation gate below covers this path.
|
|
212
|
+
- **`claude --remote-control` on-device sidebar spawn** — spawned per-click by `/rc-spawn` in `http-server.ts`, **`spawnPty` (node-pty) + `systemd-run --scope`** path. Node-pty inside the scope owns the real `xterm-256color` TTY (120×40); the manager process holds the master fd **for the session's entire lifetime**. The pty master IS the live session — claude operates on the slave, and closing the master hangs up the slave (Task 557 evidence: claude exited ~0.8 s after `op=fd-release trigger=session-ready`). Task 558 deletes that release point. The only valid master-release points are now (1) the explicit operator teardown — `/stop` → `stopSession` → `op=archive-release` — and (2) the natural-exit path inside `pty.onExit → handlePtyNaturalExit`. Task 552's `systemdSpawn`-with-`stdio:'ignore'` swap broke this surface for five occasions because `--scope` units inherit the launcher's fds, so claude got `/dev/null` on all three streams and exited at the no-input branch in ~50 ms; Task 557 restored node-pty here and left the daemon path unchanged.
|
|
213
|
+
|
|
214
|
+
**`/rc-spawn` lifecycle observability (Tasks 557 + 558).** Every on-device sidebar resume emits a stream of `[rc-spawn]` lines tagged with the same `unitToken=rc-resume-<uuid>` so one spawn's full lifeline can be reconstructed by `grep` alone. The lines, in order:
|
|
215
|
+
|
|
216
|
+
| Step | Line shape |
|
|
217
|
+
|------|-----------|
|
|
218
|
+
| 1 | `[rc-spawn] op=request unitToken=<t> sessionId=<8|new> name=<…|none> mode=<resume|fresh> jsonl=<path|none>` |
|
|
219
|
+
| 2 | `[rc-spawn] op=argv unitToken=<t> cwd=<dir> argv=<json>` (inner claude argv; the `systemd-run --scope` wrap is composed by the spawnPty adapter) |
|
|
220
|
+
| 3 | `[rc-spawn] op=pty-spawned unitToken=<t> pid=<pid> openFds=<n>` (fd baseline) |
|
|
221
|
+
| 4 | `[rc-spawn] op=child-output unitToken=<t> pid=<pid> head=<json>` (first ≤1 KB or 500 ms idle — claude's own words: `Remote Control connecting…` on success, `No deferred tool marker` / `Unknown assignment` on failure) |
|
|
222
|
+
| 5 | `[rc-spawn] op=alive unitToken=<t> pid=<pid> ranMs=<n>` and `op=early-exit` mirror — `early-exit` fires when `pty.onExit` lands before the pid file (the no-TTY death signature) |
|
|
223
|
+
| 6 | `[rc-spawn] op=session-ready unitToken=<t> pid=<pid> pidFile=<path> bridgeId=<…>` — **terminal for the spawn path.** No `op=fd-release trigger=session-ready` follows. The tracker remains in `livePtys` for the session's lifetime. |
|
|
224
|
+
| 7 | `[pty-tracker] op=spawn sessionId=<8> pid=<pid> size=<n>` (also fires for spawnClaudeSession; same line shape on the rc-spawn path) |
|
|
225
|
+
| 8 | `[rc-spawn] op=exit unitToken=<t> pid=<pid> ranMs=<n>` paired with `[pty-tracker] op=exit` from `handlePtyNaturalExit` — fires when claude exits on its own (operator typed `/quit`, SIGINT in the PTY, crash). |
|
|
226
|
+
|
|
227
|
+
**Operator-archive release (Task 558).** When the operator clicks End in the UI, `/stop` → `stopSession` → `archiveReleaseTracker` emits a single verified release line:
|
|
228
|
+
|
|
229
|
+
`[rc-spawn] op=archive-release sessionId=<8> pid=<pid> master-fd=<closed|close-failed err=…> fdBefore=<n> fdAfter=<n> fdDelta=<n> removedFds=<list|none> trackerRemoved=<bool> verified=<bool>`
|
|
230
|
+
|
|
231
|
+
`verified=true` requires `master-fd=closed` AND `fdDelta>=1` AND `trackerRemoved=true`. `master-fd=close-failed` is logged at error level (`[rc-spawn-error]` prefix) — never swallowed; the next post-archive sweep is the catch-net.
|
|
232
|
+
|
|
233
|
+
**Post-archive fd sweep (Task 558).** Independent of spawn/archive request traffic, the manager runs a 60 s sweep that walks both directions of the master-fd invariant:
|
|
234
|
+
|
|
235
|
+
- `[fd-audit] op=orphan-master sessionId=<8> pid=<n> archivedAt=<ms> heldSinceArchiveMs=<n> fd=<n|unknown>` — fires per tracker whose row is archived (the leak).
|
|
236
|
+
- `[fd-audit] op=orphan-master-escalate sessionId=<8> fd=<n|unknown> heldSinceArchiveMs=<n>` — fires when `heldSinceArchiveMs ≥ 300 000` ms (5 min); strongest leak signal.
|
|
237
|
+
- `[fd-audit] op=post-archive-sweep archivedSessions=<n> orphanMasters=<n> openFds=<n> livePtys=<n>` — once per sweep.
|
|
238
|
+
- `[fd-audit] op=master-reconcile liveTrackers=<n> liveSessions=<n> archivedWithMaster=<n> orphanLiveSessionsNoMaster=<n>` — once per sweep. `archivedWithMaster>0` = fd leak; `orphanLiveSessionsNoMaster>0` = inverse defect (a live session whose master is gone — it cannot operate). Both are alarms.
|
|
239
|
+
|
|
240
|
+
The sweep is the catch-net for `master-fd=close-failed` and any future regression that orphans a tracker after archive. The steady-state `archivedWithMaster=0 orphanLiveSessionsNoMaster=0` is itself the signal the sweep ran.
|
|
241
|
+
|
|
242
|
+
**Manager-shutdown master-audit (Task 558).** On SIGTERM/SIGINT the manager emits `[manager-shutdown] op=master-audit held=<n> liveSessionsClosed=<n>` after walking `livePtys`. `held` is the count of trackers at shutdown entry; `liveSessionsClosed` is the subset whose master was destroyed by this shutdown. This is the data the out-of-scope "does manager restart kill on-device live sessions?" question is decided by — a logged number, not speculation.
|
|
243
|
+
|
|
244
|
+
`openFdCount()` reads `/proc/self/fd` directly on Linux and returns `-1` on darwin (the dev-Mac path). The fd-leak audit on the laptop: `~/maxy-code/platform/scripts/logs-read.sh --tail server 400 | grep -E '\[fd-audit\]|op=archive-release'`. Full per-spawn lifeline: `grep -E '\[rc-spawn\]|\[pty-tracker\]'` filtered by `unitToken`.
|
|
245
|
+
|
|
246
|
+
**Non-PTY scope spawn — activation gate (Tasks 552 + 554 + 555 + 556).** `claude rc` daemon spawns (and historically `/rc-spawn`, until Task 557 restored node-pty for that path) go through `systemdSpawn` (a `child_process.spawn` around `systemd-run --user --scope ...`, no PTY). The scope's inner command is `script -q -f -c "<claude cmd>" "<captureFilePath>"` (Task 556): `script(1)` allocates a real pty master and slave so claude runs on a TTY (without one, `claude --remote-control --resume` and `claude rc --spawn` early-exit because `--scope` units inherit the launcher's `stdio: 'ignore'` fds), and writes its typescript to a per-spawn capture file at `~/.<brand>/logs/spawn-<unitToken>.log`. The capture file holds the child's stdout/stderr and survives scope reaping, so a fast-exiting child's last words are recoverable by reading the file. (The prior Task 555 attempt — `StandardOutput=journal` + `SyslogIdentifier=` scope properties — was a no-op because those are `Exec*=` *service-unit* fields that systemd silently ignores on `--scope` units.) The activation gate polls `systemctl show -p ActiveState -p ControlGroup -p Result -p SubState` every 50 ms until either the scope reaches `ActiveState=active` with a pid in `cgroup.procs` (success — a handle is returned) or it reaches a terminal state. Four terminal outcomes are distinguished:
|
|
210
247
|
|
|
211
248
|
- **`boot-crash`** — `cgroup.procs` was observed non-empty at some point and is now empty: claude reached init and crashed. The observed pid is included.
|
|
212
249
|
- **`child-exited-during-activation`** — `cgroup.procs` was never observed populated and systemd reports `Result=success`: the child exited normally faster than the poll interval (50 ms). Task 555 added this branch to distinguish a clean fast-exit from a genuine systemd-side start failure. Pre-Task-556 the canonical trigger was a `--remote-control … --resume <sid>` spawn that received `/dev/null` on stdin and exited at the no-input branch in ~50 ms; with the script(1) TTY wrap in place, this branch now fires only for genuine fast-exits (a missing transcript, a bad `--resume` id, etc.).
|
|
@@ -162,6 +162,37 @@ How the change will be monitored once deployed. This section is
|
|
|
162
162
|
mandatory — code that cannot be observed in production is incomplete
|
|
163
163
|
code.
|
|
164
164
|
|
|
165
|
+
**Derive it first, adversarially — do not write this section last.**
|
|
166
|
+
Observability designed after the fix inherits the fix's blind spots;
|
|
167
|
+
designed first, it forces the fix to expose what would otherwise stay
|
|
168
|
+
hidden. Before writing prose, name **every way the change can fail**,
|
|
169
|
+
and treat the failures that emit *no event and do not reproduce on
|
|
170
|
+
demand* as first-class — they are the ones routinely missed. For
|
|
171
|
+
**each** failure mode, answer one forcing question and write the answer
|
|
172
|
+
in:
|
|
173
|
+
|
|
174
|
+
> **What signal reveals this failure — and does it fire (a) without
|
|
175
|
+
> reproducing the failure and (b) without waiting for unrelated future
|
|
176
|
+
> activity?**
|
|
177
|
+
|
|
178
|
+
The two clauses are the part repeatedly gotten wrong:
|
|
179
|
+
|
|
180
|
+
- **Presence ≠ adequacy.** A section that merely has "what to log /
|
|
181
|
+
signals / diagnostic path" passes a presence check while still being
|
|
182
|
+
blind. The test is not "is there a section" but "is every failure
|
|
183
|
+
mode answered by the forcing question."
|
|
184
|
+
- **Event-driven logging is structurally blind to no-event failures.**
|
|
185
|
+
A failure that triggers no action produces no log line, and re-running
|
|
186
|
+
the code will not surface it. Any such failure REQUIRES a **standing
|
|
187
|
+
check — an independent periodic audit that reconciles expected state
|
|
188
|
+
against actual** — not a log hung on some action. A task that names a
|
|
189
|
+
no-event failure mode but specifies no standing check to detect it is
|
|
190
|
+
inadequate by construction.
|
|
191
|
+
- **Verify outcomes, don't log intentions.** "Called the cleanup" is
|
|
192
|
+
not "the resource was freed." Log the *verified* post-condition (a
|
|
193
|
+
measured delta, the resource absent from the live set), and never
|
|
194
|
+
swallow the failure branch.
|
|
195
|
+
|
|
165
196
|
The observability section addresses:
|
|
166
197
|
|
|
167
198
|
- **What to log** — the specific events, state transitions, or error
|
|
@@ -98,7 +98,44 @@ Each session row also carries a small muted timestamp crumb under the name showi
|
|
|
98
98
|
|
|
99
99
|
**Download JSONL (Task 197).** `GET /<id>/log?download=1` is a one-shot byte-stream of the session's JSONL transcript with attachment-disposition headers, designed for the pane's **Download JSONL** button. Headers: `Content-Type: application/x-ndjson`, `Content-Disposition: attachment; filename="<sessionId>.jsonl"` (the basename is sanitised so any non-`[A-Za-z0-9._-]` character is replaced with underscore), `Cache-Control: no-store`. Four status branches: **200** with the byte-identical file body; **404** `{error: 'session-not-found'}` when the store has no row for the id; **202** `{pending: true, jsonlPath: null}` when the row exists but claude has not flushed the first turn yet; **404** `{error: 'jsonl-missing-on-disk'}` when the row carries a `jsonlPath` but the file has been removed under the manager (post-Purge race). The download branch is declared **before** the follow check, so `?download=1` always wins over `?follow=1` if both are set. The proxy at `app.get('/:sessionId/log')` rebuilds the upstream query from a fixed `follow|download` allowlist; inbound query keys outside that allowlist are dropped. Observability: `[claude-session-manager] log-download sessionId=<sid> bytes=<n> ms=<n>` lands per successful stream completion; the browser console emits `[admin-ui] pane-download-jsonl sessionId=<8> outcome=initiated` on click. `outcome=initiated` rather than `outcome=ok` is intentional — the handler resolves before the browser writes the bytes, so the log line names "the request was kicked off", not "the file landed". If the file does not appear in the operator's downloads folder, check the manager line for the bytes count and the browser's downloads UI for the suppression record. Auth is unchanged from the rest of the `/api/admin/claude-sessions` surface (cookie session via `requireAdminSession`); there is no new key surface.
|
|
100
100
|
|
|
101
|
-
**
|
|
101
|
+
**Two spawn methods coexist (Task 557).** The manager runs two separate on-device spawn surfaces:
|
|
102
|
+
|
|
103
|
+
- **`claude rc` daemon** — spawned at platform boot by `rc-daemon.ts`, **`systemdSpawn`** path. Owns the long-lived composer session that backs claude.ai/code Remote Control. The script(1) TTY wrap (Task 556) inside the scope keeps the daemon resident even though the manager holds no fd. The activation gate below covers this path.
|
|
104
|
+
- **`claude --remote-control` on-device sidebar spawn** — spawned per-click by `/rc-spawn` in `http-server.ts`, **`spawnPty` (node-pty) + `systemd-run --scope`** path. Node-pty inside the scope owns the real `xterm-256color` TTY (120×40); the manager process holds the master fd **for the session's entire lifetime**. The pty master IS the live session — claude operates on the slave, and closing the master hangs up the slave (Task 557 evidence: claude exited ~0.8 s after `op=fd-release trigger=session-ready`). Task 558 deletes that release point. The only valid master-release points are now (1) the explicit operator teardown — `/stop` → `stopSession` → `op=archive-release` — and (2) the natural-exit path inside `pty.onExit → handlePtyNaturalExit`. Task 552's `systemdSpawn`-with-`stdio:'ignore'` swap broke this surface for five occasions because `--scope` units inherit the launcher's fds, so claude got `/dev/null` on all three streams and exited at the no-input branch in ~50 ms; Task 557 restored node-pty here and left the daemon path unchanged.
|
|
105
|
+
|
|
106
|
+
**`/rc-spawn` lifecycle observability (Tasks 557 + 558).** Every on-device sidebar resume emits a stream of `[rc-spawn]` lines tagged with the same `unitToken=rc-resume-<uuid>` so one spawn's full lifeline can be reconstructed by `grep` alone. The lines, in order:
|
|
107
|
+
|
|
108
|
+
| Step | Line shape |
|
|
109
|
+
|------|-----------|
|
|
110
|
+
| 1 | `[rc-spawn] op=request unitToken=<t> sessionId=<8|new> name=<…|none> mode=<resume|fresh> jsonl=<path|none>` |
|
|
111
|
+
| 2 | `[rc-spawn] op=argv unitToken=<t> cwd=<dir> argv=<json>` (inner claude argv; the `systemd-run --scope` wrap is composed by the spawnPty adapter) |
|
|
112
|
+
| 3 | `[rc-spawn] op=pty-spawned unitToken=<t> pid=<pid> openFds=<n>` (fd baseline) |
|
|
113
|
+
| 4 | `[rc-spawn] op=child-output unitToken=<t> pid=<pid> head=<json>` (first ≤1 KB or 500 ms idle — claude's own words: `Remote Control connecting…` on success, `No deferred tool marker` / `Unknown assignment` on failure) |
|
|
114
|
+
| 5 | `[rc-spawn] op=alive unitToken=<t> pid=<pid> ranMs=<n>` and `op=early-exit` mirror — `early-exit` fires when `pty.onExit` lands before the pid file (the no-TTY death signature) |
|
|
115
|
+
| 6 | `[rc-spawn] op=session-ready unitToken=<t> pid=<pid> pidFile=<path> bridgeId=<…>` — **terminal for the spawn path.** No `op=fd-release trigger=session-ready` follows. The tracker remains in `livePtys` for the session's lifetime. |
|
|
116
|
+
| 7 | `[pty-tracker] op=spawn sessionId=<8> pid=<pid> size=<n>` (also fires for spawnClaudeSession; same line shape on the rc-spawn path) |
|
|
117
|
+
| 8 | `[rc-spawn] op=exit unitToken=<t> pid=<pid> ranMs=<n>` paired with `[pty-tracker] op=exit` from `handlePtyNaturalExit` — fires when claude exits on its own (operator typed `/quit`, SIGINT in the PTY, crash). |
|
|
118
|
+
|
|
119
|
+
**Operator-archive release (Task 558).** When the operator clicks End in the UI, `/stop` → `stopSession` → `archiveReleaseTracker` emits a single verified release line:
|
|
120
|
+
|
|
121
|
+
`[rc-spawn] op=archive-release sessionId=<8> pid=<pid> master-fd=<closed|close-failed err=…> fdBefore=<n> fdAfter=<n> fdDelta=<n> removedFds=<list|none> trackerRemoved=<bool> verified=<bool>`
|
|
122
|
+
|
|
123
|
+
`verified=true` requires `master-fd=closed` AND `fdDelta>=1` AND `trackerRemoved=true`. `master-fd=close-failed` is logged at error level (`[rc-spawn-error]` prefix) — never swallowed; the next post-archive sweep is the catch-net.
|
|
124
|
+
|
|
125
|
+
**Post-archive fd sweep (Task 558).** Independent of spawn/archive request traffic, the manager runs a 60 s sweep that walks both directions of the master-fd invariant:
|
|
126
|
+
|
|
127
|
+
- `[fd-audit] op=orphan-master sessionId=<8> pid=<n> archivedAt=<ms> heldSinceArchiveMs=<n> fd=<n|unknown>` — fires per tracker whose row is archived (the leak).
|
|
128
|
+
- `[fd-audit] op=orphan-master-escalate sessionId=<8> fd=<n|unknown> heldSinceArchiveMs=<n>` — fires when `heldSinceArchiveMs ≥ 300 000` ms (5 min); strongest leak signal.
|
|
129
|
+
- `[fd-audit] op=post-archive-sweep archivedSessions=<n> orphanMasters=<n> openFds=<n> livePtys=<n>` — once per sweep.
|
|
130
|
+
- `[fd-audit] op=master-reconcile liveTrackers=<n> liveSessions=<n> archivedWithMaster=<n> orphanLiveSessionsNoMaster=<n>` — once per sweep. `archivedWithMaster>0` = fd leak; `orphanLiveSessionsNoMaster>0` = inverse defect (a live session whose master is gone — it cannot operate). Both are alarms.
|
|
131
|
+
|
|
132
|
+
The sweep is the catch-net for `master-fd=close-failed` and any future regression that orphans a tracker after archive. The steady-state `archivedWithMaster=0 orphanLiveSessionsNoMaster=0` is itself the signal the sweep ran.
|
|
133
|
+
|
|
134
|
+
**Manager-shutdown master-audit (Task 558).** On SIGTERM/SIGINT the manager emits `[manager-shutdown] op=master-audit held=<n> liveSessionsClosed=<n>` after walking `livePtys`. `held` is the count of trackers at shutdown entry; `liveSessionsClosed` is the subset whose master was destroyed by this shutdown. This is the data the out-of-scope "does manager restart kill on-device live sessions?" question is decided by — a logged number, not speculation.
|
|
135
|
+
|
|
136
|
+
`openFdCount()` reads `/proc/self/fd` directly on Linux and returns `-1` on darwin (the dev-Mac path). The fd-leak audit on the laptop: `~/maxy-code/platform/scripts/logs-read.sh --tail server 400 | grep -E '\[fd-audit\]|op=archive-release'`. Full per-spawn lifeline: `grep -E '\[rc-spawn\]|\[pty-tracker\]'` filtered by `unitToken`.
|
|
137
|
+
|
|
138
|
+
**Non-PTY scope spawn — activation gate (Tasks 552 + 554 + 555 + 556).** `claude rc` daemon spawns (and historically `/rc-spawn`, until Task 557 restored node-pty for that path) go through `systemdSpawn` (a `child_process.spawn` around `systemd-run --user --scope ...`, no PTY). The scope's inner command is `script -q -f -c "<claude cmd>" "<captureFilePath>"` (Task 556): `script(1)` allocates a real pty master and slave so claude runs on a TTY (without one, `claude --remote-control --resume` and `claude rc --spawn` early-exit because `--scope` units inherit the launcher's `stdio: 'ignore'` fds), and writes its typescript to a per-spawn capture file at `~/.<brand>/logs/spawn-<unitToken>.log`. The capture file holds the child's stdout/stderr and survives scope reaping, so a fast-exiting child's last words are recoverable by reading the file. (The prior Task 555 attempt — `StandardOutput=journal` + `SyslogIdentifier=` scope properties — was a no-op because those are `Exec*=` *service-unit* fields that systemd silently ignores on `--scope` units.) The activation gate polls `systemctl show -p ActiveState -p ControlGroup -p Result -p SubState` every 50 ms until either the scope reaches `ActiveState=active` with a pid in `cgroup.procs` (success — a handle is returned) or it reaches a terminal state. Four terminal outcomes are distinguished:
|
|
102
139
|
|
|
103
140
|
- **`boot-crash`** — `cgroup.procs` was observed non-empty at some point and is now empty: claude reached init and crashed. The observed pid is included.
|
|
104
141
|
- **`child-exited-during-activation`** — `cgroup.procs` was never observed populated and systemd reports `Result=success`: the child exited normally faster than the poll interval (50 ms). Task 555 added this branch to distinguish a clean fast-exit from a genuine systemd-side start failure. Pre-Task-556 the canonical trigger was a `--remote-control … --resume <sid>` spawn that received `/dev/null` on stdin and exited at the no-input branch in ~50 ms; with the script(1) TTY wrap in place, this branch now fires only for genuine fast-exits (a missing transcript, a bad `--resume` id, etc.).
|
|
@@ -19,6 +19,7 @@ This skill is invoked two ways, and they want opposite outputs. Decide which one
|
|
|
19
19
|
- Write it as plain prose or a short bulleted brief. No fenced code block. No role line, no XML tags, no length-cap ceremony unless the user asked for one.
|
|
20
20
|
- Do not end with "Think before answering". Do not ask the user to paste anything — you already have the content and the context.
|
|
21
21
|
- Do not invent requirements or add scope the user did not ask for. If the prompt was already clear, say so in one line and restate it tightly.
|
|
22
|
+
- The restated brief is bound by the three doctrines (see `/doctrine`): precise/concise/plain, no speculation, observability-first. If the user's wording carries a speculative claim or skips an observability step the doctrines require, the restatement corrects it rather than echoing the slip.
|
|
22
23
|
- End the restated brief with this exact sentence as its own final line: "Delegate this task to the appropriate specialist." Apply on every restatement, without exception. The owning specialist is identified from the AGENTS.md roster loaded alongside this seat — the closing line is the trigger, not the classifier (Task 529).
|
|
23
24
|
|
|
24
25
|
Then do the work from the restated task. Everything below this section is for the other mode and does not apply — stop reading here.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { type SpawnDeps } from './pty-spawner.js';
|
|
3
|
-
import {
|
|
3
|
+
import type { SystemdScopeHandle } from './systemd-spawn.js';
|
|
4
4
|
import type { FsWatcher } from './fs-watcher.js';
|
|
5
5
|
import type { RateLimiter } from './spawn-rate-limiter.js';
|
|
6
6
|
import type { AuditRegistry } from './public-tool-audit.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAM3B,OAAO,
|
|
1
|
+
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAM3B,OAAO,EAgBL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAa5D,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,iBAAiB,CAAA;AAE5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAA;AA+E9E,eAAO,MAAM,kBAAkB,QAA2B,CAAA;AAI1D,MAAM,WAAW,QAAS,SAAQ,IAAI,CAAC,SAAS,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAC7E,OAAO,EAAE,SAAS,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,iBAAiB,EAAE,MAAM,CAAA;IACzB,kBAAkB,EAAE,WAAW,CAAA;IAC/B,eAAe,EAAE,aAAa,CAAA;IAC9B;kFAC8E;IAC9E,cAAc,EAAE,cAAc,CAAA;IAC9B;;;;2DAIuD;IACvD,YAAY,EAAE,CAAC,MAAM,EAAE;QACrB,SAAS,EAAE,MAAM,CAAA;QACjB,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;QAC7B,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;QACrC,GAAG,EAAE,MAAM,CAAA;QACX,SAAS,EAAE,MAAM,CAAA;QACjB,eAAe,EAAE,MAAM,CAAA;KACxB,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAA;CAClC;AA4LD;;;kEAGkE;AAClE,MAAM,MAAM,OAAO,GAAG,IAAI,GAAG;IAC3B,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACpD;;;+CAG2C;IAC3C,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAChD,CAAA;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAm2CpD"}
|
|
@@ -24,11 +24,11 @@ import { stream } from 'hono/streaming';
|
|
|
24
24
|
import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, mkdirSync, renameSync, readFileSync } from 'node:fs';
|
|
25
25
|
import { randomUUID } from 'node:crypto';
|
|
26
26
|
import { buildRcChildEnv } from './rc-daemon.js';
|
|
27
|
-
import { spawnClaudeSession, stopSession, writeInputToPty, isLive, onPtyExit, livePtyCount, getPtyTrackerForTests, livePidForSession, priorExitedCountForSession, PERMISSION_MODES, resolveLiveMemory, } from './pty-spawner.js';
|
|
27
|
+
import { spawnClaudeSession, stopSession, writeInputToPty, isLive, onPtyExit, livePtyCount, getPtyTrackerForTests, livePidForSession, priorExitedCountForSession, PERMISSION_MODES, resolveLiveMemory, openFdCount, registerRcSpawnPty, } from './pty-spawner.js';
|
|
28
28
|
import { readSidecar, updateSidecar, appendBridgeId } from './session-sidecar.js';
|
|
29
29
|
import { readJsonlSession } from './jsonl-enumerator.js';
|
|
30
30
|
import { claudeStateRoot, projectSlugForCwd, jsonlPathForSessionId, sidecarPathForSessionId, permissionModeLogPathForSessionId, findExistingJsonlForSessionId, } from './jsonl-path.js';
|
|
31
|
-
import { basename,
|
|
31
|
+
import { basename, join } from 'node:path';
|
|
32
32
|
import { validateUserTitle } from './user-title-store.js';
|
|
33
33
|
const ROLES = ['admin', 'public'];
|
|
34
34
|
const CHANNELS = ['browser', 'whatsapp', 'telegram', 'webchat', 'email'];
|
|
@@ -1406,11 +1406,25 @@ export function buildHttpApp(deps) {
|
|
|
1406
1406
|
// Task 543 — fire-and-forget `claude --remote-control [name] [--session-id <sid>]`
|
|
1407
1407
|
// spawn for the admin sessions pane. The new PTY registers itself in
|
|
1408
1408
|
// claude.ai/code as its own Remote Control entry; reconciliation with the
|
|
1409
|
-
// rc-daemon is out of scope. The
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
1409
|
+
// rc-daemon is out of scope. The PID lands in
|
|
1410
|
+
// `<CLAUDE_CONFIG_DIR>/sessions/<pid>.json` via claude's own bootstrap,
|
|
1411
|
+
// which is how the sidebar list keeps `live` accurate and how the delete
|
|
1412
|
+
// route later finds the PID to SIGTERM.
|
|
1413
|
+
//
|
|
1414
|
+
// Task 557 — restored the node-pty spawn path (Task 552 had swapped it
|
|
1415
|
+
// for `systemdSpawn` with `stdio:'ignore'`, which inherited /dev/null on
|
|
1416
|
+
// all three streams; `claude --remote-control --resume` early-exited at
|
|
1417
|
+
// the no-input branch in ~50 ms). The manager now owns a real PTY for
|
|
1418
|
+
// the rc-spawn child — `xterm-256color` inside `systemd-run --scope` —
|
|
1419
|
+
// and registers it via `registerRcSpawnPty` so the master fd has a
|
|
1420
|
+
// defined owner and release path. Lifecycle observability is the bulk
|
|
1421
|
+
// of the change: every spawn writes `[rc-spawn]` lines tagged with the
|
|
1422
|
+
// `unitToken` for request, argv, pty-spawned, child-output (first
|
|
1423
|
+
// ≤1 KB), alive / early-exit, session-ready, fd-release,
|
|
1424
|
+
// survives-fd-release, and exit, plus an `[fd-census]` snapshot of
|
|
1425
|
+
// `openFdCount()` vs `livePtyCount()` after fd-release. This ends the
|
|
1426
|
+
// 5-occasion blindness — the next failure on this surface is
|
|
1427
|
+
// diagnosable from `server.log` alone.
|
|
1414
1428
|
app.post('/rc-spawn', async (c) => {
|
|
1415
1429
|
const start = Date.now();
|
|
1416
1430
|
let body;
|
|
@@ -1429,10 +1443,7 @@ export function buildHttpApp(deps) {
|
|
|
1429
1443
|
// Task 547 — when a JSONL already exists for the requested sessionId,
|
|
1430
1444
|
// resume the existing conversation instead of forking a fresh one
|
|
1431
1445
|
// under the same id. `--session-id` is claude's fresh-spawn flag;
|
|
1432
|
-
// `--resume` is the continue-this-conversation flag.
|
|
1433
|
-
// Resume button posts an existing sessionId; without this branch it
|
|
1434
|
-
// would mint a new conversation under that id and abandon the prior
|
|
1435
|
-
// transcript.
|
|
1446
|
+
// `--resume` is the continue-this-conversation flag.
|
|
1436
1447
|
const existingJsonlPath = sessionId ? findExistingJsonlForSessionId(deps.claudeConfigDir, sessionId) : null;
|
|
1437
1448
|
const mode = existingJsonlPath ? 'resume' : 'fresh';
|
|
1438
1449
|
const argv = ['--remote-control'];
|
|
@@ -1449,49 +1460,111 @@ export function buildHttpApp(deps) {
|
|
|
1449
1460
|
brandedNeo4jPassword: process.env.NEO4J_PASSWORD ?? '',
|
|
1450
1461
|
});
|
|
1451
1462
|
const unitToken = `rc-resume-${randomUUID()}`;
|
|
1452
|
-
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
|
|
1463
|
+
const sessionIdForLog = sessionId ? sessionId.slice(0, 8) : 'new';
|
|
1464
|
+
// Step 1 — request received, before any spawn work. The argv-resolved
|
|
1465
|
+
// line (step 2) emits next, so a 500 between them isolates resolution
|
|
1466
|
+
// failures from spawn failures.
|
|
1467
|
+
deps.logger(`[rc-spawn] op=request unitToken=${unitToken} sessionId=${sessionIdForLog} name=${name ?? 'none'} mode=${mode} jsonl=${existingJsonlPath ?? 'none'}`);
|
|
1468
|
+
// Step 2 — the exact claude argv (the inner command). The systemd-run
|
|
1469
|
+
// wrap is composed by the spawnPty adapter; logging the inner argv
|
|
1470
|
+
// here is enough for an operator to reproduce by hand. The wrapped
|
|
1471
|
+
// argv is fully reconstructible from this line + the spawn adapter
|
|
1472
|
+
// source (Task 250 / 556).
|
|
1473
|
+
deps.logger(`[rc-spawn] op=argv unitToken=${unitToken} cwd=${deps.spawnCwd} argv=${JSON.stringify([deps.claudeBin, ...argv])}`);
|
|
1474
|
+
let pty;
|
|
1457
1475
|
try {
|
|
1458
|
-
|
|
1459
|
-
// Record<string,string>, and buildRcChildEnv may carry undefined
|
|
1460
|
-
// values through from process.env spreading.
|
|
1461
|
-
const cleanEnv = {};
|
|
1462
|
-
for (const [k, v] of Object.entries(env)) {
|
|
1463
|
-
if (typeof v === 'string')
|
|
1464
|
-
cleanEnv[k] = v;
|
|
1465
|
-
}
|
|
1466
|
-
handle = await deps.systemdSpawn({
|
|
1467
|
-
claudeBin: deps.claudeBin,
|
|
1468
|
-
claudeArgs: argv,
|
|
1469
|
-
env: cleanEnv,
|
|
1470
|
-
cwd: deps.spawnCwd,
|
|
1471
|
-
unitToken,
|
|
1472
|
-
// Task 556 — per-spawn script(1) typescript file; sibling to
|
|
1473
|
-
// ~/.<brand>/logs/server.log so post-mortem greps land in one tree.
|
|
1474
|
-
captureFilePath: join(dirname(deps.claudeConfigDir), 'logs', `spawn-${unitToken}.log`),
|
|
1475
|
-
});
|
|
1476
|
+
pty = deps.spawnPty(deps.claudeBin, argv, env, unitToken);
|
|
1476
1477
|
}
|
|
1477
1478
|
catch (err) {
|
|
1478
1479
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1479
1480
|
deps.logger(`[sessions-rc-resume] spawn-failed sessionId=${sessionId ?? 'new'} name=${name ?? 'none'} err=${msg}`);
|
|
1481
|
+
deps.logger(`[rc-spawn] op=spawn-failed unitToken=${unitToken} err=${JSON.stringify(msg)}`);
|
|
1480
1482
|
timed(deps.logger, 'POST', '/rc-spawn', 500, Date.now() - start);
|
|
1481
1483
|
return c.json({ error: msg }, 500);
|
|
1482
1484
|
}
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
1485
|
-
|
|
1486
|
-
//
|
|
1487
|
-
//
|
|
1488
|
-
//
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
|
|
1492
|
-
|
|
1485
|
+
// Step 3 — PTY spawned. fd baseline at this instant; the fd-release
|
|
1486
|
+
// line later in the lifecycle is read relative to this value.
|
|
1487
|
+
deps.logger(`[rc-spawn] op=pty-spawned unitToken=${unitToken} pid=${pty.pid} openFds=${openFdCount()}`);
|
|
1488
|
+
// Tracker registration — closes the leak gap the Task 552 path opened.
|
|
1489
|
+
// For fresh spawns without a caller-supplied sessionId we register the
|
|
1490
|
+
// tracker under the unitToken so the entry has a stable key; the
|
|
1491
|
+
// operator-visible sessionId only becomes known when claude writes its
|
|
1492
|
+
// pid file. Both keys are equally good for fd-ownership purposes.
|
|
1493
|
+
const trackerKey = sessionId ?? `rc-${unitToken}`;
|
|
1494
|
+
const tracker = registerRcSpawnPty({ logger: deps.logger }, trackerKey, pty, unitToken);
|
|
1495
|
+
// Step 4 — first child output. Accumulate up to 1 KB or until 500 ms
|
|
1496
|
+
// after the first byte arrives, then emit one line. The single log
|
|
1497
|
+
// that ends the 5-occasion blindness: claude's own words land here
|
|
1498
|
+
// — `Remote Control` on success, `No deferred tool marker` /
|
|
1499
|
+
// `Unknown assignment` on failure.
|
|
1500
|
+
let outputBuf = '';
|
|
1501
|
+
let outputEmitted = false;
|
|
1502
|
+
let outputTimer = null;
|
|
1503
|
+
const emitChildOutput = () => {
|
|
1504
|
+
if (outputEmitted)
|
|
1505
|
+
return;
|
|
1506
|
+
outputEmitted = true;
|
|
1507
|
+
if (outputTimer) {
|
|
1508
|
+
clearTimeout(outputTimer);
|
|
1509
|
+
outputTimer = null;
|
|
1510
|
+
}
|
|
1511
|
+
deps.logger(`[rc-spawn] op=child-output unitToken=${unitToken} pid=${pty.pid} head=${JSON.stringify(outputBuf)}`);
|
|
1512
|
+
};
|
|
1513
|
+
pty.onData((data) => {
|
|
1514
|
+
if (outputEmitted)
|
|
1515
|
+
return;
|
|
1516
|
+
const remaining = 1024 - outputBuf.length;
|
|
1517
|
+
outputBuf += remaining >= data.length ? data : data.slice(0, remaining);
|
|
1518
|
+
if (outputBuf.length >= 1024)
|
|
1519
|
+
emitChildOutput();
|
|
1520
|
+
else if (!outputTimer)
|
|
1521
|
+
outputTimer = setTimeout(emitChildOutput, 500);
|
|
1522
|
+
});
|
|
1523
|
+
// Step 5/10 — exit observation. `pidFileSeen` discriminates `early-exit`
|
|
1524
|
+
// (no pid file ever appeared — the no-TTY death signature) from `exit`
|
|
1525
|
+
// (clean termination after a normal run). `handlePtyNaturalExit` (wired
|
|
1526
|
+
// by `registerRcSpawnPty`) handles the tracker side — fd release +
|
|
1527
|
+
// tracker delete + the `kill … reason=process-exited` log.
|
|
1528
|
+
let pidFileSeen = false;
|
|
1529
|
+
pty.onExit((e) => {
|
|
1530
|
+
const ranMs = Date.now() - start;
|
|
1531
|
+
if (!pidFileSeen) {
|
|
1532
|
+
deps.logger(`[rc-spawn] op=early-exit unitToken=${unitToken} pid=${pty.pid} ranMs=${ranMs} exitCode=${e.exitCode} signal=${e.signal ?? 'none'}`);
|
|
1533
|
+
}
|
|
1534
|
+
deps.logger(`[rc-spawn] op=exit unitToken=${unitToken} pid=${pty.pid} ranMs=${ranMs}`);
|
|
1535
|
+
// Flush any buffered child output that never reached 1 KB nor the
|
|
1536
|
+
// 500 ms idle deadline before the child exited.
|
|
1537
|
+
if (!outputEmitted)
|
|
1538
|
+
emitChildOutput();
|
|
1539
|
+
});
|
|
1540
|
+
// Task 558 — session-ready is terminal for the spawn path. The PTY
|
|
1541
|
+
// master IS the live session, not a reclaimable spawn artifact:
|
|
1542
|
+
// claude operates on the slave, and closing the master hangs up the
|
|
1543
|
+
// slave (Task 557 evidence: claude exits ~0.8s after `op=fd-release
|
|
1544
|
+
// trigger=session-ready`). The master must stay open for the slave
|
|
1545
|
+
// to function. The only valid release points are the explicit
|
|
1546
|
+
// operator teardown — `/stop` → `stopSession` → `releasePtyMasterFd`
|
|
1547
|
+
// — and the natural-exit path inside `pty.onExit →
|
|
1548
|
+
// handlePtyNaturalExit`. The tracker remains in `livePtys` for the
|
|
1549
|
+
// session's lifetime.
|
|
1550
|
+
void deps.watcher
|
|
1551
|
+
.waitForPid(pty.pid, deps.pidFileTimeoutMs)
|
|
1552
|
+
.then((row) => {
|
|
1553
|
+
pidFileSeen = true;
|
|
1554
|
+
const aliveMs = Date.now() - start;
|
|
1555
|
+
deps.logger(`[rc-spawn] op=alive unitToken=${unitToken} pid=${pty.pid} ranMs=${aliveMs}`);
|
|
1556
|
+
deps.logger(`[rc-spawn] op=session-ready unitToken=${unitToken} pid=${pty.pid} pidFile=${row.pidFilePath ?? 'unknown'} bridgeId=${row.bridgeSessionId ?? 'unknown'}`);
|
|
1557
|
+
})
|
|
1558
|
+
.catch((err) => {
|
|
1559
|
+
deps.logger(`[rc-spawn] op=wait-pid-failed unitToken=${unitToken} pid=${pty.pid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
1560
|
+
});
|
|
1561
|
+
// Task 552 marker preserved for grep continuity; the spawn method is
|
|
1562
|
+
// now node-pty + systemd-scope (Task 557), but the operator-visible
|
|
1563
|
+
// log key stays `spawnMethod=systemd-scope` because every rc-spawn
|
|
1564
|
+
// child still runs inside a `systemd-run --user --scope` unit.
|
|
1565
|
+
deps.logger(`[sessions-rc-resume] mode=${mode} sessionId=${sessionId ?? 'none'} jsonlPath=${existingJsonlPath ?? 'none'} name=${name ?? 'none'} spawnedPid=${pty.pid} bridgeId=pending spawnMethod=systemd-scope env.CLAUDE_CONFIG_DIR=${deps.claudeConfigDir}`);
|
|
1493
1566
|
timed(deps.logger, 'POST', '/rc-spawn', 200, Date.now() - start);
|
|
1494
|
-
return c.json({ spawnedPid:
|
|
1567
|
+
return c.json({ spawnedPid: pty.pid, sessionId: sessionId ?? null });
|
|
1495
1568
|
});
|
|
1496
1569
|
return app;
|
|
1497
1570
|
}
|