@nowcrew/daemon 0.5.20 → 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.
@@ -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: "protocol-v1 is disabled until a Windows Job Object backend owns every runtime process",
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
+ }
@@ -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
- dependencies.log("execution.recovery_failed", "execution journal 恢复失败", {
26
- level: "ERROR",
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: "ERROR",
31
- event_type: "execution.recovery_failed",
32
- message: "execution journal 恢复失败",
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
- recovery_error_type: errorType,
47
- recovery_error_message: errorMessage,
53
+ ...(alreadyRunning
54
+ ? { startup_error_type: errorType, startup_error_message: errorMessage }
55
+ : { recovery_error_type: errorType, recovery_error_message: errorMessage }),
48
56
  };
49
- dependencies.log("execution.recovery_cleanup_failed", "execution journal 恢复失败后的清理失败", { level: "ERROR", ...cleanupDiagnostics });
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: "execution.recovery_cleanup_failed",
53
- message: "execution journal 恢复失败后的清理失败",
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
  }
@@ -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")
@@ -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 message = e instanceof ProfileAgentsRootConflictError
104
- ? formatDaemonText(detectDaemonLang(), PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, {
105
- profile: e.profile,
106
- conflict: e.conflict,
107
- agentsRoot: e.agentsRoot,
108
- command: e.command,
109
- })
110
- : e.message;
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,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.20",
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.6"
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",