Skip to content

Commit 473b815

Browse files
authored
feat: Improve agent session lifeycle and bump SDKs (#1089)
1. Share mock node environment across sessions instead of creating/deleting per-session 2. Cache machine encryption key to avoid repeated scrypt derivation 3. Simplify process tree kill to SIGKILL directly without SIGTERM fallback 4. Promote codex-acp stderr to warn-level logging 5. Bump codex-acp (0.9.5), claude-agent-sdk (0.2.63), anthropic sdk (0.78.0)
1 parent d651118 commit 473b815

9 files changed

Lines changed: 79 additions & 60 deletions

File tree

apps/twig/scripts/download-binaries.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const DEST_DIR = join(__dirname, "..", "resources", "codex-acp");
1919
const BINARIES = [
2020
{
2121
name: "codex-acp",
22-
version: "0.9.1",
22+
version: "0.9.5",
2323
getUrl: (version, target) => {
2424
const ext = target.includes("windows") ? "zip" : "tar.gz";
2525
return `https://github.com/zed-industries/codex-acp/releases/download/v${version}/codex-acp-${version}-${target}.${ext}`;

apps/twig/src/main/services/agent/service.ts

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import fs, { mkdirSync, rmSync, symlinkSync } from "node:fs";
1+
import fs, { existsSync, mkdirSync, symlinkSync } from "node:fs";
22
import { tmpdir } from "node:os";
3-
import { isAbsolute, join, relative, resolve, sep } from "node:path";
3+
import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
44
import {
55
type Client,
66
ClientSideConnection,
@@ -46,6 +46,8 @@ export type { InterruptReason };
4646

4747
const log = logger.scope("agent-service");
4848

49+
const SHARED_MOCK_NODE_DIR = join(tmpdir(), "agent-node-shared");
50+
4951
/** Mark all content blocks as hidden so the renderer doesn't show a duplicate user message on retry. */
5052
function hidePromptBlocks(prompt: ContentBlock[]): ContentBlock[] {
5153
return prompt.map((block) => {
@@ -201,7 +203,6 @@ interface ManagedSession {
201203
channel: string;
202204
createdAt: number;
203205
lastActivityAt: number;
204-
mockNodeDir: string;
205206
config: SessionConfig;
206207
interruptReason?: InterruptReason;
207208
needsRecreation: boolean;
@@ -246,6 +247,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
246247
private sessions = new Map<string, ManagedSession>();
247248
private currentToken: string | null = null;
248249
private pendingPermissions = new Map<string, PendingPermission>();
250+
private mockNodeReady = false;
249251
private processTracking: ProcessTrackingService;
250252
private sleepService: SleepService;
251253
private fsService: FsService;
@@ -494,7 +496,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
494496
}
495497

496498
const channel = `agent-event:${taskRunId}`;
497-
const mockNodeDir = this.setupMockNodeEnvironment(taskRunId);
499+
const mockNodeDir = this.setupMockNodeEnvironment();
498500
this.setupEnvironment(credentials, mockNodeDir);
499501

500502
// Preview sessions don't persist logs — no real task exists
@@ -658,7 +660,6 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
658660
channel,
659661
createdAt: Date.now(),
660662
lastActivityAt: Date.now(),
661-
mockNodeDir,
662663
config,
663664
needsRecreation: false,
664665
promptPending: false,
@@ -676,7 +677,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
676677
} catch {
677678
log.debug("Agent cleanup failed during error handling", { taskRunId });
678679
}
679-
this.cleanupMockNodeEnvironment(mockNodeDir);
680+
680681
if (!isRetry && isAuthError(err)) {
681682
log.warn(
682683
`Auth error during ${isReconnect ? "reconnect" : "create"}, retrying`,
@@ -1003,8 +1004,10 @@ For git operations while detached:
10031004
mockNodeDir: string,
10041005
): void {
10051006
const token = this.getToken(credentials.apiKey);
1006-
const newPath = `${mockNodeDir}:${process.env.PATH || ""}`;
1007-
process.env.PATH = newPath;
1007+
const currentPath = process.env.PATH || "";
1008+
if (!currentPath.split(delimiter).includes(mockNodeDir)) {
1009+
process.env.PATH = `${mockNodeDir}${delimiter}${currentPath}`;
1010+
}
10081011
process.env.POSTHOG_AUTH_HEADER = `Bearer ${token}`;
10091012
process.env.ANTHROPIC_API_KEY = token;
10101013
process.env.ANTHROPIC_AUTH_TOKEN = token;
@@ -1026,29 +1029,20 @@ For git operations while detached:
10261029
process.env.POSTHOG_PROJECT_ID = String(credentials.projectId);
10271030
}
10281031

1029-
private setupMockNodeEnvironment(sessionId: string): string {
1030-
const mockNodeDir = join(tmpdir(), `array-agent-node-${sessionId}`);
1031-
try {
1032-
mkdirSync(mockNodeDir, { recursive: true });
1033-
const nodeSymlinkPath = join(mockNodeDir, "node");
1032+
private setupMockNodeEnvironment(): string {
1033+
if (!this.mockNodeReady) {
10341034
try {
1035-
rmSync(nodeSymlinkPath, { force: true });
1036-
} catch {
1037-
/* ignore */
1035+
mkdirSync(SHARED_MOCK_NODE_DIR, { recursive: true });
1036+
const nodeSymlinkPath = join(SHARED_MOCK_NODE_DIR, "node");
1037+
if (!existsSync(nodeSymlinkPath)) {
1038+
symlinkSync(process.execPath, nodeSymlinkPath);
1039+
}
1040+
this.mockNodeReady = true;
1041+
} catch (err) {
1042+
log.warn("Failed to setup mock node environment", err);
10381043
}
1039-
symlinkSync(process.execPath, nodeSymlinkPath);
1040-
} catch (err) {
1041-
log.warn("Failed to setup mock node environment", err);
1042-
}
1043-
return mockNodeDir;
1044-
}
1045-
1046-
private cleanupMockNodeEnvironment(mockNodeDir: string): void {
1047-
try {
1048-
rmSync(mockNodeDir, { recursive: true, force: true });
1049-
} catch {
1050-
/* ignore */
10511044
}
1045+
return SHARED_MOCK_NODE_DIR;
10521046
}
10531047

10541048
private async cleanupSession(taskRunId: string): Promise<void> {
@@ -1060,7 +1054,7 @@ For git operations while detached:
10601054
} catch {
10611055
log.debug("Agent cleanup failed", { taskRunId });
10621056
}
1063-
this.cleanupMockNodeEnvironment(session.mockNodeDir);
1057+
10641058
this.sessions.delete(taskRunId);
10651059
}
10661060
}

apps/twig/src/main/utils/encryption.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@ import { machineIdSync } from "node-machine-id";
88
const APP_SALT = "array-v1";
99
const ENCRYPTION_VERSION = 1;
1010

11+
let cachedMachineKey: Buffer | undefined;
12+
1113
function getMachineKey(): Buffer {
12-
const machineId = machineIdSync();
13-
const identifier = [machineId, os.platform(), os.arch()].join("|");
14-
return crypto.scryptSync(identifier, APP_SALT, 32);
14+
if (!cachedMachineKey) {
15+
const machineId = machineIdSync();
16+
const identifier = [machineId, os.platform(), os.arch()].join("|");
17+
cachedMachineKey = crypto.scryptSync(identifier, APP_SALT, 32);
18+
}
19+
return cachedMachineKey;
1520
}
1621

1722
export function encrypt(plaintext: string): string {

apps/twig/src/main/utils/process-utils.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { logger } from "./logger.js";
44

55
const log = logger.scope("process-utils");
66

7+
const SIGKILL_GRACE_MS = 5_000;
8+
79
/**
810
* Kill a process and all its children by killing the process group.
911
* On Unix, we use process.kill(-pid) to kill the entire process group.
@@ -15,21 +17,29 @@ export function killProcessTree(pid: number): void {
1517
// Windows: use taskkill with /T to kill process tree
1618
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
1719
} else {
18-
// Try process group first (-pid), fall back to individual process (pid).
19-
// SIGTERM for graceful shutdown, SIGKILL as last resort.
20-
const signals: Array<[number, NodeJS.Signals]> = [
21-
[-pid, "SIGTERM"],
22-
[-pid, "SIGKILL"],
23-
[pid, "SIGTERM"],
24-
[pid, "SIGKILL"],
25-
];
26-
for (const [target, signal] of signals) {
20+
// SIGTERM the process group first, fall back to individual process
21+
let sent = false;
22+
for (const target of [-pid, pid]) {
2723
try {
28-
process.kill(target, signal);
29-
return;
24+
process.kill(target, "SIGTERM");
25+
sent = true;
26+
break;
3027
} catch {}
3128
}
32-
log.warn(`Failed to kill process ${pid}`);
29+
30+
if (!sent) return;
31+
32+
// Force kill after a grace period — unref so the timer doesn't delay app exit.
33+
// We skip the liveness check since isProcessAlive only tests the group leader;
34+
// orphaned children in the same group would be missed. The catch blocks
35+
// handle ESRCH if everything already exited.
36+
setTimeout(() => {
37+
for (const target of [-pid, pid]) {
38+
try {
39+
process.kill(target, "SIGKILL");
40+
} catch {}
41+
}
42+
}, SIGKILL_GRACE_MS).unref();
3343
}
3444
} catch (err) {
3545
log.warn(`Failed to kill process tree for PID ${pid}`, err);

packages/agent/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@
8585
},
8686
"dependencies": {
8787
"@agentclientprotocol/sdk": "^0.14.0",
88-
"@anthropic-ai/claude-agent-sdk": "0.2.59",
89-
"@anthropic-ai/sdk": "^0.71.0",
88+
"@anthropic-ai/claude-agent-sdk": "0.2.63",
89+
"@anthropic-ai/sdk": "^0.78.0",
9090
"@hono/node-server": "^1.19.9",
9191
"@opentelemetry/api-logs": "^0.208.0",
9292
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",

packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ function processContentChunk(
257257
case "citations_delta":
258258
case "signature_delta":
259259
case "container_upload":
260+
case "compaction":
261+
case "compaction_delta":
260262
return null;
261263

262264
default:

packages/agent/src/adapters/codex/spawn.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type ChildProcess, spawn } from "node:child_process";
22
import { existsSync } from "node:fs";
3+
import { delimiter, dirname } from "node:path";
34
import type { Readable, Writable } from "node:stream";
45
import type { ProcessSpawnedCallback } from "../../types.js";
56
import { Logger } from "../../utils/logger.js";
@@ -79,8 +80,8 @@ export function spawnCodexProcess(options: CodexProcessOptions): CodexProcess {
7980
const { command, args } = findCodexBinary(options);
8081

8182
if (options.binaryPath && existsSync(options.binaryPath)) {
82-
const binDir = options.binaryPath.replace(/\/[^/]+$/, "");
83-
env.PATH = `${binDir}:${env.PATH ?? ""}`;
83+
const binDir = dirname(options.binaryPath);
84+
env.PATH = `${binDir}${delimiter}${env.PATH ?? ""}`;
8485
}
8586

8687
logger.info("Spawning codex-acp process", {
@@ -100,7 +101,7 @@ export function spawnCodexProcess(options: CodexProcessOptions): CodexProcess {
100101
});
101102

102103
child.stderr?.on("data", (data: Buffer) => {
103-
logger.debug("codex-acp stderr:", data.toString());
104+
logger.error("codex-acp stderr:", data.toString());
104105
});
105106

106107
child.on("error", (err) => {

packages/agent/src/test/mocks/claude-sdk.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export function createMockQuery(
9999
initializationResult: vi.fn().mockResolvedValue({}),
100100
reconnectMcpServer: vi.fn().mockResolvedValue(undefined),
101101
toggleMcpServer: vi.fn().mockResolvedValue(undefined),
102+
supportedAgents: vi.fn().mockResolvedValue([]),
102103
stopTask: vi.fn().mockResolvedValue(undefined),
103104
[Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined),
104105
_abortController: abortController,
@@ -176,6 +177,9 @@ export function createSuccessResult(
176177
},
177178
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
178179
service_tier: "standard",
180+
inference_geo: "us",
181+
iterations: [],
182+
speed: "standard",
179183
},
180184
modelUsage: {},
181185
permission_denials: [],
@@ -209,6 +213,9 @@ export function createErrorResult(
209213
},
210214
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
211215
service_tier: "standard",
216+
inference_geo: "us",
217+
iterations: [],
218+
speed: "standard",
212219
},
213220
modelUsage: {},
214221
permission_denials: [],

pnpm-lock.yaml

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)