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 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 recorded server. Both run stateless scripts, keep
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).
@@ -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
- It keeps the pid recorded in `<base>/userdata/server-runtime.json`.
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. Duplicate servers must have started strictly
321
- before the keeper, comparing field 22 (`starttime`) of `/proc/<pid>/stat`.
322
- An unknown start time or any duplicate started at or after the keeper holds
323
- the whole run, preserving a possible starting replacement. The script also
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`). `--help` lists the flags. Decisions
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, successful kills still
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 only after a kill, use `systemctl --user edit axstack-single-server.service`
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 killed pids. It notes that runs started by the killed
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 after the kills; command output is suppressed. Dry-run, unchanged and
375
- hold decisions never notify. Verify delivery under the host's notification authority.
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`.
@@ -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 recorded runtime pid.
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.0",
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
- if (base !== undefined) return base || null;
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
- return value('T3CODE_HOME') || (value('HOME') ? `${value('HOME')}/.t3` : null);
14
- } catch { return null; }
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 replacementHold(procRoot, servers, keeper) {
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 (servers.some((pid) => pid !== keeper && starts.get(pid) >= starts.get(keeper))) return 'possible-replacement';
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
- + 'keep the runtime pid. --dry-run reports only. AXSTACK_SINGLE_SERVER_NOTIFY runs via sh -c after kills.');
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
- let runtimePid;
103
- try { runtimePid = JSON.parse(fs.readFileSync(`${baseDir}/userdata/server-runtime.json`, 'utf8')).pid; }
104
- catch { /* Missing, unreadable or malformed runtime data is never authority to kill. */ }
105
- if (Number.isSafeInteger(runtimePid) && servers.includes(runtimePid)) decision.keeper = runtimePid;
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(procRoot, servers, decision.keeper) : null;
108
- if (unknownBase) decision.reason = 'unknown-base-dir';
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
- if (decision.killed.length && env.AXSTACK_SINGLE_SERVER_NOTIFY) {
146
- failure = 'notify-failed';
147
- const message = `${hostname()}: kept pid ${decision.keeper}; killed pids ${decision.killed.join(', ')}. `
148
- + 'Runs started by the killed server may stay running and need t3_thread_interrupt.\n';
149
- const child = spawn(['sh', '-c', env.AXSTACK_SINGLE_SERVER_NOTIFY], {
150
- stdin: new Blob([message]), stdout: 'ignore', stderr: 'ignore', env,
151
- });
152
- if (await child.exited !== 0) throw new Error('notification failed');
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.reason = failure;
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));