trantor 0.18.52 → 0.18.53
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/bin/crew/core.mjs +14 -1
- package/bin/crew-runner.mjs +41 -6
- package/bin/state-bench.mjs +39 -16
- package/hooks/lib/handoff.mjs +94 -19
- package/hub/overseer.mjs +10 -0
- package/hub/reaper.mjs +17 -3
- package/hub/routes/admin.mjs +8 -7
- package/hub/routes/cards.mjs +26 -1
- package/hub/routes/messages.mjs +5 -2
- package/lib/overseer.mjs +74 -33
- package/lib/turn-policy.mjs +94 -0
- package/mcp.mjs +25 -10
- package/package.json +1 -1
- package/skills/crew/SKILL.md +14 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.53",
|
|
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/bin/crew/core.mjs
CHANGED
|
@@ -132,8 +132,21 @@ export function gridColumns(size) {
|
|
|
132
132
|
return columns;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// Operator flags the RUNNER itself reads, forwarded from whoever launched the seat.
|
|
136
|
+
//
|
|
137
|
+
// The Trantor State flags are read as `process.env` INSIDE crew-runner.mjs, not by the CLI it
|
|
138
|
+
// spawns — so ~/.agent-bus/.env (the crew key layer) cannot set them: that file is applied to the
|
|
139
|
+
// spawned command, one level too deep. Without this list the flags documented in TDD §11 have no
|
|
140
|
+
// supported way to reach a seat at all, which is how Phase 2a came to be "enabled" with a schema
|
|
141
|
+
// file that was never written and a runner still on the transcript path.
|
|
142
|
+
const FORWARDED_ENV = ["TRANTOR_STATE", "TRANTOR_STATE_ASSEMBLE", "TRANTOR_STATE_HANDOFF", "TRANTOR_STATE_GATE"];
|
|
143
|
+
|
|
135
144
|
export function runnerCommand(ctx, agent, model = "") {
|
|
136
|
-
|
|
145
|
+
const forwarded = FORWARDED_ENV
|
|
146
|
+
.filter((name) => process.env[name])
|
|
147
|
+
.map((name) => `${name}=${shellQuote(process.env[name])} `)
|
|
148
|
+
.join("");
|
|
149
|
+
return `cd ${shellQuote(ctx.dir)} && ${forwarded}CREW_MODEL=${shellQuote(model)} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
export function listPids(pattern) {
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -24,8 +24,8 @@ import {
|
|
|
24
24
|
} from "../lib/classify-failure.mjs";
|
|
25
25
|
import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
|
|
26
26
|
import {
|
|
27
|
-
|
|
28
|
-
senderProjectOf, isLinkedProject,
|
|
27
|
+
cardRefs, wakeCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
|
|
28
|
+
senderProjectOf, isLinkedProject, stateSkipReason,
|
|
29
29
|
} from "../lib/turn-policy.mjs";
|
|
30
30
|
import {
|
|
31
31
|
auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
|
|
@@ -611,10 +611,33 @@ const STATE_MODE = (() => {
|
|
|
611
611
|
mkdirSync(join(homedir(), ".agent-bus"), { recursive: true, mode: 0o700 });
|
|
612
612
|
writeFileSync(STATE_SCHEMA_FILE, JSON.stringify(TURN_RESULT_SCHEMA), { mode: 0o600 });
|
|
613
613
|
} catch (e) { log(`\x1b[33mstate mode OFF — could not write ${STATE_SCHEMA_FILE}: ${e.message}\x1b[0m`); return false; }
|
|
614
|
-
|
|
614
|
+
// #7060: this line used to read "ASSEMBLE mode ON for this seat", which is a claim about the
|
|
615
|
+
// PROMPT that nothing here established. What the four checks above prove is CONFIGURATION, and
|
|
616
|
+
// the two come apart on the literal next turn: the kickoff runs before any message exists, so it
|
|
617
|
+
// belongs to no card and cannot be a state step. So say what was proved — armed — and name the
|
|
618
|
+
// one thing that engages it. Each turn then reports which path it actually took.
|
|
619
|
+
log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (schema ${STATE_SCHEMA_FILE})\x1b[0m`);
|
|
620
|
+
log(`\x1b[36m a turn is assembled only when a wake ASSIGNS it a card — the kickoff and every pulse run the transcript path, and each turn says which one it took\x1b[0m`);
|
|
615
621
|
return true;
|
|
616
622
|
})();
|
|
617
623
|
|
|
624
|
+
// #7060: the one place a turn decides whether it is a state step, and the one place a skip is
|
|
625
|
+
// spoken. Returns the reason the turn is NOT assembled (already logged), or null when it is — so
|
|
626
|
+
// the runner reads `if (!stateSkip(...))` and cannot drift from what the operator was just told.
|
|
627
|
+
//
|
|
628
|
+
// It speaks on CHANGE, not on repetition. A pulse fires on a timer and skips for the same reason
|
|
629
|
+
// every time; printing that line forever is the repetition the monitoring doctrine rules out, and
|
|
630
|
+
// it would bury the turn where the path actually flipped. Assembling a turn clears the memory, so
|
|
631
|
+
// the next skip after real work always speaks. Silent when state mode is off: the IIFE above
|
|
632
|
+
// already said why, once, and a transcript seat has no claim here to mistake for proof.
|
|
633
|
+
let spokenStateSkip = null;
|
|
634
|
+
const stateSkip = (kind, card = 0) => {
|
|
635
|
+
const why = stateSkipReason({ mode: STATE_MODE, kind, breakerTripped, card });
|
|
636
|
+
if (STATE_MODE && why && why !== spokenStateSkip) log(`\x1b[33mTrantor State: this turn is NOT assembled — ${why}\x1b[0m`);
|
|
637
|
+
spokenStateSkip = why;
|
|
638
|
+
return why;
|
|
639
|
+
};
|
|
640
|
+
|
|
618
641
|
// The PREAMBLE, and it is the whole cost claim in one constant: the bytes before STATE_DELIM must
|
|
619
642
|
// be identical on every step or provider prefix caching never engages and the curve stays O(T).
|
|
620
643
|
// So it is computed ONCE, from things that do not vary per turn — no clock, no turn number, no
|
|
@@ -1201,6 +1224,9 @@ function askedExcerpt(message) {
|
|
|
1201
1224
|
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
1202
1225
|
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
1203
1226
|
|
|
1227
|
+
// #7060: the turn the boot line was read as a promise about. It is a transcript turn by
|
|
1228
|
+
// construction and now says so, in the same breath as the line that armed the mode.
|
|
1229
|
+
stateSkip("kickoff");
|
|
1204
1230
|
const ec0 = await runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
|
|
1205
1231
|
if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
|
|
1206
1232
|
let lastTurnAt = Date.now();
|
|
@@ -1211,6 +1237,7 @@ function askedExcerpt(message) {
|
|
|
1211
1237
|
// pulse first: a due mission beat runs even on a silent bus. Measured from the END of the
|
|
1212
1238
|
// last turn, so a long turn doesn't stack an immediate pulse on top of itself.
|
|
1213
1239
|
if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
|
|
1240
|
+
stateSkip("pulse");
|
|
1214
1241
|
const ecp = await runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
|
|
1215
1242
|
if (ecp) await reportFailure(ecp, "pulse"); else await reportHealthy();
|
|
1216
1243
|
lastTurnAt = Date.now();
|
|
@@ -1362,12 +1389,20 @@ function askedExcerpt(message) {
|
|
|
1362
1389
|
// into every later turn — qwen's 85.7M tokens were 96.7% cached, i.e. replayed history. The
|
|
1363
1390
|
// card that moved this wake decides: a different one starts a fresh CLI session, and the seat
|
|
1364
1391
|
// is told so, because a fresh session remembers nothing and must be sent to its card.
|
|
1365
|
-
|
|
1392
|
+
// #7061: bound by SHAPE, not by position. `cardRef` alone took the earliest id in the wake
|
|
1393
|
+
// TEXT, and an order that opens with what shipped ("#7037 is merged as a01f629 … YOUR CARD:
|
|
1394
|
+
// #6983") binds the turn — its state sidecar, its card log, its run record — to a done card.
|
|
1395
|
+
const card = wakeCard(wakeForTurn, { session: SESSION });
|
|
1366
1396
|
const fresh = card > 0 && card !== sessionCard;
|
|
1367
1397
|
if (card) sessionCard = card;
|
|
1398
|
+
const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
|
|
1368
1399
|
const freshText = fresh
|
|
1369
1400
|
? `\n(FRESH SESSION for card #${card} — you are not the session that worked earlier cards and you remember none of them. Read your card first: relay_board with card:${card}.)\n`
|
|
1370
|
-
|
|
1401
|
+
// A wake naming several cards used to leave the seat guessing which one the machine believed
|
|
1402
|
+
// — the prompt named two and committed to neither. Say it, even when the session continues.
|
|
1403
|
+
: (card && cited.length > 1
|
|
1404
|
+
? `\n(This turn is card #${card} — the wake cites ${cited.length} cards; the rest are context.)\n`
|
|
1405
|
+
: "");
|
|
1371
1406
|
const prompt = composedTurn({
|
|
1372
1407
|
wakeText, ctxText, againText: againText + freshText + dutyNudgeDirective(dutyPlan),
|
|
1373
1408
|
tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
|
|
@@ -1375,7 +1410,7 @@ function askedExcerpt(message) {
|
|
|
1375
1410
|
});
|
|
1376
1411
|
const stopDutyNudgeWatcher = startDutyNudgeWatcher(dutyPlan, tStart);
|
|
1377
1412
|
let ec;
|
|
1378
|
-
const stateStep =
|
|
1413
|
+
const stateStep = !stateSkip("wake", card);
|
|
1379
1414
|
try {
|
|
1380
1415
|
ec = stateStep
|
|
1381
1416
|
? await stateTurn({
|
package/bin/state-bench.mjs
CHANGED
|
@@ -206,6 +206,15 @@ function isToolResult(row) {
|
|
|
206
206
|
return c.some(part => part && typeof part === "object" && part.type === "tool_result");
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/** The rows a MEASUREMENT gate may read: every row except one the time box cut short (#7135).
|
|
210
|
+
* A cut row is honest — recordStep keeps its absent cost null and its cache_read 0 — and §8.7
|
|
211
|
+
* needs it to find the cut, but it measured nothing: its 0 is not a cache miss and its null is
|
|
212
|
+
* not a price. Gates 3, 4 and 5 filter through here so one boxed turn neither reads as preamble
|
|
213
|
+
* drift nor voids a whole run's cost basis; §8.6 and §8.7 keep the full record. */
|
|
214
|
+
export function measurableSteps(steps) {
|
|
215
|
+
return (Array.isArray(steps) ? steps : []).filter(s => s?.cut !== true);
|
|
216
|
+
}
|
|
217
|
+
|
|
209
218
|
/** Sum rows into the totals the ratio is computed from. `cost_usd` stays null unless EVERY row
|
|
210
219
|
* carried one: a partial sum compared against a full one is a fake ratio. */
|
|
211
220
|
export function totals(rows) {
|
|
@@ -299,22 +308,25 @@ export function baselineMarkdown({ project, card, transcript, rows, capturedAt,
|
|
|
299
308
|
* assembled preamble drifted between turns, prefix caching never engaged, and the cost claim is
|
|
300
309
|
* void even when the totals happen to look fine — which they can, on a short run, for entirely
|
|
301
310
|
* unrelated reasons. The first step is exempt because there is nothing before it to have cached.
|
|
311
|
+
* Cut rows are excluded before any of this (measurableSteps): a boxed turn's cache_read 0 is an
|
|
312
|
+
* absence of measurement, not a miss.
|
|
302
313
|
*/
|
|
303
314
|
export function checkCache(steps) {
|
|
304
|
-
|
|
305
|
-
|
|
315
|
+
const rows = measurableSteps(steps);
|
|
316
|
+
if (rows.length < 2) {
|
|
317
|
+
return { ok: false, code: "TOO_FEW_STEPS", checked: rows.length, violations: [], message: "fewer than two steps: the cache property has no second turn to hold on and was not observed" };
|
|
306
318
|
}
|
|
307
319
|
const violations = [];
|
|
308
|
-
for (let i = 1; i <
|
|
309
|
-
const read = tokens(
|
|
310
|
-
if (read <= 0) violations.push({ turn:
|
|
320
|
+
for (let i = 1; i < rows.length; i++) {
|
|
321
|
+
const read = tokens(rows[i].cache_read);
|
|
322
|
+
if (read <= 0) violations.push({ turn: rows[i].turn ?? i + 1, cache_read: read });
|
|
311
323
|
}
|
|
312
324
|
return {
|
|
313
325
|
ok: violations.length === 0, code: violations.length ? "CACHE_MISS" : null,
|
|
314
|
-
checked:
|
|
326
|
+
checked: rows.length - 1, violations,
|
|
315
327
|
message: violations.length
|
|
316
|
-
? `${violations.length} of ${
|
|
317
|
-
: `all ${
|
|
328
|
+
? `${violations.length} of ${rows.length - 1} steps read 0 cached tokens — the preamble drifted, prefix caching never engaged, and the ≥${COST_FACTOR}× claim is VOID regardless of the totals`
|
|
329
|
+
: `all ${rows.length - 1} steps after the first read cached prefix tokens`,
|
|
318
330
|
};
|
|
319
331
|
}
|
|
320
332
|
|
|
@@ -327,11 +339,16 @@ export function checkCache(steps) {
|
|
|
327
339
|
* problem worth knowing about; a bench that only knows how to agree is not a gate. When no row
|
|
328
340
|
* carries a price the ratio is computed on total tokens and says so in `basis`, because tokens are
|
|
329
341
|
* a real measurement and a price derived from a rate card we did not record is not.
|
|
342
|
+
*
|
|
343
|
+
* Cut rows are excluded first (measurableSteps): one boxed turn's null price used to void
|
|
344
|
+
* `cost_usd` for the ENTIRE run through totals(), silently downgrading a measured-dollar result
|
|
345
|
+
* to an estimate — the unfalsifiability the PRD forbids (#7135).
|
|
330
346
|
*/
|
|
331
347
|
export function costGate(steps, baselineRows, opts = {}) {
|
|
332
348
|
const factor = opts.factor ?? COST_FACTOR;
|
|
333
349
|
const minTurns = opts.minTurns ?? MIN_TURNS;
|
|
334
|
-
const
|
|
350
|
+
const measured = measurableSteps(steps);
|
|
351
|
+
const run = totals(measured);
|
|
335
352
|
const base = totals(baselineRows || []);
|
|
336
353
|
const out = { ok: false, code: null, run, base, turns: run.turns, slope: null, slope_ratio: null, ratio: null, basis: null, message: "" };
|
|
337
354
|
|
|
@@ -353,12 +370,12 @@ export function costGate(steps, baselineRows, opts = {}) {
|
|
|
353
370
|
out.basis = priced ? "cost_usd" : "weighted";
|
|
354
371
|
const weigh = (r) => tokens(r.input) * weights.input + tokens(r.cache_read) * weights.cache_read
|
|
355
372
|
+ tokens(r.cache_creation) * weights.cache_creation + tokens(r.output) * weights.output;
|
|
356
|
-
const perTurn =
|
|
373
|
+
const perTurn = measured.map(s => (priced ? finite(s.cost_usd) : weigh(s)));
|
|
357
374
|
out.slope = fitSlope(perTurn);
|
|
358
375
|
const mean = perTurn.reduce((a, b) => a + (b ?? 0), 0) / perTurn.length;
|
|
359
376
|
out.slope_ratio = out.slope === null || mean === 0 ? null : Math.abs(out.slope) * (perTurn.length - 1) / mean;
|
|
360
377
|
|
|
361
|
-
const runTotal = priced ? run.cost_usd :
|
|
378
|
+
const runTotal = priced ? run.cost_usd : measured.reduce((a, r) => a + weigh(r), 0);
|
|
362
379
|
const baseTotal = priced ? base.cost_usd : (baselineRows || []).reduce((a, r) => a + weigh(r), 0);
|
|
363
380
|
out.ratio = runTotal > 0 ? baseTotal / runTotal : null;
|
|
364
381
|
|
|
@@ -381,18 +398,23 @@ export function costGate(steps, baselineRows, opts = {}) {
|
|
|
381
398
|
* that quietly re-read half the worktree), and the action check catches the cheaper failure the
|
|
382
399
|
* bound cannot see: one small `Read` that says the state block did not carry, so the seat went
|
|
383
400
|
* back to the filesystem to find out where it was.
|
|
401
|
+
*
|
|
402
|
+
* Cut rows are excluded first (measurableSteps): a boxed turn's zeroed measurements are not a
|
|
403
|
+
* recovery step — if the row after a disturbance was cut, this check reports NO_NEXT_STEP rather
|
|
404
|
+
* than passing on a turn that never ran.
|
|
384
405
|
*/
|
|
385
406
|
export function disturbanceCheck(steps, opts = {}) {
|
|
386
|
-
const
|
|
407
|
+
const rows = measurableSteps(steps);
|
|
408
|
+
const marks = rows.map((s, i) => (s.disturbed ? i : -1)).filter(i => i >= 0);
|
|
387
409
|
if (!marks.length) {
|
|
388
410
|
return { ok: false, code: "NO_DISTURBANCE", cases: [], message: "no step is marked `disturbed` — §8.5 was never exercised on this run, so it did not pass it" };
|
|
389
411
|
}
|
|
390
412
|
const factor = opts.factor ?? DISTURB_INPUT_FACTOR;
|
|
391
413
|
const cases = [];
|
|
392
414
|
for (const i of marks) {
|
|
393
|
-
const next =
|
|
394
|
-
if (!next) { cases.push({ at:
|
|
395
|
-
const priorMedian = median(
|
|
415
|
+
const next = rows[i + 1];
|
|
416
|
+
if (!next) { cases.push({ at: rows[i].turn ?? i + 1, ok: false, code: "NO_NEXT_STEP", detail: "the run ended at the disturbance — the recovery step was never taken" }); continue; }
|
|
417
|
+
const priorMedian = median(rows.slice(0, i + 1).map(s => tokens(s.input)));
|
|
396
418
|
const bound = priorMedian === null ? null : priorMedian * factor;
|
|
397
419
|
const input = tokens(next.input);
|
|
398
420
|
const tool = next.action && typeof next.action === "object" ? next.action.tool : null;
|
|
@@ -630,7 +652,8 @@ export function evaluateRun({ project, card, repo = REPO, ...opts }) {
|
|
|
630
652
|
gates.push({ n: 2, name: "state-mode run recorded", ok: false, code: "NO_RUN", message: `no run steps at ${runPath(project, card)} — the state-mode driver (P6) has not recorded a run for this card, so there is nothing to measure. This is an UNKNOWN, and an unknown is not a pass.` });
|
|
631
653
|
return { ok: false, gates, halted: "no run", project, card, baseline: base };
|
|
632
654
|
}
|
|
633
|
-
|
|
655
|
+
const cutRows = steps.length - measurableSteps(steps).length;
|
|
656
|
+
gates.push({ n: 2, name: "state-mode run recorded", ok: true, code: null, message: `${steps.length} steps at ${runPath(project, card)}${cutRows ? ` · ${cutRows} cut row${cutRows === 1 ? "" : "s"} excluded from the measurement gates (3–5); §8.7 still counts ${cutRows === 1 ? "it" : "them"}` : ""}` });
|
|
634
657
|
|
|
635
658
|
const cache = checkCache(steps);
|
|
636
659
|
gates.push({ n: 3, name: "cache read > 0 after the first step (§8.3)", ok: cache.ok, code: cache.code, message: cache.message });
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -16,7 +16,9 @@ import { homedir, hostname } from "node:os";
|
|
|
16
16
|
import { execSync, spawn } from "node:child_process";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
|
|
19
|
-
import { signedPost } from "./api.mjs";
|
|
19
|
+
import { signedPost, loadIdentity } from "./api.mjs";
|
|
20
|
+
// #7037: signedHeaders is SYNCHRONOUS, which is why this path can sign at all — see hubCallSync.
|
|
21
|
+
import { signedHeaders } from "../../lib/signed-fetch.mjs";
|
|
20
22
|
import { loadAutonomy, resolveAutonomy } from "../../lib/autonomy.mjs";
|
|
21
23
|
import { resolveProject, orchSessionsPath, hostId } from "../../lib/project.mjs";
|
|
22
24
|
// Trantor State (TDD §4.5). Dark behind TRANTOR_STATE_HANDOFF: these are imported unconditionally
|
|
@@ -473,23 +475,89 @@ export function resolveSeat(projectName, env = process.env) {
|
|
|
473
475
|
return env.RELAY_SESSION || (env.RELAY_AGENT ? `${env.RELAY_AGENT}:${projectName}` : `${hostId()}:${projectName}`);
|
|
474
476
|
}
|
|
475
477
|
|
|
478
|
+
// ── #7037: one SIGNED, synchronous hub call, and an error that cannot be mistaken for data ──────
|
|
479
|
+
//
|
|
480
|
+
// Three reads on this path curl'd the hub UNSIGNED and read the refusal as an answer. The hub
|
|
481
|
+
// replies 401 {"error":"signature required"}; a 401 body is VALID JSON, so `JSON.parse(out).tasks
|
|
482
|
+
// || []` parses fine, finds no `tasks` key, and hands back an empty list. The catch never fires
|
|
483
|
+
// because nothing threw. Silently, on every handoff: the card id resolved to 0, the verify-gate
|
|
484
|
+
// list to [], and the storm guard — which exists because an old-hook session once fired 9 handoffs
|
|
485
|
+
// in 49 minutes — read a refusal as "allowed" and stopped guarding.
|
|
486
|
+
//
|
|
487
|
+
// So the shape matters more than the signature: this returns { ok, status, json, reason } and NEVER
|
|
488
|
+
// a bare list. A caller has to look at `ok` before it can reach the data, which is the property the
|
|
489
|
+
// old code lacked. An auth failure must not be spellable as an empty result.
|
|
490
|
+
//
|
|
491
|
+
// Synchronous on purpose — this whole path is (see the state imports above), so api.mjs's async
|
|
492
|
+
// signedGet is unavailable. signedHeaders IS synchronous, so only the transport differs.
|
|
493
|
+
function hubCallSync(path, { project, session, method = "GET", body, timeoutMs = 2500 } = {}) {
|
|
494
|
+
const url = relayUrl(project) + path;
|
|
495
|
+
let headers = {};
|
|
496
|
+
try {
|
|
497
|
+
headers = signedHeaders(loadIdentity(session || resolveSeat(project || "")), url, { method, body });
|
|
498
|
+
} catch { /* unsigned is still worth attempting — the hub decides, not us */ }
|
|
499
|
+
const args = Object.entries(headers).map(([k, v]) => `-H ${JSON.stringify(`${k}: ${v}`)}`);
|
|
500
|
+
if (method !== "GET") args.push("-X", method);
|
|
501
|
+
if (body !== undefined) args.push("-H 'content-type: application/json'", "-d", JSON.stringify(body));
|
|
502
|
+
try {
|
|
503
|
+
// maxBuffer is NOT decoration: /tasks on this project is 1.6MB across 941 cards, and execSync's
|
|
504
|
+
// 1MB default turns that into ENOBUFS — which the old catch would have swallowed straight back
|
|
505
|
+
// into the same silent 0. A fix that only signed the request would still have failed here.
|
|
506
|
+
const out = execSync(
|
|
507
|
+
`curl -s --max-time ${Math.ceil(timeoutMs / 1000)} ${args.join(" ")} -w '\\n%{http_code}' ${JSON.stringify(url)}`,
|
|
508
|
+
{ encoding: "utf8", timeout: timeoutMs + 500, maxBuffer: 32 * 1024 * 1024 });
|
|
509
|
+
const cut = out.lastIndexOf("\n");
|
|
510
|
+
const status = Number(out.slice(cut + 1).trim()) || 0;
|
|
511
|
+
const text = cut >= 0 ? out.slice(0, cut) : out;
|
|
512
|
+
let json = null; try { json = text ? JSON.parse(text) : null; } catch {}
|
|
513
|
+
if (status < 200 || status >= 300) {
|
|
514
|
+
return { ok: false, status, json, reason: json?.error ? `HTTP ${status}: ${json.error}` : `HTTP ${status}` };
|
|
515
|
+
}
|
|
516
|
+
return { ok: true, status, json, reason: "" };
|
|
517
|
+
} catch (e) {
|
|
518
|
+
return { ok: false, status: 0, json: null, reason: e?.message || "unreachable" };
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// A refusal is worth exactly one line on stderr, and it must name the endpoint and the reason — the
|
|
523
|
+
// whole cost of this bug was that it made no sound at all. Never throws into the handoff path: a
|
|
524
|
+
// session losing its baton over a warning is a worse failure than the one being reported.
|
|
525
|
+
function warnHubRead(what, r) {
|
|
526
|
+
try { process.stderr.write(`[trantor] handoff: ${what} unavailable — ${r.reason || "unknown"}; continuing without it\n`); } catch {}
|
|
527
|
+
}
|
|
528
|
+
|
|
476
529
|
/**
|
|
477
530
|
* Which card this handoff belongs to. `TRANTOR_CARD` wins — the crew runner knows the answer for
|
|
478
531
|
* certain and a lookup cannot beat being told. Otherwise ask the hub for this seat's newest open
|
|
479
532
|
* card, on the same 2s best-effort budget as the verify-gates fetch: a hub that is down costs the
|
|
480
533
|
* handoff a card number, never the handoff.
|
|
534
|
+
*
|
|
535
|
+
* #7037: reads /catchup, not /tasks. /tasks is the whole board with every card's full log — 1.6MB
|
|
536
|
+
* here, 625-961ms typical, and it already blew a 1500ms budget once today (#6983). /catchup answers
|
|
537
|
+
* the question actually being asked in a few hundred bytes. Its buckets are capped at 8, so "my
|
|
538
|
+
* card is not in the list" and "the list was truncated before it got to me" are different answers,
|
|
539
|
+
* and the second is reported as UNKNOWN rather than quietly resolving to 0 — which is the same
|
|
540
|
+
* defect this card exists to remove, one layer up.
|
|
481
541
|
*/
|
|
482
542
|
export function resolveHandoffCard({ projectName, seat, env = process.env } = {}) {
|
|
483
543
|
const told = Number(env.TRANTOR_CARD);
|
|
484
544
|
if (Number.isInteger(told) && told > 0) return told;
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
545
|
+
const r = hubCallSync(`/catchup?project=${encodeURIComponent(projectName)}`, { project: projectName, session: seat });
|
|
546
|
+
if (!r.ok) { warnHubRead(`card lookup for ${projectName}`, r); return 0; }
|
|
547
|
+
const CAP = 8; // hub-side pick() limit; keep in step with /catchup
|
|
548
|
+
for (const status of ["doing", "testing"]) {
|
|
549
|
+
const bucket = Array.isArray(r.json?.[status]) ? r.json[status] : [];
|
|
550
|
+
const mine = bucket
|
|
551
|
+
.filter(t => t && Number.isInteger(t.id) && t.assignee === seat)
|
|
552
|
+
.sort((a, b) => (b.updated || 0) - (a.updated || 0));
|
|
553
|
+
if (mine.length) return mine[0].id;
|
|
554
|
+
if (bucket.length >= CAP) {
|
|
555
|
+
warnHubRead(`card lookup for ${projectName}`,
|
|
556
|
+
{ reason: `/catchup ${status} list truncated at ${CAP} — this seat's card may exist but was not returned` });
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return 0;
|
|
493
561
|
}
|
|
494
562
|
|
|
495
563
|
/**
|
|
@@ -625,12 +693,16 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
625
693
|
// cooldown is SKIPPED — no file, no spawn. Manual (/trantor:handoff) + at-wall (precompact) handoffs
|
|
626
694
|
// force through. Fail-OPEN if the hub is unreachable, so a legit handoff is never blocked.
|
|
627
695
|
if (!force) {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
696
|
+
// #7037: this is the costliest of the three unsigned reads. A 401 body parses, `r.allow` comes
|
|
697
|
+
// back undefined, `undefined === false` is false — so the guard said "go" on every handoff and
|
|
698
|
+
// the storm it exists to stop had nothing standing in its way. Signed now, and a REFUSAL is
|
|
699
|
+
// distinguished from a DENIAL: only a hub that answered gets to allow or deny.
|
|
700
|
+
const r = hubCallSync("/handoff", {
|
|
701
|
+
project: projectName, session: sessionId || "", method: "POST",
|
|
702
|
+
body: { project: projectName, session: sessionId || "", trigger: trigger || "auto" },
|
|
703
|
+
});
|
|
704
|
+
if (!r.ok) warnHubRead("storm guard", r); // fail-OPEN, but never silently
|
|
705
|
+
else if (r.json && r.json.allow === false) return { skipped: true, reason: r.json.reason || "storm-guard", sinceSec: r.json.sinceSec };
|
|
634
706
|
}
|
|
635
707
|
if (!existsSync(HANDOFF_DIR)) mkdirSync(HANDOFF_DIR, { recursive: true });
|
|
636
708
|
// #5648: an automatic digest must never recompose+supersede a FRESH model-authored handoff.
|
|
@@ -655,11 +727,14 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
655
727
|
// MUST survive the handoff (a narrative line gets skimmed past; this is what the v0.17.31 incident
|
|
656
728
|
// taught — the "verify Gail coefficients" intent vanished into prose). Fetched synchronously from
|
|
657
729
|
// the local hub; best-effort, never blocks the handoff.
|
|
730
|
+
// #7037: signed, and an unreadable list is reported rather than rendered as "no open gates" —
|
|
731
|
+
// a record that silently claims zero gates is worse than one that admits it could not ask.
|
|
658
732
|
let verifyGates = [];
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
verifyGates =
|
|
662
|
-
|
|
733
|
+
{
|
|
734
|
+
const r = hubCallSync(`/verify-gates?project=${encodeURIComponent(projectName)}`, { project: projectName, session: sessionId || "" });
|
|
735
|
+
if (r.ok) verifyGates = Array.isArray(r.json?.gates) ? r.json.gates : [];
|
|
736
|
+
else warnHubRead(`verify gates for ${projectName}`, r);
|
|
737
|
+
}
|
|
663
738
|
const record = {
|
|
664
739
|
id: `${projectName}-${stamp}`,
|
|
665
740
|
project: projectDir, projectName, machine: hostname(),
|
package/hub/overseer.mjs
CHANGED
|
@@ -36,6 +36,12 @@ function overseerInputs() {
|
|
|
36
36
|
llm: v.llm || "", model: v.model || "", status: v.status || "",
|
|
37
37
|
})),
|
|
38
38
|
claims: [...fileClaims.values()],
|
|
39
|
+
// #7029: linked-activity needs an EVENT, and a card held from both sides of a link is one of
|
|
40
|
+
// the two the hub already has. Only cards in hand travel — the board is 950+ rows and 99% of
|
|
41
|
+
// them are done, so shipping the open ones keeps this tick as cheap as it was.
|
|
42
|
+
cards: state.tasks
|
|
43
|
+
.filter(t => t.status === "doing" || t.status === "testing")
|
|
44
|
+
.map(t => ({ id: t.id, project: t.project || "", status: t.status, assignee: t.assignee || "", workedBy: t.workedBy || "" })),
|
|
39
45
|
...overseerPolicy(),
|
|
40
46
|
now: now(),
|
|
41
47
|
};
|
|
@@ -160,6 +166,10 @@ function overseerTick() {
|
|
|
160
166
|
// intro only to newly arrived sessions, and remember them so they are not re-introduced.
|
|
161
167
|
standing.lastTick = t;
|
|
162
168
|
c.since = standing.since;
|
|
169
|
+
// Every standing kind reports DURATION, not a count — the doctrine's rule, and until #7029 only
|
|
170
|
+
// same-project obeyed it. "held for 4h" is the line that tells an operator whether a collision
|
|
171
|
+
// is a moment or a stuck seat; "warned 40 times" tells them only that the watcher is loud.
|
|
172
|
+
if (_sameProject?.durationLabel) c.detail = `${c.detail || ""} (standing for ${_sameProject.durationLabel(t - standing.since)})`.trim();
|
|
163
173
|
for (const me of parties) if (!standing.sessions.has(me)) intro(c, me, parties);
|
|
164
174
|
for (const me of parties) standing.sessions.add(me);
|
|
165
175
|
continue;
|
package/hub/reaper.mjs
CHANGED
|
@@ -158,6 +158,15 @@ function contractRecipientIsAnswerable(m) {
|
|
|
158
158
|
return !!m.to && m.to !== "all" && m.from !== m.to && !m.to.startsWith("hub:") && !m.from.startsWith("hub:");
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// A direct message the SENDER declared owes nothing back (#7079). `wake:false` is the sender saying
|
|
162
|
+
// "context, not a contract" — the send result even prints "(batched — no turn)" — and a `receipt` or
|
|
163
|
+
// `status` is a report, not a request. None of these ever buys the recipient a turn, so none can be
|
|
164
|
+
// answered; counted as contracts they age into `stalled` and block the dispatcher's stop hook over
|
|
165
|
+
// work that was never owed (four times in one day, every row an ack to an idle seat).
|
|
166
|
+
function contractIsAck(m) {
|
|
167
|
+
return m.wake === false || m.kind === "receipt" || m.kind === "status";
|
|
168
|
+
}
|
|
169
|
+
|
|
161
170
|
function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, overdueMs = null } = {}) {
|
|
162
171
|
const t = now();
|
|
163
172
|
const cutoff = t - windowMs;
|
|
@@ -174,8 +183,12 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
|
|
|
174
183
|
|
|
175
184
|
const out = [];
|
|
176
185
|
for (const c of mine.sort((a, b) => a.ts - b.ts)) {
|
|
186
|
+
const ack = contractIsAck(c);
|
|
177
187
|
let answer = byRe.get(c.id) || null;
|
|
178
|
-
|
|
188
|
+
// An ack never claims a LOOSE reply: it is older than the real contract more often than not, and
|
|
189
|
+
// letting it consume the seat's untagged "done" would leave the real row WAITING — a false stall
|
|
190
|
+
// manufactured by the very row that was supposed to owe nothing.
|
|
191
|
+
if (!answer && !ack) {
|
|
179
192
|
const pool = looseByPeer.get(c.to) || [];
|
|
180
193
|
const i = pool.findIndex(r => r.ts > c.ts);
|
|
181
194
|
if (i >= 0) answer = pool.splice(i, 1)[0];
|
|
@@ -190,6 +203,7 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
|
|
|
190
203
|
// tombstone. A seat that comes back and reports still closes its own contract.
|
|
191
204
|
let disposition;
|
|
192
205
|
if (answer) disposition = "answered";
|
|
206
|
+
else if (ack) disposition = "ack"; // nothing owed: never waits, never stalls
|
|
193
207
|
else if (seen < abandonCut) disposition = "abandoned"; // covers never-seen (seen === 0)
|
|
194
208
|
else if (!online || (overdueMs != null && ageMs >= overdueMs)) disposition = "stalled";
|
|
195
209
|
else disposition = "waiting";
|
|
@@ -224,7 +238,7 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
|
|
|
224
238
|
if (c.answered && c.ts > (newestAnswered.get(c.to) || 0)) newestAnswered.set(c.to, c.ts);
|
|
225
239
|
}
|
|
226
240
|
for (const c of out) {
|
|
227
|
-
if (c.answered || c.disposition === "abandoned") continue;
|
|
241
|
+
if (c.answered || c.disposition === "abandoned" || c.disposition === "ack") continue;
|
|
228
242
|
if (c.ageMs < CONTRACT_ABANDON_MS) continue;
|
|
229
243
|
if (c.ts < (newestAnswered.get(c.to) || 0)) c.disposition = "superseded";
|
|
230
244
|
}
|
|
@@ -248,7 +262,7 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
|
|
|
248
262
|
if (r.ts > (latestDirectReply.get(r.from) || 0)) latestDirectReply.set(r.from, r.ts);
|
|
249
263
|
}
|
|
250
264
|
for (const c of out) {
|
|
251
|
-
if (c.answered || c.disposition === "abandoned") continue;
|
|
265
|
+
if (c.answered || c.disposition === "abandoned" || c.disposition === "ack") continue;
|
|
252
266
|
if (c.ageMs < CONTRACT_ABANDON_MS) continue;
|
|
253
267
|
const latest = latestDirectReply.get(c.to);
|
|
254
268
|
if (latest != null && c.ts < latest) c.disposition = "superseded";
|
package/hub/routes/admin.mjs
CHANGED
|
@@ -215,14 +215,15 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
215
215
|
declaredCrew: declaredCrewFor(c.project),
|
|
216
216
|
now: now(),
|
|
217
217
|
}).reason === "crew-only"));
|
|
218
|
-
// The record line reports DURATION ("
|
|
218
|
+
// The record line reports DURATION ("standing for 6h"), never a count of warnings. Episode
|
|
219
|
+
// identity is project+kind+files, exactly as the tick loop keys it, so this view says the
|
|
220
|
+
// same thing the warning did for every kind, not just same-project (#7029).
|
|
219
221
|
for (const c of warnings) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
222
|
+
const ep = overseer.active.get(`${c.project} ${c.kind}${c.kind === "same-project-sessions" ? "" : ` ${(c.files || []).join(",")}`}`);
|
|
223
|
+
if (!ep) continue;
|
|
224
|
+
c.since = ep.since;
|
|
225
|
+
const label = c.kind === "same-project-sessions" ? "same-project" : "standing";
|
|
226
|
+
if (overseer.sameProject?.durationLabel) c.detail = `${c.detail || ""} (${label} for ${overseer.sameProject.durationLabel(now() - ep.since)})`.trim();
|
|
226
227
|
}
|
|
227
228
|
} catch {}
|
|
228
229
|
return json(res, 200, { level, links: links.map(l => ({ projects: l.projects, reason: l.reason })), peers: peersOut, inflight, warnings });
|
package/hub/routes/cards.mjs
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
/* oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-conditional-empty-object-spread -- SAFETY: Card wire envelopes and omission semantics are a compatibility contract; this module is a mechanical extraction with unchanged suites. */
|
|
2
|
+
// The three fields that make a card heavy (#6983): its note log, its status history, and its
|
|
3
|
+
// checklist. All three are per-card detail nobody reads off a board — a board shows how many notes
|
|
4
|
+
// a card carries, never their text. Dropping them is what turns a 1.55MB read into 0.26MB.
|
|
5
|
+
const SLIM_DROP = new Set(["log", "history", "checklist"]);
|
|
6
|
+
function slimCard(t) {
|
|
7
|
+
const out = {};
|
|
8
|
+
for (const k of Object.keys(t)) if (!SLIM_DROP.has(k)) out[k] = t[k];
|
|
9
|
+
// Keep the ·N the board renders, so slimming costs a reader nothing visible.
|
|
10
|
+
out.logCount = Array.isArray(t.log) ? t.log.length : 0;
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
|
|
2
14
|
export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
3
15
|
const {
|
|
4
16
|
state, body, json, crossProjectGuard, touch, canon, filterReadable,
|
|
@@ -361,9 +373,22 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
361
373
|
if (t && !canRead(auth, t.project || "")) return json(res, 404, { id: null, task: null });
|
|
362
374
|
return json(res, 200, { id: t ? t.id : null, task: t || null });
|
|
363
375
|
}
|
|
376
|
+
// #6983 fixes 2+3. /tasks is the hot read — every seat pays it several times a session — and on
|
|
377
|
+
// trantor it had grown to 1.55MB across 958 cards: 625-961ms against a 1500ms budget, and past
|
|
378
|
+
// execSync's 1MB pipe. Measured, the weight is not the cards, it is what hangs off them: log
|
|
379
|
+
// 56.3%, history 11.8%, checklist 4.4%. `fields=slim` drops exactly those three and keeps every
|
|
380
|
+
// column a reader actually renders (log becomes logCount) — 16.8% of full. `card=<id>` then
|
|
381
|
+
// re-attaches the ONE card the caller opened, so reading a card costs an index, not the board.
|
|
382
|
+
// Both are opt-in and additive: a client that sends neither gets the unchanged full payload, so
|
|
383
|
+
// this does not move the cliff for anyone, and old clients keep working against a new hub.
|
|
364
384
|
if (req.method === "GET" && P === "/tasks") {
|
|
365
385
|
const proj = q.project ? canon(q.project) : ""; const ts = filterReadable(auth, proj ? state.tasks.filter(t => canon(t.project) === proj) : state.tasks, t => t.project || "");
|
|
366
|
-
return json(res, 200, { tasks: ts });
|
|
386
|
+
if (String(q.fields || "") !== "slim") return json(res, 200, { tasks: ts });
|
|
387
|
+
const keep = Number(q.card);
|
|
388
|
+
const tasks = ts.map(t => (Number.isInteger(keep) && t.id === keep ? t : slimCard(t)));
|
|
389
|
+
// Echo the projection back. Without it a client cannot tell "hub honored slim" from "old hub
|
|
390
|
+
// ignored the param and sent everything", and would have to guess from a missing field.
|
|
391
|
+
return json(res, 200, { tasks, fields: "slim" });
|
|
367
392
|
}
|
|
368
393
|
if (req.method === "GET" && P === "/history") {
|
|
369
394
|
const requestedLimit = Number(q.limit || 200);
|
package/hub/routes/messages.mjs
CHANGED
|
@@ -84,13 +84,16 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
|
|
|
84
84
|
// `superseded` leaves `contracts` for exactly the reason `abandoned` does: a session's hooks
|
|
85
85
|
// are PINNED at session start, so an older stop hook filters this array with its own
|
|
86
86
|
// predicate and would keep blocking on a row the hub has already settled.
|
|
87
|
-
|
|
87
|
+
// `ack` leaves `contracts` too (#7079): a `wake:false` send, a `receipt` or a `status` is the
|
|
88
|
+
// sender declaring nothing is owed, so an old pinned hook must never see it as a row to block on.
|
|
89
|
+
const out = all.filter(c => c.disposition !== "abandoned" && c.disposition !== "superseded" && c.disposition !== "ack");
|
|
88
90
|
return json(res, 200, {
|
|
89
91
|
session, contracts: out, abandonedContracts: all.filter(c => c.disposition === "abandoned"),
|
|
90
92
|
supersededContracts: all.filter(c => c.disposition === "superseded"),
|
|
93
|
+
ackContracts: all.filter(c => c.disposition === "ack"),
|
|
91
94
|
open: out.filter(c => !c.answered).length,
|
|
92
95
|
waiting: by("waiting"), stalled: by("stalled"), abandoned: by("abandoned"),
|
|
93
|
-
superseded: by("superseded"), answered: by("answered"),
|
|
96
|
+
superseded: by("superseded"), ack: by("ack"), answered: by("answered"),
|
|
94
97
|
});
|
|
95
98
|
}
|
|
96
99
|
|
package/lib/overseer.mjs
CHANGED
|
@@ -3,12 +3,10 @@
|
|
|
3
3
|
// so the episode-recurrence path was untestable and therefore unproven.
|
|
4
4
|
const PEER_LIVE_MS = Number(process.env.RELAY_OVERSEER_PEER_LIVE_MS || 5 * 60 * 1000);
|
|
5
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);
|
|
10
|
-
|
|
11
6
|
const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity"]);
|
|
7
|
+
// A card in `todo` is queued, not held; `done`/`failed`/`blocked` are nobody's hands. Only these
|
|
8
|
+
// two mean a session has it open right now.
|
|
9
|
+
const HELD_STATUSES = new Set(["doing", "testing"]);
|
|
12
10
|
|
|
13
11
|
const asArray = (v) => Array.isArray(v) ? v : [];
|
|
14
12
|
const clean = (v) => String(v ?? "").trim();
|
|
@@ -66,7 +64,7 @@ function sortCollisions(collisions) {
|
|
|
66
64
|
);
|
|
67
65
|
}
|
|
68
66
|
|
|
69
|
-
export function detectCollisions({ peers = [], claims = [], links = [], autonomy = {}, now } = {}) {
|
|
67
|
+
export function detectCollisions({ peers = [], claims = [], links = [], cards = [], autonomy = {}, now } = {}) {
|
|
70
68
|
const at = finiteNumber(now) ?? 0;
|
|
71
69
|
const out = [];
|
|
72
70
|
|
|
@@ -102,6 +100,11 @@ export function detectCollisions({ peers = [], claims = [], links = [], autonomy
|
|
|
102
100
|
}
|
|
103
101
|
|
|
104
102
|
const claimSessionsByFile = new Map();
|
|
103
|
+
// The same claims indexed by PATH ALONE, project dropped. A claim's `project` is the CLAIMANT's
|
|
104
|
+
// project, not the file's, so one path claimed under two project names is two sessions on one
|
|
105
|
+
// file — the cross-project overlap that claimSessionsByFile, keyed on project+file, structurally
|
|
106
|
+
// cannot see. linked-activity reads this one.
|
|
107
|
+
const claimProjectsByPath = new Map();
|
|
105
108
|
for (const claim of asArray(claims)) {
|
|
106
109
|
const project = clean(claim?.project);
|
|
107
110
|
const file = clean(claim?.file);
|
|
@@ -111,6 +114,9 @@ export function detectCollisions({ peers = [], claims = [], links = [], autonomy
|
|
|
111
114
|
const sessions = claimSessionsByFile.get(key) ?? new Set();
|
|
112
115
|
sessions.add(session);
|
|
113
116
|
claimSessionsByFile.set(key, sessions);
|
|
117
|
+
const byProject = claimProjectsByPath.get(file) ?? new Map();
|
|
118
|
+
byProject.set(project, (byProject.get(project) ?? new Set()).add(session));
|
|
119
|
+
claimProjectsByPath.set(file, byProject);
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
for (const key of [...claimSessionsByFile.keys()].sort()) {
|
|
@@ -126,40 +132,75 @@ export function detectCollisions({ peers = [], claims = [], links = [], autonomy
|
|
|
126
132
|
});
|
|
127
133
|
}
|
|
128
134
|
|
|
129
|
-
// A link is DECLARED — the operator already told us these projects move together
|
|
130
|
-
//
|
|
131
|
-
// crebral-health ↔ crebral-scribe produced 468 identical warnings across 8 days
|
|
132
|
-
// audit)
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
135
|
+
// A link is DECLARED — the operator already told us these projects move together, so two sessions
|
|
136
|
+
// being LIVE on both sides restates the operator's own declaration and stays true for as long as
|
|
137
|
+
// the machine is on. crebral-health ↔ crebral-scribe produced 468 identical warnings across 8 days
|
|
138
|
+
// (2026-08-12 audit); trantor ↔ trantor-duty is codependent by policy, so on that machine the
|
|
139
|
+
// condition is permanent (#7029: it woke a seat that then spent a full turn proving a negative).
|
|
140
|
+
// Presence is a STATE. Narrowing it to "both sides are executing" was still a state — a busier
|
|
141
|
+
// one. The only thing worth a wake is an EVENT: two sessions actually on the same thing. There
|
|
142
|
+
// are exactly two the hub already holds, so neither has to be invented —
|
|
143
|
+
// (1) one FILE PATH claimed live from both sides of the link (file claims exist for this; the
|
|
144
|
+
// file-conflict kind keys on project+file and so is blind across projects), and
|
|
145
|
+
// (2) one CARD ID held (doing/testing) by live sessions from both sides — `assignee` is intent
|
|
146
|
+
// and `workedBy` is the signed evidence of who actually moved it, so a card wearing two
|
|
147
|
+
// live faces from two linked projects is two sessions on one piece of work.
|
|
148
|
+
// No event, no warning. This under-warns by construction and that is the intended bias: a warning
|
|
149
|
+
// that fires on a permanent condition teaches its readers to ignore it.
|
|
150
|
+
const projectOfSession = new Map();
|
|
151
|
+
for (const peer of livePeers) if (!projectOfSession.has(peer.session)) projectOfSession.set(peer.session, peer.project);
|
|
152
|
+
|
|
153
|
+
const heldCards = [];
|
|
154
|
+
for (const card of asArray(cards)) {
|
|
155
|
+
const id = finiteNumber(card?.id);
|
|
156
|
+
if (id == null || !HELD_STATUSES.has(clean(card?.status))) continue;
|
|
157
|
+
// Only LIVE sessions count. A card assigned to a seat that went home is not a collision.
|
|
158
|
+
const holders = sortedStrings([card?.assignee, card?.workedBy]).filter((s) => projectOfSession.has(s));
|
|
159
|
+
if (holders.length < 2) continue;
|
|
160
|
+
heldCards.push({ id, holders });
|
|
149
161
|
}
|
|
150
162
|
|
|
151
163
|
for (const link of asArray(links)) {
|
|
152
164
|
const projects = sortedStrings(link?.projects ?? []);
|
|
153
165
|
if (projects.length < 2) continue;
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
const sessions =
|
|
166
|
+
const inLink = new Set(projects);
|
|
167
|
+
const files = [];
|
|
168
|
+
const sessions = new Set();
|
|
169
|
+
const contested = new Set();
|
|
170
|
+
const evidence = [];
|
|
171
|
+
|
|
172
|
+
for (const path of [...claimProjectsByPath.keys()].sort()) {
|
|
173
|
+
const claimSides = [...claimProjectsByPath.get(path).entries()].filter(([project]) => inLink.has(project));
|
|
174
|
+
if (claimSides.length < 2) continue;
|
|
175
|
+
const claimants = sortedStrings(claimSides.flatMap(([, ss]) => [...ss]));
|
|
176
|
+
if (claimants.length < 2) continue;
|
|
177
|
+
files.push(path);
|
|
178
|
+
for (const [project] of claimSides) contested.add(project);
|
|
179
|
+
for (const s of claimants) sessions.add(s);
|
|
180
|
+
evidence.push(`${path} is claimed by ${claimants.join(", ")}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const card of heldCards) {
|
|
184
|
+
const holders = card.holders.filter((s) => inLink.has(projectOfSession.get(s)));
|
|
185
|
+
const holderProjects = new Set(holders.map((s) => projectOfSession.get(s)));
|
|
186
|
+
if (holderProjects.size < 2) continue;
|
|
187
|
+
for (const project of holderProjects) contested.add(project);
|
|
188
|
+
for (const s of holders) sessions.add(s);
|
|
189
|
+
evidence.push(`card #${card.id} is held by ${holders.join(", ")}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (evidence.length === 0) continue;
|
|
193
|
+
// ONE collision per link, evidence and all. The episode key downstream is project+kind+files, so
|
|
194
|
+
// per-file splitting would mint a fresh episode (and a fresh wake) every time the evidence set
|
|
195
|
+
// shifted — the same volatility that made membership a bad episode key (#5350). A second
|
|
196
|
+
// contended card inside a standing episode therefore stays silent; under-warning is the bias.
|
|
197
|
+
const sides = sortedStrings(contested);
|
|
157
198
|
pushCollision(out, {
|
|
158
|
-
project:
|
|
199
|
+
project: sides[0] ?? projects[0],
|
|
159
200
|
kind: "linked-activity",
|
|
160
|
-
sessions,
|
|
161
|
-
files
|
|
162
|
-
detail: `Linked projects ${
|
|
201
|
+
sessions: [...sessions],
|
|
202
|
+
files,
|
|
203
|
+
detail: `Linked projects ${sides.join(", ")} are on the same work: ${evidence.join("; ")}.`,
|
|
163
204
|
});
|
|
164
205
|
}
|
|
165
206
|
|
package/lib/turn-policy.mjs
CHANGED
|
@@ -14,6 +14,22 @@
|
|
|
14
14
|
/// The first card a message cites. A turn belongs to exactly one card, and this is how the runner
|
|
15
15
|
/// knows when a wake has moved to a different one.
|
|
16
16
|
export const CARD_REF_RE = /#(\d{1,7})(?!\d)/;
|
|
17
|
+
const CARD_REF_RE_G = /#(\d{1,7})(?!\d)/g;
|
|
18
|
+
|
|
19
|
+
/// #7061: a citation that HANDS THE CARD OVER, as opposed to one that merely mentions it. A work
|
|
20
|
+
/// order naturally opens with what shipped since the seat's last turn — "#7037 is merged as
|
|
21
|
+
/// a01f629 … YOUR CARD: #6983" — so the earliest id in the prose is routinely a DONE card. Binding
|
|
22
|
+
/// to it wrote the state sidecar, the card log and the run record against a card nobody worked.
|
|
23
|
+
/// Shape is what separates the two roles; position never did. Deliberately narrow: an assignment
|
|
24
|
+
/// verb or label immediately before the id, never a general parse of English.
|
|
25
|
+
const ASSIGN_CARD_RE = new RegExp([
|
|
26
|
+
// a label: "YOUR CARD: #6983", "card #7010", "contract: #7061", "work order — #6897"
|
|
27
|
+
String.raw`\b(?:your\s+card|card|contract|work\s+order|assignment)\s*(?:is\s*)?[:\u2014\u2013-]?\s*#(\d{1,7})(?!\d)`,
|
|
28
|
+
// a verb that hands it over: "take #7061", "take card #6897", "work on #7001", "bounce on #6134"
|
|
29
|
+
String.raw`\b(?:take|work|pick\s+up|resume|start|finish|bounced?)\s+(?:on\s+)?(?:card\s+)?#(\d{1,7})(?!\d)`,
|
|
30
|
+
// the card as the subject of a hand-over: "#7061 is yours", "#7002 is bounced"
|
|
31
|
+
String.raw`#(\d{1,7})(?!\d)\s+(?:is|are)\s+(?:yours|bounced|reopened|back)\b`,
|
|
32
|
+
].join("|"), "i");
|
|
17
33
|
|
|
18
34
|
/// Words that make a direct message an instruction rather than conversation. Deliberately short:
|
|
19
35
|
/// the point is to catch a contract that forgot to cite its card, not to parse English.
|
|
@@ -24,6 +40,84 @@ export function cardRef(text) {
|
|
|
24
40
|
return m ? Number(m[1]) : 0;
|
|
25
41
|
}
|
|
26
42
|
|
|
43
|
+
/// Every card a message cites, in the order it cites them — what the runner uses to tell a wake
|
|
44
|
+
/// that names ONE card from one that names several.
|
|
45
|
+
export function cardRefs(text) {
|
|
46
|
+
return [...String(text || "").matchAll(CARD_REF_RE_G)].map((m) => Number(m[1]));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// The card this text ASSIGNS, or 0 when it only mentions cards. Leftmost assignment wins: an
|
|
50
|
+
/// order says what it wants first and explains itself after.
|
|
51
|
+
export function assignedCardRef(text) {
|
|
52
|
+
const m = ASSIGN_CARD_RE.exec(String(text || ""));
|
|
53
|
+
return m ? Number(m.slice(1).find((g) => g != null)) : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// #7061: the card a wake batch binds its turn to. Messages addressed to THIS seat outrank
|
|
57
|
+
/// @mentions (a mention is someone else's contract that named you), and within them the NEWEST
|
|
58
|
+
/// assignment wins, because a later order supersedes an earlier one. Only when no message in the
|
|
59
|
+
/// batch is assignment-shaped does this fall back to the newest message's first citation — the
|
|
60
|
+
/// pre-#7061 behaviour, narrowed from "first id anywhere in the batch" to one message.
|
|
61
|
+
export function wakeCard(messages, { session = "" } = {}) {
|
|
62
|
+
const all = (Array.isArray(messages) ? messages : []).filter(Boolean);
|
|
63
|
+
if (!all.length) return 0;
|
|
64
|
+
const direct = session ? all.filter((m) => m.to === session) : [];
|
|
65
|
+
const pool = newestLast(direct.length ? direct : all);
|
|
66
|
+
for (let i = pool.length - 1; i >= 0; i--) {
|
|
67
|
+
const c = assignedCardRef(pool[i].text);
|
|
68
|
+
if (c) return c;
|
|
69
|
+
}
|
|
70
|
+
for (let i = pool.length - 1; i >= 0; i--) {
|
|
71
|
+
const c = cardRef(pool[i].text);
|
|
72
|
+
if (c) return c;
|
|
73
|
+
}
|
|
74
|
+
// A batch whose only citation sits in an @mention still names a card; nothing is worse than 0.
|
|
75
|
+
return all.map((m) => cardRef(m.text)).find(Boolean) || 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Chronological order, by whichever field EVERY message in the batch actually has. Mixing hub ids
|
|
79
|
+
/// with wall-clock ts across messages would sort by two different scales and silently misorder, so
|
|
80
|
+
/// a batch that is not uniform keeps the order the runner queued it in.
|
|
81
|
+
function newestLast(msgs) {
|
|
82
|
+
for (const key of ["id", "ts"]) {
|
|
83
|
+
if (msgs.every((m) => Number.isFinite(Number(m?.[key])))) {
|
|
84
|
+
return msgs.map((m, i) => ({ m, i }))
|
|
85
|
+
.sort((a, b) => (Number(a.m[key]) - Number(b.m[key])) || (a.i - b.i))
|
|
86
|
+
.map((x) => x.m);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return msgs;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Turn kinds that exist BEFORE or WITHOUT a wake message. Neither can carry a card, so neither
|
|
93
|
+
/// can ever be a state step. That is a fact about the SHAPE of the turn, not about configuration,
|
|
94
|
+
/// which is exactly why no amount of correct config makes it come out differently.
|
|
95
|
+
const NO_CARD_TURNS = {
|
|
96
|
+
kickoff: "a kickoff runs before any message arrives, so it belongs to no card",
|
|
97
|
+
pulse: "a pulse is a timer rather than a message, so it belongs to no card",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/// #7060: why THIS turn is not assembled from a WorkingState, or null when it is. The runner's
|
|
101
|
+
/// boot line only ever proved CONFIGURATION — the flag, an eligible seat, a CLI carrying
|
|
102
|
+
/// `--json-schema`, the schema on disk — and an operator read it as a claim about the PROMPT. The
|
|
103
|
+
/// two came apart on the very next turn: the kickoff runs before any message exists, so it belongs
|
|
104
|
+
/// to no card and can never be a state step, and it said nothing at all about that. The seat that
|
|
105
|
+
/// hit it had confirmed flags, a valid schema and an unassembled prompt, and the only way to tell
|
|
106
|
+
/// a skip from proof was to read the source. A skip must not be mistaken for proof — the same rule
|
|
107
|
+
/// the drill's SKIP already keeps (bin/drill-surface.mjs:39) — so the runner asks this every turn
|
|
108
|
+
/// and prints the answer.
|
|
109
|
+
///
|
|
110
|
+
/// Reasons are ordered by which constraint actually BINDS. A pulse belongs to no card whether or
|
|
111
|
+
/// not the breaker tripped, so on those turns the structural fact is the honest answer.
|
|
112
|
+
export function stateSkipReason({ mode = false, kind = "wake", breakerTripped = false, card = 0 } = {}) {
|
|
113
|
+
if (!mode) return "state mode is off for this seat";
|
|
114
|
+
const structural = NO_CARD_TURNS[String(kind)];
|
|
115
|
+
if (structural) return structural;
|
|
116
|
+
if (breakerTripped) return "the state-mode breaker tripped earlier this run, so the seat is back on the transcript path";
|
|
117
|
+
if (!(Number(card) > 0)) return "this wake assigns no card, and a state turn is bound to exactly one";
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
27
121
|
export function hasImperative(text) {
|
|
28
122
|
return IMPERATIVE_RE.test(String(text || ""));
|
|
29
123
|
}
|
package/mcp.mjs
CHANGED
|
@@ -154,7 +154,7 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
|
|
|
154
154
|
catch (e) { return { content: [{ type: "text", text: `could not reach the hub: ${e?.message || e}` }] }; }
|
|
155
155
|
// The hub keeps abandoned contracts in their own key so older stop hooks stop blocking on them.
|
|
156
156
|
// The ledger still wants to SHOW them, so put the two halves back together here.
|
|
157
|
-
const all = [...(r?.contracts || []), ...(r?.abandonedContracts || []), ...(r?.supersededContracts || [])]
|
|
157
|
+
const all = [...(r?.contracts || []), ...(r?.abandonedContracts || []), ...(r?.supersededContracts || []), ...(r?.ackContracts || [])]
|
|
158
158
|
.sort((a, b) => a.ts - b.ts);
|
|
159
159
|
if (!all.length) return { content: [{ type: "text", text: "You have not dispatched any contracts in the last 24h." }] };
|
|
160
160
|
// Fall back to the pre-disposition shape when talking to an older hub.
|
|
@@ -162,8 +162,9 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
|
|
|
162
162
|
const open = all.filter(c => disp(c) === "waiting" || disp(c) === "stalled");
|
|
163
163
|
const abandoned = all.filter(c => disp(c) === "abandoned");
|
|
164
164
|
const superseded = all.filter(c => disp(c) === "superseded");
|
|
165
|
+
const acks = all.filter(c => disp(c) === "ack");
|
|
165
166
|
const mins = (ms) => (ms >= 60000 ? `${Math.round(ms / 60000)}m` : `${Math.round(ms / 1000)}s`);
|
|
166
|
-
const MARK = { answered: "✅", waiting: "⏳", stalled: "⚠️", abandoned: "🪦", superseded: "⤳" };
|
|
167
|
+
const MARK = { answered: "✅", waiting: "⏳", stalled: "⚠️", abandoned: "🪦", superseded: "⤳", ack: "·" };
|
|
167
168
|
const line = (c) => {
|
|
168
169
|
const d = disp(c);
|
|
169
170
|
const health = d === "answered" ? "" :
|
|
@@ -184,9 +185,13 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
|
|
|
184
185
|
if (superseded.length) {
|
|
185
186
|
notes.push(`⤳ ${superseded.length} SUPERSEDED: the assignee is alive and has since answered a newer contract from you, so these were never going to be answered. Nothing is owed — do not chase them.`);
|
|
186
187
|
}
|
|
187
|
-
|
|
188
|
+
if (acks.length) {
|
|
189
|
+
notes.push(`· ${acks.length} ACK: sent with wake:false, or as a receipt/status. You declared nothing was owed on these, so they never wait and never stall.`);
|
|
190
|
+
}
|
|
191
|
+
const text = `${open.length} outstanding of ${all.length - acks.length} contract(s) in the last 24h`
|
|
188
192
|
+ (abandoned.length ? ` (plus ${abandoned.length} abandoned)` : "")
|
|
189
|
-
+ (superseded.length ? ` (plus ${superseded.length} superseded)` : "")
|
|
193
|
+
+ (superseded.length ? ` (plus ${superseded.length} superseded)` : "")
|
|
194
|
+
+ (acks.length ? ` (plus ${acks.length} acks, nothing owed)` : "") + `:\n`
|
|
190
195
|
+ all.slice(-25).map(line).join("\n")
|
|
191
196
|
+ (notes.length ? "\n\n" + notes.join("\n") : "");
|
|
192
197
|
return { content: [{ type: "text", text }] };
|
|
@@ -332,6 +337,10 @@ const STOPWORDS = new Set(["the","a","an","and","or","of","to","in","on","for","
|
|
|
332
337
|
function titleWords(title) {
|
|
333
338
|
return new Set(String(title || "").toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g)?.filter(w => !STOPWORDS.has(w)) || []);
|
|
334
339
|
}
|
|
340
|
+
// How many notes a card carries, whichever shape the hub sent (#6983). A slim card has been stripped
|
|
341
|
+
// of its log and carries logCount instead; a full card — an older hub, or the one card we asked for
|
|
342
|
+
// in full — still has the log itself. Read both so the ·N is right against any hub version.
|
|
343
|
+
const noteCount = (t) => (Array.isArray(t.log) ? t.log.length : Number(t.logCount) || 0);
|
|
335
344
|
function cardView(tasks, id, proj) {
|
|
336
345
|
const card = tasks.find(t => t.id === id);
|
|
337
346
|
if (!card) return `${proj}: no card #${id}`;
|
|
@@ -358,7 +367,7 @@ function cardView(tasks, id, proj) {
|
|
|
358
367
|
.filter(t => t.status === "done" && t.id !== card.id && [...titleWords(t.title)].some(w => mine.has(w)))
|
|
359
368
|
.sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0))
|
|
360
369
|
.slice(0, 5)
|
|
361
|
-
.map(t => `#${t.id} ${t.title}${t
|
|
370
|
+
.map(t => `#${t.id} ${t.title}${noteCount(t) ? ` ·${noteCount(t)}` : ""}`);
|
|
362
371
|
if (kin.length) out.push(`related done cards:\n ${kin.join("\n ")}`);
|
|
363
372
|
|
|
364
373
|
return out.join("\n");
|
|
@@ -369,11 +378,16 @@ server.tool("relay_board", "Show a project's Kanban board (all cards + their sta
|
|
|
369
378
|
card: z.number().optional().describe("read ONE card instead of the board: the card itself, its notes, and the last five done cards whose title shares a word with it. This is what a seat starting a card should call — the whole board is 1900+ cards of someone else's work.") },
|
|
370
379
|
async ({ project, card }) => {
|
|
371
380
|
const proj = project || PROJECT;
|
|
372
|
-
|
|
381
|
+
// #6983: ask for the SLIM projection, and when opening one card ask for that card full. The board
|
|
382
|
+
// needs titles and a note COUNT, never every card's note text — on trantor that was 1.55MB and
|
|
383
|
+
// 625-961ms against a 1500ms budget, which is why this tool intermittently answered "hub 0" when
|
|
384
|
+
// the hub was 200 OK and merely slow. An older hub ignores both params and returns the full board,
|
|
385
|
+
// which still renders — so this asks for cheap and works either way, rather than requiring a deploy.
|
|
386
|
+
const { tasks } = await api("GET", `/tasks?project=${encodeURIComponent(proj)}&fields=slim${card ? `&card=${encodeURIComponent(card)}` : ""}`);
|
|
373
387
|
if (!tasks.length) return { content: [{ type: "text", text: `${proj}: no cards yet` }] };
|
|
374
388
|
if (card) return { content: [{ type: "text", text: cardView(tasks, card, proj) }] };
|
|
375
389
|
const by = { todo: [], doing: [], testing: [], failed: [], done: [], blocked: [] };
|
|
376
|
-
for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}${t
|
|
390
|
+
for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}${noteCount(t) ? ` ·${noteCount(t)}` : ""}`);
|
|
377
391
|
const cols = Object.entries(by).filter(([, v]) => v.length).map(([k, v]) => `${k.toUpperCase()}:\n ${v.join("\n ")}`);
|
|
378
392
|
return { content: [{ type: "text", text: `${proj} board\n${cols.join("\n")}` }] };
|
|
379
393
|
});
|
|
@@ -391,8 +405,9 @@ server.tool("relay_peers", "Find who you can talk to: the live agent sessions on
|
|
|
391
405
|
|
|
392
406
|
server.tool("relay_send", "Send a live message to another agent session (or 'all' to broadcast). Reach the other agent YOURSELF: if you are about to ask the human to pass something along, tell the session directly instead — asking a person to carry a message between two agents is a failure, not politeness. Don't know the id? relay_peers lists them, linked projects included. Cross-project action is a breach unless the operator linked the projects (`trantor policy link <a> <b> --reason \"<why>\"`) — the hub answers 403 for a send into an unlinked project.",
|
|
393
407
|
{ to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body"),
|
|
394
|
-
wake: z.boolean().optional().describe("false = context, not a contract: the message batches into the target's next turn instead of buying it a whole CLI session. Use it for acks, FYIs and queue notes; leave it unset for anything you expect worked on.")
|
|
395
|
-
|
|
408
|
+
wake: z.boolean().optional().describe("false = context, not a contract: the message batches into the target's next turn instead of buying it a whole CLI session. Use it for acks, FYIs and queue notes; leave it unset for anything you expect worked on."),
|
|
409
|
+
re: z.number().optional().describe("the message id you are ANSWERING. Set it whenever you reply to a specific message. Without it the hub matches your reply to the peer's OLDEST outstanding contract, so answering their newest question silently closes their oldest one and leaves the real one reading WAITING forever — which is how seats end up chasing contracts that were answered long ago.") },
|
|
410
|
+
async ({ to, text, wake, re }) => {
|
|
396
411
|
// The event log is append-only — a secret in it is unrecoverable, so refuse BEFORE
|
|
397
412
|
// anything reaches the hub. Returns the offending kinds so the caller can fix it.
|
|
398
413
|
const scrub = assertNoSecrets(text);
|
|
@@ -401,7 +416,7 @@ server.tool("relay_send", "Send a live message to another agent session (or 'all
|
|
|
401
416
|
}
|
|
402
417
|
let sent;
|
|
403
418
|
try {
|
|
404
|
-
sent = await api("POST", "/send", { from: SESSION, to, text, ...(wake === false ? { wake: false } : {}) });
|
|
419
|
+
sent = await api("POST", "/send", { from: SESSION, to, text, ...(wake === false ? { wake: false } : {}), ...(Number.isFinite(re) ? { re: Number(re) } : {}) });
|
|
405
420
|
} catch (error) {
|
|
406
421
|
// A duty relay refusal is itself a fleet incident. Record it on the target lane before the
|
|
407
422
|
// tool returns the 403; the model must not interpret a failed report as permission to skip
|
package/package.json
CHANGED
package/skills/crew/SKILL.md
CHANGED
|
@@ -122,6 +122,20 @@ Loop until the board is done — you are a foreman, not a mailbox:
|
|
|
122
122
|
- **ABANDONED** — the assignee has been gone long enough that the contract can never be
|
|
123
123
|
answered. Nobody is coming back. **Reassign the work or drop it deliberately** — it will not
|
|
124
124
|
block you again, and it will not resolve itself.
|
|
125
|
+
- **SUPERSEDED** — the assignee is alive and has since answered a NEWER contract from you, so
|
|
126
|
+
this row was never going to be answered on its own. Nothing is owed. Do not chase it.
|
|
127
|
+
- **ACK** — you sent it with `wake:false`, or as a `receipt`/`status`. You declared that nothing
|
|
128
|
+
was owed, so it never waits and never stalls. It stays in the ledger as a record that you said
|
|
129
|
+
the thing; it is not work you are waiting on.
|
|
130
|
+
|
|
131
|
+
Two sizing rules that cost real money to learn:
|
|
132
|
+
- **A turn is killed at 20 minutes** (`TURN_MAX_MS`), so write a contract that can FINISH inside
|
|
133
|
+
one. A seat cut at the box loses the turn, and on the state path nothing salvages it. If a
|
|
134
|
+
seat reports a "crash", check `duration_ms` against 1200000 before believing it — SIGKILL at
|
|
135
|
+
the box exits 137 and reads like a fault.
|
|
136
|
+
- **A wake is capped at 2000 characters and truncated HEAD-first**, which eats your instructions
|
|
137
|
+
and keeps your rationale. Put the ask in the FIRST sentence, keep it short, and split a long
|
|
138
|
+
order into two sends rather than one long one.
|
|
125
139
|
A contract is never closed on the assignee's behalf: quiet is not an outcome. An abandoned one
|
|
126
140
|
stays in the ledger with the evidence, and a seat that revives still closes its own contract.
|
|
127
141
|
4. Grunt sub-tasks that appear mid-build (a regex, a config block, a doc paragraph) →
|