trantor 0.18.88 → 0.18.90

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.88",
3
+ "version": "0.18.90",
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": {
@@ -60,6 +60,7 @@ export class DrillReport {
60
60
 
61
61
  exitCode() {
62
62
  // Manual recipes stay visible as SKIP; only missing/failed proof or a refused close is red.
63
+ // "gone" is neither: the seam proved, the card just isn't on the board anymore (#11498).
63
64
  return Object.values(this.results()).some(result => result.status === "fail" || result.closure.startsWith("failed:")) ? 1 : 0;
64
65
  }
65
66
 
@@ -70,11 +71,18 @@ export class DrillReport {
70
71
  id: Number(id), project: this.project, by: this.session, status: "done",
71
72
  note: `Drill: trantor drill PASS\n${result.evidence.join("\n")}`.slice(0, 2000), // "Drill:" prefix: the hub's done gate (#6452) reads it as the drill line
72
73
  }, { project: this.project, session: this.session, timeoutMs: 15000 });
74
+ // A 404 on /task/update is the hub saying the card isn't on the board — the fleet cap deletes
75
+ // old cards (#11142), so a green drill can hold a stale map. That is not a failed seam: record
76
+ // it as gone and stay green. Any other refusal (403 cross-project, 409 done-gate, 500) is a
77
+ // real hub "no" and stays red (#11498). Never renumber the map to chase new card ids.
73
78
  this.closures[id] = response.ok && response.json?.task?.status === "done"
74
79
  && response.json.task.id === Number(id) && response.json.task.project === this.project
75
- ? "done" : `failed: hub ${response.status} ${response.json?.error || "did not confirm done"}`;
80
+ ? "done"
81
+ : response.status === 404 && response.json?.error === "no such task" ? "gone: card no longer on the board"
82
+ : `failed: hub ${response.status} ${response.json?.error || "did not confirm done"}`;
76
83
  this.write();
77
84
  }
78
- return Object.values(this.closures).every(value => value === "done");
85
+ // "gone" closures are not failures — the verdict must not red on a stale map.
86
+ return Object.values(this.closures).every(value => !value.startsWith("failed:"));
79
87
  }
80
88
  }
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { createServer, createConnection } from "node:net";
6
+ import { createServer as createHttpServer } from "node:http";
6
7
  import { DrillReport } from "./drill-report.mjs";
7
8
  import { CARD_STEPS, findHandoff } from "./drill-surface.mjs";
8
9
  import { appVerdict, startDrillHub, stopChild, checkSocketHome } from "./drill-seams.mjs";
@@ -72,12 +73,14 @@ test("signed closer on an enforce hub: complete evidence closes, partial/failure
72
73
  assert.equal(blocked.results()[blockedId].status, status);
73
74
  assert.equal((await signedGet("/tasks?project=trantor")).json.tasks.find(row => row.id === blockedId).status, "testing");
74
75
  }
76
+ // #11498: a green seam mapped to a card the board no longer has (the fleet cap deletes old
77
+ // cards, #11142) — the hub's 404 "no such task" is a stale map, recorded as gone, never red.
75
78
  const missing = new DrillReport({ 999999: { steps: ["step"], autoClose: true } }, path, { project, session });
76
79
  missing.record("step", "pass", "proof");
77
80
  missing.complete("step");
78
- assert.equal(await missing.closePassed(), false);
79
- assert.match(missing.results()[999999].closure, /failed: hub 404/);
80
- assert.equal(missing.exitCode(), 1);
81
+ assert.equal(await missing.closePassed(), true);
82
+ assert.equal(missing.results()[999999].closure, "gone: card no longer on the board");
83
+ assert.equal(missing.exitCode(), 0);
81
84
  const fresh = new DrillReport({ [id]: { steps: ["one"], autoClose: true } }, path, { project, session });
82
85
  assert.equal(fresh.results()[id].status, "fail", "a rerun cannot inherit old PASS evidence");
83
86
  } finally {
@@ -88,6 +91,61 @@ test("signed closer on an enforce hub: complete evidence closes, partial/failure
88
91
  }
89
92
  });
90
93
 
94
+ test("gone vs refused (#11498): a 404 no-such-task close is green, any other refusal stays red", async () => {
95
+ const out = join(import.meta.dirname, "..", ".agent-bus-out");
96
+ mkdirSync(out, { recursive: true });
97
+ const world = mkdtempSync(join(out, "refusal-test-"));
98
+ const bus = join(world, "bus");
99
+ mkdirSync(bus, { recursive: true });
100
+ // A stub hub that refuses per card id: 777 is gone (404 no-such-task, the fleet cap's answer,
101
+ // #11142), 4242 is a cross-project refusal, 5150 a server error — the closes a drill must keep red.
102
+ const answers = { 777: [404, "no such task"], 4242: [403, "cross-project writes are refused"], 5150: [500, "hub exploded"] };
103
+ const stub = createHttpServer((req, res) => {
104
+ let body = "";
105
+ req.on("data", d => { body += d; });
106
+ req.on("end", () => {
107
+ let id = null; try { id = JSON.parse(body).id; } catch {}
108
+ const [code, error] = answers[id] || [404, "no such task"];
109
+ res.writeHead(code, { "content-type": "application/json" });
110
+ res.end(JSON.stringify({ error }));
111
+ });
112
+ });
113
+ await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
114
+ const saved = { ...process.env };
115
+ Object.assign(process.env, { RELAY_URL: `http://127.0.0.1:${stub.address().port}`, AGENT_BUS_DIR: bus, RELAY_SESSION: "drill:trantor", RELAY_PROJECT: "trantor" });
116
+ const ctx = { project: "trantor", session: "drill:trantor" };
117
+ try {
118
+ const report = (id) => {
119
+ const r = new DrillReport({ [id]: { steps: ["step"], autoClose: true } }, join(world, `result-${id}.json`), ctx);
120
+ r.record("step", "pass", "proof", "real evidence");
121
+ r.complete("step");
122
+ return r;
123
+ };
124
+ const gone = report(777);
125
+ assert.equal(await gone.closePassed(), true, "404 no-such-task is a stale map, not a failed seam");
126
+ assert.equal(gone.results()[777].closure, "gone: card no longer on the board");
127
+ assert.equal(gone.exitCode(), 0);
128
+
129
+ // gone + refused together: one real hub refusal among gones still reds the drill
130
+ const mixed = new DrillReport({ 777: { steps: ["step"], autoClose: true }, 4242: { steps: ["step"], autoClose: true } }, join(world, "result-mixed.json"), ctx);
131
+ for (const id of [777, 4242]) { mixed.record("step", "pass", "proof", "real evidence"); mixed.complete("step"); }
132
+ assert.equal(await mixed.closePassed(), false);
133
+ assert.equal(mixed.results()[777].closure, "gone: card no longer on the board");
134
+ assert.match(mixed.results()[4242].closure, /^failed: hub 403/);
135
+ assert.equal(mixed.exitCode(), 1);
136
+
137
+ const dead = report(5150);
138
+ assert.equal(await dead.closePassed(), false, "a server error is a real refusal");
139
+ assert.match(dead.results()[5150].closure, /^failed: hub 500/);
140
+ assert.equal(dead.exitCode(), 1);
141
+ } finally {
142
+ for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key];
143
+ Object.assign(process.env, saved);
144
+ stub.close();
145
+ rmSync(world, { recursive: true, force: true });
146
+ }
147
+ });
148
+
91
149
  test("app proof requires every leg and rejects skipped key targets", () => {
92
150
  assert.deepEqual(CARD_STEPS[6317].steps, ["S6-key-post", "S6-key-throw"]);
93
151
  assert.equal(appVerdict("ask", "ask-drill PASS:"), false);
package/bin/keys.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // trantor keys — identity private keys in the macOS Keychain instead of plaintext files (#11370).
3
- // status [--json] · migrate [--dry-run] [--json] · prune [--apply] [--idle-days N]
3
+ // status [--json] · migrate [name...] [--include-tests] [--dry-run] [--json] · prune [--apply] [--idle-days N]
4
4
  // migrate copies each file-held key into the Keychain (service trantor-identity), proves a
5
5
  // signature round-trip, and only then rewrites the file as a pubkey-only stub. Keys are never printed.
6
6
  import { readFileSync } from "node:fs";
@@ -15,7 +15,7 @@ const verb = args[0] || "status";
15
15
  const JSON_MODE = args.includes("--json");
16
16
  const C = { dim: "\x1b[2m", grn: "\x1b[32m", yel: "\x1b[33m", red: "\x1b[31m", off: "\x1b[0m" };
17
17
  const usage = () => {
18
- console.error("usage: trantor keys status [--json] | migrate [--dry-run] [--json] | prune [--apply] [--idle-days N] [--json]");
18
+ console.error("usage: trantor keys status [--json] | migrate [name...] [--include-tests] [--dry-run] [--json] | prune [--apply] [--idle-days N] [--json]");
19
19
  process.exit(1);
20
20
  };
21
21
  const tilde = (p) => String(p).replace(process.env.HOME || "", "~");
@@ -37,14 +37,22 @@ function status() {
37
37
 
38
38
  function migrate() {
39
39
  const dryRun = args.includes("--dry-run");
40
- const r = migrateKeysToKeychain({ dryRun });
41
- if (JSON_MODE) { console.log(JSON.stringify(r, null, 2)); return; }
42
- if (!r.usable) {
40
+ const flags = new Set(["--dry-run", "--json", "--include-tests"]);
41
+ const names = [];
42
+ for (const arg of args.slice(1)) {
43
+ if (flags.has(arg)) continue;
44
+ if (arg.startsWith("--")) throw new Error(`unknown migrate option: ${arg}`);
45
+ names.push(arg);
46
+ }
47
+ const r = migrateKeysToKeychain({ dryRun, names, includeTests: args.includes("--include-tests") });
48
+ if (JSON_MODE) { console.log(JSON.stringify(r, null, 2)); if (r.failed.length) process.exitCode = 1; return; }
49
+ if (!r.usable && !dryRun) {
43
50
  console.log("no Keychain on this platform (or TRANTOR_NO_KEYCHAIN=1) — identity keys stay in files; nothing to do");
44
51
  return;
45
52
  }
46
53
  const did = dryRun ? "would move" : "moved";
47
54
  console.log(`${dryRun ? `${C.dim}dry run${C.off} · ` : ""}keychain service: ${r.service}`);
55
+ console.log(`skipped ${r.skippedTests} test identities (use --include-tests to include them)`);
48
56
  for (const n of r.moved) console.log(` ${C.grn}✓${C.off} ${did} ${n}${dryRun ? "" : " (file is now a pubkey-only stub)"}`);
49
57
  for (const n of r.already) console.log(` ${C.dim}already in the keychain: ${n}${C.off}`);
50
58
  for (const f of r.failed) console.log(` ${C.red}✗${C.off} ${f.name}: ${f.reason} — the file was left untouched`);
package/lib/identity.mjs CHANGED
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSy
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { join } from "node:path";
7
7
  import { homedir } from "node:os";
8
+ import { isTestIdentity } from "./test-identity-names.mjs";
8
9
  import { asFunction, asString } from "./decode.mjs";
9
10
 
10
11
  export const SCHEME = "trantor-v1";
@@ -227,9 +228,11 @@ function writeKeyStub(f, id) {
227
228
  // Copy each file-held private key into the Keychain, prove the stored copy SIGNS (a round-trip
228
229
  // against the file's pubkey), and only then rewrite the file as a pubkey-only stub. A key that
229
230
  // fails to store or to verify keeps its file untouched.
230
- export function migrateKeysToKeychain({ dryRun = false } = {}) {
231
- const result = { service: IDENTITY_SERVICE, usable: keychainUsable(), moved: [], already: [], failed: [], dryRun };
232
- if (!result.usable) return result;
231
+ export function migrateKeysToKeychain({ dryRun = false, names = [], includeTests = false } = {}) {
232
+ const result = { service: IDENTITY_SERVICE, usable: keychainUsable(), moved: [], already: [], failed: [], skippedTests: 0, dryRun };
233
+ if (!result.usable && !dryRun) return result;
234
+ const requested = new Set(names);
235
+ const found = new Set();
233
236
  for (const fileBase of listKeyNames()) {
234
237
  let id = null;
235
238
  try { id = JSON.parse(readFileSync(keyPath(fileBase), "utf8")); } catch {}
@@ -238,6 +241,12 @@ export function migrateKeysToKeychain({ dryRun = false } = {}) {
238
241
  // raw name, so storing under "name_proj" while hooks look for "name:proj" would orphan
239
242
  // every migrated key on its first read.
240
243
  const name = id?.name ?? fileBase;
244
+ if (requested.size && !requested.has(name)) continue;
245
+ found.add(name);
246
+ if (!requested.size && !includeTests && isTestIdentity(name)) {
247
+ result.skippedTests++;
248
+ continue;
249
+ }
241
250
  if (!id?.pubkey) { result.failed.push({ name, reason: "unreadable key file" }); continue; }
242
251
  if (!id.privkey) { result.already.push(name); continue; }
243
252
  if (dryRun) { result.moved.push(name); continue; }
@@ -255,6 +264,9 @@ export function migrateKeysToKeychain({ dryRun = false } = {}) {
255
264
  result.failed.push({ name, reason: String(e?.message || e) });
256
265
  }
257
266
  }
267
+ for (const name of requested) {
268
+ if (!found.has(name)) result.failed.push({ name, reason: "identity not found" });
269
+ }
258
270
  return result;
259
271
  }
260
272
 
package/lib/key-prune.mjs CHANGED
@@ -1,9 +1,8 @@
1
1
  import { lstatSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { keyPath, listKeyNames } from "./identity.mjs";
3
3
 
4
- // Exact fixture families with numeric run IDs; a generic test/demo substring is not enough.
5
- const TEST_NAME = /^(?:[A-Za-z0-9.-]+[_:])?(?:relay-selftest|hoff-selftest|consumedby|tt-h5|pctest|trantor-handoff|trantor-supersede|trantor-defer|cap-e2e|injcap|manual-skill)-\d+(?:[_:.-][\w:.-]+)?$/;
6
- export const isTestIdentity = name => TEST_NAME.test(name);
4
+ import { isTestIdentity } from "./test-identity-names.mjs";
5
+ export { isTestIdentity } from "./test-identity-names.mjs";
7
6
 
8
7
  export function pruneTestKeys({ snapshots, idleDays = 7, apply = false, now = Date.now() }) {
9
8
  if (!Number.isFinite(idleDays) || idleDays <= 0) throw new Error("--idle-days must be a positive number");
@@ -0,0 +1,4 @@
1
+ // Exact fixture families with numeric run IDs; a generic test/demo substring is not enough.
2
+ // Bare defer/supersede PID names come from test/handoff/test-handoff.mjs (#11501).
3
+ const TEST_NAME = /^(?:[A-Za-z0-9.-]+[_:])?(?:(?:defer|supersede)-\d+|(?:relay-selftest|hoff-selftest|consumedby|tt-h5|pctest|trantor-handoff|trantor-supersede|trantor-defer|cap-e2e|injcap|manual-skill)-\d+(?:[_:.-][\w:.-]+)?)$/;
4
+ export const isTestIdentity = name => TEST_NAME.test(name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.88",
3
+ "version": "0.18.90",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"