agent-gear 2.3.0 → 2.5.0
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/AGENTS.md +74 -3
- package/dist/cloud-host/supervisor.d.ts +92 -0
- package/dist/cloud-host/supervisor.d.ts.map +1 -0
- package/dist/cloud-host/supervisor.js +216 -0
- package/dist/cloud-host/supervisor.js.map +1 -0
- package/dist/cloud-host/supervisor.test.d.ts +2 -0
- package/dist/cloud-host/supervisor.test.d.ts.map +1 -0
- package/dist/cloud-host/supervisor.test.js +296 -0
- package/dist/cloud-host/supervisor.test.js.map +1 -0
- package/dist/commands/harness.d.ts +17 -0
- package/dist/commands/harness.d.ts.map +1 -0
- package/dist/commands/harness.js +35 -0
- package/dist/commands/harness.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -3
- package/dist/config.js.map +1 -1
- package/dist/harness/env.d.ts +36 -0
- package/dist/harness/env.d.ts.map +1 -0
- package/dist/harness/env.js +78 -0
- package/dist/harness/env.js.map +1 -0
- package/dist/harness/env.test.d.ts +2 -0
- package/dist/harness/env.test.d.ts.map +1 -0
- package/dist/harness/env.test.js +70 -0
- package/dist/harness/env.test.js.map +1 -0
- package/dist/harness/opencode-cli.d.ts +24 -0
- package/dist/harness/opencode-cli.d.ts.map +1 -0
- package/dist/harness/opencode-cli.js +74 -0
- package/dist/harness/opencode-cli.js.map +1 -0
- package/dist/harness/opencode-cli.test.d.ts +2 -0
- package/dist/harness/opencode-cli.test.d.ts.map +1 -0
- package/dist/harness/opencode-cli.test.js +62 -0
- package/dist/harness/opencode-cli.test.js.map +1 -0
- package/dist/harness/opencode-config.atomic-failure.test.d.ts +2 -0
- package/dist/harness/opencode-config.atomic-failure.test.d.ts.map +1 -0
- package/dist/harness/opencode-config.atomic-failure.test.js +35 -0
- package/dist/harness/opencode-config.atomic-failure.test.js.map +1 -0
- package/dist/harness/opencode-config.d.ts +54 -0
- package/dist/harness/opencode-config.d.ts.map +1 -0
- package/dist/harness/opencode-config.js +130 -0
- package/dist/harness/opencode-config.js.map +1 -0
- package/dist/harness/opencode-config.test.d.ts +2 -0
- package/dist/harness/opencode-config.test.d.ts.map +1 -0
- package/dist/harness/opencode-config.test.js +183 -0
- package/dist/harness/opencode-config.test.js.map +1 -0
- package/dist/harness/prepare.d.ts +34 -0
- package/dist/harness/prepare.d.ts.map +1 -0
- package/dist/harness/prepare.js +46 -0
- package/dist/harness/prepare.js.map +1 -0
- package/dist/harness/prepare.test.d.ts +2 -0
- package/dist/harness/prepare.test.d.ts.map +1 -0
- package/dist/harness/prepare.test.js +96 -0
- package/dist/harness/prepare.test.js.map +1 -0
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +2 -0
- package/dist/heartbeat.js.map +1 -1
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -1
- package/dist/socketio-client.d.ts +62 -3
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +155 -16
- package/dist/socketio-client.js.map +1 -1
- package/dist/socketio-client.test.d.ts +2 -0
- package/dist/socketio-client.test.d.ts.map +1 -0
- package/dist/socketio-client.test.js +132 -0
- package/dist/socketio-client.test.js.map +1 -0
- package/dist/workload-executor.d.ts +31 -2
- package/dist/workload-executor.d.ts.map +1 -1
- package/dist/workload-executor.js +89 -16
- package/dist/workload-executor.js.map +1 -1
- package/dist/workload-executor.test.js +180 -1
- package/dist/workload-executor.test.js.map +1 -1
- package/package.json +2 -1
- package/src/cloud-host/supervisor.test.ts +367 -0
- package/src/cloud-host/supervisor.ts +289 -0
- package/src/commands/harness.ts +44 -0
- package/src/config.ts +9 -3
- package/src/harness/env.test.ts +79 -0
- package/src/harness/env.ts +89 -0
- package/src/harness/opencode-cli.test.ts +82 -0
- package/src/harness/opencode-cli.ts +99 -0
- package/src/harness/opencode-config.atomic-failure.test.ts +51 -0
- package/src/harness/opencode-config.test.ts +238 -0
- package/src/harness/opencode-config.ts +176 -0
- package/src/harness/prepare.test.ts +117 -0
- package/src/harness/prepare.ts +69 -0
- package/src/heartbeat.ts +2 -0
- package/src/index.ts +22 -0
- package/src/socketio-client.test.ts +155 -0
- package/src/socketio-client.ts +204 -18
- package/src/workload-executor.test.ts +237 -0
- package/src/workload-executor.ts +115 -17
- package/tests/commands/harness.test.ts +90 -0
- package/tests/socketio-client.test.ts +140 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const fake = vi.hoisted(() => {
|
|
4
|
+
const handlers = new Map<string, (...args: unknown[]) => void>();
|
|
5
|
+
const socket = {
|
|
6
|
+
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
7
|
+
handlers.set(event, handler);
|
|
8
|
+
return socket;
|
|
9
|
+
}),
|
|
10
|
+
emit: vi.fn(),
|
|
11
|
+
disconnect: vi.fn(),
|
|
12
|
+
connect: vi.fn(),
|
|
13
|
+
close: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
return { handlers, socket };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
vi.mock('socket.io-client', () => ({
|
|
19
|
+
io: vi.fn(() => fake.socket),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { cloudHostReconnectDelay, connectToServer } from './socketio-client.js';
|
|
23
|
+
|
|
24
|
+
function errorHandler(): (payload: { code: string; message: string }) => void {
|
|
25
|
+
const handler = fake.handlers.get('error');
|
|
26
|
+
if (!handler) throw new Error('error handler not registered');
|
|
27
|
+
return handler as (payload: { code: string; message: string }) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function shutdownHandler(): (payload: { reason: string }) => void {
|
|
31
|
+
const handler = fake.handlers.get('shutdown');
|
|
32
|
+
if (!handler) throw new Error('shutdown handler not registered');
|
|
33
|
+
return handler as (payload: { reason: string }) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setup(withShutdown = true) {
|
|
37
|
+
const onShutdown = vi.fn();
|
|
38
|
+
connectToServer('http://localhost:4001', 'controller-key', {
|
|
39
|
+
serverName: 'test-controller',
|
|
40
|
+
handshakeToken: 'controller-key',
|
|
41
|
+
skipLocalMcpStart: true,
|
|
42
|
+
handleWorkload: false,
|
|
43
|
+
persistConfig: false,
|
|
44
|
+
...(withShutdown ? { onShutdown } : {}),
|
|
45
|
+
});
|
|
46
|
+
return { onShutdown };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('cloud-host reconnect backoff (C-3)', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
fake.handlers.clear();
|
|
52
|
+
fake.socket.on.mockClear();
|
|
53
|
+
fake.socket.emit.mockClear();
|
|
54
|
+
fake.socket.disconnect.mockClear();
|
|
55
|
+
fake.socket.connect.mockClear();
|
|
56
|
+
fake.socket.close.mockClear();
|
|
57
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
58
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.useRealTimers();
|
|
64
|
+
vi.restoreAllMocks();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('backs off exponentially from 1s and caps at 30s', () => {
|
|
68
|
+
expect(cloudHostReconnectDelay(0)).toBe(1000);
|
|
69
|
+
expect(cloudHostReconnectDelay(1)).toBe(2000);
|
|
70
|
+
expect(cloudHostReconnectDelay(2)).toBe(4000);
|
|
71
|
+
expect(cloudHostReconnectDelay(4)).toBe(16000);
|
|
72
|
+
expect(cloudHostReconnectDelay(5)).toBe(30000);
|
|
73
|
+
expect(cloudHostReconnectDelay(10)).toBe(30000);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('KEY_ALREADY_ONLINE reconnects in cloud-host mode after 1s then 2s', () => {
|
|
77
|
+
const { onShutdown } = setup(true);
|
|
78
|
+
const handler = errorHandler();
|
|
79
|
+
|
|
80
|
+
handler({ code: 'KEY_ALREADY_ONLINE', message: 'Key already has an active session' });
|
|
81
|
+
|
|
82
|
+
expect(fake.socket.disconnect).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(onShutdown).not.toHaveBeenCalled();
|
|
84
|
+
expect(fake.socket.connect).not.toHaveBeenCalled();
|
|
85
|
+
|
|
86
|
+
vi.advanceTimersByTime(999);
|
|
87
|
+
expect(fake.socket.connect).not.toHaveBeenCalled();
|
|
88
|
+
vi.advanceTimersByTime(1);
|
|
89
|
+
expect(fake.socket.connect).toHaveBeenCalledTimes(1);
|
|
90
|
+
|
|
91
|
+
// 再次冲突:退避翻倍到 2s
|
|
92
|
+
handler({ code: 'KEY_ALREADY_ONLINE', message: 'Key already has an active session' });
|
|
93
|
+
vi.advanceTimersByTime(1999);
|
|
94
|
+
expect(fake.socket.connect).toHaveBeenCalledTimes(1);
|
|
95
|
+
vi.advanceTimersByTime(1);
|
|
96
|
+
expect(fake.socket.connect).toHaveBeenCalledTimes(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('escalates backoff across real connect→error cycles (R2-6)', () => {
|
|
100
|
+
setup(true);
|
|
101
|
+
const handler = errorHandler();
|
|
102
|
+
|
|
103
|
+
// 第一次冲突:1s
|
|
104
|
+
handler({ code: 'KEY_ALREADY_ONLINE', message: 'busy' });
|
|
105
|
+
vi.advanceTimersByTime(1000);
|
|
106
|
+
expect(fake.socket.connect).toHaveBeenCalledTimes(1);
|
|
107
|
+
|
|
108
|
+
// 真实时序:先 connect,再收到 KEY_ALREADY_ONLINE;不得因 connect 归零
|
|
109
|
+
fake.handlers.get('connect')?.();
|
|
110
|
+
handler({ code: 'KEY_ALREADY_ONLINE', message: 'busy' });
|
|
111
|
+
vi.advanceTimersByTime(1999);
|
|
112
|
+
expect(fake.socket.connect).toHaveBeenCalledTimes(1);
|
|
113
|
+
vi.advanceTimersByTime(1);
|
|
114
|
+
expect(fake.socket.connect).toHaveBeenCalledTimes(2);
|
|
115
|
+
|
|
116
|
+
// 注册成功(registered)才重置:下一轮恢复 1s
|
|
117
|
+
fake.handlers.get('registered')?.({ controllerId: 'c-1' });
|
|
118
|
+
handler({ code: 'KEY_ALREADY_ONLINE', message: 'busy' });
|
|
119
|
+
vi.advanceTimersByTime(999);
|
|
120
|
+
expect(fake.socket.connect).toHaveBeenCalledTimes(2);
|
|
121
|
+
vi.advanceTimersByTime(1);
|
|
122
|
+
expect(fake.socket.connect).toHaveBeenCalledTimes(3);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('does not reconnect after a normal shutdown (timer is cleared)', () => {
|
|
126
|
+
const { onShutdown } = setup(true);
|
|
127
|
+
errorHandler()({ code: 'KEY_ALREADY_ONLINE', message: 'busy' });
|
|
128
|
+
|
|
129
|
+
shutdownHandler()({ reason: 'server requested shutdown' });
|
|
130
|
+
|
|
131
|
+
expect(onShutdown).toHaveBeenCalledWith({ reason: 'server requested shutdown' });
|
|
132
|
+
vi.advanceTimersByTime(60_000);
|
|
133
|
+
expect(fake.socket.connect).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('keeps legacy behaviour outside cloud-host mode (no manual reconnect)', () => {
|
|
137
|
+
setup(false);
|
|
138
|
+
errorHandler()({ code: 'KEY_ALREADY_ONLINE', message: 'busy' });
|
|
139
|
+
|
|
140
|
+
expect(fake.socket.disconnect).toHaveBeenCalledTimes(1);
|
|
141
|
+
vi.advanceTimersByTime(60_000);
|
|
142
|
+
expect(fake.socket.connect).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('forwards error paths with failure-prefixed reasons for exit-code classification', () => {
|
|
146
|
+
const { onShutdown } = setup(true);
|
|
147
|
+
|
|
148
|
+
errorHandler()({ code: 'VERSION_MISMATCH', message: 'too old' });
|
|
149
|
+
expect(onShutdown).toHaveBeenCalledWith({ reason: 'version_mismatch: too old' });
|
|
150
|
+
|
|
151
|
+
const second = setup(true);
|
|
152
|
+
errorHandler()({ code: 'REGISTER_FAILED', message: 'boom' });
|
|
153
|
+
expect(second.onShutdown).toHaveBeenCalledWith({ reason: 'error: REGISTER_FAILED' });
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/socketio-client.ts
CHANGED
|
@@ -18,14 +18,19 @@ import { gatewayManager } from './gateway.js';
|
|
|
18
18
|
import { processRegistry, isProcessAlive } from './acp/process-registry.js';
|
|
19
19
|
import { McpRelay } from './mcp/relay.js';
|
|
20
20
|
import { LocalMcpServer } from './mcp/local-server.js';
|
|
21
|
-
import { WorkloadExecutor, type WorkloadRunPayload } from './workload-executor.js';
|
|
21
|
+
import { WorkloadExecutor, type WorkloadRunPayload, collectSecretValues, redactSecrets } from './workload-executor.js';
|
|
22
22
|
|
|
23
23
|
// 与 package.json 版本保持一致(register 上报的完整版本;服务端按 MIN_WORKER_VERSION=2.2.0 门禁)。
|
|
24
|
-
|
|
24
|
+
// 2.5.0 起支持云电脑双连接(`/worker` + controller-api)与 `harness prepare`(MIN_HARNESS_WORKER_VERSION=2.5.0)。
|
|
25
|
+
const CONTROLLER_VERSION = '2.5.0';
|
|
25
26
|
// 进程内全局单实例:工作负载批次严格串行(新批次打断旧批次,见 workload-executor)。
|
|
26
27
|
const workloadExecutor = new WorkloadExecutor();
|
|
27
28
|
// shutdown 到达时若仍有 post 批次在跑,最多等待该时长后退出(契约 §2.8)。
|
|
28
29
|
const SHUTDOWN_WORKLOAD_WAIT_MS = 5000;
|
|
30
|
+
// 云电脑模式 `KEY_ALREADY_ONLINE` 退避重连:断开后手动重连(1s→2s→…→30s)。
|
|
31
|
+
// 非云电脑模式保持既有行为(仅断开,不做额外兜底)。
|
|
32
|
+
const CLOUD_HOST_RECONNECT_BASE_MS = 1000;
|
|
33
|
+
const CLOUD_HOST_RECONNECT_MAX_MS = 30_000;
|
|
29
34
|
const acpBridges = new Map<string, AcpSessionBridge>();
|
|
30
35
|
const permissionRequestBridge = new Map<string, AcpSessionBridge>();
|
|
31
36
|
const elicitationRequestBridge = new Map<string, AcpSessionBridge>();
|
|
@@ -34,6 +39,15 @@ function isStringArray(value: unknown): value is string[] {
|
|
|
34
39
|
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
/** 安全序列化(循环引用/不可序列化时不抛错,日志路径不得因脱敏而失败)。 */
|
|
43
|
+
function safeStringify(value: unknown): string {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.stringify(value) ?? String(value);
|
|
46
|
+
} catch {
|
|
47
|
+
return '<unserializable>';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
function createSpawner(serverType: string, sessionId: string, cwd: string): AcpSpawner {
|
|
38
52
|
const config = acpDetector.getSpawnerConfig(serverType);
|
|
39
53
|
return new AcpSpawner({ sessionId, cwd, command: config.command, args: config.args });
|
|
@@ -42,6 +56,56 @@ function createSpawner(serverType: string, sessionId: string, cwd: string): AcpS
|
|
|
42
56
|
interface RegisteredPayload {
|
|
43
57
|
controllerId: string;
|
|
44
58
|
resumeToken?: string;
|
|
59
|
+
/** 云电脑场景:是否存在待执行的初始化脚本(缺省/false 立即建立标准连接)。 */
|
|
60
|
+
initializing?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `workload:run` 批次结束摘要(成功/失败/被打断都会回调)。 */
|
|
64
|
+
export interface WorkloadBatchSummary {
|
|
65
|
+
phase: 'init' | 'post';
|
|
66
|
+
itemCount: number;
|
|
67
|
+
/** 批次正常结束且无失败项为 completed;有失败/超时为 failed;被打断为 cancelled。 */
|
|
68
|
+
status: 'completed' | 'failed' | 'cancelled';
|
|
69
|
+
/** 失败/超时条目数。 */
|
|
70
|
+
failedCount?: number;
|
|
71
|
+
/** 批次是否被新批次/销毁流程打断(打断不得视为初始化完成)。 */
|
|
72
|
+
cancelled?: boolean;
|
|
73
|
+
error?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ConnectToServerOptions {
|
|
77
|
+
serverName?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Socket.IO 握手 `auth.token`:
|
|
80
|
+
* - `string`:显式使用(controller-api 标准连接传 `ZAG_HOST_KEY`,worker 连接传一次性 token);
|
|
81
|
+
* - `false`:不发送 `auth.token`(既有本地控制器 legacy 行为由缺省值处理,通常无需使用);
|
|
82
|
+
* - 缺省:endpoint query `?token=`(兼容旧注入)否则用 `key`(cloud-controller 场景 key=token)。
|
|
83
|
+
*/
|
|
84
|
+
handshakeToken?: string | false;
|
|
85
|
+
/** `registered` 回调(云电脑 supervisor 据此判断 `initializing`)。 */
|
|
86
|
+
onRegistered?: (payload: RegisteredPayload) => void;
|
|
87
|
+
/** `workload:run` 批次结束回调(成功/失败都回调;用于 init 完成后建立标准连接)。 */
|
|
88
|
+
onWorkloadBatchComplete?: (summary: WorkloadBatchSummary) => void;
|
|
89
|
+
/**
|
|
90
|
+
* 不启动本地 MCP 回环服务(双连接时仅由 controller-api 标准连接启动一次,
|
|
91
|
+
* 避免 worker 连接重复监听同一端口)。
|
|
92
|
+
*/
|
|
93
|
+
skipLocalMcpStart?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* 是否处理 `workload:run` / 重连重放(默认 true)。云电脑的 controller-api 标准连接
|
|
96
|
+
* 不处理工作负载事件,避免结果被重放到非 cloud-controller 侧。
|
|
97
|
+
*/
|
|
98
|
+
handleWorkload?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* 是否持久化本地配置(resumeToken/清除 Key)。云电脑模式必须为 false:
|
|
101
|
+
* 容器内不读写 `~/.zag/config.toml`,避免凭据落盘。
|
|
102
|
+
*/
|
|
103
|
+
persistConfig?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* 自定义 `shutdown` 处理(云电脑双连接需同时关闭两端)。缺省保持既有行为:
|
|
106
|
+
* 清理本地 Key、关闭 socket、退出进程。
|
|
107
|
+
*/
|
|
108
|
+
onShutdown?: (payload: ShutdownPayload) => void;
|
|
45
109
|
}
|
|
46
110
|
|
|
47
111
|
interface ErrorPayload {
|
|
@@ -287,14 +351,31 @@ export function describeConnectError(err: Error): string {
|
|
|
287
351
|
return parts.length > 0 ? ` (${parts.join(' ')})` : '';
|
|
288
352
|
}
|
|
289
353
|
|
|
354
|
+
/**
|
|
355
|
+
* 云电脑模式重连退避:`1s → 2s → 4s → …`,封顶 30s(C-3)。
|
|
356
|
+
* `attempt` 从 0 开始(首次断开后等待 1s)。
|
|
357
|
+
*/
|
|
358
|
+
export function cloudHostReconnectDelay(attempt: number): number {
|
|
359
|
+
const safeAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
|
360
|
+
return Math.min(CLOUD_HOST_RECONNECT_MAX_MS, CLOUD_HOST_RECONNECT_BASE_MS * 2 ** safeAttempt);
|
|
361
|
+
}
|
|
362
|
+
|
|
290
363
|
export function connectToServer(
|
|
291
364
|
endpoint: string,
|
|
292
365
|
key: string,
|
|
293
|
-
options?:
|
|
366
|
+
options?: ConnectToServerOptions,
|
|
294
367
|
): Socket {
|
|
295
368
|
// 一次性 token 优先取 endpoint query(兼容旧注入),否则用 key(cloud-controller 场景 key=token)。
|
|
296
369
|
// token 不再放进 URL,改经 Socket.IO 握手 auth 传递,避免进入 ingress/中间层访问日志。
|
|
297
|
-
const
|
|
370
|
+
const endpointToken = extractHandshakeToken(endpoint);
|
|
371
|
+
let handshakeToken: string | undefined;
|
|
372
|
+
if (options?.handshakeToken === false) {
|
|
373
|
+
handshakeToken = undefined;
|
|
374
|
+
} else if (typeof options?.handshakeToken === 'string' && options.handshakeToken.length > 0) {
|
|
375
|
+
handshakeToken = options.handshakeToken;
|
|
376
|
+
} else {
|
|
377
|
+
handshakeToken = endpointToken ?? (key || undefined);
|
|
378
|
+
}
|
|
298
379
|
const connectEndpoint = stripTokenFromEndpoint(endpoint);
|
|
299
380
|
|
|
300
381
|
const socket = createSocketIOClient(connectEndpoint, {
|
|
@@ -307,12 +388,16 @@ export function connectToServer(
|
|
|
307
388
|
|
|
308
389
|
const heartbeat = new Heartbeat();
|
|
309
390
|
const controllerName = options?.serverName || hostname();
|
|
391
|
+
const persistConfig = options?.persistConfig !== false;
|
|
310
392
|
const mcpRelay = new McpRelay((payload) => socket.emit('mcp:request', payload));
|
|
311
|
-
const
|
|
393
|
+
const handleWorkload = options?.handleWorkload !== false;
|
|
394
|
+
// 双连接(云电脑)只启动一个本地 MCP 回环服务:worker 连接传 skipLocalMcpStart=true,
|
|
395
|
+
// controller-api 标准连接负责启动(ACP 会话的 `mcpEndpoint` 走标准连接)。
|
|
396
|
+
const localServer = options?.skipLocalMcpStart ? null : new LocalMcpServer(mcpRelay);
|
|
312
397
|
// 回环 HTTP 服务在守护进程内只启动一次、进程退出前不停止(提案 Phase 2 要求全守护进程共享一个端口)。
|
|
313
398
|
// 断线快速失败由 mcpRelay.handleDisconnect()(503)承担,无需随 socket 连接启停 HTTP 服务。
|
|
314
399
|
// ready promise 保证 acp:start/acp:resume 读取 localServer.port 前服务已 listening(启动只 await 一次)。
|
|
315
|
-
const mcpServerReady = localServer.start();
|
|
400
|
+
const mcpServerReady = localServer ? localServer.start() : Promise.resolve();
|
|
316
401
|
mcpServerReady.catch((err: Error) => {
|
|
317
402
|
console.error(`[MCP] failed to start local MCP endpoint: ${err.message}`);
|
|
318
403
|
});
|
|
@@ -323,11 +408,47 @@ export function connectToServer(
|
|
|
323
408
|
fsHandler.registerAllowedPath(entry.cwd);
|
|
324
409
|
}
|
|
325
410
|
|
|
411
|
+
// 云电脑模式(supervisor 注入 onShutdown)下,`KEY_ALREADY_ONLINE` 的主动 disconnect
|
|
412
|
+
// 会停掉 Socket.IO 自动重连,且 supervisor 无兜底 → 主机永久离线(C-3)。
|
|
413
|
+
// 这里用指数退避手动重连;正常 shutdown 时清除定时器,避免退出前再连。
|
|
414
|
+
const cloudHostMode = typeof options?.onShutdown === 'function';
|
|
415
|
+
let reconnectAttempt = 0;
|
|
416
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
417
|
+
let shutdownStarted = false;
|
|
418
|
+
|
|
419
|
+
const clearReconnectTimer = (): void => {
|
|
420
|
+
if (reconnectTimer) {
|
|
421
|
+
clearTimeout(reconnectTimer);
|
|
422
|
+
reconnectTimer = null;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const scheduleReconnect = (): void => {
|
|
427
|
+
if (!cloudHostMode || shutdownStarted) return;
|
|
428
|
+
clearReconnectTimer();
|
|
429
|
+
const delay = cloudHostReconnectDelay(reconnectAttempt);
|
|
430
|
+
reconnectAttempt += 1;
|
|
431
|
+
console.log(`Connection lost; reconnecting in ${delay}ms...`);
|
|
432
|
+
reconnectTimer = setTimeout(() => {
|
|
433
|
+
reconnectTimer = null;
|
|
434
|
+
if (!shutdownStarted) socket.connect();
|
|
435
|
+
}, delay);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/** 统一 shutdown 入口:先停重连定时器,再交给 supervisor(避免与正常 shutdown 冲突)。 */
|
|
439
|
+
const notifyShutdown = (payload: ShutdownPayload): void => {
|
|
440
|
+
shutdownStarted = true;
|
|
441
|
+
clearReconnectTimer();
|
|
442
|
+
options?.onShutdown?.(payload);
|
|
443
|
+
};
|
|
444
|
+
|
|
326
445
|
socket.on('connect', () => {
|
|
446
|
+
// 注意:不在此重置 reconnectAttempt —— KEY_ALREADY_ONLINE 在 connect 之后经 error 送达,
|
|
447
|
+
// 若此处归零会使退避永远停留在 1s(R2-6)。仅在 registered(真正注册成功)后重置。
|
|
327
448
|
// 不直接打印 endpoint:cloud-controller 场景下 endpoint 含一次性 token,避免凭据进入日志
|
|
328
449
|
const { origin, namespace } = parseEndpoint(connectEndpoint);
|
|
329
450
|
console.log(`Connected to ${origin}${namespace === '/' ? '' : namespace}`);
|
|
330
|
-
const resumeToken = getResumeTokenFromConfig();
|
|
451
|
+
const resumeToken = persistConfig ? getResumeTokenFromConfig() : undefined;
|
|
331
452
|
socket.emit('register', {
|
|
332
453
|
key,
|
|
333
454
|
hostname: controllerName,
|
|
@@ -337,16 +458,23 @@ export function connectToServer(
|
|
|
337
458
|
platform: platform(),
|
|
338
459
|
});
|
|
339
460
|
// 重连重放:将未确认的工作负载执行结果重新上报;服务端按终态幂等忽略(契约 §2.9)。
|
|
340
|
-
|
|
461
|
+
// controller-api 标准连接不处理工作负载事件(handleWorkload=false),避免结果被重放到非 worker 侧。
|
|
462
|
+
if (handleWorkload) {
|
|
463
|
+
workloadExecutor.replayPendingResults((result) => socket.emit('workload:result', result));
|
|
464
|
+
}
|
|
341
465
|
});
|
|
342
466
|
|
|
343
467
|
socket.on('registered', (data: RegisteredPayload) => {
|
|
344
468
|
console.log(`Controller registered: ${data.controllerId}`);
|
|
345
|
-
|
|
469
|
+
// R2-6:注册成功才重置退避计数(connect 事件早于 register 结果,不能作为成功信号)。
|
|
470
|
+
reconnectAttempt = 0;
|
|
471
|
+
clearReconnectTimer();
|
|
472
|
+
if (data.resumeToken && persistConfig) {
|
|
346
473
|
saveResumeTokenToConfig(data.resumeToken);
|
|
347
474
|
console.log('Resume token saved to config');
|
|
348
475
|
}
|
|
349
476
|
heartbeat.start(() => socket.emit('heartbeat'));
|
|
477
|
+
options?.onRegistered?.(data);
|
|
350
478
|
});
|
|
351
479
|
|
|
352
480
|
socket.on('error', (data: ErrorPayload) => {
|
|
@@ -355,33 +483,59 @@ export function connectToServer(
|
|
|
355
483
|
console.error('The agent-controller version is not supported by the server.');
|
|
356
484
|
console.error('Please upgrade agent-controller to 2.x (npm i -g agent-gear@2 or the matching release).');
|
|
357
485
|
socket.disconnect();
|
|
486
|
+
if (options?.onShutdown) {
|
|
487
|
+
notifyShutdown({ reason: `version_mismatch: ${data.message}` });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
358
490
|
process.exit(1);
|
|
359
491
|
return;
|
|
360
492
|
}
|
|
361
493
|
if (data.code === 'KEY_ALREADY_ONLINE') {
|
|
362
494
|
console.log('Clearing resume token and retrying connection...');
|
|
363
|
-
clearResumeTokenFromConfig();
|
|
495
|
+
if (persistConfig) clearResumeTokenFromConfig();
|
|
496
|
+
if (cloudHostMode) {
|
|
497
|
+
// 云电脑模式:disconnect() 会关闭 Socket.IO 自动重连,按指数退避手动重试(C-3)。
|
|
498
|
+
console.log('Cloud-host mode: retrying connection with backoff...');
|
|
499
|
+
socket.disconnect();
|
|
500
|
+
scheduleReconnect();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
364
503
|
// Don't exit, let the reconnection logic handle it
|
|
365
504
|
socket.disconnect();
|
|
366
505
|
return;
|
|
367
506
|
}
|
|
368
507
|
socket.disconnect();
|
|
508
|
+
if (options?.onShutdown) {
|
|
509
|
+
notifyShutdown({ reason: `error: ${data.code}` });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
369
512
|
process.exit(1);
|
|
370
513
|
});
|
|
371
514
|
|
|
372
515
|
socket.on('unregistered', () => {
|
|
373
516
|
console.log('Unregistered from server');
|
|
374
517
|
heartbeat.stop();
|
|
375
|
-
clearConfigKey();
|
|
518
|
+
if (persistConfig) clearConfigKey();
|
|
519
|
+
if (options?.onShutdown) {
|
|
520
|
+
notifyShutdown({ reason: 'unregistered' });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
376
523
|
socket.close();
|
|
377
524
|
process.exit(0);
|
|
378
525
|
});
|
|
379
526
|
|
|
380
527
|
socket.on('shutdown', (data: ShutdownPayload) => {
|
|
381
528
|
console.log(`Shutdown requested: ${data.reason}`);
|
|
529
|
+
shutdownStarted = true;
|
|
530
|
+
clearReconnectTimer();
|
|
382
531
|
const finishShutdown = (): void => {
|
|
383
532
|
heartbeat.stop();
|
|
384
|
-
|
|
533
|
+
if (options?.onShutdown) {
|
|
534
|
+
// 云电脑双连接:由 supervisor 统一关闭两端并退出(不发送 unregister)。
|
|
535
|
+
notifyShutdown(data);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (persistConfig) clearConfigKey();
|
|
385
539
|
socket.close();
|
|
386
540
|
process.exit(0);
|
|
387
541
|
};
|
|
@@ -396,15 +550,41 @@ export function connectToServer(
|
|
|
396
550
|
|
|
397
551
|
// Workload events
|
|
398
552
|
socket.on('workload:run', (data: WorkloadRunPayload) => {
|
|
553
|
+
if (!handleWorkload) return;
|
|
399
554
|
if (!data || !Array.isArray(data.items)) {
|
|
400
|
-
|
|
555
|
+
// payload 可能携带容器 env(含 secret),日志必须脱敏后再输出。
|
|
556
|
+
const secrets = collectSecretValues(process.env, data?.env);
|
|
557
|
+
console.warn(redactSecrets(`[Workload] workload:run ignored: invalid payload ${safeStringify(data)}`, secrets));
|
|
401
558
|
return;
|
|
402
559
|
}
|
|
403
560
|
const budget = typeof data.budgetMs === 'number' ? ` budgetMs=${data.budgetMs}` : '';
|
|
404
561
|
console.log(`[Workload] workload:run received: phase=${data.phase} items=${data.items.length}${budget}`);
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
562
|
+
const summary: WorkloadBatchSummary = {
|
|
563
|
+
phase: data.phase,
|
|
564
|
+
itemCount: data.items.length,
|
|
565
|
+
status: 'completed',
|
|
566
|
+
};
|
|
567
|
+
workloadExecutor
|
|
568
|
+
.runBatch(data, (result) => socket.emit('workload:result', result))
|
|
569
|
+
.then((outcome) => {
|
|
570
|
+
const status: WorkloadBatchSummary['status'] = outcome.cancelled
|
|
571
|
+
? 'cancelled'
|
|
572
|
+
: outcome.failedCount > 0
|
|
573
|
+
? 'failed'
|
|
574
|
+
: 'completed';
|
|
575
|
+
options?.onWorkloadBatchComplete?.({
|
|
576
|
+
...summary,
|
|
577
|
+
status,
|
|
578
|
+
cancelled: outcome.cancelled,
|
|
579
|
+
failedCount: outcome.failedCount,
|
|
580
|
+
});
|
|
581
|
+
})
|
|
582
|
+
.catch((error: Error) => {
|
|
583
|
+
const secrets = collectSecretValues(process.env, data.env);
|
|
584
|
+
const message = redactSecrets(error.message, secrets);
|
|
585
|
+
console.error(`[Workload] batch execution failed: ${message}`);
|
|
586
|
+
options?.onWorkloadBatchComplete?.({ ...summary, status: 'failed', error: message });
|
|
587
|
+
});
|
|
408
588
|
});
|
|
409
589
|
|
|
410
590
|
// ACP events
|
|
@@ -456,6 +636,9 @@ export function connectToServer(
|
|
|
456
636
|
if (data.mcpEndpoint) {
|
|
457
637
|
// 确保回环服务已 listening 后再读取 port 并注入(启动失败时 fail loudly,不静默注入端口 0)
|
|
458
638
|
await mcpServerReady;
|
|
639
|
+
if (!localServer) {
|
|
640
|
+
throw new Error('MCP endpoint unavailable: local MCP server is not started on this connection');
|
|
641
|
+
}
|
|
459
642
|
if (localServer.port <= 0) {
|
|
460
643
|
throw new Error(`MCP endpoint unavailable: local loopback server not listening (port=${localServer.port})`);
|
|
461
644
|
}
|
|
@@ -469,7 +652,7 @@ export function connectToServer(
|
|
|
469
652
|
cwd: data.cwd,
|
|
470
653
|
mcpServers: data.mcpServers,
|
|
471
654
|
mcpEndpoint: data.mcpEndpoint,
|
|
472
|
-
mcpPort: localServer
|
|
655
|
+
mcpPort: localServer?.port ?? 0,
|
|
473
656
|
additionalDirectories: data.additionalDirectories,
|
|
474
657
|
initialModeId: data.modeId,
|
|
475
658
|
initialModelId: data.modelId,
|
|
@@ -595,6 +778,9 @@ export function connectToServer(
|
|
|
595
778
|
if (data.mcpEndpoint) {
|
|
596
779
|
// 确保回环服务已 listening 后再读取 port 并注入(启动失败时 fail loudly,不静默注入端口 0)
|
|
597
780
|
await mcpServerReady;
|
|
781
|
+
if (!localServer) {
|
|
782
|
+
throw new Error('MCP endpoint unavailable: local MCP server is not started on this connection');
|
|
783
|
+
}
|
|
598
784
|
if (localServer.port <= 0) {
|
|
599
785
|
throw new Error(`MCP endpoint unavailable: local loopback server not listening (port=${localServer.port})`);
|
|
600
786
|
}
|
|
@@ -607,7 +793,7 @@ export function connectToServer(
|
|
|
607
793
|
sessionId: data.sessionId,
|
|
608
794
|
cwd: data.cwd,
|
|
609
795
|
mcpEndpoint: data.mcpEndpoint,
|
|
610
|
-
mcpPort: localServer
|
|
796
|
+
mcpPort: localServer?.port ?? 0,
|
|
611
797
|
additionalDirectories: data.additionalDirectories,
|
|
612
798
|
// 初始 mode/model:resume 完成后、acp:initialized 之前应用(时序同 acp:start)
|
|
613
799
|
initialModeId: data.modeId,
|