trantor 0.18.49 → 0.18.50
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/baton-pane.mjs +62 -9
- package/bin/baton.mjs +8 -2
- package/bin/connect.mjs +44 -4
- package/bin/crew/open.mjs +54 -5
- package/bin/crew-runner.mjs +29 -11
- package/bin/drill-report.mjs +80 -0
- package/bin/drill-report.test.mjs +146 -0
- package/bin/drill-seams.mjs +157 -0
- package/bin/drill-surface.mjs +157 -54
- package/deploy/setup.sh +38 -0
- package/hooks/lib/handoff.mjs +43 -1
- package/lib/duty-nudges.mjs +48 -10
- package/lib/state/apply.mjs +170 -0
- package/lib/state/migrate.mjs +126 -0
- package/lib/state/schema.mjs +216 -0
- package/lib/state/validate.mjs +231 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.50",
|
|
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/baton-pane.mjs
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Env seams (the drill's off switches, same doctrine as TRANTOR_NO_HANDOFF_SPAWN):
|
|
12
12
|
// TRANTOR_BATON_IDLE_DEADLINE_S give up waiting for idle after this many seconds (default 600)
|
|
13
|
+
// TRANTOR_BATON_AGENT_DROP_MS give herdr this long to retire the ended agent (default 10000)
|
|
13
14
|
// TRANTOR_BATON_REOPEN command to reopen the pane, instead of `trantor open`
|
|
14
15
|
// (the drill points this at its own herdr world)
|
|
15
16
|
// Detached means nobody reads stdout: everything lands in <bus>/logs/baton-pane-<project>.log.
|
|
16
17
|
import { readFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
|
|
17
18
|
import { join, basename } from "node:path";
|
|
18
19
|
import { homedir } from "node:os";
|
|
19
|
-
import { execFileSync,
|
|
20
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
20
21
|
import { createConnection } from "node:net";
|
|
21
22
|
|
|
22
23
|
const arg = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : ""; };
|
|
@@ -67,8 +68,50 @@ function socketRequest(req, timeoutMs = 30_000) {
|
|
|
67
68
|
|
|
68
69
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
69
70
|
const agentStatus = (pane) => herdrJson(["agent", "get", pane])?.result?.agent?.agent_status || null;
|
|
71
|
+
const hasAgent = (pane) => Boolean(herdrJson(["agent", "get", pane])?.result?.agent);
|
|
70
72
|
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
71
73
|
|
|
74
|
+
// The shells a pane idles in once its agent is gone. Their pid is never the process to end.
|
|
75
|
+
const SHELL_NAMES = new Set(["zsh", "bash", "sh", "fish", "dash", "ksh", "tcsh", "csh", "nu", "login"]);
|
|
76
|
+
function isShellProcess(p) {
|
|
77
|
+
const raw = String(p?.name || p?.argv0 || "").trim();
|
|
78
|
+
const name = basename(raw.replace(/^-/, "")); // login shells spell themselves "-zsh"
|
|
79
|
+
return SHELL_NAMES.has(name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The pid the graceful end targets, from herdr's `pane process-info` result.process_info —
|
|
83
|
+
* parity with lib.rs foreground_pid_from_process_info. #6668: the 09-07 12:35 chain would have
|
|
84
|
+
* TERMed 80368, the pane's bare zsh, because foreground_process_group_id was taken as-is. The
|
|
85
|
+
* shell (process_info.shell_pid, or any entry named like one) is never a candidate: with only
|
|
86
|
+
* the shell in the foreground there is nothing to end, and the answer is null. */
|
|
87
|
+
export function foregroundPid(info) {
|
|
88
|
+
if (!info) return null;
|
|
89
|
+
const procs = Array.isArray(info.foreground_processes) ? info.foreground_processes : [];
|
|
90
|
+
const shellPid = Number(info.shell_pid) || 0;
|
|
91
|
+
const byPid = (pid) => procs.find(p => Number(p?.pid) === pid);
|
|
92
|
+
const usable = (pid) => pid > 0 && pid !== shellPid && !isShellProcess(byPid(pid));
|
|
93
|
+
const group = Number(info.foreground_process_group_id) || 0;
|
|
94
|
+
if (usable(group)) return group;
|
|
95
|
+
const claude = procs.find(p => /claude/i.test([p?.name, p?.argv0, p?.cmdline].map(s => String(s || "")).join(" ")) && !isShellProcess(p));
|
|
96
|
+
if (claude && Number(claude.pid) > 0 && Number(claude.pid) !== shellPid) return Number(claude.pid);
|
|
97
|
+
for (let i = procs.length - 1; i >= 0; i--) {
|
|
98
|
+
const pid = Number(procs[i]?.pid) || 0;
|
|
99
|
+
if (usable(pid)) return pid;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function agentDropStep(present, elapsedMs, deadlineMs) {
|
|
105
|
+
if (!present) return "dropped";
|
|
106
|
+
return elapsedMs >= deadlineMs ? "deadline" : "wait";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function summary(value, limit = 240) {
|
|
110
|
+
const oneLine = String(value || "").trim().replace(/\s+/g, " ");
|
|
111
|
+
if (!oneLine) return "<empty>";
|
|
112
|
+
return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine;
|
|
113
|
+
}
|
|
114
|
+
|
|
72
115
|
async function main() {
|
|
73
116
|
if (!handoffFile || !existsSync(handoffFile)) { log(`no handoff file (${handoffFile}) — abort`); process.exit(1); }
|
|
74
117
|
const pane = arg("--pane") || orchPane((() => { try { return readFileSync(join(busDir, "crew-windows.txt"), "utf8"); } catch { return ""; } })(), projectName);
|
|
@@ -88,7 +131,7 @@ async function main() {
|
|
|
88
131
|
|
|
89
132
|
// 2. Graceful end, mirroring end_process_gracefully: TERM, short wait, KILL.
|
|
90
133
|
const info = herdrJson(["pane", "process-info", "--pane", pane])?.result?.process_info;
|
|
91
|
-
const pid =
|
|
134
|
+
const pid = foregroundPid(info) || 0;
|
|
92
135
|
if (pid > 0 && alive(pid)) {
|
|
93
136
|
try { process.kill(pid, "SIGTERM"); } catch {}
|
|
94
137
|
const killAt = Date.now() + 10_000;
|
|
@@ -96,18 +139,26 @@ async function main() {
|
|
|
96
139
|
if (alive(pid)) { try { process.kill(pid, "SIGKILL"); } catch {} }
|
|
97
140
|
log(`ended pid ${pid}`);
|
|
98
141
|
} else {
|
|
99
|
-
log("no foreground process to end (already gone)");
|
|
142
|
+
log("no foreground process to end (already gone, or only the pane's shell is in the foreground)");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const dropStarted = Date.now();
|
|
146
|
+
const dropDeadline = Number(process.env.TRANTOR_BATON_AGENT_DROP_MS) || 10_000;
|
|
147
|
+
let dropOutcome;
|
|
148
|
+
while ((dropOutcome = agentDropStep(hasAgent(pane), Date.now() - dropStarted, dropDeadline)) === "wait") {
|
|
149
|
+
await sleep(200);
|
|
100
150
|
}
|
|
151
|
+
log(`agent drop ${dropOutcome} after ${Date.now() - dropStarted}ms`);
|
|
101
152
|
|
|
102
153
|
// 3. Reopen. `trantor open` rebinds orch-sessions.txt and restarts the pane's session — the
|
|
103
154
|
// bookkeeping the classic seam regression comes from skipping. The drill overrides this to
|
|
104
155
|
// stay inside its own herdr world.
|
|
105
156
|
const reopen = process.env.TRANTOR_BATON_REOPEN || "trantor open";
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
log(`reopen FAILED (${
|
|
157
|
+
log(`reopen starting via: ${reopen}`);
|
|
158
|
+
const reopened = spawnSync(reopen, { cwd: projectDir, encoding: "utf8", timeout: 120_000, stdio: "pipe", shell: true });
|
|
159
|
+
log(`reopen result: status=${reopened.status ?? "none"} stdout=${summary(reopened.stdout)} stderr=${summary(reopened.stderr)}`);
|
|
160
|
+
if (reopened.error || reopened.status !== 0) {
|
|
161
|
+
log(`reopen FAILED (${summary(reopened.error?.message || reopened.signal || `exit ${reopened.status}`)}) — handoff waits on disk`);
|
|
111
162
|
process.exit(1);
|
|
112
163
|
}
|
|
113
164
|
|
|
@@ -118,7 +169,9 @@ async function main() {
|
|
|
118
169
|
}
|
|
119
170
|
try {
|
|
120
171
|
const raw = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: { target: pane, text: KICKOFF_PROMPT } });
|
|
121
|
-
|
|
172
|
+
const response = JSON.parse(raw);
|
|
173
|
+
const result = response.result?.type || response.error?.code || "unexpected_response";
|
|
174
|
+
log(`kickoff result: ${result}${response.error?.message ? ` (${summary(response.error.message)})` : ""}`);
|
|
122
175
|
} catch (e) {
|
|
123
176
|
log(`kickoff FAILED (${String(e?.message).slice(0, 120)}) — successor may sit idle until spoken to`);
|
|
124
177
|
}
|
package/bin/baton.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { join, basename, dirname } from "node:path";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { writeHandoff, spawnBaton, resolveHandoffSurface, armBaton, contextUsage, controllingTty, turnInFlight, armMaxMs } from "../hooks/lib/handoff.mjs";
|
|
11
|
+
import { writeHandoff, spawnBaton, resolveHandoffSurface, armBaton, contextUsage, controllingTty, turnInFlight, armMaxMs, sessionProcessState } from "../hooks/lib/handoff.mjs";
|
|
12
12
|
|
|
13
13
|
// #6074: the skill path (write-handoff.mjs) and this CLI path must share ONE resolution of which
|
|
14
14
|
// project this is and where the session lives. Both call resolveHandoffSurface; the name comes
|
|
@@ -82,7 +82,13 @@ function autoBaton() {
|
|
|
82
82
|
// a turn still in flight ARMS instead of writing: no record, no spawn, and the session's own
|
|
83
83
|
// Stop hook fires the baton at the boundary, where the summary describes finished work.
|
|
84
84
|
const force = process.argv.includes("--force");
|
|
85
|
-
|
|
85
|
+
// #6668: a transcript whose session has NO live process is at its boundary, whatever its last
|
|
86
|
+
// row says. The 09-07 12:35 chain armed on a session that had exited at 12:16 (its tail was a
|
|
87
|
+
// tool_result "Connection closed"), then sat in the 17-minute boundary wait for a Stop hook no
|
|
88
|
+
// process would ever run. Write now; the record describes a session that is over.
|
|
89
|
+
const processState = sessionProcessState(sessionId);
|
|
90
|
+
if (processState === "dead") console.log(`session ${sessionId} has no live process — at its boundary, writing now`);
|
|
91
|
+
if (!force && processState !== "dead" && turnInFlight(transcript)) {
|
|
86
92
|
armBaton(sessionId, {
|
|
87
93
|
projectDir: cwd,
|
|
88
94
|
transcript, reason: trigger, windowId: "", tty: controllingTty(),
|
package/bin/connect.mjs
CHANGED
|
@@ -16,6 +16,14 @@ import { fileURLToPath } from "node:url";
|
|
|
16
16
|
const DRY = process.argv.includes("--dry-run");
|
|
17
17
|
const MCP = join(dirname(dirname(fileURLToPath(import.meta.url))), "mcp.mjs");
|
|
18
18
|
const URL_ = process.env.RELAY_URL || "http://127.0.0.1:4477";
|
|
19
|
+
// Graft (github.com/NanoNets/context-graph-engine): a local Tree-sitter dependency graph served
|
|
20
|
+
// over MCP (graft_find_code / _find_all / _trace_calls / _file_api / _repo_map). Wired next to
|
|
21
|
+
// `relay` so a seat can locate code with one call instead of grep+read-many — the graph refreshes
|
|
22
|
+
// itself before each query (no freshness hook) and serves the nearest ancestor with a graft/ index,
|
|
23
|
+
// so it keys off the seat's cwd project. A project with no graft/ index simply returns empty tools,
|
|
24
|
+
// never an error. `graft build` (or `graft init`) seeds a project's index; graft/ is gitignored.
|
|
25
|
+
const GRAFT = (() => { try { return execSync("command -v graft", { encoding: "utf8", shell: "/bin/sh" }).trim(); } catch { return "graft"; } })();
|
|
26
|
+
const HAS_GRAFT = GRAFT !== "graft" || (() => { try { execSync("command -v graft", { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } })();
|
|
19
27
|
const has = (cmd) => { try { execSync(`command -v ${cmd}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } };
|
|
20
28
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
21
29
|
const backup = (p) => { const b = `${p}.bak-${stamp}`; if (!existsSync(b)) copyFileSync(p, b); return b; };
|
|
@@ -65,6 +73,16 @@ if (has("codex")) {
|
|
|
65
73
|
if (!DRY) { if (existsSync(p)) backup(p); else mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, cur + block); }
|
|
66
74
|
report("codex", cur ? "wired" : "wired (new config)", p);
|
|
67
75
|
}
|
|
76
|
+
// graft alongside relay
|
|
77
|
+
if (HAS_GRAFT) {
|
|
78
|
+
const g = existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
79
|
+
if (g.includes("[mcp_servers.graft]")) report("codex", "graft already wired");
|
|
80
|
+
else {
|
|
81
|
+
const gblock = `\n# trantor — Graft code-graph tools (graft_find_code/_find_all/_trace_calls/_file_api/_repo_map)\n[mcp_servers.graft]\ncommand = "${GRAFT}"\nargs = ["mcp"]\n`;
|
|
82
|
+
if (!DRY) { if (existsSync(p)) backup(p); writeFileSync(p, g + gblock); }
|
|
83
|
+
report("codex", "graft wired", p);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
68
86
|
}
|
|
69
87
|
|
|
70
88
|
// ---- Gemini CLI ---- (existing relay entries are never overwritten — user customization wins)
|
|
@@ -73,6 +91,7 @@ if (has("gemini")) {
|
|
|
73
91
|
report("gemini", patchJson(p, d => {
|
|
74
92
|
d.mcpServers ||= {};
|
|
75
93
|
d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("gemini") };
|
|
94
|
+
if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
|
|
76
95
|
}), p);
|
|
77
96
|
}
|
|
78
97
|
|
|
@@ -82,6 +101,7 @@ if (has("kimi")) {
|
|
|
82
101
|
report("kimi", patchJson(p, d => {
|
|
83
102
|
d.mcpServers ||= {};
|
|
84
103
|
d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("kimi") };
|
|
104
|
+
if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
|
|
85
105
|
}), p);
|
|
86
106
|
}
|
|
87
107
|
|
|
@@ -92,6 +112,7 @@ if (has("opencode")) {
|
|
|
92
112
|
d.$schema ||= "https://opencode.ai/config.json";
|
|
93
113
|
d.mcp ||= {};
|
|
94
114
|
d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true };
|
|
115
|
+
if (HAS_GRAFT) d.mcp.graft ||= { type: "local", command: [GRAFT, "mcp"], enabled: true };
|
|
95
116
|
d.mcp.relay.environment ||= {};
|
|
96
117
|
// Migrate the old generated pin too: `||=` alone left RELAY_AGENT=opencode in every existing
|
|
97
118
|
// config forever, where OpenCode overlaid it on the qwen/glm/deepseek runner environment.
|
|
@@ -135,7 +156,7 @@ if (has("dsh")) {
|
|
|
135
156
|
},
|
|
136
157
|
dsh: { profile: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-headless"] } },
|
|
137
158
|
};
|
|
138
|
-
const patch = `# trantor — generated by \`trantor connect\` (
|
|
159
|
+
const patch = `# trantor — generated by \`trantor connect\` (connect rewrites this file only when it is missing a row; delete it to force a full regen)
|
|
139
160
|
- insert:
|
|
140
161
|
- id: trantor-cc-hooks
|
|
141
162
|
name: '@deepseek-ai/dsh-hooks-claude-code'
|
|
@@ -154,10 +175,29 @@ if (has("dsh")) {
|
|
|
154
175
|
RELAY_AGENT: !!js process.env.RELAY_AGENT ?? 'dsh'
|
|
155
176
|
RELAY_PROJECT: !!js process.env.RELAY_PROJECT ?? ''
|
|
156
177
|
RELAY_SESSION: !!js process.env.RELAY_SESSION ?? ''
|
|
157
|
-
|
|
178
|
+
${HAS_GRAFT ? ` - id: trantor-graft
|
|
179
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
180
|
+
config:
|
|
181
|
+
serverName: graft
|
|
182
|
+
transport: stdio
|
|
183
|
+
command: ${GRAFT}
|
|
184
|
+
args: ['mcp']
|
|
185
|
+
` : ""}`;
|
|
186
|
+
// "a profile exists" is not "a profile is current": connect grows rows over time (relay, then
|
|
187
|
+
// graft, then whatever comes next), and an existence check short-circuits on a profile written
|
|
188
|
+
// by an older connect forever — the seat silently never gets the new row. So the gate is
|
|
189
|
+
// CONTENT-based: every row id this connect would write must already be in the patch; a missing
|
|
190
|
+
// one regenerates the patch (backed up). Presence, not diff — user edits to rows that ARE there
|
|
191
|
+
// still win, the same rule as the gemini/kimi/opencode `||=` patches above.
|
|
192
|
+
const expectedIds = [...patch.matchAll(/- id: (\S+)/g)].map(m => m[1]);
|
|
193
|
+
const cur = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "";
|
|
194
|
+
const missing = expectedIds.filter(id => !cur.split("\n").some(l => l.trim() === `- id: ${id}`));
|
|
158
195
|
const fresh = !existsSync(patchPath);
|
|
159
|
-
if (!fresh) report("dsh", "already wired", prof);
|
|
160
|
-
else {
|
|
196
|
+
if (!fresh && !missing.length) report("dsh", "already wired", prof);
|
|
197
|
+
else if (!fresh) {
|
|
198
|
+
if (!DRY) { backup(patchPath); writeFileSync(patchPath, patch); }
|
|
199
|
+
report("dsh", `regenerated — was missing: ${missing.join(", ")}`, prof);
|
|
200
|
+
} else {
|
|
161
201
|
if (!DRY) {
|
|
162
202
|
mkdirSync(prof, { recursive: true });
|
|
163
203
|
// The seat runs the plugin's hooks MINUS SessionStart: the crew runner already owns
|
package/bin/crew/open.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { existsSync, openSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { spawn, execSync } from "node:child_process";
|
|
6
|
+
import { call, parseJsonOutput } from "./core.mjs";
|
|
7
|
+
import { createWorkspace, herdrCall, reportAgent, splitPane, workspaceList, workspacePane } from "./herdr.mjs";
|
|
6
8
|
import { dropState, readRows, recordState } from "./state.mjs";
|
|
7
9
|
import { resolveOrchestratorDir } from "./worktrees.mjs";
|
|
8
10
|
|
|
@@ -72,6 +74,19 @@ function orchestratorCommand(ctx, id) {
|
|
|
72
74
|
return `${env} claude${harnessFlag(ctx)} ${action}`;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
export function paneHasAgent(ctx, pane) {
|
|
78
|
+
const result = herdrCall(ctx, ["pane", "process-info", "--pane", pane]);
|
|
79
|
+
const info = parseJsonOutput(result.stdout)?.result?.process_info;
|
|
80
|
+
if (!result.ok || !info) return false;
|
|
81
|
+
const processes = Array.isArray(info.foreground_processes) ? info.foreground_processes : [];
|
|
82
|
+
return processes.some(process => {
|
|
83
|
+
const command = [process.name, process.argv0, process.cmdline, ...(Array.isArray(process.argv) ? process.argv : [])]
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.join(" ");
|
|
86
|
+
return /(^|[/\s])claude(?:\.exe)?(?:$|\s)/i.test(command);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
function tracked(ctx, live) {
|
|
76
91
|
let workspace = "";
|
|
77
92
|
let pane = "";
|
|
@@ -91,7 +106,8 @@ function reattach(ctx, workspace, pane, id) {
|
|
|
91
106
|
const renamed = herdrCall(ctx, ["pane", "rename", pane, `orchestrator · ${ctx.project}`]);
|
|
92
107
|
if (!renamed.ok) { dropState(ctx, ctx.project, "orch"); return false; }
|
|
93
108
|
if (!paneHasAgent(ctx, pane)) {
|
|
94
|
-
herdrCall(ctx, ["pane", "run", pane, orchestratorCommand(ctx, id)]);
|
|
109
|
+
const started = herdrCall(ctx, ["pane", "run", pane, orchestratorCommand(ctx, id)]);
|
|
110
|
+
if (!started.ok) throw new Error(`trantor open: could not start the orchestrator in pane ${pane}`);
|
|
95
111
|
reportAgent(ctx, pane, "claude");
|
|
96
112
|
console.error(`— orchestrator pane was empty: resumed session ${id} in herdr:${workspace || "?"}/${pane} —`);
|
|
97
113
|
} else console.error(`— orchestrator already hosted: reattached to herdr:${workspace || "?"}/${pane} —`);
|
|
@@ -136,6 +152,38 @@ function hostPane(ctx, chosen, id) {
|
|
|
136
152
|
return pane;
|
|
137
153
|
}
|
|
138
154
|
|
|
155
|
+
// #6888: index the project once so its seats' Graft tools (graft_find_code, _trace_calls, …) have a
|
|
156
|
+
// graph to serve. Non-blocking — the pane opens now, the ~7s build lands in the background; a no-op
|
|
157
|
+
// if graft isn't installed or the index already exists (the graph self-refreshes per query after the
|
|
158
|
+
// first build). Keeps the 20MB index out of the project's git.
|
|
159
|
+
export function maybeBuildGraft(dir) {
|
|
160
|
+
if (!dir) return;
|
|
161
|
+
try { execSync("command -v graft", { stdio: "ignore", shell: "/bin/sh" }); } catch { return; }
|
|
162
|
+
try {
|
|
163
|
+
if (existsSync(join(dir, "graft", ".graph"))) return;
|
|
164
|
+
const gi = join(dir, ".gitignore");
|
|
165
|
+
try {
|
|
166
|
+
const cur = existsSync(gi) ? readFileSync(gi, "utf8") : "";
|
|
167
|
+
if (!/^graft\/?$/m.test(cur)) writeFileSync(gi, cur + (cur && !cur.endsWith("\n") ? "\n" : "") + "graft/\n");
|
|
168
|
+
} catch {}
|
|
169
|
+
// The build is backgrounded, so we cannot gate the pane on its exit code — but a failure has to
|
|
170
|
+
// land SOMEWHERE. It used to go to stdio:"ignore", which meant a graft whose native parsers were
|
|
171
|
+
// never built (npm v12 skips node-gyp by default; see deploy/setup.sh) failed in total silence
|
|
172
|
+
// while the line below still claimed an index was being built.
|
|
173
|
+
//
|
|
174
|
+
// The log goes to the OS temp dir rather than the project: nothing to gitignore, nothing to clean
|
|
175
|
+
// up, and no exit handler — after unref() the parent usually dies first, so an on-exit cleanup
|
|
176
|
+
// would be a promise we cannot keep. It is simply overwritten by the next open of this project.
|
|
177
|
+
const log = join(tmpdir(), `trantor-graft-build-${basename(dir)}.log`);
|
|
178
|
+
let fd = "ignore";
|
|
179
|
+
try { fd = openSync(log, "w"); } catch {}
|
|
180
|
+
const child = spawn("graft", ["build", dir], { cwd: dir, stdio: ["ignore", fd, fd], detached: true });
|
|
181
|
+
child.on("error", () => {}); // ENOENT/EACCES: the log and the absent index are the evidence
|
|
182
|
+
child.unref();
|
|
183
|
+
console.error(`— indexing this project for the seats' Graft tools in the background (failures land in ${log}) —`);
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
|
|
139
187
|
export function openOrchestrator(ctx, args) {
|
|
140
188
|
let parsed;
|
|
141
189
|
try { parsed = parseArgs(args); }
|
|
@@ -143,6 +191,7 @@ export function openOrchestrator(ctx, args) {
|
|
|
143
191
|
if (parsed.help) { usage(); return 0; }
|
|
144
192
|
try {
|
|
145
193
|
Object.assign(ctx, resolveOrchestratorDir(ctx, parsed.project));
|
|
194
|
+
if (!ctx.dry) maybeBuildGraft(ctx.dir);
|
|
146
195
|
if (!ctx.have.herdr) throw new Error("trantor open needs herdr (the pane host) — install: curl -fsSL https://herdr.dev/install.sh | sh");
|
|
147
196
|
let id = sessionId(ctx);
|
|
148
197
|
if (hasPendingHandoff(ctx)) {
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
senderProjectOf, isLinkedProject,
|
|
29
29
|
} from "../lib/turn-policy.mjs";
|
|
30
30
|
import {
|
|
31
|
-
auditDutyNudges,
|
|
31
|
+
auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
|
|
32
|
+
observedDutyNudgeIds,
|
|
32
33
|
} from "../lib/duty-nudges.mjs";
|
|
33
34
|
|
|
34
35
|
const AGENT = process.argv[2];
|
|
@@ -1067,10 +1068,30 @@ function askedExcerpt(message) {
|
|
|
1067
1068
|
// queued, on disk, with a backoff — which is the whole point of the change.
|
|
1068
1069
|
async function deliverWake() {
|
|
1069
1070
|
const wake = pendingWake;
|
|
1070
|
-
const
|
|
1071
|
+
const dutyPlan = DUTY_NUDGES
|
|
1072
|
+
? await claimDutyNudges({
|
|
1073
|
+
messages: wake,
|
|
1074
|
+
statePath: DUTY_NUDGE_STATE,
|
|
1075
|
+
owner: `${RUNNER_ID}:${TURN + 1}`,
|
|
1076
|
+
})
|
|
1077
|
+
: { items: [], targets: [], owner: "" };
|
|
1078
|
+
const claimedIds = new Set(dutyPlan.items.map(item => item.id));
|
|
1079
|
+
const wakeForTurn = DUTY_NUDGES
|
|
1080
|
+
? wake.filter(message => {
|
|
1081
|
+
const escalation = dutyEscalations([message])[0];
|
|
1082
|
+
return !escalation || claimedIds.has(escalation.id);
|
|
1083
|
+
})
|
|
1084
|
+
: wake;
|
|
1085
|
+
if (!wakeForTurn.length) {
|
|
1086
|
+
pendingWake = [];
|
|
1087
|
+
savePending([], pendingBcast);
|
|
1088
|
+
log("duty escalation already nudged or reserved by another turn — consumed without a duplicate model wake");
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
const wakeCapped = capWake(wakeForTurn);
|
|
1071
1092
|
const bcastCapped = capBcast(pendingBcast);
|
|
1072
1093
|
const wakeText = wakeCapped.text
|
|
1073
|
-
? `NEW BUS MESSAGE${
|
|
1094
|
+
? `NEW BUS MESSAGE${wakeForTurn.length > 1 ? "S" : ""} for you:\n${wakeCapped.text}\n`
|
|
1074
1095
|
: "";
|
|
1075
1096
|
const ctxText = bcastCapped.text
|
|
1076
1097
|
? `\nFYI broadcasts since your last turn (context only):\n${bcastCapped.text}\n`
|
|
@@ -1078,29 +1099,26 @@ function askedExcerpt(message) {
|
|
|
1078
1099
|
// Say plainly that this is a second look. Without it the model re-reads an old escalation as
|
|
1079
1100
|
// brand new and can redo work it already half-did before the turn died.
|
|
1080
1101
|
const againText = deliveryFails
|
|
1081
|
-
? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${
|
|
1102
|
+
? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${wakeForTurn.length > 1 ? "these" : "this"}. Check what you already did before repeating it.)\n`
|
|
1082
1103
|
: "";
|
|
1083
1104
|
await loadLessons();
|
|
1084
1105
|
const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
|
|
1085
|
-
const trigger =
|
|
1106
|
+
const trigger = wakeForTurn.some(m => m.to === SESSION) ? "direct message" : "@mention";
|
|
1086
1107
|
// Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
|
|
1087
1108
|
const assigners = [];
|
|
1088
|
-
for (const m of
|
|
1089
|
-
const asked = askedExcerpt(
|
|
1109
|
+
for (const m of wakeForTurn) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
|
|
1110
|
+
const asked = askedExcerpt(wakeForTurn[0]);
|
|
1090
1111
|
const tStart = Date.now();
|
|
1091
1112
|
// #6134: ONE SESSION PER CARD. A seat that resumes forever carries every card it ever worked
|
|
1092
1113
|
// into every later turn — qwen's 85.7M tokens were 96.7% cached, i.e. replayed history. The
|
|
1093
1114
|
// card that moved this wake decides: a different one starts a fresh CLI session, and the seat
|
|
1094
1115
|
// is told so, because a fresh session remembers nothing and must be sent to its card.
|
|
1095
|
-
const card =
|
|
1116
|
+
const card = wakeForTurn.map(m => cardRef(m.text)).find(Boolean) || 0;
|
|
1096
1117
|
const fresh = card > 0 && card !== sessionCard;
|
|
1097
1118
|
if (card) sessionCard = card;
|
|
1098
1119
|
const freshText = fresh
|
|
1099
1120
|
? `\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`
|
|
1100
1121
|
: "";
|
|
1101
|
-
const dutyPlan = DUTY_NUDGES
|
|
1102
|
-
? planDutyNudges(wake, DUTY_NUDGE_STATE)
|
|
1103
|
-
: { items: [], targets: [] };
|
|
1104
1122
|
const prompt = composedTurn({
|
|
1105
1123
|
wakeText, ctxText, againText: againText + freshText + dutyNudgeDirective(dutyPlan),
|
|
1106
1124
|
tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { signedPost } from "../hooks/lib/api.mjs";
|
|
4
|
+
|
|
5
|
+
// A PASS assertion is evidence, not a verdict: every mapped step must finish cleanly.
|
|
6
|
+
export class DrillReport {
|
|
7
|
+
constructor(map, path, { project, session }) {
|
|
8
|
+
this.map = map;
|
|
9
|
+
this.path = path;
|
|
10
|
+
this.project = project;
|
|
11
|
+
this.session = session;
|
|
12
|
+
this.steps = {};
|
|
13
|
+
this.closures = {};
|
|
14
|
+
for (const { steps, autoClose, recipe } of Object.values(map)) {
|
|
15
|
+
if (autoClose || !recipe) continue;
|
|
16
|
+
for (const step of steps) this.steps[step] = {
|
|
17
|
+
complete: true, checks: [{ status: "skip", assertion: recipe, evidence: "" }],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
this.write();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
record(step, status, assertion, evidence = "") {
|
|
24
|
+
(this.steps[step] ||= { complete: false, checks: [] }).checks.push({ status, assertion, evidence });
|
|
25
|
+
this.write();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
complete(step) {
|
|
29
|
+
(this.steps[step] ||= { complete: false, checks: [] }).complete = true;
|
|
30
|
+
this.write();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
cardsFor(step) {
|
|
34
|
+
return Object.keys(this.map).filter(id => this.map[id].steps.includes(step));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
results() {
|
|
38
|
+
return Object.fromEntries(Object.entries(this.map).map(([id, { steps }]) => {
|
|
39
|
+
const evidence = steps.flatMap(step => {
|
|
40
|
+
const row = this.steps[step];
|
|
41
|
+
return row?.checks.length
|
|
42
|
+
? row.checks.map(check => `${step} ${check.status}: ${check.assertion}${check.evidence ? ` — ${check.evidence}` : ""}`)
|
|
43
|
+
: [`${step} fail: not run`];
|
|
44
|
+
});
|
|
45
|
+
const complete = steps.every(step => {
|
|
46
|
+
const row = this.steps[step];
|
|
47
|
+
return row?.complete && row.checks.length > 0 && row.checks.every(check => check.status !== "fail");
|
|
48
|
+
});
|
|
49
|
+
const skipped = steps.some(step => this.steps[step]?.checks.some(check => check.status === "skip"));
|
|
50
|
+
return [id, { status: complete ? skipped ? "skip" : "pass" : "fail", evidence, closure: this.closures[id] || "not attempted" }];
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
write() {
|
|
55
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
56
|
+
const temp = `${this.path}.${process.pid}.tmp`;
|
|
57
|
+
writeFileSync(temp, JSON.stringify(this.results(), null, 2) + "\n", { mode: 0o600 });
|
|
58
|
+
renameSync(temp, this.path);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
exitCode() {
|
|
62
|
+
// Manual recipes stay visible as SKIP; only missing/failed proof or a refused close is red.
|
|
63
|
+
return Object.values(this.results()).some(result => result.status === "fail" || result.closure.startsWith("failed:")) ? 1 : 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async closePassed() {
|
|
67
|
+
for (const [id, result] of Object.entries(this.results())) {
|
|
68
|
+
if (!this.map[id].autoClose || result.status !== "pass" || this.closures[id]) continue;
|
|
69
|
+
const response = await signedPost("/task/update", {
|
|
70
|
+
id: Number(id), project: this.project, by: this.session, status: "done",
|
|
71
|
+
note: `trantor drill PASS\n${result.evidence.join("\n")}`.slice(0, 2000),
|
|
72
|
+
}, { project: this.project, session: this.session, timeoutMs: 15000 });
|
|
73
|
+
this.closures[id] = response.ok && response.json?.task?.status === "done"
|
|
74
|
+
&& response.json.task.id === Number(id) && response.json.task.project === this.project
|
|
75
|
+
? "done" : `failed: hub ${response.status} ${response.json?.error || "did not confirm done"}`;
|
|
76
|
+
this.write();
|
|
77
|
+
}
|
|
78
|
+
return Object.values(this.closures).every(value => value === "done");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createServer, createConnection } from "node:net";
|
|
6
|
+
import { DrillReport } from "./drill-report.mjs";
|
|
7
|
+
import { CARD_STEPS, findHandoff } from "./drill-surface.mjs";
|
|
8
|
+
import { appVerdict, startDrillHub, stopChild, checkSocketHome } from "./drill-seams.mjs";
|
|
9
|
+
import { signedGet, signedPost } from "../hooks/lib/api.mjs";
|
|
10
|
+
|
|
11
|
+
test("S4 waits for the hook ledger when relay_handoff arrives first", () => {
|
|
12
|
+
const world = mkdtempSync(join(import.meta.dirname, "..", ".agent-bus-out", "ledger-"));
|
|
13
|
+
try {
|
|
14
|
+
assert.equal(findHandoff(join(world, "missing"), "trantor"), null);
|
|
15
|
+
writeFileSync(join(world, "trantor-100.json"), JSON.stringify({ summary: "tool handoff" }));
|
|
16
|
+
writeFileSync(join(world, "trantor-101.json"), "{");
|
|
17
|
+
writeFileSync(join(world, "trantor-102.json"), JSON.stringify({ states: [] }));
|
|
18
|
+
writeFileSync(join(world, "other-100.json"), JSON.stringify({ states: [{ state: "written" }] }));
|
|
19
|
+
assert.equal(findHandoff(world, "trantor"), null);
|
|
20
|
+
const ledger = join(world, "trantor-103.json");
|
|
21
|
+
writeFileSync(ledger, JSON.stringify({ states: [{ state: "written" }] }));
|
|
22
|
+
assert.equal(findHandoff(world, "trantor"), ledger);
|
|
23
|
+
writeFileSync(ledger, JSON.stringify({ states: [{ state: "written" }, { state: "claimed" }, { state: "recapped" }] }));
|
|
24
|
+
assert.equal(findHandoff(world, "trantor"), ledger);
|
|
25
|
+
} finally { rmSync(world, { recursive: true, force: true }); }
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("signed closer on an enforce hub: complete evidence closes, partial/failure/skip never does", async () => {
|
|
29
|
+
const out = join(import.meta.dirname, "..", ".agent-bus-out");
|
|
30
|
+
mkdirSync(out, { recursive: true });
|
|
31
|
+
const world = mkdtempSync(join(out, "closer-test-"));
|
|
32
|
+
const bus = join(world, "bus");
|
|
33
|
+
const hub = await startDrillHub(world, bus);
|
|
34
|
+
const saved = { ...process.env };
|
|
35
|
+
Object.assign(process.env, { RELAY_URL: hub.url, AGENT_BUS_DIR: bus, RELAY_SESSION: "drill:trantor", RELAY_PROJECT: "trantor" });
|
|
36
|
+
const project = "trantor", session = "drill:trantor";
|
|
37
|
+
const path = join(world, "drill-result.json");
|
|
38
|
+
try {
|
|
39
|
+
const create = await signedPost("/task", { project, by: session, title: "closer test", status: "testing" });
|
|
40
|
+
assert.equal(create.ok, true, JSON.stringify(create));
|
|
41
|
+
const id = create.json.task.id;
|
|
42
|
+
const report = new DrillReport({ [id]: { steps: ["one", "two"], autoClose: true } }, path, { project, session });
|
|
43
|
+
report.record("one", "pass", "first assertion", "real evidence A");
|
|
44
|
+
await report.closePassed();
|
|
45
|
+
assert.equal(report.results()[id].status, "fail");
|
|
46
|
+
assert.equal((await signedGet("/tasks?project=trantor")).json.tasks[0].status, "testing");
|
|
47
|
+
report.complete("one");
|
|
48
|
+
report.record("two", "pass", "second assertion", "real evidence B");
|
|
49
|
+
report.complete("two");
|
|
50
|
+
assert.equal(await report.closePassed(), true);
|
|
51
|
+
const card = (await signedGet("/tasks?project=trantor")).json.tasks[0];
|
|
52
|
+
assert.equal(card.status, "done");
|
|
53
|
+
assert.equal(card.workedBy, session);
|
|
54
|
+
assert.match(card.log.at(-1).text, /real evidence A/);
|
|
55
|
+
assert.match(card.log.at(-1).text, /real evidence B/);
|
|
56
|
+
assert.equal(JSON.parse(readFileSync(path, "utf8"))[id].closure, "done");
|
|
57
|
+
const notes = card.log.length;
|
|
58
|
+
await report.closePassed();
|
|
59
|
+
assert.equal((await signedGet("/tasks?project=trantor")).json.tasks[0].log.length, notes);
|
|
60
|
+
const unsigned = await fetch(`${hub.url}/task/update`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id, status: "testing", by: session }) });
|
|
61
|
+
assert.equal(unsigned.status, 401);
|
|
62
|
+
|
|
63
|
+
for (const status of ["fail", "skip"]) {
|
|
64
|
+
const created = await signedPost("/task", { project, by: session, title: status, status: "testing" });
|
|
65
|
+
const blockedId = created.json.task.id;
|
|
66
|
+
const blocked = new DrillReport({ [blockedId]: { steps: ["step"], autoClose: true } }, path, { project, session });
|
|
67
|
+
blocked.record("step", "pass", "partial success");
|
|
68
|
+
blocked.record("step", status, "missing proof");
|
|
69
|
+
blocked.complete("step");
|
|
70
|
+
await blocked.closePassed();
|
|
71
|
+
assert.equal(blocked.results()[blockedId].status, status);
|
|
72
|
+
assert.equal((await signedGet("/tasks?project=trantor")).json.tasks.find(row => row.id === blockedId).status, "testing");
|
|
73
|
+
}
|
|
74
|
+
const missing = new DrillReport({ 999999: { steps: ["step"], autoClose: true } }, path, { project, session });
|
|
75
|
+
missing.record("step", "pass", "proof");
|
|
76
|
+
missing.complete("step");
|
|
77
|
+
assert.equal(await missing.closePassed(), false);
|
|
78
|
+
assert.match(missing.results()[999999].closure, /failed: hub 404/);
|
|
79
|
+
assert.equal(missing.exitCode(), 1);
|
|
80
|
+
const fresh = new DrillReport({ [id]: { steps: ["one"], autoClose: true } }, path, { project, session });
|
|
81
|
+
assert.equal(fresh.results()[id].status, "fail", "a rerun cannot inherit old PASS evidence");
|
|
82
|
+
} finally {
|
|
83
|
+
for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key];
|
|
84
|
+
Object.assign(process.env, saved);
|
|
85
|
+
await stopChild(hub.child);
|
|
86
|
+
rmSync(world, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("app proof requires every leg and rejects skipped key targets", () => {
|
|
91
|
+
assert.deepEqual(CARD_STEPS[6317].steps, ["S6-key-post", "S6-key-throw"]);
|
|
92
|
+
assert.equal(appVerdict("ask", "ask-drill PASS:"), false);
|
|
93
|
+
assert.equal(appVerdict("ask", "ask-drill open PASS\nask-drill cold PASS\nask-drill PASS:"), true);
|
|
94
|
+
const key = [1, 2, 3].map(pass => `key-drill pass=${pass} posted keyDown+keyUp`).join("\n") + "\nkey-drill verdict exit=0";
|
|
95
|
+
assert.equal(appVerdict("key-post", key), true);
|
|
96
|
+
assert.equal(appVerdict("key-post", key + "\npass=2 skipped"), false);
|
|
97
|
+
assert.equal(appVerdict("key-throw", key), false);
|
|
98
|
+
assert.equal(appVerdict("key-throw", key, "TaoObjcExceptionDrill"), true);
|
|
99
|
+
assert.equal(appVerdict("handoff", "handoff-drill PASS:\nhandoff-drill verdict exit=3"), false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("manual probes remain SKIP even when the runner stops before reaching them", () => {
|
|
103
|
+
const world = mkdtempSync(join(import.meta.dirname, "..", ".agent-bus-out", "skip-"));
|
|
104
|
+
try {
|
|
105
|
+
const report = new DrillReport(CARD_STEPS, join(world, "result.json"), { project: "trantor", session: "drill:trantor" });
|
|
106
|
+
assert.deepEqual(Object.keys(CARD_STEPS).filter(id => CARD_STEPS[id].autoClose), ["6481", "6667", "6668"]);
|
|
107
|
+
for (const id of [6317, 6533, 6587, 6483]) {
|
|
108
|
+
assert.equal(report.results()[id].status, "skip");
|
|
109
|
+
assert.equal(report.results()[id].closure, "not attempted");
|
|
110
|
+
assert.match(report.results()[id].evidence.join(" "), id === 6587 ? /live duty probe/ : /covered by in-app Drill Mode/);
|
|
111
|
+
}
|
|
112
|
+
assert.equal(report.exitCode(), 1, "unrun seams still fail");
|
|
113
|
+
for (const { steps, recipe } of Object.values(CARD_STEPS)) {
|
|
114
|
+
if (recipe) continue;
|
|
115
|
+
for (const step of steps) {
|
|
116
|
+
report.record(step, step === "S5" ? "skip" : "pass", step === "S5" ? "interactive Terminal takeover probe" : "seam proof");
|
|
117
|
+
report.complete(step);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
assert.equal(report.exitCode(), 0, "passing seams plus manual SKIPs exit zero");
|
|
121
|
+
report.record("S4", "fail", "ledger missing");
|
|
122
|
+
assert.equal(report.exitCode(), 1, "a real seam failure remains fatal");
|
|
123
|
+
} finally { rmSync(world, { recursive: true, force: true }); }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("short drill HOME connects through the native socket symlink; old staging is rejected", async () => {
|
|
127
|
+
const world = mkdtempSync(`${join(import.meta.dirname, "..", ".agent-bus-out")}/`);
|
|
128
|
+
const server = createServer(socket => socket.end("connected"));
|
|
129
|
+
try {
|
|
130
|
+
const path = checkSocketHome(world);
|
|
131
|
+
assert.throws(() => checkSocketHome(join(world, "x".repeat(108))), /too long/);
|
|
132
|
+
mkdirSync(join(world, ".config", "herdr"), { recursive: true });
|
|
133
|
+
const target = join(world, "s");
|
|
134
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(target, resolve); });
|
|
135
|
+
symlinkSync(target, path);
|
|
136
|
+
const proof = await new Promise((resolve, reject) => {
|
|
137
|
+
const socket = createConnection(path);
|
|
138
|
+
socket.once("error", reject);
|
|
139
|
+
socket.once("data", data => resolve(data.toString()));
|
|
140
|
+
});
|
|
141
|
+
assert.equal(proof, "connected");
|
|
142
|
+
} finally {
|
|
143
|
+
await new Promise(resolve => server.close(resolve));
|
|
144
|
+
rmSync(world, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|