axstack 0.36.0 → 0.36.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 +7 -4
- package/docs/host-operations.md +77 -5
- package/docs/installation.md +5 -2
- package/package.json +1 -1
- package/skills/axstack/scripts/single-server.js +164 -0
- package/skills/axstack/systemd/axstack-single-server.service +8 -0
- package/skills/axstack/systemd/axstack-single-server.timer +10 -0
package/README.md
CHANGED
|
@@ -126,10 +126,13 @@ Eligible own PRs use guarded automatic merge; see [merge boundaries, exclusions,
|
|
|
126
126
|
## Some notes
|
|
127
127
|
|
|
128
128
|
- T3 Code is the only supported active runtime. Axstack adds no daemon or runtime
|
|
129
|
-
database. The
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
database. The exceptions are exactly two opt-in systemd user timers:
|
|
130
|
+
`axstack-default-sync` rewrites only T3 `defaultModelSelection`, and
|
|
131
|
+
`axstack-single-server` removes duplicate `t3 serve` process trees for one
|
|
132
|
+
base dir while keeping the recorded server. Both run stateless scripts, keep
|
|
133
|
+
no workflow state and launch no agent; see
|
|
134
|
+
[New-chat default sync](docs/host-operations.md#new-chat-default-sync) and
|
|
135
|
+
[Single T3 server](docs/host-operations.md#single-t3-server).
|
|
133
136
|
- The recorded owning watch thread merges eligible own PRs; excluded PRs use a merge card.
|
|
134
137
|
- This is an early project; expect the workflows to evolve.
|
|
135
138
|
|
package/docs/host-operations.md
CHANGED
|
@@ -44,9 +44,10 @@ the affected operation. Provider/model routing, Linear documents through the
|
|
|
44
44
|
executor MCP, and live schedule behavior need separate preflights.
|
|
45
45
|
|
|
46
46
|
Installation creates no production schedule and never enables a timer.
|
|
47
|
-
Axstack allows
|
|
48
|
-
|
|
49
|
-
`
|
|
47
|
+
Axstack allows exactly two opt-in systemd user timers as scheduler exceptions:
|
|
48
|
+
`axstack-default-sync` rewrites only T3 `defaultModelSelection`, and
|
|
49
|
+
`axstack-single-server` removes duplicate server processes for one base dir.
|
|
50
|
+
Both run stateless scripts, keep no workflow state and launch no agent.
|
|
50
51
|
Every verified own-PR publication arms or joins the driver's chat-run watch.
|
|
51
52
|
Its bound T3 schedule uses the cadence in [Chat-run watch activation](#chat-run-watch-activation).
|
|
52
53
|
See [Chat-run PR watch](workflows.md#chat-run-pr-watch) for authority, schedule identity and stop conditions.
|
|
@@ -204,8 +205,9 @@ retirement. No broad scratch deletion or forced worktree removal applies.
|
|
|
204
205
|
Manual review and user-driven `axstack-watch` remain outside this schedule.
|
|
205
206
|
Requested peer reviews cover any accessible repository. T3 owns schedules,
|
|
206
207
|
threads, runs, and delegated tasks; Axstack adds no queue engine, cursor files,
|
|
207
|
-
or historical runtime fallback. The only scheduler
|
|
208
|
-
systemd user
|
|
208
|
+
or historical runtime fallback. The only scheduler exceptions are the two opt-in
|
|
209
|
+
systemd user timers described in [New-chat default sync](#new-chat-default-sync)
|
|
210
|
+
and [Single T3 server](#single-t3-server).
|
|
209
211
|
|
|
210
212
|
## Review automation
|
|
211
213
|
|
|
@@ -282,3 +284,73 @@ Rollback: remove the pool drop-in, reload, and restore the full selection in T3
|
|
|
282
284
|
The sibling `<settings>.axstack-default-sync.lock` directory prevents overlap
|
|
283
285
|
(unchanged/locked). Empty locks older than sixty seconds are removed and retried
|
|
284
286
|
once; fresh contention reports locked.
|
|
287
|
+
|
|
288
|
+
## Single T3 server
|
|
289
|
+
|
|
290
|
+
This opt-in timer checks once per minute for this user's `t3 serve` processes
|
|
291
|
+
with the selected base dir (target default `$HOME/.t3`). Each server's base dir
|
|
292
|
+
comes from its `--base-dir <dir>` or `--base-dir=<dir>` flag, then its own
|
|
293
|
+
`T3CODE_HOME`, then its own `$HOME/.t3`, reading the latter values from that
|
|
294
|
+
process's environment. An unreadable or missing environment without an explicit
|
|
295
|
+
flag, or missing base-dir values, makes the whole run hold and kill nothing.
|
|
296
|
+
It keeps the pid recorded in `<base>/userdata/server-runtime.json`.
|
|
297
|
+
With zero or one known matching server it does nothing; with duplicates but no
|
|
298
|
+
valid readable keeper it holds. Duplicate servers must have started strictly
|
|
299
|
+
before the keeper, comparing field 22 (`starttime`) of `/proc/<pid>/stat`.
|
|
300
|
+
An unknown start time or any duplicate started at or after the keeper holds
|
|
301
|
+
the whole run, preserving a possible starting replacement. The script also
|
|
302
|
+
holds if its own pid or an ancestor appears in the planned victim set.
|
|
303
|
+
Otherwise it snapshots descendants before killing each duplicate server first,
|
|
304
|
+
then its children with SIGKILL. The keeper and its descendants are protected.
|
|
305
|
+
It starts no server, reads no database and interrupts no runs automatically.
|
|
306
|
+
|
|
307
|
+
Preview the one-line JSON decision without killing or notifying:
|
|
308
|
+
|
|
309
|
+
```sh
|
|
310
|
+
bun ~/.agents/skills/axstack/scripts/single-server.js --dry-run
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`--base-dir <dir>` selects a different base dir; `--proc-root <dir>` supplies
|
|
314
|
+
a process-tree fixture (default `/proc`). `--help` lists the flags. Decisions
|
|
315
|
+
are `unchanged`, `hold`, `would-kill` or `killed`, with reason, keeper, matching
|
|
316
|
+
server pids and killed pids (proposed victims in dry-run). Non-ESRCH kill failures
|
|
317
|
+
add their pids to `failed`; remaining kills continue, successful kills still
|
|
318
|
+
notify if configured, and the command exits 1. Exit 0 includes holds;
|
|
319
|
+
exit 1 reports invalid input, discovery, kill or notification failure.
|
|
320
|
+
|
|
321
|
+
Under host-mutation authority, copy both assets and enable the timer:
|
|
322
|
+
|
|
323
|
+
```sh
|
|
324
|
+
mkdir -p ~/.config/systemd/user
|
|
325
|
+
cp ~/.agents/skills/axstack/systemd/axstack-single-server.service ~/.config/systemd/user/axstack-single-server.service
|
|
326
|
+
cp ~/.agents/skills/axstack/systemd/axstack-single-server.timer ~/.config/systemd/user/axstack-single-server.timer
|
|
327
|
+
systemctl --user daemon-reload
|
|
328
|
+
systemctl --user enable --now axstack-single-server.timer
|
|
329
|
+
systemctl --user list-timers axstack-single-server.timer
|
|
330
|
+
journalctl --user -u axstack-single-server.service
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Installation only copies assets; it never enables either timer. The single-server
|
|
334
|
+
timer starts one minute after user-manager startup and repeats every minute.
|
|
335
|
+
Override Bun/skills paths or add `--base-dir` through the service's `ExecStart`
|
|
336
|
+
drop-in, clearing the existing `ExecStart` first.
|
|
337
|
+
|
|
338
|
+
To notify only after a kill, use `systemctl --user edit axstack-single-server.service`
|
|
339
|
+
and add a per-host command, then reload:
|
|
340
|
+
|
|
341
|
+
```ini
|
|
342
|
+
[Service]
|
|
343
|
+
Environment="AXSTACK_SINGLE_SERVER_NOTIFY=hermes send --to telegram --quiet"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The VPS has Hermes locally; other hosts can use
|
|
347
|
+
`Environment="AXSTACK_SINGLE_SERVER_NOTIFY=ssh '<relay-host>' hermes send --to telegram --quiet"`.
|
|
348
|
+
Replace `<relay-host>` with the authorized gateway's hostname.
|
|
349
|
+
The script runs the command via `sh -c`, sending a short message on stdin with
|
|
350
|
+
hostname, kept pid and killed pids. It notes that runs started by the killed
|
|
351
|
+
server may remain `running` and need `t3_thread_interrupt`. Notification failure
|
|
352
|
+
exits 1 after the kills; command output is suppressed. Dry-run, unchanged and
|
|
353
|
+
hold decisions never notify. Verify delivery under the host's notification authority.
|
|
354
|
+
|
|
355
|
+
Disable with `systemctl --user disable --now axstack-single-server.timer` and
|
|
356
|
+
stop any active one-shot with `systemctl --user stop axstack-single-server.service`.
|
package/docs/installation.md
CHANGED
|
@@ -96,10 +96,13 @@ Run these with Bun from the installed `<skills-dir>/axstack/scripts/` directory:
|
|
|
96
96
|
`resolve-models.js` (model IDs), `pick-instance.js` (dispatch account),
|
|
97
97
|
`pr-digest.js` (PR discovery), `archive-evidence.js` (evidence archive), and
|
|
98
98
|
`sync-default.js [--settings <path>] [--dry-run]` (New-chat default; `AXSTACK_SYNC_POOL` opts into cross-driver rotation).
|
|
99
|
+
`single-server.js [--base-dir <dir>] [--proc-root <dir>] [--dry-run]` removes
|
|
100
|
+
duplicate `t3 serve` process trees while keeping the recorded runtime pid.
|
|
99
101
|
`account-usage.js` shares usage retrieval and the five-minute cache between
|
|
100
102
|
picker and sync. The opt-in systemd user unit files ship in `axstack/systemd/`;
|
|
101
|
-
installation copies them as assets and never enables
|
|
102
|
-
See [
|
|
103
|
+
installation copies them as assets and never enables either timer.
|
|
104
|
+
See [New-chat default sync](host-operations.md#new-chat-default-sync) and
|
|
105
|
+
[Single T3 server](host-operations.md#single-t3-server) for activation.
|
|
103
106
|
|
|
104
107
|
## Role presets
|
|
105
108
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "axstack",
|
|
3
|
-
"version": "0.36.
|
|
3
|
+
"version": "0.36.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",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
function serverBase(procRoot, pid, argv) {
|
|
4
|
+
let base;
|
|
5
|
+
for (let i = 2; i < argv.length; i++) {
|
|
6
|
+
if (argv[i] === '--base-dir') base = argv[++i];
|
|
7
|
+
else if (argv[i].startsWith('--base-dir=')) base = argv[i].slice('--base-dir='.length);
|
|
8
|
+
}
|
|
9
|
+
if (base !== undefined) return base || null;
|
|
10
|
+
try {
|
|
11
|
+
const environ = fs.readFileSync(`${procRoot}/${pid}/environ`, 'utf8').split('\0');
|
|
12
|
+
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; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function discover(procRoot, uid, baseDir) {
|
|
18
|
+
const processes = new Map();
|
|
19
|
+
const servers = [];
|
|
20
|
+
let unknownBase = false;
|
|
21
|
+
const pids = fs.readdirSync(procRoot).filter((name) => /^[1-9]\d*$/.test(name)).map(Number).sort((a, b) => a - b);
|
|
22
|
+
for (const pid of pids) {
|
|
23
|
+
try {
|
|
24
|
+
const status = fs.readFileSync(`${procRoot}/${pid}/status`, 'utf8');
|
|
25
|
+
const parent = /^PPid:\s+(\d+)/m.exec(status);
|
|
26
|
+
const user = /^Uid:\s+(\d+)/m.exec(status);
|
|
27
|
+
if (!parent || !user) throw new Error('invalid process status');
|
|
28
|
+
processes.set(pid, Number(parent[1]));
|
|
29
|
+
if (Number(user[1]) !== uid) continue;
|
|
30
|
+
const argv = fs.readFileSync(`${procRoot}/${pid}/cmdline`, 'utf8').split('\0');
|
|
31
|
+
if (argv[0].split('/').at(-1) !== 't3' || argv[1] !== 'serve') continue;
|
|
32
|
+
const base = serverBase(procRoot, pid, argv);
|
|
33
|
+
if (base === null) unknownBase = true;
|
|
34
|
+
else if (base === baseDir) servers.push(pid);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// Processes can exit during discovery; every other scan failure prevents kills.
|
|
37
|
+
if (!['ENOENT', 'ESRCH'].includes(error.code)) throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { processes, servers, unknownBase };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function descendants(pid, processes) {
|
|
44
|
+
const found = new Set([pid]);
|
|
45
|
+
for (const parent of found) {
|
|
46
|
+
for (const [child, ppid] of processes) if (ppid === parent) found.add(child);
|
|
47
|
+
}
|
|
48
|
+
found.delete(pid);
|
|
49
|
+
return [...found];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function replacementHold(procRoot, servers, keeper) {
|
|
53
|
+
const starts = new Map(servers.map((pid) => {
|
|
54
|
+
let start = null;
|
|
55
|
+
try {
|
|
56
|
+
const stat = fs.readFileSync(`${procRoot}/${pid}/stat`, 'utf8');
|
|
57
|
+
const end = stat.lastIndexOf(')');
|
|
58
|
+
// The comm field can contain spaces and parentheses; field 22 is index 19 after it.
|
|
59
|
+
const value = end < 0 ? '' : stat.slice(end + 1).trim().split(/\s+/)[19];
|
|
60
|
+
if (/^\d+$/.test(value)) start = BigInt(value);
|
|
61
|
+
} catch { /* Unknown age never authorizes a kill. */ }
|
|
62
|
+
return [pid, start];
|
|
63
|
+
}));
|
|
64
|
+
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';
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function run(args = [], {
|
|
70
|
+
env = process.env, uid = process.getuid(), kill = process.kill, spawn = Bun.spawn,
|
|
71
|
+
hostname = () => fs.readFileSync('/proc/sys/kernel/hostname', 'utf8').trim(), output = console.log,
|
|
72
|
+
} = {}) {
|
|
73
|
+
let decision = { action: 'hold', reason: 'invalid-input', keeper: null, servers: [], killed: [] };
|
|
74
|
+
let failure = 'invalid-input';
|
|
75
|
+
let code = 0;
|
|
76
|
+
try {
|
|
77
|
+
let baseDir = `${env.HOME}/.t3`;
|
|
78
|
+
let procRoot = '/proc';
|
|
79
|
+
let dryRun = false;
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
for (let i = 0; i < args.length; i++) {
|
|
82
|
+
const flag = args[i];
|
|
83
|
+
if (flag === '--help' && args.length === 1) {
|
|
84
|
+
output('Usage: bun single-server.js [--base-dir DIR] [--proc-root DIR] [--dry-run] [--help]\n'
|
|
85
|
+
+ '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.');
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (seen.has(flag)) throw new Error('duplicate flag');
|
|
90
|
+
seen.add(flag);
|
|
91
|
+
if (flag === '--dry-run') dryRun = true;
|
|
92
|
+
else if (['--base-dir', '--proc-root'].includes(flag) && args[i + 1] && !args[i + 1].startsWith('--')) {
|
|
93
|
+
const value = args[++i];
|
|
94
|
+
if (flag === '--base-dir') baseDir = value;
|
|
95
|
+
else procRoot = value;
|
|
96
|
+
} else throw new Error('invalid flag');
|
|
97
|
+
}
|
|
98
|
+
if (!env.HOME) throw new Error('missing home');
|
|
99
|
+
failure = 'scan-failed';
|
|
100
|
+
const { processes, servers, unknownBase } = discover(procRoot, uid, baseDir);
|
|
101
|
+
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;
|
|
106
|
+
const ageHold = !unknownBase && servers.length > 1 && decision.keeper !== null
|
|
107
|
+
? replacementHold(procRoot, servers, decision.keeper) : null;
|
|
108
|
+
if (unknownBase) decision.reason = 'unknown-base-dir';
|
|
109
|
+
else if (servers.length < 2) decision = { ...decision, action: 'unchanged', reason: 'single-server' };
|
|
110
|
+
else if (decision.keeper === null) decision.reason = 'no-valid-keeper';
|
|
111
|
+
else if (ageHold) decision.reason = ageHold;
|
|
112
|
+
else {
|
|
113
|
+
const protectedPids = new Set([decision.keeper, ...descendants(decision.keeper, processes)]);
|
|
114
|
+
// Snapshot every tree before SIGKILL can reparent children, including other server trees.
|
|
115
|
+
const trees = servers.filter((pid) => !protectedPids.has(pid))
|
|
116
|
+
.map((pid) => [pid, ...descendants(pid, processes)]);
|
|
117
|
+
const roots = trees.filter(([pid]) => !trees.some((tree) => tree[0] !== pid && tree.includes(pid)));
|
|
118
|
+
const victims = [...new Set(roots.flat())].filter((pid) => !protectedPids.has(pid));
|
|
119
|
+
const ancestors = new Set([process.pid]);
|
|
120
|
+
let parent = processes.get(process.pid) ?? process.ppid;
|
|
121
|
+
while (parent && !ancestors.has(parent)) {
|
|
122
|
+
ancestors.add(parent);
|
|
123
|
+
parent = processes.get(parent);
|
|
124
|
+
}
|
|
125
|
+
decision.reason = 'duplicate-servers';
|
|
126
|
+
if (victims.some((pid) => ancestors.has(pid))) decision.reason = 'own-process-tree';
|
|
127
|
+
else if (dryRun) decision = { ...decision, action: 'would-kill', killed: victims };
|
|
128
|
+
else {
|
|
129
|
+
failure = 'kill-failed';
|
|
130
|
+
for (const pid of victims) {
|
|
131
|
+
try { kill(pid, 'SIGKILL'); }
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error.code !== 'ESRCH') {
|
|
134
|
+
decision.failed ??= [];
|
|
135
|
+
decision.failed.push(pid);
|
|
136
|
+
code = 1;
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
decision.action = 'killed';
|
|
141
|
+
decision.killed.push(pid);
|
|
142
|
+
}
|
|
143
|
+
if (decision.failed?.length) decision.reason = 'kill-failed';
|
|
144
|
+
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');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
decision.reason = failure;
|
|
158
|
+
code = 1;
|
|
159
|
+
}
|
|
160
|
+
output(JSON.stringify(decision));
|
|
161
|
+
return code;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (import.meta.main) process.exitCode = await run(process.argv.slice(2));
|