agent-gear 2.4.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 +64 -5
- 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 +24 -1
- package/dist/workload-executor.d.ts.map +1 -1
- package/dist/workload-executor.js +78 -16
- package/dist/workload-executor.js.map +1 -1
- package/dist/workload-executor.test.js +99 -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 +120 -0
- package/src/workload-executor.ts +100 -17
- package/tests/commands/harness.test.ts +90 -0
- package/tests/socketio-client.test.ts +140 -2
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, mkdir, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { Socket } from 'socket.io-client';
|
|
5
|
+
import {
|
|
6
|
+
CLOUD_HOST_MODE_KEYS,
|
|
7
|
+
DEFAULT_HARNESS_INIT_WAIT_MS,
|
|
8
|
+
DEFAULT_WORKSPACE,
|
|
9
|
+
CloudHostEnvError,
|
|
10
|
+
isCloudHostMode,
|
|
11
|
+
parseInitWaitMs,
|
|
12
|
+
readCloudHostEnv,
|
|
13
|
+
resolveShutdownExitCode,
|
|
14
|
+
startCloudHostSupervisor,
|
|
15
|
+
} from './supervisor.js';
|
|
16
|
+
import type { ConnectToServerOptions } from '../socketio-client.js';
|
|
17
|
+
|
|
18
|
+
const TMP_BASE = '/tmp/opencode';
|
|
19
|
+
|
|
20
|
+
const BASE_ENV: NodeJS.ProcessEnv = {
|
|
21
|
+
ZAG_HOST_ENDPOINT: 'wss://controller.example.com',
|
|
22
|
+
ZAG_HOST_KEY: 'zag_k_1_host_key_secret',
|
|
23
|
+
ZAG_CONTROLLER_KEY: 'controller-token-secret',
|
|
24
|
+
ZAG_SERVER_ENDPOINT: 'wss://cloud-controller.example.com/worker',
|
|
25
|
+
ZAG_HOST_NAME: 'my-cloud-host',
|
|
26
|
+
ZAG_DEFAULT_WORKSPACE: '/root/workspace',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface FakeSocket {
|
|
30
|
+
disconnect: ReturnType<typeof vi.fn>;
|
|
31
|
+
emit: ReturnType<typeof vi.fn>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fakeSocket(): FakeSocket {
|
|
35
|
+
return { disconnect: vi.fn(), emit: vi.fn() };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setup(overrides: { env?: NodeJS.ProcessEnv; makeDir?: (path: string) => Promise<void> } = {}) {
|
|
39
|
+
const worker = fakeSocket();
|
|
40
|
+
const controller = fakeSocket();
|
|
41
|
+
const connect = vi.fn((endpoint: string, key: string, options?: ConnectToServerOptions) => {
|
|
42
|
+
void endpoint;
|
|
43
|
+
void key;
|
|
44
|
+
void options;
|
|
45
|
+
return (connect.mock.calls.length === 1 ? worker : controller) as unknown as Socket;
|
|
46
|
+
});
|
|
47
|
+
const exit = vi.fn();
|
|
48
|
+
const logs: string[] = [];
|
|
49
|
+
return {
|
|
50
|
+
worker,
|
|
51
|
+
controller,
|
|
52
|
+
connect,
|
|
53
|
+
exit,
|
|
54
|
+
logs,
|
|
55
|
+
deps: {
|
|
56
|
+
env: overrides.env ?? { ...BASE_ENV },
|
|
57
|
+
connect: connect as never,
|
|
58
|
+
makeDir: overrides.makeDir ?? (async () => {}),
|
|
59
|
+
exit,
|
|
60
|
+
log: (message: string) => logs.push(message),
|
|
61
|
+
installSignalHandlers: false,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('cloud host mode detection (T-AG2)', () => {
|
|
67
|
+
it('requires all three cloud-host env variables to be non-empty', () => {
|
|
68
|
+
expect(isCloudHostMode(BASE_ENV)).toBe(true);
|
|
69
|
+
for (const key of CLOUD_HOST_MODE_KEYS) {
|
|
70
|
+
expect(isCloudHostMode({ ...BASE_ENV, [key]: '' })).toBe(false);
|
|
71
|
+
expect(isCloudHostMode({ ...BASE_ENV, [key]: ' ' })).toBe(false);
|
|
72
|
+
expect(isCloudHostMode({ ...BASE_ENV, [key]: undefined })).toBe(false);
|
|
73
|
+
}
|
|
74
|
+
// 本地控制器单连接(无 ZAG_HOST_*)不进入云电脑模式
|
|
75
|
+
expect(isCloudHostMode({
|
|
76
|
+
ZAG_SERVER_ENDPOINT: 'http://localhost:4001',
|
|
77
|
+
ZAG_CONTROLLER_KEY: 'zag_k_1_local',
|
|
78
|
+
})).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('parses HARNESS_INIT_WAIT_MS with the 10 minute default', () => {
|
|
82
|
+
expect(parseInitWaitMs(undefined)).toBe(DEFAULT_HARNESS_INIT_WAIT_MS);
|
|
83
|
+
expect(DEFAULT_HARNESS_INIT_WAIT_MS).toBe(600_000);
|
|
84
|
+
expect(parseInitWaitMs('1500')).toBe(1500);
|
|
85
|
+
expect(parseInitWaitMs('0')).toBe(DEFAULT_HARNESS_INIT_WAIT_MS);
|
|
86
|
+
expect(parseInitWaitMs('-1')).toBe(DEFAULT_HARNESS_INIT_WAIT_MS);
|
|
87
|
+
expect(parseInitWaitMs('abc')).toBe(DEFAULT_HARNESS_INIT_WAIT_MS);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('readCloudHostEnv (T-AG7)', () => {
|
|
92
|
+
it('reads required values and applies defaults', () => {
|
|
93
|
+
const env = readCloudHostEnv({
|
|
94
|
+
ZAG_HOST_ENDPOINT: ' wss://controller.example.com ',
|
|
95
|
+
ZAG_HOST_KEY: 'host-key',
|
|
96
|
+
ZAG_CONTROLLER_KEY: 'worker-token',
|
|
97
|
+
ZAG_SERVER_ENDPOINT: 'wss://cc.example.com/worker',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(env).toMatchObject({
|
|
101
|
+
hostEndpoint: 'wss://controller.example.com',
|
|
102
|
+
hostKey: 'host-key',
|
|
103
|
+
controllerKey: 'worker-token',
|
|
104
|
+
serverEndpoint: 'wss://cc.example.com/worker',
|
|
105
|
+
workspace: DEFAULT_WORKSPACE,
|
|
106
|
+
initWaitMs: DEFAULT_HARNESS_INIT_WAIT_MS,
|
|
107
|
+
});
|
|
108
|
+
expect(env.hostName.length).toBeGreaterThan(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('lists every missing variable by name without leaking values of present ones', () => {
|
|
112
|
+
let error: unknown;
|
|
113
|
+
try {
|
|
114
|
+
readCloudHostEnv({
|
|
115
|
+
ZAG_HOST_ENDPOINT: 'wss://controller.example.com',
|
|
116
|
+
ZAG_HOST_KEY: 'super-secret-host-key',
|
|
117
|
+
});
|
|
118
|
+
} catch (caught) {
|
|
119
|
+
error = caught;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
expect(error).toBeInstanceOf(CloudHostEnvError);
|
|
123
|
+
const message = (error as Error).message;
|
|
124
|
+
expect(message).toContain('ZAG_CONTROLLER_KEY');
|
|
125
|
+
expect(message).toContain('ZAG_SERVER_ENDPOINT');
|
|
126
|
+
expect(message).not.toContain('super-secret-host-key');
|
|
127
|
+
expect(message).not.toContain('wss://controller.example.com');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('startCloudHostSupervisor', () => {
|
|
132
|
+
const dirs: string[] = [];
|
|
133
|
+
|
|
134
|
+
afterEach(async () => {
|
|
135
|
+
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('T-AG6: creates the default workspace idempotently before connecting', async () => {
|
|
139
|
+
await mkdir(TMP_BASE, { recursive: true });
|
|
140
|
+
const dir = await mkdtemp(join(TMP_BASE, 'supervisor-'));
|
|
141
|
+
dirs.push(dir);
|
|
142
|
+
const workspace = join(dir, 'workspace', 'nested');
|
|
143
|
+
|
|
144
|
+
const context = setup({
|
|
145
|
+
env: { ...BASE_ENV, ZAG_DEFAULT_WORKSPACE: workspace },
|
|
146
|
+
makeDir: async (path: string) => {
|
|
147
|
+
await mkdir(path, { recursive: true });
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
const handle = await startCloudHostSupervisor(context.deps);
|
|
151
|
+
await startCloudHostSupervisor(context.deps);
|
|
152
|
+
|
|
153
|
+
expect((await stat(workspace)).isDirectory()).toBe(true);
|
|
154
|
+
expect(handle.workspace).toBe(workspace);
|
|
155
|
+
// makeDir 在连接之前完成(connect 的调用发生在其后)
|
|
156
|
+
expect(context.connect).toHaveBeenCalledTimes(2);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('T-AG3: connects /worker first with the one-time token and no local MCP server', async () => {
|
|
160
|
+
const context = setup();
|
|
161
|
+
const handle = await startCloudHostSupervisor(context.deps);
|
|
162
|
+
|
|
163
|
+
expect(context.connect).toHaveBeenCalledTimes(1);
|
|
164
|
+
const [endpoint, key, options] = context.connect.mock.calls[0] as [string, string, ConnectToServerOptions];
|
|
165
|
+
expect(endpoint).toBe(BASE_ENV.ZAG_SERVER_ENDPOINT);
|
|
166
|
+
expect(key).toBe(BASE_ENV.ZAG_CONTROLLER_KEY);
|
|
167
|
+
expect(options).toMatchObject({
|
|
168
|
+
serverName: 'my-cloud-host',
|
|
169
|
+
handshakeToken: BASE_ENV.ZAG_CONTROLLER_KEY,
|
|
170
|
+
skipLocalMcpStart: true,
|
|
171
|
+
handleWorkload: true,
|
|
172
|
+
persistConfig: false,
|
|
173
|
+
});
|
|
174
|
+
expect(handle.controllerSocket).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('T-AG3b: connects controller-api immediately when initializing is false/absent', async () => {
|
|
178
|
+
const context = setup();
|
|
179
|
+
await startCloudHostSupervisor(context.deps);
|
|
180
|
+
|
|
181
|
+
const workerOptions = context.connect.mock.calls[0][2] as ConnectToServerOptions;
|
|
182
|
+
workerOptions.onRegistered?.({ controllerId: 'container-1', initializing: false });
|
|
183
|
+
|
|
184
|
+
expect(context.connect).toHaveBeenCalledTimes(2);
|
|
185
|
+
const [endpoint, key, options] = context.connect.mock.calls[1] as [string, string, ConnectToServerOptions];
|
|
186
|
+
expect(endpoint).toBe(BASE_ENV.ZAG_HOST_ENDPOINT);
|
|
187
|
+
expect(key).toBe(BASE_ENV.ZAG_HOST_KEY);
|
|
188
|
+
expect(options).toMatchObject({
|
|
189
|
+
handshakeToken: BASE_ENV.ZAG_HOST_KEY,
|
|
190
|
+
skipLocalMcpStart: false,
|
|
191
|
+
handleWorkload: false,
|
|
192
|
+
persistConfig: false,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// 重复 registered(重连)不应重复建连
|
|
196
|
+
workerOptions.onRegistered?.({ controllerId: 'container-1' });
|
|
197
|
+
expect(context.connect).toHaveBeenCalledTimes(2);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('T-AG4: waits for the first init workload batch when initializing=true', async () => {
|
|
201
|
+
const context = setup();
|
|
202
|
+
await startCloudHostSupervisor(context.deps);
|
|
203
|
+
const workerOptions = context.connect.mock.calls[0][2] as ConnectToServerOptions;
|
|
204
|
+
|
|
205
|
+
workerOptions.onRegistered?.({ controllerId: 'container-1', initializing: true });
|
|
206
|
+
expect(context.connect).toHaveBeenCalledTimes(1);
|
|
207
|
+
|
|
208
|
+
// post 批次不触发标准连接
|
|
209
|
+
workerOptions.onWorkloadBatchComplete?.({ phase: 'post', itemCount: 1, status: 'completed' });
|
|
210
|
+
expect(context.connect).toHaveBeenCalledTimes(1);
|
|
211
|
+
|
|
212
|
+
workerOptions.onWorkloadBatchComplete?.({ phase: 'init', itemCount: 2, status: 'completed' });
|
|
213
|
+
expect(context.connect).toHaveBeenCalledTimes(2);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('T-AG4b: connects after a failed init batch too', async () => {
|
|
217
|
+
const context = setup();
|
|
218
|
+
await startCloudHostSupervisor(context.deps);
|
|
219
|
+
const workerOptions = context.connect.mock.calls[0][2] as ConnectToServerOptions;
|
|
220
|
+
|
|
221
|
+
workerOptions.onRegistered?.({ controllerId: 'container-1', initializing: true });
|
|
222
|
+
workerOptions.onWorkloadBatchComplete?.({ phase: 'init', itemCount: 1, status: 'failed', error: 'boom' });
|
|
223
|
+
|
|
224
|
+
expect(context.connect).toHaveBeenCalledTimes(2);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('T-AG4e: does not treat an interrupted (cancelled) init batch as complete', async () => {
|
|
228
|
+
const context = setup();
|
|
229
|
+
await startCloudHostSupervisor(context.deps);
|
|
230
|
+
const workerOptions = context.connect.mock.calls[0][2] as ConnectToServerOptions;
|
|
231
|
+
|
|
232
|
+
workerOptions.onRegistered?.({ controllerId: 'container-1', initializing: true });
|
|
233
|
+
workerOptions.onWorkloadBatchComplete?.({
|
|
234
|
+
phase: 'init',
|
|
235
|
+
itemCount: 1,
|
|
236
|
+
status: 'cancelled',
|
|
237
|
+
cancelled: true,
|
|
238
|
+
failedCount: 0,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(context.connect).toHaveBeenCalledTimes(1);
|
|
242
|
+
|
|
243
|
+
// 重连重放后的真实 init 完成才建立标准连接
|
|
244
|
+
workerOptions.onWorkloadBatchComplete?.({
|
|
245
|
+
phase: 'init',
|
|
246
|
+
itemCount: 1,
|
|
247
|
+
status: 'completed',
|
|
248
|
+
cancelled: false,
|
|
249
|
+
failedCount: 0,
|
|
250
|
+
});
|
|
251
|
+
expect(context.connect).toHaveBeenCalledTimes(2);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('T-AG4c: connects after the init wait timeout', async () => {
|
|
255
|
+
const context = setup({ env: { ...BASE_ENV, HARNESS_INIT_WAIT_MS: '20' } });
|
|
256
|
+
await startCloudHostSupervisor(context.deps);
|
|
257
|
+
const workerOptions = context.connect.mock.calls[0][2] as ConnectToServerOptions;
|
|
258
|
+
|
|
259
|
+
workerOptions.onRegistered?.({ controllerId: 'container-1', initializing: true });
|
|
260
|
+
expect(context.connect).toHaveBeenCalledTimes(1);
|
|
261
|
+
|
|
262
|
+
await vi.waitFor(() => {
|
|
263
|
+
expect(context.connect).toHaveBeenCalledTimes(2);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// 超时后迟到的 initializing registered 不得再次进入等待(不产生幽灵定时器/重复连接)
|
|
267
|
+
workerOptions.onRegistered?.({ controllerId: 'container-1', initializing: true });
|
|
268
|
+
expect(context.connect).toHaveBeenCalledTimes(2);
|
|
269
|
+
expect(context.logs.join('\n')).toContain('timed out');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('T-AG4d: connects immediately when the init batch finished before registered', async () => {
|
|
273
|
+
const context = setup();
|
|
274
|
+
await startCloudHostSupervisor(context.deps);
|
|
275
|
+
const workerOptions = context.connect.mock.calls[0][2] as ConnectToServerOptions;
|
|
276
|
+
|
|
277
|
+
workerOptions.onWorkloadBatchComplete?.({ phase: 'init', itemCount: 1, status: 'completed' });
|
|
278
|
+
expect(context.connect).toHaveBeenCalledTimes(1);
|
|
279
|
+
|
|
280
|
+
workerOptions.onRegistered?.({ controllerId: 'container-1', initializing: true });
|
|
281
|
+
expect(context.connect).toHaveBeenCalledTimes(2);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('T-AG17: shutdown from either side closes both sockets and exits 0 without unregister', async () => {
|
|
285
|
+
const context = setup();
|
|
286
|
+
const handle = await startCloudHostSupervisor(context.deps);
|
|
287
|
+
const workerOptions = context.connect.mock.calls[0][2] as ConnectToServerOptions;
|
|
288
|
+
workerOptions.onRegistered?.({ controllerId: 'container-1', initializing: false });
|
|
289
|
+
const controllerOptions = context.connect.mock.calls[1][2] as ConnectToServerOptions;
|
|
290
|
+
|
|
291
|
+
workerOptions.onShutdown?.({ reason: 'server requested shutdown' });
|
|
292
|
+
|
|
293
|
+
expect(context.worker.disconnect).toHaveBeenCalled();
|
|
294
|
+
expect(context.controller.disconnect).toHaveBeenCalled();
|
|
295
|
+
expect(context.exit).toHaveBeenCalledWith(0);
|
|
296
|
+
expect(context.worker.emit).not.toHaveBeenCalledWith('unregister', expect.anything());
|
|
297
|
+
expect(context.controller.emit).not.toHaveBeenCalledWith('unregister', expect.anything());
|
|
298
|
+
|
|
299
|
+
// 另一端 shutdown / 重复 shutdown 幂等
|
|
300
|
+
controllerOptions.onShutdown?.({ reason: 'duplicate' });
|
|
301
|
+
handle.shutdown('SIGTERM');
|
|
302
|
+
expect(context.exit).toHaveBeenCalledTimes(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('T-AG17b: worker shutdown before the controller connection only closes the worker', async () => {
|
|
306
|
+
const context = setup();
|
|
307
|
+
await startCloudHostSupervisor(context.deps);
|
|
308
|
+
const workerOptions = context.connect.mock.calls[0][2] as ConnectToServerOptions;
|
|
309
|
+
|
|
310
|
+
workerOptions.onShutdown?.({ reason: 'server requested shutdown' });
|
|
311
|
+
|
|
312
|
+
expect(context.worker.disconnect).toHaveBeenCalled();
|
|
313
|
+
expect(context.controller.disconnect).not.toHaveBeenCalled();
|
|
314
|
+
expect(context.exit).toHaveBeenCalledWith(0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('C-2: failure/version-mismatch shutdown exits non-zero (entrypoint self-heal path)', async () => {
|
|
318
|
+
// socketio-client 的错误事件 reason 前缀
|
|
319
|
+
expect(resolveShutdownExitCode('version_mismatch: agent-gear 版本不兼容')).toBe(1);
|
|
320
|
+
expect(resolveShutdownExitCode('error: REGISTER_FAILED')).toBe(1);
|
|
321
|
+
// 必须工作负载初始化失败(worker shutdown 包装后仍须识别)
|
|
322
|
+
expect(resolveShutdownExitCode('worker shutdown: 必须工作负载「OpenCode(Harness)」初始化失败')).toBe(1);
|
|
323
|
+
expect(resolveShutdownExitCode('required init failed')).toBe(1);
|
|
324
|
+
// 正常路径
|
|
325
|
+
expect(resolveShutdownExitCode('controller-api shutdown: server requested')).toBe(0);
|
|
326
|
+
expect(resolveShutdownExitCode('SIGINT')).toBe(0);
|
|
327
|
+
expect(resolveShutdownExitCode('unregistered')).toBe(0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('C-2: required init failure closes both sockets and exits 1', async () => {
|
|
331
|
+
const context = setup();
|
|
332
|
+
await startCloudHostSupervisor(context.deps);
|
|
333
|
+
const workerOptions = context.connect.mock.calls[0][2] as ConnectToServerOptions;
|
|
334
|
+
workerOptions.onRegistered?.({ controllerId: 'container-1', initializing: false });
|
|
335
|
+
|
|
336
|
+
workerOptions.onShutdown?.({ reason: 'worker shutdown: 必须工作负载初始化失败' });
|
|
337
|
+
|
|
338
|
+
expect(context.worker.disconnect).toHaveBeenCalled();
|
|
339
|
+
expect(context.controller.disconnect).toHaveBeenCalled();
|
|
340
|
+
expect(context.exit).toHaveBeenCalledWith(1);
|
|
341
|
+
expect(context.worker.emit).not.toHaveBeenCalledWith('unregister', expect.anything());
|
|
342
|
+
|
|
343
|
+
// 显式 code 优先(调用方可覆盖)
|
|
344
|
+
const explicit = setup();
|
|
345
|
+
const explicitHandle = await startCloudHostSupervisor(explicit.deps);
|
|
346
|
+
explicitHandle.shutdown('server requested shutdown', 1);
|
|
347
|
+
expect(explicit.exit).toHaveBeenCalledWith(1);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('C-2: version_mismatch reason exits 1', async () => {
|
|
351
|
+
const context = setup();
|
|
352
|
+
await startCloudHostSupervisor(context.deps);
|
|
353
|
+
const workerOptions = context.connect.mock.calls[0][2] as ConnectToServerOptions;
|
|
354
|
+
|
|
355
|
+
workerOptions.onShutdown?.({ reason: 'version_mismatch: agent-gear 版本不兼容' });
|
|
356
|
+
|
|
357
|
+
expect(context.exit).toHaveBeenCalledWith(1);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('T-AG7b: rejects with a clear error instead of connecting when cloud-host env is incomplete', async () => {
|
|
361
|
+
const context = setup({ env: { ...BASE_ENV, ZAG_HOST_KEY: '' } });
|
|
362
|
+
|
|
363
|
+
await expect(startCloudHostSupervisor(context.deps)).rejects.toBeInstanceOf(CloudHostEnvError);
|
|
364
|
+
expect(context.connect).not.toHaveBeenCalled();
|
|
365
|
+
expect(context.exit).not.toHaveBeenCalled();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import type { Socket } from 'socket.io-client';
|
|
4
|
+
import {
|
|
5
|
+
connectToServer,
|
|
6
|
+
parseEndpoint,
|
|
7
|
+
type ConnectToServerOptions,
|
|
8
|
+
} from '../socketio-client.js';
|
|
9
|
+
|
|
10
|
+
/** 未设置 `HARNESS_INIT_WAIT_MS` 时的初始化等待上限(10 分钟)。 */
|
|
11
|
+
export const DEFAULT_HARNESS_INIT_WAIT_MS = 600_000;
|
|
12
|
+
|
|
13
|
+
/** 未设置 `ZAG_DEFAULT_WORKSPACE` 时的默认工作区(容器内以 root 运行)。 */
|
|
14
|
+
export const DEFAULT_WORKSPACE = '/root/workspace';
|
|
15
|
+
|
|
16
|
+
/** 云电脑模式必需的环境变量(三者同时非空才进入 supervisor)。 */
|
|
17
|
+
export const CLOUD_HOST_MODE_KEYS = ['ZAG_HOST_ENDPOINT', 'ZAG_HOST_KEY', 'ZAG_CONTROLLER_KEY'] as const;
|
|
18
|
+
|
|
19
|
+
/** 云电脑 supervisor 的运行参数(从容器 env 读取)。 */
|
|
20
|
+
export interface CloudHostEnv {
|
|
21
|
+
/** controller-api 标准连接地址(env `ZAG_HOST_ENDPOINT`)。 */
|
|
22
|
+
hostEndpoint: string;
|
|
23
|
+
/** 标准连接凭据(env `ZAG_HOST_KEY`,仅经握手 auth.token 携带)。 */
|
|
24
|
+
hostKey: string;
|
|
25
|
+
/** cloud-controller `/worker` 一次性 token(env `ZAG_CONTROLLER_KEY`)。 */
|
|
26
|
+
controllerKey: string;
|
|
27
|
+
/** cloud-controller `/worker` 地址(env `ZAG_SERVER_ENDPOINT`)。 */
|
|
28
|
+
serverEndpoint: string;
|
|
29
|
+
/** 注册上报的主机名(env `ZAG_HOST_NAME`,缺省 `os.hostname()`)。 */
|
|
30
|
+
hostName: string;
|
|
31
|
+
/** 默认工作区(env `ZAG_DEFAULT_WORKSPACE`,缺省 `/root/workspace`)。 */
|
|
32
|
+
workspace: string;
|
|
33
|
+
/** 初始化等待超时(env `HARNESS_INIT_WAIT_MS`,缺省 10 分钟)。 */
|
|
34
|
+
initWaitMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 云电脑 env 校验失败(消息只含变量名,不含任何值)。 */
|
|
38
|
+
export class CloudHostEnvError extends Error {
|
|
39
|
+
constructor(message: string) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'CloudHostEnvError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nonEmpty(value: string | undefined): string {
|
|
46
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 模式检测:同时设置 `ZAG_HOST_ENDPOINT` + `ZAG_HOST_KEY` + `ZAG_CONTROLLER_KEY` 且非空
|
|
51
|
+
* → 云电脑模式;否则沿用本地控制器单连接旧路径。
|
|
52
|
+
*/
|
|
53
|
+
export function isCloudHostMode(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
54
|
+
return CLOUD_HOST_MODE_KEYS.every((key) => nonEmpty(env[key]).length > 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 解析初始化等待超时:显式非正整数时回退默认值(不因错误配置阻塞上线)。 */
|
|
58
|
+
export function parseInitWaitMs(value: string | undefined): number {
|
|
59
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
60
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HARNESS_INIT_WAIT_MS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 读取并校验云电脑模式 env。
|
|
65
|
+
*
|
|
66
|
+
* 缺失项一次性列出变量名(便于 init 日志定位),**绝不输出变量值**(含 secret)。
|
|
67
|
+
*/
|
|
68
|
+
export function readCloudHostEnv(env: NodeJS.ProcessEnv = process.env): CloudHostEnv {
|
|
69
|
+
const required = [
|
|
70
|
+
'ZAG_HOST_ENDPOINT',
|
|
71
|
+
'ZAG_HOST_KEY',
|
|
72
|
+
'ZAG_CONTROLLER_KEY',
|
|
73
|
+
'ZAG_SERVER_ENDPOINT',
|
|
74
|
+
] as const;
|
|
75
|
+
const missing = required.filter((key) => nonEmpty(env[key]).length === 0);
|
|
76
|
+
if (missing.length > 0) {
|
|
77
|
+
throw new CloudHostEnvError(
|
|
78
|
+
`missing required environment variable(s) for cloud-host mode: ${missing.join(', ')}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
hostEndpoint: nonEmpty(env.ZAG_HOST_ENDPOINT),
|
|
84
|
+
hostKey: nonEmpty(env.ZAG_HOST_KEY),
|
|
85
|
+
controllerKey: nonEmpty(env.ZAG_CONTROLLER_KEY),
|
|
86
|
+
serverEndpoint: nonEmpty(env.ZAG_SERVER_ENDPOINT),
|
|
87
|
+
hostName: nonEmpty(env.ZAG_HOST_NAME) || hostname(),
|
|
88
|
+
workspace: nonEmpty(env.ZAG_DEFAULT_WORKSPACE) || DEFAULT_WORKSPACE,
|
|
89
|
+
initWaitMs: parseInitWaitMs(env.HARNESS_INIT_WAIT_MS),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 依赖注入(测试锚定时序与副作用;生产使用默认实现)。 */
|
|
94
|
+
export interface CloudHostSupervisorDeps {
|
|
95
|
+
env?: NodeJS.ProcessEnv;
|
|
96
|
+
connect?: (endpoint: string, key: string, options?: ConnectToServerOptions) => Socket;
|
|
97
|
+
makeDir?: (path: string) => Promise<void>;
|
|
98
|
+
exit?: (code: number) => void;
|
|
99
|
+
log?: (message: string) => void;
|
|
100
|
+
/** 是否接管 SIGINT/SIGTERM(默认 true;测试关闭以免污染进程)。 */
|
|
101
|
+
installSignalHandlers?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface CloudHostSupervisorHandle {
|
|
105
|
+
/** `/worker` 连接(cloud-controller)。 */
|
|
106
|
+
workerSocket: Socket;
|
|
107
|
+
/** controller-api 标准连接(初始化完成后才建立)。 */
|
|
108
|
+
readonly controllerSocket: Socket | null;
|
|
109
|
+
/** 已创建(幂等)的默认工作区。 */
|
|
110
|
+
workspace: string;
|
|
111
|
+
/**
|
|
112
|
+
* 关闭两端并退出(不发送 `unregister`)。
|
|
113
|
+
*
|
|
114
|
+
* `code` 缺省按 `reason` 自动判定:服务端主动 shutdown / 信号 / unregistered → 0;
|
|
115
|
+
* `version_mismatch:*` / `error:*` / 初始化失败类 → 1(避免错误路径 `exit(0)` 掩盖失败,
|
|
116
|
+
* 并让 entrypoint 的 VERSION_MISMATCH 自愈生效,C-2)。
|
|
117
|
+
*/
|
|
118
|
+
shutdown: (reason: string, code?: number) => void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 错误/失败类 shutdown reason 判定(C-2):
|
|
123
|
+
*
|
|
124
|
+
* - `socketio-client` 把 `VERSION_MISMATCH` / 其它 `error` 事件转成
|
|
125
|
+
* `version_mismatch:*` / `error:<code>` 前缀;
|
|
126
|
+
* - cloud-controller 的必须工作负载失败 reason 形如「必须工作负载「x」初始化失败」
|
|
127
|
+
* (经 `worker shutdown: ...` 包装后仍需识别);
|
|
128
|
+
* - 其余(服务端主动 shutdown、`unregistered`、SIGINT/SIGTERM)视为正常退出。
|
|
129
|
+
*/
|
|
130
|
+
export function isFailureShutdownReason(reason: string): boolean {
|
|
131
|
+
const normalized = reason.trim().toLowerCase();
|
|
132
|
+
// reason 可能被 `worker shutdown:` / `controller-api shutdown:` 包装,用 includes 判定前缀。
|
|
133
|
+
if (normalized.includes('version_mismatch:') || /(^|[\s:])error:/.test(normalized)) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
return (
|
|
137
|
+
reason.includes('初始化失败') ||
|
|
138
|
+
normalized.includes('init failed') ||
|
|
139
|
+
normalized.includes('initialization failed')
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 由 shutdown reason 推导退出码:失败路径 1、正常退出 0。 */
|
|
144
|
+
export function resolveShutdownExitCode(reason: string): number {
|
|
145
|
+
return isFailureShutdownReason(reason) ? 1 : 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 日志中的连接目标:仅 origin + namespace,绝不包含 query/token。 */
|
|
149
|
+
function describeEndpoint(endpoint: string): string {
|
|
150
|
+
const { origin, namespace } = parseEndpoint(endpoint);
|
|
151
|
+
return namespace === '/' ? origin : `${origin}${namespace}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 云电脑双连接 supervisor(契约 `agent-controller-websocket.md` §4.3):
|
|
156
|
+
*
|
|
157
|
+
* 1. `mkdir -p ZAG_DEFAULT_WORKSPACE`(幂等);
|
|
158
|
+
* 2. 先连 cloud-controller `/worker`(`ZAG_SERVER_ENDPOINT` + `ZAG_CONTROLLER_KEY`);
|
|
159
|
+
* 3. `registered.initializing=true` → 等待首个 `workload:run` 批次结束(成功/失败/超时)后
|
|
160
|
+
* 再连 controller-api(`ZAG_HOST_ENDPOINT` + `ZAG_HOST_KEY`);`false`/缺省立即连接;
|
|
161
|
+
* 4. 任一端 `shutdown` → 关闭两端并退出(正常 0;`version_mismatch`/`error`/初始化失败 1);**始终不发送 `unregister`**;
|
|
162
|
+
* 5. 仅 controller-api 标准连接启动本地 MCP 回环服务(worker 连接跳过,避免端口冲突)。
|
|
163
|
+
*/
|
|
164
|
+
export async function startCloudHostSupervisor(
|
|
165
|
+
deps: CloudHostSupervisorDeps = {},
|
|
166
|
+
): Promise<CloudHostSupervisorHandle> {
|
|
167
|
+
const env = readCloudHostEnv(deps.env ?? process.env);
|
|
168
|
+
const connect = deps.connect ?? connectToServer;
|
|
169
|
+
const makeDir = deps.makeDir ?? ((path: string) => mkdir(path, { recursive: true }).then(() => undefined));
|
|
170
|
+
const exit = deps.exit ?? ((code: number) => process.exit(code));
|
|
171
|
+
const log = deps.log ?? ((message: string) => console.log(message));
|
|
172
|
+
|
|
173
|
+
// 初始化脚本/工作负载需要在工作区落地前执行,先幂等创建工作区。
|
|
174
|
+
await makeDir(env.workspace);
|
|
175
|
+
log(`[CloudHost] workspace ready: ${env.workspace}`);
|
|
176
|
+
|
|
177
|
+
let controllerSocket: Socket | null = null;
|
|
178
|
+
let initWaitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
179
|
+
let waitingForInit = false;
|
|
180
|
+
let initBatchCompleted = false;
|
|
181
|
+
let shutdownStarted = false;
|
|
182
|
+
|
|
183
|
+
const clearInitTimer = (): void => {
|
|
184
|
+
if (initWaitTimer) {
|
|
185
|
+
clearTimeout(initWaitTimer);
|
|
186
|
+
initWaitTimer = null;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const shutdown = (reason: string, code?: number): void => {
|
|
191
|
+
if (shutdownStarted) return;
|
|
192
|
+
shutdownStarted = true;
|
|
193
|
+
clearInitTimer();
|
|
194
|
+
const exitCode = code ?? resolveShutdownExitCode(reason);
|
|
195
|
+
log(`[CloudHost] shutting down: ${reason} (exit ${exitCode})`);
|
|
196
|
+
try {
|
|
197
|
+
workerSocket.disconnect();
|
|
198
|
+
} catch {
|
|
199
|
+
/* socket may already be closed */
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
controllerSocket?.disconnect();
|
|
203
|
+
} catch {
|
|
204
|
+
/* socket may already be closed */
|
|
205
|
+
}
|
|
206
|
+
// 不发送 unregister:容器销毁由 agent-api 编排(stopCloudHost / TTL / 失败回调)。
|
|
207
|
+
// 失败/版本不兼容路径必须非零退出:Job 终态映射为 failed,且 entrypoint 的自愈升级生效(C-2)。
|
|
208
|
+
exit(exitCode);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const connectController = (): void => {
|
|
212
|
+
if (controllerSocket) return;
|
|
213
|
+
clearInitTimer();
|
|
214
|
+
waitingForInit = false;
|
|
215
|
+
log(`[CloudHost] connecting controller-api: ${describeEndpoint(env.hostEndpoint)}`);
|
|
216
|
+
controllerSocket = connect(env.hostEndpoint, env.hostKey, {
|
|
217
|
+
serverName: env.hostName,
|
|
218
|
+
handshakeToken: env.hostKey,
|
|
219
|
+
handleWorkload: false,
|
|
220
|
+
skipLocalMcpStart: false,
|
|
221
|
+
persistConfig: false,
|
|
222
|
+
onShutdown: (payload) => shutdown(`controller-api shutdown: ${payload.reason}`),
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const waitForInitBatch = (): void => {
|
|
227
|
+
if (initBatchCompleted) {
|
|
228
|
+
connectController();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (waitingForInit) return;
|
|
232
|
+
waitingForInit = true;
|
|
233
|
+
log(`[CloudHost] waiting for harness initialization (timeout ${env.initWaitMs}ms)`);
|
|
234
|
+
initWaitTimer = setTimeout(() => {
|
|
235
|
+
initWaitTimer = null;
|
|
236
|
+
// 超时后视为已完成:迟到的 registered(initializing=true) 不得再次进入等待(幽灵定时器)。
|
|
237
|
+
initBatchCompleted = true;
|
|
238
|
+
log('[CloudHost] harness initialization wait timed out; connecting controller-api anyway');
|
|
239
|
+
connectController();
|
|
240
|
+
}, env.initWaitMs);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const workerSocket = connect(env.serverEndpoint, env.controllerKey, {
|
|
244
|
+
serverName: env.hostName,
|
|
245
|
+
handshakeToken: env.controllerKey,
|
|
246
|
+
handleWorkload: true,
|
|
247
|
+
skipLocalMcpStart: true,
|
|
248
|
+
persistConfig: false,
|
|
249
|
+
onRegistered: (payload) => {
|
|
250
|
+
log(`[CloudHost] worker registered: ${payload.controllerId}${payload.initializing ? ' (initializing)' : ''}`);
|
|
251
|
+
if (payload.initializing) {
|
|
252
|
+
waitForInitBatch();
|
|
253
|
+
} else {
|
|
254
|
+
connectController();
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
onWorkloadBatchComplete: (summary) => {
|
|
258
|
+
if (summary.phase !== 'init') return;
|
|
259
|
+
if (summary.cancelled || summary.status === 'cancelled') {
|
|
260
|
+
// 被新批次/销毁打断:不得视为初始化完成(重连重放 init 时 OpenCode 可能仍在安装)。
|
|
261
|
+
log('[CloudHost] harness initialization interrupted; waiting for replay');
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
initBatchCompleted = true;
|
|
265
|
+
if (waitingForInit) {
|
|
266
|
+
log(
|
|
267
|
+
`[CloudHost] harness initialization finished (${summary.status}` +
|
|
268
|
+
`${summary.failedCount ? `, failed=${summary.failedCount}` : ''}); connecting controller-api`,
|
|
269
|
+
);
|
|
270
|
+
connectController();
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
onShutdown: (payload) => shutdown(`worker shutdown: ${payload.reason}`),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (deps.installSignalHandlers !== false) {
|
|
277
|
+
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
278
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
workerSocket,
|
|
283
|
+
get controllerSocket() {
|
|
284
|
+
return controllerSocket;
|
|
285
|
+
},
|
|
286
|
+
workspace: env.workspace,
|
|
287
|
+
shutdown,
|
|
288
|
+
};
|
|
289
|
+
}
|