trantor 0.17.98 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/stop-inbox.mjs +25 -7
- package/hub.mjs +179 -33
- package/lib/store-contract.mjs +5 -2
- package/lib/store-pg.mjs +29 -4
- package/mcp.mjs +24 -8
- package/package.json +1 -1
- package/skills/crew/SKILL.md +11 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.
|
|
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/hooks/stop-inbox.mjs
CHANGED
|
@@ -54,10 +54,17 @@ function readStdin() {
|
|
|
54
54
|
// complaint this exists to answer. So before letting a stop through we ask the hub what this
|
|
55
55
|
// session is still owed, and refuse ONCE if any of it has gone quiet.
|
|
56
56
|
//
|
|
57
|
-
// Deliberately narrow: only a contract
|
|
58
|
-
// An open contract with a healthy seat working on it is exactly what "in progress"
|
|
59
|
-
// nagging about it every stop would be worse than saying nothing. Fail-open
|
|
60
|
-
// is down or slow must never trap a session.
|
|
57
|
+
// Deliberately narrow: only a contract the hub calls `stalled` blocks — assignee offline, or overdue
|
|
58
|
+
// while online. An open contract with a healthy seat working on it is exactly what "in progress"
|
|
59
|
+
// looks like, and nagging about it every stop would be worse than saying nothing. Fail-open
|
|
60
|
+
// throughout: a hub that is down or slow must never trap a session.
|
|
61
|
+
//
|
|
62
|
+
// `abandoned` deliberately does NOT block. Its assignee has been quiet so long the contract can no
|
|
63
|
+
// longer resolve itself, and blocking on it would nag every future session forever about a seat that
|
|
64
|
+
// is never coming back — the ghost problem this hook was accused of causing. The lifecycle guarantees
|
|
65
|
+
// the dispatcher was already told: a contract is always `stalled` (and blocking) for a long while
|
|
66
|
+
// before it can become `abandoned`. After that the hub keeps it in the ledger, and relay_contracts
|
|
67
|
+
// still shows it, but it stops trapping anyone.
|
|
61
68
|
const OVERDUE_MS = (() => {
|
|
62
69
|
const raw = process.env.TRANTOR_CONTRACT_OVERDUE_MS;
|
|
63
70
|
const n = raw === undefined || raw === "" ? NaN : Number(raw);
|
|
@@ -68,13 +75,18 @@ async function stalledContractCheck({ session, project, instanceId }) {
|
|
|
68
75
|
if (process.env.RELAY_STOP_CONTRACTS === "0") return allow();
|
|
69
76
|
let contracts = [];
|
|
70
77
|
try {
|
|
71
|
-
const r = await signedGet(`/contracts?session=${encodeURIComponent(session)}&project=${encodeURIComponent(project)}`,
|
|
78
|
+
const r = await signedGet(`/contracts?session=${encodeURIComponent(session)}&project=${encodeURIComponent(project)}&overdueMs=${OVERDUE_MS}`,
|
|
72
79
|
{ timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId, project });
|
|
73
80
|
if (!r.ok) return allow();
|
|
74
81
|
contracts = r.json?.contracts || [];
|
|
75
82
|
} catch { return allow(); }
|
|
76
83
|
|
|
77
|
-
|
|
84
|
+
// The hub owns the verdict now (it also knows the abandon window). An older hub that does not send
|
|
85
|
+
// `disposition` falls back to the original predicate, so a new hook against an old hub is unchanged.
|
|
86
|
+
const stalled = contracts.filter(c => c.disposition
|
|
87
|
+
? c.disposition === "stalled"
|
|
88
|
+
: (!c.answered && (!c.assigneeOnline || c.ageMs >= OVERDUE_MS)));
|
|
89
|
+
const abandoned = contracts.filter(c => c.disposition === "abandoned");
|
|
78
90
|
if (!stalled.length) return allow();
|
|
79
91
|
|
|
80
92
|
const mins = (ms) => (ms >= 60000 ? `${Math.round(ms / 60000)}m` : `${Math.round(ms / 1000)}s`);
|
|
@@ -85,9 +97,15 @@ async function stalledContractCheck({ session, project, instanceId }) {
|
|
|
85
97
|
return `- ${sanitize(c.to)} (${health}) — asked ${mins(c.ageMs)} ago: "${sanitize(String(c.text).slice(0, 120))}"`;
|
|
86
98
|
}).join("\n");
|
|
87
99
|
|
|
100
|
+
const ghosts = abandoned.length
|
|
101
|
+
? `\n\nAlso ${abandoned.length} contract(s) are ABANDONED — their assignee has been gone long enough ` +
|
|
102
|
+
`that they can never be answered (${abandoned.slice(0, 3).map(c => sanitize(c.to)).join(", ")}). ` +
|
|
103
|
+
`They no longer block you and you will not be told again. If that work still matters, reassign it.`
|
|
104
|
+
: "";
|
|
105
|
+
|
|
88
106
|
const reason =
|
|
89
107
|
`You dispatched ${stalled.length} contract(s) that have gone quiet, and you were about to go idle:\n` +
|
|
90
|
-
lines + `\n\n` +
|
|
108
|
+
lines + ghosts + `\n\n` +
|
|
91
109
|
`Do not just wait, and do not ask the human to check. Use relay_contracts to see everything you are owed, ` +
|
|
92
110
|
`relay_peers to see whether the seat is alive, and relay_send to ask it directly. If a seat is down, say so and ` +
|
|
93
111
|
`either swap it (\`trantor swap <agent>\`) or reassign the work. If the work is genuinely still running, say that ` +
|
package/hub.mjs
CHANGED
|
@@ -46,6 +46,22 @@ const FOCUS_OFFLINE_MS = Number(process.env.RELAY_FOCUS_OFFLINE_MS || ONLINE_MS)
|
|
|
46
46
|
// untouched for an hour is routine — a big task runs a long time between prompts.
|
|
47
47
|
const FOCUS_IDLE_MS = Number(process.env.RELAY_FOCUS_IDLE_MS || 6 * 60 * 60 * 1000);
|
|
48
48
|
const REAP_INTERVAL_MS = Number(process.env.RELAY_REAP_INTERVAL_MS || 60000); // how often the reaper sweeps (env-tunable; tests set it low)
|
|
49
|
+
// Contract lifecycle. A contract closes when the ASSIGNEE answers — so a seat that does the work and
|
|
50
|
+
// then dies never closes one, and a session found 16 such ghosts in a day, every one with its files
|
|
51
|
+
// already on disk. The dispatcher then gets nagged about them at every stop, forever, and the human
|
|
52
|
+
// becomes the one who remembers. That is the complaint this whole area exists to answer.
|
|
53
|
+
//
|
|
54
|
+
// The fix is the stale-card lane's shape, not an auto-close: quiet is NOT proof of death, so the hub
|
|
55
|
+
// never marks a contract answered on the assignee's behalf. Instead a contract walks a lifecycle —
|
|
56
|
+
// waiting assignee online and inside the overdue window; this is what progress looks like
|
|
57
|
+
// stalled assignee offline, or overdue while online; ACTIONABLE, and what blocks a stop
|
|
58
|
+
// abandoned assignee quiet past CONTRACT_ABANDON_MS; it can no longer resolve itself
|
|
59
|
+
// — and only the middle state nags. Abandoned ones stay in the ledger with their evidence so
|
|
60
|
+
// `relay_contracts` can still show what died, but they stop trapping every future session.
|
|
61
|
+
// The window is deliberately much longer than the stop hook's overdue window, so a contract is
|
|
62
|
+
// always surfaced as `stalled` (and acted on) BEFORE it can go quiet as `abandoned`.
|
|
63
|
+
const CONTRACT_ABANDON_MS = Number(process.env.RELAY_CONTRACT_ABANDON_MS || 60 * 60 * 1000);
|
|
64
|
+
const CONTRACT_WINDOW_MS = Number(process.env.RELAY_CONTRACT_WINDOW_MS || 24 * 60 * 60 * 1000);
|
|
49
65
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
50
66
|
const isLoopbackHost = (host) => {
|
|
51
67
|
const h = String(host || "").toLowerCase();
|
|
@@ -95,7 +111,7 @@ function scanTelemetry() {
|
|
|
95
111
|
// TIMELINE view are untouched; every NEW type is dotted ("message", "presence.online", …) and is
|
|
96
112
|
// filtered OUT of /history. Loads from the old `cardEvents` key when `events` is absent.
|
|
97
113
|
function emptyState() {
|
|
98
|
-
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: "" };
|
|
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 };
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
const CARD_LOG_MAX = 40;
|
|
@@ -171,6 +187,15 @@ function normalizeState(loaded = {}) {
|
|
|
171
187
|
s.focus = loaded.focus && typeof loaded.focus === "object" ? loaded.focus : {};
|
|
172
188
|
s.orgPolicy = loaded.orgPolicy && typeof loaded.orgPolicy === "object" ? loaded.orgPolicy : {};
|
|
173
189
|
s.dutySession = String(loaded.dutySession || "");
|
|
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
|
+
);
|
|
174
199
|
for (const [session, v] of Object.entries(loaded.peers || {})) {
|
|
175
200
|
// migrate old numeric form
|
|
176
201
|
s.peers[session] = typeof v === "number"
|
|
@@ -186,6 +211,11 @@ if (STORE_KIND === "pg" || STORE_KIND === "postgres") {
|
|
|
186
211
|
try {
|
|
187
212
|
const { createPgStore } = await import("./lib/store-pg.mjs");
|
|
188
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
|
+
};
|
|
189
219
|
await durableStore.init();
|
|
190
220
|
if (ORG_ID !== DEFAULT_ORG) await durableStore.createOrg({ id: ORG_ID, name: ORG_ID, ownerPubkey: "local-owner" });
|
|
191
221
|
state = normalizeState(await durableStore.loadSnapshot(ORG_ID));
|
|
@@ -605,6 +635,124 @@ function reapStaleCards() {
|
|
|
605
635
|
}
|
|
606
636
|
setInterval(reapStaleCards, REAP_INTERVAL_MS).unref?.();
|
|
607
637
|
|
|
638
|
+
// ---- the contract ledger -------------------------------------------------------------------
|
|
639
|
+
// ONE derivation, shared by GET /contracts and the reaper below. Two copies of this is how you get
|
|
640
|
+
// an endpoint and a sweeper that disagree about what is open, which is the same "two names for one
|
|
641
|
+
// intent" mistake the spawn guards made.
|
|
642
|
+
//
|
|
643
|
+
// A contract is a DIRECT message from `session` to one peer. It closes when that peer answers:
|
|
644
|
+
// strictly by `re`, or, for seats that predate that column, oldest-open-first. Excluded outright,
|
|
645
|
+
// because none of these can ever be answered and so would hang open forever:
|
|
646
|
+
// - broadcasts (`to === "all"`)
|
|
647
|
+
// - self-dispatch (`from === to`) — a reply from yourself is never counted as an answer
|
|
648
|
+
// - the hub's own pseudo-identities (`hub:*`) and hub-authored mail — nothing polls them (0.17.87)
|
|
649
|
+
// Durations an agent READS and acts on, so they must not round to nonsense. Minutes are the useful
|
|
650
|
+
// unit in production (the abandon window is an hour), but the drills run in seconds and "past the 0m
|
|
651
|
+
// abandon window" is a sentence that tells the reader nothing.
|
|
652
|
+
function humanMs(ms) {
|
|
653
|
+
const n = Math.max(0, Number(ms) || 0);
|
|
654
|
+
if (n < 60000) return `${Math.max(1, Math.round(n / 1000))}s`;
|
|
655
|
+
if (n < 3600000) return `${Math.round(n / 60000)}m`;
|
|
656
|
+
return `${(n / 3600000).toFixed(n < 36000000 ? 1 : 0)}h`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function contractRecipientIsAnswerable(m) {
|
|
660
|
+
return !!m.to && m.to !== "all" && m.from !== m.to && !m.to.startsWith("hub:") && !m.from.startsWith("hub:");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, overdueMs = null } = {}) {
|
|
664
|
+
const t = now();
|
|
665
|
+
const cutoff = t - windowMs;
|
|
666
|
+
const abandonCut = t - CONTRACT_ABANDON_MS;
|
|
667
|
+
const onCut = t - ONLINE_MS;
|
|
668
|
+
const mine = state.messages.filter(m =>
|
|
669
|
+
m.from === session && m.ts >= cutoff && contractRecipientIsAnswerable(m) && (!project || m.project === project));
|
|
670
|
+
const replies = state.messages.filter(m => m.to === session && m.from !== session && m.ts >= cutoff);
|
|
671
|
+
const byRe = new Map();
|
|
672
|
+
for (const r of replies) if (r.re) byRe.set(Number(r.re), r);
|
|
673
|
+
const looseByPeer = new Map();
|
|
674
|
+
for (const r of replies) if (!r.re) { if (!looseByPeer.has(r.from)) looseByPeer.set(r.from, []); looseByPeer.get(r.from).push(r); }
|
|
675
|
+
for (const arr of looseByPeer.values()) arr.sort((a, b) => a.ts - b.ts);
|
|
676
|
+
|
|
677
|
+
const out = [];
|
|
678
|
+
for (const c of mine.sort((a, b) => a.ts - b.ts)) {
|
|
679
|
+
let answer = byRe.get(c.id) || null;
|
|
680
|
+
if (!answer) {
|
|
681
|
+
const pool = looseByPeer.get(c.to) || [];
|
|
682
|
+
const i = pool.findIndex(r => r.ts > c.ts);
|
|
683
|
+
if (i >= 0) answer = pool.splice(i, 1)[0];
|
|
684
|
+
}
|
|
685
|
+
const peer = state.peers[c.to] || null;
|
|
686
|
+
const seen = peer?.lastSeen || 0;
|
|
687
|
+
const online = !!seen && seen > onCut;
|
|
688
|
+
const ageMs = t - c.ts;
|
|
689
|
+
const reaped = state.contractReap?.[String(c.id)] || null;
|
|
690
|
+
|
|
691
|
+
// An answer ALWAYS wins, including over a recorded abandonment: the reap is evidence, not a
|
|
692
|
+
// tombstone. A seat that comes back and reports still closes its own contract.
|
|
693
|
+
let disposition;
|
|
694
|
+
if (answer) disposition = "answered";
|
|
695
|
+
else if (seen < abandonCut) disposition = "abandoned"; // covers never-seen (seen === 0)
|
|
696
|
+
else if (!online || (overdueMs != null && ageMs >= overdueMs)) disposition = "stalled";
|
|
697
|
+
else disposition = "waiting";
|
|
698
|
+
|
|
699
|
+
out.push({
|
|
700
|
+
id: c.id, to: c.to, text: c.text, ts: c.ts, ageMs,
|
|
701
|
+
answered: !!answer,
|
|
702
|
+
answer: answer ? { id: answer.id, ts: answer.ts, text: answer.text } : null,
|
|
703
|
+
disposition,
|
|
704
|
+
assigneeOnline: online,
|
|
705
|
+
assigneeStatus: String(peer?.status || ""),
|
|
706
|
+
assigneeLastSeenMs: seen ? t - seen : null,
|
|
707
|
+
reaped: reaped ? { ts: reaped.ts, reason: reaped.reason } : null,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
return out;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Every session that has dispatched inside the ledger window. The reaper needs all of them; the
|
|
714
|
+
// endpoint only ever asks about one.
|
|
715
|
+
function contractDispatchers(windowMs = CONTRACT_WINDOW_MS) {
|
|
716
|
+
const cutoff = now() - windowMs;
|
|
717
|
+
const set = new Set();
|
|
718
|
+
for (const m of state.messages) if (m.ts >= cutoff && contractRecipientIsAnswerable(m)) set.add(m.from);
|
|
719
|
+
return set;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// The contract reaper. Records — never invents an answer for — a contract whose assignee has been
|
|
723
|
+
// quiet past CONTRACT_ABANDON_MS, so the abandonment survives a hub restart (in-memory-only state is
|
|
724
|
+
// exactly why the escalation backlog re-fires on every restart) and shows up once in the FEED.
|
|
725
|
+
// After this the contract stops counting as open, so it stops nagging every future session; it stays
|
|
726
|
+
// listed with its evidence, so `relay_contracts` can still show what died.
|
|
727
|
+
function reapAbandonedContracts() {
|
|
728
|
+
let changed = false;
|
|
729
|
+
for (const session of contractDispatchers()) {
|
|
730
|
+
for (const c of contractsFor(session)) {
|
|
731
|
+
if (c.disposition !== "abandoned") continue;
|
|
732
|
+
const key = String(c.id);
|
|
733
|
+
if (state.contractReap[key]) continue;
|
|
734
|
+
const quiet = c.assigneeLastSeenMs == null
|
|
735
|
+
? "never seen on the bus"
|
|
736
|
+
: `last seen ${humanMs(c.assigneeLastSeenMs)} ago`;
|
|
737
|
+
const reason = `assignee ${c.to} ${quiet}, past the ${humanMs(CONTRACT_ABANDON_MS)} abandon window`;
|
|
738
|
+
state.contractReap[key] = { ts: now(), from: session, to: c.to, reason, dispatchedTs: c.ts };
|
|
739
|
+
appendEvent("contract.abandoned", "", "reaper", {
|
|
740
|
+
msgId: c.id, fromSession: session, toSession: c.to, reason,
|
|
741
|
+
text: String(c.text || "").slice(0, 500),
|
|
742
|
+
});
|
|
743
|
+
changed = true;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// Forget reap records whose contract has aged out of the ledger window entirely — nothing can
|
|
747
|
+
// read them any more and the map would grow without bound.
|
|
748
|
+
const cutoff = now() - CONTRACT_WINDOW_MS;
|
|
749
|
+
for (const [key, r] of Object.entries(state.contractReap)) {
|
|
750
|
+
if ((r?.dispatchedTs || 0) < cutoff) { delete state.contractReap[key]; changed = true; }
|
|
751
|
+
}
|
|
752
|
+
if (changed) dirty = true;
|
|
753
|
+
}
|
|
754
|
+
setInterval(reapAbandonedContracts, REAP_INTERVAL_MS).unref?.();
|
|
755
|
+
|
|
608
756
|
// dashboard HTML (read once at startup)
|
|
609
757
|
let UI = "";
|
|
610
758
|
try { UI = readFileSync(new URL("./ui.html", import.meta.url), "utf8"); } catch {}
|
|
@@ -904,8 +1052,13 @@ const CARD_TYPES = new Set(["created", "moved", "updated"]);
|
|
|
904
1052
|
const isCardEvent = e => CARD_TYPES.has(e?.type);
|
|
905
1053
|
|
|
906
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.
|
|
907
1059
|
const last = state.events[state.events.length - 1];
|
|
908
|
-
|
|
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 || "" };
|
|
909
1062
|
state.events.push(ev);
|
|
910
1063
|
if (state.events.length > EVENT_CAP) state.events.splice(0, state.events.length - EVENT_CAP);
|
|
911
1064
|
pushEventToStreams(ev);
|
|
@@ -1172,7 +1325,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1172
1325
|
}
|
|
1173
1326
|
for (const e of (Array.isArray(b.events) ? b.events : [])) {
|
|
1174
1327
|
const last = state.events[state.events.length - 1];
|
|
1175
|
-
|
|
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 };
|
|
1176
1333
|
if (ev.taskId != null && remap.has(Number(ev.taskId))) ev.taskId = remap.get(Number(ev.taskId));
|
|
1177
1334
|
state.events.push(ev); added.events++;
|
|
1178
1335
|
}
|
|
@@ -2288,36 +2445,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
2288
2445
|
if (req.method === "GET" && P === "/contracts") {
|
|
2289
2446
|
const session = String(q.session || "");
|
|
2290
2447
|
if (!session) return json(res, 400, { error: "session required" });
|
|
2291
|
-
const
|
|
2292
|
-
const
|
|
2293
|
-
const
|
|
2294
|
-
const
|
|
2295
|
-
const
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
const seen = peer?.lastSeen || 0;
|
|
2311
|
-
out.push({
|
|
2312
|
-
id: c.id, to: c.to, text: c.text, ts: c.ts, ageMs: now() - c.ts,
|
|
2313
|
-
answered: !!answer,
|
|
2314
|
-
answer: answer ? { id: answer.id, ts: answer.ts, text: answer.text } : null,
|
|
2315
|
-
assigneeOnline: !!seen && (now() - seen) < ONLINE_MS,
|
|
2316
|
-
assigneeStatus: String(peer?.status || ""),
|
|
2317
|
-
assigneeLastSeenMs: seen ? now() - seen : null,
|
|
2318
|
-
});
|
|
2319
|
-
}
|
|
2320
|
-
return json(res, 200, { session, contracts: out, open: out.filter(c => !c.answered).length });
|
|
2448
|
+
const windowMs = Math.max(60000, Number(q.windowMs || CONTRACT_WINDOW_MS));
|
|
2449
|
+
const rawOverdue = q.overdueMs === undefined || q.overdueMs === "" ? null : Number(q.overdueMs);
|
|
2450
|
+
const overdueMs = Number.isFinite(rawOverdue) ? Math.max(0, rawOverdue) : null;
|
|
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");
|
|
2462
|
+
return json(res, 200, {
|
|
2463
|
+
session, contracts: out, abandonedContracts: all.filter(c => c.disposition === "abandoned"),
|
|
2464
|
+
open: out.filter(c => !c.answered).length,
|
|
2465
|
+
waiting: by("waiting"), stalled: by("stalled"), abandoned: by("abandoned"), answered: by("answered"),
|
|
2466
|
+
});
|
|
2321
2467
|
}
|
|
2322
2468
|
|
|
2323
2469
|
if (req.method === "GET" && P === "/inbox") {
|
package/lib/store-contract.mjs
CHANGED
|
@@ -126,7 +126,7 @@ CREATE TABLE IF NOT EXISTS peers (
|
|
|
126
126
|
-- The fields that currently ride in-memory and are LOST on restart. This is the debt being paid.
|
|
127
127
|
CREATE TABLE IF NOT EXISTS kv (
|
|
128
128
|
org_id TEXT NOT NULL,
|
|
129
|
-
key TEXT NOT NULL, -- 'verifyGates'|'balances'|'handoffLog'|'aliases'|'phaseMeta'|'focus'|'projectMeta'|'lessons'|'orgPolicy'|'meta'
|
|
129
|
+
key TEXT NOT NULL, -- 'verifyGates'|'balances'|'handoffLog'|'aliases'|'phaseMeta'|'focus'|'projectMeta'|'lessons'|'orgPolicy'|'meta'|'contractReap'
|
|
130
130
|
value JSONB NOT NULL,
|
|
131
131
|
PRIMARY KEY (org_id, key)
|
|
132
132
|
);
|
|
@@ -138,9 +138,12 @@ CREATE TABLE IF NOT EXISTS schema_meta (version INT PRIMARY KEY, applied_at BIGI
|
|
|
138
138
|
// `proposals` (agent-proposed permissions, v0.17.68) especially: a denied proposal is a MEMORY —
|
|
139
139
|
// the hub refuses near-duplicate re-proposals against it, and a restart that forgets denials
|
|
140
140
|
// silently re-opens every door the operator closed.
|
|
141
|
+
// `contractReap` likewise: it records that a dispatched contract's assignee went quiet for good.
|
|
142
|
+
// Held only in memory it would be rebuilt from scratch on every restart, and the whole ghost backlog
|
|
143
|
+
// would re-announce itself — the same shape of bug as the in-memory escalation set.
|
|
141
144
|
export const KV_KEYS = ["verifyGates", "balances", "handoffLog", "aliases", "phaseMeta", "focus",
|
|
142
145
|
"projectMeta", "lessons", "orgPolicy", "meta", "subagentCostReset", "seq",
|
|
143
|
-
"proposals"];
|
|
146
|
+
"proposals", "contractReap"];
|
|
144
147
|
|
|
145
148
|
// ---------------------------------------------------------------------------------------------
|
|
146
149
|
// INVARIANTS — carried forward from 0.17.54. Breaking one silently corrupts the board.
|
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 (
|
|
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;
|
|
@@ -123,11 +133,13 @@ function kvFromState(state) {
|
|
|
123
133
|
lessons: state.lessons || [],
|
|
124
134
|
orgPolicy: state.orgPolicy || {},
|
|
125
135
|
proposals: state.proposals || [],
|
|
136
|
+
contractReap: state.contractReap || {},
|
|
126
137
|
meta: {
|
|
127
138
|
taskSeq: Number(state.taskSeq || 0),
|
|
128
139
|
verifyGateSeq: Number(state.verifyGateSeq || 0),
|
|
129
140
|
proposalSeq: Number(state.proposalSeq || 0),
|
|
130
141
|
cardEventsBackfilled: !!state.cardEventsBackfilled,
|
|
142
|
+
eventSeq: Number(state.eventSeq || 0),
|
|
131
143
|
inviteTokens: state.inviteTokens || {},
|
|
132
144
|
instances: state.instances || {},
|
|
133
145
|
},
|
|
@@ -541,12 +553,18 @@ export class PgStore {
|
|
|
541
553
|
}
|
|
542
554
|
if (tasks.deletes.length) await c.query("DELETE FROM tasks WHERE org_id=$1 AND id = ANY($2::bigint[])", [orgId, tasks.deletes]);
|
|
543
555
|
for (const e of events.upserts) {
|
|
544
|
-
await c.query(
|
|
556
|
+
const r = await c.query(
|
|
545
557
|
`INSERT INTO events(id, org_id, ts, type, project, by_session, task_id, payload)
|
|
546
558
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb)
|
|
547
559
|
ON CONFLICT (id) DO NOTHING`,
|
|
548
560
|
[Number(e.id), orgId, ms(e.ts), e.type || "", e.project || "", e.by || "", e.taskId ?? null, JSON.stringify(stripEventPayload(e))],
|
|
549
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
|
+
}
|
|
550
568
|
}
|
|
551
569
|
if (events.deletes.length) await c.query("DELETE FROM events WHERE org_id=$1 AND id = ANY($2::bigint[])", [orgId, events.deletes]);
|
|
552
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)");
|
|
@@ -633,13 +651,18 @@ export class PgStore {
|
|
|
633
651
|
}
|
|
634
652
|
|
|
635
653
|
async loadSnapshot(orgId) {
|
|
636
|
-
const [tasks, peersRows, eventsRows, messagesRows, identitiesRows, kvRows] = await Promise.all([
|
|
654
|
+
const [tasks, peersRows, eventsRows, messagesRows, identitiesRows, kvRows, eventMaxRow] = await Promise.all([
|
|
637
655
|
this.pool.query("SELECT * FROM tasks WHERE org_id=$1 ORDER BY id ASC", [orgId]),
|
|
638
656
|
this.pool.query("SELECT * FROM peers WHERE org_id=$1 ORDER BY session ASC", [orgId]),
|
|
639
657
|
this.pool.query("SELECT * FROM events WHERE org_id=$1 ORDER BY id ASC", [orgId]),
|
|
640
658
|
this.pool.query("SELECT * FROM messages WHERE org_id=$1 ORDER BY id ASC", [orgId]),
|
|
641
659
|
this.pool.query("SELECT * FROM identities WHERE org_id=$1 ORDER BY created_at ASC", [orgId]),
|
|
642
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"),
|
|
643
666
|
]);
|
|
644
667
|
const kv = Object.fromEntries(kvRows.rows.map(r => [r.key, r.value]));
|
|
645
668
|
const peers = {};
|
|
@@ -681,6 +704,8 @@ export class PgStore {
|
|
|
681
704
|
instances: meta.instances && typeof meta.instances === "object" ? meta.instances : {},
|
|
682
705
|
focus: kv.focus && typeof kv.focus === "object" ? kv.focus : {},
|
|
683
706
|
orgPolicy: kv.orgPolicy && typeof kv.orgPolicy === "object" ? kv.orgPolicy : {},
|
|
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)),
|
|
684
709
|
};
|
|
685
710
|
}
|
|
686
711
|
}
|
package/mcp.mjs
CHANGED
|
@@ -136,26 +136,42 @@ server.tool("relay_whoami", "Show this session's relay identity, project, hub UR
|
|
|
136
136
|
return { content: [{ type: "text", text }] };
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
-
server.tool("relay_contracts", "What you dispatched and are still owed. Lists every DIRECT message you sent to another session
|
|
139
|
+
server.tool("relay_contracts", "What you dispatched and are still owed. Lists every DIRECT message you sent to another session, with how long it has been outstanding, whether that session is still alive, and its last known status. Each one carries a disposition: WAITING (assignee alive and working — normal), STALLED (assignee offline or overdue — poke it, swap it, or reassign), ABANDONED (assignee gone so long the contract can never be answered — the work needs reassigning, nobody is coming back), or answered, with the outcome text. Call this before concluding a crew is idle, stuck, or done — silence on the bus is not evidence either way.", {},
|
|
140
140
|
async () => {
|
|
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
|
-
|
|
145
|
-
|
|
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);
|
|
146
147
|
if (!all.length) return { content: [{ type: "text", text: "You have not dispatched any contracts in the last 24h." }] };
|
|
148
|
+
// Fall back to the pre-disposition shape when talking to an older hub.
|
|
149
|
+
const disp = (c) => c.disposition || (c.answered ? "answered" : (c.assigneeOnline ? "waiting" : "stalled"));
|
|
150
|
+
const open = all.filter(c => disp(c) === "waiting" || disp(c) === "stalled");
|
|
151
|
+
const abandoned = all.filter(c => disp(c) === "abandoned");
|
|
147
152
|
const mins = (ms) => (ms >= 60000 ? `${Math.round(ms / 60000)}m` : `${Math.round(ms / 1000)}s`);
|
|
153
|
+
const MARK = { answered: "✅", waiting: "⏳", stalled: "⚠️", abandoned: "🪦" };
|
|
148
154
|
const line = (c) => {
|
|
149
|
-
const
|
|
155
|
+
const d = disp(c);
|
|
156
|
+
const health = d === "answered" ? "" :
|
|
150
157
|
c.assigneeOnline ? ` · alive (${c.assigneeStatus || "no status"})`
|
|
151
158
|
: c.assigneeLastSeenMs == null ? " · NEVER SEEN on the bus"
|
|
152
159
|
: ` · LAST SEEN ${mins(c.assigneeLastSeenMs)} ago`;
|
|
153
|
-
const out =
|
|
154
|
-
|
|
160
|
+
const out = d === "answered" ? ` → ${String(c.answer?.text || "").slice(0, 120)}` : "";
|
|
161
|
+
const tag = d === "answered" ? "" : ` [${d.toUpperCase()}]`;
|
|
162
|
+
return `${MARK[d] || "⏳"} #${c.id} → ${c.to} (${mins(c.ageMs)} ago)${tag}${health}: "${String(c.text).slice(0, 100)}"${out}`;
|
|
155
163
|
};
|
|
156
|
-
const
|
|
164
|
+
const notes = [];
|
|
165
|
+
if (open.some(c => disp(c) === "stalled")) {
|
|
166
|
+
notes.push("⚠️ A STALLED contract's assignee is offline or overdue. Waiting will not finish the work — check it, swap it (`trantor swap <agent>`), or reassign.");
|
|
167
|
+
}
|
|
168
|
+
if (abandoned.length) {
|
|
169
|
+
notes.push(`🪦 ${abandoned.length} ABANDONED: the assignee has been gone too long for these to ever be answered. Nobody is coming back — reassign the work or drop it deliberately.`);
|
|
170
|
+
}
|
|
171
|
+
const text = `${open.length} outstanding of ${all.length} contract(s) in the last 24h`
|
|
172
|
+
+ (abandoned.length ? ` (plus ${abandoned.length} abandoned)` : "") + `:\n`
|
|
157
173
|
+ all.slice(-25).map(line).join("\n")
|
|
158
|
-
+ (
|
|
174
|
+
+ (notes.length ? "\n\n" + notes.join("\n") : "");
|
|
159
175
|
return { content: [{ type: "text", text }] };
|
|
160
176
|
});
|
|
161
177
|
|
package/package.json
CHANGED
package/skills/crew/SKILL.md
CHANGED
|
@@ -92,6 +92,17 @@ Loop until the board is done — you are a foreman, not a mailbox:
|
|
|
92
92
|
3. ACT within one cycle: failed card → read the report, send a fix contract, card back to
|
|
93
93
|
doing · dead agent → `trantor up <agent>` (re-verifies) + resend contract · silent-but-alive
|
|
94
94
|
→ direct-message nudge naming the card.
|
|
95
|
+
Use `relay_contracts` for the ledger of what you dispatched and are still owed. Every contract
|
|
96
|
+
carries a **disposition**, and each one means a different action:
|
|
97
|
+
- **WAITING** — assignee alive, inside the overdue window. This is what progress looks like. Do
|
|
98
|
+
nothing; do not nudge it just because it is quiet.
|
|
99
|
+
- **STALLED** — assignee offline, or overdue while online. Act NOW: check the seat, `trantor up`
|
|
100
|
+
/ `trantor swap` it, or reassign. This is also the only disposition that blocks your stop.
|
|
101
|
+
- **ABANDONED** — the assignee has been gone long enough that the contract can never be
|
|
102
|
+
answered. Nobody is coming back. **Reassign the work or drop it deliberately** — it will not
|
|
103
|
+
block you again, and it will not resolve itself.
|
|
104
|
+
A contract is never closed on the assignee's behalf: quiet is not an outcome. An abandoned one
|
|
105
|
+
stays in the ledger with the evidence, and a seat that revives still closes its own contract.
|
|
95
106
|
4. Grunt sub-tasks that appear mid-build (a regex, a config block, a doc paragraph) →
|
|
96
107
|
`relay_scrooge`, don't burn a crew seat or your own window.
|
|
97
108
|
5. Record lessons as you diagnose (`relay_lesson(text, scope)` — global or per-agent quirks);
|