trantor 0.18.77 → 0.18.79
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/cmux.mjs +2 -0
- package/bin/crew/core.mjs +2 -1
- package/bin/crew/herdr.mjs +1 -0
- package/bin/crew/models.mjs +2 -3
- package/bin/crew/processes.mjs +29 -0
- package/bin/crew/stage.mjs +28 -0
- package/bin/crew/state.mjs +2 -0
- package/bin/crew/swap.mjs +163 -0
- package/bin/crew-runner.mjs +93 -17
- package/bin/crew.mjs +56 -21
- package/bin/summarize.mjs +5 -15
- package/hooks/heartbeat.mjs +4 -2
- package/hooks/prompt-focus.mjs +30 -12
- package/hub/routes/cards.mjs +13 -4
- package/hub/routes/contract-transfer.mjs +58 -0
- package/hub/routes/messages.mjs +2 -0
- package/lib/classify-failure.mjs +7 -0
- package/lib/turn-policy.mjs +23 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.79",
|
|
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/cmux.mjs
CHANGED
|
@@ -55,6 +55,7 @@ function tracked(ctx) {
|
|
|
55
55
|
|
|
56
56
|
function prepare(ctx, prune) {
|
|
57
57
|
const result = tracked(ctx);
|
|
58
|
+
if (ctx.swap) return result.reuse;
|
|
58
59
|
for (const id of result.stale) {
|
|
59
60
|
console.log(` → closing stale stacked crew workspace for ${ctx.project} (${id})`);
|
|
60
61
|
closeWorkspace(ctx, id);
|
|
@@ -178,6 +179,7 @@ function spawnAppleScript(ctx, specs, resolve) {
|
|
|
178
179
|
function prepareAppleScriptWorkspace(ctx) {
|
|
179
180
|
const ids = readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmuxws").map(row => row.handle);
|
|
180
181
|
const reuse = ids.at(-1) || "";
|
|
182
|
+
if (ctx.swap) return reuse;
|
|
181
183
|
for (const id of ids.slice(0, -1)) {
|
|
182
184
|
console.log(` → closing stale stacked crew workspace for ${ctx.project} (${id})`);
|
|
183
185
|
closeWorkspace(ctx, id);
|
package/bin/crew/core.mjs
CHANGED
|
@@ -146,8 +146,9 @@ export function runnerCommand(ctx, agent, model = "", effort = null) {
|
|
|
146
146
|
.join("");
|
|
147
147
|
// #7777: the launcher resolved the catalog's per-difficulty effort for this seat; the runner
|
|
148
148
|
// reads CREW_EFFORT, applies what the CLI accepts and logs the one-line summary.
|
|
149
|
+
const swapEnv = ctx.swap ? ` CREW_SWAP_STAGE=${shellQuote(ctx.swap.path)} CREW_SWAP_LABEL=${shellQuote(ctx.swap.label)} CREW_SWAP_AGENT=${shellQuote(ctx.swap.agent)} CREW_SWAP_DRIVER=${shellQuote(ctx.swap.driver || "")}` : "";
|
|
149
150
|
const effortEnv = effort ? ` CREW_EFFORT=${shellQuote(JSON.stringify(effort))}` : "";
|
|
150
|
-
return `cd ${shellQuote(ctx.dir)} && ${forwarded}CREW_MODEL=${shellQuote(model)}${effortEnv} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
|
|
151
|
+
return `cd ${shellQuote(ctx.dir)} && ${forwarded}CREW_MODEL=${shellQuote(model)}${effortEnv}${swapEnv} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
export function listPids(pattern) {
|
package/bin/crew/herdr.mjs
CHANGED
|
@@ -121,6 +121,7 @@ function orchPaneWorkspaces(ctx) {
|
|
|
121
121
|
|
|
122
122
|
function prepareWorkspace(ctx, prune) {
|
|
123
123
|
const tracked = trackedWorkspace(ctx);
|
|
124
|
+
if (ctx.swap) return tracked.reuse;
|
|
124
125
|
const orchSpaces = orchPaneWorkspaces(ctx);
|
|
125
126
|
for (const id of tracked.stale) {
|
|
126
127
|
// #7285: never close reuse itself, nor a workspace hosting the tracked orch pane — `up` once
|
package/bin/crew/models.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { call } from "./core.mjs";
|
|
4
|
-
import { reapSeat } from "./state.mjs";
|
|
5
4
|
import { resolveEffort } from "../../lib/model-catalog.mjs";
|
|
6
5
|
|
|
7
6
|
function routeModel(ctx, provider, candidates, task, difficulty) {
|
|
@@ -21,7 +20,8 @@ export function resolveModel(ctx, agent, provider, task, difficulty) {
|
|
|
21
20
|
const candidates = result.ok ? result.stdout.split(/\s+/).filter(Boolean).join(" ") : "";
|
|
22
21
|
let model = routeModel(ctx, provider, candidates, task, difficulty);
|
|
23
22
|
if (!model && candidates) {
|
|
24
|
-
|
|
23
|
+
const first = candidates.split(" ")[0];
|
|
24
|
+
model = first.startsWith(`${provider}/`) ? first : `${provider}/${first}`;
|
|
25
25
|
console.error(`[crew] router unavailable for ${agent}:${provider} — using the provider's own catalog head (${model})`);
|
|
26
26
|
}
|
|
27
27
|
if (!model) throw new Error(`[crew] live model selection failed for ${agent}:${provider} — no router and no catalog; refusing opencode global default`);
|
|
@@ -31,7 +31,6 @@ export function resolveModel(ctx, agent, provider, task, difficulty) {
|
|
|
31
31
|
|
|
32
32
|
export function resolveSpec(ctx, spec, task, difficulty, skipped) {
|
|
33
33
|
const [agent, ...rest] = spec.split(":");
|
|
34
|
-
reapSeat(ctx, agent);
|
|
35
34
|
let field = rest.join(":");
|
|
36
35
|
if (!field) {
|
|
37
36
|
if (agent === "glm") field = "zai-coding-plan";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { call } from "./core.mjs";
|
|
4
|
+
|
|
5
|
+
export const runnerRecord = (ctx, project, agent) => join(ctx.seatDir, `${project}-${agent}.runner.json`);
|
|
6
|
+
|
|
7
|
+
export function seatProcesses(ctx, project, agent) {
|
|
8
|
+
if (ctx.env.CREW_NO_PROC_KILL === "1") return [];
|
|
9
|
+
let label = agent, dir = ctx.dir;
|
|
10
|
+
try {
|
|
11
|
+
const record = JSON.parse(readFileSync(runnerRecord(ctx, project, agent), "utf8"));
|
|
12
|
+
label = record.label;
|
|
13
|
+
dir = record.dir;
|
|
14
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
15
|
+
const rows = call("ps", ["-axo", "pid=,ppid=,command="]).stdout.split("\n").map(line => {
|
|
16
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
17
|
+
return match ? { pid: Number(match[1]), parent: Number(match[2]), command: match[3] } : null;
|
|
18
|
+
}).filter(Boolean);
|
|
19
|
+
const ids = new Set(rows.filter(row => row.command.endsWith(`crew-runner.mjs ${label} ${dir}`)).map(row => row.pid));
|
|
20
|
+
let before;
|
|
21
|
+
do { before = ids.size; for (const row of rows) if (ids.has(row.parent)) ids.add(row.pid); } while (ids.size > before);
|
|
22
|
+
return [...ids];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function signalProcesses(pids, signal) {
|
|
26
|
+
for (const pid of pids) {
|
|
27
|
+
try { process.kill(pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { setTimeout } from "node:timers/promises";
|
|
3
|
+
import { loadOrCreate } from "../../lib/identity.mjs";
|
|
4
|
+
import { ensureEnrolled } from "../../lib/enroll.mjs";
|
|
5
|
+
import { sfetchJson } from "../../lib/signed-fetch.mjs";
|
|
6
|
+
|
|
7
|
+
// A replacement registers before it touches the old runner's worktree or queue.
|
|
8
|
+
export async function awaitSwapRelease(env = process.env) {
|
|
9
|
+
if (!env.CREW_SWAP_STAGE) return;
|
|
10
|
+
const stage = env.CREW_SWAP_STAGE;
|
|
11
|
+
const session = `${env.CREW_SWAP_LABEL}:${env.RELAY_PROJECT}`;
|
|
12
|
+
const identity = loadOrCreate(session, "agent");
|
|
13
|
+
const enrollment = await ensureEnrolled(env.RELAY_URL, identity, env.RELAY_PROJECT);
|
|
14
|
+
if (!enrollment.ok) throw new Error(`replacement enrollment failed: ${enrollment.reason}`);
|
|
15
|
+
const response = await sfetchJson(`${env.RELAY_URL}/register`, {
|
|
16
|
+
identity, payload: { session, project: env.RELAY_PROJECT, kind: "agent", status: "replacement ready; awaiting handoff" },
|
|
17
|
+
signal: AbortSignal.timeout(8000),
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok) throw new Error(`replacement registration failed: ${response.status}`);
|
|
20
|
+
writeFileSync(`${stage}.ready`, JSON.stringify({ pid: process.pid, session }));
|
|
21
|
+
const deadline = Date.now() + 120000;
|
|
22
|
+
while (!existsSync(`${stage}.release`)) {
|
|
23
|
+
if (Date.now() > deadline) throw new Error("swap handoff timed out; replacement stopped before taking work");
|
|
24
|
+
await setTimeout(100);
|
|
25
|
+
}
|
|
26
|
+
const handoff = JSON.parse(readFileSync(`${stage}.release`, "utf8"));
|
|
27
|
+
if (handoff.agent !== env.CREW_SWAP_AGENT) throw new Error("swap release names a different seat");
|
|
28
|
+
}
|
package/bin/crew/state.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { seatProcesses, signalProcesses } from "./processes.mjs";
|
|
2
3
|
import { call, listPids, run } from "./core.mjs";
|
|
3
4
|
import { hostId } from "../../lib/project.mjs";
|
|
4
5
|
import { statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../../lib/docker-janitor.mjs";
|
|
@@ -61,6 +62,7 @@ async function stopSeatContainers(ctx, project, agent) {
|
|
|
61
62
|
async function killSeatProcesses(ctx, project, agent) {
|
|
62
63
|
if (ctx.env.CREW_NO_PROC_KILL === "1" || !project || !agent) return;
|
|
63
64
|
await stopSeatContainers(ctx, project, agent);
|
|
65
|
+
if (!ctx.dry) signalProcesses(seatProcesses(ctx, project, agent), "SIGKILL");
|
|
64
66
|
const patterns = [`seats/${project}-${agent}\\.sh`, `crew-runner\\.mjs ${agent} .*/${project}$`];
|
|
65
67
|
for (const pattern of patterns) {
|
|
66
68
|
for (const pid of listPids(pattern)) run(ctx, "kill", ["-9", pid], { rendered: `kill -9 ${pid} 2>/dev/null` });
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { setTimeout } from "node:timers/promises";
|
|
5
|
+
import { call, commandExists } from "./core.mjs";
|
|
6
|
+
import { resolveSpec } from "./models.mjs";
|
|
7
|
+
import { runnerRecord, seatProcesses, signalProcesses } from "./processes.mjs";
|
|
8
|
+
import { down, readRows, writeRows } from "./state.mjs";
|
|
9
|
+
import { providerStatus } from "../../lib/providers.mjs";
|
|
10
|
+
import { lookup } from "../../lib/model-catalog.mjs";
|
|
11
|
+
import { resolveKeys } from "../../lib/provider-keys.mjs";
|
|
12
|
+
import { resolveSecrets } from "../../lib/secrets.mjs";
|
|
13
|
+
import { signedGet, signedPost } from "../../hooks/lib/api.mjs";
|
|
14
|
+
|
|
15
|
+
const NATIVE = new Set(["codex", "kimi", "claude", "gemini", "dsh"]);
|
|
16
|
+
|
|
17
|
+
export function swapOptions(args) {
|
|
18
|
+
const positional = [];
|
|
19
|
+
const options = { task: "code", difficulty: "medium", provider: "", model: "" };
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const key = { "--task": "task", "--difficulty": "difficulty", "--diff": "difficulty", "--provider": "provider", "--model": "model" }[args[i]];
|
|
22
|
+
if (key) {
|
|
23
|
+
if (!args[i + 1] || args[i + 1].startsWith("--")) throw new Error(`missing value for ${args[i]}`);
|
|
24
|
+
options[key] = args[++i];
|
|
25
|
+
} else if (args[i].startsWith("--")) throw new Error(`unknown swap flag ${args[i]}`);
|
|
26
|
+
else positional.push(args[i]);
|
|
27
|
+
}
|
|
28
|
+
const [from, replacement = options.provider ? from : ""] = positional;
|
|
29
|
+
if (!from || !replacement || positional.length > 2) throw new Error("usage: trantor swap <old> [new[:provider/model]] [--provider P] [--model M] [--json]");
|
|
30
|
+
const agent = replacement.split(":")[0];
|
|
31
|
+
if (![from, agent].every(value => /^[A-Za-z0-9_-]+$/.test(value))) throw new Error("invalid seat name");
|
|
32
|
+
return { ...options, from, replacement, agent };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function validateReplacement(ctx, options, status = providerStatus) {
|
|
36
|
+
const explicit = options.provider;
|
|
37
|
+
const field = options.replacement.split(":").slice(1).join(":");
|
|
38
|
+
const native = NATIVE.has(options.agent) && !explicit;
|
|
39
|
+
const provider = native ? options.agent : (explicit || field.split("/")[0] || (options.agent === "glm" ? "zai-coding-plan" : options.agent));
|
|
40
|
+
const binary = native ? options.agent : "opencode";
|
|
41
|
+
if (!commandExists(binary, ctx.env)) throw new Error(`${binary} CLI not found on PATH; old seat unchanged`);
|
|
42
|
+
const registryName = provider === "zai-coding-plan" ? "zai" : provider;
|
|
43
|
+
const env = resolveKeys(ctx.env, [join(ctx.home, ".token-scrooge/.env"), join(ctx.home, ".agent-bus/.env")], resolveSecrets(ctx.env));
|
|
44
|
+
const rows = await status({ env, path: env.PATH, only: [registryName] });
|
|
45
|
+
const row = rows[0];
|
|
46
|
+
if (!row || row.state !== "connected") throw new Error(`${provider}: ${row?.reason || "no credential validator available"}; old seat unchanged`);
|
|
47
|
+
const spec = explicit ? `${options.agent}-provider:${provider}${options.model ? `/${options.model}` : ""}` : options.model ? `${options.agent}:${options.model}` : options.replacement;
|
|
48
|
+
const skipped = [];
|
|
49
|
+
const seat = resolveSpec({ ...ctx, env }, spec, options.task, options.difficulty, skipped);
|
|
50
|
+
if (!seat) throw new Error(skipped.join("; "));
|
|
51
|
+
if (native && seat.model && !lookup(seat.model).found) {
|
|
52
|
+
let models = [];
|
|
53
|
+
const cache = join(ctx.home, ".codex/models_cache.json");
|
|
54
|
+
if (options.agent === "codex" && existsSync(cache)) models = JSON.parse(readFileSync(cache, "utf8")).models || [];
|
|
55
|
+
if (!models.some(model => model.slug === seat.model)) throw new Error(`cannot validate native model ${seat.model}; old seat unchanged`);
|
|
56
|
+
}
|
|
57
|
+
if (!native) {
|
|
58
|
+
const catalog = call("opencode", ["models", provider], { env });
|
|
59
|
+
const models = catalog.stdout.split(/\s+/).filter(Boolean).map(m => m.startsWith(`${provider}/`) ? m : `${provider}/${m}`);
|
|
60
|
+
if (!catalog.ok || !models.includes(seat.model)) throw new Error(`model ${seat.model} is not in ${provider}'s available catalog; old seat unchanged`);
|
|
61
|
+
}
|
|
62
|
+
return { ...seat, agent: options.agent, driver: native ? "" : "opencode" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function pendingPath(ctx, agent) { return join(ctx.home, ".agent-bus", `pending-${agent}-${ctx.project}.json`); }
|
|
66
|
+
|
|
67
|
+
function readPending(ctx, agent) {
|
|
68
|
+
const file = pendingPath(ctx, agent);
|
|
69
|
+
if (!existsSync(file)) return { wake: [], bcast: [], seen: [], ask: 0, askTo: "" };
|
|
70
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function savePending(ctx, agent, pending) {
|
|
74
|
+
const file = pendingPath(ctx, agent);
|
|
75
|
+
writeFileSync(`${file}.swap`, JSON.stringify({ ...pending, agent, project: ctx.project, ts: Date.now() }));
|
|
76
|
+
renameSync(`${file}.swap`, file);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function hubRequest(ctx, path, body) {
|
|
80
|
+
const response = body
|
|
81
|
+
? await signedPost(`${ctx.hub}${path}`, body, { project: ctx.project })
|
|
82
|
+
: await signedGet(`${ctx.hub}${path}`, { project: ctx.project });
|
|
83
|
+
if (!response.ok || !response.json?.ok) throw new Error(response.json?.error || response.reason || `hub handoff failed (${response.status})`);
|
|
84
|
+
return response.json;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function swapRuntime(ctx, adapters, spawn) {
|
|
88
|
+
let pids = [];
|
|
89
|
+
let replacementPid;
|
|
90
|
+
return {
|
|
91
|
+
validate: options => {
|
|
92
|
+
if (options.agent !== options.from && readRows(ctx).some(row => row.project === ctx.project && row.agent === options.agent)) throw new Error(`replacement seat ${options.agent} already exists; old seat unchanged`);
|
|
93
|
+
return validateReplacement(ctx, options);
|
|
94
|
+
},
|
|
95
|
+
async start(seat, stage) {
|
|
96
|
+
const staged = { ...ctx, swap: { ...stage, agent: seat.agent, driver: seat.driver } };
|
|
97
|
+
spawn(staged, { ...seat, agent: stage.label });
|
|
98
|
+
},
|
|
99
|
+
async ready(stage) {
|
|
100
|
+
const deadline = Date.now() + 30000;
|
|
101
|
+
while (!existsSync(`${stage.path}.ready`)) {
|
|
102
|
+
if (Date.now() > deadline) throw new Error("replacement did not join the bus within 30s; old seat unchanged");
|
|
103
|
+
await setTimeout(100);
|
|
104
|
+
}
|
|
105
|
+
const ready = JSON.parse(readFileSync(`${stage.path}.ready`, "utf8"));
|
|
106
|
+
const response = await signedGet(`${ctx.hub}/peers?project=${encodeURIComponent(ctx.project)}`, { project: ctx.project });
|
|
107
|
+
if (!response.ok || !response.json?.peers?.some(p => p.session === ready.session)) throw new Error("replacement registration is not visible on the bus");
|
|
108
|
+
process.kill(ready.pid, 0);
|
|
109
|
+
replacementPid = ready.pid;
|
|
110
|
+
return ready.session;
|
|
111
|
+
},
|
|
112
|
+
freeze(agent) { pids = seatProcesses(ctx, ctx.project, agent); signalProcesses(pids, "SIGSTOP"); },
|
|
113
|
+
resume() { signalProcesses(pids, "SIGCONT"); },
|
|
114
|
+
transfer: body => hubRequest(ctx, "/contracts/transfer", body),
|
|
115
|
+
async stop(agent) {
|
|
116
|
+
process.kill(replacementPid, 0);
|
|
117
|
+
await down(ctx, [agent], adapters);
|
|
118
|
+
signalProcesses(pids, "SIGKILL");
|
|
119
|
+
},
|
|
120
|
+
async cancel(stage) { await down(ctx, [stage.label], adapters); },
|
|
121
|
+
release(stage, agent, cursor) {
|
|
122
|
+
writeRows(ctx, readRows(ctx).map(row => row.project === ctx.project && row.agent === stage.label ? { ...row, agent } : row));
|
|
123
|
+
writeFileSync(runnerRecord(ctx, ctx.project, agent), JSON.stringify({ label: stage.label, dir: ctx.dir }));
|
|
124
|
+
writeFileSync(`${stage.path}.release`, JSON.stringify({ agent, cursor }));
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function performSwap(ctx, options, runtime) {
|
|
130
|
+
const result = { ok: false, action: "swap", seat: options.from, to: options.agent, moved: [] };
|
|
131
|
+
let stage, frozen = false, transferred = false;
|
|
132
|
+
try {
|
|
133
|
+
const seat = await runtime.validate(options);
|
|
134
|
+
const label = `${options.agent}-swap-${randomUUID().slice(0, 8)}`;
|
|
135
|
+
mkdirSync(ctx.seatDir, { recursive: true });
|
|
136
|
+
stage = { label, path: join(ctx.seatDir, label) };
|
|
137
|
+
await runtime.start(seat, stage);
|
|
138
|
+
const readySession = await runtime.ready(stage);
|
|
139
|
+
frozen = true;
|
|
140
|
+
await runtime.freeze(options.from);
|
|
141
|
+
const pending = readPending(ctx, options.from);
|
|
142
|
+
const target = options.agent === options.from ? pending : readPending(ctx, options.agent);
|
|
143
|
+
const from = `${options.from}:${ctx.project}`, to = `${options.agent}:${ctx.project}`;
|
|
144
|
+
const handoff = await runtime.transfer({ from, to, project: ctx.project, readySession, _op: `swap-${stage.label}`, heldAsk: pending.ask || 0, pendingIds: pending.wake.map(m => m.id) });
|
|
145
|
+
transferred = true;
|
|
146
|
+
result.moved = [...new Set([...pending.wake.map(m => m.id), ...handoff.moved])];
|
|
147
|
+
const wake = new Map([...target.wake, ...pending.wake, ...handoff.messages].map(m => [m.id, { ...m, to }]));
|
|
148
|
+
savePending(ctx, options.agent, { ...target, wake: [...wake.values()], bcast: [...new Map([...target.bcast, ...pending.bcast].map(m => [m.id, m])).values()], ask: handoff.messages.some(m => m.re === pending.ask) ? 0 : pending.ask || target.ask, askTo: pending.askTo || target.askTo });
|
|
149
|
+
await runtime.stop(options.from);
|
|
150
|
+
frozen = false;
|
|
151
|
+
if (options.agent !== options.from) savePending(ctx, options.from, { ...pending, wake: [], bcast: [], ask: 0, askTo: "" });
|
|
152
|
+
await runtime.release(stage, options.agent, handoff.cursor);
|
|
153
|
+
return { ...result, ok: true };
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (!transferred) {
|
|
156
|
+
if (frozen) await runtime.resume();
|
|
157
|
+
if (stage) await runtime.cancel(stage);
|
|
158
|
+
}
|
|
159
|
+
result.reason = error.message;
|
|
160
|
+
if (transferred) result.reason += "; handoff recorded on hub; old seat held for recovery";
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
}
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -16,13 +16,13 @@ import { applyWorktreeDeclaration, ensureSeatWorktree, provisioningLines, readWo
|
|
|
16
16
|
import {
|
|
17
17
|
AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
|
|
18
18
|
verdictFor, substantiveOutput, stallVerdict, cutSignalFor,
|
|
19
|
-
BILLING_RE, looksLikeBillingDeath, usageSaysNoWork, zeroUsageVerdict,
|
|
19
|
+
BILLING_RE, looksLikeBillingDeath, usageSaysNoWork, zeroUsageVerdict, noDeliveryVerdict,
|
|
20
20
|
permissionRejection, looksLikePermissionDeath,
|
|
21
21
|
readPromptText, stripPromptEcho,
|
|
22
22
|
} from "../lib/classify-failure.mjs";
|
|
23
23
|
import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine } from "./crew-payload.mjs";
|
|
24
24
|
import {
|
|
25
|
-
cardRefs,
|
|
25
|
+
cardRefs, servedContractCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
|
|
26
26
|
senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
|
|
27
27
|
CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark,
|
|
28
28
|
} from "../lib/turn-policy.mjs";
|
|
@@ -41,7 +41,10 @@ import {
|
|
|
41
41
|
import { costLine } from "../lib/state/cost.mjs";
|
|
42
42
|
import { resolveStateFlags } from "../lib/state/flags.mjs";
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
import { awaitSwapRelease } from "./crew/stage.mjs";
|
|
45
|
+
|
|
46
|
+
await awaitSwapRelease();
|
|
47
|
+
const AGENT = process.env.CREW_SWAP_AGENT || process.argv[2];
|
|
45
48
|
const DIR = process.argv[3] || process.cwd();
|
|
46
49
|
// Crew agents MUST share the orchestrator's project key (one repo = one lane).
|
|
47
50
|
// RELAY_PROJECT is inherited from crew.mjs (the host's resolved key); else fall
|
|
@@ -241,7 +244,8 @@ const CLI = {
|
|
|
241
244
|
// <label>:<provider>` for any opencode vendor the user configured — run with no per-provider code
|
|
242
245
|
// here; its model id arrives pre-qualified (`<provider>/<model>`) as CREW_MODEL.
|
|
243
246
|
const NATIVE = new Set(["codex", "gemini", "kimi", "claude", "dsh"]);
|
|
244
|
-
const
|
|
247
|
+
const DRIVER = process.env.CREW_SWAP_DRIVER || AGENT;
|
|
248
|
+
const cli = CLI[DRIVER] || (NATIVE.has(AGENT) ? null : CLI.opencode);
|
|
245
249
|
if (!cli) { console.error(`unknown agent '${AGENT}' (native: ${[...NATIVE].join(", ")}; any other name = an opencode provider seat)`); process.exit(1); }
|
|
246
250
|
if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an opencode provider (BYOM)`);
|
|
247
251
|
|
|
@@ -250,7 +254,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
|
|
|
250
254
|
// parameters the CLI can carry (codex -c model_reasoning_effort / claude --effort / opencode
|
|
251
255
|
// --variant) and logs ONE line saying what it set — or that the model is uncatalogued.
|
|
252
256
|
const EFFORT = (() => { try { return JSON.parse(process.env.CREW_EFFORT || "null"); } catch { return null; } })();
|
|
253
|
-
const EFFORT_FLAG = EFFORT ? cliEffortFlag(
|
|
257
|
+
const EFFORT_FLAG = EFFORT ? cliEffortFlag(DRIVER, EFFORT) : { flag: "", text: "" };
|
|
254
258
|
|
|
255
259
|
// RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
|
|
256
260
|
// always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
|
|
@@ -279,6 +283,11 @@ let lastEmptyOutput = false;
|
|
|
279
283
|
// the CLI printed its banner and quit. The both-streams-silent rule above stays; this is the
|
|
280
284
|
// second, harder-to-see empty shape.
|
|
281
285
|
let lastEmptyTurn = false;
|
|
286
|
+
// #10197: exit 0 on a turn BOUND to a card contract with neither a new commit nor a card move to
|
|
287
|
+
// testing/done — the ibkr shape (48s, prose, card still todo, announced done). The card's status
|
|
288
|
+
// at turn end rides along so the assigner's notice can name it.
|
|
289
|
+
let lastNoDelivery = false;
|
|
290
|
+
let lastNoDeliveryStatus = "";
|
|
282
291
|
const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
|
|
283
292
|
const DUTY_NUDGES = process.env.RUNNER_DUTY_NUDGES === "1";
|
|
284
293
|
const DUTY_NUDGE_STATE = process.env.RUNNER_DUTY_NUDGE_STATE
|
|
@@ -612,6 +621,7 @@ const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-$
|
|
|
612
621
|
const STATE_MODE = (() => {
|
|
613
622
|
if (!STATE_FLAG_ON) return false;
|
|
614
623
|
if (AGENT !== "claude") { log(`${STATE_ENV}=1 but this seat is '${AGENT}' — state mode is claude-only (TDD §7.3); staying on the transcript path`); return false; }
|
|
624
|
+
if (DRIVER !== "claude") return false;
|
|
615
625
|
const probe = hasJsonSchemaFlag((bin, args) => spawnSync(bin, args, { encoding: "utf8", timeout: 20000 }));
|
|
616
626
|
if (!probe) { log(`\x1b[33m${STATE_ENV}=1 but this claude CLI has no --json-schema — state mode stays OFF (TDD §6)\x1b[0m`); return false; }
|
|
617
627
|
try {
|
|
@@ -742,6 +752,16 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
742
752
|
// #7759: the worktree snapshot the turn is judged against at its end — porcelain covers edits
|
|
743
753
|
// AND new untracked files, which a HEAD-only comparison misses.
|
|
744
754
|
const statusBefore = gitOut(["status", "--porcelain"], TURN_DIR);
|
|
755
|
+
// #10197: the card status snapshot the turn is judged against at its end — a move to
|
|
756
|
+
// testing/done under the turn is delivery even without a commit. null = the hub read failed:
|
|
757
|
+
// the delivery check stands down rather than claim no-delivery on a transport error.
|
|
758
|
+
let cardStatusBefore = null;
|
|
759
|
+
if (!opts.state && sessionCard > 0) {
|
|
760
|
+
try {
|
|
761
|
+
const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
|
|
762
|
+
cardStatusBefore = task ? String(task.status || "") : null;
|
|
763
|
+
} catch { cardStatusBefore = null; }
|
|
764
|
+
}
|
|
745
765
|
// #9778: the docker snapshot the turn is judged against — containers the CLI spawns are recorded
|
|
746
766
|
// against the seat's card at turn end, then stopped when the card closes or the seat parks.
|
|
747
767
|
const dockerBefore = listContainers();
|
|
@@ -1011,16 +1031,38 @@ exit $turn_exit`;
|
|
|
1011
1031
|
effExit = 1;
|
|
1012
1032
|
log("\x1b[31mexit 0 but the session usage record resolved to all zeros — no model work happened; treating as FAILED\x1b[0m");
|
|
1013
1033
|
}
|
|
1034
|
+
// #10197: a turn BOUND to a card contract reads done only on DELIVERY — a NEW commit since the
|
|
1035
|
+
// turn started, or the card moved to testing/done under it. The ibkr shape (exit 0, output, no
|
|
1036
|
+
// commit, card still todo) is NO-DELIVERY: the ledger names it and the contract stays owed.
|
|
1037
|
+
// Judged on hub reads of the card status; a failed read keeps today's label, never no-delivery.
|
|
1038
|
+
let cardStatusAtEnd = "", noDelivery = false;
|
|
1039
|
+
if (!cut && !opts.state && realExit === 0 && effExit === 0 && sessionCard > 0 && cardStatusBefore !== null) {
|
|
1040
|
+
let endStatus = null;
|
|
1041
|
+
try {
|
|
1042
|
+
const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
|
|
1043
|
+
endStatus = task ? String(task.status || "") : null;
|
|
1044
|
+
} catch { endStatus = null; }
|
|
1045
|
+
if (endStatus !== null) {
|
|
1046
|
+
cardStatusAtEnd = endStatus;
|
|
1047
|
+
const moved = ["testing", "done"].includes(endStatus) && !["testing", "done"].includes(cardStatusBefore);
|
|
1048
|
+
noDelivery = !newCommit && !moved;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
lastNoDelivery = noDelivery;
|
|
1052
|
+
lastNoDeliveryStatus = cardStatusAtEnd;
|
|
1053
|
+
if (noDelivery) log(`\x1b[33mexit 0 but the turn DELIVERED NOTHING on #${sessionCard} — no new commit, card still ${cardStatusAtEnd || "unknown"}\x1b[0m`);
|
|
1014
1054
|
// #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
|
|
1015
1055
|
// away — the same "classified X because Y" shape the runner logs. A silent cut names its own
|
|
1016
1056
|
// verdict (#7752): the exit under it is the sweep's SIGPIPE. A box cut passes `cut` so its 137
|
|
1017
1057
|
// reads as the sweep's SIGKILL (#7099); a zero-usage turn names its own verdict too (#9724).
|
|
1018
|
-
const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict()
|
|
1058
|
+
const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict()
|
|
1059
|
+
: noDelivery ? noDeliveryVerdict(sessionCard, cardStatusAtEnd || "unknown")
|
|
1060
|
+
: verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
|
|
1019
1061
|
// #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
|
|
1020
1062
|
// api-error, completed — and what it cost (0 means "not reported", never "free"). #7762: `card`
|
|
1021
1063
|
// binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
|
|
1022
1064
|
// empty/stalled turns to the card that produced nothing. `cut` stays too: the drills read it.
|
|
1023
|
-
const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : lastEmptyTurn ? "empty" : "completed");
|
|
1065
|
+
const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : noDelivery ? "no-delivery" : lastEmptyTurn ? "empty" : "completed");
|
|
1024
1066
|
// #7756: a clean turn that ASKED its assigner is demoted-but-owed, not "completed". The judge
|
|
1025
1067
|
// (deliverWake's /contracts read) renames the ledger row, so "asked" is what the log keeps.
|
|
1026
1068
|
let finalOutcome = outcome;
|
|
@@ -1251,7 +1293,9 @@ async function newestOwnCard(session, why) {
|
|
|
1251
1293
|
} catch { return 0; } // board unreadable: no binding beats a guessed one
|
|
1252
1294
|
}
|
|
1253
1295
|
async function resolveWakeCard(messages, { session }) {
|
|
1254
|
-
|
|
1296
|
+
// #10824: the contract message BEING SERVED names the card — the newest direct message's own
|
|
1297
|
+
// binding — never a contract still queued behind it and never the session's previous card.
|
|
1298
|
+
const cited = servedContractCard(messages, { session });
|
|
1255
1299
|
if (!(cited > 0)) return 0;
|
|
1256
1300
|
try {
|
|
1257
1301
|
const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${cited}`);
|
|
@@ -1265,7 +1309,8 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1265
1309
|
await loadLessons();
|
|
1266
1310
|
// start cursor at the CURRENT tip so we don't replay history
|
|
1267
1311
|
let cursor = 0;
|
|
1268
|
-
|
|
1312
|
+
if (process.env.CREW_SWAP_STAGE) cursor = JSON.parse(readFileSync(`${process.env.CREW_SWAP_STAGE}.release`, "utf8")).cursor;
|
|
1313
|
+
else try { const r = await api(`/inbox?session=${encodeURIComponent(SESSION)}&since=0`); cursor = r.cursor || 0; } catch {}
|
|
1269
1314
|
// kind "agent" on every beat (#6075): the peer row's kind is the hub's OWN record of what a
|
|
1270
1315
|
// session is — the overseer's declared-crew exemption reads it, and on the remote hub there is
|
|
1271
1316
|
// no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
|
|
@@ -1312,6 +1357,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1312
1357
|
}
|
|
1313
1358
|
let retryAt = 0; // 0 = deliver at the next opportunity
|
|
1314
1359
|
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
1360
|
+
let noDeliveryRetry = false;
|
|
1315
1361
|
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
1316
1362
|
|
|
1317
1363
|
// #7060: the turn the boot line was read as a promise about. It is a transcript turn by
|
|
@@ -1455,6 +1501,12 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1455
1501
|
// a bounded park instead of queueing behind the window; a mention does not, because the room
|
|
1456
1502
|
// talking is not a request. An exhausted or auth park is a wall and stands anyway (#6134).
|
|
1457
1503
|
const directWake = wake.filter(m => m.to === SESSION);
|
|
1504
|
+
// #10197: no-delivery owes the old contract, but its retry delay must not batch away
|
|
1505
|
+
// fresh contracts. Only actual CLI failures impose backoff on a new wake.
|
|
1506
|
+
if (noDeliveryRetry) {
|
|
1507
|
+
unpark("new wake after no-delivery");
|
|
1508
|
+
retryAt = 0; deliveryFails = 0; noDeliveryRetry = false;
|
|
1509
|
+
}
|
|
1458
1510
|
if (parkState && directWake.length) {
|
|
1459
1511
|
if (isBoundedPark(parkState.reason)) {
|
|
1460
1512
|
unpark(`direct message from ${directWake[0].from || "?"}`);
|
|
@@ -1491,6 +1543,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1491
1543
|
} catch { return 0; }
|
|
1492
1544
|
}
|
|
1493
1545
|
async function deliverWake() {
|
|
1546
|
+
noDeliveryRetry = false;
|
|
1494
1547
|
// #7756: captured and cleared HERE so an early return below never leaks the flag into a
|
|
1495
1548
|
// later turn — only the turn the answer released reads it.
|
|
1496
1549
|
const resumedAfterAsk = askReleased;
|
|
@@ -1560,12 +1613,13 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1560
1613
|
// #6134: ONE SESSION PER CARD; a different card starts a fresh CLI session and the seat is told.
|
|
1561
1614
|
// #7061: bound by SHAPE, not position, so an order opening with what shipped binds the right card.
|
|
1562
1615
|
// #7763: a message-card (or phantom) citation never binds — the seat's own newest card does.
|
|
1563
|
-
|
|
1616
|
+
// #10824: the binding is the SERVED contract's; #7756's answer-released turn keeps the held card.
|
|
1617
|
+
const card = resumedAfterAsk ? sessionCard : await resolveWakeCard(wakeForTurn, { session: SESSION });
|
|
1564
1618
|
// #7754: a `base:` sha this worktree cannot resolve is never worked from origin/main — the card
|
|
1565
1619
|
// goes to blocked, the assigner is told, and no model turn is spent. The check keeps the id the
|
|
1566
|
-
//
|
|
1620
|
+
// served contract cites: #7763's rebinding covers confirmed message-card ids only, never a
|
|
1567
1621
|
// card cited in the text, so the unresolvable-base branch blocks the cited card.
|
|
1568
|
-
const baseCard = card ||
|
|
1622
|
+
const baseCard = card || servedContractCard(wakeForTurn, { session: SESSION });
|
|
1569
1623
|
const base = contractBase(wakeForTurn) || localMainHead();
|
|
1570
1624
|
if (base && baseCard && !resolvesHere(base)) {
|
|
1571
1625
|
const text = `cannot resolve base ${base} in ${TURN_DIR} (git cat-file -e failed) — #${baseCard} moved to blocked; push or fetch that sha, or name one this worktree can reach`;
|
|
@@ -1579,13 +1633,13 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1579
1633
|
return;
|
|
1580
1634
|
}
|
|
1581
1635
|
const baseText = base ? `\n${baseLine(base)}\n` : "";
|
|
1582
|
-
// #6289: the
|
|
1583
|
-
//
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
1636
|
+
// #6289: the session SHAPE (fresh vs resume) reads the served contract's raw citation — a wake
|
|
1637
|
+
// whose card the board lacks still opens fresh, never a bare resume of the kickoff's CLI.
|
|
1638
|
+
// #10824: the LABEL is the binding, 0 included — a wake that binds nothing labels its turn 0
|
|
1639
|
+
// and never inherits the previous contract's card (#10197's judge then reads the right card).
|
|
1586
1640
|
const sessionCardForTurn = card || baseCard;
|
|
1587
1641
|
const fresh = !resumedAfterAsk && sessionCardForTurn > 0 && sessionCardForTurn !== sessionCard;
|
|
1588
|
-
|
|
1642
|
+
sessionCard = card;
|
|
1589
1643
|
const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
|
|
1590
1644
|
const freshText = fresh
|
|
1591
1645
|
? `\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`
|
|
@@ -1602,6 +1656,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1602
1656
|
const stopDutyNudgeWatcher = startDutyNudgeWatcher(dutyPlan, tStart);
|
|
1603
1657
|
let ec;
|
|
1604
1658
|
turnAsk = null; // #7756: the judge reads THIS turn's ending, never a previous turn's ask
|
|
1659
|
+
lastNoDelivery = false; lastNoDeliveryStatus = ""; // #10197: runTurn judges THIS turn only
|
|
1605
1660
|
const stateStep = !stateSkip("wake", card);
|
|
1606
1661
|
try {
|
|
1607
1662
|
ec = stateStep
|
|
@@ -1723,6 +1778,27 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1723
1778
|
savePending(pendingWake, pendingBcast);
|
|
1724
1779
|
await reportHealthy();
|
|
1725
1780
|
log(`turn asked its assigner — holding the contract until ask #${awaitingAsk.id} is answered`);
|
|
1781
|
+
} else if (lastNoDelivery) {
|
|
1782
|
+
// #10197: the turn exited 0 but DELIVERED NOTHING on its card — no new commit, the card
|
|
1783
|
+
// still short of testing/done. "⚠ no commit" is not a receipt, so the sender's ledger keeps
|
|
1784
|
+
// the contract owed; the queue is kept and the ladder retries — a second no-delivery
|
|
1785
|
+
// attempt parks, exactly like the empty-turn ladder it sits beside.
|
|
1786
|
+
deliveryFails++;
|
|
1787
|
+
noDeliveryRetry = true;
|
|
1788
|
+
savePending(pendingWake, pendingBcast);
|
|
1789
|
+
const still = lastNoDeliveryStatus || "unresolved";
|
|
1790
|
+
if (deliveryFails >= 2) {
|
|
1791
|
+
retryAt = await parkSeat("no-delivery", pendingWake.length);
|
|
1792
|
+
await notifyAssigners(assigners,
|
|
1793
|
+
`⛔ your contract is PARKED on ${SESSION} (no-delivery: two exit-0 turns with no commit and card #${sessionCard} unmoved) · asked: "${asked}"`);
|
|
1794
|
+
lastTurnAt = Date.now();
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
const wait = RETRY_MS[Math.min(deliveryFails - 1, RETRY_MS.length - 1)];
|
|
1798
|
+
retryAt = Date.now() + wait;
|
|
1799
|
+
log(`\x1b[33mno commit, card #${sessionCard} still ${still} — wake not consumed, ${pendingWake.length} message(s) stay owed; retrying in ${Math.round(wait / 1000)}s\x1b[0m`);
|
|
1800
|
+
await notifyAssigners(assigners,
|
|
1801
|
+
`⚠ no commit, card #${sessionCard} still ${still} on ${SESSION} (exit 0, ${secs}s) · asked: "${asked}"`);
|
|
1726
1802
|
} else if (lastEmptyTurn) {
|
|
1727
1803
|
// #7759: the turn exited 0 but was HOLLOW — a banner is not work. The wake is NOT
|
|
1728
1804
|
// consumed: the queue is kept and the ladder retries, and the assigner hears EMPTY,
|
package/bin/crew.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import { createCmuxAdapter, spawnCmux } from "./crew/cmux.mjs";
|
|
|
5
5
|
import { createHerdrAdapter, spawnHerdr } from "./crew/herdr.mjs";
|
|
6
6
|
import { resolveSpec, reportSkipped } from "./crew/models.mjs";
|
|
7
7
|
import { openOrchestrator } from "./crew/open.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { performSwap, swapOptions, swapRuntime } from "./crew/swap.mjs";
|
|
9
|
+
import { redactKeys } from "../lib/redact.mjs";
|
|
10
|
+
import { reapSeat, down, prune } from "./crew/state.mjs";
|
|
9
11
|
import { spawnTerminal, spawnTmux } from "./crew/tmux.mjs";
|
|
10
12
|
import { epochMs, failedAgents, verifyCrew } from "./crew/verify.mjs";
|
|
11
13
|
import { guardCrossProjectUp } from "./crew/worktrees.mjs";
|
|
@@ -13,10 +15,21 @@ import { preflightFirstSeat } from "./crew/preflight.mjs";
|
|
|
13
15
|
import { resolveAgentLaunchSpecs } from "../lib/agent-preferences.mjs";
|
|
14
16
|
import { readConfig } from "../lib/project.mjs";
|
|
15
17
|
|
|
16
|
-
const [command = "up", ...
|
|
18
|
+
const [command = "up", ...inputArgs] = process.argv.slice(2);
|
|
19
|
+
const jsonMode = inputArgs.includes("--json");
|
|
20
|
+
const rawArgs = inputArgs.filter(arg => arg !== "--json");
|
|
21
|
+
const output = console.log.bind(console);
|
|
22
|
+
const diagnostics = [];
|
|
23
|
+
if (jsonMode) console.log = (...parts) => {
|
|
24
|
+
const line = redactKeys(parts.join(" "));
|
|
25
|
+
diagnostics.push(line);
|
|
26
|
+
console.error(line);
|
|
27
|
+
};
|
|
28
|
+
const jsonResult = (code, extra = {}) => ({ ok: code === 0, action: command, seat: resultSeat, moved: [], ...extra });
|
|
29
|
+
let resultSeat = rawArgs.filter(a => !a.startsWith("--")).join(",");
|
|
17
30
|
let ctx;
|
|
18
31
|
try { ctx = createContext(command); }
|
|
19
|
-
catch (error) { console.error(error.message); process.exit(1); }
|
|
32
|
+
catch (error) { if (jsonMode) output(JSON.stringify(jsonResult(1, { reason: redactKeys(error.message) }))); else console.error(error.message); process.exit(1); }
|
|
20
33
|
|
|
21
34
|
const adapters = {
|
|
22
35
|
cmux: createCmuxAdapter(ctx),
|
|
@@ -39,7 +52,11 @@ function parseUpArgs(args) {
|
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
function spawnCrew(options, skipped) {
|
|
42
|
-
const resolve = spec =>
|
|
55
|
+
const resolve = spec => {
|
|
56
|
+
const seat = resolveSpec(ctx, spec, options.task, options.difficulty, skipped);
|
|
57
|
+
if (seat) reapSeat(ctx, seat.agent);
|
|
58
|
+
return seat;
|
|
59
|
+
};
|
|
43
60
|
const pruneNow = () => prune(ctx, adapters);
|
|
44
61
|
if (ctx.mux === "herdr") spawnHerdr(ctx, options.specs, resolve, pruneNow);
|
|
45
62
|
else if (ctx.mux === "cmux") spawnCmux(ctx, options.specs, resolve, pruneNow);
|
|
@@ -56,6 +73,7 @@ async function runUp(args, verify = true) {
|
|
|
56
73
|
const options = parseUpArgs(args);
|
|
57
74
|
const requested = resolveAgentLaunchSpecs(options.specs, readConfig());
|
|
58
75
|
options.specs = requested.specs;
|
|
76
|
+
resultSeat = options.specs.map(spec => spec.split(":")[0]).join(",");
|
|
59
77
|
if (!options.specs.length) {
|
|
60
78
|
console.log("usage: crew.mjs up [--task K --difficulty D] codex glm kimi deepseek (or set a default in Settings → Agents)");
|
|
61
79
|
return 1;
|
|
@@ -95,25 +113,42 @@ async function runUp(args, verify = true) {
|
|
|
95
113
|
return reportSkipped(skipped);
|
|
96
114
|
}
|
|
97
115
|
|
|
116
|
+
function spawnReplacement(context, seat) {
|
|
117
|
+
const resolve = () => seat;
|
|
118
|
+
const specs = [seat.agent];
|
|
119
|
+
const noPrune = () => {};
|
|
120
|
+
if (context.mux === "herdr") spawnHerdr(context, specs, resolve, noPrune);
|
|
121
|
+
else if (context.mux === "cmux") spawnCmux(context, specs, resolve, noPrune);
|
|
122
|
+
else if (context.mux === "tmux") spawnTmux(context, specs, resolve);
|
|
123
|
+
else spawnTerminal(context, specs, resolve);
|
|
124
|
+
}
|
|
125
|
+
|
|
98
126
|
async function swap(args) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
await down(ctx, [oldAgent], adapters);
|
|
106
|
-
console.log(`— spawning replacement: ${replacement} —`);
|
|
107
|
-
const code = await runUp([...flags, replacement]);
|
|
108
|
-
if (!code) console.log(`— swapped. RESEND the contract to '${replacement.split(":")[0]}' (it joined fresh with no context). —`);
|
|
109
|
-
return code;
|
|
127
|
+
const options = swapOptions(args);
|
|
128
|
+
resultSeat = options.from;
|
|
129
|
+
if (resolveAgentLaunchSpecs([options.replacement], readConfig()).disabled.length) throw new Error(`replacement ${options.agent} is disabled in agent settings`);
|
|
130
|
+
if (!guardCrossProjectUp(ctx)) throw new Error("cross-project swap refused");
|
|
131
|
+
if (ctx.dry) throw new Error("swap requires a live bus; dry-run cannot verify a handoff");
|
|
132
|
+
return performSwap(ctx, options, swapRuntime(ctx, adapters, spawnReplacement));
|
|
110
133
|
}
|
|
111
134
|
|
|
112
135
|
let code = 0;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
else if (command === "
|
|
117
|
-
else if (command === "
|
|
118
|
-
else {
|
|
136
|
+
let result;
|
|
137
|
+
try {
|
|
138
|
+
if (command === "down") code = await down(ctx, rawArgs, adapters);
|
|
139
|
+
else if (command === "prune") { prune(ctx, adapters); console.log(`— pruned dead crew rows (${ctx.statePath}) —`); }
|
|
140
|
+
else if (command === "open") code = openOrchestrator(ctx, rawArgs);
|
|
141
|
+
else if (command === "swap") { result = await swap(rawArgs); code = result.ok ? 0 : 1; }
|
|
142
|
+
else if (command === "up") code = guardCrossProjectUp(ctx) ? await runUp(rawArgs) : 1;
|
|
143
|
+
else { usage(); code = 1; }
|
|
144
|
+
} catch (error) {
|
|
145
|
+
code = 1;
|
|
146
|
+
result = jsonResult(code, { reason: redactKeys(error.message) });
|
|
147
|
+
}
|
|
148
|
+
if (!result) result = jsonResult(code);
|
|
149
|
+
if (!result.ok && !result.reason) result.reason = diagnostics.at(-1) || `${command} failed; see stderr`;
|
|
150
|
+
if (result.reason) result.reason = redactKeys(result.reason);
|
|
151
|
+
if (jsonMode) output(JSON.stringify(result));
|
|
152
|
+
else if (result.reason) console.error(result.reason);
|
|
153
|
+
else if (command === "swap") console.log(`— swapped ${result.seat} → ${result.to}; moved contracts: ${result.moved.join(", ") || "none"} —`);
|
|
119
154
|
process.exit(code);
|
package/bin/summarize.mjs
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// trantor summarize
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Title-cleaning makes machine-generated cards readable; it cannot make them MEAN anything. This
|
|
7
|
-
// gives each card a one-line narrative ("assigned — did") written by a CHEAP model from the card's
|
|
8
|
-
// own thread (its events + the messages that cite it). The board then reads as a story a human can
|
|
9
|
-
// follow, which was the ask: "what the agent was assigned, what the agent did, what the flow was."
|
|
10
|
-
//
|
|
11
|
-
// Economics by design (the Scrooge doctrine): candidates are MACHINE-TITLED cards without a
|
|
12
|
-
// summary (--all widens to every unsummarized card), batched into ONE cheap-model call per hub,
|
|
13
|
-
// difficulty easy, capped per run. The summary lands via /task/update and rides the tasks.extra
|
|
14
|
-
// column — permanent, never recomputed. Runs ambiently from the heartbeat (hourly, detached) and
|
|
15
|
-
// on demand as `trantor summarize`.
|
|
2
|
+
// trantor summarize [--project <p>] [--limit N] [--all] [--dry] [--quiet] — gives each machine-titled
|
|
3
|
+
// card a one-line "assigned — did" narrative written by ONE cheap-model call per hub from the card's
|
|
4
|
+
// own thread; lands via /task/update on tasks.extra (permanent, never recomputed). Runs ambiently
|
|
5
|
+
// from the heartbeat (hourly, detached) and on demand as `trantor summarize`.
|
|
16
6
|
import { execSync, spawnSync } from "node:child_process";
|
|
17
7
|
import { readFileSync, existsSync } from "node:fs";
|
|
18
8
|
import { join } from "node:path";
|
|
@@ -40,7 +30,7 @@ const scroogeBin = () => process.env.SCROOGE_BIN
|
|
|
40
30
|
// A title no human wrote: protocol frames, dumped prompts, id soup. These are the cards whose
|
|
41
31
|
// board presence is noise until a narrative replaces them.
|
|
42
32
|
const machineTitled = (t) =>
|
|
43
|
-
/^\s*[<{[]/.test(t) || /<task-notification>|<system-reminder>|toolu_[A-Za-z0-9]/.test(t) ||
|
|
33
|
+
/^\s*[<{[]/.test(t) || /<task-notification>|<system-reminder>|<trantor-succession>|<trantor-takeover>|toolu_[A-Za-z0-9]/.test(t) ||
|
|
44
34
|
/^\s*(subagent|general-purpose|Explore|Task|Plan):/i.test(t) || t.length > 130;
|
|
45
35
|
|
|
46
36
|
const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -119,10 +119,12 @@ async function maybeEarlyWarn(stdinRaw, session) {
|
|
|
119
119
|
// It is marked where the baton fires (here on the backstop, in Stop on the normal path).
|
|
120
120
|
armBaton(sessionId, { projectDir, transcript, reason: "context-warn", windowId, tty, tokens: usage.tokens });
|
|
121
121
|
// Tell the RUNNING agent (once, at arm time): wrap up and author the boundary handoff yourself.
|
|
122
|
-
|
|
122
|
+
// NOT <system-reminder>: CC 2.1.292 escapes that tag in hook output, so the notice would
|
|
123
|
+
// arrive as literal text (#11110). Our own <trantor-…> tags pass through like the other hooks'.
|
|
124
|
+
ARM_CTX = `<trantor-succession>TRANTOR SUCCESSION ARMED — this session is at ${Math.round(usage.frac * 100)}% of its context window, and the baton is armed: your NEXT STOP fires the handoff. You are now responsible for your own succession:\n`
|
|
123
125
|
+ `1. Reach a real task boundary — finish, pause, or checkpoint your in-flight work NOW; do not start new work.\n`
|
|
124
126
|
+ `2. Author (or refresh) the rich handoff from your CURRENT state — via the handoff skill or the relay_handoff MCP tool — so the successor gets the freshest picture, not a digest of a session 36 seconds stale. A fresh model-authored handoff supersedes the auto-digest.\n`
|
|
125
|
-
+ `3. Then end your turn. The Stop hook fires the baton at that boundary.</
|
|
127
|
+
+ `3. Then end your turn. The Stop hook fires the baton at that boundary.</trantor-succession>`;
|
|
126
128
|
} catch {}
|
|
127
129
|
}
|
|
128
130
|
|
package/hooks/prompt-focus.mjs
CHANGED
|
@@ -18,6 +18,25 @@ function readStdin() {
|
|
|
18
18
|
}
|
|
19
19
|
// A prompt that is JUST an acknowledgement/continuation (anchored to end) — not a new focus.
|
|
20
20
|
const ACK = /^(y|yes|yep|yeah|ok|okay|sure|go|go ahead|continue|proceed|do it|please|thanks|thank you|ty|next|k|cool|nice|great|perfect|sounds good|👍)[\s.!]*$/i;
|
|
21
|
+
// A paste arrives wrapped in `<pasted_content id="…">…</pasted_content>`: the wrapper is transport,
|
|
22
|
+
// not content — the operator typed what is inside (#10027's chat.rs rule, same channel). Peel it so
|
|
23
|
+
// the marker check and the focus title see the words, never the tag. Unwrapped text passes through.
|
|
24
|
+
function unwrapPasted(t) {
|
|
25
|
+
const s = String(t || "").trim();
|
|
26
|
+
if (!s.startsWith("<pasted_content")) return s;
|
|
27
|
+
const gt = s.indexOf(">");
|
|
28
|
+
if (gt < 0) return s;
|
|
29
|
+
const close = s.lastIndexOf("</pasted_content>");
|
|
30
|
+
return (close > gt ? s.slice(gt + 1, close) : s.slice(gt + 1)).trim();
|
|
31
|
+
}
|
|
32
|
+
// HARNESS-INJECTED prompts are not a human's focus. A CLOSED list of known prefixes, not "starts
|
|
33
|
+
// with <": a paste merely happens to start with a tag, and a human quoting JSON is still a human.
|
|
34
|
+
// (The old ^\s*[<{[] heuristic silently dropped every pasted operator prompt, #11110.)
|
|
35
|
+
const HARNESS_MARKERS = [
|
|
36
|
+
"<task-notification", "<system-reminder", "<teammate-message",
|
|
37
|
+
"<command-name", "<environment_context", "[SYSTEM NOTIFICATION",
|
|
38
|
+
];
|
|
39
|
+
const isHarnessInjection = (t) => HARNESS_MARKERS.some(m => t.startsWith(m));
|
|
21
40
|
function titleFrom(prompt) {
|
|
22
41
|
let s = String(prompt || "").replace(/\s+/g, " ").trim();
|
|
23
42
|
// strip a leading politeness/imperative wrapper so the card reads as the WORK, not "can you please…"
|
|
@@ -42,7 +61,7 @@ function loadRecapCtx(sessionId) {
|
|
|
42
61
|
const mandate = rec.mode === "unattended"
|
|
43
62
|
? " Then RESUME the handoff's OPEN THREADS immediately — they are your work order; this succession is unattended, do NOT wait for the user."
|
|
44
63
|
: " Then WAIT for the user.";
|
|
45
|
-
return `<
|
|
64
|
+
return `<trantor-takeover>You took over via handoff ${rec.handoffId}. If you have not yet recapped it, your reply MUST begin with the ≤3-sentence recap (task, state, next step) before anything else — including before answering this message.${mandate}</trantor-takeover>`;
|
|
46
65
|
} catch { return ""; }
|
|
47
66
|
}
|
|
48
67
|
function emitAndExit() {
|
|
@@ -61,17 +80,16 @@ try {
|
|
|
61
80
|
// don't card home-dir sessions (matches sessionstart's phantom-project guard)
|
|
62
81
|
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && cwd === homedir()) { emitAndExit(); }
|
|
63
82
|
const trimmed = prompt.replace(/\s+/g, " ").trim();
|
|
83
|
+
const text = unwrapPasted(trimmed); // a paste's wrapper is transport; judge the words inside
|
|
64
84
|
// skip empties, tiny continuations, and pure acks — they're not a new focus
|
|
65
|
-
if (!
|
|
85
|
+
if (!text || text.length < 12 || ACK.test(text)) { emitAndExit(); }
|
|
66
86
|
// The Accounts ask drill launches a real Claude session, so its scripted prompt traverses this
|
|
67
87
|
// hook just like operator work. It is harness traffic, though, and must never become a focus card.
|
|
68
|
-
if (/\bTRANTOR ASK DRILL\b/.test(
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
if (
|
|
73
|
-
emitAndExit();
|
|
74
|
-
}
|
|
88
|
+
if (/\bTRANTOR ASK DRILL\b/.test(text)) { emitAndExit(); }
|
|
89
|
+
// Task notifications, hook reminders and protocol frames arrive through the same UserPromptSubmit
|
|
90
|
+
// channel, and carding one titled a board card "<task-notification> <task-id>bavlqfmzq</task-id>…"
|
|
91
|
+
// — pure noise a human cannot read.
|
|
92
|
+
if (isHarnessInjection(text)) { emitAndExit(); }
|
|
75
93
|
const project = resolveProject(cwd);
|
|
76
94
|
const session = process.env.RELAY_SESSION
|
|
77
95
|
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
@@ -79,18 +97,18 @@ try {
|
|
|
79
97
|
// two Claude sessions in one project share (and fight over) a single focus card, and sub-agent
|
|
80
98
|
// cards, whose `parent` is exactly this UUID, have nothing to nest under.
|
|
81
99
|
const cc = String(input.session_id || "").slice(0, 120);
|
|
82
|
-
const r = await signedPost("/focus", { session, project, title: titleFrom(
|
|
100
|
+
const r = await signedPost("/focus", { session, project, title: titleFrom(text), by: session, cc }, { session });
|
|
83
101
|
|
|
84
102
|
// Only pay a model when the heuristic actually mangles the prompt. A short, already-clear ask
|
|
85
103
|
// ("fix the login redirect") reads fine as-is and buying a rewrite for it is exactly the kind of
|
|
86
104
|
// reflexive spend the economics doctrine exists to stop.
|
|
87
105
|
const id = r?.json?.id;
|
|
88
|
-
if (id &&
|
|
106
|
+
if (id && text.length > 90 && process.env.TRANTOR_NO_SCROOGE_TITLES !== "1") {
|
|
89
107
|
try {
|
|
90
108
|
const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
91
109
|
mkdirSync(busDir, { recursive: true });
|
|
92
110
|
const pf = join(busDir, `focus-prompt-${String(cc || session).replace(/[^A-Za-z0-9_.-]/g, "_")}.txt`);
|
|
93
|
-
writeFileSync(pf,
|
|
111
|
+
writeFileSync(pf, text);
|
|
94
112
|
const worker = join(dirname(dirname(fileURLToPath(import.meta.url))), "bin", "focus-title.mjs");
|
|
95
113
|
// Detached + unref'd + stdio ignored: the hook returns NOW. Nothing downstream waits on this,
|
|
96
114
|
// and a worker that dies takes the heuristic title with it, which is a fine outcome.
|
package/hub/routes/cards.mjs
CHANGED
|
@@ -6,6 +6,15 @@ const SLIM_DROP = new Set(["log", "history", "checklist"]);
|
|
|
6
6
|
// #7968: the shape hollow-move.mjs posts — { base, changed[], unindexed[], dependents } or
|
|
7
7
|
// { unavailable: true }; anything else is dropped so a stray payload cannot bloat the log.
|
|
8
8
|
const BLAST_PATHS_MAX = 40;
|
|
9
|
+
// #11142: the fleet-wide cap evicts done/stale cards oldest-first, never an open one.
|
|
10
|
+
const TASK_CAP = 2000, TASK_TRIM_TO = 1750;
|
|
11
|
+
const EVICTABLE = new Set(["done", "stale"]);
|
|
12
|
+
function trimTasks(state) {
|
|
13
|
+
if (state.tasks.length <= TASK_CAP) return;
|
|
14
|
+
let drop = state.tasks.length - TASK_TRIM_TO;
|
|
15
|
+
state.tasks = state.tasks.filter(t => drop <= 0 || !EVICTABLE.has(t.status) || (drop--, false));
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
function cleanBlast(v) {
|
|
10
19
|
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
|
|
11
20
|
if (v.unavailable) return { unavailable: true };
|
|
@@ -153,7 +162,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
153
162
|
by: b.by || "", ts: ts0, updated: ts0, history: [{ to: target, by: b.by || "", ts: ts0 }] };
|
|
154
163
|
if (bgId) bt._aid = bgId; if (b.agentType) bt._atype = String(b.agentType).slice(0, 40);
|
|
155
164
|
appendTaskNote(bt, b, ts0);
|
|
156
|
-
state.tasks.push(bt);
|
|
165
|
+
state.tasks.push(bt); trimTasks(state);
|
|
157
166
|
appendCardEvent("created", bt, b.by, null, target);
|
|
158
167
|
markDirty(); return json(res, 200, { ok: true, task: bt, created: true });
|
|
159
168
|
}
|
|
@@ -180,7 +189,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
180
189
|
{ const dr = cleanDrill(b.drill); if (dr) t.drill = dr; } // #6452 — the card's drill line, rides `extra`
|
|
181
190
|
if (b.source === "cc-subagent") { t._fp = subFp(b.title); if (b.agentType) t._atype = String(b.agentType).slice(0, 40); if (b.agentId) t._aid = String(b.agentId).slice(0, 80); if (b.parent) t.parent = String(b.parent).slice(0, 120); t.count = 1; if (t.status === "doing") { t._everStarted = true; t._inflight = 1; } }
|
|
182
191
|
appendTaskNote(t, b, ts0);
|
|
183
|
-
state.tasks.push(t);
|
|
192
|
+
state.tasks.push(t); trimTasks(state);
|
|
184
193
|
appendCardEvent("created", t, b.by, null, st0);
|
|
185
194
|
// A COMMIT closes the focus. A focus card says "this session is working on X right now"; the
|
|
186
195
|
// commit is X arriving, so the card that was rolling forever now completes with the commit
|
|
@@ -330,7 +339,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
330
339
|
if (seen.has(t.todoKey) || t.status === "done") continue; // keep accomplished work on the board
|
|
331
340
|
state.tasks = state.tasks.filter(x => x.id !== t.id); appendCardEvent("deleted", t, session, null, null); markDirty();
|
|
332
341
|
}
|
|
333
|
-
|
|
342
|
+
trimTasks(state);
|
|
334
343
|
return json(res, 200, { ok: true, count: todos.length });
|
|
335
344
|
}
|
|
336
345
|
// A REGULAR session's live "focus" card — what THIS session is working on right now, set from each
|
|
@@ -375,7 +384,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
375
384
|
history: [{ to: "doing", by: session, ts: now() }] };
|
|
376
385
|
state.tasks.push(t); appendCardEvent("created", t, session, null, "doing");
|
|
377
386
|
appendEvent("focus", project, session, { taskId: t.id, title, shift: false });
|
|
378
|
-
|
|
387
|
+
trimTasks(state);
|
|
379
388
|
}
|
|
380
389
|
markDirty(); return json(res, 200, { ok: true, id: t.id, task: t });
|
|
381
390
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
function heldMessages(ctx, source, project, pendingIds) {
|
|
2
|
+
const ids = new Set(pendingIds);
|
|
3
|
+
const senders = new Set(ctx.state.messages.filter(m => m.to === source && m.project === project).map(m => m.from));
|
|
4
|
+
for (const sender of senders) {
|
|
5
|
+
for (const contract of ctx.contractsFor(sender, { project, windowMs: Number.MAX_SAFE_INTEGER })) {
|
|
6
|
+
if (contract.to !== source) continue;
|
|
7
|
+
if (!contract.answered && !["ack", "superseded"].includes(contract.disposition)) ids.add(contract.id);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return ctx.state.messages.filter(m => ids.has(m.id) && m.to === source && m.project === project);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function routeContractTransfer({ req, res, q, P, auth, ctx }) {
|
|
14
|
+
if (P !== "/contracts/transfer") return false;
|
|
15
|
+
if (!["GET", "POST"].includes(req.method)) return ctx.json(res, 405, { error: "GET or POST required" });
|
|
16
|
+
const b = req.method === "POST" ? await ctx.body(req) : q;
|
|
17
|
+
const { from, to, project } = b;
|
|
18
|
+
if (!project || !from || !to || !from.endsWith(`:${project}`) || !to.endsWith(`:${project}`)) {
|
|
19
|
+
return ctx.json(res, 400, { error: "source and replacement must belong to the named project" });
|
|
20
|
+
}
|
|
21
|
+
const signer = auth?.identity?.name;
|
|
22
|
+
if (signer && !signer.endsWith(`:${project}`) && auth.identity.kind !== "human") {
|
|
23
|
+
return ctx.json(res, 403, { error: "swap must be requested from its project" });
|
|
24
|
+
}
|
|
25
|
+
// #11221: a seat may never move another seat's work; only the orchestrator or a human swaps.
|
|
26
|
+
if (signer && auth.identity.kind !== "human" && ctx.state.peers[signer]?.kind !== "orch") {
|
|
27
|
+
return ctx.json(res, 403, { error: "only the project's orchestrator or a human can swap seats" });
|
|
28
|
+
}
|
|
29
|
+
const messages = heldMessages(ctx, from, project, b.pendingIds || []);
|
|
30
|
+
if (req.method === "GET") return ctx.json(res, 200, { ok: true, messages });
|
|
31
|
+
if (!ctx.state.peers[b.readySession]?.lastSeen || ctx.state.peers[b.readySession].project !== project) {
|
|
32
|
+
return ctx.json(res, 409, { error: "replacement has not registered on this project's bus" });
|
|
33
|
+
}
|
|
34
|
+
// IDs and original senders survive the move, so replies settle the assigner's original ledger.
|
|
35
|
+
for (const message of messages) message.to = to;
|
|
36
|
+
const ids = new Set(messages.map(m => m.id));
|
|
37
|
+
for (const message of ctx.state.messages) {
|
|
38
|
+
if (message.project !== project) continue;
|
|
39
|
+
if (message.kind === "ask" && message.from === from && (ids.has(message.re) || message.id === b.heldAsk)) {
|
|
40
|
+
message.from = to;
|
|
41
|
+
for (const answer of ctx.state.messages) {
|
|
42
|
+
if (answer.re !== message.id || answer.to !== from) continue;
|
|
43
|
+
answer.to = to;
|
|
44
|
+
if (!ids.has(answer.id)) { messages.push(answer); ids.add(answer.id); }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const card of ctx.state.tasks) {
|
|
49
|
+
if (card.project !== project || card.assignee !== from || !["todo", "doing", "testing", "blocked", "failed"].includes(card.status)) continue;
|
|
50
|
+
card.assignee = to;
|
|
51
|
+
card.updated = ctx.now();
|
|
52
|
+
ctx.appendTaskLog(card, signer || from, `swap reassigned ${from} → ${to}`);
|
|
53
|
+
ctx.appendCardEvent("updated", card, signer || from);
|
|
54
|
+
}
|
|
55
|
+
ctx.appendEvent("crew.swap", project, signer || from, { from, to, moved: [...ids] });
|
|
56
|
+
ctx.markDirty();
|
|
57
|
+
return ctx.json(res, 200, { ok: true, moved: [...ids], messages, cursor: ctx.state.seq });
|
|
58
|
+
}
|
package/hub/routes/messages.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/* oxlint-disable anti-slop/no-conditional-empty-object-spread -- SAFETY: Optional message fields must remain absent, not undefined; preserving the existing spread keeps the signed wire shape identical. */
|
|
2
|
+
import { routeContractTransfer } from "./contract-transfer.mjs";
|
|
2
3
|
import { setTimeout, setInterval, clearInterval } from "node:timers";
|
|
3
4
|
|
|
4
5
|
export async function routeMessages({ req, res, q, P, auth, ctx }) {
|
|
6
|
+
if (await routeContractTransfer({ req, res, q, P, auth, ctx })) return true;
|
|
5
7
|
const {
|
|
6
8
|
state, body, json, stripNulText, crossProjectGuard, touch, pushToStreams,
|
|
7
9
|
appendEvent, appendCardEvent, appendTaskLog, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
|
package/lib/classify-failure.mjs
CHANGED
|
@@ -200,6 +200,13 @@ export function zeroUsageVerdict() {
|
|
|
200
200
|
return "classified zero-usage because the session usage record resolved to all zeros — no model work happened";
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/** #10197: the verdict line for a card-bound turn that DELIVERED NOTHING — exit 0 with output but
|
|
204
|
+
* no new commit since turn start and the card unmoved (the ibkr specimen: 48s, prose, card still
|
|
205
|
+
* todo, announced done). Named here so the drill pins the string. */
|
|
206
|
+
export function noDeliveryVerdict(card, status) {
|
|
207
|
+
return `classified no-delivery because exit 0 with no new commit and card #${card} still ${status}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
203
210
|
/** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
|
|
204
211
|
* runner logs, so the next misclassification is diagnosable from the seat log alone. `cut` is the
|
|
205
212
|
* runner's box/stall marker: 137 reads as the sweep's SIGKILL only under it — without the marker
|
package/lib/turn-policy.mjs
CHANGED
|
@@ -53,6 +53,29 @@ export function assignedCardRef(text) {
|
|
|
53
53
|
return m ? Number(m.slice(1).find((g) => g != null)) : 0;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/// #10824: the CONTRACT MESSAGE BEING SERVED decides the card — the newest direct message's own
|
|
57
|
+
/// binding: a hub-stamped `card` field, else the id its own text ASSIGNS (#7061 shape), else its
|
|
58
|
+
/// first citation — never a contract queued behind it or the previous turn's card. Deference: a
|
|
59
|
+
/// newest message that re-cites an older assignment is commentary on it, and #7061's reading stands.
|
|
60
|
+
export function servedContractCard(messages, { session = "" } = {}) {
|
|
61
|
+
const all = (Array.isArray(messages) ? messages : []).filter(Boolean);
|
|
62
|
+
if (!all.length) return 0;
|
|
63
|
+
const direct = session ? all.filter((m) => m.to === session) : [];
|
|
64
|
+
if (!direct.length) return wakeCard(all, { session });
|
|
65
|
+
const sorted = newestLast(direct);
|
|
66
|
+
const served = sorted[sorted.length - 1];
|
|
67
|
+
const stamped = Number(served?.card);
|
|
68
|
+
if (Number.isFinite(stamped) && stamped > 0) return stamped;
|
|
69
|
+
const own = assignedCardRef(served?.text);
|
|
70
|
+
if (own) return own;
|
|
71
|
+
const named = cardRefs(served?.text);
|
|
72
|
+
if (named.length) {
|
|
73
|
+
const olderAssigned = new Set(sorted.slice(0, -1).map((m) => assignedCardRef(m?.text)).filter(Boolean));
|
|
74
|
+
if (!named.some((c) => olderAssigned.has(c))) return named[0];
|
|
75
|
+
}
|
|
76
|
+
return wakeCard(all, { session });
|
|
77
|
+
}
|
|
78
|
+
|
|
56
79
|
/// #7061: the card a wake batch binds to. Direct messages outrank @mentions, the NEWEST assignment wins,
|
|
57
80
|
/// and only an assignment-free batch falls back to the newest message's first citation.
|
|
58
81
|
export function wakeCard(messages, { session = "" } = {}) {
|