@nowcrew/daemon 0.5.20 → 0.5.22
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/dist/console-formatter.js +60 -0
- package/dist/console-payload.js +73 -0
- package/dist/console.js +11 -5
- package/dist/daemon-startup-error.js +30 -0
- package/dist/execution-backend.js +35 -2
- package/dist/execution-event-limit.js +5 -0
- package/dist/execution-protocol.js +1 -0
- package/dist/execution-recovery.js +34 -10
- package/dist/execution-runner.js +1 -0
- package/dist/execution-supervisor-child.js +51 -0
- package/dist/execution-supervisor.js +4 -2
- package/dist/i18n.js +2 -0
- package/dist/local-executor.js +4 -2
- package/dist/machine-info.js +1 -1
- package/dist/main.js +14 -9
- package/dist/serve.js +3 -2
- package/dist/unified-diff.js +84 -0
- package/dist/win32-job-object.js +193 -0
- package/package.json +4 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 单次 runtime execution 的 console 格式化器。
|
|
3
|
+
* 用 tool_use id 关联 Read 结果,避免将整文件作为普通 tool_result 倾泻到终端。
|
|
4
|
+
*/
|
|
5
|
+
import { buildFilePreview } from "./console-payload.js";
|
|
6
|
+
import { toConsoleLines, TOOL_RESULT_CAP } from "./console.js";
|
|
7
|
+
import { parseUnifiedDiff } from "./unified-diff.js";
|
|
8
|
+
const extract = (content) => {
|
|
9
|
+
if (typeof content === "string")
|
|
10
|
+
return content;
|
|
11
|
+
if (!Array.isArray(content))
|
|
12
|
+
return "";
|
|
13
|
+
return content.map((item) => item && typeof item === "object" && typeof item.text === "string"
|
|
14
|
+
? item.text : "").filter(Boolean).join("\n");
|
|
15
|
+
};
|
|
16
|
+
const clip = (text) => text.length > TOOL_RESULT_CAP
|
|
17
|
+
? `${text.slice(0, TOOL_RESULT_CAP)}… (+${text.length - TOOL_RESULT_CAP})`
|
|
18
|
+
: text;
|
|
19
|
+
export function createConsoleFormatter() {
|
|
20
|
+
const pending = new Map();
|
|
21
|
+
return {
|
|
22
|
+
format(event) {
|
|
23
|
+
const rec = event && typeof event === "object" ? event : {};
|
|
24
|
+
const message = rec.message && typeof rec.message === "object" ? rec.message : undefined;
|
|
25
|
+
if (rec.type === "assistant" && Array.isArray(message?.content)) {
|
|
26
|
+
for (const block of message.content) {
|
|
27
|
+
if (block.type === "tool_use" && block.id && block.name) {
|
|
28
|
+
pending.set(block.id, { name: block.name, ...(block.input ? { input: block.input } : {}) });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (rec.type === "user" && Array.isArray(message?.content)) {
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const block of message.content) {
|
|
35
|
+
if (block.type !== "tool_result")
|
|
36
|
+
continue;
|
|
37
|
+
const text = extract(block.content);
|
|
38
|
+
if (!text)
|
|
39
|
+
continue;
|
|
40
|
+
const tool = block.tool_use_id ? pending.get(block.tool_use_id) : undefined;
|
|
41
|
+
if (block.tool_use_id)
|
|
42
|
+
pending.delete(block.tool_use_id);
|
|
43
|
+
if (!block.is_error && tool?.name === "Read" && typeof tool.input?.file_path === "string") {
|
|
44
|
+
const start = typeof tool.input.offset === "number" && Number.isSafeInteger(tool.input.offset)
|
|
45
|
+
? Math.max(1, tool.input.offset) : 1;
|
|
46
|
+
const preview = buildFilePreview("read", tool.input.file_path, text, start);
|
|
47
|
+
out.push({ stream: "tool_result", text: `Read ${preview.totalLines} line(s) from ${preview.path}`, payload: preview });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const patch = block.is_error ? null : parseUnifiedDiff(text);
|
|
51
|
+
out.push(patch
|
|
52
|
+
? { stream: "tool_result", text: `Changed ${patch.files.length} file(s): +${patch.additions} -${patch.deletions}`, payload: patch }
|
|
53
|
+
: { stream: "tool_result", text: clip(text) });
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
return toConsoleLines(event);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentConsole 的结构化展示协议与有界构造器。
|
|
3
|
+
* 所有代码/文件预览在 daemon 边界裁剪,避免 server/web 接收整文件。
|
|
4
|
+
*/
|
|
5
|
+
export const CONSOLE_PREVIEW_ROWS = 12;
|
|
6
|
+
export const CONSOLE_PREVIEW_HEAD_ROWS = 8;
|
|
7
|
+
export const CONSOLE_DIFF_ROWS = 40;
|
|
8
|
+
export const CONSOLE_ROW_CHARS = 240;
|
|
9
|
+
const clipRow = (text) => text.length > CONSOLE_ROW_CHARS
|
|
10
|
+
? `${text.slice(0, CONSOLE_ROW_CHARS)}…`
|
|
11
|
+
: text;
|
|
12
|
+
/** 头 8 + 尾 4,中间显式 omitted;小文件全部显示。 */
|
|
13
|
+
export function buildFilePreview(operation, path, content, startLine = 1) {
|
|
14
|
+
const lines = content ? content.split("\n") : [];
|
|
15
|
+
const visible = lines.length <= CONSOLE_PREVIEW_ROWS
|
|
16
|
+
? lines.map((text, index) => ({ type: "line", line: startLine + index, text: clipRow(text) }))
|
|
17
|
+
: [
|
|
18
|
+
...lines.slice(0, CONSOLE_PREVIEW_HEAD_ROWS).map((text, index) => ({
|
|
19
|
+
type: "line", line: startLine + index, text: clipRow(text),
|
|
20
|
+
})),
|
|
21
|
+
{ type: "omitted", count: lines.length - CONSOLE_PREVIEW_ROWS },
|
|
22
|
+
...lines.slice(-4).map((text, index) => ({
|
|
23
|
+
type: "line", line: startLine + lines.length - 4 + index, text: clipRow(text),
|
|
24
|
+
})),
|
|
25
|
+
];
|
|
26
|
+
return {
|
|
27
|
+
kind: "file_preview",
|
|
28
|
+
operation,
|
|
29
|
+
path,
|
|
30
|
+
totalLines: lines.length,
|
|
31
|
+
totalBytes: Buffer.byteLength(content, "utf8"),
|
|
32
|
+
rows: visible,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** old/new 片段转紧凑带双行号 diff;变更中段超过预算时裁剪。 */
|
|
36
|
+
export function buildSnippetDiff(path, oldText, newText) {
|
|
37
|
+
const oldLines = oldText ? oldText.split("\n") : [];
|
|
38
|
+
const newLines = newText ? newText.split("\n") : [];
|
|
39
|
+
let prefix = 0;
|
|
40
|
+
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix])
|
|
41
|
+
prefix++;
|
|
42
|
+
let oldEnd = oldLines.length;
|
|
43
|
+
let newEnd = newLines.length;
|
|
44
|
+
while (oldEnd > prefix && newEnd > prefix && oldLines[oldEnd - 1] === newLines[newEnd - 1]) {
|
|
45
|
+
oldEnd--;
|
|
46
|
+
newEnd--;
|
|
47
|
+
}
|
|
48
|
+
const rows = [];
|
|
49
|
+
const contextStart = Math.max(0, prefix - 2);
|
|
50
|
+
for (let index = contextStart; index < prefix; index++) {
|
|
51
|
+
rows.push({ type: "context", oldLine: index + 1, newLine: index + 1, text: clipRow(oldLines[index]) });
|
|
52
|
+
}
|
|
53
|
+
for (let index = prefix; index < oldEnd; index++) {
|
|
54
|
+
rows.push({ type: "delete", oldLine: index + 1, newLine: null, text: clipRow(oldLines[index]) });
|
|
55
|
+
}
|
|
56
|
+
for (let index = prefix; index < newEnd; index++) {
|
|
57
|
+
rows.push({ type: "add", oldLine: null, newLine: index + 1, text: clipRow(newLines[index]) });
|
|
58
|
+
}
|
|
59
|
+
for (let offset = 0; offset < Math.min(2, oldLines.length - oldEnd); offset++) {
|
|
60
|
+
rows.push({ type: "context", oldLine: oldEnd + offset + 1, newLine: newEnd + offset + 1, text: clipRow(oldLines[oldEnd + offset]) });
|
|
61
|
+
}
|
|
62
|
+
const additions = Math.max(0, newEnd - prefix);
|
|
63
|
+
const deletions = Math.max(0, oldEnd - prefix);
|
|
64
|
+
if (rows.length <= CONSOLE_DIFF_ROWS)
|
|
65
|
+
return { kind: "diff_rows", files: [{ path, rows }], additions, deletions };
|
|
66
|
+
const head = rows.slice(0, 28);
|
|
67
|
+
const tail = rows.slice(-11);
|
|
68
|
+
return {
|
|
69
|
+
kind: "diff_rows",
|
|
70
|
+
files: [{ path, rows: [...head, { type: "omitted", oldCount: rows.length - 39, newCount: rows.length - 39 }, ...tail] }],
|
|
71
|
+
additions, deletions, truncated: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
package/dist/console.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* kimi(OpenAI 消息风格行)。
|
|
14
14
|
*/
|
|
15
15
|
import { detectDaemonLang, translateDaemon } from "./i18n.js";
|
|
16
|
+
import { buildFilePreview, buildSnippetDiff } from "./console-payload.js";
|
|
17
|
+
import { parseUnifiedDiff } from "./unified-diff.js";
|
|
16
18
|
/** 单条工具返回正文上限(超出截断并标注),避免单条把终端/DB 撑爆。 */
|
|
17
19
|
export const TOOL_RESULT_CAP = 4000;
|
|
18
20
|
/** 工具输入摘要上限(标题行那一段)。 */
|
|
@@ -95,10 +97,10 @@ function toolPayload(name, input) {
|
|
|
95
97
|
return { kind: "command", command: clip(input.command, PAYLOAD_TEXT_CAP) };
|
|
96
98
|
}
|
|
97
99
|
if (name === "Edit" && typeof input.file_path === "string") {
|
|
98
|
-
return
|
|
100
|
+
return buildSnippetDiff(input.file_path, typeof input.old_string === "string" ? input.old_string : "", typeof input.new_string === "string" ? input.new_string : "");
|
|
99
101
|
}
|
|
100
102
|
if (name === "Write" && typeof input.file_path === "string" && typeof input.content === "string") {
|
|
101
|
-
return
|
|
103
|
+
return buildFilePreview("write", input.file_path, input.content);
|
|
102
104
|
}
|
|
103
105
|
if (name === "TodoWrite") {
|
|
104
106
|
const todos = normalizeTodos(input.todos);
|
|
@@ -125,7 +127,7 @@ function toolUseChunks(name, input) {
|
|
|
125
127
|
chunks.push({
|
|
126
128
|
stream: "tool",
|
|
127
129
|
text: `⏺ Edit(${clip(file, TOOL_INPUT_CAP)})`,
|
|
128
|
-
payload:
|
|
130
|
+
payload: buildSnippetDiff(file, typeof rec.old_string === "string" ? rec.old_string : "", typeof rec.new_string === "string" ? rec.new_string : ""),
|
|
129
131
|
});
|
|
130
132
|
}
|
|
131
133
|
if (chunks.length)
|
|
@@ -170,8 +172,12 @@ function codexItemChunks(item, td) {
|
|
|
170
172
|
},
|
|
171
173
|
}];
|
|
172
174
|
const output = item.aggregated_output?.trim();
|
|
173
|
-
if (output)
|
|
174
|
-
|
|
175
|
+
if (output) {
|
|
176
|
+
const patch = parseUnifiedDiff(output);
|
|
177
|
+
out.push(patch
|
|
178
|
+
? { stream: "tool_result", text: `Changed ${patch.files.length} file(s): +${patch.additions} -${patch.deletions}`, payload: patch }
|
|
179
|
+
: { stream: "tool_result", text: clip(output, TOOL_RESULT_CAP) });
|
|
180
|
+
}
|
|
175
181
|
return out;
|
|
176
182
|
}
|
|
177
183
|
if (item.type === "file_change" && Array.isArray(item.changes)) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { formatDaemonText, } from "./i18n.js";
|
|
2
|
+
const PROFILE_MESSAGE = "Daemon is already running (PID={{ownerPid}}) for agents root '{{agentsRoot}}'. Journal: '{{journalPath}}'. Do not delete the active journal lock. Stop the existing daemon before retrying. If this profile is managed as a service, use 'crew-daemon stop --profile {{profileName}}' or 'crew-daemon restart --profile {{profileName}}'.";
|
|
3
|
+
const MANUAL_MESSAGE = "Daemon is already running (PID={{ownerPid}}) for agents root '{{agentsRoot}}'. Journal: '{{journalPath}}'. Do not delete the active journal lock. Stop the existing daemon before retrying. If it is managed as a service, use 'crew-daemon stop --profile <name>' or 'crew-daemon restart --profile <name>'.";
|
|
4
|
+
export class DaemonAlreadyRunningError extends Error {
|
|
5
|
+
ownerPid;
|
|
6
|
+
agentsRoot;
|
|
7
|
+
journalPath;
|
|
8
|
+
serverUrl;
|
|
9
|
+
profileName;
|
|
10
|
+
constructor(details) {
|
|
11
|
+
super(`Daemon is already running (PID=${details.ownerPid})`, { cause: details.cause });
|
|
12
|
+
this.name = "DaemonAlreadyRunningError";
|
|
13
|
+
this.ownerPid = details.ownerPid;
|
|
14
|
+
this.agentsRoot = details.agentsRoot;
|
|
15
|
+
this.journalPath = details.journalPath;
|
|
16
|
+
this.serverUrl = details.serverUrl;
|
|
17
|
+
if (details.profileName !== undefined)
|
|
18
|
+
this.profileName = details.profileName;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function formatDaemonStartupError(error, lang) {
|
|
22
|
+
if (!(error instanceof DaemonAlreadyRunningError))
|
|
23
|
+
return null;
|
|
24
|
+
return formatDaemonText(lang, error.profileName === undefined ? MANUAL_MESSAGE : PROFILE_MESSAGE, {
|
|
25
|
+
ownerPid: error.ownerPid,
|
|
26
|
+
agentsRoot: error.agentsRoot,
|
|
27
|
+
journalPath: error.journalPath,
|
|
28
|
+
...(error.profileName === undefined ? {} : { profileName: error.profileName }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -1,11 +1,44 @@
|
|
|
1
|
+
import { isJobObjectSupported } from "./win32-job-object.js";
|
|
2
|
+
/** 灰度开关:默认关。只有显式打开且运行时探测成功,win32 才翻为 supported。 */
|
|
3
|
+
const GRAYSCALE_ENV = "CREW_WINDOWS_JOB_OBJECT";
|
|
4
|
+
function grayscaleEnabled() {
|
|
5
|
+
const flag = process.env[GRAYSCALE_ENV];
|
|
6
|
+
return flag === "1" || flag === "true";
|
|
7
|
+
}
|
|
8
|
+
let cachedProbe;
|
|
9
|
+
/**
|
|
10
|
+
* 默认探测:灰度关 → 一律 false(行为与今日一致)。灰度开 → 同步探测 koffi/kernel32 一次并缓存。
|
|
11
|
+
* 同步实现(createRequire),因此 `executionBackendCapability` 保持同步,不波及 serve.ts 的同步调用链。
|
|
12
|
+
*/
|
|
13
|
+
function defaultJobObjectProbe() {
|
|
14
|
+
if (!grayscaleEnabled())
|
|
15
|
+
return false;
|
|
16
|
+
if (cachedProbe === undefined)
|
|
17
|
+
cachedProbe = isJobObjectSupported();
|
|
18
|
+
return cachedProbe;
|
|
19
|
+
}
|
|
20
|
+
const WINDOWS_UNAVAILABLE_REASON = "protocol-v1 is disabled until a Windows Job Object backend owns every runtime process";
|
|
1
21
|
/** Durable execution needs ownership that survives daemon crashes, not only a best-effort kill. */
|
|
2
|
-
export function executionBackendCapability(platform = process.platform) {
|
|
22
|
+
export function executionBackendCapability(platform = process.platform, probe = defaultJobObjectProbe) {
|
|
3
23
|
if (platform === "win32") {
|
|
24
|
+
if (probe())
|
|
25
|
+
return { supported: true, backend: "windows-job-object" };
|
|
4
26
|
return {
|
|
5
27
|
supported: false,
|
|
6
28
|
backend: "windows-job-object-unavailable",
|
|
7
|
-
reason:
|
|
29
|
+
reason: WINDOWS_UNAVAILABLE_REASON,
|
|
8
30
|
};
|
|
9
31
|
}
|
|
10
32
|
return { supported: true, backend: "posix-process-group" };
|
|
11
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* supervisor child 是否应通过 Job Object 拥有 runtime 树。仅当 durable + win32 + Job Object 后端已选中时为真。
|
|
36
|
+
* legacy(process-lifetime)恒为 false —— 保证灰度关/koffi 不可用时 Windows legacy 执行完全不碰 Job,
|
|
37
|
+
* 维持今日的 taskkill 语义与 fail-closed 降级承诺。
|
|
38
|
+
*/
|
|
39
|
+
export function ownsRuntimeViaJobObject(ownershipMode, platform = process.platform, probe) {
|
|
40
|
+
if (ownershipMode !== "durable")
|
|
41
|
+
return false;
|
|
42
|
+
const backend = executionBackendCapability(platform, probe);
|
|
43
|
+
return backend.supported && backend.backend === "windows-job-object";
|
|
44
|
+
}
|
|
@@ -38,6 +38,11 @@ export function boundExecutionFrame(input, maxBytes) {
|
|
|
38
38
|
}
|
|
39
39
|
else if (input.type === "execution:console" || input.type === "execution:output") {
|
|
40
40
|
frame = withBoundedString(frame, "text", maxBytes, false);
|
|
41
|
+
// 富 payload 是可选增强;事件超限时先丢 payload,保留必需 text 帧。
|
|
42
|
+
if (executionFrameBytes(frame) > maxBytes && "payload" in frame) {
|
|
43
|
+
const { payload: _payload, ...withoutPayload } = frame;
|
|
44
|
+
frame = withoutPayload;
|
|
45
|
+
}
|
|
41
46
|
}
|
|
42
47
|
else if (input.type === "execution:rejected") {
|
|
43
48
|
frame = withBoundedString(frame, "message", maxBytes, true);
|
|
@@ -191,6 +191,7 @@ export const ExecutionConsoleSchema = z.object({
|
|
|
191
191
|
executionId: ExecutionIdSchema,
|
|
192
192
|
stream: ConsoleStreamSchema,
|
|
193
193
|
text: z.string(),
|
|
194
|
+
payload: z.record(z.string(), z.unknown()).optional(),
|
|
194
195
|
seq: SequenceSchema,
|
|
195
196
|
at: TimestampSchema,
|
|
196
197
|
}).strict();
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
|
+
import { JournalLockedError } from "./execution-journal.js";
|
|
3
|
+
import { DaemonAlreadyRunningError } from "./daemon-startup-error.js";
|
|
2
4
|
export async function reconcileExecutionJournal(journal, dependencies) {
|
|
3
5
|
try {
|
|
4
6
|
await journal.reconcileAfterRestart();
|
|
@@ -14,22 +16,27 @@ export async function reconcileExecutionJournal(journal, dependencies) {
|
|
|
14
16
|
const ownerPid = typeof errorRecord.ownerPid === "number" ? errorRecord.ownerPid : undefined;
|
|
15
17
|
const errorType = error instanceof Error ? error.name : typeof error;
|
|
16
18
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
19
|
+
const alreadyRunning = error instanceof JournalLockedError && ownerPid !== undefined;
|
|
17
20
|
const diagnostics = {
|
|
18
21
|
server_url: dependencies.serverUrl,
|
|
19
22
|
agents_root: agentsRoot,
|
|
20
23
|
journal_path: journalPath,
|
|
21
24
|
owner_pid: ownerPid,
|
|
25
|
+
...(dependencies.profileName === undefined ? {} : { profile_name: dependencies.profileName }),
|
|
22
26
|
error_type: errorType,
|
|
23
27
|
error_message: errorMessage,
|
|
24
28
|
};
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
const failureEvent = alreadyRunning ? "daemon.already_running" : "execution.recovery_failed";
|
|
30
|
+
const failureMessage = alreadyRunning ? "daemon 已在运行" : "execution journal 恢复失败";
|
|
31
|
+
const failureLevel = alreadyRunning ? "WARN" : "ERROR";
|
|
32
|
+
dependencies.log(failureEvent, failureMessage, {
|
|
33
|
+
level: failureLevel,
|
|
27
34
|
...diagnostics,
|
|
28
35
|
});
|
|
29
36
|
dependencies.writeStderr(`${JSON.stringify({
|
|
30
|
-
level:
|
|
31
|
-
event_type:
|
|
32
|
-
message:
|
|
37
|
+
level: failureLevel,
|
|
38
|
+
event_type: failureEvent,
|
|
39
|
+
message: failureMessage,
|
|
33
40
|
...diagnostics,
|
|
34
41
|
})}\n`);
|
|
35
42
|
const reportCleanupFailure = (stage, cleanupError) => {
|
|
@@ -43,14 +50,21 @@ export async function reconcileExecutionJournal(journal, dependencies) {
|
|
|
43
50
|
cleanup_stage: stage,
|
|
44
51
|
error_type: cleanupErrorType,
|
|
45
52
|
error_message: cleanupErrorMessage,
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
...(alreadyRunning
|
|
54
|
+
? { startup_error_type: errorType, startup_error_message: errorMessage }
|
|
55
|
+
: { recovery_error_type: errorType, recovery_error_message: errorMessage }),
|
|
48
56
|
};
|
|
49
|
-
|
|
57
|
+
const cleanupEvent = alreadyRunning
|
|
58
|
+
? "daemon.already_running_cleanup_failed"
|
|
59
|
+
: "execution.recovery_cleanup_failed";
|
|
60
|
+
const cleanupMessage = alreadyRunning
|
|
61
|
+
? "daemon 重复启动后的清理失败"
|
|
62
|
+
: "execution journal 恢复失败后的清理失败";
|
|
63
|
+
dependencies.log(cleanupEvent, cleanupMessage, { level: "ERROR", ...cleanupDiagnostics });
|
|
50
64
|
dependencies.writeStderr(`${JSON.stringify({
|
|
51
65
|
level: "ERROR",
|
|
52
|
-
event_type:
|
|
53
|
-
message:
|
|
66
|
+
event_type: cleanupEvent,
|
|
67
|
+
message: cleanupMessage,
|
|
54
68
|
...cleanupDiagnostics,
|
|
55
69
|
})}\n`);
|
|
56
70
|
};
|
|
@@ -66,6 +80,16 @@ export async function reconcileExecutionJournal(journal, dependencies) {
|
|
|
66
80
|
catch (cleanupError) {
|
|
67
81
|
reportCleanupFailure("journal_close", cleanupError);
|
|
68
82
|
}
|
|
83
|
+
if (alreadyRunning) {
|
|
84
|
+
throw new DaemonAlreadyRunningError({
|
|
85
|
+
ownerPid,
|
|
86
|
+
agentsRoot,
|
|
87
|
+
journalPath,
|
|
88
|
+
serverUrl: dependencies.serverUrl,
|
|
89
|
+
...(dependencies.profileName === undefined ? {} : { profileName: dependencies.profileName }),
|
|
90
|
+
cause: error,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
69
93
|
throw error;
|
|
70
94
|
}
|
|
71
95
|
}
|
package/dist/execution-runner.js
CHANGED
|
@@ -422,6 +422,7 @@ export async function runExecution(config, input, dependencies) {
|
|
|
422
422
|
executionId: spec.executionId,
|
|
423
423
|
stream: chunk.stream,
|
|
424
424
|
text: chunk.text,
|
|
425
|
+
...(chunk.payload ? { payload: chunk.payload } : {}),
|
|
425
426
|
seq: consoleSequence++,
|
|
426
427
|
at: now().toISOString(),
|
|
427
428
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { assignProcessToJob, createKillOnCloseJob, terminateJob, } from "./win32-job-object.js";
|
|
4
5
|
const LaunchSchema = z.object({
|
|
5
6
|
command: z.string().min(1),
|
|
6
7
|
args: z.array(z.string()),
|
|
@@ -8,6 +9,9 @@ const LaunchSchema = z.object({
|
|
|
8
9
|
env: z.record(z.string()),
|
|
9
10
|
stdinText: z.string().optional(),
|
|
10
11
|
}).strict();
|
|
12
|
+
function messageOf(error) {
|
|
13
|
+
return error instanceof Error ? error.message : String(error);
|
|
14
|
+
}
|
|
11
15
|
function send(message, callback) {
|
|
12
16
|
if (!process.connected) {
|
|
13
17
|
callback?.();
|
|
@@ -56,10 +60,17 @@ function forwardWhileWritable(source, destination) {
|
|
|
56
60
|
}
|
|
57
61
|
export function runExecutionSupervisorChild() {
|
|
58
62
|
let launch = null;
|
|
63
|
+
// 是否用 Job Object 拥有 runtime 树。由 parent 依据「durable + win32 + Job Object 后端已选中」决定并下发;
|
|
64
|
+
// legacy(process-lifetime)路径恒为 false —— 关键:不能仅凭 process.platform 就建 Job,否则灰度关时
|
|
65
|
+
// legacy Windows 执行会被强行套 Job,且 koffi 加载失败会误杀 legacy runtime(退回 fail-closed 承诺被破坏)。
|
|
66
|
+
let useJobObject = false;
|
|
59
67
|
let released = false;
|
|
60
68
|
let runtime = null;
|
|
61
69
|
let settled = false;
|
|
62
70
|
let cleaningTree = false;
|
|
71
|
+
// win32:本 child 创建并持有的 Job(KILL_ON_JOB_CLOSE)。持有它 = 拥有整棵 runtime 进程树:
|
|
72
|
+
// 本进程一死(含崩溃)内核即回收 Job 内全部进程,等价于 POSIX killpg 且覆盖 supervisor 自身崩溃。
|
|
73
|
+
let jobHandle = null;
|
|
63
74
|
const outputForwarders = [];
|
|
64
75
|
const terminateOwnedTree = () => {
|
|
65
76
|
if (cleaningTree)
|
|
@@ -68,6 +79,15 @@ export function runExecutionSupervisorChild() {
|
|
|
68
79
|
for (const forwarder of outputForwarders)
|
|
69
80
|
forwarder.discard();
|
|
70
81
|
if (process.platform === "win32") {
|
|
82
|
+
// 显式一次性杀光 Job 内进程;即便这里失败,process.exit 关闭 job handle 也会触发内核回收。
|
|
83
|
+
if (jobHandle !== null) {
|
|
84
|
+
try {
|
|
85
|
+
terminateJob(jobHandle);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// handle 随进程退出自动关闭,KILL_ON_JOB_CLOSE 兜底。
|
|
89
|
+
}
|
|
90
|
+
}
|
|
71
91
|
process.exit(1);
|
|
72
92
|
return;
|
|
73
93
|
}
|
|
@@ -126,6 +146,7 @@ export function runExecutionSupervisorChild() {
|
|
|
126
146
|
return;
|
|
127
147
|
}
|
|
128
148
|
launch = parsed.data;
|
|
149
|
+
useJobObject = raw.useJobObject === true;
|
|
129
150
|
send({ type: "ready" });
|
|
130
151
|
return;
|
|
131
152
|
}
|
|
@@ -139,6 +160,18 @@ export function runExecutionSupervisorChild() {
|
|
|
139
160
|
if (raw.type !== "release" || released || launch === null)
|
|
140
161
|
return;
|
|
141
162
|
released = true;
|
|
163
|
+
if (useJobObject) {
|
|
164
|
+
// 所有权链条落地(仅 durable win32):先建 Job(带 KILL_ON_JOB_CLOSE),再 spawn,spawn 后立即 assign。
|
|
165
|
+
// 建 Job 失败即无法保证所有权 → 宁可不 spawn,报 spawn-error 让上层 fail-closed。
|
|
166
|
+
try {
|
|
167
|
+
jobHandle = createKillOnCloseJob();
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
send({ type: "runtime-spawn-error", message: `Job Object creation failed: ${messageOf(error)}` });
|
|
171
|
+
process.exit(2);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
142
175
|
const child = spawn(launch.command, launch.args, {
|
|
143
176
|
cwd: launch.cwd,
|
|
144
177
|
env: launch.env,
|
|
@@ -146,6 +179,24 @@ export function runExecutionSupervisorChild() {
|
|
|
146
179
|
stdio: [launch.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
147
180
|
});
|
|
148
181
|
runtime = child;
|
|
182
|
+
if (jobHandle !== null && child.pid !== undefined) {
|
|
183
|
+
// spawn 返回后 child.pid 同步可用,此刻立即 assign,把「assign 前已 fork 孙进程」的窗口压到最小。
|
|
184
|
+
try {
|
|
185
|
+
assignProcessToJob(jobHandle, child.pid);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
// 无法建立所有权:杀掉刚起的 runtime 并拆除,不让它脱离 Job 裸奔。
|
|
189
|
+
send({ type: "runtime-spawn-error", message: `AssignProcessToJobObject failed: ${messageOf(error)}` });
|
|
190
|
+
try {
|
|
191
|
+
child.kill("SIGKILL");
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// 已退出。
|
|
195
|
+
}
|
|
196
|
+
terminateOwnedTree();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
149
200
|
outputForwarders.push(forwardWhileWritable(child.stdout, process.stdout), forwardWhileWritable(child.stderr, process.stderr));
|
|
150
201
|
child.once("spawn", () => {
|
|
151
202
|
send({ type: "runtime-started" });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fork } from "node:child_process";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { executionBackendCapability } from "./execution-backend.js";
|
|
3
|
+
import { executionBackendCapability, ownsRuntimeViaJobObject } from "./execution-backend.js";
|
|
4
4
|
const DEFAULT_ABORT_TIMEOUT_MS = 5_000;
|
|
5
5
|
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000;
|
|
6
6
|
const DEFAULT_TASKKILL_TIMEOUT_MS = 5_000;
|
|
@@ -141,6 +141,8 @@ export async function startDormantSupervisor(launch, options = {}) {
|
|
|
141
141
|
if (!backend.supported)
|
|
142
142
|
throw new Error(backend.reason);
|
|
143
143
|
}
|
|
144
|
+
// 仅 durable + win32 + Job Object 后端已选中才让 child 套 Job;legacy 恒 false(见 ownsRuntimeViaJobObject)。
|
|
145
|
+
const useJobObject = ownsRuntimeViaJobObject(ownershipMode, platform);
|
|
144
146
|
const signalTree = options.signalTree ?? signalSupervisorTree;
|
|
145
147
|
const childEntry = options.childEntry
|
|
146
148
|
?? fileURLToPath(new URL("./execution-supervisor-child.js", import.meta.url));
|
|
@@ -249,7 +251,7 @@ export async function startDormantSupervisor(launch, options = {}) {
|
|
|
249
251
|
};
|
|
250
252
|
try {
|
|
251
253
|
await new Promise((resolve, reject) => {
|
|
252
|
-
child.send({ type: "launch", launch: { ...launch, args: [...launch.args], env } }, (error) => {
|
|
254
|
+
child.send({ type: "launch", launch: { ...launch, args: [...launch.args], env }, useJobObject }, (error) => {
|
|
253
255
|
if (error === null)
|
|
254
256
|
resolve();
|
|
255
257
|
else
|
package/dist/i18n.js
CHANGED
|
@@ -51,6 +51,8 @@ const zh = {
|
|
|
51
51
|
"--token-stdin requires a token on standard input": "--token-stdin 需要从标准输入读取令牌",
|
|
52
52
|
"Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest": "服务生命周期需要全局安装 @nowcrew/daemon;请运行 npm install --global @nowcrew/daemon@latest",
|
|
53
53
|
"Profile '{{profile}}' conflicts with profile '{{conflict}}': both resolve to agents root '{{agentsRoot}}'. Save it with a unique root, for example: {{command}}": "配置 '{{profile}}' 与配置 '{{conflict}}' 解析到了同一个 agents root '{{agentsRoot}}'。请保存为唯一目录,例如:{{command}}",
|
|
54
|
+
"Daemon is already running (PID={{ownerPid}}) for agents root '{{agentsRoot}}'. Journal: '{{journalPath}}'. Do not delete the active journal lock. Stop the existing daemon before retrying. If this profile is managed as a service, use 'crew-daemon stop --profile {{profileName}}' or 'crew-daemon restart --profile {{profileName}}'.": "daemon 已在运行(PID={{ownerPid}}),agents root 为 '{{agentsRoot}}'。日志目录:'{{journalPath}}'。不要删除活跃进程持有的 journal 锁。请先停止现有 daemon 再重试;如果此 profile 由系统服务管理,请使用 'crew-daemon stop --profile {{profileName}}' 或 'crew-daemon restart --profile {{profileName}}'。",
|
|
55
|
+
"Daemon is already running (PID={{ownerPid}}) for agents root '{{agentsRoot}}'. Journal: '{{journalPath}}'. Do not delete the active journal lock. Stop the existing daemon before retrying. If it is managed as a service, use 'crew-daemon stop --profile <name>' or 'crew-daemon restart --profile <name>'.": "daemon 已在运行(PID={{ownerPid}}),agents root 为 '{{agentsRoot}}'。日志目录:'{{journalPath}}'。不要删除活跃进程持有的 journal 锁。请先停止现有 daemon 再重试;如果它由系统服务管理,请使用 'crew-daemon stop --profile <name>' 或 'crew-daemon restart --profile <name>'。",
|
|
54
56
|
};
|
|
55
57
|
export function translateDaemon(lang, message) {
|
|
56
58
|
if (lang === "zh")
|
package/dist/local-executor.js
CHANGED
|
@@ -9,7 +9,7 @@ import { applyProviderEnv, providerFingerprint } from "./provider-env.js";
|
|
|
9
9
|
import { augmentedPath } from "./runtime-path.js";
|
|
10
10
|
import { extractFinalText, extractRunMeta, normalizeEvent, parseLine, } from "./normalize.js";
|
|
11
11
|
import { readSession, writeSession, pickResumeId, isNearBudget } from "./session.js";
|
|
12
|
-
import {
|
|
12
|
+
import { createConsoleFormatter } from "./console-formatter.js";
|
|
13
13
|
import { capMemoryForInject, capWorkLogForInject } from "./prompt.js";
|
|
14
14
|
import { decodeExternalOutputEvent, extractExternalAnswer, ExternalAnswerDecoder, stripExternalAnswerMarkers, } from "./external-output.js";
|
|
15
15
|
import { cleanupMaterializedAttachments as cleanupAttachments, executionAttachmentDirectory, materializeAttachments, } from "./attachments.js";
|
|
@@ -300,6 +300,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
300
300
|
let finalText = null;
|
|
301
301
|
let sentViaCrew = false;
|
|
302
302
|
const externalOutput = new ExternalAnswerDecoder();
|
|
303
|
+
// 每轮独立:tool_use/result 关联状态不能跨 execution 泄漏。
|
|
304
|
+
const consoleFormatter = createConsoleFormatter();
|
|
303
305
|
const readline = createInterface({ input: child.stdout });
|
|
304
306
|
readline.on("line", (line) => {
|
|
305
307
|
const event = parseLine(line);
|
|
@@ -327,7 +329,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
327
329
|
for (const text of decodeExternalOutputEvent(runtime.name, event, externalOutput)) {
|
|
328
330
|
callbacks.onExternalOutput?.(text);
|
|
329
331
|
}
|
|
330
|
-
for (const chunk of
|
|
332
|
+
for (const chunk of consoleFormatter.format(event))
|
|
331
333
|
callbacks.onConsole?.(chunk);
|
|
332
334
|
});
|
|
333
335
|
let stderrTail = "";
|
package/dist/machine-info.js
CHANGED
|
@@ -103,7 +103,7 @@ export async function collectMachineHello(agentsRoot, executionLimits, runtimePl
|
|
|
103
103
|
(dependencies.detectInstalled ?? detectRuntimes)(),
|
|
104
104
|
listAgentHandles(agentsRoot),
|
|
105
105
|
]);
|
|
106
|
-
const backend = executionBackendCapability(runtimePlatform);
|
|
106
|
+
const backend = executionBackendCapability(runtimePlatform, dependencies.jobObjectProbe);
|
|
107
107
|
const executionRuntimes = backend.supported
|
|
108
108
|
? await (dependencies.detectExecutable ?? detectExecutionRuntimes)(runtimes)
|
|
109
109
|
: [];
|
package/dist/main.js
CHANGED
|
@@ -17,6 +17,7 @@ import { formatDaemonLogLine } from "./log-format.js";
|
|
|
17
17
|
import { applyProfileToEnv, assertProfileAgentsRootUnique, daemonHome, loadProfile, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, ProfileAgentsRootConflictError, } from "./computer-profile.js";
|
|
18
18
|
import { runComputerCommand } from "./computer-cli.js";
|
|
19
19
|
import { runServeLifecycle } from "./serve-lifecycle.js";
|
|
20
|
+
import { formatDaemonStartupError } from "./daemon-startup-error.js";
|
|
20
21
|
async function main() {
|
|
21
22
|
const computerResult = await runComputerCommand(process.argv.slice(2));
|
|
22
23
|
if (computerResult !== null) {
|
|
@@ -79,7 +80,9 @@ async function main() {
|
|
|
79
80
|
}
|
|
80
81
|
if (cmd === "serve") {
|
|
81
82
|
process.stdout.write(formatDaemonLogLine(`🛰️ crew-daemon v${daemonVersion()} (cli v${cliVersion()}) ${td("resident, connecting to")} ${config.serverUrl} ${td("control plane")}...`) + "\n");
|
|
82
|
-
const service = serve(config
|
|
83
|
+
const service = serve(config, {
|
|
84
|
+
...(values.profile === undefined ? {} : { profileName: values.profile }),
|
|
85
|
+
});
|
|
83
86
|
await runServeLifecycle(service);
|
|
84
87
|
return;
|
|
85
88
|
}
|
|
@@ -100,14 +103,16 @@ async function main() {
|
|
|
100
103
|
process.exit(result.exitCode);
|
|
101
104
|
}
|
|
102
105
|
main().catch((e) => {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
const lang = detectDaemonLang();
|
|
107
|
+
const message = formatDaemonStartupError(e, lang)
|
|
108
|
+
?? (e instanceof ProfileAgentsRootConflictError
|
|
109
|
+
? formatDaemonText(lang, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, {
|
|
110
|
+
profile: e.profile,
|
|
111
|
+
conflict: e.conflict,
|
|
112
|
+
agentsRoot: e.agentsRoot,
|
|
113
|
+
command: e.command,
|
|
114
|
+
})
|
|
115
|
+
: e.message);
|
|
111
116
|
process.stderr.write(`crew-daemon: ${message}\n`);
|
|
112
117
|
process.exitCode = 1;
|
|
113
118
|
});
|
package/dist/serve.js
CHANGED
|
@@ -33,9 +33,9 @@ const ACTIVITY_MAP = {
|
|
|
33
33
|
checking: "checking", claiming: "claiming", crew: "working", tool: "working",
|
|
34
34
|
tool_result: "working", done: "done", error: "error",
|
|
35
35
|
};
|
|
36
|
-
export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform = process.platform) {
|
|
36
|
+
export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform = process.platform, jobObjectProbe) {
|
|
37
37
|
const query = new URLSearchParams({ key: machineToken });
|
|
38
|
-
if (executionBackendCapability(runtimePlatform).supported) {
|
|
38
|
+
if (executionBackendCapability(runtimePlatform, jobObjectProbe).supported) {
|
|
39
39
|
query.set("execution_min", String(EXECUTION_PROTOCOL.min));
|
|
40
40
|
query.set("execution_max", String(EXECUTION_PROTOCOL.max));
|
|
41
41
|
}
|
|
@@ -762,6 +762,7 @@ export function serve(config, opts = {}) {
|
|
|
762
762
|
await reconcileExecutionJournal(executionJournal, {
|
|
763
763
|
agentsRoot: config.agentsRoot,
|
|
764
764
|
serverUrl: config.serverUrl,
|
|
765
|
+
...(opts.profileName === undefined ? {} : { profileName: opts.profileName }),
|
|
765
766
|
log: dslog,
|
|
766
767
|
flush: flushSlog,
|
|
767
768
|
writeStderr: (line) => process.stderr.write(line),
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** 严格、有限的 unified diff 解析器。只有完整文件头 + hunk 才识别,避免误染普通 +/- 日志。 */
|
|
2
|
+
import { CONSOLE_DIFF_ROWS, CONSOLE_ROW_CHARS, } from "./console-payload.js";
|
|
3
|
+
const HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
4
|
+
const clip = (text) => text.length > CONSOLE_ROW_CHARS ? `${text.slice(0, CONSOLE_ROW_CHARS)}…` : text;
|
|
5
|
+
const pathFromHeader = (line) => line.slice(4).split("\t", 1)[0].replace(/^[ab]\//, "");
|
|
6
|
+
export function parseUnifiedDiff(input) {
|
|
7
|
+
const lines = input.split("\n");
|
|
8
|
+
const files = [];
|
|
9
|
+
let additions = 0;
|
|
10
|
+
let deletions = 0;
|
|
11
|
+
let cursor = 0;
|
|
12
|
+
let totalRows = 0;
|
|
13
|
+
let truncated = false;
|
|
14
|
+
while (cursor < lines.length) {
|
|
15
|
+
while (cursor < lines.length && !isFileHeaderPair(lines, cursor))
|
|
16
|
+
cursor++;
|
|
17
|
+
if (cursor >= lines.length)
|
|
18
|
+
break;
|
|
19
|
+
const oldPath = pathFromHeader(lines[cursor]);
|
|
20
|
+
const newPath = pathFromHeader(lines[cursor + 1]);
|
|
21
|
+
const path = newPath === "/dev/null" ? oldPath : newPath;
|
|
22
|
+
cursor += 2;
|
|
23
|
+
const rows = [];
|
|
24
|
+
let sawHunk = false;
|
|
25
|
+
while (cursor < lines.length && !isFileHeaderPair(lines, cursor)) {
|
|
26
|
+
const match = lines[cursor].match(HUNK);
|
|
27
|
+
if (!match) {
|
|
28
|
+
cursor++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
sawHunk = true;
|
|
32
|
+
let oldLine = Number(match[1]);
|
|
33
|
+
let oldRemaining = Number(match[2] ?? 1);
|
|
34
|
+
let newLine = Number(match[3]);
|
|
35
|
+
let newRemaining = Number(match[4] ?? 1);
|
|
36
|
+
cursor++;
|
|
37
|
+
while (cursor < lines.length && (oldRemaining > 0 || newRemaining > 0)) {
|
|
38
|
+
const line = lines[cursor];
|
|
39
|
+
cursor++;
|
|
40
|
+
if (line === "\")
|
|
41
|
+
continue;
|
|
42
|
+
let row;
|
|
43
|
+
if (line.startsWith("+")) {
|
|
44
|
+
row = { type: "add", oldLine: null, newLine, text: clip(line.slice(1)) };
|
|
45
|
+
newLine++;
|
|
46
|
+
newRemaining--;
|
|
47
|
+
additions++;
|
|
48
|
+
}
|
|
49
|
+
else if (line.startsWith("-")) {
|
|
50
|
+
row = { type: "delete", oldLine, newLine: null, text: clip(line.slice(1)) };
|
|
51
|
+
oldLine++;
|
|
52
|
+
oldRemaining--;
|
|
53
|
+
deletions++;
|
|
54
|
+
}
|
|
55
|
+
else if (line.startsWith(" ")) {
|
|
56
|
+
row = { type: "context", oldLine, newLine, text: clip(line.slice(1)) };
|
|
57
|
+
oldLine++;
|
|
58
|
+
newLine++;
|
|
59
|
+
oldRemaining--;
|
|
60
|
+
newRemaining--;
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
break;
|
|
64
|
+
if (totalRows < CONSOLE_DIFF_ROWS) {
|
|
65
|
+
rows.push(row);
|
|
66
|
+
totalRows++;
|
|
67
|
+
}
|
|
68
|
+
else
|
|
69
|
+
truncated = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (sawHunk) {
|
|
73
|
+
if (truncated && !rows.some((row) => row.type === "omitted"))
|
|
74
|
+
rows.push({ type: "omitted", oldCount: 1, newCount: 1 });
|
|
75
|
+
files.push({ path, rows });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!files.length)
|
|
79
|
+
return null;
|
|
80
|
+
return { kind: "diff_rows", files, additions, deletions, ...(truncated ? { truncated: true } : {}) };
|
|
81
|
+
}
|
|
82
|
+
function isFileHeaderPair(lines, index) {
|
|
83
|
+
return lines[index]?.startsWith("--- ") === true && lines[index + 1]?.startsWith("+++ ") === true;
|
|
84
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows Job Object 执行后端的 FFI 封装(仅 win32 生效)。
|
|
3
|
+
*
|
|
4
|
+
* Node 不暴露 Job Object API,这里用 koffi 直接调 kernel32.dll。koffi 是可选依赖
|
|
5
|
+
* (optionalDependencies),用 createRequire 惰性同步加载 —— 加载失败/非 win32 一律抛错,
|
|
6
|
+
* 由上层降级到 fail-closed,不影响 Mac/Linux 路径。
|
|
7
|
+
*
|
|
8
|
+
* 关键语义:job 上设 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 后,持有 job handle 的进程一旦退出
|
|
9
|
+
* (含崩溃),内核立即回收 Job 内所有进程 —— 等价于 POSIX「父死子亡 + 进程组整组清理」,且更强。
|
|
10
|
+
* 因此 job 必须由「离 runtime 最近、崩溃即代表该放弃所有权」的进程(supervisor child)创建并持有。
|
|
11
|
+
*/
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x0000_2000;
|
|
14
|
+
const JOBOBJECT_EXTENDED_LIMIT_INFORMATION = 9;
|
|
15
|
+
const JOBOBJECT_BASIC_ACCOUNTING_INFORMATION = 1;
|
|
16
|
+
const PROCESS_TERMINATE = 0x0001;
|
|
17
|
+
const PROCESS_SET_QUOTA = 0x0100;
|
|
18
|
+
const JOB_ASSIGN_ACCESS = PROCESS_TERMINATE | PROCESS_SET_QUOTA;
|
|
19
|
+
const JOB_EMPTY_POLL_MS = 10;
|
|
20
|
+
// koffi 的 struct 布局/对齐由类型描述自动推导,无需手工塞 padding。
|
|
21
|
+
// size_t/uintptr_t 在 x64/arm64 均为 8 字节,与 SIZE_T/ULONG_PTR 对齐。
|
|
22
|
+
const BASIC_LIMIT_INFORMATION = {
|
|
23
|
+
PerProcessUserTimeLimit: "int64",
|
|
24
|
+
PerJobUserTimeLimit: "int64",
|
|
25
|
+
LimitFlags: "uint32",
|
|
26
|
+
MinimumWorkingSetSize: "size_t",
|
|
27
|
+
MaximumWorkingSetSize: "size_t",
|
|
28
|
+
ActiveProcessLimit: "uint32",
|
|
29
|
+
Affinity: "uintptr_t",
|
|
30
|
+
PriorityClass: "uint32",
|
|
31
|
+
SchedulingClass: "uint32",
|
|
32
|
+
};
|
|
33
|
+
const IO_COUNTERS = {
|
|
34
|
+
ReadOperationCount: "uint64",
|
|
35
|
+
WriteOperationCount: "uint64",
|
|
36
|
+
OtherOperationCount: "uint64",
|
|
37
|
+
ReadTransferCount: "uint64",
|
|
38
|
+
WriteTransferCount: "uint64",
|
|
39
|
+
OtherTransferCount: "uint64",
|
|
40
|
+
};
|
|
41
|
+
let cachedApi = null;
|
|
42
|
+
let loadFailed = false;
|
|
43
|
+
/**
|
|
44
|
+
* 惰性加载并绑定 kernel32。非 win32 或 koffi 不可用时抛错。结果缓存;失败也缓存(不反复重试)。
|
|
45
|
+
*/
|
|
46
|
+
function loadKernel32() {
|
|
47
|
+
if (cachedApi !== null)
|
|
48
|
+
return cachedApi;
|
|
49
|
+
if (loadFailed)
|
|
50
|
+
throw new Error("Windows Job Object backend previously failed to load");
|
|
51
|
+
if (process.platform !== "win32") {
|
|
52
|
+
loadFailed = true;
|
|
53
|
+
throw new Error("Windows Job Object backend is only available on win32");
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
cachedApi = bindKernel32();
|
|
57
|
+
return cachedApi;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
loadFailed = true;
|
|
61
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function bindKernel32() {
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- koffi 无类型声明,单点收窄。
|
|
66
|
+
const koffi = createRequire(import.meta.url)("koffi");
|
|
67
|
+
const lib = koffi.load("kernel32.dll");
|
|
68
|
+
koffi.struct("JOBOBJECT_BASIC_LIMIT_INFORMATION", BASIC_LIMIT_INFORMATION);
|
|
69
|
+
koffi.struct("IO_COUNTERS", IO_COUNTERS);
|
|
70
|
+
koffi.struct("JOBOBJECT_EXTENDED_LIMIT_INFORMATION", {
|
|
71
|
+
BasicLimitInformation: "JOBOBJECT_BASIC_LIMIT_INFORMATION",
|
|
72
|
+
IoInfo: "IO_COUNTERS",
|
|
73
|
+
ProcessMemoryLimit: "size_t",
|
|
74
|
+
JobMemoryLimit: "size_t",
|
|
75
|
+
PeakProcessMemoryUsed: "size_t",
|
|
76
|
+
PeakJobMemoryUsed: "size_t",
|
|
77
|
+
});
|
|
78
|
+
koffi.struct("JOBOBJECT_BASIC_ACCOUNTING_INFORMATION", {
|
|
79
|
+
TotalUserTime: "int64",
|
|
80
|
+
TotalKernelTime: "int64",
|
|
81
|
+
ThisPeriodTotalUserTime: "int64",
|
|
82
|
+
ThisPeriodTotalKernelTime: "int64",
|
|
83
|
+
TotalPageFaultCount: "uint32",
|
|
84
|
+
TotalProcesses: "uint32",
|
|
85
|
+
ActiveProcesses: "uint32",
|
|
86
|
+
TotalTerminatedProcesses: "uint32",
|
|
87
|
+
});
|
|
88
|
+
const CreateJobObjectW = lib.func("void* __stdcall CreateJobObjectW(void* attrs, void* name)");
|
|
89
|
+
const SetInformationJobObject = lib.func("bool __stdcall SetInformationJobObject(void* job, int cls, void* info, uint32_t len)");
|
|
90
|
+
const OpenProcess = lib.func("void* __stdcall OpenProcess(uint32_t access, bool inherit, uint32_t pid)");
|
|
91
|
+
const AssignProcessToJobObject = lib.func("bool __stdcall AssignProcessToJobObject(void* job, void* process)");
|
|
92
|
+
const TerminateJobObject = lib.func("bool __stdcall TerminateJobObject(void* job, uint32_t code)");
|
|
93
|
+
const QueryInformationJobObject = lib.func("bool __stdcall QueryInformationJobObject(void* job, int cls, _Out_ void* info, uint32_t len, void* ret)");
|
|
94
|
+
const CloseHandle = lib.func("bool __stdcall CloseHandle(void* handle)");
|
|
95
|
+
const GetLastError = lib.func("uint32_t __stdcall GetLastError()");
|
|
96
|
+
return {
|
|
97
|
+
createJobObject: () => CreateJobObjectW(null, null),
|
|
98
|
+
setKillOnClose: (job) => {
|
|
99
|
+
// 只需把 LimitFlags 置上 KILL_ON_JOB_CLOSE,其余字段留 0。用 Buffer + offsetof 直写,
|
|
100
|
+
// 规避 koffi.encode 的入参数量怪癖;offset 由 koffi 从结构体推导,x64/arm64 一致。
|
|
101
|
+
const size = koffi.sizeof("JOBOBJECT_EXTENDED_LIMIT_INFORMATION");
|
|
102
|
+
const flagsOffset = koffi.offsetof("JOBOBJECT_BASIC_LIMIT_INFORMATION", "LimitFlags");
|
|
103
|
+
const buffer = Buffer.alloc(size);
|
|
104
|
+
buffer.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, flagsOffset);
|
|
105
|
+
return SetInformationJobObject(job, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, buffer, size);
|
|
106
|
+
},
|
|
107
|
+
openProcess: (access, inherit, pid) => OpenProcess(access, inherit, pid),
|
|
108
|
+
assignProcessToJob: (job, process) => AssignProcessToJobObject(job, process),
|
|
109
|
+
terminateJobObject: (job, exitCode) => TerminateJobObject(job, exitCode),
|
|
110
|
+
queryActiveProcessCount: (job) => {
|
|
111
|
+
const size = koffi.sizeof("JOBOBJECT_BASIC_ACCOUNTING_INFORMATION");
|
|
112
|
+
const activeOffset = koffi.offsetof("JOBOBJECT_BASIC_ACCOUNTING_INFORMATION", "ActiveProcesses");
|
|
113
|
+
const buffer = Buffer.alloc(size);
|
|
114
|
+
const ok = QueryInformationJobObject(job, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, buffer, size, null);
|
|
115
|
+
if (!ok)
|
|
116
|
+
throw new Error(`QueryInformationJobObject failed (GetLastError=${GetLastError()})`);
|
|
117
|
+
return buffer.readUInt32LE(activeOffset);
|
|
118
|
+
},
|
|
119
|
+
closeHandle: (handle) => CloseHandle(handle),
|
|
120
|
+
getLastError: () => GetLastError(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 同步探测:能否创建带 KILL_ON_JOB_CLOSE 的 Job Object。用于 backend 能力判定。
|
|
125
|
+
* 非 win32 / koffi 缺失 / API 调用失败 → false(fail-closed 降级 legacy)。副作用为零(建后即关)。
|
|
126
|
+
*/
|
|
127
|
+
export function isJobObjectSupported() {
|
|
128
|
+
if (process.platform !== "win32")
|
|
129
|
+
return false;
|
|
130
|
+
try {
|
|
131
|
+
const handle = createKillOnCloseJob();
|
|
132
|
+
closeJob(handle);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** 创建带 KILL_ON_JOB_CLOSE 的 Job Object。失败抛错。 */
|
|
140
|
+
export function createKillOnCloseJob() {
|
|
141
|
+
const api = loadKernel32();
|
|
142
|
+
const native = api.createJobObject();
|
|
143
|
+
if (!native)
|
|
144
|
+
throw new Error(`CreateJobObjectW failed (GetLastError=${api.getLastError()})`);
|
|
145
|
+
if (!api.setKillOnClose(native)) {
|
|
146
|
+
const code = api.getLastError();
|
|
147
|
+
api.closeHandle(native);
|
|
148
|
+
throw new Error(`SetInformationJobObject(KILL_ON_JOB_CLOSE) failed (GetLastError=${code})`);
|
|
149
|
+
}
|
|
150
|
+
return { native };
|
|
151
|
+
}
|
|
152
|
+
/** 把 pid 对应进程塞进 Job。进程随后 fork 的子孙默认继承 Job(未设 SILENT_BREAKAWAY,禁止逃逸)。 */
|
|
153
|
+
export function assignProcessToJob(handle, pid) {
|
|
154
|
+
const api = loadKernel32();
|
|
155
|
+
const process = api.openProcess(JOB_ASSIGN_ACCESS, false, pid);
|
|
156
|
+
if (!process)
|
|
157
|
+
throw new Error(`OpenProcess(${pid}) failed (GetLastError=${api.getLastError()})`);
|
|
158
|
+
try {
|
|
159
|
+
if (!api.assignProcessToJob(handle.native, process)) {
|
|
160
|
+
throw new Error(`AssignProcessToJobObject(${pid}) failed (GetLastError=${api.getLastError()})`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
api.closeHandle(process);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** 一次性杀光 Job 内所有进程(超时/取消/runtime 挂了 supervisor 还在)。 */
|
|
168
|
+
export function terminateJob(handle, exitCode = 1) {
|
|
169
|
+
const api = loadKernel32();
|
|
170
|
+
if (!api.terminateJobObject(handle.native, exitCode)) {
|
|
171
|
+
throw new Error(`TerminateJobObject failed (GetLastError=${api.getLastError()})`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** 关闭 job handle。若为最后一个引用且设了 KILL_ON_JOB_CLOSE,内核连带杀光 Job 内进程。 */
|
|
175
|
+
export function closeJob(handle) {
|
|
176
|
+
const api = loadKernel32();
|
|
177
|
+
api.closeHandle(handle.native);
|
|
178
|
+
}
|
|
179
|
+
/** 当前 Job 内活跃进程数(BasicAccountingInformation.ActiveProcesses)。 */
|
|
180
|
+
export function activeProcessCount(handle) {
|
|
181
|
+
return loadKernel32().queryActiveProcessCount(handle.native);
|
|
182
|
+
}
|
|
183
|
+
/** 轮询等待 Job 清空(整组退出确认),复用 10ms 节奏。超时抛错。 */
|
|
184
|
+
export async function waitForJobEmpty(handle, timeoutMs, pollMs = JOB_EMPTY_POLL_MS) {
|
|
185
|
+
const deadline = Date.now() + timeoutMs;
|
|
186
|
+
while (true) {
|
|
187
|
+
if (activeProcessCount(handle) === 0)
|
|
188
|
+
return;
|
|
189
|
+
if (Date.now() >= deadline)
|
|
190
|
+
throw new Error(`Job Object did not empty within ${timeoutMs}ms`);
|
|
191
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
192
|
+
}
|
|
193
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowcrew/daemon",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.22",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"zod": "^3.23.0",
|
|
24
24
|
"@nowcrew/cli": "^0.4.6"
|
|
25
25
|
},
|
|
26
|
+
"optionalDependencies": {
|
|
27
|
+
"koffi": "^2.9.0"
|
|
28
|
+
},
|
|
26
29
|
"devDependencies": {
|
|
27
30
|
"@types/cross-spawn": "^6.0.6",
|
|
28
31
|
"@types/node": "^22.0.0",
|