trantor 0.18.114 → 0.18.115

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.114",
3
+ "version": "0.18.115",
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": {
@@ -4,6 +4,14 @@ import { loadOrCreate } from "../../lib/identity.mjs";
4
4
  import { ensureEnrolled } from "../../lib/enroll.mjs";
5
5
  import { sfetchJson } from "../../lib/signed-fetch.mjs";
6
6
 
7
+ export async function unregisterSwapStage(hub, project, session) {
8
+ const identity = loadOrCreate(session, "agent");
9
+ const response = await sfetchJson(`${hub}/unregister`, {
10
+ identity, payload: { session, project }, signal: AbortSignal.timeout(8000),
11
+ });
12
+ if (!response.ok) throw new Error(`replacement deregistration failed: ${response.status}`);
13
+ }
14
+
7
15
  // A replacement registers before it touches the old runner's worktree or queue.
8
16
  export async function awaitSwapRelease(env = process.env) {
9
17
  if (!env.CREW_SWAP_STAGE) return;
@@ -25,4 +33,5 @@ export async function awaitSwapRelease(env = process.env) {
25
33
  }
26
34
  const handoff = JSON.parse(readFileSync(`${stage}.release`, "utf8"));
27
35
  if (handoff.agent !== env.CREW_SWAP_AGENT) throw new Error("swap release names a different seat");
36
+ await unregisterSwapStage(env.RELAY_URL, env.RELAY_PROJECT, session);
28
37
  }
package/bin/crew/swap.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { unregisterSwapStage } from "./stage.mjs";
1
2
  import { updatePersona } from "../../lib/persona.mjs";
2
3
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -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 = Date.now() + 30000;
101
+ const deadline = now() + 30000;
101
102
  while (!existsSync(`${stage.path}.ready`)) {
102
- if (Date.now() > deadline) throw new Error("replacement did not join the bus within 30s; old seat unchanged");
103
- await setTimeout(100);
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
- const response = await signedGet(`${ctx.hub}/peers?project=${encodeURIComponent(ctx.project)}`, { project: ctx.project });
107
- if (!response.ok || !response.json?.peers?.some(p => p.session === ready.session)) throw new Error("replacement registration is not visible on the bus");
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;
@@ -117,7 +127,10 @@ export function swapRuntime(ctx, adapters, spawn) {
117
127
  await down(ctx, [agent], adapters);
118
128
  signalProcesses(pids, "SIGKILL");
119
129
  },
120
- async cancel(stage) { await down(ctx, [stage.label], adapters); },
130
+ async cancel(stage) {
131
+ await down(ctx, [stage.label], adapters);
132
+ await unregisterSwapStage(ctx.hub, ctx.project, `${stage.label}:${ctx.project}`);
133
+ },
121
134
  release(stage, persona, cursor) {
122
135
  writeRows(ctx, readRows(ctx).map(row => row.project === ctx.project && row.persona === stage.label ? { ...row, persona } : row));
123
136
  writeFileSync(runnerRecord(ctx, ctx.project, persona), JSON.stringify({ label: stage.label, dir: ctx.dir }));
@@ -154,7 +167,10 @@ export async function performSwap(ctx, options, runtime) {
154
167
  } catch (error) {
155
168
  if (!transferred) {
156
169
  if (frozen) await runtime.resume();
157
- if (stage) await runtime.cancel(stage);
170
+ if (stage) {
171
+ try { await runtime.cancel(stage); }
172
+ catch (cleanupError) { error.message += `; replacement cleanup failed: ${cleanupError.message}`; }
173
+ }
158
174
  }
159
175
  result.reason = error.message;
160
176
  if (transferred) result.reason += "; handoff recorded on hub; old seat held for recovery";
@@ -36,6 +36,18 @@ 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
+ if (AUTH_MODE !== "off" && (auth?.identity?.name !== b.session || !scopeAllows(auth.identity, b.project, "write"))) {
43
+ return json(res, 403, { error: "only the session owner can unregister its peer" });
44
+ }
45
+ const peer = state.peers[b.session];
46
+ if (peer && peer.project !== canon(b.project)) return json(res, 403, { error: "peer belongs to a different project" });
47
+ delete state.peers[b.session];
48
+ markDirty();
49
+ return json(res, 200, { ok: true });
50
+ }
39
51
  if (req.method === "POST" && P === "/register") {
40
52
  const b = await body(req);
41
53
  const cpg = crossProjectGuard(auth, P, b);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.114",
3
+ "version": "0.18.115",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"