trantor 0.17.99 → 0.18.0

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.17.99",
3
+ "version": "0.18.0",
4
4
  "description": "Trantor \u2014 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/hub.mjs CHANGED
@@ -111,7 +111,7 @@ function scanTelemetry() {
111
111
  // TIMELINE view are untouched; every NEW type is dotted ("message", "presence.online", …) and is
112
112
  // filtered OUT of /history. Loads from the old `cardEvents` key when `events` is absent.
113
113
  function emptyState() {
114
- return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {}, dutySession: "", contractReap: {} };
114
+ return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
115
115
  }
116
116
 
117
117
  const CARD_LOG_MAX = 40;
@@ -188,6 +188,14 @@ function normalizeState(loaded = {}) {
188
188
  s.orgPolicy = loaded.orgPolicy && typeof loaded.orgPolicy === "object" ? loaded.orgPolicy : {};
189
189
  s.dutySession = String(loaded.dutySession || "");
190
190
  s.contractReap = loaded.contractReap && typeof loaded.contractReap === "object" ? loaded.contractReap : {};
191
+ // The event id high-water mark. Seeded from the store's own MAX(id) where it supplied one, and
192
+ // otherwise from the largest id in the array — never from the array TAIL, which is exactly the
193
+ // assumption that let a clobbered id mint colliding event ids and kill the log.
194
+ s.eventSeq = Math.max(
195
+ Number(loaded.eventSeq || 0),
196
+ ...s.events.map(e => Number(e?.id) || 0),
197
+ 0,
198
+ );
191
199
  for (const [session, v] of Object.entries(loaded.peers || {})) {
192
200
  // migrate old numeric form
193
201
  s.peers[session] = typeof v === "number"
@@ -203,6 +211,11 @@ if (STORE_KIND === "pg" || STORE_KIND === "postgres") {
203
211
  try {
204
212
  const { createPgStore } = await import("./lib/store-pg.mjs");
205
213
  durableStore = createPgStore({ url: PG_URL });
214
+ // An event insert that hits ON CONFLICT DO NOTHING is a LOST append, not a no-op. Silence here
215
+ // hid a dead log for 18 days. Never let it be quiet again.
216
+ durableStore.onDroppedEvent = ({ id, type }) => {
217
+ process.stderr.write(`[trantor] EVENT DROPPED: id ${id} (${type}) already exists — the append-only log is not appending. This is a bug, not routine.\n`);
218
+ };
206
219
  await durableStore.init();
207
220
  if (ORG_ID !== DEFAULT_ORG) await durableStore.createOrg({ id: ORG_ID, name: ORG_ID, ownerPubkey: "local-owner" });
208
221
  state = normalizeState(await durableStore.loadSnapshot(ORG_ID));
@@ -1039,8 +1052,13 @@ const CARD_TYPES = new Set(["created", "moved", "updated"]);
1039
1052
  const isCardEvent = e => CARD_TYPES.has(e?.type);
1040
1053
 
1041
1054
  function appendEvent(type, project, by, extra = {}) {
1055
+ // The id comes from a monotonic high-water mark, NOT from the tail of the array. Trusting the tail
1056
+ // meant one bad id anywhere in the log made every future append collide, and ON CONFLICT DO NOTHING
1057
+ // then dropped them all without a word. `extra` is spread FIRST so a stray id in a payload can
1058
+ // never take over the event's own identity.
1042
1059
  const last = state.events[state.events.length - 1];
1043
- const ev = { id: (last?.id || 0) + 1, ts: now(), type, project: project || "", by: by || "", ...extra };
1060
+ state.eventSeq = Math.max(Number(state.eventSeq || 0), Number(last?.id) || 0) + 1;
1061
+ const ev = { ...extra, id: state.eventSeq, ts: now(), type, project: project || "", by: by || "" };
1044
1062
  state.events.push(ev);
1045
1063
  if (state.events.length > EVENT_CAP) state.events.splice(0, state.events.length - EVENT_CAP);
1046
1064
  pushEventToStreams(ev);
@@ -1307,7 +1325,11 @@ const server = http.createServer(async (req, res) => {
1307
1325
  }
1308
1326
  for (const e of (Array.isArray(b.events) ? b.events : [])) {
1309
1327
  const last = state.events[state.events.length - 1];
1310
- const ev = { ...e, id: (last?.id || 0) + 1, project: proj };
1328
+ // Spread the incoming event FIRST, then stamp OUR id and project over it. The incoming `id`
1329
+ // belongs to the other hub's log and must not survive in any form: carried into the payload
1330
+ // it comes back on load and overwrites the real row id.
1331
+ state.eventSeq = Math.max(Number(state.eventSeq || 0), Number(last?.id) || 0) + 1;
1332
+ const ev = { ...e, id: state.eventSeq, project: proj };
1311
1333
  if (ev.taskId != null && remap.has(Number(ev.taskId))) ev.taskId = remap.get(Number(ev.taskId));
1312
1334
  state.events.push(ev); added.events++;
1313
1335
  }
@@ -2426,13 +2448,20 @@ const server = http.createServer(async (req, res) => {
2426
2448
  const windowMs = Math.max(60000, Number(q.windowMs || CONTRACT_WINDOW_MS));
2427
2449
  const rawOverdue = q.overdueMs === undefined || q.overdueMs === "" ? null : Number(q.overdueMs);
2428
2450
  const overdueMs = Number.isFinite(rawOverdue) ? Math.max(0, rawOverdue) : null;
2429
- const out = contractsFor(session, { project: String(q.project || ""), windowMs, overdueMs });
2430
- const by = (d) => out.filter(c => c.disposition === d).length;
2431
- // `open` stays the count a caller has to DO something about, which is what it always meant.
2432
- // Abandoned ones are reported separately: they are the ledger's record of what died, not work.
2451
+ const all = contractsFor(session, { project: String(q.project || ""), windowMs, overdueMs });
2452
+ const by = (d) => all.filter(c => c.disposition === d).length;
2453
+ // Abandoned contracts leave `contracts` entirely and ride in their own key.
2454
+ //
2455
+ // Not cosmetic. A session's hooks are PINNED at session start, so an older stop hook iterates
2456
+ // `contracts` with its own predicate and knows nothing about `disposition` — it kept blocking on
2457
+ // ghosts no matter what the hub called them. Keeping them in the array meant the fix only
2458
+ // reached sessions that restarted, and a live one nagged its operator every single turn.
2459
+ // Splitting them out fixes every running session the moment the hub redeploys, and the ledger
2460
+ // still shows what died via `abandonedContracts`.
2461
+ const out = all.filter(c => c.disposition !== "abandoned");
2433
2462
  return json(res, 200, {
2434
- session, contracts: out,
2435
- open: out.filter(c => !c.answered && c.disposition !== "abandoned").length,
2463
+ session, contracts: out, abandonedContracts: all.filter(c => c.disposition === "abandoned"),
2464
+ open: out.filter(c => !c.answered).length,
2436
2465
  waiting: by("waiting"), stalled: by("stalled"), abandoned: by("abandoned"), answered: by("answered"),
2437
2466
  });
2438
2467
  }
package/lib/store-pg.mjs CHANGED
@@ -10,24 +10,34 @@ const ms = (v, fallback = Date.now()) => {
10
10
  };
11
11
  const num = (v) => (v == null ? v : Number(v));
12
12
 
13
+ const EVENT_COLUMN_KEYS = ["id", "ts", "type", "project", "by", "by_session", "taskId", "task_id", "payload"];
13
14
  function stripEventPayload(evt = {}) {
14
15
  const payload = { ...(evt.payload && typeof evt.payload === "object" ? evt.payload : {}) };
16
+ // The nested payload gets the same treatment as the top level. It used to be copied in wholesale,
17
+ // so a column key riding inside it (an `id` from an imported event) was stored and then clobbered
18
+ // the real column on the way back out.
19
+ for (const k of EVENT_COLUMN_KEYS) delete payload[k];
15
20
  for (const [k, v] of Object.entries(evt)) {
16
- if (["id", "ts", "type", "project", "by", "by_session", "taskId", "task_id", "payload"].includes(k)) continue;
21
+ if (EVENT_COLUMN_KEYS.includes(k)) continue;
17
22
  payload[k] = v;
18
23
  }
19
24
  return payload;
20
25
  }
21
26
 
27
+ // The COLUMNS are the truth; the payload is only the fields that have no column. Spreading payload
28
+ // LAST let a stored `id` overwrite the row id, and that silently killed the append-only log for 18
29
+ // days: 1198 rows (8706..9903 on the production hub) carried a shadow payload id, so on load the
30
+ // array tail reported id 4655 instead of 9903, appendEvent then minted 4656 and every insert after
31
+ // that hit ON CONFLICT (id) DO NOTHING. Payload first, columns after: a column can never be clobbered.
22
32
  function eventFromRow(row) {
23
33
  const payload = row.payload && typeof row.payload === "object" ? row.payload : {};
24
34
  const ev = {
35
+ ...payload,
25
36
  id: Number(row.id),
26
37
  ts: Number(row.ts),
27
38
  type: row.type,
28
39
  project: row.project || "",
29
40
  by: row.by_session || "",
30
- ...payload,
31
41
  };
32
42
  if (row.task_id != null) ev.taskId = Number(row.task_id);
33
43
  return ev;
@@ -129,6 +139,7 @@ function kvFromState(state) {
129
139
  verifyGateSeq: Number(state.verifyGateSeq || 0),
130
140
  proposalSeq: Number(state.proposalSeq || 0),
131
141
  cardEventsBackfilled: !!state.cardEventsBackfilled,
142
+ eventSeq: Number(state.eventSeq || 0),
132
143
  inviteTokens: state.inviteTokens || {},
133
144
  instances: state.instances || {},
134
145
  },
@@ -542,12 +553,18 @@ export class PgStore {
542
553
  }
543
554
  if (tasks.deletes.length) await c.query("DELETE FROM tasks WHERE org_id=$1 AND id = ANY($2::bigint[])", [orgId, tasks.deletes]);
544
555
  for (const e of events.upserts) {
545
- await c.query(
556
+ const r = await c.query(
546
557
  `INSERT INTO events(id, org_id, ts, type, project, by_session, task_id, payload)
547
558
  VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb)
548
559
  ON CONFLICT (id) DO NOTHING`,
549
560
  [Number(e.id), orgId, ms(e.ts), e.type || "", e.project || "", e.by || "", e.taskId ?? null, JSON.stringify(stripEventPayload(e))],
550
561
  );
562
+ // DO NOTHING protects a foreign row, but it also swallows a REAL append when the id collides.
563
+ // That is what hid the dead log for 18 days: every event was dropped and nothing said a word.
564
+ // A conflict here is never routine, so say so.
565
+ if (r?.rowCount === 0) {
566
+ this.onDroppedEvent?.({ id: Number(e.id), type: e.type || "", orgId });
567
+ }
551
568
  }
552
569
  if (events.deletes.length) await c.query("DELETE FROM events WHERE org_id=$1 AND id = ANY($2::bigint[])", [orgId, events.deletes]);
553
570
  if (events.upserts.length) await c.query("SELECT setval(pg_get_serial_sequence('events','id'), GREATEST((SELECT COALESCE(MAX(id),0) FROM events), 1), true)");
@@ -634,13 +651,18 @@ export class PgStore {
634
651
  }
635
652
 
636
653
  async loadSnapshot(orgId) {
637
- const [tasks, peersRows, eventsRows, messagesRows, identitiesRows, kvRows] = await Promise.all([
654
+ const [tasks, peersRows, eventsRows, messagesRows, identitiesRows, kvRows, eventMaxRow] = await Promise.all([
638
655
  this.pool.query("SELECT * FROM tasks WHERE org_id=$1 ORDER BY id ASC", [orgId]),
639
656
  this.pool.query("SELECT * FROM peers WHERE org_id=$1 ORDER BY session ASC", [orgId]),
640
657
  this.pool.query("SELECT * FROM events WHERE org_id=$1 ORDER BY id ASC", [orgId]),
641
658
  this.pool.query("SELECT * FROM messages WHERE org_id=$1 ORDER BY id ASC", [orgId]),
642
659
  this.pool.query("SELECT * FROM identities WHERE org_id=$1 ORDER BY created_at ASC", [orgId]),
643
660
  this.pool.query("SELECT key, value FROM kv WHERE org_id=$1", [orgId]),
661
+ // The authoritative high-water mark for event ids. Deriving it from the loaded array is what
662
+ // broke: a clobbered tail reported a lower id than the table actually held. Ask the table.
663
+ // Deliberately NOT scoped to org: the events primary key is (id) alone, so the id space is
664
+ // global and a per-org max would still collide across orgs.
665
+ this.pool.query("SELECT COALESCE(MAX(id),0) AS max_id FROM events"),
644
666
  ]);
645
667
  const kv = Object.fromEntries(kvRows.rows.map(r => [r.key, r.value]));
646
668
  const peers = {};
@@ -683,6 +705,7 @@ export class PgStore {
683
705
  focus: kv.focus && typeof kv.focus === "object" ? kv.focus : {},
684
706
  orgPolicy: kv.orgPolicy && typeof kv.orgPolicy === "object" ? kv.orgPolicy : {},
685
707
  contractReap: kv.contractReap && typeof kv.contractReap === "object" ? kv.contractReap : {},
708
+ eventSeq: Math.max(Number(eventMaxRow?.rows?.[0]?.max_id || 0), Number(meta.eventSeq || 0)),
686
709
  };
687
710
  }
688
711
  }
package/mcp.mjs CHANGED
@@ -141,7 +141,9 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
141
141
  let r;
142
142
  try { r = await api("GET", `/contracts?session=${encodeURIComponent(SESSION)}&project=${encodeURIComponent(PROJECT)}`); }
143
143
  catch (e) { return { content: [{ type: "text", text: `could not reach the hub: ${e?.message || e}` }] }; }
144
- const all = r?.contracts || [];
144
+ // The hub keeps abandoned contracts in their own key so older stop hooks stop blocking on them.
145
+ // The ledger still wants to SHOW them, so put the two halves back together here.
146
+ const all = [...(r?.contracts || []), ...(r?.abandonedContracts || [])].sort((a, b) => a.ts - b.ts);
145
147
  if (!all.length) return { content: [{ type: "text", text: "You have not dispatched any contracts in the last 24h." }] };
146
148
  // Fall back to the pre-disposition shape when talking to an older hub.
147
149
  const disp = (c) => c.disposition || (c.answered ? "answered" : (c.assigneeOnline ? "waiting" : "stalled"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.99",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"