trantor 0.18.114 → 0.18.116
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/.claude-plugin/plugin.json +1 -1
- package/bin/crew/stage.mjs +13 -0
- package/bin/crew/state.mjs +30 -3
- package/bin/crew/swap.mjs +39 -16
- package/hub/routes/admin.mjs +14 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.116",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/bin/crew/stage.mjs
CHANGED
|
@@ -3,6 +3,15 @@ import { setTimeout } from "node:timers/promises";
|
|
|
3
3
|
import { loadOrCreate } from "../../lib/identity.mjs";
|
|
4
4
|
import { ensureEnrolled } from "../../lib/enroll.mjs";
|
|
5
5
|
import { sfetchJson } from "../../lib/signed-fetch.mjs";
|
|
6
|
+
import { personaName } from "../../lib/persona.mjs";
|
|
7
|
+
|
|
8
|
+
export async function unregisterSwapStage(hub, project, session) {
|
|
9
|
+
const identity = loadOrCreate(session, "agent");
|
|
10
|
+
const response = await sfetchJson(`${hub}/unregister`, {
|
|
11
|
+
identity, payload: { session, project }, signal: AbortSignal.timeout(8000),
|
|
12
|
+
});
|
|
13
|
+
if (!response.ok) throw new Error(`replacement deregistration failed: ${response.status}`);
|
|
14
|
+
}
|
|
6
15
|
|
|
7
16
|
// A replacement registers before it touches the old runner's worktree or queue.
|
|
8
17
|
export async function awaitSwapRelease(env = process.env) {
|
|
@@ -25,4 +34,8 @@ export async function awaitSwapRelease(env = process.env) {
|
|
|
25
34
|
}
|
|
26
35
|
const handoff = JSON.parse(readFileSync(`${stage}.release`, "utf8"));
|
|
27
36
|
if (handoff.agent !== env.CREW_SWAP_AGENT) throw new Error("swap release names a different seat");
|
|
37
|
+
const persona = personaName(handoff.persona);
|
|
38
|
+
if (env.CREW_PERSONA && env.CREW_PERSONA !== persona) throw new Error("swap release names a different persona");
|
|
39
|
+
env.CREW_PERSONA = persona;
|
|
40
|
+
await unregisterSwapStage(env.RELAY_URL, env.RELAY_PROJECT, session);
|
|
28
41
|
}
|
package/bin/crew/state.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { seatProcesses, signalProcesses } from "./processes.mjs";
|
|
4
4
|
import { call, listPids, run } from "./core.mjs";
|
|
5
5
|
import { hostId } from "../../lib/project.mjs";
|
|
6
|
+
import { signedPost } from "../../hooks/lib/api.mjs";
|
|
6
7
|
import { statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../../lib/docker-janitor.mjs";
|
|
7
8
|
|
|
8
9
|
export function parseRow(line) {
|
|
@@ -122,15 +123,22 @@ function closeTmux(ctx, row, perSeat, seen) {
|
|
|
122
123
|
run(ctx, "tmux", ["kill-session", "-t", session], { rendered: `tmux kill-session -t '${session}' 2>/dev/null` });
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
|
|
126
|
+
async function unregisterSeat(ctx, project, persona) {
|
|
127
|
+
const path = project === ctx.project ? `${ctx.hub}/unregister` : "/unregister";
|
|
128
|
+
const response = await signedPost(path, { session: `${persona}:${project}`, project }, { project });
|
|
129
|
+
if (!response.ok || !response.json?.ok) throw new Error(response.json?.error || response.reason || `hub HTTP ${response.status}`);
|
|
130
|
+
return response.json;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function down(ctx, args, adapters, { unregister = unregisterSeat } = {}) {
|
|
126
134
|
const options = parseDownArgs(ctx, args);
|
|
127
135
|
if (options.help) return 0;
|
|
128
136
|
if (options.error) return 1;
|
|
129
137
|
const rows = readRows(ctx);
|
|
130
|
-
if (!rows.length) { console.log("no tracked crew windows"); return 0; }
|
|
138
|
+
if (!rows.length && !options.agents.length) { console.log("no tracked crew windows"); return 0; }
|
|
131
139
|
const scoped = rows.filter(row => (options.all || row.project === ctx.project) && (!options.agents.length || options.agents.includes(row.persona)));
|
|
132
140
|
const scope = options.all ? "all projects" : `project \"${ctx.project}\"`;
|
|
133
|
-
if (!scoped.length) { console.log(`nothing to tear down for ${scope}`); return 0; }
|
|
141
|
+
if (!scoped.length && !options.agents.length) { console.log(`nothing to tear down for ${scope}`); return 0; }
|
|
134
142
|
console.log(`— tearing down (${scope}):`);
|
|
135
143
|
for (const row of scoped) if (!["attach", "cmuxws", "herdrws", "orch"].includes(row.kind)) console.log(` • ${row.project || "<legacy>"} · ${row.persona} (${row.kind})`);
|
|
136
144
|
if (options.all && !options.yes) {
|
|
@@ -144,6 +152,21 @@ export async function down(ctx, args, adapters) {
|
|
|
144
152
|
const kept = rows.filter(row => !removed.has(`${row.project}|${row.kind}|${row.persona}|${row.handle}`) || (orchProjects.has(row.project) && ["herdrws", "cmuxws", "orch"].includes(row.kind)));
|
|
145
153
|
writeRows(ctx, kept);
|
|
146
154
|
const seatRows = scoped.filter(row => !["attach", "cmuxws", "herdrws", "orch"].includes(row.kind));
|
|
155
|
+
const staleSeats = options.agents.filter(persona => !scoped.some(row => row.persona === persona))
|
|
156
|
+
.map(persona => ({ project: ctx.project, persona, stale: true }));
|
|
157
|
+
const seats = new Map([...seatRows, ...staleSeats].map(row => [`${row.project}/${row.persona}`, row]));
|
|
158
|
+
const failures = [];
|
|
159
|
+
for (const row of seats.values()) {
|
|
160
|
+
if (!row.project) continue;
|
|
161
|
+
if (ctx.dry) { console.log(`[dry] unregister ${row.persona}:${row.project}`); continue; }
|
|
162
|
+
try {
|
|
163
|
+
const result = await unregister(ctx, row.project, row.persona);
|
|
164
|
+
if (row.stale) console.log(result.removed ? ` removed a stale seat row: ${row.persona}:${row.project}` : ` no hub seat row: ${row.persona}:${row.project}`);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
failures.push(`${row.persona}:${row.project}: ${error.message}`);
|
|
167
|
+
console.error(` ${row.persona}:${row.project}: hub row removal failed: ${error.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
147
170
|
if (!ctx.dry) {
|
|
148
171
|
const logDir = join(dirname(ctx.statePath), "logs");
|
|
149
172
|
mkdirSync(logDir, { recursive: true });
|
|
@@ -152,6 +175,10 @@ export async function down(ctx, args, adapters) {
|
|
|
152
175
|
`${JSON.stringify({ ts: Date.now(), agent: row.persona, project: row.project, stopped: true })}\n`);
|
|
153
176
|
}
|
|
154
177
|
}
|
|
178
|
+
if (failures.length) {
|
|
179
|
+
console.log(`— hub row removal incomplete; retry Stop: ${failures.join("; ")}`);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
155
182
|
if (options.agents.length) {
|
|
156
183
|
const stopped = [...new Set(seatRows.map(row => row.persona))];
|
|
157
184
|
const remaining = [...new Set(kept.filter(row => row.project === ctx.project &&
|
package/bin/crew/swap.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { unregisterSwapStage } from "./stage.mjs";
|
|
2
|
+
import { allocatePersona, updatePersona } from "../../lib/persona.mjs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { setTimeout } from "node:timers/promises";
|
|
@@ -85,7 +86,7 @@ async function hubRequest(ctx, path, body) {
|
|
|
85
86
|
return response.json;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
export function swapRuntime(ctx, adapters, spawn) {
|
|
89
|
+
export function swapRuntime(ctx, adapters, spawn, { now = Date.now, pause = setTimeout, requestTimeoutMs = 8000 } = {}) {
|
|
89
90
|
let pids = [];
|
|
90
91
|
let replacementPid;
|
|
91
92
|
return {
|
|
@@ -97,14 +98,23 @@ export function swapRuntime(ctx, adapters, spawn) {
|
|
|
97
98
|
spawn(staged, { ...seat, persona: stage.label });
|
|
98
99
|
},
|
|
99
100
|
async ready(stage) {
|
|
100
|
-
const deadline =
|
|
101
|
+
const deadline = now() + 30000;
|
|
101
102
|
while (!existsSync(`${stage.path}.ready`)) {
|
|
102
|
-
if (
|
|
103
|
-
await
|
|
103
|
+
if (now() > deadline) throw new Error("replacement did not join the bus within 30s; old seat unchanged");
|
|
104
|
+
await pause(100);
|
|
104
105
|
}
|
|
105
106
|
const ready = JSON.parse(readFileSync(`${stage.path}.ready`, "utf8"));
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
let failure = "status=0 reason=readiness deadline elapsed timedOut=false";
|
|
108
|
+
let visible = false;
|
|
109
|
+
while (now() < deadline) {
|
|
110
|
+
const response = await signedGet(`${ctx.hub}/peers?project=${encodeURIComponent(ctx.project)}`, {
|
|
111
|
+
project: ctx.project, timeoutMs: Math.min(requestTimeoutMs, deadline - now()),
|
|
112
|
+
});
|
|
113
|
+
if (response.ok && response.json?.peers?.some(p => p.session === ready.session)) { visible = true; break; }
|
|
114
|
+
failure = `status=${response.status} reason=${response.reason || response.json?.error || "replacement absent from roster"} timedOut=${response.timedOut === true}`;
|
|
115
|
+
await pause(Math.min(100, Math.max(0, deadline - now())));
|
|
116
|
+
}
|
|
117
|
+
if (!visible) throw new Error(`replacement registration is not visible on the bus: ${failure}`);
|
|
108
118
|
process.kill(ready.pid, 0);
|
|
109
119
|
replacementPid = ready.pid;
|
|
110
120
|
return ready.session;
|
|
@@ -114,10 +124,16 @@ export function swapRuntime(ctx, adapters, spawn) {
|
|
|
114
124
|
transfer: body => hubRequest(ctx, "/contracts/transfer", body),
|
|
115
125
|
async stop(agent) {
|
|
116
126
|
process.kill(replacementPid, 0);
|
|
117
|
-
await down(ctx, [agent], adapters);
|
|
127
|
+
if (await down(ctx, [agent], adapters)) throw new Error("old seat stopped locally but its hub row could not be removed");
|
|
118
128
|
signalProcesses(pids, "SIGKILL");
|
|
119
129
|
},
|
|
120
|
-
async cancel(stage) {
|
|
130
|
+
async cancel(stage) {
|
|
131
|
+
const code = await down(ctx, [stage.label], adapters, { unregister: async () => {
|
|
132
|
+
await unregisterSwapStage(ctx.hub, ctx.project, `${stage.label}:${ctx.project}`);
|
|
133
|
+
return { removed: true };
|
|
134
|
+
} });
|
|
135
|
+
if (code) throw new Error("replacement hub row could not be removed");
|
|
136
|
+
},
|
|
121
137
|
release(stage, persona, cursor) {
|
|
122
138
|
writeRows(ctx, readRows(ctx).map(row => row.project === ctx.project && row.persona === stage.label ? { ...row, persona } : row));
|
|
123
139
|
writeFileSync(runnerRecord(ctx, ctx.project, persona), JSON.stringify({ label: stage.label, dir: ctx.dir }));
|
|
@@ -132,29 +148,36 @@ export async function performSwap(ctx, options, runtime) {
|
|
|
132
148
|
let stage, frozen = false, transferred = false;
|
|
133
149
|
try {
|
|
134
150
|
const seat = await runtime.validate(options);
|
|
151
|
+
const occupied = [options.from, seat.agent, ...readRows(ctx).filter(row => row.project === ctx.project).map(row => row.persona)];
|
|
152
|
+
const { persona } = allocatePersona({ dir: join(ctx.home, ".agent-bus"), project: ctx.project, agent: seat.agent, model: seat.model, occupied });
|
|
153
|
+
result.to = persona;
|
|
135
154
|
const label = `${options.agent}-swap-${randomUUID().slice(0, 8)}`;
|
|
136
155
|
mkdirSync(ctx.seatDir, { recursive: true });
|
|
137
|
-
stage = { label, path: join(ctx.seatDir, label), persona
|
|
156
|
+
stage = { label, path: join(ctx.seatDir, label), persona, agent: seat.agent, model: seat.model };
|
|
138
157
|
await runtime.start(seat, stage);
|
|
139
158
|
const readySession = await runtime.ready(stage);
|
|
140
159
|
frozen = true;
|
|
141
160
|
await runtime.freeze(options.from);
|
|
142
161
|
const pending = readPending(ctx, options.from);
|
|
143
|
-
const target =
|
|
144
|
-
const from = `${options.from}:${ctx.project}`, to =
|
|
162
|
+
const target = readPending(ctx, persona);
|
|
163
|
+
const from = `${options.from}:${ctx.project}`, to = `${persona}:${ctx.project}`;
|
|
145
164
|
const handoff = await runtime.transfer({ from, to, project: ctx.project, readySession, _op: `swap-${stage.label}`, heldAsk: pending.ask || 0, pendingIds: pending.wake.map(m => m.id) });
|
|
146
165
|
transferred = true;
|
|
147
166
|
result.moved = [...new Set([...pending.wake.map(m => m.id), ...handoff.moved])];
|
|
148
167
|
const wake = new Map([...target.wake, ...pending.wake, ...handoff.messages].map(m => [m.id, { ...m, to }]));
|
|
149
|
-
savePending(ctx,
|
|
168
|
+
savePending(ctx, persona, { ...target, wake: [...wake.values()], bcast: [...new Map([...target.bcast, ...pending.bcast].map(m => [m.id, m])).values()], seen: [...new Set([...target.seen, ...pending.seen])], ask: handoff.messages.some(m => m.re === pending.ask) ? 0 : pending.ask || target.ask, askTo: pending.askTo || target.askTo });
|
|
150
169
|
await runtime.stop(options.from);
|
|
151
170
|
frozen = false;
|
|
152
|
-
await runtime.release(stage,
|
|
171
|
+
await runtime.release(stage, persona, handoff.cursor);
|
|
172
|
+
if (existsSync(pendingPath(ctx, options.from))) unlinkSync(pendingPath(ctx, options.from));
|
|
153
173
|
return { ...result, ok: true };
|
|
154
174
|
} catch (error) {
|
|
155
175
|
if (!transferred) {
|
|
156
176
|
if (frozen) await runtime.resume();
|
|
157
|
-
if (stage)
|
|
177
|
+
if (stage) {
|
|
178
|
+
try { await runtime.cancel(stage); }
|
|
179
|
+
catch (cleanupError) { error.message += `; replacement cleanup failed: ${cleanupError.message}`; }
|
|
180
|
+
}
|
|
158
181
|
}
|
|
159
182
|
result.reason = error.message;
|
|
160
183
|
if (transferred) result.reason += "; handoff recorded on hub; old seat held for recovery";
|
package/hub/routes/admin.mjs
CHANGED
|
@@ -36,6 +36,20 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
36
36
|
}
|
|
37
37
|
return json(res, 200, keyUsageSnapshot(state, now(), ONLINE_MS));
|
|
38
38
|
}
|
|
39
|
+
if (req.method === "POST" && P === "/unregister") {
|
|
40
|
+
const b = await body(req);
|
|
41
|
+
if (!b.session || !b.project) return json(res, 400, { error: "session and project required" });
|
|
42
|
+
const peer = state.peers[b.session];
|
|
43
|
+
if (peer && peer.project !== canon(b.project)) return json(res, 403, { error: "peer belongs to a different project" });
|
|
44
|
+
const self = auth?.identity?.name === b.session && scopeAllows(auth.identity, b.project, "write");
|
|
45
|
+
const owner = scopeAllows(auth?.identity, b.project, "owner") && (!peer || peer.kind === "agent");
|
|
46
|
+
if (AUTH_MODE !== "off" && !self && !owner) {
|
|
47
|
+
return json(res, 403, { error: "only the session itself or a project owner removing an agent can unregister its peer" });
|
|
48
|
+
}
|
|
49
|
+
delete state.peers[b.session];
|
|
50
|
+
markDirty();
|
|
51
|
+
return json(res, 200, { ok: true, removed: !!peer });
|
|
52
|
+
}
|
|
39
53
|
if (req.method === "POST" && P === "/register") {
|
|
40
54
|
const b = await body(req);
|
|
41
55
|
const cpg = crossProjectGuard(auth, P, b);
|