@zhushanwen/subagent-core 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -2
- package/agents/analyst.md +60 -0
- package/agents/coder.md +69 -0
- package/agents/debugger.md +66 -0
- package/agents/doc-reviewer.md +49 -0
- package/agents/explorer.md +63 -0
- package/agents/general-purpose.md +32 -0
- package/agents/orchestrator.md +61 -0
- package/agents/planner.md +53 -0
- package/agents/researcher.md +67 -0
- package/agents/reviewer.md +73 -0
- package/dist/chunk-4KN4TTG7.js +240 -0
- package/dist/chunk-APZY4IME.js +27 -0
- package/dist/chunk-X7SZ5HLQ.js +66 -0
- package/dist/execution/engine/engines/zcode/constants.cjs +65 -0
- package/dist/execution/engine/engines/zcode/constants.d.cts +93 -1
- package/dist/execution/engine/engines/zcode/constants.d.ts +93 -1
- package/dist/execution/engine/engines/zcode/constants.js +43 -1
- package/dist/execution/engine/engines/zcode/reader.d.cts +1 -1
- package/dist/execution/engine/engines/zcode/reader.d.ts +1 -1
- package/dist/execution/engine/engines/zcode/reader.js +4 -234
- package/dist/execution/engine/paths.js +7 -19
- package/dist/index.cjs +14709 -100
- package/dist/index.d.cts +4053 -200
- package/dist/index.d.ts +4053 -200
- package/dist/index.js +14358 -102
- package/dist/{types-BxyAidGf.d.cts → types-DpUO16pj.d.cts} +464 -1
- package/dist/{types-BxyAidGf.d.ts → types-DpUO16pj.d.ts} +464 -1
- package/package.json +6 -3
- package/src/__tests__/review-fix-loop-script.test.ts +90 -2
- package/src/__tests__/review-fix-loop-utils.test.ts +21 -0
- package/src/__tests__/smoke.test.ts +9 -2
- package/src/execution/__tests__/agent-profile.test.ts +232 -0
- package/src/execution/__tests__/agents-assembly.test.ts +219 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +71 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +1 -0
- package/src/execution/__tests__/create-concurrency-pool.test.ts +194 -0
- package/src/execution/__tests__/delivery-methods.test.ts +2 -0
- package/src/execution/__tests__/descendant-sweep.test.ts +269 -0
- package/src/execution/__tests__/dialog-queue.test.ts +199 -1
- package/src/execution/__tests__/epipe-fallback.test.ts +2 -0
- package/src/execution/__tests__/execution-runtime-face.test.ts +272 -0
- package/src/execution/__tests__/finalize-record.test.ts +106 -0
- package/src/execution/__tests__/gc-timer.test.ts +2 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +2 -0
- package/src/execution/__tests__/get-state-handshake.test.ts +127 -0
- package/src/execution/__tests__/keep-alive-no-progress.test.ts +378 -0
- package/src/execution/__tests__/kill-all-escalation.test.ts +196 -0
- package/src/execution/__tests__/lifecycle-manager-idle-timer-identity.test.ts +117 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +39 -0
- package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +99 -0
- package/src/execution/__tests__/max-turns-to-watchdog-ms.test.ts +113 -0
- package/src/execution/__tests__/notify-ledger.test.ts +294 -0
- package/src/execution/__tests__/record-store-orphan-revive.test.ts +159 -0
- package/src/execution/__tests__/record-store.test.ts +83 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +4 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +1 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +407 -17
- package/src/execution/__tests__/run-spawn-integration.test.ts +1 -1
- package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +12 -4
- package/src/execution/__tests__/service-kill-escalation.test.ts +89 -0
- package/src/execution/__tests__/session-pending.test.ts +228 -0
- package/src/execution/__tests__/session-runner-branch-cache-lru.test.ts +110 -0
- package/src/execution/__tests__/session-runner-close-prune.test.ts +181 -0
- package/src/execution/__tests__/session-runner-epipe.test.ts +1 -0
- package/src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts +226 -0
- package/src/execution/__tests__/settled-watchdog.test.ts +289 -0
- package/src/execution/__tests__/spawned-children.test.ts +153 -10
- package/src/execution/__tests__/subagent-actions-core.test.ts +997 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +71 -10
- package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +174 -0
- package/src/execution/__tests__/subagent-service-notify-gate.test.ts +266 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +3 -0
- package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +316 -0
- package/src/execution/__tests__/timeout-integration.test.ts +7 -7
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +95 -1
- package/src/execution/__tests__/ui-request-queue.test.ts +25 -0
- package/src/execution/__tests__/worktree-git-ops.test.ts +571 -0
- package/src/execution/__tests__/worktree-reconcile-aging.test.ts +204 -0
- package/src/execution/agent-registry.ts +225 -1
- package/src/execution/agents-assembly.ts +88 -0
- package/src/execution/concurrency-pool.ts +71 -9
- package/src/execution/dialog-queue.ts +101 -3
- package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +49 -2
- package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +87 -1
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +26 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +83 -1
- package/src/execution/engine/__tests__/conformance/zcode-appserver-harness.ts +131 -0
- package/src/execution/engine/__tests__/registry.test.ts +90 -1
- package/src/execution/engine/engine-discovery.ts +12 -16
- package/src/execution/engine/engines/pi/task-spec-mapper.ts +3 -3
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/fake-appserver.mjs +274 -0
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-appserver.json +36 -0
- package/src/execution/engine/engines/zcode/__tests__/connection.test.ts +472 -0
- package/src/execution/engine/engines/zcode/__tests__/preparer-appserver.test.ts +387 -0
- package/src/execution/engine/engines/zcode/__tests__/session-channel.test.ts +780 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-appserver.test.ts +815 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +462 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +119 -1
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +136 -6
- package/src/execution/engine/engines/zcode/appserver-home.ts +442 -0
- package/src/execution/engine/engines/zcode/appserver-probe.ts +141 -0
- package/src/execution/engine/engines/zcode/connection.ts +585 -0
- package/src/execution/engine/engines/zcode/constants.ts +129 -0
- package/src/execution/engine/engines/zcode/golden-sample.ts +54 -8
- package/src/execution/engine/engines/zcode/preparer.ts +25 -22
- package/src/execution/engine/engines/zcode/session-channel.ts +656 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +909 -41
- package/src/execution/engine/host-task-spec.ts +3 -3
- package/src/execution/engine/port.ts +41 -1
- package/src/execution/engine/registry.ts +53 -0
- package/src/execution/finalize-record.ts +63 -5
- package/src/execution/get-state-handshake.ts +85 -10
- package/src/execution/lifecycle-manager.ts +27 -3
- package/src/execution/manifest-store.ts +53 -62
- package/src/execution/notifier.ts +10 -10
- package/src/execution/notify-ledger.ts +117 -16
- package/src/execution/record-entry.ts +8 -2
- package/src/execution/record-store.ts +29 -15
- package/src/execution/session-pending.ts +213 -62
- package/src/execution/session-runner.ts +974 -128
- package/src/execution/sessions-index.ts +10 -55
- package/src/execution/settled-watchdog.ts +99 -0
- package/src/execution/subagent-actions-core.ts +686 -0
- package/src/execution/subagent-service.ts +396 -49
- package/src/execution/ui-request-handler-factory.ts +27 -4
- package/src/execution/worktree-git-ops.ts +397 -0
- package/src/execution/worktree-manager.ts +72 -10
- package/src/execution/worktree-registry.ts +6 -12
- package/src/index.ts +413 -6
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +42 -3
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -4
- package/src/orchestration/__tests__/args-meta.test.ts +358 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +1 -8
- package/src/orchestration/__tests__/error-recovery-rebuild-failure.test.ts +312 -0
- package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +382 -0
- package/src/orchestration/__tests__/file-run-store-prune.test.ts +168 -0
- package/src/orchestration/__tests__/file-run-store-throttle.test.ts +168 -0
- package/src/orchestration/__tests__/file-run-store.test.ts +389 -0
- package/src/orchestration/__tests__/helpers/flush-microtasks.ts +13 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +34 -0
- package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +357 -0
- package/src/orchestration/__tests__/lifecycle-recover-crashed.test.ts +289 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +77 -0
- package/src/orchestration/__tests__/run-snapshot.test.ts +353 -0
- package/src/orchestration/__tests__/script-generate.test.ts +314 -0
- package/src/orchestration/__tests__/worker-pending-timeout-abort.test.ts +275 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +78 -1
- package/src/orchestration/__tests__/workflow-files.test.ts +186 -0
- package/src/orchestration/__tests__/workflow-run-summary.test.ts +119 -0
- package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +124 -0
- package/src/orchestration/agent-opts-resolver.ts +7 -7
- package/src/orchestration/args-meta.ts +198 -0
- package/src/orchestration/error-recovery.ts +416 -146
- package/src/orchestration/execute-agent-call.ts +9 -9
- package/src/orchestration/file-run-store.ts +327 -0
- package/src/orchestration/launcher.ts +35 -26
- package/src/orchestration/lifecycle.ts +355 -79
- package/src/orchestration/models/agent-call.ts +7 -7
- package/src/orchestration/models/budget.ts +5 -5
- package/src/orchestration/models/run-runtime.ts +13 -13
- package/src/orchestration/models/trace.ts +8 -8
- package/src/orchestration/models/workflow-run.ts +27 -27
- package/src/orchestration/models/workflow-script.ts +4 -4
- package/src/orchestration/run-snapshot.ts +266 -0
- package/src/orchestration/script-generate.ts +154 -0
- package/src/orchestration/script-lint.ts +33 -33
- package/src/orchestration/worker-handle.ts +10 -10
- package/src/orchestration/worker-host.ts +10 -10
- package/src/orchestration/worker-script-builder.ts +401 -346
- package/src/orchestration/workflow-files.ts +37 -11
- package/src/orchestration/workflow-run-summary.ts +69 -0
- package/src/orchestration/workflow-script-registry-impl.ts +31 -9
- package/src/shared/__tests__/agent-ref.test.ts +209 -2
- package/src/shared/__tests__/atomic-write.test.ts +267 -0
- package/src/shared/__tests__/bounded-serialize.test.ts +236 -0
- package/src/shared/__tests__/injection-render.test.ts +518 -0
- package/src/shared/__tests__/resource-discovery-host-roots.test.ts +474 -0
- package/src/shared/__tests__/resource-discovery.test.ts +3 -2
- package/src/shared/agent-ref.ts +143 -2
- package/src/shared/atomic-write.ts +320 -0
- package/src/shared/bounded-serialize.ts +154 -0
- package/src/shared/injection-render.ts +279 -0
- package/src/shared/meta-parser.ts +41 -1
- package/src/shared/resource-discovery.ts +104 -37
- package/src/shared/resource-meta.ts +14 -0
- package/src/shared/xml-injection.ts +9 -9
- package/workflows/README.md +9 -9
- package/workflows/chain.js +4 -2
- package/workflows/map-reduce.js +5 -3
- package/workflows/parallel.js +5 -3
- package/workflows/review-fix-loop-utils.cjs +14 -3
- package/workflows/review-fix-loop.js +18 -7
- package/workflows/scatter-gather.js +4 -2
- package/dist/chunk-3VOERJPJ.js +0 -22
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// zcode-engine.test.ts —— EnginePort 四面单测(fake launcher/源 config,不依赖真机
|
|
2
2
|
// CLI 与真凭据;真机链路见 zcode-engine.live.test.ts 的手动门)。覆盖验收 6 的
|
|
3
3
|
// capabilities/probe 部分 + run 错误语义三条(§3.3.5)。
|
|
4
|
+
// [R4] 本文件钉扎 spawn 单轮路径(processEnv 显式 XYZ_ZCODE_MODE=spawn——D2 兜底
|
|
5
|
+
// 行为零改动回归;缺省已切 app-server 常驻,见 zcode-engine-appserver.test.ts)。
|
|
4
6
|
|
|
5
7
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
6
8
|
import * as fs from "node:fs";
|
|
@@ -13,6 +15,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
13
15
|
|
|
14
16
|
import type { RunContext } from "../../../port.ts";
|
|
15
17
|
import type { AgentEvent, AgentTaskSpec, EngineHandle } from "../../../types.ts";
|
|
18
|
+
import { getLogger } from "../../../../../core/logger.ts";
|
|
16
19
|
import { HOST_TIMEOUT_ABORT_REASON } from "../../../common/kill-chain.ts";
|
|
17
20
|
import { ZCODE_GOLDEN_STDOUT } from "../golden-sample.ts";
|
|
18
21
|
import type { ZcodeLaunchedProcess } from "../launcher.ts";
|
|
@@ -20,6 +23,9 @@ import { ZcodeEngine, type ZcodeEngineDeps } from "../zcode-engine.ts";
|
|
|
20
23
|
|
|
21
24
|
const PROVIDER = "test-provider";
|
|
22
25
|
|
|
26
|
+
/** 与 zcode-engine.ts 同一 facade 单例引用(spy 它的方法即拦截引擎的 warn 出声)。 */
|
|
27
|
+
const subagentsLogger = getLogger("subagents");
|
|
28
|
+
|
|
23
29
|
let tmpRoot: string;
|
|
24
30
|
let dataDir: string;
|
|
25
31
|
let v2Path: string;
|
|
@@ -84,7 +90,8 @@ function makeEngine(overrides?: Partial<ZcodeEngineDeps>): ZcodeEngine {
|
|
|
84
90
|
return new ZcodeEngine({
|
|
85
91
|
engineDataDir: () => dataDir,
|
|
86
92
|
sources: { v2ConfigPath: v2Path },
|
|
87
|
-
|
|
93
|
+
// [R4] 钉扎 spawn 模式(缺省已是 appserver 常驻——本文件是 spawn 兜底路径的回归)
|
|
94
|
+
processEnv: { PATH: "/usr/bin", XYZ_ZCODE_MODE: "spawn" },
|
|
88
95
|
...overrides,
|
|
89
96
|
});
|
|
90
97
|
}
|
|
@@ -99,14 +106,14 @@ function makeCtx(overrides?: Partial<RunContext>): RunContext {
|
|
|
99
106
|
|
|
100
107
|
// ── capabilities ──
|
|
101
108
|
|
|
102
|
-
describe("capabilities(D3 声明,验收 6)", () => {
|
|
103
|
-
it("zcode
|
|
109
|
+
describe("capabilities(D3 声明,验收 6;R4 D5:eventGranularity 升 stream)", () => {
|
|
110
|
+
it("zcode 十项声明(spawn 路径钉扎下声明同为 stream——能力位是引擎级,不随模式分派)", () => {
|
|
104
111
|
expect(makeEngine().capabilities()).toEqual({
|
|
105
112
|
schemaEnforcement: "emulated",
|
|
106
113
|
steer: "unsupported",
|
|
107
114
|
conversation: "unsupported",
|
|
108
115
|
personaInjection: "prompt",
|
|
109
|
-
eventGranularity: "
|
|
116
|
+
eventGranularity: "stream",
|
|
110
117
|
sandbox: "none",
|
|
111
118
|
sessionRead: "full",
|
|
112
119
|
resume: "cold",
|
|
@@ -271,9 +278,36 @@ describe("run ② 成功路径:golden stdout → outcome/handle/事件合成",
|
|
|
271
278
|
expect(call.args).toContain("--cwd");
|
|
272
279
|
expect(call.args[call.args.indexOf("--cwd") + 1]).toBe("/work/dir");
|
|
273
280
|
expect(call.args).toContain("--disallowed-tools");
|
|
274
|
-
// prompt = persona 段在前 + task 正文在后(personaInjection: 'prompt'
|
|
281
|
+
// prompt = persona 段在前 + task 正文在后(personaInjection: 'prompt')。
|
|
282
|
+
// [S5] persona 段现经 common/persona-router.applyPersona 统一拼装(## Persona
|
|
283
|
+
// 结构头 + appendSystemPrompt 正文 join "\n")——旧断言 "PERSONA A\n\nPERSONA B"
|
|
284
|
+
// 锚定的是被本次接线推翻的手拼形态。
|
|
275
285
|
const promptIdx = call.args.indexOf("--prompt");
|
|
276
|
-
expect(call.args[promptIdx + 1]).toBe("
|
|
286
|
+
expect(call.args[promptIdx + 1]).toBe("## Persona\nPERSONA A\nPERSONA B\n\nTASK BODY");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("persona 的 agentRef/skillPath 引用行拼进 prompt(S5:prompt 通道不再丢弃,zcode 无 --skill flag)", async () => {
|
|
290
|
+
const fake = makeFakeLaunch({ stdout: ZCODE_GOLDEN_STDOUT });
|
|
291
|
+
const engine = makeEngine({ launch: fake.launch });
|
|
292
|
+
await engine.run(
|
|
293
|
+
makeTask({
|
|
294
|
+
task: "TASK BODY",
|
|
295
|
+
persona: { agentRef: "reviewer", skillPath: "/skills/review.md", appendSystemPrompt: ["RULE X"] },
|
|
296
|
+
}),
|
|
297
|
+
makeCtx(),
|
|
298
|
+
);
|
|
299
|
+
const promptIdx = fake.calls[0]!.args.indexOf("--prompt");
|
|
300
|
+
expect(fake.calls[0]!.args[promptIdx + 1]).toBe(
|
|
301
|
+
"## Persona\n# Agent: reviewer\nSkill context: /skills/review.md\nRULE X\n\nTASK BODY",
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("persona 全空:prompt 不带 Persona 段(applyPersona 空载体不拼空段)", async () => {
|
|
306
|
+
const fake = makeFakeLaunch({ stdout: ZCODE_GOLDEN_STDOUT });
|
|
307
|
+
const engine = makeEngine({ launch: fake.launch });
|
|
308
|
+
await engine.run(makeTask({ task: "BARE TASK", persona: {} }), makeCtx());
|
|
309
|
+
const promptIdx = fake.calls[0]!.args.indexOf("--prompt");
|
|
310
|
+
expect(fake.calls[0]!.args[promptIdx + 1]).toBe("BARE TASK");
|
|
277
311
|
});
|
|
278
312
|
|
|
279
313
|
it("preparer 池 config 已落盘(无 plugins 块)", async () => {
|
|
@@ -287,6 +321,102 @@ describe("run ② 成功路径:golden stdout → outcome/handle/事件合成",
|
|
|
287
321
|
});
|
|
288
322
|
});
|
|
289
323
|
|
|
324
|
+
// ── prepare 期可见信号(F15b/F16b:spawn 路径 effort 丢弃 / ctxModel 忽略留痕)──
|
|
325
|
+
|
|
326
|
+
describe("run prepare 期可见信号(F15b/F16b)", () => {
|
|
327
|
+
it("F15b: effort 在 spawn 路径被忽略并出声(文案含 effort 值与不支持语义,不阻断任务)", async () => {
|
|
328
|
+
const fake = makeFakeLaunch({ stdout: ZCODE_GOLDEN_STDOUT });
|
|
329
|
+
const engine = makeEngine({ launch: fake.launch });
|
|
330
|
+
const warns: string[] = [];
|
|
331
|
+
const warnSpy = vi.spyOn(subagentsLogger, "warn").mockImplementation(((msg: string) => {
|
|
332
|
+
warns.push(msg);
|
|
333
|
+
}) as typeof subagentsLogger.warn);
|
|
334
|
+
try {
|
|
335
|
+
const { outcome } = await engine.run(makeTask({ effort: "high" }), makeCtx());
|
|
336
|
+
expect(outcome.error).toBeUndefined(); // 信号只留痕,不改变任务结论
|
|
337
|
+
const hit = warns.find((m) => m.includes("effort"));
|
|
338
|
+
expect(hit).toBeDefined();
|
|
339
|
+
expect(hit).toContain("effort=high");
|
|
340
|
+
expect(hit).toContain("zcode spawn 不支持");
|
|
341
|
+
} finally {
|
|
342
|
+
warnSpy.mockRestore();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("F15b: effort 未传/空白 → 零 effort 信号(避免噪音)", async () => {
|
|
347
|
+
const fake = makeFakeLaunch({ stdout: ZCODE_GOLDEN_STDOUT });
|
|
348
|
+
const engine = makeEngine({ launch: fake.launch });
|
|
349
|
+
const warns: string[] = [];
|
|
350
|
+
const warnSpy = vi.spyOn(subagentsLogger, "warn").mockImplementation(((msg: string) => {
|
|
351
|
+
warns.push(msg);
|
|
352
|
+
}) as typeof subagentsLogger.warn);
|
|
353
|
+
try {
|
|
354
|
+
await engine.run(makeTask({ effort: " " }), makeCtx());
|
|
355
|
+
await engine.run(makeTask(), makeCtx());
|
|
356
|
+
expect(warns.filter((m) => m.includes("effort"))).toHaveLength(0);
|
|
357
|
+
} finally {
|
|
358
|
+
warnSpy.mockRestore();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("F16b: task.model 缺省 + ctx.ctxModel 存在 → 出声留痕(含被忽略的 ctx 模型与实际引擎缺省模型)", async () => {
|
|
363
|
+
// v2 config 补缺省模型 provider 条目——ZCODE_FALLBACK_DEFAULT_MODEL 的解析链才可校验通过
|
|
364
|
+
writeJson(v2Path, {
|
|
365
|
+
provider: {
|
|
366
|
+
[PROVIDER]: { options: { apiKey: "k", baseURL: "https://t.example" }, models: { m1: {} } },
|
|
367
|
+
"builtin:bigmodel-coding-plan": { options: { apiKey: "k" }, models: { "GLM-5.3": {} } },
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
const fake = makeFakeLaunch({ stdout: ZCODE_GOLDEN_STDOUT });
|
|
371
|
+
const engine = makeEngine({ launch: fake.launch });
|
|
372
|
+
const warns: string[] = [];
|
|
373
|
+
const warnSpy = vi.spyOn(subagentsLogger, "warn").mockImplementation(((msg: string) => {
|
|
374
|
+
warns.push(msg);
|
|
375
|
+
}) as typeof subagentsLogger.warn);
|
|
376
|
+
try {
|
|
377
|
+
const { outcome } = await engine.run(
|
|
378
|
+
makeTask({ model: undefined }),
|
|
379
|
+
makeCtx({ ctxModel: { id: "main-model", name: "Main", provider: "p", reasoning: false } }),
|
|
380
|
+
);
|
|
381
|
+
expect(outcome.error).toBeUndefined(); // 信号只留痕,不改变任务结论
|
|
382
|
+
const hit = warns.find((m) => m.includes("ctxModel"));
|
|
383
|
+
expect(hit).toBeDefined();
|
|
384
|
+
expect(hit).toContain("main-model");
|
|
385
|
+
expect(hit).toContain("builtin:bigmodel-coding-plan/GLM-5.3");
|
|
386
|
+
} finally {
|
|
387
|
+
warnSpy.mockRestore();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("F16b: 显式 task.model 或 ctx 无 ctxModel → 零 ctxModel 信号(只在「ctx 有模型但被忽略」时出声)", async () => {
|
|
392
|
+
// 第二段 run(model 缺省)走引擎缺省链——v2 config 需含 ZCODE_FALLBACK_DEFAULT_MODEL 的 provider
|
|
393
|
+
writeJson(v2Path, {
|
|
394
|
+
provider: {
|
|
395
|
+
[PROVIDER]: { options: { apiKey: "k", baseURL: "https://t.example" }, models: { m1: {} } },
|
|
396
|
+
"builtin:bigmodel-coding-plan": { options: { apiKey: "k" }, models: { "GLM-5.3": {} } },
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
const fake = makeFakeLaunch({ stdout: ZCODE_GOLDEN_STDOUT });
|
|
400
|
+
const engine = makeEngine({ launch: fake.launch });
|
|
401
|
+
const warns: string[] = [];
|
|
402
|
+
const warnSpy = vi.spyOn(subagentsLogger, "warn").mockImplementation(((msg: string) => {
|
|
403
|
+
warns.push(msg);
|
|
404
|
+
}) as typeof subagentsLogger.warn);
|
|
405
|
+
try {
|
|
406
|
+
// 显式 task.model + ctxModel:走正常解析链,不出声
|
|
407
|
+
await engine.run(
|
|
408
|
+
makeTask(),
|
|
409
|
+
makeCtx({ ctxModel: { id: "main-model", name: "Main", provider: "p", reasoning: false } }),
|
|
410
|
+
);
|
|
411
|
+
// model 缺省 + ctx 无 ctxModel:预期缺省链,不出声
|
|
412
|
+
await engine.run(makeTask({ model: undefined }), makeCtx());
|
|
413
|
+
expect(warns.filter((m) => m.includes("ctxModel"))).toHaveLength(0);
|
|
414
|
+
} finally {
|
|
415
|
+
warnSpy.mockRestore();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
290
420
|
// ── D10 终止链路径①(C-ext-17 回归):spawn 成功后同步回调 onChildSpawned ──
|
|
291
421
|
|
|
292
422
|
describe("run D10:onChildSpawned(宿主终止链记账钩子)", () => {
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// src/execution/engine/engines/zcode/appserver-home.ts
|
|
2
|
+
//
|
|
3
|
+
// [R4 D7] app-server 常驻 HOME 管理(设计 zcode-engine-appserver-resident.md §3.3
|
|
4
|
+
// D7 全量 + D6③ pidfile 孤儿自愈)。从 preparer.ts 拆出(单一关注点:常驻 HOME 的
|
|
5
|
+
// 所有权与引导;preparer.ts 保留 spawn 池语义):
|
|
6
|
+
// - 常驻 HOME = resolvePoolDir(engineDataDir,'zcode','home-appserver')(poolKey
|
|
7
|
+
// 固定名,锚定不变量 poolDir==HOME==db 所在目录——SQLite 落 HOME/.zcode/cli/db/
|
|
8
|
+
// db.sqlite,journal 同落该池目录);
|
|
9
|
+
// - config.json 写入全部带 apiKey 的 provider(allProviders 引导——per-session
|
|
10
|
+
// model 经 create 参数传递,不走 config.main 串池);
|
|
11
|
+
// - 凭据刷新按「provider 注册表内容 hash」比对(model.main 不参与——任务间换模型
|
|
12
|
+
// 不触发连接重建);
|
|
13
|
+
// - 目录锁(O_EXCL + 持锁宿主 pid + 心跳 mtime;活持有判定 = pid 活即持有,心跳
|
|
14
|
+
// 不参与否决)与 pidfile 孤儿回收(三重判据)。
|
|
15
|
+
//
|
|
16
|
+
// 依赖方向:本模块 import preparer 的源 config 读取件与结构化错误(同目录平级,无环)。
|
|
17
|
+
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import * as crypto from "node:crypto";
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
|
|
23
|
+
import { getLogger } from "../../../../core/logger.ts";
|
|
24
|
+
|
|
25
|
+
import { resolvePoolDir } from "../../paths.ts";
|
|
26
|
+
import { writeAtomicFileSync } from "../../../../shared/atomic-write.ts";
|
|
27
|
+
import {
|
|
28
|
+
ZCODE_APPSERVER_LOCKFILE_NAME,
|
|
29
|
+
ZCODE_APPSERVER_LOCK_HEARTBEAT_MS,
|
|
30
|
+
ZCODE_APPSERVER_MAX_DERIVED_HOMES,
|
|
31
|
+
ZCODE_APPSERVER_PIDFILE_GRACE_MS,
|
|
32
|
+
ZCODE_APPSERVER_PIDFILE_NAME,
|
|
33
|
+
ZCODE_APPSERVER_POOL_KEY,
|
|
34
|
+
ZCODE_ENGINE_ID,
|
|
35
|
+
ZCODE_POOL_CONFIG_SUFFIX,
|
|
36
|
+
} from "./constants.ts";
|
|
37
|
+
import {
|
|
38
|
+
CONFIG_INDENT_SPACES,
|
|
39
|
+
ZcodePrepareError,
|
|
40
|
+
defaultV2ConfigPath,
|
|
41
|
+
hasApiKey,
|
|
42
|
+
isProviderEntry,
|
|
43
|
+
isRecord,
|
|
44
|
+
readSourceConfig,
|
|
45
|
+
type ZcodeProviderEntry,
|
|
46
|
+
type ZcodeSourcePaths,
|
|
47
|
+
} from "./preparer.ts";
|
|
48
|
+
|
|
49
|
+
// core log facade(engines/zcode 目录惯例:模块顶层缓存,best-effort 清理路径的
|
|
50
|
+
// 最小留痕——见 taste/no-silent-catch 的「至少记录」要求)
|
|
51
|
+
const logger = getLogger("subagents");
|
|
52
|
+
|
|
53
|
+
/** 孤儿回收的退出轮询间隔(ms)。 */
|
|
54
|
+
const PID_REAP_POLL_INTERVAL_MS = 50;
|
|
55
|
+
|
|
56
|
+
/** 错误/日志出声用的 message 提取(非 Error 值不抛二次异常)。 */
|
|
57
|
+
function errMessage(err: unknown): string {
|
|
58
|
+
return err instanceof Error ? err.message : String(err);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================================
|
|
62
|
+
// [R4 D7] app-server 常驻 HOME:allProviders 引导 + 内容 hash 刷新
|
|
63
|
+
// ============================================================
|
|
64
|
+
|
|
65
|
+
/** v2 config 内全部带 apiKey 的 provider(appserver HOME 的引导面——allProviders 方案)。 */
|
|
66
|
+
function providersWithKey(sources?: ZcodeSourcePaths): Map<string, ZcodeProviderEntry> {
|
|
67
|
+
const v2 = readSourceConfig(sources?.v2ConfigPath ?? defaultV2ConfigPath());
|
|
68
|
+
return new Map([...v2.providers.entries()].filter(([, e]) => hasApiKey(e)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* provider 注册表的规范化内容 hash(D7 凭据刷新判据)。只覆盖 provider 数据——
|
|
73
|
+
* model.main 刻意排除:任务间换模型是常态(per-session model),若计入 hash 会在
|
|
74
|
+
* 每次模型切换时误判「凭据变化」杀掉常驻进程,冷启动收益归零。provider id 排序后
|
|
75
|
+
* 序列化,防 v2 config 键序变化产生假差异。
|
|
76
|
+
*/
|
|
77
|
+
export function hashProviderRegistry(providers: Map<string, ZcodeProviderEntry>): string {
|
|
78
|
+
const canonical: Array<[string, unknown]> = [...providers.keys()].sort().map((id) => [id, providers.get(id)]);
|
|
79
|
+
return crypto.createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 常驻 HOME 内 config.json 的 provider 段 hash(读不出/损坏/无 provider 段 = undefined——恒判需重写)。 */
|
|
83
|
+
export function hashPoolConfigProviders(configPath: string): string | undefined {
|
|
84
|
+
let parsed: unknown;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (!isRecord(parsed) || !isRecord(parsed["provider"])) return undefined;
|
|
91
|
+
const providers = new Map<string, ZcodeProviderEntry>();
|
|
92
|
+
for (const [id, entry] of Object.entries(parsed["provider"])) {
|
|
93
|
+
if (isProviderEntry(entry)) providers.set(id, entry);
|
|
94
|
+
}
|
|
95
|
+
return hashProviderRegistry(providers);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface AppServerConfigBootstrap {
|
|
99
|
+
configPath: string;
|
|
100
|
+
/** 本次是否实际重写(内容 hash 命中时 false——连接不重建,零开销)。 */
|
|
101
|
+
wroteConfig: boolean;
|
|
102
|
+
/** 引导后的 provider 注册表 hash(凭据刷新比对的落盘基准)。 */
|
|
103
|
+
providerHash: string;
|
|
104
|
+
/** 写入的 provider id 清单(诊断/断言面)。 */
|
|
105
|
+
providerIds: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 引导 app-server 常驻 HOME 的 config(D7①):写入全部带 apiKey 的 provider
|
|
110
|
+
* (进程启动即要求 $HOME/.zcode/cli/config.json 有模型配置——缺失则 create 恒
|
|
111
|
+
* -32603)。重写判据 = provider 注册表内容 hash 与池内现状不一致(内容基准取代
|
|
112
|
+
* spawn 池的 mtime 基准:常驻 HOME 的 config 可能被本进程早前任务写就,mtime 比对
|
|
113
|
+
* 无法识别「内容回退」形态)。model.main 一并落(create 未带 model 的兜底),但
|
|
114
|
+
* 不参与 hash(见 hashProviderRegistry)。
|
|
115
|
+
*/
|
|
116
|
+
export function bootstrapAppServerConfig(opts: {
|
|
117
|
+
homeDir: string;
|
|
118
|
+
modelRef: string;
|
|
119
|
+
sources?: ZcodeSourcePaths;
|
|
120
|
+
}): AppServerConfigBootstrap {
|
|
121
|
+
const providers = providersWithKey(opts.sources);
|
|
122
|
+
if (providers.size === 0) {
|
|
123
|
+
throw new ZcodePrepareError(
|
|
124
|
+
"engine_credential_missing",
|
|
125
|
+
`zcode 常驻 HOME 引导失败:v2 config 无任何带 apiKey 的 provider(${opts.sources?.v2ConfigPath ?? defaultV2ConfigPath()})。` +
|
|
126
|
+
`恢复指引:先在 ZCode 桌面端登录并配置 provider 后重试。`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const configPath = path.join(opts.homeDir, ...ZCODE_POOL_CONFIG_SUFFIX);
|
|
130
|
+
const providerHash = hashProviderRegistry(providers);
|
|
131
|
+
let wroteConfig = false;
|
|
132
|
+
if (hashPoolConfigProviders(configPath) !== providerHash) {
|
|
133
|
+
const providerObj: Record<string, unknown> = {};
|
|
134
|
+
for (const [id, entry] of providers) providerObj[id] = entry;
|
|
135
|
+
const payload = JSON.stringify({ model: { main: opts.modelRef }, provider: providerObj }, null, CONFIG_INDENT_SPACES);
|
|
136
|
+
// tmp+rename 原子写(shared/atomic-write 统一原语,U6b 迁移)——跨进程并发下
|
|
137
|
+
// 读者永远看到完整文件;spawn 池同款纪律(ensureDir 对齐旧显式 mkdirSync)
|
|
138
|
+
writeAtomicFileSync(configPath, payload);
|
|
139
|
+
wroteConfig = true;
|
|
140
|
+
}
|
|
141
|
+
return { configPath, wroteConfig, providerHash, providerIds: [...providers.keys()] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================================
|
|
145
|
+
// [R4 D7] 目录锁(lockfile:O_EXCL + 持锁宿主 pid + 心跳 mtime)
|
|
146
|
+
// ============================================================
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* pid 活性探测(信号 0 探针)。EPERM = 进程存在但非本方所有(仍活);
|
|
150
|
+
* ESRCH = 已死。pid 归属纪律:本函数只用于 lockfile.pid(宿主进程)与 pidfile.pid
|
|
151
|
+
* (app-server 进程)的活性判据,两文件的语义互不混用(D7 pid 归属分离钉死)。
|
|
152
|
+
*/
|
|
153
|
+
export function isPidAlive(pid: number): boolean {
|
|
154
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
155
|
+
try {
|
|
156
|
+
process.kill(pid, 0);
|
|
157
|
+
return true;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** lockfile 内容(持锁宿主进程 pid + 获取时间;心跳只刷 mtime 不改内容)。 */
|
|
164
|
+
interface LockFileContent {
|
|
165
|
+
pid: number;
|
|
166
|
+
acquiredAt: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** O_EXCL 创建 lockfile(已存在返回 false——互斥判据即 open 'wx' 的原子性)。 */
|
|
170
|
+
function tryCreateLock(lockPath: string): boolean {
|
|
171
|
+
try {
|
|
172
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
173
|
+
try {
|
|
174
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() } satisfies LockFileContent));
|
|
175
|
+
} finally {
|
|
176
|
+
fs.closeSync(fd);
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 读 lockfile 的持锁 pid(损坏/缺失 = undefined——按无主处理走接管)。 */
|
|
186
|
+
function readLockPid(lockPath: string): number | undefined {
|
|
187
|
+
try {
|
|
188
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
189
|
+
if (isRecord(parsed) && typeof parsed["pid"] === "number") return parsed["pid"];
|
|
190
|
+
return undefined;
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 本进程是否仍持有该锁(引擎「已持有则跳过重复获取」的判据——自持锁对判定循环表现为活持有,会误派生)。 */
|
|
197
|
+
export function isLockHeldByUs(lockPath: string): boolean {
|
|
198
|
+
return readLockPid(lockPath) === process.pid;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface AppServerHomeLockHooks {
|
|
202
|
+
/**
|
|
203
|
+
* 接管方删旧锁之后、O_EXCL 重建之前的窗口回调(测试注入——双接管者竞争闭环的
|
|
204
|
+
* 确定性构造点:窗口内他方先行建锁,本方 O_EXCL 必失败、重走判定循环读到活 pid
|
|
205
|
+
* → 派生)。生产不传。
|
|
206
|
+
*/
|
|
207
|
+
afterTakeoverUnlink?: (lockPath: string) => void;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface AppServerHomeLock {
|
|
211
|
+
/** 实际落定的池 key(固定名或派生后缀名——handle.poolKey 数据源)。 */
|
|
212
|
+
poolKey: string;
|
|
213
|
+
homeDir: string;
|
|
214
|
+
lockPath: string;
|
|
215
|
+
/** 是否经「锁无主接管」获得(D6③:仅接管路径触发 pidfile 孤儿回收)。 */
|
|
216
|
+
tookOver: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 常驻 HOME 目录锁判定(D7 所有权隔离)。「活持有」判定钉死:lockfile.pid 活 ⇒
|
|
221
|
+
* 一律视为持有(新实例派生后缀目录 home-appserver-2…作自己的 HOME)——心跳 mtime
|
|
222
|
+
* **不参与活持有否决**(桌面睡眠/长 GC 致心跳过期时误判死 → 偷锁双写同一 SQLite,
|
|
223
|
+
* 不可接受),仅 pid 已死时用于锁破坏加速的观测。宿主死 ⇒ 锁无主 ⇒ 接管方接管:
|
|
224
|
+
* 删旧锁 + O_EXCL 重建;O_EXCL 失败 = 他方已先行接管,失败方重走判定循环(读到
|
|
225
|
+
* 对方活 pid → 派生)。
|
|
226
|
+
*/
|
|
227
|
+
export function acquireAppServerHomeLock(
|
|
228
|
+
engineDataDir: string,
|
|
229
|
+
opts: { pidAlive?: (pid: number) => boolean; hooks?: AppServerHomeLockHooks } = {},
|
|
230
|
+
): AppServerHomeLock {
|
|
231
|
+
const pidAlive = opts.pidAlive ?? isPidAlive;
|
|
232
|
+
for (let n = 1; n <= ZCODE_APPSERVER_MAX_DERIVED_HOMES; n++) {
|
|
233
|
+
const name = n === 1 ? ZCODE_APPSERVER_POOL_KEY : `${ZCODE_APPSERVER_POOL_KEY}-${n}`;
|
|
234
|
+
const homeDir = resolvePoolDir(engineDataDir, ZCODE_ENGINE_ID, name);
|
|
235
|
+
fs.mkdirSync(homeDir, { recursive: true });
|
|
236
|
+
const lockPath = path.join(homeDir, ZCODE_APPSERVER_LOCKFILE_NAME);
|
|
237
|
+
let tookOver = false;
|
|
238
|
+
for (;;) {
|
|
239
|
+
if (tryCreateLock(lockPath)) return { poolKey: name, homeDir, lockPath, tookOver };
|
|
240
|
+
const holder = readLockPid(lockPath);
|
|
241
|
+
if (holder !== undefined && pidAlive(holder)) {
|
|
242
|
+
// 活持有(心跳 mtime 不参与否决):派生下一个目录,不碰他人 HOME/pidfile
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
// 锁无主(持锁宿主已死)/损坏残留:接管 = 删旧锁 + O_EXCL 重建
|
|
246
|
+
tookOver = true;
|
|
247
|
+
try {
|
|
248
|
+
fs.unlinkSync(lockPath);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
// 恰被并发清理/消失——继续 O_EXCL 争夺即可
|
|
251
|
+
logger.debug(`[zcode-preparer] 接管删锁失败(${lockPath},继续争夺): ${errMessage(err)}`);
|
|
252
|
+
}
|
|
253
|
+
opts.hooks?.afterTakeoverUnlink?.(lockPath);
|
|
254
|
+
// 循环重走:O_EXCL 成功 = 接管完成;失败(EEXIST)= 他方先行——重读 pid,
|
|
255
|
+
// 活 pid → break 派生;仍无主 → 再度接管争夺
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
throw new Error(
|
|
259
|
+
`[engine_run_failed] zcode 常驻 HOME 目录锁竞争超限(${ZCODE_APPSERVER_MAX_DERIVED_HOMES} 个派生目录全被活宿主持有)。` +
|
|
260
|
+
`恢复指引:清理 ${resolvePoolDir(engineDataDir, ZCODE_ENGINE_ID, ZCODE_APPSERVER_POOL_KEY)} 下无主 lockfile 后重试。`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 目录锁心跳(D7):定期刷新 lockfile mtime。语义边界(钉死):心跳只作观测面
|
|
266
|
+
* (pid 已死时区分「崩溃残留 vs 活持有」的加速信息),绝不参与活持有否决。
|
|
267
|
+
*
|
|
268
|
+
* @returns 停跳函数(引擎 dispose 语义下通常无需停——锁随宿主进程存活,进程退出
|
|
269
|
+
* 即无主;unref 保证不阻塞进程退出)
|
|
270
|
+
*/
|
|
271
|
+
export function startLockHeartbeat(lockPath: string): () => void {
|
|
272
|
+
const timer = setInterval(() => {
|
|
273
|
+
try {
|
|
274
|
+
const now = new Date();
|
|
275
|
+
fs.utimesSync(lockPath, now, now);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
// 锁文件被接管方清理等场景:心跳停跳即可——debug 留痕,不外溢
|
|
278
|
+
logger.debug(`[zcode-preparer] 锁心跳 touch 失败(${lockPath},停跳): ${errMessage(err)}`);
|
|
279
|
+
}
|
|
280
|
+
}, ZCODE_APPSERVER_LOCK_HEARTBEAT_MS);
|
|
281
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
282
|
+
return () => clearInterval(timer);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ============================================================
|
|
286
|
+
// [R4 D6③] pidfile 孤儿自愈(三重判据回收)
|
|
287
|
+
// ============================================================
|
|
288
|
+
|
|
289
|
+
/** pidfile 内容:app-server 进程 pid + 启动时间戳(ps lstart 输出原文)。 */
|
|
290
|
+
interface PidFileContent {
|
|
291
|
+
pid: number;
|
|
292
|
+
lstart?: string;
|
|
293
|
+
startedAt: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function pidfilePath(homeDir: string): string {
|
|
297
|
+
return path.join(homeDir, ZCODE_APPSERVER_PIDFILE_NAME);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function psField(pid: number, field: string): Promise<string | undefined> {
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
execFile("ps", ["-p", String(pid), `-o`, `${field}=`], { encoding: "utf8", timeout: 2_000 }, (err, stdout) => {
|
|
303
|
+
// ps 失败/进程消失:undefined(三重判据按不满足处理——漏回收可接受,误杀不可)
|
|
304
|
+
resolve(err ? undefined : stdout.trim() || undefined);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** 进程启动时间戳(ps lstart 原文——pid 复用封死判据)。 */
|
|
310
|
+
export function probePidLstart(pid: number): Promise<string | undefined> {
|
|
311
|
+
return psField(pid, "lstart");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 进程命令行(ps command——app-server 形态匹配判据)。 */
|
|
315
|
+
export function probePidCommand(pid: number): Promise<string | undefined> {
|
|
316
|
+
return psField(pid, "command");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 常驻进程 spawn 后写 pidfile(D6③)。lstart 采集失败仍写 pid(届时三重判据中的
|
|
321
|
+
* 时间戳判据不可满足 → 宁漏回收不误杀,wrapper 形态假阴性同口径)。
|
|
322
|
+
*/
|
|
323
|
+
export async function writeAppServerPidFile(homeDir: string, pid: number): Promise<void> {
|
|
324
|
+
const content: PidFileContent = {
|
|
325
|
+
pid,
|
|
326
|
+
startedAt: Date.now(),
|
|
327
|
+
...(pid > 0 ? { lstart: await probePidLstart(pid) } : {}),
|
|
328
|
+
};
|
|
329
|
+
// ensureDir:false:homeDir 已由 prepare 阶段 bootstrap(目录被删属异常态,
|
|
330
|
+
// 维持旧实现 fail-fast 上抛,不静默重建)
|
|
331
|
+
writeAtomicFileSync(pidfilePath(homeDir), JSON.stringify(content, null, CONFIG_INDENT_SPACES), { ensureDir: false });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 回收判定结论(诊断/测试断言面)。 */
|
|
335
|
+
export type OrphanReapStatus =
|
|
336
|
+
| "no-pidfile"
|
|
337
|
+
| "pid-dead"
|
|
338
|
+
| "criteria-mismatch"
|
|
339
|
+
| "reaped";
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* pidfile 孤儿自愈(D6③)。前提时序(钉死):**先过 D7 目录锁判定**——本函数只在
|
|
343
|
+
* 「锁无主、本方接管 HOME」后被调用(锁被活宿主持有时派生新 HOME,不触碰他人
|
|
344
|
+
* pidfile)。三重判据全过才回收:pid 仍活 AND ps lstart 与 pidfile 记录一致(封死
|
|
345
|
+
* pid 复用误杀)AND 命令行匹配 app-server 形态(wrapper 形态假阴性漏回收可接受)。
|
|
346
|
+
* pidfile 在所有分支处理毕即清理(死 pid / 判据不符 = 陈旧记录,无保留价值)。
|
|
347
|
+
*/
|
|
348
|
+
export async function reapOrphanAppServer(homeDir: string, opts: { graceMs?: number } = {}): Promise<OrphanReapStatus> {
|
|
349
|
+
const file = pidfilePath(homeDir);
|
|
350
|
+
let content: PidFileContent | undefined;
|
|
351
|
+
try {
|
|
352
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
353
|
+
if (isRecord(parsed) && typeof parsed["pid"] === "number") {
|
|
354
|
+
content = { pid: parsed["pid"], startedAt: 0, ...(typeof parsed["lstart"] === "string" ? { lstart: parsed["lstart"] } : {}) };
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
content = undefined;
|
|
358
|
+
}
|
|
359
|
+
if (content === undefined) return "no-pidfile";
|
|
360
|
+
const unlinkPidfile = (): void => {
|
|
361
|
+
try {
|
|
362
|
+
fs.unlinkSync(file);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
logger.debug(`[zcode-preparer] pidfile 清理失败(${file},已被并发清理则无妨): ${errMessage(err)}`);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
if (!isPidAlive(content.pid)) {
|
|
368
|
+
unlinkPidfile();
|
|
369
|
+
return "pid-dead";
|
|
370
|
+
}
|
|
371
|
+
// 两次独立探测串行(互不依赖也互不需齐备——任一 undefined 即判据不满足)
|
|
372
|
+
const lstart = await probePidLstart(content.pid);
|
|
373
|
+
const command = await probePidCommand(content.pid);
|
|
374
|
+
const lstartMatches = content.lstart !== undefined && lstart !== undefined && lstart === content.lstart;
|
|
375
|
+
const commandMatches = command !== undefined && command.includes("app-server");
|
|
376
|
+
if (lstartMatches && commandMatches) {
|
|
377
|
+
reapPid(content.pid, opts.graceMs ?? ZCODE_APPSERVER_PIDFILE_GRACE_MS);
|
|
378
|
+
unlinkPidfile();
|
|
379
|
+
return "reaped";
|
|
380
|
+
}
|
|
381
|
+
// 判据不符(pid 被复用成无关进程 / wrapper 形态):不杀,清陈旧 pidfile
|
|
382
|
+
unlinkPidfile();
|
|
383
|
+
return "criteria-mismatch";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 非 child 进程的回收:SIGTERM → 轮询 grace → SIGKILL(best-effort,异常吞掉)。 */
|
|
387
|
+
function reapPid(pid: number, graceMs: number): void {
|
|
388
|
+
const killWith = (signal: NodeJS.Signals): void => {
|
|
389
|
+
try {
|
|
390
|
+
process.kill(pid, signal);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
// 进程恰已退出——回收目的已达,debug 留痕
|
|
393
|
+
logger.debug(`[zcode-preparer] 孤儿回收信号 ${signal} 未送达(pid ${pid},可能已退出): ${errMessage(err)}`);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
killWith("SIGTERM");
|
|
397
|
+
const deadline = Date.now() + graceMs;
|
|
398
|
+
const poll = (): void => {
|
|
399
|
+
if (!isPidAlive(pid)) return;
|
|
400
|
+
if (Date.now() >= deadline) {
|
|
401
|
+
killWith("SIGKILL");
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const t = setTimeout(poll, PID_REAP_POLL_INTERVAL_MS);
|
|
405
|
+
if (typeof t.unref === "function") t.unref();
|
|
406
|
+
};
|
|
407
|
+
poll();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ============================================================
|
|
411
|
+
// [R4 D7] 常驻 HOME 组合入口(锁 → 孤儿回收 → config 引导)
|
|
412
|
+
// ============================================================
|
|
413
|
+
|
|
414
|
+
export interface AppServerHomeHandle extends AppServerHomeLock, AppServerConfigBootstrap {
|
|
415
|
+
/** 孤儿回收结论(非接管路径恒 "no-pidfile" 语义的 not-applicable 占位)。 */
|
|
416
|
+
orphanReap: OrphanReapStatus | "not-applicable";
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 获取 app-server 常驻 HOME(每任务调用——含凭据刷新比对)。首次:目录锁判定
|
|
421
|
+
* (活持有 → 派生;无主 → 接管 + pidfile 孤儿回收)+ config 引导。后续:config
|
|
422
|
+
* 内容 hash 比对(不一致 → 重写,调用方据此重建连接)。锚定不变量:poolDir ==
|
|
423
|
+
* HOME == db 所在目录(SQLite 落 HOME/.zcode/cli/db/db.sqlite;journal 同落该池,
|
|
424
|
+
* 文件名 = record id 无冲突)。
|
|
425
|
+
*/
|
|
426
|
+
export async function acquireAppServerHome(opts: {
|
|
427
|
+
engineDataDir: string;
|
|
428
|
+
modelRef: string;
|
|
429
|
+
sources?: ZcodeSourcePaths;
|
|
430
|
+
pidAlive?: (pid: number) => boolean;
|
|
431
|
+
hooks?: AppServerHomeLockHooks;
|
|
432
|
+
}): Promise<AppServerHomeHandle> {
|
|
433
|
+
const lock = acquireAppServerHomeLock(opts.engineDataDir, {
|
|
434
|
+
...(opts.pidAlive !== undefined ? { pidAlive: opts.pidAlive } : {}),
|
|
435
|
+
...(opts.hooks !== undefined ? { hooks: opts.hooks } : {}),
|
|
436
|
+
});
|
|
437
|
+
// D6③ 时序钉死:仅接管路径(锁无主)才回收 pidfile——锁被活宿主持有时派生目录,
|
|
438
|
+
// 派生目录内的 pidfile(若有)属本方历史残留之外的形态,不碰
|
|
439
|
+
const orphanReap = lock.tookOver ? await reapOrphanAppServer(lock.homeDir) : "not-applicable";
|
|
440
|
+
const boot = bootstrapAppServerConfig({ homeDir: lock.homeDir, modelRef: opts.modelRef, sources: opts.sources });
|
|
441
|
+
return { ...lock, ...boot, orphanReap };
|
|
442
|
+
}
|