cofluxd 2.3.0 → 2.4.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/cofluxd.mjs +12 -35
- package/executor/host.js +933 -0
- package/executor/runner.js +380 -0
- package/package.json +14 -3
- package/service-unit.mjs +106 -0
- package/skills/coflux/SKILL.md +31 -13
package/executor/host.js
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
// ../executor/src/host.ts
|
|
2
|
+
import { fork } from "node:child_process";
|
|
3
|
+
import { closeSync, mkdirSync, openSync, readFileSync as readFileSync2, realpathSync as realpathSync2, unlinkSync, writeSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join as join2 } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
// ../executor/src/settings-cache.ts
|
|
9
|
+
import { readFileSync, statSync } from "node:fs";
|
|
10
|
+
var EMPTY_EXECUTOR_CACHE = {
|
|
11
|
+
present: false,
|
|
12
|
+
revision: 0,
|
|
13
|
+
provider: "",
|
|
14
|
+
modelId: "",
|
|
15
|
+
customProviders: [],
|
|
16
|
+
credentials: {},
|
|
17
|
+
credentialError: ""
|
|
18
|
+
};
|
|
19
|
+
function asString(value) {
|
|
20
|
+
return typeof value === "string" ? value : "";
|
|
21
|
+
}
|
|
22
|
+
function parseCustomProviders(value) {
|
|
23
|
+
if (!Array.isArray(value)) return [];
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const item of value) {
|
|
26
|
+
if (!item || typeof item !== "object") continue;
|
|
27
|
+
const record = item;
|
|
28
|
+
const id = asString(record.id);
|
|
29
|
+
if (!id) continue;
|
|
30
|
+
out.push({
|
|
31
|
+
id,
|
|
32
|
+
name: asString(record.name) || id,
|
|
33
|
+
baseUrl: asString(record.baseUrl),
|
|
34
|
+
api: asString(record.api),
|
|
35
|
+
models: Array.isArray(record.models) ? record.models.filter((model) => !!model && typeof model === "object").map((model) => ({ id: asString(model.id), name: asString(model.name) || asString(model.id) })).filter((model) => model.id.length > 0) : [],
|
|
36
|
+
authHeader: record.authHeader === true,
|
|
37
|
+
keyless: record.keyless === true
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function parseCredentials(value) {
|
|
43
|
+
if (!Array.isArray(value)) return {};
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const item of value) {
|
|
46
|
+
if (!item || typeof item !== "object") continue;
|
|
47
|
+
const record = item;
|
|
48
|
+
const providerId = asString(record.providerId);
|
|
49
|
+
const apiKey = asString(record.apiKey);
|
|
50
|
+
if (!providerId || record.type !== "api_key" || !apiKey) continue;
|
|
51
|
+
out[providerId] = apiKey;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function readExecutorSettingsCache(path) {
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = readFileSync(path, "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
return EMPTY_EXECUTOR_CACHE;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
if (!parsed || typeof parsed !== "object") return EMPTY_EXECUTOR_CACHE;
|
|
65
|
+
const record = parsed;
|
|
66
|
+
return {
|
|
67
|
+
present: true,
|
|
68
|
+
revision: typeof record.revision === "number" && Number.isFinite(record.revision) ? record.revision : 0,
|
|
69
|
+
provider: asString(record.provider),
|
|
70
|
+
modelId: asString(record.modelId),
|
|
71
|
+
customProviders: parseCustomProviders(record.customProviders),
|
|
72
|
+
credentials: parseCredentials(record.credentials),
|
|
73
|
+
credentialError: asString(record.credentialError)
|
|
74
|
+
};
|
|
75
|
+
} catch {
|
|
76
|
+
return { ...EMPTY_EXECUTOR_CACHE, present: true, credentialError: "\u672C\u673A\u7684 executor \u914D\u7F6E\u7F13\u5B58\u65E0\u6CD5\u89E3\u6790\uFF0C\u7B49\u5F85 daemon \u91CD\u65B0\u4E0B\u53D1" };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function defaultStat(path) {
|
|
80
|
+
try {
|
|
81
|
+
const stats = statSync(path);
|
|
82
|
+
return { mtimeMs: stats.mtimeMs, size: stats.size };
|
|
83
|
+
} catch {
|
|
84
|
+
return void 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function createExecutorSettingsCache(options) {
|
|
88
|
+
const read = options.read ?? readExecutorSettingsCache;
|
|
89
|
+
const stat = options.stat ?? defaultStat;
|
|
90
|
+
const pollMs = options.pollMs ?? 2e3;
|
|
91
|
+
let current = read(options.path);
|
|
92
|
+
let fingerprint = fingerprintOf(stat(options.path));
|
|
93
|
+
function fingerprintOf(stats) {
|
|
94
|
+
return stats ? `${stats.mtimeMs}:${stats.size}` : "";
|
|
95
|
+
}
|
|
96
|
+
function refresh() {
|
|
97
|
+
const next = fingerprintOf(stat(options.path));
|
|
98
|
+
if (next === fingerprint && current.present) return false;
|
|
99
|
+
fingerprint = next;
|
|
100
|
+
const settings = read(options.path);
|
|
101
|
+
const changed = JSON.stringify(settings) !== JSON.stringify(current);
|
|
102
|
+
current = settings;
|
|
103
|
+
if (changed) options.onChange?.(settings);
|
|
104
|
+
return changed;
|
|
105
|
+
}
|
|
106
|
+
const timer = setInterval(refresh, pollMs);
|
|
107
|
+
timer.unref();
|
|
108
|
+
return {
|
|
109
|
+
snapshot: () => current,
|
|
110
|
+
refresh,
|
|
111
|
+
async waitForRevision(revision, timeoutMs) {
|
|
112
|
+
const deadline = Date.now() + timeoutMs;
|
|
113
|
+
for (; ; ) {
|
|
114
|
+
refresh();
|
|
115
|
+
if (current.present && current.revision >= revision) return true;
|
|
116
|
+
if (Date.now() >= deadline) return false;
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
dispose: () => clearInterval(timer)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ../executor/src/config.ts
|
|
125
|
+
var EXECUTOR_SYSTEM_PROMPT = [
|
|
126
|
+
"You are the coflux executor: a focused sub-agent that another coding agent delegates a single, bounded task to.",
|
|
127
|
+
"",
|
|
128
|
+
"Hard boundaries, enforced by a kernel sandbox \u2014 do not fight them, work within them:",
|
|
129
|
+
"- You can only modify files inside the workspace you were started in. Paths outside it are unreadable and unwritable.",
|
|
130
|
+
"- Git metadata is read-only. You cannot commit, stage, or rewrite history. Leave the changes in the working tree; the agent that called you reviews and commits them.",
|
|
131
|
+
"- Your shell has no network access. Do not try to install dependencies or fetch anything; assume what is already present is all you get.",
|
|
132
|
+
"- Use absolute paths for every file tool call.",
|
|
133
|
+
"",
|
|
134
|
+
"How to work:",
|
|
135
|
+
"- You get one prompt and no follow-up. There is nobody to ask, so make reasonable assumptions and say what you assumed.",
|
|
136
|
+
"- Finish the whole task. If part of it is impossible under the boundaries above, do the rest and state plainly what you skipped and why.",
|
|
137
|
+
"- Your final message is the entire report the calling agent receives. Lead with what you did and what changed, then anything that needs attention. Be concise and concrete."
|
|
138
|
+
].join("\n");
|
|
139
|
+
function deriveReadiness(settings) {
|
|
140
|
+
if (settings.credentialError) {
|
|
141
|
+
return { ready: false, reason: `executor \u7684 API key \u8BFB\u4E0D\u51FA\u6765\uFF1A${settings.credentialError}\uFF08\u5728 Coflux \u8BBE\u7F6E\u9875\u7684 Executor \u5206\u533A\u91CD\u65B0\u586B\u4E00\u6B21\uFF09` };
|
|
142
|
+
}
|
|
143
|
+
if (!settings.present) {
|
|
144
|
+
return {
|
|
145
|
+
ready: false,
|
|
146
|
+
reason: "\u672C\u673A\u8FD8\u6CA1\u6536\u5230\u8D26\u53F7\u91CC\u7684 executor \u914D\u7F6E\uFF1A\u786E\u8BA4 Coflux \u5DF2\u63A5\u5165\u8FD9\u53F0\u673A\u5668\u3001daemon \u5728\u8DD1\u4E14\u662F\u6700\u65B0\u7248\u672C"
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (!settings.provider || !settings.modelId) {
|
|
150
|
+
return { ready: false, reason: "\u8D26\u53F7\u91CC\u8FD8\u6CA1\u914D executor \u7684 provider \u4E0E\u6A21\u578B\uFF1A\u5728 Coflux \u8BBE\u7F6E\u9875\u7684 Executor \u5206\u533A\u9009\u597D\u518D\u53D1" };
|
|
151
|
+
}
|
|
152
|
+
const custom = settings.customProviders.find((provider) => provider.id === settings.provider);
|
|
153
|
+
if (!custom?.keyless && !settings.credentials[settings.provider]) {
|
|
154
|
+
return { ready: false, reason: `\u8D26\u53F7\u91CC ${settings.provider} \u7684 API key \u8FD8\u6CA1\u586B\uFF1A\u5728 Coflux \u8BBE\u7F6E\u9875\u7684 Executor \u5206\u533A\u586B\u597D\u518D\u53D1` };
|
|
155
|
+
}
|
|
156
|
+
return { ready: true, reason: "" };
|
|
157
|
+
}
|
|
158
|
+
function toExecutorView(settings) {
|
|
159
|
+
const credentialProviders = Object.keys(settings.credentials).sort();
|
|
160
|
+
return {
|
|
161
|
+
provider: settings.provider,
|
|
162
|
+
modelId: settings.modelId,
|
|
163
|
+
hasApiKey: settings.provider ? credentialProviders.includes(settings.provider) : credentialProviders.length > 0,
|
|
164
|
+
present: settings.present,
|
|
165
|
+
credentialError: settings.credentialError,
|
|
166
|
+
customProviders: settings.customProviders,
|
|
167
|
+
credentialProviders,
|
|
168
|
+
revision: settings.revision,
|
|
169
|
+
...deriveReadiness(settings)
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function createExecutorConfigStore(options) {
|
|
173
|
+
const cache = createExecutorSettingsCache({
|
|
174
|
+
path: options.cachePath,
|
|
175
|
+
pollMs: options.pollMs,
|
|
176
|
+
onChange: (settings) => options.onChange?.(toExecutorView(settings))
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
view: () => toExecutorView(cache.snapshot()),
|
|
180
|
+
secrets() {
|
|
181
|
+
const settings = cache.snapshot();
|
|
182
|
+
const custom = settings.customProviders.find((provider) => provider.id === settings.provider);
|
|
183
|
+
return {
|
|
184
|
+
provider: settings.provider,
|
|
185
|
+
modelId: settings.modelId,
|
|
186
|
+
apiKey: settings.credentials[settings.provider] ?? "",
|
|
187
|
+
customProviders: settings.customProviders,
|
|
188
|
+
keyless: custom?.keyless === true
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
cached: () => cache.snapshot(),
|
|
192
|
+
refresh: () => cache.refresh(),
|
|
193
|
+
waitForRevision: (revision, timeoutMs) => cache.waitForRevision(revision, timeoutMs),
|
|
194
|
+
dispose: () => cache.dispose()
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ../executor/src/host-core.ts
|
|
199
|
+
import { randomUUID } from "node:crypto";
|
|
200
|
+
|
|
201
|
+
// ../executor/src/capability.ts
|
|
202
|
+
var EXECUTOR_HOST_CAPABILITY = "executor_host_v1";
|
|
203
|
+
var EXECUTOR_HOST_CAPABILITIES = [EXECUTOR_HOST_CAPABILITY];
|
|
204
|
+
|
|
205
|
+
// ../executor/src/manager.ts
|
|
206
|
+
import { execFileSync } from "node:child_process";
|
|
207
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
208
|
+
import { join } from "node:path";
|
|
209
|
+
import { tmpdir } from "node:os";
|
|
210
|
+
|
|
211
|
+
// ../executor/src/jobs.ts
|
|
212
|
+
var TERMINAL_STATES = /* @__PURE__ */ new Set([
|
|
213
|
+
"succeeded",
|
|
214
|
+
"rejected",
|
|
215
|
+
"model_error",
|
|
216
|
+
"tool_failed",
|
|
217
|
+
"cancelled",
|
|
218
|
+
"unknown"
|
|
219
|
+
]);
|
|
220
|
+
function isTerminal(state) {
|
|
221
|
+
return TERMINAL_STATES.has(state);
|
|
222
|
+
}
|
|
223
|
+
var DEFAULT_EXECUTOR_LIMITS = { maxConcurrent: 3 };
|
|
224
|
+
var ExecutorJobTable = class {
|
|
225
|
+
constructor(limits = DEFAULT_EXECUTOR_LIMITS, readiness = { ready: false, reason: "\u5C1A\u672A\u914D\u7F6E provider \u4E0E\u6A21\u578B" }) {
|
|
226
|
+
this.limits = limits;
|
|
227
|
+
this.readiness = readiness;
|
|
228
|
+
}
|
|
229
|
+
jobs = /* @__PURE__ */ new Map();
|
|
230
|
+
setReadiness(readiness) {
|
|
231
|
+
this.readiness = readiness;
|
|
232
|
+
}
|
|
233
|
+
get(runId) {
|
|
234
|
+
return this.jobs.get(runId);
|
|
235
|
+
}
|
|
236
|
+
all() {
|
|
237
|
+
return [...this.jobs.values()];
|
|
238
|
+
}
|
|
239
|
+
/** The run ids still in flight (accepted / running), for reconnect reconciliation to re-report. */
|
|
240
|
+
activeRunIds() {
|
|
241
|
+
return this.all().filter((job) => !isTerminal(job.state)).map((job) => job.assignment.runId);
|
|
242
|
+
}
|
|
243
|
+
activeCount() {
|
|
244
|
+
return this.all().filter((job) => !isTerminal(job.state)).length;
|
|
245
|
+
}
|
|
246
|
+
writerOf(workspaceId) {
|
|
247
|
+
return this.all().find(
|
|
248
|
+
(job) => job.assignment.write && job.assignment.workspaceId === workspaceId && !isTerminal(job.state)
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Take an assignment. **Idempotent**: the same runId arriving again only re-emits the effect for
|
|
253
|
+
* the current state and never starts a second process — this is what the daemon relies on when a
|
|
254
|
+
* submission times out and is re-pushed.
|
|
255
|
+
*/
|
|
256
|
+
assign(assignment) {
|
|
257
|
+
const existing = this.jobs.get(assignment.runId);
|
|
258
|
+
if (existing) {
|
|
259
|
+
return [{ kind: "report", runId: existing.assignment.runId, state: existing.state, note: existing.note, outcome: existing.outcome }];
|
|
260
|
+
}
|
|
261
|
+
const rejection = this.rejectionFor(assignment);
|
|
262
|
+
if (rejection) {
|
|
263
|
+
const job2 = {
|
|
264
|
+
assignment,
|
|
265
|
+
state: "rejected",
|
|
266
|
+
note: rejection,
|
|
267
|
+
outcome: null,
|
|
268
|
+
reportPending: true,
|
|
269
|
+
stopRequested: false
|
|
270
|
+
};
|
|
271
|
+
this.jobs.set(assignment.runId, job2);
|
|
272
|
+
return [{ kind: "report", runId: assignment.runId, state: "rejected", note: rejection, outcome: null }];
|
|
273
|
+
}
|
|
274
|
+
const job = {
|
|
275
|
+
assignment,
|
|
276
|
+
state: "accepted",
|
|
277
|
+
note: "",
|
|
278
|
+
outcome: null,
|
|
279
|
+
reportPending: false,
|
|
280
|
+
stopRequested: false
|
|
281
|
+
};
|
|
282
|
+
this.jobs.set(assignment.runId, job);
|
|
283
|
+
return [
|
|
284
|
+
{ kind: "report", runId: assignment.runId, state: "accepted", note: "", outcome: null },
|
|
285
|
+
{ kind: "start", assignment }
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
/** The reason for refusing on the spot; null means it can be taken. The wording is read by the
|
|
289
|
+
* calling agent, so it has to say what that agent can do next. */
|
|
290
|
+
rejectionFor(assignment) {
|
|
291
|
+
if (!this.readiness.ready) {
|
|
292
|
+
return this.readiness.reason || "\u684C\u9762 app \u5C1A\u672A\u914D\u7F6E executor \u7684 provider \u4E0E\u6A21\u578B";
|
|
293
|
+
}
|
|
294
|
+
if (assignment.write) {
|
|
295
|
+
const writer = this.writerOf(assignment.workspaceId);
|
|
296
|
+
if (writer) {
|
|
297
|
+
return `\u8BE5\u5DE5\u4F5C\u533A\u5DF2\u6709\u4E00\u4E2A\u5199\u6A21\u5F0F executor \u4EFB\u52A1\u5728\u8DD1\uFF08${writer.assignment.runId}\uFF09\uFF1B\u5199\u6A21\u5F0F\u540C\u4E00\u5DE5\u4F5C\u533A\u53EA\u80FD\u6709\u4E00\u4E2A\uFF0C\u7B49\u5B83\u7ED3\u675F\u518D\u53D1`;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (this.activeCount() >= this.limits.maxConcurrent) {
|
|
301
|
+
return `\u672C\u673A\u540C\u65F6\u5728\u8DD1\u7684 executor \u4EFB\u52A1\u5DF2\u8FBE\u4E0A\u9650 ${this.limits.maxConcurrent}\uFF0C\u7B49\u4E00\u4E2A\u7ED3\u675F\u518D\u53D1`;
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
/** The runner really started. */
|
|
306
|
+
markRunning(runId, note = "") {
|
|
307
|
+
const job = this.jobs.get(runId);
|
|
308
|
+
if (!job || isTerminal(job.state)) return [];
|
|
309
|
+
if (job.state === "running" && job.note === note) return [];
|
|
310
|
+
job.state = "running";
|
|
311
|
+
job.note = note;
|
|
312
|
+
return [{ kind: "report", runId, state: "running", note, outcome: null }];
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Record a terminal state. The caller must call this **only after confirming the tool's child
|
|
316
|
+
* processes really stopped** — the write lock holds the next write request off by virtue of this
|
|
317
|
+
* run not being finished, so finishing early releases the lock early and lets a second writer in.
|
|
318
|
+
*/
|
|
319
|
+
finish(runId, outcome) {
|
|
320
|
+
const job = this.jobs.get(runId);
|
|
321
|
+
if (!job || isTerminal(job.state)) return [];
|
|
322
|
+
job.state = outcome.state;
|
|
323
|
+
job.outcome = outcome;
|
|
324
|
+
job.note = outcome.error ?? job.note;
|
|
325
|
+
job.reportPending = true;
|
|
326
|
+
return [{ kind: "report", runId, state: outcome.state, note: job.note, outcome }];
|
|
327
|
+
}
|
|
328
|
+
/** Cancel is **idempotent**: cancelling a finished run does nothing, and repeated cancels never
|
|
329
|
+
* emit a second stop. */
|
|
330
|
+
cancel(runId) {
|
|
331
|
+
const job = this.jobs.get(runId);
|
|
332
|
+
if (!job || isTerminal(job.state) || job.stopRequested) return [];
|
|
333
|
+
job.stopRequested = true;
|
|
334
|
+
job.note = "\u6B63\u5728\u505C\u6B62";
|
|
335
|
+
return [{ kind: "stop", runId }];
|
|
336
|
+
}
|
|
337
|
+
/** The daemon confirmed it received the terminal state; the local copy can go. */
|
|
338
|
+
ack(runId) {
|
|
339
|
+
const job = this.jobs.get(runId);
|
|
340
|
+
if (!job) return;
|
|
341
|
+
job.reportPending = false;
|
|
342
|
+
if (isTerminal(job.state)) this.jobs.delete(runId);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Reconnect reconciliation: the daemon hands over its list of unfinished runs and we re-report
|
|
346
|
+
* each one. Runs we recognize and that are still going are reported as they are; **anything we do
|
|
347
|
+
* not recognize is unknown** — this host instance has no way to know how far that run got, so the
|
|
348
|
+
* daemon records unknown and leaves the judgement to a person. Never re-run automatically.
|
|
349
|
+
*/
|
|
350
|
+
reconcile(daemonRunIds) {
|
|
351
|
+
const effects = [];
|
|
352
|
+
for (const runId of daemonRunIds) {
|
|
353
|
+
const job = this.jobs.get(runId);
|
|
354
|
+
if (job) {
|
|
355
|
+
effects.push({ kind: "report", runId, state: job.state, note: job.note, outcome: job.outcome });
|
|
356
|
+
} else {
|
|
357
|
+
effects.push({
|
|
358
|
+
kind: "report",
|
|
359
|
+
runId,
|
|
360
|
+
state: "unknown",
|
|
361
|
+
note: "\u684C\u9762 app \u4E0D\u8BA4\u8BC6\u8FD9\u6761\u4EFB\u52A1\uFF08app \u91CD\u542F\u6216\u5B9E\u4F8B\u66F4\u6362\uFF09\uFF1B\u5B83\u662F\u5426\u5199\u8FC7\u6587\u4EF6\u65E0\u6CD5\u5224\u65AD",
|
|
362
|
+
outcome: { state: "unknown", error: "host \u4E0D\u8BA4\u8BC6\u8BE5 run" }
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const job of this.all()) {
|
|
367
|
+
if (job.reportPending && !daemonRunIds.includes(job.assignment.runId)) {
|
|
368
|
+
effects.push({
|
|
369
|
+
kind: "report",
|
|
370
|
+
runId: job.assignment.runId,
|
|
371
|
+
state: job.state,
|
|
372
|
+
note: job.note,
|
|
373
|
+
outcome: job.outcome
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return effects;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The local runtime is going away: every unfinished run becomes `cancelled` and is asked to stop.
|
|
381
|
+
*
|
|
382
|
+
* "The runtime stopped, so the job stopped" is an accepted product constraint, but a **definite
|
|
383
|
+
* terminal state is not optional** — without one the CLI on the other end polls forever. The
|
|
384
|
+
* caller passes the sentence saying which user action ended the run (see `executor-lifecycle`).
|
|
385
|
+
*/
|
|
386
|
+
cancelAll(error) {
|
|
387
|
+
const effects = [];
|
|
388
|
+
for (const job of this.all()) {
|
|
389
|
+
if (isTerminal(job.state)) continue;
|
|
390
|
+
effects.push({ kind: "stop", runId: job.assignment.runId });
|
|
391
|
+
effects.push(...this.finish(job.assignment.runId, { state: "cancelled", error }));
|
|
392
|
+
}
|
|
393
|
+
return effects;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// ../executor/src/sandbox.ts
|
|
398
|
+
var WRITABLE_DEVICES = ["/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty", "/dev/dtracehelper"];
|
|
399
|
+
function assertProfilePath(label, path) {
|
|
400
|
+
if (!path || !path.startsWith("/")) {
|
|
401
|
+
throw new Error(`executor \u6C99\u7BB1\uFF1A${label} \u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\uFF0C\u6536\u5230 ${JSON.stringify(path)}`);
|
|
402
|
+
}
|
|
403
|
+
if (/["\\\n\r]/.test(path)) {
|
|
404
|
+
throw new Error(`executor \u6C99\u7BB1\uFF1A${label} \u542B\u5F15\u53F7\u6216\u6362\u884C\uFF0C\u62D2\u7EDD\u5199\u8FDB profile\uFF1A${JSON.stringify(path)}`);
|
|
405
|
+
}
|
|
406
|
+
if (path !== "/" && path.endsWith("/")) {
|
|
407
|
+
throw new Error(`executor \u6C99\u7BB1\uFF1A${label} \u4E0D\u5E94\u4EE5\u659C\u6760\u7ED3\u5C3E\uFF08subpath \u524D\u7F00\u8BED\u4E49\u4F1A\u53D8\uFF09\uFF0C\u6536\u5230 ${JSON.stringify(path)}`);
|
|
408
|
+
}
|
|
409
|
+
if (path.split("/").includes("..") || path.split("/").includes(".")) {
|
|
410
|
+
throw new Error(`executor \u6C99\u7BB1\uFF1A${label} \u5FC5\u987B\u662F realpath \u89E3\u6790\u540E\u7684\u8DEF\u5F84\uFF0C\u4E0D\u80FD\u542B . \u6216 ..\uFF1A${JSON.stringify(path)}`);
|
|
411
|
+
}
|
|
412
|
+
if (/^\/(var|tmp|etc)(\/|$)/.test(path)) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
`executor \u6C99\u7BB1\uFF1A${label} \u770B\u8D77\u6765\u672A\u7ECF realpath \u89E3\u6790\uFF08${path}\uFF09\u2014\u2014macOS \u4E0A /var /tmp /etc \u90FD\u662F\u6307\u5411 /private \u7684\u7B26\u53F7\u94FE\u63A5\uFF0C\u76F4\u63A5\u5199\u8FDB profile \u89C4\u5219\u4F1A\u9759\u9ED8\u5931\u6548`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function subpath(rule, path) {
|
|
419
|
+
return `(${rule} (subpath "${path}"))`;
|
|
420
|
+
}
|
|
421
|
+
function buildSandboxProfile(input) {
|
|
422
|
+
assertProfilePath("\u5DE5\u4F5C\u533A\u6839", input.workspaceRoot);
|
|
423
|
+
assertProfilePath("scratch \u76EE\u5F55", input.scratchDir);
|
|
424
|
+
for (const path of input.nestedWorktrees) assertProfilePath("\u5D4C\u5957 worktree", path);
|
|
425
|
+
for (const path of input.gitDirs) assertProfilePath("git \u76EE\u5F55", path);
|
|
426
|
+
const lines = [
|
|
427
|
+
"(version 1)",
|
|
428
|
+
";; coflux executor tool sandbox. Tier = against mistakes, not adversaries: Mach/XPC and Apple Events are not closed off.",
|
|
429
|
+
"(allow default)",
|
|
430
|
+
"",
|
|
431
|
+
";; Network: one blanket denial. It blocks loopback TCP and unix sockets alike (measured), so a tool",
|
|
432
|
+
";; process cannot call back into the daemon's /agent and have the unconstrained daemon run commands",
|
|
433
|
+
";; for it via terminal.new. Model calls happen in the runner, outside the sandbox, and are unaffected.",
|
|
434
|
+
"(deny network*)",
|
|
435
|
+
"",
|
|
436
|
+
";; File writes: deny everything by default, then allow back item by item.",
|
|
437
|
+
"(deny file-write*)"
|
|
438
|
+
];
|
|
439
|
+
if (input.writable) {
|
|
440
|
+
lines.push("", ";; Writable mode: allow only the workspace that started the task.", subpath("allow file-write*", input.workspaceRoot));
|
|
441
|
+
} else {
|
|
442
|
+
lines.push("", ";; Read-only mode: even the workspace is not writable; the executor may only look and run.");
|
|
443
|
+
}
|
|
444
|
+
lines.push(
|
|
445
|
+
"",
|
|
446
|
+
";; The task's private scratch (TMPDIR points at it). Not all of /tmp, which holds other tasks and shared sockets.",
|
|
447
|
+
subpath("allow file-write*", input.scratchDir),
|
|
448
|
+
"",
|
|
449
|
+
";; The device nodes shell redirection needs.",
|
|
450
|
+
`(allow file-write* ${WRITABLE_DEVICES.map((device) => `(literal "${device}")`).join(" ")})`
|
|
451
|
+
);
|
|
452
|
+
if (input.gitDirs.length > 0) {
|
|
453
|
+
lines.push(
|
|
454
|
+
"",
|
|
455
|
+
";; Git metadata is read-only (v1 does not commit). The carve-out only works after the allow,",
|
|
456
|
+
";; because the main workspace's .git sits inside the workspace root. It also closes the chain of",
|
|
457
|
+
";; persisted execution entry points: hooks, worktree-level config, the rebase sequencer's exec,",
|
|
458
|
+
";; and submodule config.",
|
|
459
|
+
...input.gitDirs.map((path) => subpath("deny file-write*", path))
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
if (input.nestedWorktrees.length > 0) {
|
|
463
|
+
lines.push(
|
|
464
|
+
"",
|
|
465
|
+
";; Other registered worktrees nested inside: someone else's unmerged work must not become",
|
|
466
|
+
";; writable just because it happens to live under this workspace's directory.",
|
|
467
|
+
...input.nestedWorktrees.map((path) => subpath("deny file-write*", path))
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return `${lines.join("\n")}
|
|
471
|
+
`;
|
|
472
|
+
}
|
|
473
|
+
function otherWorktreePaths(porcelain, selfRoot) {
|
|
474
|
+
return porcelain.split("\n").filter((line) => line.startsWith("worktree ")).map((line) => line.slice("worktree ".length).trim()).filter((path) => path.length > 0 && path !== selfRoot);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ../executor/src/workspace.ts
|
|
478
|
+
function collectWorkspaceFacts(root, runGit) {
|
|
479
|
+
const gitDir = readGitPath(runGit, root, "--git-dir");
|
|
480
|
+
const commonDir = readGitPath(runGit, root, "--git-common-dir");
|
|
481
|
+
const listed = runGit(["worktree", "list", "--porcelain"], root);
|
|
482
|
+
const gitDirs = [...new Set([gitDir, commonDir].filter((path) => Boolean(path)))];
|
|
483
|
+
const otherWorktrees = listed.ok ? otherWorktreePaths(listed.stdout, root) : [];
|
|
484
|
+
return { root, gitDirs, otherWorktrees: [...new Set(otherWorktrees)] };
|
|
485
|
+
}
|
|
486
|
+
function readGitPath(runGit, cwd, flag) {
|
|
487
|
+
const result = runGit(["rev-parse", "--path-format=absolute", flag], cwd);
|
|
488
|
+
if (!result.ok) return null;
|
|
489
|
+
const value = result.stdout.trim();
|
|
490
|
+
return value.length > 0 ? value : null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ../executor/src/manager.ts
|
|
494
|
+
var DEFAULT_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
495
|
+
var defaultRunGit = (args, cwd) => {
|
|
496
|
+
try {
|
|
497
|
+
return { stdout: execFileSync("git", [...args], { cwd, encoding: "utf8", timeout: 1e4 }), ok: true };
|
|
498
|
+
} catch {
|
|
499
|
+
return { stdout: "", ok: false };
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
var ExecutorManager = class {
|
|
503
|
+
constructor(deps) {
|
|
504
|
+
this.deps = deps;
|
|
505
|
+
this.runGit = deps.runGit ?? defaultRunGit;
|
|
506
|
+
const config = deps.config();
|
|
507
|
+
this.table = new ExecutorJobTable(void 0, { ready: config.ready, reason: config.reason });
|
|
508
|
+
}
|
|
509
|
+
table;
|
|
510
|
+
live = /* @__PURE__ */ new Map();
|
|
511
|
+
runGit;
|
|
512
|
+
/** The configuration changed (the user just set a provider/model): the job table's admission
|
|
513
|
+
* criteria have to follow. */
|
|
514
|
+
refreshReadiness() {
|
|
515
|
+
const config = this.deps.config();
|
|
516
|
+
this.table.setReadiness({ ready: config.ready, reason: config.reason });
|
|
517
|
+
}
|
|
518
|
+
activeRunIds() {
|
|
519
|
+
return this.table.activeRunIds();
|
|
520
|
+
}
|
|
521
|
+
onAssign(assignment) {
|
|
522
|
+
this.apply(this.table.assign(assignment));
|
|
523
|
+
}
|
|
524
|
+
onCancel(runId) {
|
|
525
|
+
this.apply(this.table.cancel(runId));
|
|
526
|
+
}
|
|
527
|
+
onAck(runId) {
|
|
528
|
+
this.table.ack(runId);
|
|
529
|
+
}
|
|
530
|
+
onReconcile(daemonRunIds) {
|
|
531
|
+
this.apply(this.table.reconcile(daemonRunIds));
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* The local runtime is going away: every unfinished run reaches a definite terminal state and
|
|
535
|
+
* every tool process group is stopped, so the write lock is released and no CLI polls forever.
|
|
536
|
+
* Idempotent — a second call finds nothing unfinished and nothing live.
|
|
537
|
+
*/
|
|
538
|
+
cancelAll(error) {
|
|
539
|
+
this.apply(this.table.cancelAll(error));
|
|
540
|
+
for (const [, run] of this.live) run.handle.kill();
|
|
541
|
+
this.live.clear();
|
|
542
|
+
}
|
|
543
|
+
log(message) {
|
|
544
|
+
this.deps.log?.(`[executor] ${message}`);
|
|
545
|
+
}
|
|
546
|
+
apply(effects) {
|
|
547
|
+
for (const effect of effects) {
|
|
548
|
+
if (effect.kind === "report") {
|
|
549
|
+
this.deps.sendReport({
|
|
550
|
+
runId: effect.runId,
|
|
551
|
+
state: effect.state,
|
|
552
|
+
note: effect.note,
|
|
553
|
+
summary: effect.outcome?.summary,
|
|
554
|
+
changedFiles: effect.outcome?.changedFiles,
|
|
555
|
+
error: effect.outcome?.error
|
|
556
|
+
});
|
|
557
|
+
} else if (effect.kind === "start") {
|
|
558
|
+
this.start(effect.assignment);
|
|
559
|
+
} else {
|
|
560
|
+
this.stop(effect.runId);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
start(assignment) {
|
|
565
|
+
let scratchDir = "";
|
|
566
|
+
try {
|
|
567
|
+
const config = this.deps.config();
|
|
568
|
+
const root = realpathSync(assignment.workspaceRoot);
|
|
569
|
+
scratchDir = realpathSync(mkdtempSync(join(tmpdir(), "coflux-executor-")));
|
|
570
|
+
const facts = collectWorkspaceFacts(root, this.runGit);
|
|
571
|
+
const profile = buildSandboxProfile({
|
|
572
|
+
workspaceRoot: root,
|
|
573
|
+
writable: assignment.write,
|
|
574
|
+
nestedWorktrees: facts.otherWorktrees,
|
|
575
|
+
gitDirs: facts.gitDirs,
|
|
576
|
+
scratchDir
|
|
577
|
+
});
|
|
578
|
+
const profilePath = join(scratchDir, "sandbox.sb");
|
|
579
|
+
writeFileSync(profilePath, profile, { mode: 384 });
|
|
580
|
+
const handle = this.deps.spawnRunner();
|
|
581
|
+
this.live.set(assignment.runId, { handle, scratchDir, settled: false });
|
|
582
|
+
handle.on("message", (message) => this.onRunnerMessage(assignment.runId, message));
|
|
583
|
+
handle.on("exit", (code) => this.onRunnerExit(assignment.runId, code));
|
|
584
|
+
const start = {
|
|
585
|
+
type: "start",
|
|
586
|
+
runId: assignment.runId,
|
|
587
|
+
prompt: assignment.prompt,
|
|
588
|
+
write: assignment.write,
|
|
589
|
+
workspaceRoot: root,
|
|
590
|
+
scratchDir,
|
|
591
|
+
sandboxProfilePath: profilePath,
|
|
592
|
+
shell: config.shell,
|
|
593
|
+
systemPrompt: config.systemPrompt,
|
|
594
|
+
model: { provider: config.provider, id: config.modelId },
|
|
595
|
+
customProviders: config.customProviders,
|
|
596
|
+
apiKey: config.apiKey,
|
|
597
|
+
timeoutMs: DEFAULT_TIMEOUT_MS
|
|
598
|
+
};
|
|
599
|
+
handle.postMessage(start);
|
|
600
|
+
this.log(`run ${assignment.runId} \u5DF2\u8D77\uFF08${assignment.write ? "\u53EF\u5199" : "\u53EA\u8BFB"}\uFF0C${root}\uFF09`);
|
|
601
|
+
} catch (error) {
|
|
602
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
603
|
+
this.log(`run ${assignment.runId} \u542F\u52A8\u5931\u8D25\uFF1A${text}`);
|
|
604
|
+
if (scratchDir) this.cleanScratch(scratchDir);
|
|
605
|
+
this.live.delete(assignment.runId);
|
|
606
|
+
this.apply(this.table.finish(assignment.runId, { state: "tool_failed", error: `executor \u542F\u52A8\u5931\u8D25\uFF1A${text}` }));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
stop(runId) {
|
|
610
|
+
const run = this.live.get(runId);
|
|
611
|
+
if (!run) return;
|
|
612
|
+
run.handle.postMessage({ type: "abort" });
|
|
613
|
+
setTimeout(() => {
|
|
614
|
+
if (this.live.has(runId)) {
|
|
615
|
+
this.log(`run ${runId} \u672A\u5728\u5BBD\u9650\u671F\u5185\u81EA\u884C\u505C\u6B62\uFF0C\u5F3A\u6740 runner`);
|
|
616
|
+
run.handle.kill();
|
|
617
|
+
}
|
|
618
|
+
}, 8e3);
|
|
619
|
+
}
|
|
620
|
+
onRunnerMessage(runId, message) {
|
|
621
|
+
if (message.type === "running") {
|
|
622
|
+
this.apply(this.table.markRunning(runId));
|
|
623
|
+
} else if (message.type === "progress") {
|
|
624
|
+
this.apply(this.table.markRunning(runId, message.note));
|
|
625
|
+
} else if (message.type === "done") {
|
|
626
|
+
const outcome = {
|
|
627
|
+
state: message.outcome,
|
|
628
|
+
summary: message.summary,
|
|
629
|
+
changedFiles: message.changedFiles,
|
|
630
|
+
error: message.error
|
|
631
|
+
};
|
|
632
|
+
const run = this.live.get(runId);
|
|
633
|
+
if (run) run.settled = true;
|
|
634
|
+
this.apply(this.table.finish(runId, outcome));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* The runner process is gone. Fill in a terminal state **only if one was not recorded already** —
|
|
639
|
+
* otherwise a run that already succeeded would be overwritten as a failure. What gets filled in is
|
|
640
|
+
* `unknown`, not `tool_failed`: when a process vanishes unexpectedly, we do not know how far it got.
|
|
641
|
+
*/
|
|
642
|
+
onRunnerExit(runId, code) {
|
|
643
|
+
const run = this.live.get(runId);
|
|
644
|
+
this.live.delete(runId);
|
|
645
|
+
if (run) this.cleanScratch(run.scratchDir);
|
|
646
|
+
if (!run || run.settled) return;
|
|
647
|
+
this.log(`run ${runId} \u7684 runner \u5F02\u5E38\u9000\u51FA\uFF08code=${code}\uFF09\uFF0C\u5224 unknown`);
|
|
648
|
+
this.apply(
|
|
649
|
+
this.table.finish(runId, {
|
|
650
|
+
state: "unknown",
|
|
651
|
+
error: `executor \u8FDB\u7A0B\u5F02\u5E38\u9000\u51FA\uFF08code=${code}\uFF09\uFF1B\u5B83\u662F\u5426\u5DF2\u7ECF\u6539\u8FC7\u6587\u4EF6\u65E0\u6CD5\u5224\u65AD`
|
|
652
|
+
})
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
/** Delete only the directory mkdtemp handed back, and only when the string is non-empty. */
|
|
656
|
+
cleanScratch(scratchDir) {
|
|
657
|
+
if (!scratchDir || !scratchDir.includes("coflux-executor-")) return;
|
|
658
|
+
try {
|
|
659
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
660
|
+
} catch {
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// ../executor/src/host-core.ts
|
|
666
|
+
function createExecutorHostCore(options) {
|
|
667
|
+
const hostId = randomUUID();
|
|
668
|
+
let accepted = false;
|
|
669
|
+
const manager = new ExecutorManager({
|
|
670
|
+
spawnRunner: options.spawnRunner,
|
|
671
|
+
config: () => {
|
|
672
|
+
const view = options.config.view();
|
|
673
|
+
const secrets = options.config.secrets();
|
|
674
|
+
return {
|
|
675
|
+
ready: view.ready,
|
|
676
|
+
reason: view.reason,
|
|
677
|
+
provider: secrets.provider,
|
|
678
|
+
modelId: secrets.modelId,
|
|
679
|
+
apiKey: secrets.apiKey,
|
|
680
|
+
customProviders: secrets.customProviders,
|
|
681
|
+
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
|
|
682
|
+
shell: options.shell || process.env.SHELL || "/bin/zsh"
|
|
683
|
+
};
|
|
684
|
+
},
|
|
685
|
+
sendReport: (report) => options.send({ type: "report", ...report }),
|
|
686
|
+
log: options.log
|
|
687
|
+
});
|
|
688
|
+
return {
|
|
689
|
+
hostId,
|
|
690
|
+
registerFrame(epoch) {
|
|
691
|
+
const view = options.config.view();
|
|
692
|
+
return {
|
|
693
|
+
type: "register",
|
|
694
|
+
hostId,
|
|
695
|
+
hostEpoch: epoch,
|
|
696
|
+
capabilities: [...EXECUTOR_HOST_CAPABILITIES],
|
|
697
|
+
ready: view.ready,
|
|
698
|
+
notReadyReason: view.reason
|
|
699
|
+
};
|
|
700
|
+
},
|
|
701
|
+
handle(message) {
|
|
702
|
+
switch (message.type) {
|
|
703
|
+
case "registered":
|
|
704
|
+
if (!message.ok) {
|
|
705
|
+
accepted = false;
|
|
706
|
+
options.log(`\u672C\u673A daemon \u62D2\u7EDD\u4E86 host \u767B\u8BB0\uFF1A${message.error ?? "\u672A\u7ED9\u51FA\u539F\u56E0"}`);
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
accepted = true;
|
|
710
|
+
manager.onReconcile(message.reconcileRunIds);
|
|
711
|
+
break;
|
|
712
|
+
case "assign":
|
|
713
|
+
manager.onAssign({
|
|
714
|
+
runId: message.runId,
|
|
715
|
+
prompt: message.prompt,
|
|
716
|
+
write: message.write,
|
|
717
|
+
workspaceId: message.workspaceId,
|
|
718
|
+
workspaceRoot: message.workspaceRoot,
|
|
719
|
+
submittedAt: message.submittedAt
|
|
720
|
+
});
|
|
721
|
+
break;
|
|
722
|
+
case "cancel":
|
|
723
|
+
manager.onCancel(message.runId);
|
|
724
|
+
break;
|
|
725
|
+
case "ack":
|
|
726
|
+
manager.onAck(message.runId);
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
refreshReadiness: () => manager.refreshReadiness(),
|
|
731
|
+
hosting: () => accepted,
|
|
732
|
+
activeRunIds: () => manager.activeRunIds(),
|
|
733
|
+
cancelAll: (reason) => manager.cancelAll(reason)
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ../executor/src/host-protocol.ts
|
|
738
|
+
var MAX_HOST_LINE_BYTES = 4 * 1024 * 1024;
|
|
739
|
+
|
|
740
|
+
// ../executor/src/host.ts
|
|
741
|
+
var LOCK_WAIT_MS = 25 * 60 * 1e3;
|
|
742
|
+
var LOCK_POLL_MS = 500;
|
|
743
|
+
var DRAIN_TIMEOUT_MS = 25 * 60 * 1e3;
|
|
744
|
+
var DRAIN_POLL_MS = 1e3;
|
|
745
|
+
function homeDir() {
|
|
746
|
+
const configured = process.env.COFLUX_HOME?.trim();
|
|
747
|
+
return configured || join2(homedir(), ".coflux");
|
|
748
|
+
}
|
|
749
|
+
function logLine(message) {
|
|
750
|
+
process.stderr.write(`[executor-host] ${message}
|
|
751
|
+
`);
|
|
752
|
+
}
|
|
753
|
+
async function acquireHostLock(path) {
|
|
754
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
755
|
+
for (; ; ) {
|
|
756
|
+
try {
|
|
757
|
+
const fd = openSync(path, "wx", 384);
|
|
758
|
+
writeSync(fd, `${process.pid}
|
|
759
|
+
`);
|
|
760
|
+
closeSync(fd);
|
|
761
|
+
return () => {
|
|
762
|
+
try {
|
|
763
|
+
if (readHolder(path) === process.pid) unlinkSync(path);
|
|
764
|
+
} catch {
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
} catch (error) {
|
|
768
|
+
if (error.code !== "EEXIST") throw error;
|
|
769
|
+
}
|
|
770
|
+
const holder = readHolder(path);
|
|
771
|
+
if (holder === null || !alive(holder)) {
|
|
772
|
+
try {
|
|
773
|
+
unlinkSync(path);
|
|
774
|
+
} catch {
|
|
775
|
+
}
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (Date.now() >= deadline) {
|
|
779
|
+
throw new Error(`\u672C\u673A\u5DF2\u6709 executor host \u5728\u8DD1\uFF08pid ${holder}\uFF09\uFF0C\u7B49\u5F85\u8D85\u65F6`);
|
|
780
|
+
}
|
|
781
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function readHolder(path) {
|
|
785
|
+
try {
|
|
786
|
+
const pid = Number.parseInt(readFileSync2(path, "utf8").trim(), 10);
|
|
787
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
788
|
+
} catch {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function alive(pid) {
|
|
793
|
+
try {
|
|
794
|
+
process.kill(pid, 0);
|
|
795
|
+
return true;
|
|
796
|
+
} catch (error) {
|
|
797
|
+
return error.code === "EPERM";
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
function sendFrame(message) {
|
|
801
|
+
try {
|
|
802
|
+
process.stdout.write(`${JSON.stringify(message)}
|
|
803
|
+
`);
|
|
804
|
+
} catch {
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
function readFrames(onFrame, onEnd) {
|
|
808
|
+
let buffer = "";
|
|
809
|
+
process.stdin.setEncoding("utf8");
|
|
810
|
+
process.stdin.on("data", (chunk) => {
|
|
811
|
+
buffer += chunk;
|
|
812
|
+
if (buffer.length > MAX_HOST_LINE_BYTES) {
|
|
813
|
+
logLine("\u4E0A\u884C\u5E27\u8D85\u8FC7\u957F\u5EA6\u4E0A\u9650\uFF0C\u4E22\u5F03\u7F13\u51B2");
|
|
814
|
+
buffer = "";
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
let index = buffer.indexOf("\n");
|
|
818
|
+
while (index >= 0) {
|
|
819
|
+
const line = buffer.slice(0, index).trim();
|
|
820
|
+
buffer = buffer.slice(index + 1);
|
|
821
|
+
if (line) {
|
|
822
|
+
try {
|
|
823
|
+
onFrame(JSON.parse(line));
|
|
824
|
+
} catch {
|
|
825
|
+
logLine("\u6536\u5230\u65E0\u6CD5\u89E3\u6790\u7684\u5E27\uFF0C\u5DF2\u5FFD\u7565");
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
index = buffer.indexOf("\n");
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
process.stdin.on("end", onEnd);
|
|
832
|
+
process.stdin.on("close", onEnd);
|
|
833
|
+
process.stdin.on("error", onEnd);
|
|
834
|
+
}
|
|
835
|
+
function runnerPath() {
|
|
836
|
+
return join2(dirname(fileURLToPath(import.meta.url)), "runner.js");
|
|
837
|
+
}
|
|
838
|
+
function forkRunner() {
|
|
839
|
+
const child = fork(runnerPath(), [], { stdio: ["ignore", "ignore", "pipe", "ipc"] });
|
|
840
|
+
child.stderr?.setEncoding("utf8");
|
|
841
|
+
child.stderr?.on("data", (chunk) => {
|
|
842
|
+
for (const line of chunk.split("\n")) if (line.trim()) logLine(`runner: ${line.trim()}`);
|
|
843
|
+
});
|
|
844
|
+
return {
|
|
845
|
+
postMessage: (message) => {
|
|
846
|
+
if (child.connected) child.send(message);
|
|
847
|
+
},
|
|
848
|
+
kill: () => void child.kill(),
|
|
849
|
+
on(event, listener) {
|
|
850
|
+
if (event === "message") child.on("message", listener);
|
|
851
|
+
else child.on("exit", listener);
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
async function runExecutorHost() {
|
|
856
|
+
const home = homeDir();
|
|
857
|
+
mkdirSync(home, { recursive: true, mode: 448 });
|
|
858
|
+
const release = await acquireHostLock(join2(home, "executor-host.lock"));
|
|
859
|
+
let epoch = 0;
|
|
860
|
+
let draining = false;
|
|
861
|
+
const config = createExecutorConfigStore({
|
|
862
|
+
cachePath: join2(home, "executor-settings.json"),
|
|
863
|
+
onChange: () => {
|
|
864
|
+
core.refreshReadiness();
|
|
865
|
+
if (!draining) register();
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
const core = createExecutorHostCore({
|
|
869
|
+
config,
|
|
870
|
+
spawnRunner: forkRunner,
|
|
871
|
+
send: sendFrame,
|
|
872
|
+
log: logLine
|
|
873
|
+
});
|
|
874
|
+
function register() {
|
|
875
|
+
epoch += 1;
|
|
876
|
+
sendFrame(core.registerFrame(epoch));
|
|
877
|
+
logLine(`\u5DF2\u5411\u672C\u673A daemon \u62A5\u5230\uFF08hostId=${core.hostId}\uFF0Cepoch=${epoch}\uFF09`);
|
|
878
|
+
}
|
|
879
|
+
readFrames(
|
|
880
|
+
(message) => {
|
|
881
|
+
if (draining) return;
|
|
882
|
+
core.handle(message);
|
|
883
|
+
},
|
|
884
|
+
() => {
|
|
885
|
+
if (draining) return;
|
|
886
|
+
draining = true;
|
|
887
|
+
const active = core.activeRunIds().length;
|
|
888
|
+
logLine(active === 0 ? "daemon \u94FE\u8DEF\u5DF2\u65AD\uFF0C\u65E0\u5728\u8DD1\u4EFB\u52A1\uFF0C\u9000\u51FA" : `daemon \u94FE\u8DEF\u5DF2\u65AD\uFF0C\u7B49\u5F85 ${active} \u4E2A\u5728\u8DD1\u4EFB\u52A1\u6536\u5C3E\u540E\u9000\u51FA`);
|
|
889
|
+
void drain();
|
|
890
|
+
}
|
|
891
|
+
);
|
|
892
|
+
async function drain() {
|
|
893
|
+
const deadline = Date.now() + DRAIN_TIMEOUT_MS;
|
|
894
|
+
while (core.activeRunIds().length > 0 && Date.now() < deadline) {
|
|
895
|
+
await new Promise((resolve) => setTimeout(resolve, DRAIN_POLL_MS));
|
|
896
|
+
}
|
|
897
|
+
if (core.activeRunIds().length > 0) {
|
|
898
|
+
logLine("\u5728\u8DD1\u4EFB\u52A1\u8D85\u8FC7\u6536\u5C3E\u4E0A\u9650\uFF0C\u5F3A\u5236\u505C\u6B62\u540E\u9000\u51FA");
|
|
899
|
+
core.cancelAll("daemon \u5DF2\u9000\u51FA\u4E14\u4EFB\u52A1\u672A\u80FD\u5728\u9650\u671F\u5185\u6536\u5C3E");
|
|
900
|
+
}
|
|
901
|
+
config.dispose();
|
|
902
|
+
release();
|
|
903
|
+
process.exit(0);
|
|
904
|
+
}
|
|
905
|
+
process.stdout.on("error", () => {
|
|
906
|
+
});
|
|
907
|
+
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
908
|
+
process.on(signal, () => {
|
|
909
|
+
logLine(`\u6536\u5230 ${signal}\uFF0C\u505C\u6B62\u6240\u6709\u5728\u8DD1\u4EFB\u52A1`);
|
|
910
|
+
core.cancelAll("\u672C\u673A daemon \u505C\u6B62\u4E86 executor host\uFF0C\u4EFB\u52A1\u88AB\u4E2D\u65AD");
|
|
911
|
+
config.dispose();
|
|
912
|
+
release();
|
|
913
|
+
process.exit(0);
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
register();
|
|
917
|
+
}
|
|
918
|
+
function startedDirectly() {
|
|
919
|
+
try {
|
|
920
|
+
return realpathSync2(process.argv[1] ?? "") === realpathSync2(fileURLToPath(import.meta.url));
|
|
921
|
+
} catch {
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (startedDirectly()) {
|
|
926
|
+
runExecutorHost().catch((error) => {
|
|
927
|
+
logLine(`\u542F\u52A8\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
|
|
928
|
+
process.exit(1);
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
export {
|
|
932
|
+
runExecutorHost
|
|
933
|
+
};
|