axstack 0.40.0 → 0.40.1
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 +2 -1
- package/docs/host-operations.md +29 -13
- package/docs/installation.md +3 -2
- package/package.json +1 -1
- package/skills/axstack/scripts/single-server.js +123 -24
package/README.md
CHANGED
|
@@ -131,7 +131,8 @@ Eligible own PRs use guarded automatic merge; see [merge boundaries, exclusions,
|
|
|
131
131
|
database. The exceptions are exactly two opt-in systemd user timers:
|
|
132
132
|
`axstack-default-sync` rewrites only T3 `defaultModelSelection`, and
|
|
133
133
|
`axstack-single-server` removes duplicate `t3 serve` process trees for one
|
|
134
|
-
base dir while keeping the
|
|
134
|
+
base dir while keeping the ownership-lock holder, otherwise the recorded
|
|
135
|
+
server. Both run stateless scripts, keep
|
|
135
136
|
no workflow state and launch no agent; see
|
|
136
137
|
[New-chat default sync](docs/host-operations.md#new-chat-default-sync) and
|
|
137
138
|
[Single T3 server](docs/host-operations.md#single-t3-server).
|
package/docs/host-operations.md
CHANGED
|
@@ -315,12 +315,21 @@ comes from its `--base-dir <dir>` or `--base-dir=<dir>` flag, then its own
|
|
|
315
315
|
`T3CODE_HOME`, then its own `$HOME/.t3`, reading the latter values from that
|
|
316
316
|
process's environment. An unreadable or missing environment without an explicit
|
|
317
317
|
flag, or missing base-dir values, makes the whole run hold and kill nothing.
|
|
318
|
-
|
|
318
|
+
Both the target and server base dirs expand `~` using their own HOME, resolve
|
|
319
|
+
relative paths against `/proc/<pid>/cwd`, and remove trailing slashes and dot
|
|
320
|
+
segments before comparison. Unresolvable server paths hold the run.
|
|
321
|
+
It keeps the unique server with an open fd to `<base>/userdata/server-owner.sqlite`
|
|
322
|
+
(matching lexical and realpath-resolved userdata paths, and checking the main pid
|
|
323
|
+
before descendants), holds on multiple holders or a known
|
|
324
|
+
holder alongside unreadable competing fds, and falls back to `server-runtime.json`
|
|
325
|
+
when no holder is found, including when fd directories are unreadable.
|
|
319
326
|
With zero or one known matching server it does nothing; with duplicates but no
|
|
320
|
-
valid readable keeper it holds.
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
327
|
+
valid readable keeper it holds. Servers are ordered by field 22 (`starttime`)
|
|
328
|
+
of `/proc/<pid>/stat`. A duplicate started at or after the keeper holds the
|
|
329
|
+
whole run while younger than 120 seconds, preserving a possible starting
|
|
330
|
+
replacement. Once all such duplicates reach the grace, the selected live
|
|
331
|
+
keeper wins and cleanup proceeds. Age is `/proc/uptime` minus start ticks / 100;
|
|
332
|
+
unknown uptime or start time still holds the run. The script also
|
|
324
333
|
holds if its own pid or an ancestor appears in the planned victim set.
|
|
325
334
|
Otherwise it snapshots descendants before killing each duplicate server first,
|
|
326
335
|
then its children with SIGKILL. The keeper and its descendants are protected.
|
|
@@ -333,12 +342,14 @@ bun ~/.agents/skills/axstack/scripts/single-server.js --dry-run
|
|
|
333
342
|
```
|
|
334
343
|
|
|
335
344
|
`--base-dir <dir>` selects a different base dir; `--proc-root <dir>` supplies
|
|
336
|
-
a process-tree fixture (default `/proc`). `--
|
|
345
|
+
a process-tree fixture (default `/proc`). `--grace-seconds N` overrides the
|
|
346
|
+
120-second grace with a non-negative integer. `--help` lists the flags. Decisions
|
|
337
347
|
are `unchanged`, `hold`, `would-kill` or `killed`, with reason, keeper, matching
|
|
338
348
|
server pids and killed pids (proposed victims in dry-run). Non-ESRCH kill failures
|
|
339
|
-
add their pids to `failed`; remaining kills continue,
|
|
340
|
-
notify if configured, and the command exits 1. Exit 0 includes holds;
|
|
341
|
-
exit 1 reports invalid input, discovery, kill or notification failure.
|
|
349
|
+
add their pids to `failed`; remaining kills continue, kill attempts still
|
|
350
|
+
notify if configured (including when all fail), and the command exits 1. Exit 0 includes holds;
|
|
351
|
+
exit 1 reports invalid input, discovery, kill or notification failure. A failed
|
|
352
|
+
hold-alert notification retains the hold reason and adds `notify: "failed"`.
|
|
342
353
|
|
|
343
354
|
Under host-mutation authority, copy both assets and enable the timer:
|
|
344
355
|
|
|
@@ -357,7 +368,7 @@ timer starts one minute after user-manager startup and repeats every minute.
|
|
|
357
368
|
Override Bun/skills paths or add `--base-dir` through the service's `ExecStart`
|
|
358
369
|
drop-in, clearing the existing `ExecStart` first.
|
|
359
370
|
|
|
360
|
-
To notify
|
|
371
|
+
To notify after cleanup attempts and persistent holds, use `systemctl --user edit axstack-single-server.service`
|
|
361
372
|
and add a per-host command, then reload:
|
|
362
373
|
|
|
363
374
|
```ini
|
|
@@ -369,10 +380,15 @@ The VPS has Hermes locally; other hosts can use
|
|
|
369
380
|
`Environment="AXSTACK_SINGLE_SERVER_NOTIFY=ssh '<relay-host>' hermes send --to telegram --quiet"`.
|
|
370
381
|
Replace `<relay-host>` with the authorized gateway's hostname.
|
|
371
382
|
The script runs the command via `sh -c`, sending a short message on stdin with
|
|
372
|
-
hostname, kept pid and
|
|
383
|
+
hostname, kept pid, killed pids and failed survivor pids. It notes that runs started by the killed
|
|
373
384
|
server may remain `running` and need `t3_thread_interrupt`. Notification failure
|
|
374
|
-
exits 1
|
|
375
|
-
|
|
385
|
+
exits 1; command output is suppressed. A hold with two or more matching servers
|
|
386
|
+
also notifies when the youngest server's known age is at least 600 seconds and
|
|
387
|
+
under 780 seconds. Its message includes host, hold reason, keeper, server pids,
|
|
388
|
+
and that duplicates are still running. This stateless window produces at most
|
|
389
|
+
about three alerts at the one-minute cadence; it records no notification state.
|
|
390
|
+
Unknown ages suppress hold alerts. Dry-run and unchanged decisions never
|
|
391
|
+
notify. Verify delivery under the host's notification authority.
|
|
376
392
|
|
|
377
393
|
Disable with `systemctl --user disable --now axstack-single-server.timer` and
|
|
378
394
|
stop any active one-shot with `systemctl --user stop axstack-single-server.service`.
|
package/docs/installation.md
CHANGED
|
@@ -90,8 +90,9 @@ Run these with Bun from the installed `<skills-dir>/axstack/scripts/` directory:
|
|
|
90
90
|
`resolve-models.js` (model IDs), `pick-instance.js` (dispatch account),
|
|
91
91
|
`pr-digest.js` (PR discovery), `archive-evidence.js` (evidence archive), and
|
|
92
92
|
`sync-default.js [--settings <path>] [--dry-run]` (New-chat default; `AXSTACK_SYNC_POOL` opts into cross-driver rotation).
|
|
93
|
-
`single-server.js [--base-dir <dir>] [--proc-root <dir>] [--dry-run]` removes
|
|
94
|
-
duplicate `t3 serve` process trees while keeping the
|
|
93
|
+
`single-server.js [--base-dir <dir>] [--proc-root <dir>] [--grace-seconds N] [--dry-run]` removes
|
|
94
|
+
duplicate `t3 serve` process trees while keeping the ownership-lock holder,
|
|
95
|
+
otherwise the recorded runtime pid.
|
|
95
96
|
`account-usage.js` shares usage retrieval and the five-minute cache between
|
|
96
97
|
picker and sync. The opt-in systemd user unit files ship in `axstack/systemd/`;
|
|
97
98
|
installation copies them as assets and never enables either timer.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "axstack",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.1",
|
|
4
4
|
"description": "Axstack installer and setup CLI: installs owned chat skills and role data, configures supported harness settings, and checks T3 Code capabilities.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
|
|
3
|
+
function normalizeBase(procRoot, pid, base, home) {
|
|
4
|
+
if (!base) return null;
|
|
5
|
+
if (base === '~' || base.startsWith('~/')) {
|
|
6
|
+
if (!home) return null;
|
|
7
|
+
base = home + base.slice(1);
|
|
8
|
+
}
|
|
9
|
+
if (!base.startsWith('/')) {
|
|
10
|
+
let cwd;
|
|
11
|
+
try { cwd = fs.readlinkSync(`${procRoot}/${pid}/cwd`); }
|
|
12
|
+
catch { return null; }
|
|
13
|
+
if (!cwd.startsWith('/')) return null;
|
|
14
|
+
base = `${cwd}/${base}`;
|
|
15
|
+
}
|
|
16
|
+
const parts = [];
|
|
17
|
+
for (const part of base.split('/')) {
|
|
18
|
+
if (part === '..') parts.pop();
|
|
19
|
+
else if (part && part !== '.') parts.push(part);
|
|
20
|
+
}
|
|
21
|
+
return `/${parts.join('/')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
3
24
|
function serverBase(procRoot, pid, argv) {
|
|
4
25
|
let base;
|
|
5
26
|
for (let i = 2; i < argv.length; i++) {
|
|
6
27
|
if (argv[i] === '--base-dir') base = argv[++i];
|
|
7
28
|
else if (argv[i].startsWith('--base-dir=')) base = argv[i].slice('--base-dir='.length);
|
|
8
29
|
}
|
|
9
|
-
|
|
30
|
+
let home;
|
|
10
31
|
try {
|
|
11
32
|
const environ = fs.readFileSync(`${procRoot}/${pid}/environ`, 'utf8').split('\0');
|
|
12
33
|
const value = (key) => environ.find((entry) => entry.startsWith(`${key}=`))?.slice(key.length + 1);
|
|
13
|
-
|
|
14
|
-
|
|
34
|
+
home = value('HOME');
|
|
35
|
+
if (base === undefined) base = value('T3CODE_HOME') || (home ? `${home}/.t3` : null);
|
|
36
|
+
} catch { /* An absolute argv base does not need the process environment. */ }
|
|
37
|
+
return normalizeBase(procRoot, pid, base, home);
|
|
15
38
|
}
|
|
16
39
|
|
|
17
40
|
function discover(procRoot, uid, baseDir) {
|
|
@@ -49,7 +72,36 @@ function descendants(pid, processes) {
|
|
|
49
72
|
return [...found];
|
|
50
73
|
}
|
|
51
74
|
|
|
52
|
-
function
|
|
75
|
+
function ownerFd(procRoot, pid, ownerFiles) {
|
|
76
|
+
let unknown = false;
|
|
77
|
+
let fds;
|
|
78
|
+
try { fds = fs.readdirSync(`${procRoot}/${pid}/fd`); }
|
|
79
|
+
catch (error) { return ['ENOENT', 'ESRCH'].includes(error.code) ? false : null; }
|
|
80
|
+
for (const fd of fds) {
|
|
81
|
+
try { if (ownerFiles.includes(fs.readlinkSync(`${procRoot}/${pid}/fd/${fd}`))) return true; }
|
|
82
|
+
catch (error) { if (!['ENOENT', 'ESRCH'].includes(error.code)) unknown = true; }
|
|
83
|
+
}
|
|
84
|
+
return unknown ? null : false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function serverOwner(procRoot, pid, processes, ownerFiles) {
|
|
88
|
+
const main = ownerFd(procRoot, pid, ownerFiles);
|
|
89
|
+
if (main === true) return true;
|
|
90
|
+
let unknown = main === null;
|
|
91
|
+
for (const child of descendants(pid, processes)) {
|
|
92
|
+
const holds = ownerFd(procRoot, child, ownerFiles);
|
|
93
|
+
if (holds === true) return true;
|
|
94
|
+
if (holds === null) unknown = true;
|
|
95
|
+
}
|
|
96
|
+
return unknown ? null : false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function serverTimes(procRoot, servers) {
|
|
100
|
+
let uptime = null;
|
|
101
|
+
try {
|
|
102
|
+
const value = fs.readFileSync(`${procRoot}/uptime`, 'utf8').trim().split(/\s+/)[0];
|
|
103
|
+
if (/^\d+(\.\d+)?$/.test(value) && Number.isFinite(Number(value))) uptime = Number(value);
|
|
104
|
+
} catch { /* Unknown uptime never authorizes a kill. */ }
|
|
53
105
|
const starts = new Map(servers.map((pid) => {
|
|
54
106
|
let start = null;
|
|
55
107
|
try {
|
|
@@ -61,8 +113,16 @@ function replacementHold(procRoot, servers, keeper) {
|
|
|
61
113
|
} catch { /* Unknown age never authorizes a kill. */ }
|
|
62
114
|
return [pid, start];
|
|
63
115
|
}));
|
|
116
|
+
const ages = new Map([...starts].map(([pid, start]) => [pid,
|
|
117
|
+
start === null || uptime === null ? null : uptime - Number(start) / 100]));
|
|
118
|
+
return { starts, ages, uptime };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function replacementHold({ starts, ages, uptime }, servers, keeper, graceSeconds) {
|
|
64
122
|
if ([...starts.values()].includes(null)) return 'unknown-start-time';
|
|
65
|
-
if (
|
|
123
|
+
if (uptime === null) return 'unknown-uptime';
|
|
124
|
+
if (servers.some((pid) => pid !== keeper && starts.get(pid) >= starts.get(keeper)
|
|
125
|
+
&& ages.get(pid) < graceSeconds)) return 'possible-replacement';
|
|
66
126
|
return null;
|
|
67
127
|
}
|
|
68
128
|
|
|
@@ -77,35 +137,59 @@ export async function run(args = [], {
|
|
|
77
137
|
let baseDir = `${env.HOME}/.t3`;
|
|
78
138
|
let procRoot = '/proc';
|
|
79
139
|
let dryRun = false;
|
|
140
|
+
let graceSeconds = 120;
|
|
80
141
|
const seen = new Set();
|
|
81
142
|
for (let i = 0; i < args.length; i++) {
|
|
82
143
|
const flag = args[i];
|
|
83
144
|
if (flag === '--help' && args.length === 1) {
|
|
84
|
-
output('Usage: bun single-server.js [--base-dir DIR] [--proc-root DIR] [--dry-run] [--help]\n'
|
|
145
|
+
output('Usage: bun single-server.js [--base-dir DIR] [--proc-root DIR] [--grace-seconds N] [--dry-run] [--help]\n'
|
|
85
146
|
+ 'Defaults: $HOME/.t3 and /proc. SIGKILL duplicate t3 serve servers and their descendants;\n'
|
|
86
|
-
+ '
|
|
147
|
+
+ 'prefer the unique server-owner.sqlite fd holder, otherwise the runtime pid.\n'
|
|
148
|
+
+ 'Newer servers get 120 seconds grace (N: non-negative integer).\n'
|
|
149
|
+
+ '--dry-run reports only. AXSTACK_SINGLE_SERVER_NOTIFY runs via sh -c after kills,\n'
|
|
150
|
+
+ 'or for holds with 2+ servers whose youngest age is 600 to under 780 seconds.');
|
|
87
151
|
return 0;
|
|
88
152
|
}
|
|
89
153
|
if (seen.has(flag)) throw new Error('duplicate flag');
|
|
90
154
|
seen.add(flag);
|
|
91
155
|
if (flag === '--dry-run') dryRun = true;
|
|
92
|
-
else if (['--base-dir', '--proc-root'].includes(flag) && args[i + 1] && !args[i + 1].startsWith('--')) {
|
|
156
|
+
else if (['--base-dir', '--proc-root', '--grace-seconds'].includes(flag) && args[i + 1] && !args[i + 1].startsWith('--')) {
|
|
93
157
|
const value = args[++i];
|
|
94
158
|
if (flag === '--base-dir') baseDir = value;
|
|
95
|
-
else procRoot = value;
|
|
159
|
+
else if (flag === '--proc-root') procRoot = value;
|
|
160
|
+
else {
|
|
161
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) throw new Error('invalid grace');
|
|
162
|
+
graceSeconds = Number(value);
|
|
163
|
+
}
|
|
96
164
|
} else throw new Error('invalid flag');
|
|
97
165
|
}
|
|
98
166
|
if (!env.HOME) throw new Error('missing home');
|
|
167
|
+
baseDir = normalizeBase(procRoot, process.pid, baseDir, env.HOME);
|
|
168
|
+
if (baseDir === null) throw new Error('unresolved base dir');
|
|
99
169
|
failure = 'scan-failed';
|
|
100
170
|
const { processes, servers, unknownBase } = discover(procRoot, uid, baseDir);
|
|
101
171
|
decision.servers = servers;
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
catch { /*
|
|
105
|
-
|
|
172
|
+
const ownerFiles = [`${baseDir}/userdata/server-owner.sqlite`];
|
|
173
|
+
try { ownerFiles.push(`${fs.realpathSync(`${baseDir}/userdata`)}/server-owner.sqlite`); }
|
|
174
|
+
catch { /* Keep lexical matching when the userdata directory cannot be resolved. */ }
|
|
175
|
+
const owners = servers.map((pid) => [pid,
|
|
176
|
+
serverOwner(procRoot, pid, processes, ownerFiles)]);
|
|
177
|
+
const holders = owners.filter(([, holds]) => holds === true).map(([pid]) => pid);
|
|
178
|
+
const ownerHold = holders.length > 1 ? 'multiple-owner-locks'
|
|
179
|
+
: holders.length === 1 && owners.some(([, holds]) => holds === null) ? 'unknown-owner-lock' : null;
|
|
180
|
+
if (holders.length === 1 && !ownerHold) decision.keeper = holders[0];
|
|
181
|
+
else if (!holders.length) {
|
|
182
|
+
// Legacy servers have no owner fd; unreadable fds alone retain the runtime fallback.
|
|
183
|
+
let runtimePid;
|
|
184
|
+
try { runtimePid = JSON.parse(fs.readFileSync(`${baseDir}/userdata/server-runtime.json`, 'utf8')).pid; }
|
|
185
|
+
catch { /* Missing, unreadable or malformed runtime data is never authority to kill. */ }
|
|
186
|
+
if (Number.isSafeInteger(runtimePid) && servers.includes(runtimePid)) decision.keeper = runtimePid;
|
|
187
|
+
}
|
|
188
|
+
const times = serverTimes(procRoot, servers);
|
|
106
189
|
const ageHold = !unknownBase && servers.length > 1 && decision.keeper !== null
|
|
107
|
-
? replacementHold(
|
|
108
|
-
if (
|
|
190
|
+
? replacementHold(times, servers, decision.keeper, graceSeconds) : null;
|
|
191
|
+
if (ownerHold) decision.reason = ownerHold;
|
|
192
|
+
else if (unknownBase) decision.reason = 'unknown-base-dir';
|
|
109
193
|
else if (servers.length < 2) decision = { ...decision, action: 'unchanged', reason: 'single-server' };
|
|
110
194
|
else if (decision.keeper === null) decision.reason = 'no-valid-keeper';
|
|
111
195
|
else if (ageHold) decision.reason = ageHold;
|
|
@@ -142,19 +226,34 @@ export async function run(args = [], {
|
|
|
142
226
|
}
|
|
143
227
|
if (decision.failed?.length) decision.reason = 'kill-failed';
|
|
144
228
|
else if (!decision.killed.length) decision = { ...decision, action: 'unchanged', reason: 'no-victims' };
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!dryRun && env.AXSTACK_SINGLE_SERVER_NOTIFY) {
|
|
232
|
+
let message;
|
|
233
|
+
failure = 'notify-failed';
|
|
234
|
+
if (decision.killed.length || decision.failed?.length) {
|
|
235
|
+
message = `${hostname()}: kept pid ${decision.keeper}; killed pids ${decision.killed.join(', ') || 'none'}. `
|
|
236
|
+
+ 'Runs started by the killed server may stay running and need t3_thread_interrupt.\n';
|
|
237
|
+
if (decision.failed?.length) message += `Failed pids ${decision.failed.join(', ')} are still running.\n`;
|
|
238
|
+
} else if (decision.action === 'hold' && servers.length > 1) {
|
|
239
|
+
const ages = [...times.ages.values()];
|
|
240
|
+
const youngest = ages.every((age) => age !== null && age >= 0) ? Math.min(...ages) : null;
|
|
241
|
+
if (youngest !== null && youngest >= 600 && youngest < 780) {
|
|
242
|
+
message = `${hostname()}: hold ${decision.reason}; keeper ${decision.keeper ?? 'none'}; `
|
|
243
|
+
+ `servers ${servers.join(', ')}; duplicates are still running.\n`;
|
|
153
244
|
}
|
|
154
245
|
}
|
|
246
|
+
if (message) {
|
|
247
|
+
const child = spawn(['sh', '-c', env.AXSTACK_SINGLE_SERVER_NOTIFY], {
|
|
248
|
+
stdin: new Blob([message]), stdout: 'ignore', stderr: 'ignore', env,
|
|
249
|
+
});
|
|
250
|
+
if (await child.exited !== 0) throw new Error('notification failed');
|
|
251
|
+
}
|
|
155
252
|
}
|
|
156
253
|
} catch {
|
|
157
|
-
decision.
|
|
254
|
+
if (failure === 'notify-failed' && decision.action === 'hold' && !decision.failed?.length) {
|
|
255
|
+
decision.notify = 'failed';
|
|
256
|
+
} else decision.reason = failure;
|
|
158
257
|
code = 1;
|
|
159
258
|
}
|
|
160
259
|
output(JSON.stringify(decision));
|