dsh-punky-swarm 0.3.6
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/CHANGELOG.md +120 -0
- package/LICENSE +661 -0
- package/README.en.md +281 -0
- package/README.md +281 -0
- package/cordis.patch.yml +59 -0
- package/lib/acps/certs.js +310 -0
- package/lib/acps/discovery-client.js +283 -0
- package/lib/acps/registry-client.js +527 -0
- package/lib/acps/server.js +302 -0
- package/lib/aip/agent-descriptor.js +170 -0
- package/lib/aip/identity.js +469 -0
- package/lib/aip/tool-descriptor.js +93 -0
- package/lib/api.js +266 -0
- package/lib/artifact-types.js +48 -0
- package/lib/assembly/audit-blind-review.js +170 -0
- package/lib/assembly/schema.js +253 -0
- package/lib/assembly.js +41 -0
- package/lib/assets.js +103 -0
- package/lib/bridge/trajectory.js +202 -0
- package/lib/client.js +704 -0
- package/lib/comms/acps-bridge.js +219 -0
- package/lib/comms/aip-format.js +141 -0
- package/lib/comms/budget.js +124 -0
- package/lib/comms/mailbox.js +221 -0
- package/lib/comms/topic-runtime.js +76 -0
- package/lib/comms/topic.js +115 -0
- package/lib/discovery/filter.js +199 -0
- package/lib/discovery/schema.js +157 -0
- package/lib/discovery/service.js +251 -0
- package/lib/hot/config-watch.js +252 -0
- package/lib/index.js +433 -0
- package/lib/lock.js +69 -0
- package/lib/panel/batch-detail.js +163 -0
- package/lib/panel/batch-list.js +84 -0
- package/lib/panel/locales.js +93 -0
- package/lib/panel/main.js +270 -0
- package/lib/panel/stream.js +348 -0
- package/lib/panel/theme.js +103 -0
- package/lib/panel/widgets.js +63 -0
- package/lib/schema.d.ts +192 -0
- package/lib/schema.js +259 -0
- package/lib/schema.ts +398 -0
- package/lib/state/archive.js +174 -0
- package/lib/state/command-exec.js +148 -0
- package/lib/state/constants.js +37 -0
- package/lib/state/corrupt-registry.js +103 -0
- package/lib/state/event-types.js +65 -0
- package/lib/state/gates.d.ts +226 -0
- package/lib/state/gates.js +428 -0
- package/lib/state/gates.ts +348 -0
- package/lib/state/machine-rules.d.ts +14 -0
- package/lib/state/machine-rules.js +76 -0
- package/lib/state/machine-rules.ts +88 -0
- package/lib/state/machine.js +68 -0
- package/lib/state/resume.js +201 -0
- package/lib/state/schema-v3.d.ts +10 -0
- package/lib/state/schema-v3.js +57 -0
- package/lib/state/schema-v3.ts +80 -0
- package/lib/state/store.js +607 -0
- package/lib/state/task-utils.js +40 -0
- package/lib/tools/core.js +323 -0
- package/lib/tools/git-utils.js +44 -0
- package/lib/tools/lane-tools.js +455 -0
- package/lib/tools/log-tools.js +173 -0
- package/lib/tools/mailbox-tools.js +121 -0
- package/lib/tools/merge-agent.js +127 -0
- package/lib/tools/register.js +85 -0
- package/lib/tools/shared.js +32 -0
- package/lib/types/contracts.d.ts +277 -0
- package/lib/types/contracts.js +17 -0
- package/lib/types/contracts.ts +279 -0
- package/lib/verify/evidence.js +249 -0
- package/lib/verify/gate.js +161 -0
- package/lib/verify/mount.js +48 -0
- package/lib/verify/selector.js +101 -0
- package/lib/watch/lane-heartbeat.js +333 -0
- package/lib/wave-plan.d.ts +48 -0
- package/lib/wave-plan.js +473 -0
- package/lib/wave-plan.ts +465 -0
- package/package.json +71 -0
- package/presets/jiufeng/NOTICE +5 -0
- package/presets/jiufeng/agent.cordis.yml +275 -0
- package/presets/jiufeng/preset.yml +3 -0
- package/skills/jiufeng-team/SKILL.md +214 -0
- package/skills/jiufeng-team/references/constitution.md +62 -0
- package/skills/jiufeng-team/references/roles/coder.md +17 -0
- package/skills/jiufeng-team/references/roles/coordinator.md +13 -0
- package/skills/jiufeng-team/references/roles/designer.md +14 -0
- package/skills/jiufeng-team/references/roles/doc-manager.md +13 -0
- package/skills/jiufeng-team/references/roles/manager.md +22 -0
- package/skills/jiufeng-team/references/roles/reviewer.md +19 -0
- package/skills/jiufeng-team/references/roles/supervisor.md +13 -0
- package/skills/jiufeng-team/references/roles/tester.md +13 -0
- package/skills/jiufeng-team/references/templates/call-chain-matrix-template.md +121 -0
- package/skills/jiufeng-team/references/templates/endpoint-behavior-template.md +60 -0
- package/skills/jiufeng-team/references/templates/gap-list.json +33 -0
- package/skills/jiufeng-team/references/templates/leader-decision-pack.md +92 -0
- package/skills/jiufeng-team/references/templates/plan-template.md +61 -0
- package/skills/jiufeng-team/references/templates/success-pattern-seeds.md +126 -0
- package/skills/jiufeng-team/references/workflow.md +98 -0
- package/skills/jiufeng-team/scripts/check-v3-density.mjs +98 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (C) 2025-2026 Punky
|
|
3
|
+
|
|
4
|
+
This program is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU Affero General Public License as
|
|
6
|
+
published by the Free Software Foundation, either version 3 of the
|
|
7
|
+
License, or (at your option) any later version.
|
|
8
|
+
|
|
9
|
+
This program is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU Affero General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// verify/gate.js —— 完成闸门三态裁决(C3 成熟模式:dsh-verification CompletionGate)
|
|
19
|
+
// 三态:done / failed / blocked;模式 advisory(默认只记录不拦截)/ enforce(显式装配启用则拦截)。
|
|
20
|
+
// 审计消费桥接:audit lane 消费 exec 产物 + evidence → evaluateGate 产出裁决 → 裁决报告落盘为 audit
|
|
21
|
+
// produce(audit/verify-verdict.md)→ Tier3 门禁照常校验该产物存在(既有 gates.js 零改动,verify 是内容层增强)。
|
|
22
|
+
import { createSelectorRegistry } from './selector.js';
|
|
23
|
+
import { createEvidenceRegistry } from './evidence.js';
|
|
24
|
+
import { readCapability } from '../assembly/schema.js'; // P1-01:装配开关经注册表 default 缺省合并
|
|
25
|
+
|
|
26
|
+
// 状态劣化序:done < failed < blocked(blocked 需人工,优先级最高);null 视为最低(-1)
|
|
27
|
+
function worse(a, b) {
|
|
28
|
+
const rank = { done: 0, failed: 1, blocked: 2 };
|
|
29
|
+
const ra = a == null ? -1 : rank[a];
|
|
30
|
+
const rb = b == null ? -1 : rank[b];
|
|
31
|
+
return rb > ra ? b : a;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 完成闸门三态裁决(纯函数):
|
|
35
|
+
// acBindings: [{ acId, selectorRef }](AC 清单经 freeze+bind 后的形态)
|
|
36
|
+
// evidence: { bindingsFor(acId) => [{selectorRef, blobKey}], readBlob(key) => evidence }(注册表+store 适配)
|
|
37
|
+
// mode: 'advisory'(默认,只记录不拦截)| 'enforce'(拦截,status!==done 时 intercepted=true)
|
|
38
|
+
// 裁决规则:缺绑定 → failed「Missing evidence for AC x」;绑定但证据不满足 → failed+detail;
|
|
39
|
+
// 损坏/截断 blob(读校验 fail closed)→ blocked(需人工);证据为工具错误 → failed;至少一条成功证据 → done。
|
|
40
|
+
export function evaluateGate({ acBindings = [], evidence = null, mode = 'advisory' }) {
|
|
41
|
+
if (!Array.isArray(acBindings)) throw new Error('evaluateGate: acBindings must be an array');
|
|
42
|
+
if (mode !== 'advisory' && mode !== 'enforce') throw new Error('evaluateGate: mode must be advisory|enforce');
|
|
43
|
+
if (typeof evidence?.bindingsFor !== 'function' || typeof evidence?.readBlob !== 'function') {
|
|
44
|
+
throw new Error('evaluateGate: evidence must provide bindingsFor(acId) and readBlob(key)');
|
|
45
|
+
}
|
|
46
|
+
const defects = [];
|
|
47
|
+
let status = 'done';
|
|
48
|
+
|
|
49
|
+
for (const ac of acBindings) {
|
|
50
|
+
if (!ac || typeof ac.acId !== 'string') continue;
|
|
51
|
+
const bound = evidence.bindingsFor(ac.acId);
|
|
52
|
+
if (bound.length === 0) {
|
|
53
|
+
defects.push({ acId: ac.acId, selectorRef: ac.selectorRef ?? null, code: 'MISSING_EVIDENCE', detail: 'Missing evidence for AC ' + ac.acId });
|
|
54
|
+
status = worse(status, 'failed');
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
let acStatus = null;
|
|
58
|
+
for (const b of bound) {
|
|
59
|
+
let blob;
|
|
60
|
+
try {
|
|
61
|
+
blob = evidence.readBlob(b.blobKey);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
// 损坏 blob fail closed → 需人工(不可用疑似损坏的内容做裁决)
|
|
64
|
+
defects.push({ acId: ac.acId, selectorRef: b.selectorRef, code: 'EVIDENCE_UNREADABLE', detail: String(e?.message ?? e) });
|
|
65
|
+
acStatus = worse(acStatus, 'blocked');
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (blob.truncated === true) {
|
|
69
|
+
defects.push({ acId: ac.acId, selectorRef: b.selectorRef, code: 'EVIDENCE_TRUNCATED', detail: 'evidence over ' + (blob.note ?? 'limit') + ' — needs human review' });
|
|
70
|
+
acStatus = worse(acStatus, 'blocked');
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (blob.ok === false) {
|
|
74
|
+
defects.push({ acId: ac.acId, selectorRef: b.selectorRef, code: 'EVIDENCE_ERROR', detail: 'tool ' + blob.tool + ' failed: ' + (blob.error ?? 'unknown') });
|
|
75
|
+
acStatus = worse(acStatus, 'failed');
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
acStatus = worse(acStatus, 'done'); // 至少一条成功证据
|
|
79
|
+
}
|
|
80
|
+
if (acStatus) status = worse(status, acStatus);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// advisory 只记录不拦截;enforce 显式拦截并返回缺陷清单
|
|
84
|
+
const intercepted = mode === 'enforce' && status !== 'done';
|
|
85
|
+
return {
|
|
86
|
+
status,
|
|
87
|
+
defects,
|
|
88
|
+
mode,
|
|
89
|
+
intercepted,
|
|
90
|
+
message: intercepted
|
|
91
|
+
? 'verify gate blocked: ' + status + ' (' + defects.length + ' defect(s)) — see defects'
|
|
92
|
+
: (defects.length ? 'verify gate recorded ' + defects.length + ' defect(s) in advisory mode' : 'verify gate passed'),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 审计消费桥接主入口:AC 清单 × 捕获台账 → 绑定 → 三态裁决。
|
|
97
|
+
// acList: [{ id, tool, args }](audit lane 任务包携带,源自 spec.md「## 验收标准」)
|
|
98
|
+
// ledger: 捕获台账条目(readLedger 输出,含 selectorKey/blobKey)
|
|
99
|
+
// readBlob: evidence store 的 readBlob(注入,保持 gate 纯逻辑可测)
|
|
100
|
+
// mode: advisory | enforce
|
|
101
|
+
export function evaluateAcEvidence({ acList = [], ledger = [], readBlob, mode = 'advisory' }) {
|
|
102
|
+
const selectors = createSelectorRegistry();
|
|
103
|
+
selectors.bindAll(acList); // 重复 selector / 重复 AC 在此拒绝(V1)
|
|
104
|
+
const registry = createEvidenceRegistry();
|
|
105
|
+
const acBindings = selectors.list().map((s) => ({ acId: s.acId, selectorRef: s.selectorRef }));
|
|
106
|
+
for (const entry of ledger) {
|
|
107
|
+
const acId = selectors.matchByKey(entry.selectorKey); // exact-only 全等匹配
|
|
108
|
+
if (acId) registry.bindEvidence(acId, acId + ':' + entry.selectorKey, entry.blobKey);
|
|
109
|
+
}
|
|
110
|
+
const result = evaluateGate({
|
|
111
|
+
acBindings,
|
|
112
|
+
evidence: { bindingsFor: (id) => registry.bindingsFor(id), readBlob },
|
|
113
|
+
mode,
|
|
114
|
+
});
|
|
115
|
+
return { acBindings, bindings: registry, result };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 裁决报告落盘格式(预留:audit/verify-verdict.md)——audit lane 写为 produce,Tier3 校验其存在
|
|
119
|
+
export function renderVerdictReport({ batchId, sessionId, acList = [], result, evaluatedAt = new Date().toISOString() }) {
|
|
120
|
+
const lines = [
|
|
121
|
+
'# verify 裁决报告(audit/verify-verdict.md)',
|
|
122
|
+
'',
|
|
123
|
+
'- 批次:' + batchId + ' | 会话:' + sessionId,
|
|
124
|
+
'- 评估时间:' + evaluatedAt,
|
|
125
|
+
'- 模式:' + (result?.mode ?? 'advisory') + ' | 裁决:' + (result?.status ?? 'done') + (result?.intercepted ? '(已拦截)' : ''),
|
|
126
|
+
'',
|
|
127
|
+
'## 验收标准 × 证据裁决',
|
|
128
|
+
'',
|
|
129
|
+
'| AC | 工具 | 裁决 | 缺陷 |',
|
|
130
|
+
'|---|---|---|---|',
|
|
131
|
+
...acList.map((ac) => {
|
|
132
|
+
const d = (result?.defects ?? []).find((x) => x.acId === ac.id);
|
|
133
|
+
return '| ' + ac.id + ' | ' + (ac.tool ?? '') + ' | ' + (d ? d.code : 'done') + ' | ' + (d ? d.detail.replace(/\|/g, '\\|') : '') + ' |';
|
|
134
|
+
}),
|
|
135
|
+
'',
|
|
136
|
+
'## 缺陷清单',
|
|
137
|
+
'',
|
|
138
|
+
...((result?.defects ?? []).length
|
|
139
|
+
? result.defects.map((d) => '- `' + d.acId + '` [' + d.code + '] ' + d.detail)
|
|
140
|
+
: ['- 无']),
|
|
141
|
+
'',
|
|
142
|
+
];
|
|
143
|
+
return lines.join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 完成闸门工厂:config.capabilities.verify.{enabled, mode}——P1-01 缺省默认开(readCapability 合并
|
|
147
|
+
// 注册表 default VERIFY_DEFAULTS {enabled:true, mode:'advisory'});显式 enabled:false 时 evaluate
|
|
148
|
+
// 返回 skipped(无裁决、零运行时开销);enforce 仅显式装配启用。
|
|
149
|
+
export function createCompletionGate({ config = {}, mode } = {}) {
|
|
150
|
+
const vcfg = readCapability(config, 'verify') ?? {};
|
|
151
|
+
const enabled = vcfg.enabled === true;
|
|
152
|
+
const effMode = mode ?? vcfg.mode ?? 'advisory';
|
|
153
|
+
return {
|
|
154
|
+
enabled,
|
|
155
|
+
mode: effMode,
|
|
156
|
+
evaluate(args) {
|
|
157
|
+
if (!enabled) return { status: 'done', defects: [], mode: effMode, skipped: true, intercepted: false };
|
|
158
|
+
return evaluateGate({ ...args, mode: effMode });
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (C) 2025-2026 Punky
|
|
3
|
+
|
|
4
|
+
This program is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU Affero General Public License as
|
|
6
|
+
published by the Free Software Foundation, either version 3 of the
|
|
7
|
+
License, or (at your option) any later version.
|
|
8
|
+
|
|
9
|
+
This program is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU Affero General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// verify/mount.js —— verify 引擎级接线(集成注意项 1 收口)
|
|
19
|
+
// index.js 的可测缝:capabilities.verify.enabled 门控挂 installEvidenceCapture(tools/post-execute 证据捕获),
|
|
20
|
+
// 返回 { installed, count, dispose };enabled=false 零运行时开销;ctx.on 缺失静默降级(宿主能力缺失不炸)。
|
|
21
|
+
// 边界:createCompletionGate 与 audit lane 显式 DI 消费路径一字不动(gate.js 零改动)——本文件只做引擎级捕获装配,
|
|
22
|
+
// 裁决/拦截语义仍在 gate.js(advisory 只记录 / enforce 拦截),捕获侧只落 blob + ledger。
|
|
23
|
+
// TBD-4 核实(2026-08-28):本文件与 lib/state/gates.js(批次内层间门禁)零耦合——不 import 亦不被其
|
|
24
|
+
// import;verify/gate.js 的 createCompletionGate / evaluateAcEvidence 消费点 = audit lane 显式 DI 注入
|
|
25
|
+
// ({acList, ledger, readBlob})+ 装配层门控(index.js mountVerify 挂捕获 hook;assembly/schema.js verify
|
|
26
|
+
// 键注册表 consumers 标注),无共享调用路径,无需改动任何调用代码。
|
|
27
|
+
import { installEvidenceCapture } from './evidence.js';
|
|
28
|
+
// resolveVerifyConfig/VERIFY_DEFAULTS 统一归口 lib/schema.js(注册表 default:
|
|
29
|
+
// assembly-schema lane 落地同款实现,本文件不再自包含双实现——语义一致,消费路径单点,防未来字段漂移)
|
|
30
|
+
import { VERIFY_DEFAULTS, resolveVerifyConfig } from '../schema.js';
|
|
31
|
+
export { VERIFY_DEFAULTS, resolveVerifyConfig };
|
|
32
|
+
|
|
33
|
+
// 引擎级捕获挂载(index.js apply 内调用):
|
|
34
|
+
// enabled=true + ctx.on 可用 → installEvidenceCapture 订阅 tools/post-execute(pass-through 不断链)→ installed:true;
|
|
35
|
+
// enabled=false(默认)→ installed:false / reason:'disabled',不注册任何监听,零副作用;
|
|
36
|
+
// ctx.on 缺失 → installed:false / reason:'ctx.on unavailable',静默降级不 throw。
|
|
37
|
+
export function mountVerify(ctx, { root, config, logger } = {}) {
|
|
38
|
+
const cfg = resolveVerifyConfig(config);
|
|
39
|
+
const log = logger ?? ctx?.logger ?? null;
|
|
40
|
+
const inert = (reason) => ({ installed: false, reason, mode: cfg.mode, count: () => 0, dispose() {} });
|
|
41
|
+
if (cfg.enabled !== true) return inert('disabled');
|
|
42
|
+
if (!root || typeof ctx?.on !== 'function') return inert('ctx.on unavailable');
|
|
43
|
+
const cap = installEvidenceCapture(ctx, { root, enabled: true });
|
|
44
|
+
if (cap.installed) {
|
|
45
|
+
log?.info?.('[dsh-punky-swarm] verify capability enabled: post-execute evidence capture mounted (mode=' + cfg.mode + ')');
|
|
46
|
+
}
|
|
47
|
+
return { installed: cap.installed, count: cap.count, mode: cfg.mode, dispose() { cap.dispose(); } };
|
|
48
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (C) 2025-2026 Punky
|
|
3
|
+
|
|
4
|
+
This program is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU Affero General Public License as
|
|
6
|
+
published by the Free Software Foundation, either version 3 of the
|
|
7
|
+
License, or (at your option) any later version.
|
|
8
|
+
|
|
9
|
+
This program is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU Affero General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// verify/selector.js —— AC×selector 冻结(C3 成熟模式:dsh-verification selector 冻结,exact-only)
|
|
19
|
+
// 纯逻辑模块(无 IO、无工具面):canonicalizeArgs(键递归排序)+ stableHash(sha256 前缀 12)。
|
|
20
|
+
// 服务端权威语义:裁决侧按 identity+tool+argsHash 全等绑定,模型提交值一律忽略(不参与匹配)。
|
|
21
|
+
// AC 声明来源(零核心侵入):批次 plan 产物 spec.md「## 验收标准」章节 —— audit lane 任务包携带
|
|
22
|
+
// AC 清单 [{id, tool, args}],不扩展 wave-plan task schema(wave-plan.js 零改动)。
|
|
23
|
+
// TBD-4 核实(2026-08-28):本文件与 lib/state/gates.js(批次内层间门禁)零耦合——不 import 亦不被
|
|
24
|
+
// 其 import;verify 子系统(selector/mount/gate/evidence)独立成链,裁决纯函数经 DI 由 audit lane
|
|
25
|
+
// 注入消费(evaluateAcEvidence),装配层仅挂载捕获 hook(mountVerify)与注册表门控,无共享调用路径。
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
// 递归规范化参数:对象键递归排序({a:1,b:2} 与 {b:2,a:1} 同哈希);数组保序;undefined 剔除;
|
|
29
|
+
// 标量原样(字符串/数字/布尔/null)。用于哈希与全等匹配的规范形态。
|
|
30
|
+
export function canonicalizeArgs(value) {
|
|
31
|
+
if (Array.isArray(value)) return value.map(canonicalizeArgs);
|
|
32
|
+
if (value !== null && typeof value === 'object') {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const k of Object.keys(value).sort()) {
|
|
35
|
+
const v = canonicalizeArgs(value[k]);
|
|
36
|
+
if (v !== undefined) out[k] = v;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 稳定哈希:sha256 → 12 位 hex 前缀(同输入恒等,键序无关)
|
|
44
|
+
export function stableHash(value) {
|
|
45
|
+
return createHash('sha256').update(JSON.stringify(canonicalizeArgs(value))).digest('hex').slice(0, 12);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// AC×selector 冻结:同一 (acId, tool, args) 两次冻结 → selectorRef 稳定;参数键序无关;
|
|
49
|
+
// argsHash 只含 tool+args(跨 acId 可比较),selectorRef = acId:argsHash(可回溯到 AC)。
|
|
50
|
+
export function freezeSelector({ acId, tool, args = {} }) {
|
|
51
|
+
if (typeof acId !== 'string' || !acId.length) throw new Error('freezeSelector: acId required');
|
|
52
|
+
if (typeof tool !== 'string' || !tool.length) throw new Error('freezeSelector: tool required');
|
|
53
|
+
const canonical = canonicalizeArgs(args);
|
|
54
|
+
const argsHash = stableHash({ tool, args: canonical });
|
|
55
|
+
const selectorRef = acId + ':' + argsHash;
|
|
56
|
+
return { acId, tool, args: canonical, argsHash, selectorRef };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// AC→selector 绑定注册表(exact-only):一 selector 只绑一个 AC,重复绑定拒绝。
|
|
60
|
+
// audit lane 在裁决前置入 AC 清单;捕获侧按 argsHash 全等反查命中的 AC。
|
|
61
|
+
export function createSelectorRegistry() {
|
|
62
|
+
const byAc = new Map(); // acId -> frozen selector
|
|
63
|
+
const byKey = new Map(); // argsHash -> acId(exact-only 全等身份,无模糊匹配)
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
// 绑定单个 AC:{id, tool, args}(audit lane AC 清单逐项调用);重复 AC / 重复 selector 拒绝
|
|
67
|
+
bindSelector(acId, selector) {
|
|
68
|
+
if (byAc.has(acId)) throw new Error('duplicate AC binding: ' + acId);
|
|
69
|
+
if (!selector || typeof selector.tool !== 'string') throw new Error('bindSelector: tool required for ' + acId);
|
|
70
|
+
const frozen = freezeSelector({ acId, tool: selector.tool, args: selector.args ?? {} });
|
|
71
|
+
if (byKey.has(frozen.argsHash)) {
|
|
72
|
+
throw new Error('duplicate selector: ' + frozen.argsHash + ' already bound to AC ' + byKey.get(frozen.argsHash));
|
|
73
|
+
}
|
|
74
|
+
byKey.set(frozen.argsHash, acId);
|
|
75
|
+
byAc.set(acId, frozen);
|
|
76
|
+
return frozen;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// 批量绑定(AC 清单形态 [{id, tool, args}]),返回冻结后的 selector 列表
|
|
80
|
+
bindAll(acList = []) {
|
|
81
|
+
if (!Array.isArray(acList)) throw new Error('bindAll: acList must be an array');
|
|
82
|
+
return acList.map((ac) => this.bindSelector(ac.id, { tool: ac.tool, args: ac.args }));
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
has(acId) { return byAc.has(acId); },
|
|
86
|
+
get(acId) { return byAc.get(acId) ?? null; },
|
|
87
|
+
size() { return byAc.size; },
|
|
88
|
+
list() { return [...byAc.values()]; },
|
|
89
|
+
clear() { byAc.clear(); byKey.clear(); },
|
|
90
|
+
|
|
91
|
+
// exact-only 匹配(捕获侧/审计侧用):tool+args 全等 → 命中的 acId;未命中 → null(无模糊)
|
|
92
|
+
match(tool, args) {
|
|
93
|
+
const key = stableHash({ tool, args: canonicalizeArgs(args) });
|
|
94
|
+
return byKey.get(key) ?? null;
|
|
95
|
+
},
|
|
96
|
+
// 按已算好的 argsHash 反查(台账条目带 selectorKey 时免重算)
|
|
97
|
+
matchByKey(argsHash) {
|
|
98
|
+
return typeof argsHash === 'string' ? (byKey.get(argsHash) ?? null) : null;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (C) 2025-2026 Punky
|
|
3
|
+
|
|
4
|
+
This program is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU Affero General Public License as
|
|
6
|
+
published by the Free Software Foundation, either version 3 of the
|
|
7
|
+
License, or (at your option) any later version.
|
|
8
|
+
|
|
9
|
+
This program is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU Affero General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// lane-heartbeat:lane 过期检测引擎(能力补全 C1,watch 域,新建)
|
|
19
|
+
// 成熟模式:dsh-plugin-heartbeat 退避/硬停引擎(退避/硬停机制)
|
|
20
|
+
// 语义:running lane 无活动 → 退避档位追问(默认 10→20→30 分钟,冷场越久间隔越长)→
|
|
21
|
+
// 连续 N 拍(默认 3)无活动 → appendEvent('lane.stalled', {lane, missed}),停止追问。
|
|
22
|
+
// 只标记不自动处置(Manager/Leader 人审),不新增成员状态(stalled 用事件表达,不碰
|
|
23
|
+
// schema.js MEMBER_STATES/MEMBER_TRANSITIONS——写事件零侵入,加状态要动 schema/测试/面板/complete gate)。
|
|
24
|
+
// 依赖注入:deps.store(readBatch/appendEvent/listSessions/listBatches/artifactsDirOf)
|
|
25
|
+
// deps.mailbox(comms/mailbox.js:send/readUnacked,追问投递与 pending 探测)
|
|
26
|
+
// deps.config(capabilities.watch:enabled/intervalsMinutes/maxMissed/scanIntervalMinutes/probeTemplate)
|
|
27
|
+
// deps.root(可选,mailbox 根;缺省由 store.sessionsDir 推导)
|
|
28
|
+
// deps.now(可选,时钟注入,测试用;缺省 Date.now)
|
|
29
|
+
// 本文件仅导出引擎与工具定义;lane_heartbeat 工具的组装(lane-tools.js/register.js)
|
|
30
|
+
// 组装进 lane-tools.js(避免与 lane-tools 主文件耦合);本模块只动
|
|
31
|
+
// watch/ 新域 + index.js 挂载 + schema.js watch 配置键 + cordis.patch.yml 注释。
|
|
32
|
+
import { join, dirname } from 'node:path';
|
|
33
|
+
import { statSync } from 'node:fs';
|
|
34
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
35
|
+
import { resolveWatchConfig, WATCH_DEFAULTS } from '../schema.js';
|
|
36
|
+
import { sessionOf, TEXT_OUTPUT } from '../tools/shared.js'; // P2-01:watch 域不再 import tools/core(共享辅助下沉零依赖 shared.js)
|
|
37
|
+
import { isAbsPath } from '../state/constants.js'; // P1-07 单点(原 :77 内联正则收敛)
|
|
38
|
+
import { findTask } from '../state/task-utils.js'; // P1-04 单点(原 :69 本地定义删除)
|
|
39
|
+
// R-01/R-07 收敛:lane.stalled 事件字面量(发端 :188 + 读端 :101/:139)改引 EVT 常量单点
|
|
40
|
+
import * as EVT from '../state/event-types.js';
|
|
41
|
+
|
|
42
|
+
// 退避档位(分钟 → ms,单调化钳制):档位单调不减,冷场越久追问间隔越长
|
|
43
|
+
export function buildSchedule(intervalsMinutes) {
|
|
44
|
+
const src = Array.isArray(intervalsMinutes) && intervalsMinutes.length > 0
|
|
45
|
+
? intervalsMinutes
|
|
46
|
+
: WATCH_DEFAULTS.intervalsMinutes;
|
|
47
|
+
const list = src.map((m) => {
|
|
48
|
+
const n = Number(m);
|
|
49
|
+
return Number.isFinite(n) && n >= 0 ? n * 60_000 : 0;
|
|
50
|
+
});
|
|
51
|
+
for (let i = 1; i < list.length; i++) list[i] = Math.max(list[i], list[i - 1]);
|
|
52
|
+
return list;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createLaneHeartbeat({ store, mailbox, config, root, now }) {
|
|
56
|
+
const cfg = resolveWatchConfig(config);
|
|
57
|
+
const schedule = buildSchedule(cfg.intervalsMinutes);
|
|
58
|
+
const hardStop = cfg.maxMissed; // 硬停拍数:连续 N 拍无活动 → stalled
|
|
59
|
+
const clock = typeof now === 'function' ? now : () => Date.now();
|
|
60
|
+
const engineRoot = root ?? (store?.sessionsDir ? dirname(store.sessionsDir) : null);
|
|
61
|
+
// 状态表 Map<laneKey, { lastActivityAt, lastSeenTs, lastProbeAt, missedCount, stalled, pendingProbeId }>
|
|
62
|
+
// laneKey = `${sessionId}/${batchId}/${lane}`
|
|
63
|
+
const state = new Map();
|
|
64
|
+
let disposed = false;
|
|
65
|
+
|
|
66
|
+
function mailboxRootOf(sessionId, batchId) {
|
|
67
|
+
if (!engineRoot) throw new Error('lane-heartbeat: no root (pass deps.root or store.sessionsDir)');
|
|
68
|
+
return join(engineRoot, 'sessions', sessionId, 'mailbox', batchId);
|
|
69
|
+
}
|
|
70
|
+
const laneKeyOf = (sessionId, batchId, lane) => `${sessionId}/${batchId}/${lane}`;
|
|
71
|
+
|
|
72
|
+
// 产物解析(自持只读实现,与 gates.js resolveArtifact/fileExistsNonEmpty 同源模式,避免循环依赖)
|
|
73
|
+
function resolveArtifact(sessionId, batchId, rel) {
|
|
74
|
+
const base = store.artifactsDirOf(sessionId, batchId);
|
|
75
|
+
return isAbsPath(rel) ? rel : join(base, rel);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function freshEntry(nowTs) {
|
|
79
|
+
return {
|
|
80
|
+
lastActivityAt: nowTs,
|
|
81
|
+
lastSeenTs: nowTs,
|
|
82
|
+
lastProbeAt: nowTs, // 新派发 lane 先给 base 档宽限,不立即追问(重派 running 计时重置语义)
|
|
83
|
+
missedCount: 0,
|
|
84
|
+
stalled: false,
|
|
85
|
+
pendingProbeId: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function resetEntry(entry, nowTs) {
|
|
89
|
+
entry.lastActivityAt = nowTs;
|
|
90
|
+
entry.lastSeenTs = nowTs;
|
|
91
|
+
entry.lastProbeAt = nowTs;
|
|
92
|
+
entry.missedCount = 0;
|
|
93
|
+
entry.stalled = false;
|
|
94
|
+
entry.pendingProbeId = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- 活动判定(三信号任一,防「长任务无产出误判」)----
|
|
98
|
+
// ① batch.events:本 lane 可归因事件比上次扫描新(lane.stalled 为引擎自写事件,排除防自重置)
|
|
99
|
+
function laneEventActivity(batch, lane, sinceTs) {
|
|
100
|
+
const evs = batch.events ?? [];
|
|
101
|
+
for (let i = evs.length - 1; i >= 0; i--) {
|
|
102
|
+
const e = evs[i];
|
|
103
|
+
if (e.type === EVT.EVT_LANE_STALLED) continue;
|
|
104
|
+
if (e.lane === lane) {
|
|
105
|
+
const ts = Date.parse(e.ts ?? '');
|
|
106
|
+
if (Number.isFinite(ts) && ts > sinceTs) return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
// ② outbox:该 lane outbox 目录出现未 ack 消息
|
|
112
|
+
function outboxActivity(sessionId, batchId, lane) {
|
|
113
|
+
try {
|
|
114
|
+
return mailbox.readUnacked(mailboxRootOf(sessionId, batchId), { type: 'outbox', lane }).length > 0;
|
|
115
|
+
} catch { return false; }
|
|
116
|
+
}
|
|
117
|
+
// ③ 产物 mtime:artifacts/<batchId>/ 下该 lane 声明产物(outputs/produce)mtime 变化
|
|
118
|
+
function artifactActivity(sessionId, batchId, batch, lane, sinceTs) {
|
|
119
|
+
const t = findTask(batch, lane);
|
|
120
|
+
const rels = [...(t?.outputs ?? []), ...(t?.produce ?? [])];
|
|
121
|
+
for (const rel of rels) {
|
|
122
|
+
try {
|
|
123
|
+
if (statSync(resolveArtifact(sessionId, batchId, rel)).mtimeMs > sinceTs) return true;
|
|
124
|
+
} catch { /* 不存在/不可读 → 无活动 */ }
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
function hasActivity(sessionId, batchId, batch, lane, sinceTs) {
|
|
129
|
+
return laneEventActivity(batch, lane, sinceTs)
|
|
130
|
+
|| outboxActivity(sessionId, batchId, lane)
|
|
131
|
+
|| artifactActivity(sessionId, batchId, batch, lane, sinceTs);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 首次追踪某 lane 时把 lastSeenTs 基线对齐到「已知信号最新时间」(本 lane 事件 ts / 声明产物 mtime 的最大值)。
|
|
135
|
+
// 防时钟偏移/测试注入时钟时把历史活动误判为新鲜活动(历史事件 ts > 冷启动 nowTs 时会被误判持续活动)
|
|
136
|
+
function baselineTs(sessionId, batchId, batch, lane, nowTs) {
|
|
137
|
+
let t = nowTs;
|
|
138
|
+
const evs = batch.events ?? [];
|
|
139
|
+
for (let i = evs.length - 1; i >= 0; i--) {
|
|
140
|
+
const e = evs[i];
|
|
141
|
+
if (e.type === EVT.EVT_LANE_STALLED) continue;
|
|
142
|
+
if (e.lane === lane) {
|
|
143
|
+
const ts = Date.parse(e.ts ?? '');
|
|
144
|
+
if (Number.isFinite(ts) && ts > t) t = ts;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const task = findTask(batch, lane);
|
|
148
|
+
for (const rel of [...(task?.outputs ?? []), ...(task?.produce ?? [])]) {
|
|
149
|
+
try {
|
|
150
|
+
const m = statSync(resolveArtifact(sessionId, batchId, rel)).mtimeMs;
|
|
151
|
+
if (m > t) t = m;
|
|
152
|
+
} catch { /* 不存在 → 忽略 */ }
|
|
153
|
+
}
|
|
154
|
+
return t;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 同 lane 至多 1 条 pending 追问(多拍合并语义:mailbox 无 replace → 读 inbox 未 ack 追问,存在则跳过)。
|
|
158
|
+
// 追问 ts 早于最近一次活动 → 视为已被活动满足的陈旧追问,不再当作未答拍(活动后 reset 语义)
|
|
159
|
+
function pendingProbe(sessionId, batchId, lane, sinceTs) {
|
|
160
|
+
try {
|
|
161
|
+
const items = mailbox.readUnacked(mailboxRootOf(sessionId, batchId), { type: 'inbox' });
|
|
162
|
+
for (const it of items) {
|
|
163
|
+
if (it.message?.kind !== 'probe' || it.message?.lane !== lane) continue;
|
|
164
|
+
const ts = Date.parse(it.ts ?? '');
|
|
165
|
+
if (Number.isFinite(ts) && ts < sinceTs) continue; // 陈旧追问:活动发生在它之后
|
|
166
|
+
return it;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
} catch { return null; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function probeText(lane, batchId, missed) {
|
|
173
|
+
if (cfg.probeTemplate) {
|
|
174
|
+
return cfg.probeTemplate
|
|
175
|
+
.replaceAll('{lane}', lane)
|
|
176
|
+
.replaceAll('{batchId}', batchId)
|
|
177
|
+
.replaceAll('{missed}', String(missed));
|
|
178
|
+
}
|
|
179
|
+
// 轻量追问模板:≤5 句、含 lane 标识、不调工具、不复盘全部历史
|
|
180
|
+
return [
|
|
181
|
+
`Manager 心跳:lane「${lane}」(批次 ${batchId})已连续 ${missed} 拍无活动信号。`,
|
|
182
|
+
'请用 ≤3 句汇报当前进度或阻塞点(走 outbox)。',
|
|
183
|
+
'不调工具、不复盘全部历史。',
|
|
184
|
+
'本消息为自动追问,任何活动即重置计时。',
|
|
185
|
+
].join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function markStalled(sessionId, batchId, lane, entry, nowTs) {
|
|
189
|
+
try {
|
|
190
|
+
store.appendEvent(sessionId, batchId, EVT.EVT_LANE_STALLED, { lane, missed: entry.missedCount });
|
|
191
|
+
} catch { /* 批次已终态/不存在等 → 忽略(下轮不再扫) */ }
|
|
192
|
+
entry.stalled = true;
|
|
193
|
+
entry.lastSeenTs = nowTs; // 自写事件不触发自身重置
|
|
194
|
+
entry.lastProbeAt = null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 扫描全部会话 running lane:
|
|
198
|
+
// - 仅扫 batch.phase === 'running' 且 lane === 'running';paused/planning/终态批次、idle/终态 lane 不挂心跳
|
|
199
|
+
// (恢复语义:running→idle 后不挂,重派 running 时以 fresh entry 重置计时)
|
|
200
|
+
function tick() {
|
|
201
|
+
if (disposed) return;
|
|
202
|
+
const nowTs = clock();
|
|
203
|
+
for (const sessionId of store.listSessions()) {
|
|
204
|
+
for (const batchId of store.listBatches(sessionId)) {
|
|
205
|
+
const batch = store.readBatch(sessionId, batchId);
|
|
206
|
+
if (!batch) continue;
|
|
207
|
+
if (batch.phase !== 'running') { dropBatchEntries(batch); continue; }
|
|
208
|
+
for (const [lane, st] of Object.entries(batch.lanes ?? {})) {
|
|
209
|
+
const laneKey = laneKeyOf(sessionId, batchId, lane);
|
|
210
|
+
if (st !== 'running') { state.delete(laneKey); continue; }
|
|
211
|
+
let entry = state.get(laneKey);
|
|
212
|
+
if (!entry) {
|
|
213
|
+
entry = freshEntry(nowTs);
|
|
214
|
+
entry.lastSeenTs = baselineTs(sessionId, batchId, batch, lane, entry.lastSeenTs);
|
|
215
|
+
state.set(laneKey, entry);
|
|
216
|
+
}
|
|
217
|
+
// 活动 → 重置回 tier0(W3)
|
|
218
|
+
if (hasActivity(sessionId, batchId, batch, lane, entry.lastSeenTs)) { resetEntry(entry, nowTs); continue; }
|
|
219
|
+
// 已 stalled:只标记,停止追问(W2)
|
|
220
|
+
if (entry.stalled) continue;
|
|
221
|
+
// 退避档位:tier = min(missedCount, len-1),档位单调(W4)
|
|
222
|
+
const tier = Math.min(entry.missedCount, schedule.length - 1);
|
|
223
|
+
if (entry.lastProbeAt !== null && nowTs - entry.lastProbeAt < schedule[tier]) continue;
|
|
224
|
+
// 一拍 = 一次追问轮次;同 lane 至多 1 条 pending(W1),pending 存在则这一拍=已发未答
|
|
225
|
+
const pending = pendingProbe(sessionId, batchId, lane, entry.lastActivityAt);
|
|
226
|
+
if (!pending) {
|
|
227
|
+
const r = mailbox.send(mailboxRootOf(sessionId, batchId), { type: 'inbox' }, {
|
|
228
|
+
kind: 'probe',
|
|
229
|
+
lane,
|
|
230
|
+
batchId,
|
|
231
|
+
missed: entry.missedCount + 1,
|
|
232
|
+
text: probeText(lane, batchId, entry.missedCount + 1),
|
|
233
|
+
});
|
|
234
|
+
entry.pendingProbeId = r.ackId;
|
|
235
|
+
}
|
|
236
|
+
entry.missedCount += 1;
|
|
237
|
+
entry.lastProbeAt = nowTs;
|
|
238
|
+
// 硬停:连续 N 拍无活动 → lane.stalled(只标记不自动处置,不新增成员状态)
|
|
239
|
+
if (entry.missedCount >= hardStop) markStalled(sessionId, batchId, lane, entry, nowTs);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function dropBatchEntries(batch) {
|
|
246
|
+
for (const lane of Object.keys(batch.lanes ?? {})) state.delete(laneKeyOf(batch.sessionId, batch.batchId, lane));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 只读:lastActivityAt/当前档位/missed/stalled/pendingProbeId
|
|
250
|
+
function status(laneKey) {
|
|
251
|
+
const entry = state.get(laneKey);
|
|
252
|
+
// laneKey = `${sessionId}/${batchId}/${lane}`;lane 未约束可含 '/',按前两个斜杠定位
|
|
253
|
+
const i1 = laneKey.indexOf('/');
|
|
254
|
+
const i2 = i1 >= 0 ? laneKey.indexOf('/', i1 + 1) : -1;
|
|
255
|
+
const sessionId = i1 >= 0 ? laneKey.slice(0, i1) : null;
|
|
256
|
+
const batchId = i2 >= 0 ? laneKey.slice(i1 + 1, i2) : null;
|
|
257
|
+
const lane = i2 >= 0 ? laneKey.slice(i2 + 1) : null;
|
|
258
|
+
if (!entry) {
|
|
259
|
+
return { laneKey, tracked: false, lastActivityAt: null, tier: 0, intervalMs: schedule[0], missed: 0, stalled: false, pendingProbeId: null, lastProbeAt: null };
|
|
260
|
+
}
|
|
261
|
+
const tier = Math.min(entry.missedCount, schedule.length - 1);
|
|
262
|
+
return {
|
|
263
|
+
laneKey,
|
|
264
|
+
tracked: true,
|
|
265
|
+
sessionId,
|
|
266
|
+
batchId,
|
|
267
|
+
lane,
|
|
268
|
+
lastActivityAt: new Date(entry.lastActivityAt).toISOString(),
|
|
269
|
+
tier,
|
|
270
|
+
intervalMs: schedule[tier],
|
|
271
|
+
missed: entry.missedCount,
|
|
272
|
+
stalled: entry.stalled,
|
|
273
|
+
pendingProbeId: entry.pendingProbeId,
|
|
274
|
+
lastProbeAt: entry.lastProbeAt ? new Date(entry.lastProbeAt).toISOString() : null,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 活动信号外部入口:missedCount=0 回 tier0
|
|
279
|
+
function reset(laneKey) {
|
|
280
|
+
const entry = state.get(laneKey);
|
|
281
|
+
if (entry) resetEntry(entry, clock());
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function dispose() {
|
|
285
|
+
disposed = true;
|
|
286
|
+
state.clear();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { tick, status, reset, dispose };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// lane_heartbeat 工具定义(只读查询 + 可选手动触发一拍)。
|
|
293
|
+
// 组装进 lane-tools.js;enabled=false 时本工具不注册。
|
|
294
|
+
// deps: { store, root, config, heartbeat? } —— heartbeat 缺省时自建(注册侧懒加载,与挂载引擎共享状态文件)
|
|
295
|
+
export function createHeartbeatTools(ctx, deps = {}) {
|
|
296
|
+
if (resolveWatchConfig(deps.config).enabled !== true) return [];
|
|
297
|
+
const heartbeat = deps.heartbeat
|
|
298
|
+
?? createLaneHeartbeat({ store: deps.store, mailbox: deps.mailbox, config: deps.config, root: deps.root });
|
|
299
|
+
return [
|
|
300
|
+
defineTool({
|
|
301
|
+
name: 'lane_heartbeat',
|
|
302
|
+
description: '查询/手动触发 lane 心跳(watch 能力 C1,只读+可选手动一拍):返回指定批次 running lane 的过期检测状态(lastActivityAt/当前档位/missed/stalled/pendingProbeId);beat=true 时先跑一拍扫描再返回。不改变任何成员状态(stalled 用事件表达)。enabled=false 时不注册。',
|
|
303
|
+
parameters: {
|
|
304
|
+
batchId: { type: 'string', required: true, description: '批次 ID' },
|
|
305
|
+
lane: { type: 'string', description: 'lane ID;缺省返回该批全部 running lane' },
|
|
306
|
+
session: { type: 'string', description: '批次归属会话' },
|
|
307
|
+
beat: { type: 'boolean', description: '手动触发一拍扫描(tick)后再返回状态' },
|
|
308
|
+
},
|
|
309
|
+
output: {
|
|
310
|
+
schema: { type: 'object', additionalProperties: false, properties: {
|
|
311
|
+
batchId: { type: 'string', required: true },
|
|
312
|
+
sessionId: { type: 'string', required: true },
|
|
313
|
+
lanes: { type: 'array', required: true, items: { type: 'object', additionalProperties: true } },
|
|
314
|
+
} },
|
|
315
|
+
render: (_args, value) => TEXT_OUTPUT('lane heartbeat: ' + value.lanes.length + ' lane(s)'),
|
|
316
|
+
},
|
|
317
|
+
async execute(args, exec) {
|
|
318
|
+
const sessionId = sessionOf(args, exec);
|
|
319
|
+
if (args.beat === true) heartbeat.tick();
|
|
320
|
+
const batch = deps.store.readBatch(sessionId, args.batchId);
|
|
321
|
+
if (!batch) throw new Error('batch not found: ' + args.batchId);
|
|
322
|
+
const laneIds = args.lane
|
|
323
|
+
? [args.lane]
|
|
324
|
+
: Object.keys(batch.lanes ?? {}).filter((l) => batch.lanes[l] === 'running');
|
|
325
|
+
return {
|
|
326
|
+
batchId: args.batchId,
|
|
327
|
+
sessionId,
|
|
328
|
+
lanes: laneIds.map((l) => heartbeat.status(`${sessionId}/${args.batchId}/${l}`)),
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
}),
|
|
332
|
+
];
|
|
333
|
+
}
|