@adrrr/tarmac 0.2.0 → 0.4.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 +130 -55
- package/dist/fleet.js +45 -1
- package/dist/history.js +88 -0
- package/dist/install.js +44 -12
- package/dist/limits.js +62 -0
- package/dist/map.js +119 -0
- package/dist/reap.js +9 -4
- package/dist/render.js +851 -33
- package/dist/schema.js +1 -1
- package/dist/server.js +85 -4
- package/dist/sessions.js +36 -2
- package/dist/snapshots.js +19 -2
- package/dist/wrapper.js +101 -19
- package/package.json +2 -2
package/dist/schema.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import http from 'node:http';
|
|
6
6
|
import { reason, renderLive, renderPage } from './render.js';
|
|
7
7
|
import { SOURCE_PHRASE } from './config.js';
|
|
8
|
+
import { createHistory, HISTORY_CADENCE_MS } from './history.js';
|
|
8
9
|
/**
|
|
9
10
|
* On every answer, including the refusals and the 500s. The page swaps what this port returns
|
|
10
11
|
* into `innerHTML`, and loopback proves where an answer came from, never who wrote it: a
|
|
@@ -14,8 +15,41 @@ import { SOURCE_PHRASE } from './config.js';
|
|
|
14
15
|
* failures carry it too: their text is what it quotes as the reason.
|
|
15
16
|
*/
|
|
16
17
|
const IDENTITY = { 'x-tarmac': '1' };
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
/** The addresses that serve the shell, and which view each one opens on. */
|
|
19
|
+
const PAGES = new Map([
|
|
20
|
+
['/', 'table'],
|
|
21
|
+
['/map', 'map'],
|
|
22
|
+
]);
|
|
23
|
+
export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS }) {
|
|
24
|
+
// What this serve has already read, kept for a day and never written down. `since` is the
|
|
25
|
+
// moment this server was made, not the first sample that landed: the span it covers is how
|
|
26
|
+
// long the process has been up, and an hour of it with nothing in it is a fact worth
|
|
27
|
+
// showing rather than an empty record pretending to be a young one.
|
|
28
|
+
const history = createHistory({ since: Date.now(), cadence: sampleEveryMs });
|
|
29
|
+
// One at a time. `claude agents --json` has a 15s deadline of its own, and a fleet slower
|
|
30
|
+
// than a slot would otherwise be answered with a queue of processes instead of one missed
|
|
31
|
+
// minute — the tick that finds a read still running counts the slot and stands down.
|
|
32
|
+
let reading = false;
|
|
33
|
+
const sample = async () => {
|
|
34
|
+
if (reading) {
|
|
35
|
+
history.miss(Date.now());
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
reading = true;
|
|
39
|
+
try {
|
|
40
|
+
history.record(await collect());
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// A collector that throws is the normal weather here: `claude` missing, a laptop that
|
|
44
|
+
// was asleep. It costs a slot and nothing else — a throw out of this timer would be an
|
|
45
|
+
// unhandled rejection, and `serve` runs unattended for hours.
|
|
46
|
+
history.miss(Date.now());
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
reading = false;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const server = http.createServer(async (req, res) => {
|
|
19
53
|
// Loopback binding alone does not stop a DNS-rebinding page in the user's own browser
|
|
20
54
|
// from reading /api/fleet — which carries cwd paths, session ids and costs.
|
|
21
55
|
if (!isLoopbackHost(req.headers.host)) {
|
|
@@ -38,11 +72,35 @@ export function createFleetServer({ collect }) {
|
|
|
38
72
|
const url = new URL(req.url, 'http://localhost');
|
|
39
73
|
// `/live` is what the open page asks for every few seconds: the same render as `/`, minus
|
|
40
74
|
// the shell. Serving the whole page there would hand the running script a copy of itself.
|
|
41
|
-
|
|
75
|
+
//
|
|
76
|
+
// `/map` is the same page opened on the other view, and deliberately not `/?view=map`:
|
|
77
|
+
// the tabs are plain links, so the view has to be somewhere a reload and a bookmark can
|
|
78
|
+
// both find it. There is no second fragment — one `/live` carries both views, which is
|
|
79
|
+
// what keeps them from ever showing readings of different ages.
|
|
80
|
+
if (!PAGES.has(url.pathname) &&
|
|
81
|
+
url.pathname !== '/live' &&
|
|
82
|
+
url.pathname !== '/api/fleet' &&
|
|
83
|
+
url.pathname !== '/api/history') {
|
|
42
84
|
res.writeHead(404, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
|
|
43
85
|
res.end('not found\n');
|
|
44
86
|
return;
|
|
45
87
|
}
|
|
88
|
+
// Served out of the ring, above the collect below and never through it: this route is
|
|
89
|
+
// what the serve has ALREADY read, and one that collected would let a scrubber spawn
|
|
90
|
+
// `claude agents --json` on every drag of its handle.
|
|
91
|
+
if (url.pathname === '/api/history') {
|
|
92
|
+
res.writeHead(200, {
|
|
93
|
+
...IDENTITY,
|
|
94
|
+
'content-type': 'application/json; charset=utf-8',
|
|
95
|
+
'cache-control': 'no-store',
|
|
96
|
+
});
|
|
97
|
+
// Not indented, alone among the JSON answers here. `/api/fleet` pretty-prints ONE
|
|
98
|
+
// reading, which a person reads in a terminal; this is up to 1440 of them, where the
|
|
99
|
+
// indentation is 40% of a body no human will ever open — megabytes of whitespace on
|
|
100
|
+
// every poll of the replay.
|
|
101
|
+
res.end(JSON.stringify(history.read()));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
46
104
|
// Read AND render inside the guard, and send nothing until there is something to send.
|
|
47
105
|
// Writing the 200 first and rendering after made the collector's failure a 500 and the
|
|
48
106
|
// renderer's failure a dead daemon: the headers were already on the wire, the throw
|
|
@@ -58,7 +116,7 @@ export function createFleetServer({ collect }) {
|
|
|
58
116
|
}
|
|
59
117
|
else {
|
|
60
118
|
type = 'text/html; charset=utf-8';
|
|
61
|
-
body = url.pathname === '/live' ? renderLive(fleet) : renderPage(fleet);
|
|
119
|
+
body = url.pathname === '/live' ? renderLive(fleet) : renderPage(fleet, PAGES.get(url.pathname));
|
|
62
120
|
}
|
|
63
121
|
}
|
|
64
122
|
catch (e) {
|
|
@@ -72,6 +130,29 @@ export function createFleetServer({ collect }) {
|
|
|
72
130
|
res.writeHead(200, { ...IDENTITY, 'content-type': type, 'cache-control': 'no-store' });
|
|
73
131
|
res.end(body);
|
|
74
132
|
});
|
|
133
|
+
// The sampler lives exactly as long as the serving does. Started at construction it kept
|
|
134
|
+
// reading the fleet for a server whose `listen` had refused — a port named on the command
|
|
135
|
+
// line and taken — into a ring no request could ever reach; and never cleared, it did the
|
|
136
|
+
// same for every server a suite had closed behind it.
|
|
137
|
+
//
|
|
138
|
+
// `close` is the only shutdown this module has. `tarmac serve` itself has no graceful one:
|
|
139
|
+
// Ctrl-C ends the process, which takes the timer with it. Unref'ing is the belt to that
|
|
140
|
+
// braces — it keeps the sampler from being a reason `node --test`, or anything else that
|
|
141
|
+
// embeds this server, stays alive — and it is deliberately not the thing relied on.
|
|
142
|
+
let sampler = null;
|
|
143
|
+
server.on('listening', () => {
|
|
144
|
+
if (sampler !== null)
|
|
145
|
+
return;
|
|
146
|
+
sampler = setInterval(() => void sample(), sampleEveryMs);
|
|
147
|
+
sampler.unref();
|
|
148
|
+
});
|
|
149
|
+
server.on('close', () => {
|
|
150
|
+
if (sampler === null)
|
|
151
|
+
return;
|
|
152
|
+
clearInterval(sampler);
|
|
153
|
+
sampler = null;
|
|
154
|
+
});
|
|
155
|
+
return server;
|
|
75
156
|
}
|
|
76
157
|
/**
|
|
77
158
|
* How far past a port NOBODY CHOSE `serve` may walk. A corridor, not a search: wide enough
|
package/dist/sessions.js
CHANGED
|
@@ -6,10 +6,36 @@
|
|
|
6
6
|
// Design rule carried over from the fleet's "3rd blindness": a status we do not recognise
|
|
7
7
|
// is `null`, never `false`. A release that renames `busy` must make Tarmac say "I don't
|
|
8
8
|
// know", not "everything is calm" — the second is a silent outage, the first is a signal.
|
|
9
|
+
/**
|
|
10
|
+
* What each word this surface prints says about the one question the boolean asks: is this
|
|
11
|
+
* session working. A word that does not answer it is absent, and absent means `null` — "we do
|
|
12
|
+
* not know" — which is the whole point of the file.
|
|
13
|
+
*
|
|
14
|
+
* The first two arrive on a session with a process of its own; the other two are a background
|
|
15
|
+
* agent's `state`, which is where its word lives instead.
|
|
16
|
+
*
|
|
17
|
+
* Some words are left out ON PURPOSE rather than for want of a payload. `failed` and `stopped`
|
|
18
|
+
* are "not working", and that is the least interesting true thing about them; `blocked` and
|
|
19
|
+
* `waiting` are a session halted until a human answers something, where `false` reads as calm
|
|
20
|
+
* on a session that needs you and `true` as fine on one that has stopped. Unknown is the only
|
|
21
|
+
* bucket whose node prints the word itself, so those keep it: an amber node captioned `failed`
|
|
22
|
+
* says what neither boolean could.
|
|
23
|
+
*/
|
|
9
24
|
const KNOWN_STATUS = new Map([
|
|
10
25
|
['busy', true],
|
|
11
26
|
['idle', false],
|
|
27
|
+
['working', true],
|
|
28
|
+
['done', false],
|
|
12
29
|
]);
|
|
30
|
+
/**
|
|
31
|
+
* The word for a session halted until a human answers, and the only one with a field saying
|
|
32
|
+
* which answer. It stays out of the map above — the boolean it would have to fill still has
|
|
33
|
+
* no honest value — but it is no longer counted as a word we failed to recognise: it has a
|
|
34
|
+
* state of its own on both surfaces, and a reason to print beside it.
|
|
35
|
+
*/
|
|
36
|
+
const WAITING = 'waiting';
|
|
37
|
+
/** Whether this reading is halted on a human. The one status the renderers treat as a state. */
|
|
38
|
+
export const isWaiting = (s) => s.status === WAITING;
|
|
13
39
|
/** @param text raw stdout of `claude agents --json` */
|
|
14
40
|
export function parseAgents(text) {
|
|
15
41
|
let raw;
|
|
@@ -32,9 +58,16 @@ export function parseAgents(text) {
|
|
|
32
58
|
const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId : null;
|
|
33
59
|
if (!sessionId)
|
|
34
60
|
health.noSessionId += 1;
|
|
35
|
-
|
|
61
|
+
// A background agent carries no `status` at all — its word is under `state`. `status`
|
|
62
|
+
// still wins where both are present: it comes from the agent's own process, and `state`
|
|
63
|
+
// is what the dispatcher believes about an agent whose process may not be on this machine.
|
|
64
|
+
const status = typeof entry.status === 'string' ? entry.status : typeof entry.state === 'string' ? entry.state : null;
|
|
36
65
|
const busy = KNOWN_STATUS.has(status) ? KNOWN_STATUS.get(status) : null;
|
|
37
|
-
|
|
66
|
+
// Two different questions, and only the second one is a blind spot: `busy` is null here
|
|
67
|
+
// for a word we cannot answer the boolean with, and `waiting` is one of those — while
|
|
68
|
+
// being a word this tool knows by name. Counting it would put "reports a status tarmac
|
|
69
|
+
// does not know" on the banner over a session tarmac is drawing, captioned, as waiting.
|
|
70
|
+
if (busy === null && status !== WAITING)
|
|
38
71
|
health.unknownStatus += 1;
|
|
39
72
|
sessions.push({
|
|
40
73
|
sessionId,
|
|
@@ -44,6 +77,7 @@ export function parseAgents(text) {
|
|
|
44
77
|
kind: typeof entry.kind === 'string' ? entry.kind : null,
|
|
45
78
|
startedAt: typeof entry.startedAt === 'number' ? entry.startedAt : null,
|
|
46
79
|
status,
|
|
80
|
+
waitingFor: typeof entry.waitingFor === 'string' ? entry.waitingFor : null,
|
|
47
81
|
busy,
|
|
48
82
|
});
|
|
49
83
|
}
|
package/dist/snapshots.js
CHANGED
|
@@ -102,8 +102,25 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
|
|
|
102
102
|
mtimeMs = fs.statSync(file).mtimeMs;
|
|
103
103
|
payload = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
104
104
|
}
|
|
105
|
-
catch {
|
|
106
|
-
|
|
105
|
+
catch (e) {
|
|
106
|
+
// ENOENT: a name listed a moment ago that resolves to nothing now. Almost always the
|
|
107
|
+
// sweep, deleting a cold snapshot out of the very directory we are reading — its job,
|
|
108
|
+
// and a race with our own housekeeping rather than a payload we failed to parse.
|
|
109
|
+
// Counting it made tarmac drive its own format-drift warning (up to 2675 phantom
|
|
110
|
+
// unreadable on one read of a 20k directory, and `list --watch` and `serve` redraw
|
|
111
|
+
// often enough to be inside that window).
|
|
112
|
+
//
|
|
113
|
+
// The cost, said out loud: `statSync` follows symlinks, so a DANGLING one named like a
|
|
114
|
+
// snapshot is ENOENT too, and it goes silent forever — a permanent state skipped as if
|
|
115
|
+
// it were a passing race. Deliberate. There is no payload behind a dead link either,
|
|
116
|
+
// and telling the two apart (an `lstat` first) buys a warning about a file `ls` already
|
|
117
|
+
// shows. Note it is the opposite call from `reap.ts:75`, which lstats PRECISELY so a
|
|
118
|
+
// dead link is not ENOENT: it deletes, and `unlink` takes a link away just fine. Reader
|
|
119
|
+
// and reaper ask different questions of the same shape.
|
|
120
|
+
//
|
|
121
|
+
// ENOENT only. A file we were not ALLOWED to open still counts, and must.
|
|
122
|
+
if (e.code !== 'ENOENT')
|
|
123
|
+
unreadable += 1; // corrupt, half-written or unreadable: skip, but never forget
|
|
107
124
|
continue;
|
|
108
125
|
}
|
|
109
126
|
const t = extractTelemetry(payload);
|
package/dist/wrapper.js
CHANGED
|
@@ -16,13 +16,16 @@
|
|
|
16
16
|
// below is in the POSIX shell command language, and `test/portability.test.ts` runs this
|
|
17
17
|
// script under every POSIX shell present on the machine to keep it that way.
|
|
18
18
|
//
|
|
19
|
-
//
|
|
19
|
+
// Three invariants, all tested by running the real script:
|
|
20
20
|
// RULE 1 — never break the display. Missing chain, failing chain, unwritable directory:
|
|
21
21
|
// the status line still renders and the exit code is still 0. Telemetry loses,
|
|
22
22
|
// display wins, always.
|
|
23
23
|
// RULE 2 — never write outside the snapshot directory. `session_id` is external input
|
|
24
24
|
// that becomes a filename, so anything that is not UUID-shaped is REFUSED, not
|
|
25
25
|
// sanitised: a guessed name would be read back later as if it were certain.
|
|
26
|
+
// RULE 3 — the sweep may remove exactly what the writer may write, no more and no less.
|
|
27
|
+
// One rule, `SID_GLOB`, read by both — because when those two sets are merely
|
|
28
|
+
// written to agree, they stop agreeing quietly, in both directions at once (#7).
|
|
26
29
|
/**
|
|
27
30
|
* Prefix of every temp file the wrapper writes, and the ONLY thing that proves tarmac
|
|
28
31
|
* wrote one. `.<sid>.<pid>.tmp` — what this used to emit — is a convention, not a
|
|
@@ -55,21 +58,54 @@ export const PRUNE_EVERY_MIN = 60;
|
|
|
55
58
|
*/
|
|
56
59
|
export const SNAPSHOT_TTL_MIN = 48 * 60;
|
|
57
60
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* this script never wrote, deleted from inside a status line.
|
|
61
|
+
* A session id — ONE rule, and the only one the writer below, the sweep below and the
|
|
62
|
+
* TypeScript that reads this directory are allowed to know. Written as a shell pattern
|
|
63
|
+
* because two of the three consumers are shell; the third derives its regex from it.
|
|
62
64
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
65
|
+
* It is the canonical UUID, 8-4-4-4-12 hex: every fixture in this repo, every file in the
|
|
66
|
+
* snapshot directory of the fleet this was built for, every transcript file observed. The
|
|
67
|
+
* statusline payload documents `session_id` only as a "unique session identifier", so that
|
|
68
|
+
* is an observation and not a promise — and the direction of the bet is deliberate. An id
|
|
69
|
+
* that is not a UUID is refused at write time, which surfaces as a live session with
|
|
70
|
+
* `absent` telemetry: a state `fleet.ts` already names and shows. The other bet — file
|
|
71
|
+
* whatever arrives, and widen the deleters to match — would put every stem of 8..64
|
|
72
|
+
* characters of `[0-9A-Za-z-]` within reach of `rm`, in a directory whose location comes
|
|
73
|
+
* from `XDG_STATE_HOME` and can therefore be `~/.claude` itself, where the legacy purge
|
|
74
|
+
* already deletes and where people keep a git repository. A missing row is recoverable.
|
|
75
|
+
*
|
|
76
|
+
* Bracket expressions, not `?`: `?` matches a leading dot (fnmatch without FNM_PERIOD), so
|
|
77
|
+
* the old glob reached dotfiles the writer's own charset forbids it to produce (#7).
|
|
78
|
+
*
|
|
79
|
+
* And an ENUMERATION, not the range `[0-9a-fA-F]`: a range is collated by the locale, which
|
|
80
|
+
* for a status line is whatever the TUI that spawned it carries. Under `en_US.UTF-8` — the
|
|
81
|
+
* ordinary case on macOS, where `/bin/sh` is bash — `a-f` reaches `é`, `ç` and fullwidth `a`
|
|
82
|
+
* in bash, in ksh and in BSD `find`, while the regex below is ASCII code points and always
|
|
83
|
+
* will be. That is one string meaning two different sets depending on `LANG`, which is this
|
|
84
|
+
* whole rule undone: a sid filed in a Terminal, refused under `LC_ALL=C`, and a file written
|
|
85
|
+
* by the first frame that no TypeScript consumer here can ever recognise. Sixteen digits
|
|
86
|
+
* spelled out roughly doubles the pattern — 356 characters to 772 — and costs nothing
|
|
87
|
+
* measurable per frame.
|
|
66
88
|
*/
|
|
67
|
-
|
|
89
|
+
const HEX = '[0123456789abcdefABCDEF]';
|
|
90
|
+
export const SID_GLOB = [8, 4, 4, 4, 12].map((n) => HEX.repeat(n)).join('-');
|
|
68
91
|
/**
|
|
69
|
-
* The
|
|
70
|
-
*
|
|
92
|
+
* The names the sweep below is allowed to remove: the sid rule, and a `.json`. NOT `*.json` —
|
|
93
|
+
* that would take a `settings.json` or a `fleet.json` sitting next to them, data this script
|
|
94
|
+
* never wrote, deleted from inside a status line.
|
|
71
95
|
*/
|
|
72
|
-
export const
|
|
96
|
+
export const SNAPSHOT_GLOB = `${SID_GLOB}.json`;
|
|
97
|
+
/**
|
|
98
|
+
* The same rule, in Node — the pattern goes in RAW, because a bracket expression means the
|
|
99
|
+
* same set in both languages and `-` is literal in both. Only the extension is spelled twice,
|
|
100
|
+
* once per language, which is the one thing a translation could get wrong and so the one
|
|
101
|
+
* thing that is written out rather than derived.
|
|
102
|
+
*
|
|
103
|
+
* `SID_NAME` is the sid alone: `fleet.ts` asks it whether a LIVE session's id is one the
|
|
104
|
+
* wrapper would ever file, which is the difference between "no frame drawn yet" and "no frame
|
|
105
|
+
* will ever help" — two states that look identical on a row.
|
|
106
|
+
*/
|
|
107
|
+
export const SID_NAME = new RegExp(`^${SID_GLOB}$`);
|
|
108
|
+
export const SNAPSHOT_NAME = new RegExp(`^${SID_GLOB}\\.json$`);
|
|
73
109
|
/** Single-quotes a string for POSIX sh. */
|
|
74
110
|
function shQuote(s) {
|
|
75
111
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
@@ -108,16 +144,15 @@ case "$payload" in
|
|
|
108
144
|
esac
|
|
109
145
|
;;
|
|
110
146
|
esac
|
|
111
|
-
#
|
|
147
|
+
# Refuse anything that is not a session id — this value becomes a filename, and it is the
|
|
148
|
+
# same rule the sweep below deletes by: what this line declines to write, that one cannot
|
|
149
|
+
# unlink, and the reverse. An empty sid matches nothing here, so it stays empty.
|
|
112
150
|
case "$sid" in
|
|
113
|
-
|
|
151
|
+
${SID_GLOB}) ;;
|
|
152
|
+
*) sid='' ;;
|
|
114
153
|
esac
|
|
115
154
|
# refuse an ambiguous payload outright
|
|
116
155
|
[ "$payload_has_two_ids" = 1 ] && sid=''
|
|
117
|
-
if [ -n "$sid" ]; then
|
|
118
|
-
len=\${#sid}
|
|
119
|
-
if [ "$len" -lt 8 ] || [ "$len" -gt 64 ]; then sid=''; fi
|
|
120
|
-
fi
|
|
121
156
|
|
|
122
157
|
# --- drop the snapshot (best effort, atomic: temp file + rename in the same dir) ---
|
|
123
158
|
if [ -n "$sid" ] && mkdir -p "$TARMAC_DIR" 2>/dev/null; then
|
|
@@ -149,6 +184,53 @@ fi
|
|
|
149
184
|
# frame. (A directory where the stamp itself keeps failing does pay one \`touch\` per frame,
|
|
150
185
|
# forever — a fork, not a directory walk, and it means nothing can be written there anyway.)
|
|
151
186
|
#
|
|
187
|
+
# And DETACHED, because amortized is an average and the average was never the problem: on an
|
|
188
|
+
# install that has never pruned, the one frame that sweeps pays for the entire backlog at
|
|
189
|
+
# once — a directory walk plus ten thousand unlinks on 20 000 snapshots, measured at 0.5 s in
|
|
190
|
+
# the report and at 0.6-0.9 s by \`test/sweep-perf.test.ts\`, in front of the status line (#8).
|
|
191
|
+
# Bounding the work per sweep instead would only spread that cost, at one bounded batch an
|
|
192
|
+
# hour, over weeks of frames that each still stop to walk the
|
|
193
|
+
# same directory; the frame has no business waiting for any of it. So the sweep is handed to a
|
|
194
|
+
# child and the frame goes on to the chain: the frame's cost becomes one fork, whatever the
|
|
195
|
+
# directory holds, and NOTHING on the nominal path — a frame with no sweep due — changes at all.
|
|
196
|
+
# What that costs, all of it on the sweep's side of the fork:
|
|
197
|
+
# • the child outlives the frame. It is orphaned when the shell exits and reaped by init;
|
|
198
|
+
# nothing waits for it, so no zombie can accumulate in the TUI's process tree.
|
|
199
|
+
# • if the session dies mid-sweep, the sweep normally finishes on its own: it holds no state
|
|
200
|
+
# but the marker, already stamped, and an orphan is not interrupted by the death of its
|
|
201
|
+
# parent. It does NOT go away with a Ctrl-C either — POSIX has the shell set SIGINT and
|
|
202
|
+
# SIGQUIT to ignored in an asynchronous list where job control is off, which is here. Only
|
|
203
|
+
# a signal aimed at the process group (a SIGHUP or a SIGTERM from whatever supervises the
|
|
204
|
+
# TUI) takes it, and then what is left is what the next hour's sweep will find, which is
|
|
205
|
+
# where it was heading anyway.
|
|
206
|
+
# • the redirections are not hygiene, they are the point. A child that inherits the frame's
|
|
207
|
+
# stdout puts whatever it prints INTO the status line, and holds the pipe open after this
|
|
208
|
+
# shell has exited — the reader waits on EOF, so the frame would still block, having only
|
|
209
|
+
# moved where. \`</dev/null\` is the belt to those braces: POSIX already hands an
|
|
210
|
+
# asynchronous list \`/dev/null\` for stdin wherever job control is off — every shell that
|
|
211
|
+
# will ever run this — and stdin was drained by \`\$(cat)\` at the top anyway.
|
|
212
|
+
# • two frames that reach the marker check together both sweep, as they always could. The
|
|
213
|
+
# window is not the \`touch\` — it is the \`find\` that reads the marker's age, a whole
|
|
214
|
+
# process, which is why two frames drawn simultaneously fork two sweeps almost every time.
|
|
215
|
+
# Detaching improves that case rather than widening it: what used to be two frozen frames
|
|
216
|
+
# is now two detached walks. What matters is that a frame drawn AFTER a sweep has started
|
|
217
|
+
# sees a stamped marker and starts nothing, which is the ordinary case and the reason the
|
|
218
|
+
# stamping stayed in the frame rather than moving into the child. Across HOURS they can
|
|
219
|
+
# overlap — a sweep slower than the window is joined by the
|
|
220
|
+
# next one — and that is harmless: \`rm -f\` on a name another sweep already unlinked is
|
|
221
|
+
# not an error, and the marker is the only state either of them writes.
|
|
222
|
+
# • a \`find\` that HANGS (a stale network mount, a directory that never answers) is no longer
|
|
223
|
+
# one frozen frame; it is one orphaned process per hour that nothing here reaps. The trade
|
|
224
|
+
# is deliberate — a frozen frame breaks RULE 1, an idle process does not — but it is
|
|
225
|
+
# unbounded over time in a way the blocking shape was not.
|
|
226
|
+
# • the chain now runs BESIDE the sweep instead of after it, so anything reading this same
|
|
227
|
+
# directory can watch a file vanish under it mid-frame. Nothing here breaks on that — the
|
|
228
|
+
# display is untouched and no data is lost — but it is not free either: \`snapshots.ts\`
|
|
229
|
+
# counts a file that disappears between its \`readdir\` and its \`readFile\` as UNREADABLE,
|
|
230
|
+
# and \`list\`/\`serve\` report that as "the schema may have moved". Same window on the
|
|
231
|
+
# blocking shape (a sweep and a reader have always been able to overlap), so this is not
|
|
232
|
+
# a regression, and it is the READER's to fix: #17.
|
|
233
|
+
#
|
|
152
234
|
# Two known holes, both of them the safe way round, and both inherited from the fleet script
|
|
153
235
|
# this transposes:
|
|
154
236
|
# • a marker dated in the FUTURE (clock skew, a restored backup, a network mount) is never
|
|
@@ -189,7 +271,7 @@ if [ -d "$TARMAC_DIR" ]; then
|
|
|
189
271
|
# The glob is the sid SHAPE, not \`*.json\`, and that is the same rule \`reap.ts\` states
|
|
190
272
|
# for the temp files: only what we wrote — see SNAPSHOT_GLOB, which the legacy purge in
|
|
191
273
|
# \`install.ts\` reads from the same constant.
|
|
192
|
-
find "$TARMAC_DIR"/. ! -name . -prune -name '${SNAPSHOT_GLOB}' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} +
|
|
274
|
+
find "$TARMAC_DIR"/. ! -name . -prune -name '${SNAPSHOT_GLOB}' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + >/dev/null 2>&1 </dev/null &
|
|
193
275
|
fi
|
|
194
276
|
fi
|
|
195
277
|
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adrrr/tarmac",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
37
37
|
"build": "npm run clean && tsc -p tsconfig.build.json",
|
|
38
38
|
"typecheck": "tsc -p tsconfig.json",
|
|
39
|
-
"test": "npm run typecheck && node --test \"test/*.test.ts\"",
|
|
39
|
+
"test": "npm run typecheck && node --test --test-timeout=120000 --test-force-exit \"test/*.test.ts\"",
|
|
40
40
|
"fixtures:capture": "node scripts/capture-fixtures.ts",
|
|
41
41
|
"prepack": "npm run build",
|
|
42
42
|
"prepublishOnly": "npm run build && TARMAC_REQUIRE_DASH=1 npm test && node dist/cli.js --help > /dev/null"
|