trantor 0.17.65 → 0.17.66

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.65",
3
+ "version": "0.17.66",
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/hub.mjs CHANGED
@@ -212,8 +212,16 @@ async function reloadFromStore() {
212
212
  let _overseer = null;
213
213
  import("./lib/overseer.mjs").then(m => { _overseer = m; }).catch(() => {});
214
214
  const OVERSEER_TICK_MS = Number(process.env.RELAY_OVERSEER_TICK_MS || 30 * 1000);
215
- const OVERSEER_DEDUP_MS = Number(process.env.RELAY_OVERSEER_DEDUP_MS || 10 * 60 * 1000);
216
- const overseerWarned = new Map(); // dedup key -> ts
215
+ // How long a condition must be ABSENT before we consider the episode over. This is NOT a re-warn
216
+ // timer: see overseerTick.
217
+ const OVERSEER_CLEAR_MS = Number(process.env.RELAY_OVERSEER_CLEAR_MS || process.env.RELAY_OVERSEER_DEDUP_MS || 10 * 60 * 1000);
218
+ // Standing conditions, keyed by collision identity -> { since, lastTick }. A collision is a STATE,
219
+ // not an event: it persists. Emitting on a 10-minute timer turned the watcher into a metronome —
220
+ // 500 events for 4 distinct conditions (2026-08-12 audit), each one also waking the duty seat for a
221
+ // full turn. Now an episode fires ONCE when it starts and stays quiet while it holds; the entry is
222
+ // forgotten only after the condition has been gone for OVERSEER_CLEAR_MS, so a genuine recurrence
223
+ // warns again.
224
+ const overseerActive = new Map();
217
225
  // Heartbeat for the WATCHER itself: /overseer/status must distinguish "fleet is clear" from "the
218
226
  // overseer stopped ticking" — a monitor that cannot prove it is alive reads as clear when dead.
219
227
  let overseerLastTick = 0;
@@ -240,14 +248,18 @@ function overseerTick() {
240
248
  if (!_overseer?.detectCollisions) return;
241
249
  let collisions = [];
242
250
  try { collisions = _overseer.detectCollisions(overseerInputs()) || []; } catch { return; }
243
- overseerLastTick = now(); overseerLastCollisions = collisions;
244
- const cut = now() - OVERSEER_DEDUP_MS;
245
- for (const [k, ts] of overseerWarned) if (ts < cut) overseerWarned.delete(k);
251
+ const t = now();
252
+ overseerLastTick = t;
246
253
  const pol = overseerPolicy();
254
+ const seen = new Set();
247
255
  for (const c of collisions) {
248
256
  const key = `${c.project} ${c.kind} ${(c.sessions || []).join(",")} ${(c.files || []).join(",")}`;
249
- if (overseerWarned.has(key)) continue;
250
- overseerWarned.set(key, now());
257
+ c.key = key;
258
+ seen.add(key);
259
+ const standing = overseerActive.get(key);
260
+ if (standing) { standing.lastTick = t; c.since = standing.since; continue; } // holds — stay quiet
261
+ overseerActive.set(key, { since: t, lastTick: t });
262
+ c.since = t;
251
263
  appendEvent("overseer.warn", c.project, "overseer",
252
264
  { kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
253
265
  if (DUTY_SESSION) hubSend(DUTY_SESSION, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
@@ -260,8 +272,18 @@ function overseerTick() {
260
272
  appendEvent("verify.gate.opened", c.project, "overseer", { gateId: g.id, claim: g.claim, why: g.why });
261
273
  }
262
274
  }
275
+ // Episode end: a condition gone for the whole clear window is over, so a LATER recurrence is a
276
+ // new episode and warns again. Without this the map would grow forever and nothing could re-fire.
277
+ for (const [k, v] of overseerActive) {
278
+ if (!seen.has(k) && t - v.lastTick > OVERSEER_CLEAR_MS) overseerActive.delete(k);
279
+ }
280
+ overseerLastCollisions = collisions;
263
281
  }
264
282
  setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
283
+ // setInterval waits a FULL period before its first call, so for 30s after every restart the watcher
284
+ // had no lastTick and honestly reported itself stalled. Tick once shortly after boot (the delay lets
285
+ // the lazy lib import land) so a restarted hub proves it is alive immediately.
286
+ setTimeout(overseerTick, 2000).unref?.();
265
287
 
266
288
  // --- the DUTY AGENT feed (deterministic escalation; the seat itself is bin/duty.mjs) -----------
267
289
  // RELAY_DUTY_SESSION names the always-on triage seat (e.g. "claude:fleet"). The hub DMs it when:
@@ -1030,7 +1052,7 @@ const server = http.createServer(async (req, res) => {
1030
1052
  engine: !!_overseer?.detectCollisions,
1031
1053
  lastTickTs: overseerLastTick,
1032
1054
  tickMs: OVERSEER_TICK_MS,
1033
- dedupMs: OVERSEER_DEDUP_MS,
1055
+ clearMs: OVERSEER_CLEAR_MS,
1034
1056
  dutySession: DUTY_SESSION || "",
1035
1057
  watching: {
1036
1058
  sessions: livePeers.length,
@@ -1040,8 +1062,10 @@ const server = http.createServer(async (req, res) => {
1040
1062
  },
1041
1063
  autonomy: pol.autonomy,
1042
1064
  links: pol.links,
1043
- warnings: overseerLastCollisions,
1044
- warnedRecent: overseerWarned.size,
1065
+ // `since` turns a detection into a duration — "standing 4h" reads very differently from
1066
+ // "just started", and that distinction is the whole point of episode-based warning.
1067
+ warnings: overseerLastCollisions.map(c => ({ ...c, since: c.since || 0 })),
1068
+ standing: overseerActive.size,
1045
1069
  });
1046
1070
  }
1047
1071
  if (req.method === "GET" && P === "/overseer/context") {
package/lib/overseer.mjs CHANGED
@@ -1,5 +1,12 @@
1
- const PEER_LIVE_MS = 5 * 60 * 1000;
2
- const CLAIM_LIVE_MS = 10 * 60 * 1000;
1
+ // Windows are read from env ONCE at load (calls stay pure): without an override, a test cannot
2
+ // observe a condition CLEARING — peers stay "live" for five minutes no matter what the test does,
3
+ // so the episode-recurrence path was untestable and therefore unproven.
4
+ const PEER_LIVE_MS = Number(process.env.RELAY_OVERSEER_PEER_LIVE_MS || 5 * 60 * 1000);
5
+ const CLAIM_LIVE_MS = Number(process.env.RELAY_OVERSEER_CLAIM_LIVE_MS || 10 * 60 * 1000);
6
+ // "Actively executing", not merely "window open" — the same 90s bar the desktop app calls `busy`
7
+ // (the heartbeat fires on tool calls, so a fresher-than-90s peer is mid-turn). Presence alone is
8
+ // too weak a signal to call a collision: see the linked-activity note below.
9
+ const WORK_LIVE_MS = Number(process.env.RELAY_OVERSEER_WORK_LIVE_MS || 90 * 1000);
3
10
 
4
11
  const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity"]);
5
12
 
@@ -119,18 +126,40 @@ export function detectCollisions({ peers = [], claims = [], links = [], autonomy
119
126
  });
120
127
  }
121
128
 
129
+ // A link is DECLARED — the operator already told us these projects move together. Warning merely
130
+ // because both have a session OPEN restates that declaration, and it stays true for hours:
131
+ // crebral-health ↔ crebral-scribe produced 468 identical warnings across 8 days (2026-08-12
132
+ // audit), which is how a monitor teaches you to ignore it. The signal worth raising is
133
+ // CONCURRENT WORK — each side actually executing (heartbeat inside WORK_LIVE_MS) or holding a
134
+ // fresh file claim. Two idle-open windows are not a collision.
135
+ const workingByProject = new Map();
136
+ for (const peer of livePeers) {
137
+ if (!isFresh(peer?.lastSeen, at, WORK_LIVE_MS)) continue;
138
+ const sessions = workingByProject.get(peer.project) ?? [];
139
+ sessions.push(peer.session);
140
+ workingByProject.set(peer.project, sessions);
141
+ }
142
+ for (const claim of asArray(claims)) {
143
+ const project = clean(claim?.project);
144
+ const session = clean(claim?.session);
145
+ if (!project || !session || !isFresh(claim?.ts, at, CLAIM_LIVE_MS)) continue;
146
+ const sessions = workingByProject.get(project) ?? [];
147
+ sessions.push(session);
148
+ workingByProject.set(project, sessions);
149
+ }
150
+
122
151
  for (const link of asArray(links)) {
123
152
  const projects = sortedStrings(link?.projects ?? []);
124
153
  if (projects.length < 2) continue;
125
- const activeProjects = projects.filter((project) => (sessionsByProject.get(project) ?? []).length > 0);
154
+ const activeProjects = projects.filter((project) => (workingByProject.get(project) ?? []).length > 0);
126
155
  if (activeProjects.length < 2) continue;
127
- const sessions = sortedStrings(activeProjects.flatMap((project) => sessionsByProject.get(project) ?? []));
156
+ const sessions = sortedStrings(activeProjects.flatMap((project) => workingByProject.get(project) ?? []));
128
157
  pushCollision(out, {
129
158
  project: activeProjects[0],
130
159
  kind: "linked-activity",
131
160
  sessions,
132
161
  files: [],
133
- detail: `Linked projects ${activeProjects.join(", ")} have live sessions ${sessions.join(", ")}.`,
162
+ detail: `Linked projects ${activeProjects.join(", ")} are being worked on at the same time by ${sessions.join(", ")}.`,
134
163
  });
135
164
  }
136
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.65",
3
+ "version": "0.17.66",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"