Skip to content
22 changes: 22 additions & 0 deletions agent-node/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3546,6 +3546,27 @@ let grokCopresenceRuntimeSession: GrokCopresenceSession | null = null;
let grokCopresenceRuntimeOpening: Promise<GrokCopresenceSession> | null = null;
let grokCopresenceLocalTaskSequence = 0;

async function retireCachedGrokCopresenceRuntime(): Promise<void> {
const stopped = grokCopresenceRuntimeSession;
// 🔴 动态 import,和本文件里另外两处 grok-copresence 的取法一致。
// 静态 import 会被 ESM 提升 —— 被导入模块的顶层会在 cli.ts 第一条语句之前执行,
// 于是 policy.ts 顶层那句 readPinnedGrokCopresenceCapabilityProfile() 读到的是
// **ambient 环境变量**,而不是启动时从节点配置钉下来的档位。
// check-copresence-profile-pin.py 就是守这一条的,它在本分支上报的正是这里。
const { retireStoppedGrokCopresenceRuntime } = await import(
"./runtime/grok-copresence/runtime-retirement"
);
await retireStoppedGrokCopresenceRuntime(stopped, {
warn: (message) => warn(message),
retire: (retired) => {
if (grokCopresenceRuntimeSession !== retired) return;
grokCopresenceRuntimeSession = null;
clearGrokSession("fatal co-presence boundary; next task requires a fresh session");
warn("[grok-copresence] retired stopped TUI; next task will open a fresh session");
},
});
}

function grokCopresenceTimeoutMs(): number {
const raw = process.env.GROK_CLI_TIMEOUT_MS
|| fileConfig.flags?.grokCliTimeoutMs
Expand Down Expand Up @@ -3587,6 +3608,7 @@ async function ensureGrokCopresenceRuntime(): Promise<GrokCopresenceSession> {
if (process.platform !== "linux") {
throw new Error("grok co-presence preview currently requires Linux PTY, /proc, and Unix sockets");
}
await retireCachedGrokCopresenceRuntime();
if (grokCopresenceRuntimeSession) return grokCopresenceRuntimeSession;
if (grokCopresenceRuntimeOpening) return grokCopresenceRuntimeOpening;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ import { GROK_COPRESENCE_EFFECTIVE_TOOLS } from "./policy";
function validResolutionFor(tool: string) {
return {
requestTool: tool,
activeRequestId: `tool:${tool}`,
pendingRequestCount: 1,
humanDecisionDispatched: false,
waitingHuman: true,
waitingHuman: false,
turnOwner: "network" as const,
terminalEventSeen: false,
event: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ const args = buildGrokCopresenceArgs({
});
const automaticTool = (tool: string, turnOwner: "human" | "network") => isGrokPreviewAutomaticResolution({
requestTool: tool,
activeRequestId: `tool:${tool}`,
pendingRequestCount: 1,
humanDecisionDispatched: false,
waitingHuman: true,
waitingHuman: false,
turnOwner,
terminalEventSeen: false,
event: {
Expand Down
62 changes: 62 additions & 0 deletions agent-node/src/runtime/grok-copresence/runtime-retirement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "fs";
import { join } from "path";
import { retireStoppedGrokCopresenceRuntime } from "./runtime-retirement";

function fixture(input: { running: boolean; phase: string; closeError?: Error }) {
let closeCalls = 0;
return {
runtime: {
isRunning: input.running,
state: { phase: input.phase },
async close() {
closeCalls += 1;
if (input.closeError) throw input.closeError;
},
},
closeCalls: () => closeCalls,
};
}

describe("Grok co-presence terminal-runtime retirement", () => {
test("retains live and recovering runtimes", async () => {
for (const state of [
{ running: true, phase: "idle" },
{ running: false, phase: "recovering" },
]) {
const probe = fixture(state);
let retired = false;
expect(await retireStoppedGrokCopresenceRuntime(probe.runtime, {
retire: () => { retired = true; },
})).toBe(false);
expect(probe.closeCalls()).toBe(0);
expect(retired).toBe(false);
}
});

test("closes and retires a terminal runtime even when teardown reports an error", async () => {
for (const closeError of [undefined, new Error("already contained")]) {
const probe = fixture({ running: false, phase: "network_turn", closeError });
const warnings: string[] = [];
let retiredRuntime: unknown;
expect(await retireStoppedGrokCopresenceRuntime(probe.runtime, {
retire: (runtime) => { retiredRuntime = runtime; },
warn: (message) => warnings.push(message),
})).toBe(true);
expect(probe.closeCalls()).toBe(1);
expect(retiredRuntime).toBe(probe.runtime);
expect(warnings).toHaveLength(closeError ? 1 : 0);
}
});

test("wires retirement before returning the cached product runtime", () => {
const cli = readFileSync(join(import.meta.dir, "..", "..", "cli.ts"), "utf8");
const ensureStart = cli.indexOf("async function ensureGrokCopresenceRuntime()");
const ensureEnd = cli.indexOf("\nconst GROK_COPRESENCE_FAILURE_CODE_SET", ensureStart);
const ensure = cli.slice(ensureStart, ensureEnd);
const retireAt = ensure.indexOf("await retireCachedGrokCopresenceRuntime()");
const returnCachedAt = ensure.indexOf("if (grokCopresenceRuntimeSession) return grokCopresenceRuntimeSession");
expect(retireAt).toBeGreaterThanOrEqual(0);
expect(returnCachedAt).toBeGreaterThan(retireAt);
});
});
29 changes: 29 additions & 0 deletions agent-node/src/runtime/grok-copresence/runtime-retirement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export interface RetireableGrokCopresenceRuntime {
readonly isRunning: boolean;
readonly state: { phase: string };
close(): Promise<void>;
}

/**
* Finish a terminal co-presence runtime before its owner replaces the cached
* slot. A runtime doing its ordinary PTY recovery is deliberately retained.
*/
export async function retireStoppedGrokCopresenceRuntime<T extends RetireableGrokCopresenceRuntime>(
runtime: T | null,
hooks: {
retire: (runtime: T) => void;
warn?: (message: string) => void;
},
): Promise<boolean> {
if (!runtime || runtime.isRunning || runtime.state.phase === "recovering") return false;

try {
await runtime.close();
} catch (error) {
hooks.warn?.(
`[grok-copresence] stopped runtime teardown reported: ${String((error as Error)?.message || error)}`,
);
}
hooks.retire(runtime);
return true;
}
125 changes: 114 additions & 11 deletions agent-node/src/runtime/grok-copresence/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ describe("Grok copresence launch and injection policy", () => {
test("keeps the fixed-tool auto-resolution exception exact and limited to active turns", () => {
const exact = {
requestTool: "todo_write",
activeRequestId: "tool:todo_write",
pendingRequestCount: 1,
humanDecisionDispatched: false,
waitingHuman: true,
waitingHuman: false,
turnOwner: "network" as const,
terminalEventSeen: false,
event: {
Expand All @@ -72,9 +72,9 @@ describe("Grok copresence launch and injection policy", () => {
})).toBe(true);
for (const mutation of [
{ ...exact, requestTool: null },
{ ...exact, activeRequestId: "tool:read_file" },
{ ...exact, pendingRequestCount: 0 },
{ ...exact, humanDecisionDispatched: true },
{ ...exact, waitingHuman: false },
{ ...exact, waitingHuman: true },
{ ...exact, turnOwner: null },
{ ...exact, terminalEventSeen: true },
{ ...exact, event: { ...exact.event, decision: "allow_once" } },
Expand All @@ -93,9 +93,9 @@ describe("Grok copresence launch and injection policy", () => {
for (const tool of ["todo_write", "search_tool", "use_tool"]) {
const exact = {
requestTool: tool,
activeRequestId: `tool:${tool}`,
pendingRequestCount: 1,
humanDecisionDispatched: false,
waitingHuman: true,
waitingHuman: false,
turnOwner: "human" as const,
terminalEventSeen: false,
event: {
Expand All @@ -116,9 +116,9 @@ describe("Grok copresence launch and injection policy", () => {
for (const tool of ["read_file", "Bash", "commhub_send_task"]) {
expect(isGrokPreviewAutomaticResolution({
requestTool: tool,
activeRequestId: `tool:${tool}`,
pendingRequestCount: 1,
humanDecisionDispatched: false,
waitingHuman: true,
waitingHuman: false,
turnOwner: "human",
terminalEventSeen: false,
event: {
Expand Down Expand Up @@ -1370,6 +1370,23 @@ describe("Grok copresence runtime integration", () => {
handshakeTimeoutMs: 500,
})).rejects.toThrow("already attached");

input.write("/model\r");
await waitFor(() => runtime!.state.phase === "idle");
const afterBlockedSlash = await runtime.submit({
taskId: "network-after-blocked-slash",
from: "dashboard",
text: "after blocked slash",
timeoutMs: 4_000,
});
expect(afterBlockedSlash.replyText).toBe("FINAL network-after-blocked-slash");
expect(fixture.humanPrompts).not.toContain("/model");

input.write("AC");
await waitFor(() => runtime!.state.phase === "human_editing");
input.write("\x1b[D");
input.write("B\r");
await waitFor(() => fixture.humanPrompts.includes("ABC"));

input.write("\x0f");
input.write("\x1b[Z");
input.write("\x1b[111;5u");
Expand Down Expand Up @@ -1400,7 +1417,7 @@ describe("Grok copresence runtime integration", () => {
await waitFor(() => fixture.humanPrompts.includes("queued"));
input.write("lf-human\n");
await waitFor(() => fixture.humanPrompts.includes("lf-human"));
expect(fixture.humanPrompts).toEqual(["first\rsecond", "queued", "lf-human"]);
expect(fixture.humanPrompts).toEqual(["ABC", "first\rsecond", "queued", "lf-human"]);

const approvalPromise = runtime.submit({
taskId: "approval-1",
Expand Down Expand Up @@ -1650,7 +1667,9 @@ describe("Grok copresence runtime integration", () => {
from: "reviewer",
text: `AUTO_RESOLVE_TODO_${mutation}`,
timeoutMs: 3_000,
}), mutation).rejects.toThrow(/permission request|automatically resolved/);
}), mutation).rejects.toThrow(
/permission request|automatically resolved|unmatched automatic|human approval/,
);
await waitFor(() => !runtime!.isRunning);
} finally {
await runtime?.close();
Expand Down Expand Up @@ -1752,6 +1771,27 @@ describe("Grok copresence runtime integration", () => {
}
}, 8_000);

test("allows a pinned tool batch whose automatic resolutions are not request ordered", async () => {
const fixture = new RuntimeFixture();
let runtime: GrokCopresenceRuntimeSession | undefined;
try {
runtime = await fixture.open();
const result = await runtime.submit({
taskId: "preview-batched-order",
from: "reviewer",
text: "AUTO_RESOLVE_BATCHED",
timeoutMs: 3_000,
});
expect(result.replyText).toBe("BATCHED preview-batched-order");
expect(runtime.isRunning).toBe(true);
expect(runtime.state.waitingHuman).toBe(false);
expect(fixture.approvalDecisionCount()).toBe(0);
} finally {
await runtime?.close();
await fixture.close();
}
}, 8_000);

test("never replies with a tool-bearing assistant when the final log is delayed past settling", async () => {
const fixture = new RuntimeFixture();
let runtime: GrokCopresenceRuntimeSession | undefined;
Expand Down Expand Up @@ -2355,6 +2395,7 @@ class FakePty implements GrokPtyLike {
private dataListeners: Array<(data: string) => void> = [];
private exitListeners: Array<(event: { exitCode: number; signal?: number }) => void> = [];
private composer = "";
private composerCursor = 0;
private paste = false;
private awaitingApprovalTask = "";
private lateCrashTask = "";
Expand Down Expand Up @@ -2653,6 +2694,36 @@ class FakePty implements GrokPtyLike {
}, 150);
return;
}
if (message === "AUTO_RESOLVE_BATCHED") {
appendJson(join(this.sessionDir, "chat_history.jsonl"), {
type: "user",
content: `<user_query>[Agent Network/from=${from}/task=${taskId}] ${message}</user_query>`,
});
const eventsPath = join(this.sessionDir, "events.jsonl");
appendJson(eventsPath, { type: "turn_started", turn_number: 15 });
for (const tool_name of ["search_tool", "use_tool", "search_tool"]) {
appendJson(eventsPath, {
type: "permission_requested",
tool_name,
ts: `preview-${tool_name}-requested`,
});
}
for (const tool_name of ["search_tool", "search_tool", "use_tool"]) {
appendJson(eventsPath, {
type: "permission_resolved",
tool_name,
decision: "allow",
ts: `preview-${tool_name}-resolved`,
wait_ms: 0,
});
}
appendJson(join(this.sessionDir, "chat_history.jsonl"), {
type: "assistant",
content: `BATCHED ${taskId}`,
});
appendJson(eventsPath, { type: "turn_ended", outcome: "completed" });
return;
}
if (
message === "AUTO_RESOLVE_TODO_COALESCED"
|| message === "AUTO_RESOLVE_TODO_FRAGMENTED"
Expand Down Expand Up @@ -2767,6 +2838,23 @@ class FakePty implements GrokPtyLike {
ts: "preview-todo-requested-duplicate",
}),
});
if (message.endsWith("_DUPLICATE") && !message.endsWith("CHANGED_DUPLICATE")) {
appendJson(join(this.sessionDir, "events.jsonl"), {
type: "permission_resolved",
tool_name: "todo_write",
decision: "allow",
ts: "preview-todo-resolved-once",
wait_ms: 0,
});
appendJson(join(this.sessionDir, "chat_history.jsonl"), {
type: "assistant",
content: "must not complete with one unresolved automatic request",
});
appendJson(join(this.sessionDir, "events.jsonl"), {
type: "turn_ended",
outcome: "completed",
});
}
return;
}
const resolved = message.endsWith("CAMEL_CASE")
Expand Down Expand Up @@ -2866,21 +2954,36 @@ class FakePty implements GrokPtyLike {
index += 6;
continue;
}
if (!this.paste && data.startsWith("\x1b[D", index)) {
this.composerCursor = Math.max(0, this.composerCursor - 1);
index += 3;
continue;
}
if (!this.paste && data.startsWith("\x1b[C", index)) {
this.composerCursor = Math.min(this.composer.length, this.composerCursor + 1);
index += 3;
continue;
}
const char = data[index++];
if (this.awaitingApprovalTask && /^[1-9]$/.test(char)) {
this.resolveApproval();
continue;
}
if (char === "\x03" && !this.paste) {
this.composer = "";
this.composerCursor = 0;
continue;
}
if ((char !== "\r" && char !== "\n") || this.paste) {
this.composer += char;
this.composer = this.composer.slice(0, this.composerCursor)
+ char
+ this.composer.slice(this.composerCursor);
this.composerCursor += char.length;
continue;
}
const submitted = this.composer;
this.composer = "";
this.composerCursor = 0;
if (this.awaitingApprovalTask) {
this.resolveApproval();
} else if (submitted) {
Expand Down
Loading
Loading