@nowcrew/daemon 0.5.19 → 0.5.21

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/slog.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * 所以补传的日志仍落在正确时间点,时间线不乱;补传条目带 spooled=true + reported_at 归因。
7
7
  * - 上报失败(server 不可达/断网)→ 落本地 spool(~/.crew/logs/sls-spool/*.jsonl);
8
8
  * 下次 WS 重连成功(drainSpool)时补传。「daemon 为什么断开」的现场就靠这批日志还原。
9
- * - 进程退出(SIGINT/SIGTERM/exit)→ 残余队列同步落 spool,下次启动补传。
9
+ * - 进程退出(exit)→ 残余队列同步落 spool,下次启动补传。异步信号由 main.ts 统一收口。
10
10
  *
11
11
  * 永不抛错、永不阻塞业务;未 init 时所有调用是 no-op(兼容单测/一次性 run 模式无凭证场景)。
12
12
  */
@@ -22,8 +22,10 @@ let cfg = null;
22
22
  let defaults = {};
23
23
  let queue = [];
24
24
  let timer = null;
25
- let flushing = false;
25
+ let flushPromise = null;
26
+ let inFlightBatch = [];
26
27
  let lastErrorAt = 0;
28
+ let exitHookInstalled = false;
27
29
  function spoolDir() {
28
30
  return process.env.CREW_SLS_SPOOL_DIR ?? join(homedir(), ".crew", "logs", "sls-spool");
29
31
  }
@@ -34,12 +36,9 @@ export function initSlog(serverUrl, machineToken) {
34
36
  cfg = { serverUrl: serverUrl.replace(/\/+$/, ""), token: machineToken };
35
37
  defaults = { host: hostname(), pid: process.pid };
36
38
  // 退出兜底:残余队列同步落 spool(exit 回调只能做同步工作,append 正合适)
37
- process.once("exit", () => spoolRemainingSync());
38
- for (const sig of ["SIGINT", "SIGTERM"]) {
39
- process.once(sig, () => {
40
- spoolRemainingSync();
41
- process.exit(sig === "SIGINT" ? 130 : 143);
42
- });
39
+ if (!exitHookInstalled) {
40
+ exitHookInstalled = true;
41
+ process.once("exit", () => spoolRemainingSync());
43
42
  }
44
43
  }
45
44
  /** 追加默认关联字段(如 ready 帧下发的 machine_id),之后每条日志自动带上。 */
@@ -73,20 +72,34 @@ export async function flushSlog() {
73
72
  clearTimeout(timer);
74
73
  timer = null;
75
74
  }
76
- if (!cfg || flushing || queue.length === 0)
75
+ if (!cfg)
77
76
  return;
78
- flushing = true;
79
- const batch = queue;
80
- queue = [];
77
+ if (flushPromise !== null)
78
+ return flushPromise;
79
+ const current = (async () => {
80
+ while (queue.length > 0) {
81
+ const batch = queue;
82
+ queue = [];
83
+ inFlightBatch = batch;
84
+ try {
85
+ await post(batch);
86
+ }
87
+ catch (e) {
88
+ appendSpool(batch);
89
+ warnThrottled(`SLS 日志上报失败,已落本地 spool(${batch.length} 条): ${e.message}`);
90
+ }
91
+ finally {
92
+ inFlightBatch = [];
93
+ }
94
+ }
95
+ })();
96
+ flushPromise = current;
81
97
  try {
82
- await post(batch);
83
- }
84
- catch (e) {
85
- appendSpool(batch);
86
- warnThrottled(`SLS 日志上报失败,已落本地 spool(${batch.length} 条): ${e.message}`);
98
+ await current;
87
99
  }
88
100
  finally {
89
- flushing = false;
101
+ if (flushPromise === current)
102
+ flushPromise = null;
90
103
  }
91
104
  }
92
105
  /**
@@ -190,9 +203,10 @@ function rotateSpool(dir) {
190
203
  catch { /* 忽略 */ }
191
204
  }
192
205
  function spoolRemainingSync() {
193
- if (queue.length === 0)
206
+ if (inFlightBatch.length === 0 && queue.length === 0)
194
207
  return;
195
- const batch = queue;
208
+ const batch = [...inFlightBatch, ...queue];
209
+ inFlightBatch = [];
196
210
  queue = [];
197
211
  appendSpool(batch);
198
212
  }
@@ -0,0 +1,104 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { startDormantSupervisor, } from "./execution-supervisor.js";
3
+ import { buildClaudeArgs } from "./runtimes/claude.js";
4
+ import { RuntimeCancelledError } from "./runtime-cancellation.js";
5
+ export function supervisorLaunch(request) {
6
+ const common = {
7
+ wakePrompt: request.wakePrompt,
8
+ dangerous: request.effectivePermission === "full_access",
9
+ effectivePermission: request.effectivePermission,
10
+ ...(request.model === undefined ? {} : { model: request.model }),
11
+ ...(request.reasoning === undefined ? {} : { reasoning: request.reasoning }),
12
+ };
13
+ if (request.runtime === "claude") {
14
+ return {
15
+ command: request.bin,
16
+ args: buildClaudeArgs({
17
+ ...common,
18
+ bin: request.bin,
19
+ cwd: request.cwd,
20
+ env: request.env,
21
+ systemPromptPath: request.systemPromptPath,
22
+ ...(request.sessionId === undefined ? {} : {
23
+ sessionId: request.sessionId,
24
+ resume: request.resume,
25
+ }),
26
+ }),
27
+ cwd: request.cwd,
28
+ env: request.env,
29
+ };
30
+ }
31
+ if (request.runtime === "codex") {
32
+ return {
33
+ command: process.execPath,
34
+ args: [
35
+ fileURLToPath(new URL("./runtimes/codex-app-server-runner.js", import.meta.url)),
36
+ "--bin", request.bin,
37
+ ],
38
+ cwd: request.cwd,
39
+ env: request.env,
40
+ stdinText: JSON.stringify({
41
+ systemPrompt: request.systemPrompt,
42
+ wakePrompt: request.wakePrompt,
43
+ effectivePermission: request.effectivePermission,
44
+ ...(request.model === undefined ? {} : { model: request.model }),
45
+ ...(request.reasoning === undefined ? {} : { reasoning: request.reasoning }),
46
+ ...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }),
47
+ ...(request.imagePaths === undefined ? {} : { imagePaths: request.imagePaths }),
48
+ resume: request.resume,
49
+ }),
50
+ };
51
+ }
52
+ if (request.effectivePermission !== "full_access") {
53
+ throw new Error(`Kimi ACP cannot enforce ${request.effectivePermission} permission`);
54
+ }
55
+ return {
56
+ command: process.execPath,
57
+ args: [
58
+ fileURLToPath(new URL("./runtimes/kimi-acp-runner.js", import.meta.url)),
59
+ "--bin", request.bin,
60
+ ...(request.model === undefined ? [] : ["--model", request.model]),
61
+ ...(request.sessionId === undefined ? [] : ["--session", request.sessionId]),
62
+ ...(request.resume ? ["--resume"] : []),
63
+ ],
64
+ cwd: request.cwd,
65
+ env: request.env,
66
+ stdinText: `${request.systemPrompt}\n\n${request.wakePrompt}`,
67
+ };
68
+ }
69
+ export async function launchSupervisedRuntime(request, startSupervisor = startDormantSupervisor, cancellation, platform) {
70
+ if (cancellation?.isRequested())
71
+ throw new RuntimeCancelledError();
72
+ const supervisor = await startSupervisor(supervisorLaunch(request), {
73
+ ownershipMode: "process-lifetime",
74
+ ...(platform === undefined ? {} : { platform }),
75
+ });
76
+ let stopPromise = null;
77
+ const cancelOnce = () => {
78
+ stopPromise ??= Promise.resolve().then(supervisor.cancel);
79
+ return stopPromise;
80
+ };
81
+ cancellation?.register(cancelOnce);
82
+ if (cancellation?.isRequested()) {
83
+ await cancellation.waitForStop();
84
+ throw new RuntimeCancelledError();
85
+ }
86
+ const release = supervisor.release();
87
+ if (cancellation === undefined) {
88
+ await release;
89
+ }
90
+ else {
91
+ await Promise.race([
92
+ release,
93
+ cancellation.requested.then(async () => {
94
+ await cancellation.waitForStop();
95
+ throw new RuntimeCancelledError();
96
+ }),
97
+ ]);
98
+ }
99
+ if (cancellation?.isRequested()) {
100
+ await cancellation.waitForStop();
101
+ throw new RuntimeCancelledError();
102
+ }
103
+ return { ...supervisor, cancel: cancelOnce };
104
+ }
@@ -0,0 +1,53 @@
1
+ import { WebSocket } from "ws";
2
+ export function closeWebSocketWithinDeadline(socket, signal) {
3
+ if (socket === null || socket.readyState === WebSocket.CLOSED)
4
+ return Promise.resolve();
5
+ return new Promise((resolve, reject) => {
6
+ let settled = false;
7
+ const cleanup = () => {
8
+ socket.removeListener("close", onClose);
9
+ socket.removeListener("error", onError);
10
+ signal.removeEventListener("abort", onAbort);
11
+ };
12
+ const settle = (outcome) => {
13
+ if (settled)
14
+ return;
15
+ settled = true;
16
+ cleanup();
17
+ outcome();
18
+ };
19
+ const onClose = () => settle(resolve);
20
+ const onError = () => {
21
+ if (socket.readyState === WebSocket.CLOSED)
22
+ onClose();
23
+ };
24
+ const onAbort = () => {
25
+ settle(() => {
26
+ if (socket.readyState !== WebSocket.CLOSED) {
27
+ try {
28
+ socket.terminate();
29
+ }
30
+ catch { /* deadline error remains authoritative */ }
31
+ }
32
+ reject(signal.reason);
33
+ });
34
+ };
35
+ socket.on("close", onClose);
36
+ socket.on("error", onError);
37
+ signal.addEventListener("abort", onAbort, { once: true });
38
+ if (signal.aborted) {
39
+ onAbort();
40
+ }
41
+ else if (socket.readyState === WebSocket.CLOSED) {
42
+ onClose();
43
+ }
44
+ else if (socket.readyState !== WebSocket.CLOSING) {
45
+ try {
46
+ socket.close();
47
+ }
48
+ catch (error) {
49
+ settle(() => reject(error));
50
+ }
51
+ }
52
+ });
53
+ }
@@ -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.19",
3
+ "version": "0.5.21",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -21,7 +21,10 @@
21
21
  "cross-spawn": "^7.0.6",
22
22
  "ws": "^8",
23
23
  "zod": "^3.23.0",
24
- "@nowcrew/cli": "^0.4.11"
24
+ "@nowcrew/cli": "^0.4.12"
25
+ },
26
+ "optionalDependencies": {
27
+ "koffi": "^2.9.0"
25
28
  },
26
29
  "devDependencies": {
27
30
  "@types/cross-spawn": "^6.0.6",