sillyspec 3.31.0 → 3.32.1
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/.claude/skills/sillyspec-auto/SKILL.md +1 -1
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
- package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
- package/.claude/skills/sillyspec-flow/SKILL.md +10 -6
- package/.claude/skills/sillyspec-quick/SKILL.md +105 -7
- package/.zcodeignore +66 -0
- package/AGENTS.md +16 -16
- package/assets/command-cards/flow.md +9 -3
- package/assets/command-cards/run-quick.md +4 -1
- package/package.json +2 -2
- package/src/agent-session-log.js +27 -5
- package/src/config-schema.js +21 -0
- package/src/datetime.js +51 -0
- package/src/db.js +10 -3
- package/src/decision-distill.js +21 -12
- package/src/design-facts.js +46 -6
- package/src/flow-draft.js +468 -95
- package/src/flow-parity.js +199 -5
- package/src/flow-review.js +136 -6
- package/src/flow.js +786 -145
- package/src/foreign-declared.js +75 -3
- package/src/fr-index.js +148 -162
- package/src/git-helper.js +16 -3
- package/src/hooks/worktree-guard.js +16 -5
- package/src/hunk-attribution.js +28 -5
- package/src/index.js +186 -19
- package/src/knowledge-classify.js +3 -1
- package/src/knowledge-digest.js +55 -26
- package/src/knowledge-match.js +135 -7
- package/src/knowledge-stats.js +105 -4
- package/src/knowledge-vector.js +160 -0
- package/src/machine-interface.js +90 -2
- package/src/module-impact.js +22 -1
- package/src/modules.js +9 -0
- package/src/progress/change-registry.js +20 -0
- package/src/progress/stage-machine.js +4 -7
- package/src/progress.js +8 -1
- package/src/quicklog.js +20 -0
- package/src/redomain.js +23 -7
- package/src/route-hindsight.js +276 -0
- package/src/run/archive-distill.js +3 -1
- package/src/run/command.js +65 -12
- package/src/run/complete-handlers.js +219 -5
- package/src/run/complete.js +99 -22
- package/src/run/gate-snapshot.js +15 -0
- package/src/run/gates.js +60 -4
- package/src/run/prompt.js +159 -31
- package/src/run/quick-audit.js +66 -7
- package/src/run/shared.js +6 -11
- package/src/run/stage.js +5 -1
- package/src/run/verify-quality-scan.js +84 -0
- package/src/scan-facts.js +2 -1
- package/src/scope-audit.js +138 -5
- package/src/sentinel-assertions.js +67 -16
- package/src/stage-contract-engine.js +4 -1
- package/src/stage-contract-spec.js +103 -4
- package/src/stage-contract.js +55 -7
- package/src/stages/brainstorm.js +27 -14
- package/src/stages/knowledge.js +4 -3
- package/src/stages/plan-postcheck.js +98 -3
- package/src/stages/plan.js +22 -2
- package/src/stages/quick.js +1 -1
- package/src/stages/verify.js +2 -1
- package/src/task-review.js +1 -1
- package/src/task-tick.js +121 -0
- package/src/taskcard-frontmatter.js +31 -0
- package/src/ui-visual.js +43 -8
- package/src/verify-postcheck.js +518 -38
- package/src/verify-probes.js +216 -5
- package/src/watcher.js +1067 -1031
- package/src/worktree-apply.js +9 -1
- package/src/worktree.js +21 -6
- package/templates/agents-instruction.md +15 -15
- package/templates/prompts/taskcard-rules.md +1 -0
package/src/watcher.js
CHANGED
|
@@ -1,1031 +1,1067 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* watcher.js — change 产物签名 watcher(R7 切片一,detached 观测旁路)。
|
|
3
|
-
*
|
|
4
|
-
* 背景(2026-09-22-r7-protocol-surgery 切片一 / D-001):协议对话与平台心跳共线(信道
|
|
5
|
-
* 混乱)——进度信号只能靠 CLI 调用推进。本模块把观测从协议解耦:change 启动即拉起
|
|
6
|
-
* detached 监听进程,从**产物签名**(文件/git/工件三类源)推断阶段并落事件流。事件恒带
|
|
7
|
-
* provisional:true,平台 ingest 只展示不判定——`--done`(或 flow done)是唯一真相,
|
|
8
|
-
* watcher 是 best-effort 缓存,崩溃/不 spawn 零影响主流程。
|
|
9
|
-
*
|
|
10
|
-
* 结构循 src/run/bg-sync.js(spawn detached + windowsHide + 单飞锁 + 日志截尾),两处
|
|
11
|
-
* 语义差异:①长驻——锁加 heartbeatAt 心跳字段(30s 刷新,5min 无心跳或 pid 死判死;
|
|
12
|
-
* bg-sync 只有 startedAt 时效);②**未连接平台也 spawn**(本地 jsonl 是事件唯一真相源,
|
|
13
|
-
* 平台推送是展示面增强;bg-sync 的 not-connected 短路不适用)。
|
|
14
|
-
*
|
|
15
|
-
* 观测机制=轮询快照 diff(3s,弃 fs.watch:Windows/网络盘跨平台不可靠+递归噪声;事件数
|
|
16
|
-
* 与 CLI 调用数解耦是验收钉,轮询天然满足)。轮询面收窄:change 子树已知产物 +
|
|
17
|
-
* git HEAD 头指针 + verify-quality-scan 记录——不扫全仓。
|
|
18
|
-
*
|
|
19
|
-
* 平台推送:POST {platform.url}/api/changes/{name}/events 单事件上行端点(平台侧
|
|
20
|
-
* 2026-09-26-change-events-r18-full 契约;旧 /api/observation/events 批量端点随废弃
|
|
21
|
-
* 分支消亡——2026-09-27-watcher-push-endpoint 对齐),实现模式循
|
|
22
|
-
* src/agent-session-log.js 的 pushAgentLogToPlatform(凭据读取同 readPlatformPushConfig
|
|
23
|
-
* 的 local.yaml platform 段/env 双通道——该函数未导出且 agent-session-log 属他任务
|
|
24
|
-
* allowed_paths,此处同模式自建;5s 超时/条+best-effort+env SILLYSPEC_WATCHER_PUSH=0
|
|
25
|
-
* 开关;未配置/非 2xx/网络失败静默降级本地-only)。事件经 toPlatformChangeEvents
|
|
26
|
-
* 映射为平台单条契约(kind/rule/severity/provisional/detail/ts;stage 以「stage · note」
|
|
27
|
-
* 前缀并入 detail 文本、provisional 恒 true、ts=ISO UTC;本地事件无 id 不带——
|
|
28
|
-
* 平台去重键回退 ts+'|'+rule),逐条 POST 恒 200 语义;平台按去重键
|
|
29
|
-
* 幂等吸收重推(at-least-once 上行安全)。
|
|
30
|
-
*
|
|
31
|
-
* 副产品:阶段墙钟拆账(产物事件时间序列聚合)落 .runtime/watcher-stage-timing-<change>.
|
|
32
|
-
* json,替代 transcript 抽取。
|
|
33
|
-
*
|
|
34
|
-
* 生命周期:change 目录移入 archive(archived 终态事件)→ 自退;空闲 6h 无事件 → 自退;
|
|
35
|
-
* SILLYSPEC_WATCHER=0 逃生阀(父侧不 spawn)。
|
|
36
|
-
*/
|
|
37
|
-
import { spawn } from 'child_process';
|
|
38
|
-
import {
|
|
39
|
-
appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync,
|
|
40
|
-
readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
|
|
41
|
-
} from 'fs';
|
|
42
|
-
import { createHash } from 'crypto';
|
|
43
|
-
import { join, resolve } from 'path';
|
|
44
|
-
import { gitQuiet } from './git-helper.js';
|
|
45
|
-
import { normalizePath, globMatch } from './change-list.js';
|
|
46
|
-
import { resolveTestFileOwners } from './test-bindings.js';
|
|
47
|
-
|
|
48
|
-
export const WATCHER_LOCK_FILENAME = 'watcher.lock';
|
|
49
|
-
const WATCHER_LOG_FILENAME = 'watcher.log';
|
|
50
|
-
|
|
51
|
-
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
52
|
-
const LEASE_STALE_MS = 5 * 60_000;
|
|
53
|
-
const POLL_INTERVAL_MS = 3_000;
|
|
54
|
-
const IDLE_EXIT_MS = 6 * 60 * 60_1000;
|
|
55
|
-
// 硬寿命帽:孤儿绝对上限(2026-09-22 泄漏实证兜底)——无论活跃与否 12h 必退
|
|
56
|
-
const MAX_LIFETIME_MS = 12 * 60 * 60_000;
|
|
57
|
-
const PUSH_TIMEOUT_MS = 5_000;
|
|
58
|
-
const LOG_TRUNCATE_THRESHOLD = 1_000_000;
|
|
59
|
-
const LOG_TRUNCATE_KEEP = 512 * 1024;
|
|
60
|
-
|
|
61
|
-
/** 已知产物 → 阶段映射(阶段推断=产物签名;未列出的文件不发事件,防噪声)。 */
|
|
62
|
-
const STAGE_FILES = {
|
|
63
|
-
'proposal.md': 'proposal',
|
|
64
|
-
'requirements.md': 'requirements',
|
|
65
|
-
'design.md': 'design',
|
|
66
|
-
'plan.md': 'plan',
|
|
67
|
-
'tasks.md': 'tasks',
|
|
68
|
-
'decisions.md': 'proposal',
|
|
69
|
-
'verify-result.md': 'verify',
|
|
70
|
-
'module-impact.md': 'verify',
|
|
71
|
-
'symbol-impact.md': 'verify',
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
/** pid 活性探测(跨平台):signal 0 探测;EPERM = 进程存在但属他户,保守按活。 */
|
|
75
|
-
function isPidAlive(pid) {
|
|
76
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
77
|
-
try {
|
|
78
|
-
process.kill(pid, 0);
|
|
79
|
-
return true;
|
|
80
|
-
} catch (e) {
|
|
81
|
-
return e && e.code === 'EPERM';
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** 读 watcher 单飞锁(缺失/损坏 → null)。 */
|
|
86
|
-
export function readWatcherLock(runtimeRoot) {
|
|
87
|
-
try {
|
|
88
|
-
const raw = readFileSync(join(runtimeRoot, WATCHER_LOCK_FILENAME), 'utf8');
|
|
89
|
-
const obj = JSON.parse(raw);
|
|
90
|
-
return obj && typeof obj === 'object' ? obj : null;
|
|
91
|
-
} catch {
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function writeWatcherLock(runtimeRoot, lock) {
|
|
97
|
-
mkdirSync(runtimeRoot, { recursive: true });
|
|
98
|
-
const tmp = join(runtimeRoot, `${WATCHER_LOCK_FILENAME}.${process.pid}.tmp`);
|
|
99
|
-
writeFileSync(tmp, JSON.stringify(lock) + '\n', 'utf8');
|
|
100
|
-
renameSync(tmp, join(runtimeRoot, WATCHER_LOCK_FILENAME));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function removeWatcherLock(runtimeRoot) {
|
|
104
|
-
try {
|
|
105
|
-
const p = join(runtimeRoot, WATCHER_LOCK_FILENAME);
|
|
106
|
-
if (existsSync(p)) unlinkSync(p);
|
|
107
|
-
} catch { /* 清锁失败:残留锁按心跳/pid 判死后自动接管,不影响正确性 */ }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* 租约活性判定(心跳新鲜 + pid 活)。纯函数(pid 探测单列 isPidAlive 便于注入测试)。
|
|
112
|
-
* 与 bg-sync 锁的差异:心跳字段 heartbeatAt 是长驻租约的判死主锚(30s 刷新/5min 过期),
|
|
113
|
-
* startedAt 仅作审计信息——pid 复用假活由心跳过期兜底。
|
|
114
|
-
*/
|
|
115
|
-
export function isWatcherLeaseLive(lock, now = Date.now()) {
|
|
116
|
-
if (!lock || !Number.isInteger(lock.pid) || lock.pid <= 0) return false;
|
|
117
|
-
if (typeof lock.heartbeatAt !== 'number') return false;
|
|
118
|
-
if (now - lock.heartbeatAt > LEASE_STALE_MS) return false;
|
|
119
|
-
return isPidAlive(lock.pid);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function sha256(text) {
|
|
123
|
-
return createHash('sha256').update(text).digest('hex');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function countCheckboxes(text) {
|
|
127
|
-
const checked = (text.match(/- \[x\]/g) || []).length;
|
|
128
|
-
const unchecked = (text.match(/- \[ \]/g) || []).length;
|
|
129
|
-
return { checked, total: checked + unchecked };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** tasks.md/任务卡里已勾选的 task-NN id 清单(假勾选归因锚:无 id 的勾选行不入判集)。 */
|
|
133
|
-
function extractCheckedTasks(text) {
|
|
134
|
-
const out = [];
|
|
135
|
-
const re = /^[-*] \[[xX]\] (task-\d+)/gm;
|
|
136
|
-
let m;
|
|
137
|
-
while ((m = re.exec(String(text || '')))) out.push(m[1]);
|
|
138
|
-
return out;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ── 哨兵判据源(2026-09-23-sentinel-rules task-01):快照 additive 四源 ──
|
|
142
|
-
// 口径与 src/run/verify-quality-scan.js isNonCodePath 相同(.sillyspec/、docs/、*.md 非
|
|
143
|
-
// 代码面)——本地复制不 import(该模块拖 test-ledger 重链,detached 子进程不吃)。
|
|
144
|
-
function isNonCodePath(p) {
|
|
145
|
-
return p.startsWith('.sillyspec/') || p.startsWith('docs/') || p.endsWith('.md');
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* `git log -20 --format=%h|%s --name-only` 输出解析 → [{hash, subject, files}]。
|
|
150
|
-
* hash|subject 行与后续裸路径行段式聚合(空行分隔可忽略);解析不出任何 commit → []。
|
|
151
|
-
*/
|
|
152
|
-
export function parseGitLogWithFiles(text) {
|
|
153
|
-
const commits = [];
|
|
154
|
-
let cur = null;
|
|
155
|
-
for (const line of String(text || '').split('\n')) {
|
|
156
|
-
if (!line.trim()) continue;
|
|
157
|
-
const h = line.match(/^([0-9a-f]{7,40})\|(.*)$/);
|
|
158
|
-
if (h) {
|
|
159
|
-
cur = { hash: h[1], subject: h[2], files: [] };
|
|
160
|
-
commits.push(cur);
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
if (cur && !line.startsWith('commit ')) {
|
|
164
|
-
const p = line.trim().replace(/\\/g, '/');
|
|
165
|
-
if (p) cur.files.push(p);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return commits;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* 提交证据并集(hash 判重,保序追加):worktree 分支提交与主仓 log 合并——
|
|
173
|
-
* apply/merge 后同一提交从两面都可达,hash 判重防双计(newCommitsBetween 同键)。
|
|
174
|
-
*/
|
|
175
|
-
export function mergeCommitEvidence(base, extra) {
|
|
176
|
-
const out = [...(base || [])];
|
|
177
|
-
const seen = new Set(out.map((c) => c && c.hash).filter(Boolean));
|
|
178
|
-
for (const c of extra || []) {
|
|
179
|
-
if (!c || !c.hash || seen.has(c.hash)) continue;
|
|
180
|
-
seen.add(c.hash);
|
|
181
|
-
out.push(c);
|
|
182
|
-
}
|
|
183
|
-
return out;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** porcelain → 代码脏路径清单(重命名取新路径;剔非代码面;排序去重)。 */
|
|
187
|
-
export function parsePorcelainCodePaths(porcelain) {
|
|
188
|
-
const seen = new Set();
|
|
189
|
-
const out = [];
|
|
190
|
-
for (const line of String(porcelain || '').split('\n')) {
|
|
191
|
-
if (!line.trim()) continue;
|
|
192
|
-
let p = (line.slice(3) || '').trim();
|
|
193
|
-
const arrow = p.indexOf(' -> ');
|
|
194
|
-
if (arrow >= 0) p = p.slice(arrow + 4);
|
|
195
|
-
p = p.replace(/^"|"$/g, '').replace(/\\/g, '/');
|
|
196
|
-
if (!p || isNonCodePath(p) || seen.has(p)) continue;
|
|
197
|
-
seen.add(p);
|
|
198
|
-
out.push(p);
|
|
199
|
-
}
|
|
200
|
-
return out.sort();
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** execute-runs 下全部 review.json 的 mtime
|
|
204
|
-
function collectReviewMtimes(runtimeRoot, readdirSyncImpl, statSyncImpl) {
|
|
205
|
-
const reviews = {};
|
|
206
|
-
let runs = [];
|
|
207
|
-
try { runs = readdirSyncImpl(join(runtimeRoot, 'execute-runs')); } catch { return reviews; }
|
|
208
|
-
for (const run of runs) {
|
|
209
|
-
let names = [];
|
|
210
|
-
try { names = readdirSyncImpl(join(runtimeRoot, 'execute-runs', run, 'tasks')); } catch { continue; }
|
|
211
|
-
for (const t of names) {
|
|
212
|
-
const rel = `${run}/tasks/${t}/review.json`;
|
|
213
|
-
try { reviews[rel] = Math.round(statSyncImpl(join(runtimeRoot, 'execute-runs', run, 'tasks', t, 'review.json')).mtimeMs); } catch { /* 缺失跳过 */ }
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return reviews;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* 构建一次快照(纯读盘+git 面;不入事件——推断在 inferEvents 纯函数,测试主入口)。
|
|
221
|
-
* @returns {{ts:number, archived:boolean, head:string|null, files:Object, scan:Object|null,
|
|
222
|
-
* commits:Array<{hash,subject,files:string[]}>|null, dirtyCode:string[]|null,
|
|
223
|
-
* scanStatus:{status:string,ranAt:*}|null, reviews:Object}}
|
|
224
|
-
* files: { <相对 changeDir 路径>: { hash, checked, total, checkedTasks } }
|
|
225
|
-
* 哨兵四源(git 失败 → commits/dirtyCode null,规则 fail-open);全部 JSON 可序列化
|
|
226
|
-
* (水位回补落盘依赖)。
|
|
227
|
-
*/
|
|
228
|
-
export function buildSnapshot({ changeDir, cwd, runtimeRoot, changeName, readFileSyncImpl = readFileSync, statSyncImpl = statSync, readdirSyncImpl = readdirSync, gitHeadImpl, gitLogImpl, gitLogWorktreeImpl, porcelainImpl }) {
|
|
229
|
-
const snap = { ts: Date.now(), archived: false, head: null, files: {}, scan: null, commits: null, dirtyCode: null, scanStatus: null, reviews: {} };
|
|
230
|
-
if (!existsSync(changeDir)) {
|
|
231
|
-
snap.archived = true;
|
|
232
|
-
} else {
|
|
233
|
-
for (const [name, stage] of Object.entries(STAGE_FILES)) {
|
|
234
|
-
const p = join(changeDir, name);
|
|
235
|
-
if (!existsSync(p)) continue;
|
|
236
|
-
try {
|
|
237
|
-
const text = readFileSyncImpl(p, 'utf8');
|
|
238
|
-
snap.files[name] = { hash: sha256(text), stage, ...countCheckboxes(text), checkedTasks: extractCheckedTasks(text) };
|
|
239
|
-
} catch { /* 读失败按未变更处理,下轮重试 */ }
|
|
240
|
-
}
|
|
241
|
-
const tasksDir = join(changeDir, 'tasks');
|
|
242
|
-
if (existsSync(tasksDir)) {
|
|
243
|
-
for (const ent of readdirSync(tasksDir)) {
|
|
244
|
-
if (!ent.endsWith('.md')) continue;
|
|
245
|
-
const rel = `tasks/${ent}`;
|
|
246
|
-
try {
|
|
247
|
-
const text = readFileSyncImpl(join(tasksDir, ent), 'utf8');
|
|
248
|
-
snap.files[rel] = { hash: sha256(text), stage: 'tasks', ...countCheckboxes(text), checkedTasks: extractCheckedTasks(text) };
|
|
249
|
-
} catch { /* 同上 */ }
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
if (typeof gitHeadImpl === 'function') {
|
|
254
|
-
snap.head = gitHeadImpl();
|
|
255
|
-
} else {
|
|
256
|
-
const out = gitQuiet(cwd, ['rev-parse', '--short', 'HEAD']);
|
|
257
|
-
snap.head = typeof out === 'string' && out.trim() ? out.trim() : null;
|
|
258
|
-
}
|
|
259
|
-
// 哨兵源一:区间新提交(hash/subject/触及文件,单次 git log 调用同取两面)。worktree 模式下
|
|
260
|
-
// 任务提交先落 sillyspec/<change> 分支、apply 前主仓 log 不可见(2026-09-23 假勾选误报实证:
|
|
261
|
-
// change-events-channel 流 8 个 task 提交全在分支上,R1 两拍全误报),同仓分支 ref 主仓可直读
|
|
262
|
-
// ——按 hash 去重并入;分支不存在/git 失败 → null 不动(fail-open)。注入面只给 gitLogImpl 时
|
|
263
|
-
// 不追真 git(测试零真仓约定):worktree 面要么注入 gitLogWorktreeImpl,要么走真 git 路径。
|
|
264
|
-
const injectedLog = typeof gitLogImpl === 'function';
|
|
265
|
-
const mainLog = injectedLog
|
|
266
|
-
? gitLogImpl()
|
|
267
|
-
: gitQuiet(cwd, ['log', '-20', '--format=%h|%s', '--name-only']);
|
|
268
|
-
snap.commits = mainLog == null ? null : parseGitLogWithFiles(mainLog);
|
|
269
|
-
if (snap.commits != null && changeName && (!injectedLog || typeof gitLogWorktreeImpl === 'function')) {
|
|
270
|
-
const wtLog = typeof gitLogWorktreeImpl === 'function'
|
|
271
|
-
? gitLogWorktreeImpl()
|
|
272
|
-
: gitQuiet(cwd, ['log', '-20', '--format=%h|%s', '--name-only', `sillyspec/${changeName}`]);
|
|
273
|
-
if (wtLog) snap.commits = mergeCommitEvidence(snap.commits, parseGitLogWithFiles(wtLog));
|
|
274
|
-
}
|
|
275
|
-
// 哨兵源二:工作树代码脏面(范围漂移/test-tamper 工作树证据)
|
|
276
|
-
if (typeof porcelainImpl === 'function') {
|
|
277
|
-
const out = porcelainImpl();
|
|
278
|
-
snap.dirtyCode = out == null ? null : parsePorcelainCodePaths(out);
|
|
279
|
-
} else {
|
|
280
|
-
const out = gitQuiet(cwd, ['status', '--porcelain']);
|
|
281
|
-
snap.dirtyCode = out == null ? null : parsePorcelainCodePaths(out);
|
|
282
|
-
}
|
|
283
|
-
const scanPath = join(runtimeRoot, `verify-quality-scan-${changeName}.json`);
|
|
284
|
-
try {
|
|
285
|
-
const st = statSyncImpl(scanPath);
|
|
286
|
-
snap.scan = { mtimeMs: Math.round(st.mtimeMs), size: st.size };
|
|
287
|
-
} catch {
|
|
288
|
-
snap.scan = null;
|
|
289
|
-
}
|
|
290
|
-
// 哨兵源三:P2 测试账本结论(内容读,stat 只管「记录变了」事件;读失败 null)
|
|
291
|
-
try {
|
|
292
|
-
const raw = JSON.parse(readFileSyncImpl(scanPath, 'utf8'));
|
|
293
|
-
const t = raw && raw.testResult;
|
|
294
|
-
snap.scanStatus = t && t.status ? { status: String(t.status), ranAt: t.ranAt || null } : null;
|
|
295
|
-
} catch {
|
|
296
|
-
snap.scanStatus = null;
|
|
297
|
-
}
|
|
298
|
-
// 哨兵源四:review.json mtime
|
|
299
|
-
snap.reviews = collectReviewMtimes(runtimeRoot, readdirSyncImpl, statSyncImpl);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
export function
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
*/
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
if (
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
};
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1
|
+
/**
|
|
2
|
+
* watcher.js — change 产物签名 watcher(R7 切片一,detached 观测旁路)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-09-22-r7-protocol-surgery 切片一 / D-001):协议对话与平台心跳共线(信道
|
|
5
|
+
* 混乱)——进度信号只能靠 CLI 调用推进。本模块把观测从协议解耦:change 启动即拉起
|
|
6
|
+
* detached 监听进程,从**产物签名**(文件/git/工件三类源)推断阶段并落事件流。事件恒带
|
|
7
|
+
* provisional:true,平台 ingest 只展示不判定——`--done`(或 flow done)是唯一真相,
|
|
8
|
+
* watcher 是 best-effort 缓存,崩溃/不 spawn 零影响主流程。
|
|
9
|
+
*
|
|
10
|
+
* 结构循 src/run/bg-sync.js(spawn detached + windowsHide + 单飞锁 + 日志截尾),两处
|
|
11
|
+
* 语义差异:①长驻——锁加 heartbeatAt 心跳字段(30s 刷新,5min 无心跳或 pid 死判死;
|
|
12
|
+
* bg-sync 只有 startedAt 时效);②**未连接平台也 spawn**(本地 jsonl 是事件唯一真相源,
|
|
13
|
+
* 平台推送是展示面增强;bg-sync 的 not-connected 短路不适用)。
|
|
14
|
+
*
|
|
15
|
+
* 观测机制=轮询快照 diff(3s,弃 fs.watch:Windows/网络盘跨平台不可靠+递归噪声;事件数
|
|
16
|
+
* 与 CLI 调用数解耦是验收钉,轮询天然满足)。轮询面收窄:change 子树已知产物 +
|
|
17
|
+
* git HEAD 头指针 + verify-quality-scan 记录——不扫全仓。
|
|
18
|
+
*
|
|
19
|
+
* 平台推送:POST {platform.url}/api/changes/{name}/events 单事件上行端点(平台侧
|
|
20
|
+
* 2026-09-26-change-events-r18-full 契约;旧 /api/observation/events 批量端点随废弃
|
|
21
|
+
* 分支消亡——2026-09-27-watcher-push-endpoint 对齐),实现模式循
|
|
22
|
+
* src/agent-session-log.js 的 pushAgentLogToPlatform(凭据读取同 readPlatformPushConfig
|
|
23
|
+
* 的 local.yaml platform 段/env 双通道——该函数未导出且 agent-session-log 属他任务
|
|
24
|
+
* allowed_paths,此处同模式自建;5s 超时/条+best-effort+env SILLYSPEC_WATCHER_PUSH=0
|
|
25
|
+
* 开关;未配置/非 2xx/网络失败静默降级本地-only)。事件经 toPlatformChangeEvents
|
|
26
|
+
* 映射为平台单条契约(kind/rule/severity/provisional/detail/ts;stage 以「stage · note」
|
|
27
|
+
* 前缀并入 detail 文本、provisional 恒 true、ts=ISO UTC;本地事件无 id 不带——
|
|
28
|
+
* 平台去重键回退 ts+'|'+rule),逐条 POST 恒 200 语义;平台按去重键
|
|
29
|
+
* 幂等吸收重推(at-least-once 上行安全)。
|
|
30
|
+
*
|
|
31
|
+
* 副产品:阶段墙钟拆账(产物事件时间序列聚合)落 .runtime/watcher-stage-timing-<change>.
|
|
32
|
+
* json,替代 transcript 抽取。
|
|
33
|
+
*
|
|
34
|
+
* 生命周期:change 目录移入 archive(archived 终态事件)→ 自退;空闲 6h 无事件 → 自退;
|
|
35
|
+
* SILLYSPEC_WATCHER=0 逃生阀(父侧不 spawn)。
|
|
36
|
+
*/
|
|
37
|
+
import { spawn } from 'child_process';
|
|
38
|
+
import {
|
|
39
|
+
appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync,
|
|
40
|
+
readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
|
|
41
|
+
} from 'fs';
|
|
42
|
+
import { createHash } from 'crypto';
|
|
43
|
+
import { basename, dirname, join, resolve } from 'path';
|
|
44
|
+
import { gitQuiet } from './git-helper.js';
|
|
45
|
+
import { normalizePath, globMatch } from './change-list.js';
|
|
46
|
+
import { resolveTestFileOwners } from './test-bindings.js';
|
|
47
|
+
|
|
48
|
+
export const WATCHER_LOCK_FILENAME = 'watcher.lock';
|
|
49
|
+
const WATCHER_LOG_FILENAME = 'watcher.log';
|
|
50
|
+
|
|
51
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
52
|
+
const LEASE_STALE_MS = 5 * 60_000;
|
|
53
|
+
const POLL_INTERVAL_MS = 3_000;
|
|
54
|
+
const IDLE_EXIT_MS = 6 * 60 * 60_1000;
|
|
55
|
+
// 硬寿命帽:孤儿绝对上限(2026-09-22 泄漏实证兜底)——无论活跃与否 12h 必退
|
|
56
|
+
const MAX_LIFETIME_MS = 12 * 60 * 60_000;
|
|
57
|
+
const PUSH_TIMEOUT_MS = 5_000;
|
|
58
|
+
const LOG_TRUNCATE_THRESHOLD = 1_000_000;
|
|
59
|
+
const LOG_TRUNCATE_KEEP = 512 * 1024;
|
|
60
|
+
|
|
61
|
+
/** 已知产物 → 阶段映射(阶段推断=产物签名;未列出的文件不发事件,防噪声)。 */
|
|
62
|
+
const STAGE_FILES = {
|
|
63
|
+
'proposal.md': 'proposal',
|
|
64
|
+
'requirements.md': 'requirements',
|
|
65
|
+
'design.md': 'design',
|
|
66
|
+
'plan.md': 'plan',
|
|
67
|
+
'tasks.md': 'tasks',
|
|
68
|
+
'decisions.md': 'proposal',
|
|
69
|
+
'verify-result.md': 'verify',
|
|
70
|
+
'module-impact.md': 'verify',
|
|
71
|
+
'symbol-impact.md': 'verify',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** pid 活性探测(跨平台):signal 0 探测;EPERM = 进程存在但属他户,保守按活。 */
|
|
75
|
+
function isPidAlive(pid) {
|
|
76
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, 0);
|
|
79
|
+
return true;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return e && e.code === 'EPERM';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 读 watcher 单飞锁(缺失/损坏 → null)。 */
|
|
86
|
+
export function readWatcherLock(runtimeRoot) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = readFileSync(join(runtimeRoot, WATCHER_LOCK_FILENAME), 'utf8');
|
|
89
|
+
const obj = JSON.parse(raw);
|
|
90
|
+
return obj && typeof obj === 'object' ? obj : null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writeWatcherLock(runtimeRoot, lock) {
|
|
97
|
+
mkdirSync(runtimeRoot, { recursive: true });
|
|
98
|
+
const tmp = join(runtimeRoot, `${WATCHER_LOCK_FILENAME}.${process.pid}.tmp`);
|
|
99
|
+
writeFileSync(tmp, JSON.stringify(lock) + '\n', 'utf8');
|
|
100
|
+
renameSync(tmp, join(runtimeRoot, WATCHER_LOCK_FILENAME));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function removeWatcherLock(runtimeRoot) {
|
|
104
|
+
try {
|
|
105
|
+
const p = join(runtimeRoot, WATCHER_LOCK_FILENAME);
|
|
106
|
+
if (existsSync(p)) unlinkSync(p);
|
|
107
|
+
} catch { /* 清锁失败:残留锁按心跳/pid 判死后自动接管,不影响正确性 */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 租约活性判定(心跳新鲜 + pid 活)。纯函数(pid 探测单列 isPidAlive 便于注入测试)。
|
|
112
|
+
* 与 bg-sync 锁的差异:心跳字段 heartbeatAt 是长驻租约的判死主锚(30s 刷新/5min 过期),
|
|
113
|
+
* startedAt 仅作审计信息——pid 复用假活由心跳过期兜底。
|
|
114
|
+
*/
|
|
115
|
+
export function isWatcherLeaseLive(lock, now = Date.now()) {
|
|
116
|
+
if (!lock || !Number.isInteger(lock.pid) || lock.pid <= 0) return false;
|
|
117
|
+
if (typeof lock.heartbeatAt !== 'number') return false;
|
|
118
|
+
if (now - lock.heartbeatAt > LEASE_STALE_MS) return false;
|
|
119
|
+
return isPidAlive(lock.pid);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sha256(text) {
|
|
123
|
+
return createHash('sha256').update(text).digest('hex');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function countCheckboxes(text) {
|
|
127
|
+
const checked = (text.match(/- \[x\]/g) || []).length;
|
|
128
|
+
const unchecked = (text.match(/- \[ \]/g) || []).length;
|
|
129
|
+
return { checked, total: checked + unchecked };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** tasks.md/任务卡里已勾选的 task-NN id 清单(假勾选归因锚:无 id 的勾选行不入判集)。 */
|
|
133
|
+
function extractCheckedTasks(text) {
|
|
134
|
+
const out = [];
|
|
135
|
+
const re = /^[-*] \[[xX]\] (task-\d+)/gm;
|
|
136
|
+
let m;
|
|
137
|
+
while ((m = re.exec(String(text || '')))) out.push(m[1]);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── 哨兵判据源(2026-09-23-sentinel-rules task-01):快照 additive 四源 ──
|
|
142
|
+
// 口径与 src/run/verify-quality-scan.js isNonCodePath 相同(.sillyspec/、docs/、*.md 非
|
|
143
|
+
// 代码面)——本地复制不 import(该模块拖 test-ledger 重链,detached 子进程不吃)。
|
|
144
|
+
function isNonCodePath(p) {
|
|
145
|
+
return p.startsWith('.sillyspec/') || p.startsWith('docs/') || p.endsWith('.md');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `git log -20 --format=%h|%s --name-only` 输出解析 → [{hash, subject, files}]。
|
|
150
|
+
* hash|subject 行与后续裸路径行段式聚合(空行分隔可忽略);解析不出任何 commit → []。
|
|
151
|
+
*/
|
|
152
|
+
export function parseGitLogWithFiles(text) {
|
|
153
|
+
const commits = [];
|
|
154
|
+
let cur = null;
|
|
155
|
+
for (const line of String(text || '').split('\n')) {
|
|
156
|
+
if (!line.trim()) continue;
|
|
157
|
+
const h = line.match(/^([0-9a-f]{7,40})\|(.*)$/);
|
|
158
|
+
if (h) {
|
|
159
|
+
cur = { hash: h[1], subject: h[2], files: [] };
|
|
160
|
+
commits.push(cur);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (cur && !line.startsWith('commit ')) {
|
|
164
|
+
const p = line.trim().replace(/\\/g, '/');
|
|
165
|
+
if (p) cur.files.push(p);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return commits;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 提交证据并集(hash 判重,保序追加):worktree 分支提交与主仓 log 合并——
|
|
173
|
+
* apply/merge 后同一提交从两面都可达,hash 判重防双计(newCommitsBetween 同键)。
|
|
174
|
+
*/
|
|
175
|
+
export function mergeCommitEvidence(base, extra) {
|
|
176
|
+
const out = [...(base || [])];
|
|
177
|
+
const seen = new Set(out.map((c) => c && c.hash).filter(Boolean));
|
|
178
|
+
for (const c of extra || []) {
|
|
179
|
+
if (!c || !c.hash || seen.has(c.hash)) continue;
|
|
180
|
+
seen.add(c.hash);
|
|
181
|
+
out.push(c);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** porcelain → 代码脏路径清单(重命名取新路径;剔非代码面;排序去重)。 */
|
|
187
|
+
export function parsePorcelainCodePaths(porcelain) {
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const line of String(porcelain || '').split('\n')) {
|
|
191
|
+
if (!line.trim()) continue;
|
|
192
|
+
let p = (line.slice(3) || '').trim();
|
|
193
|
+
const arrow = p.indexOf(' -> ');
|
|
194
|
+
if (arrow >= 0) p = p.slice(arrow + 4);
|
|
195
|
+
p = p.replace(/^"|"$/g, '').replace(/\\/g, '/');
|
|
196
|
+
if (!p || isNonCodePath(p) || seen.has(p)) continue;
|
|
197
|
+
seen.add(p);
|
|
198
|
+
out.push(p);
|
|
199
|
+
}
|
|
200
|
+
return out.sort();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** execute-runs 下全部 review.json 的 mtime 面(有界遍历,缺失 → {})——R1 退役后无 watcher 规则消费,保留采集维持水位序列化键稳定(2026-09-29-watcher-fakecheck-retire 评审 F1)。 */
|
|
204
|
+
function collectReviewMtimes(runtimeRoot, readdirSyncImpl, statSyncImpl) {
|
|
205
|
+
const reviews = {};
|
|
206
|
+
let runs = [];
|
|
207
|
+
try { runs = readdirSyncImpl(join(runtimeRoot, 'execute-runs')); } catch { return reviews; }
|
|
208
|
+
for (const run of runs) {
|
|
209
|
+
let names = [];
|
|
210
|
+
try { names = readdirSyncImpl(join(runtimeRoot, 'execute-runs', run, 'tasks')); } catch { continue; }
|
|
211
|
+
for (const t of names) {
|
|
212
|
+
const rel = `${run}/tasks/${t}/review.json`;
|
|
213
|
+
try { reviews[rel] = Math.round(statSyncImpl(join(runtimeRoot, 'execute-runs', run, 'tasks', t, 'review.json')).mtimeMs); } catch { /* 缺失跳过 */ }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return reviews;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 构建一次快照(纯读盘+git 面;不入事件——推断在 inferEvents 纯函数,测试主入口)。
|
|
221
|
+
* @returns {{ts:number, archived:boolean, head:string|null, files:Object, scan:Object|null,
|
|
222
|
+
* commits:Array<{hash,subject,files:string[]}>|null, dirtyCode:string[]|null,
|
|
223
|
+
* scanStatus:{status:string,ranAt:*}|null, reviews:Object}}
|
|
224
|
+
* files: { <相对 changeDir 路径>: { hash, checked, total, checkedTasks } }
|
|
225
|
+
* 哨兵四源(git 失败 → commits/dirtyCode null,规则 fail-open);全部 JSON 可序列化
|
|
226
|
+
* (水位回补落盘依赖)。
|
|
227
|
+
*/
|
|
228
|
+
export function buildSnapshot({ changeDir, cwd, runtimeRoot, changeName, readFileSyncImpl = readFileSync, statSyncImpl = statSync, readdirSyncImpl = readdirSync, gitHeadImpl, gitLogImpl, gitLogWorktreeImpl, porcelainImpl }) {
|
|
229
|
+
const snap = { ts: Date.now(), archived: false, head: null, files: {}, scan: null, commits: null, dirtyCode: null, scanStatus: null, reviews: {} };
|
|
230
|
+
if (!existsSync(changeDir)) {
|
|
231
|
+
snap.archived = true;
|
|
232
|
+
} else {
|
|
233
|
+
for (const [name, stage] of Object.entries(STAGE_FILES)) {
|
|
234
|
+
const p = join(changeDir, name);
|
|
235
|
+
if (!existsSync(p)) continue;
|
|
236
|
+
try {
|
|
237
|
+
const text = readFileSyncImpl(p, 'utf8');
|
|
238
|
+
snap.files[name] = { hash: sha256(text), stage, ...countCheckboxes(text), checkedTasks: extractCheckedTasks(text) };
|
|
239
|
+
} catch { /* 读失败按未变更处理,下轮重试 */ }
|
|
240
|
+
}
|
|
241
|
+
const tasksDir = join(changeDir, 'tasks');
|
|
242
|
+
if (existsSync(tasksDir)) {
|
|
243
|
+
for (const ent of readdirSync(tasksDir)) {
|
|
244
|
+
if (!ent.endsWith('.md')) continue;
|
|
245
|
+
const rel = `tasks/${ent}`;
|
|
246
|
+
try {
|
|
247
|
+
const text = readFileSyncImpl(join(tasksDir, ent), 'utf8');
|
|
248
|
+
snap.files[rel] = { hash: sha256(text), stage: 'tasks', ...countCheckboxes(text), checkedTasks: extractCheckedTasks(text) };
|
|
249
|
+
} catch { /* 同上 */ }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (typeof gitHeadImpl === 'function') {
|
|
254
|
+
snap.head = gitHeadImpl();
|
|
255
|
+
} else {
|
|
256
|
+
const out = gitQuiet(cwd, ['rev-parse', '--short', 'HEAD']);
|
|
257
|
+
snap.head = typeof out === 'string' && out.trim() ? out.trim() : null;
|
|
258
|
+
}
|
|
259
|
+
// 哨兵源一:区间新提交(hash/subject/触及文件,单次 git log 调用同取两面)。worktree 模式下
|
|
260
|
+
// 任务提交先落 sillyspec/<change> 分支、apply 前主仓 log 不可见(2026-09-23 假勾选误报实证:
|
|
261
|
+
// change-events-channel 流 8 个 task 提交全在分支上,R1 两拍全误报),同仓分支 ref 主仓可直读
|
|
262
|
+
// ——按 hash 去重并入;分支不存在/git 失败 → null 不动(fail-open)。注入面只给 gitLogImpl 时
|
|
263
|
+
// 不追真 git(测试零真仓约定):worktree 面要么注入 gitLogWorktreeImpl,要么走真 git 路径。
|
|
264
|
+
const injectedLog = typeof gitLogImpl === 'function';
|
|
265
|
+
const mainLog = injectedLog
|
|
266
|
+
? gitLogImpl()
|
|
267
|
+
: gitQuiet(cwd, ['log', '-20', '--format=%h|%s', '--name-only']);
|
|
268
|
+
snap.commits = mainLog == null ? null : parseGitLogWithFiles(mainLog);
|
|
269
|
+
if (snap.commits != null && changeName && (!injectedLog || typeof gitLogWorktreeImpl === 'function')) {
|
|
270
|
+
const wtLog = typeof gitLogWorktreeImpl === 'function'
|
|
271
|
+
? gitLogWorktreeImpl()
|
|
272
|
+
: gitQuiet(cwd, ['log', '-20', '--format=%h|%s', '--name-only', `sillyspec/${changeName}`]);
|
|
273
|
+
if (wtLog) snap.commits = mergeCommitEvidence(snap.commits, parseGitLogWithFiles(wtLog));
|
|
274
|
+
}
|
|
275
|
+
// 哨兵源二:工作树代码脏面(范围漂移/test-tamper 工作树证据)
|
|
276
|
+
if (typeof porcelainImpl === 'function') {
|
|
277
|
+
const out = porcelainImpl();
|
|
278
|
+
snap.dirtyCode = out == null ? null : parsePorcelainCodePaths(out);
|
|
279
|
+
} else {
|
|
280
|
+
const out = gitQuiet(cwd, ['status', '--porcelain']);
|
|
281
|
+
snap.dirtyCode = out == null ? null : parsePorcelainCodePaths(out);
|
|
282
|
+
}
|
|
283
|
+
const scanPath = join(runtimeRoot, `verify-quality-scan-${changeName}.json`);
|
|
284
|
+
try {
|
|
285
|
+
const st = statSyncImpl(scanPath);
|
|
286
|
+
snap.scan = { mtimeMs: Math.round(st.mtimeMs), size: st.size };
|
|
287
|
+
} catch {
|
|
288
|
+
snap.scan = null;
|
|
289
|
+
}
|
|
290
|
+
// 哨兵源三:P2 测试账本结论(内容读,stat 只管「记录变了」事件;读失败 null)
|
|
291
|
+
try {
|
|
292
|
+
const raw = JSON.parse(readFileSyncImpl(scanPath, 'utf8'));
|
|
293
|
+
const t = raw && raw.testResult;
|
|
294
|
+
snap.scanStatus = t && t.status ? { status: String(t.status), ranAt: t.ranAt || null } : null;
|
|
295
|
+
} catch {
|
|
296
|
+
snap.scanStatus = null;
|
|
297
|
+
}
|
|
298
|
+
// 哨兵源四:review.json mtime 面(R1 退役后无规则消费方;水位兼容保留采集)
|
|
299
|
+
snap.reviews = collectReviewMtimes(runtimeRoot, readdirSyncImpl, statSyncImpl);
|
|
300
|
+
// 哨兵源五(2026-09-28-watcher-signal-widen):门实测结论——verify-runs 下本变更最新
|
|
301
|
+
// test-result.json(目录名 YYYYMMDDHHMMSS 字典序即时序)。停滞判定的核心活跃信号:
|
|
302
|
+
// 门在跑/失败/过此前对观测面完全不可见(15 分钟苦战被判停滞的实证)。
|
|
303
|
+
try {
|
|
304
|
+
const runsDir = join(runtimeRoot, 'verify-runs');
|
|
305
|
+
let latest = null;
|
|
306
|
+
for (const d of readdirSyncImpl(runsDir)) {
|
|
307
|
+
try {
|
|
308
|
+
const raw = JSON.parse(readFileSyncImpl(join(runsDir, d, 'test-result.json'), 'utf8'));
|
|
309
|
+
if (raw && raw.change === changeName) {
|
|
310
|
+
if (!latest || String(d) > latest.dir) latest = { dir: String(d), status: String(raw.status || '?'), durationMs: typeof raw.duration_ms === 'number' ? raw.duration_ms : null };
|
|
311
|
+
}
|
|
312
|
+
} catch { /* 半写/坏行跳过 */ }
|
|
313
|
+
}
|
|
314
|
+
snap.gateRun = latest;
|
|
315
|
+
} catch { snap.gateRun = null; }
|
|
316
|
+
// 哨兵源六:本地配置变更事实(local.yaml mtime——gitignored 无 git 信号;内容不上行只留痕,
|
|
317
|
+
// 「门为何突然过了」的豁免配置变更在时间线可见)
|
|
318
|
+
try {
|
|
319
|
+
const cfgStat = statSyncImpl(join(resolve(changeDir, '..', '..'), 'local.yaml'));
|
|
320
|
+
snap.localConfig = { mtimeMs: Math.round(cfgStat.mtimeMs) };
|
|
321
|
+
} catch { snap.localConfig = null; }
|
|
322
|
+
return snap;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 快照 diff → 事件(纯函数)。每事件恒带 provisional:true(护栏#5:平台 ingest 只展示
|
|
327
|
+
* 不判定)。事件面:文件首现/内容变更/checkbox 翻格/新提交/质量扫描记录/archived 终态。
|
|
328
|
+
*/
|
|
329
|
+
export function inferEvents(prev, next) {
|
|
330
|
+
if (!prev || !next) return [];
|
|
331
|
+
const events = [];
|
|
332
|
+
const mk = (kind, stage, detail) => ({ ts: next.ts, kind, stage: stage ?? null, detail: detail ?? null, provisional: true });
|
|
333
|
+
if (!prev.archived && next.archived) {
|
|
334
|
+
events.push(mk('archived', 'archive', 'change 目录已移入 archive'));
|
|
335
|
+
return events;
|
|
336
|
+
}
|
|
337
|
+
const keys = new Set([...Object.keys(prev.files || {}), ...Object.keys(next.files || {})]);
|
|
338
|
+
for (const key of keys) {
|
|
339
|
+
const p = prev.files[key];
|
|
340
|
+
const n = next.files[key];
|
|
341
|
+
if (!p && n) {
|
|
342
|
+
events.push(mk('file', n.stage, `${key} 出现`));
|
|
343
|
+
if (n.checked > 0) events.push(mk('task-done', n.stage, `checked 0→${n.checked}`));
|
|
344
|
+
} else if (p && n && p.hash !== n.hash) {
|
|
345
|
+
events.push(mk('file-update', n.stage, `${key} 内容变更`));
|
|
346
|
+
}
|
|
347
|
+
if (p && n && n.checked > p.checked) {
|
|
348
|
+
events.push(mk('task-done', n.stage ?? 'tasks', `checked ${p.checked}→${n.checked}`));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (prev.head && next.head && prev.head !== next.head) {
|
|
352
|
+
events.push(mk('commit', null, next.head));
|
|
353
|
+
}
|
|
354
|
+
if (next.scan && (!prev.scan || prev.scan.mtimeMs !== next.scan.mtimeMs || prev.scan.size !== next.scan.size)) {
|
|
355
|
+
events.push(mk('verify', 'verify', prev.scan ? '质量扫描记录更新' : '质量扫描记录出现'));
|
|
356
|
+
}
|
|
357
|
+
// 门实测结论事件(2026-09-28-watcher-signal-widen):新实测记录出现即事件(含结论与耗时
|
|
358
|
+
// ——停滞判定的活跃信号,ruleStall 经 baseEvents 非空自然计入 lastActivityAt)。
|
|
359
|
+
if (next.gateRun && (!prev.gateRun || prev.gateRun.dir !== next.gateRun.dir)) {
|
|
360
|
+
const dur = typeof next.gateRun.durationMs === 'number' ? `(${(next.gateRun.durationMs / 1000).toFixed(1)}s)` : '';
|
|
361
|
+
events.push(mk('gate-run', 'verify', `门实测 ${next.gateRun.status}${dur} · ${next.gateRun.dir}`));
|
|
362
|
+
}
|
|
363
|
+
// 本地配置变更事实事件(内容不上行——gitignored 文件只留 mtime 痕迹)
|
|
364
|
+
if (next.localConfig && (!prev.localConfig || prev.localConfig.mtimeMs !== next.localConfig.mtimeMs)) {
|
|
365
|
+
events.push(mk('config-change', null, prev.localConfig ? '本地配置 local.yaml 有变更(内容不上行)' : '本地配置 local.yaml 在场'));
|
|
366
|
+
}
|
|
367
|
+
return events;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 事件序列 → 阶段墙钟拆账(纯函数):每 stage 首末事件时间差,按首事件时序输出。
|
|
372
|
+
*/
|
|
373
|
+
export function aggregateStageTiming(events) {
|
|
374
|
+
const byStage = new Map();
|
|
375
|
+
for (const e of events) {
|
|
376
|
+
if (!e || typeof e.ts !== 'number' || !e.stage) continue;
|
|
377
|
+
let rec = byStage.get(e.stage);
|
|
378
|
+
if (!rec) {
|
|
379
|
+
rec = { stage: e.stage, firstTs: e.ts, lastTs: e.ts, events: 0 };
|
|
380
|
+
byStage.set(e.stage, rec);
|
|
381
|
+
}
|
|
382
|
+
rec.lastTs = Math.max(rec.lastTs, e.ts);
|
|
383
|
+
rec.firstTs = Math.min(rec.firstTs, e.ts);
|
|
384
|
+
rec.events += 1;
|
|
385
|
+
}
|
|
386
|
+
return [...byStage.values()]
|
|
387
|
+
.sort((a, b) => a.firstTs - b.firstTs)
|
|
388
|
+
.map((r) => ({ stage: r.stage, firstTs: r.firstTs, lastTs: r.lastTs, durationMs: r.lastTs - r.firstTs, events: r.events }));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
392
|
+
// 哨兵规则引擎(2026-09-23-sentinel-rules / L1:watcher 从只看升级为会喊)
|
|
393
|
+
//
|
|
394
|
+
// 四规则全部机械事实判定,输出恒 advisory:{kind:'warning', rule, severity:'warning',
|
|
395
|
+
// provisional:true},与基础事件同管线入 jsonl/推平台;**不写 progress db**(真相库只归
|
|
396
|
+
// 协议调用——单写者纪律)。规则逐条独立 try/catch(单规则异常 fail-open 跳过本轮,不杀
|
|
397
|
+
// 引擎不杀 watcher);warning 不计 lastActivityAt、不触发 archived 自退(不是活动)。
|
|
398
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
/** 停滞阈值(FR-05 分相位:brainstorm/plan 20min、execute 15min)。 */
|
|
401
|
+
export const STALL_EARLY_MS = 20 * 60_000;
|
|
402
|
+
export const STALL_EXECUTE_MS = 15 * 60_000;
|
|
403
|
+
|
|
404
|
+
/** 引擎状态初值(ts=计时锚;task-04 重启回补时取 max(启动时刻, 水位 ts))。 */
|
|
405
|
+
export function createSentinelState(ts = Date.now()) {
|
|
406
|
+
return { phase: 'early', lastActivityAt: typeof ts === 'number' ? ts : Date.now(), stallOpen: false, lastDriftFiles: [], testTamper: null };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function mkWarning(rule, detail, ts) {
|
|
410
|
+
return { ts, kind: 'warning', stage: null, rule, severity: 'warning', detail, provisional: true };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** 区间新提交差集(按 hash 判重,防 subject 改写误判;旧快照无 commits → 全部视为新)。 */
|
|
414
|
+
function newCommitsBetween(prev, next) {
|
|
415
|
+
const prevHashes = new Set((prev.commits || []).map((c) => c.hash));
|
|
416
|
+
return (next.commits || []).filter((c) => c && !prevHashes.has(c.hash));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── 水位回补(2026-09-23-sentinel-rules task-04 / FR-08 / D-005@v1)──
|
|
420
|
+
// 每轮把快照落 watcher-last-snapshot-<change>.json;重启读取为 prev diff 补发 backfill
|
|
421
|
+
// 事件(幂等锚=水位消费即前移);损坏/缺失 → null 全新启动(现行为零回归);覆盖写不
|
|
422
|
+
// append、内容去重不空转;archived 首拍早退路径不读不写。
|
|
423
|
+
|
|
424
|
+
export function watcherSnapshotPath(runtimeRoot, changeName) {
|
|
425
|
+
return join(runtimeRoot, `watcher-last-snapshot-${changeName}.json`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** 读水位(损坏/缺失/形状不对 → null)。 */
|
|
429
|
+
export function loadSnapshotWatermark(runtimeRoot, changeName) {
|
|
430
|
+
try {
|
|
431
|
+
const obj = JSON.parse(readFileSync(watcherSnapshotPath(runtimeRoot, changeName), 'utf8'));
|
|
432
|
+
return obj && typeof obj === 'object' && typeof obj.ts === 'number' && typeof obj.archived === 'boolean' ? obj : null;
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 写水位(best-effort,失败不抛)。cacheObj={last} 时内容去重(与上次序列化串相同跳过写,
|
|
440
|
+
* 防 3s 周期空转 IO——Grill 修正③)。
|
|
441
|
+
*/
|
|
442
|
+
export function writeSnapshotWatermark(runtimeRoot, changeName, snap, cacheObj = null, sentinel = null) {
|
|
443
|
+
try {
|
|
444
|
+
const json = JSON.stringify(sentinel ? { ...snap, sentinel } : snap);
|
|
445
|
+
if (cacheObj && cacheObj.last === json) return { written: false };
|
|
446
|
+
writeFileSync(watcherSnapshotPath(runtimeRoot, changeName), json);
|
|
447
|
+
if (cacheObj) cacheObj.last = json;
|
|
448
|
+
return { written: true };
|
|
449
|
+
} catch {
|
|
450
|
+
return { written: false };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** task-NN token 边界判据已随 R1 退役移交收口侧(sentinel-assertions.taskTokenRe 单源)。 */
|
|
455
|
+
|
|
456
|
+
// ── 事件流读取(2026-09-23 watcher-alerts quick:哨兵告警可视化出口的纯读面)──
|
|
457
|
+
// 子进程侧 appendFileSync 落盘名与此处读取名同锚(runWatcherFromEnv 内联字符串的唯一对应)。
|
|
458
|
+
|
|
459
|
+
export function watcherEventsPath(runtimeRoot, changeName) {
|
|
460
|
+
return join(runtimeRoot, `watcher-events-${changeName}.jsonl`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* 工件内容变更重推判定(2026-10-07-allticked-gate-docs-resync,纯函数):事件面出现
|
|
465
|
+
* tasks/design/requirements/proposal 四工件的内容变更(file 出现/file-update)→ true。
|
|
466
|
+
* 消费面:watcher 主循环触发 spec-sync 文档重推(防抖冷却)——修「平台停留 start 时点
|
|
467
|
+
* 初稿快照」(真实会话实证:agent 中途重写 tasks/design,spec-sync 只在协议调用时点推,
|
|
468
|
+
* 平台恒显初稿)。匹配口径与 inferEvents 的 detail 格式成对(`${key} 出现`/`${key} 内容变更`,
|
|
469
|
+
* key=工件文件名)。fail-open:解析不了的事件不触发重推。
|
|
470
|
+
*/
|
|
471
|
+
export function shouldResyncDocs(events) {
|
|
472
|
+
const DOC_KEYS = ['tasks.md', 'design.md', 'requirements.md', 'proposal.md'];
|
|
473
|
+
for (const e of events || []) {
|
|
474
|
+
if (!e || (e.kind !== 'file' && e.kind !== 'file-update')) continue;
|
|
475
|
+
const detail = String(e.detail || '');
|
|
476
|
+
if (DOC_KEYS.some((k) => detail.includes(k))) return true;
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** 工件重推防抖冷却(同 change 连续改写只触发一轮后台同步;bg-sync 自带单飞锁,双保险)。 */
|
|
482
|
+
const DOCS_RESYNC_COOLDOWN_MS = 10_000;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* 读事件流(纯读:解析+过滤,`watcher alerts`/`watcher timeline` 命令与测试消费;不写盘不推平台)。
|
|
486
|
+
* 坏行/残行/非对象行跳过并计数不抛(readKnowledgeHits 先例 R-04——并发 append 交错容忍)。
|
|
487
|
+
* path 直读形态(2026-09-28-archive-timeline-bake):传 path 时按该路径解析(同一坏行容忍
|
|
488
|
+
* 语义),供 `watcher timeline` 回退读归档包内 watcher-events.jsonl 烤制副本——path 优先于
|
|
489
|
+
* runtimeRoot/change 组合路径。
|
|
490
|
+
* @returns {{exists:boolean, events:Array, warnings:Array, badLines:number}}
|
|
491
|
+
* 文件/目录缺失 → {exists:false, 空集};warnings=告警子集(kind 或 severity 为
|
|
492
|
+
* 'warning' 即入——mkWarning 两者皆写,双字段容差防未来形态漂移)。
|
|
493
|
+
*/
|
|
494
|
+
export function readWatcherEvents({ runtimeRoot, change, path: eventsPath }) {
|
|
495
|
+
let text;
|
|
496
|
+
try {
|
|
497
|
+
text = readFileSync(eventsPath || watcherEventsPath(runtimeRoot, change), 'utf8');
|
|
498
|
+
} catch {
|
|
499
|
+
return { exists: false, events: [], warnings: [], badLines: 0 };
|
|
500
|
+
}
|
|
501
|
+
const events = [];
|
|
502
|
+
let badLines = 0;
|
|
503
|
+
for (const line of text.split('\n')) {
|
|
504
|
+
const trimmed = line.trim();
|
|
505
|
+
if (!trimmed) continue;
|
|
506
|
+
let rec;
|
|
507
|
+
try {
|
|
508
|
+
rec = JSON.parse(trimmed);
|
|
509
|
+
} catch {
|
|
510
|
+
badLines += 1;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (!rec || typeof rec !== 'object' || Array.isArray(rec)) {
|
|
514
|
+
badLines += 1;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
events.push(rec);
|
|
518
|
+
}
|
|
519
|
+
const warnings = events.filter((e) => e.kind === 'warning' || e.severity === 'warning');
|
|
520
|
+
return { exists: true, events, warnings, badLines };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const testDirtyOf = (dirtyCode) => (Array.isArray(dirtyCode) ? dirtyCode.filter((p) => p.startsWith('test/')) : []);
|
|
524
|
+
const normPath = (p) => String(p).replace(/\\/g, '/');
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* R2 改测试凑绿(FR-03;2026-09-24 定向化,fr-test-binding §1.1 受益注记):
|
|
528
|
+
* 账本 FAIL → 窗口内**新增**测试改动 → 账本 PASS → warning。
|
|
529
|
+
* 定向升级:窗口被改测试文件经绑定面(resolveTestFileOwners——FR 条目行+ql 面,
|
|
530
|
+
* active·非 superseded)解析归属;归属成立且该锚来源变更的 requirements.md 未在
|
|
531
|
+
* 窗口内改动 → 定向 warning test-tamper-bound(绑定测试被改 ∩ 场景正文未改 ∩
|
|
532
|
+
* FAIL→PASS,点名锚——绑定后 R2 从窗口级粗粒度升级为 FR 级定向,误报大降);
|
|
533
|
+
* 无归属或被规格变更抑制的文件保留窗口级 generic warning——**永不比旧版更安静**。
|
|
534
|
+
* 恒 warning 档:测试纠错是无规格变更的合法场景,三联证据收窄误报不消除。
|
|
535
|
+
* FAIL 锚按账本 statKey(mtime:size)判「新记录」——同一 FAIL 记录跨拍持续只累计
|
|
536
|
+
* 证据不重锚;testDirtyAtFail 快照区分窗口前遗留脏面(Grill 修正①)。skipped 不参与。
|
|
537
|
+
*/
|
|
538
|
+
function ruleTestTamper(prev, next, state, ts, resolveOwners = null) {
|
|
539
|
+
const status = next.scanStatus ? next.scanStatus.status : null;
|
|
540
|
+
const commitHashes = new Set((next.commits || []).map((c) => c.hash));
|
|
541
|
+
if (status === 'failed') {
|
|
542
|
+
const statKey = next.scan ? `${next.scan.mtimeMs}:${next.scan.size}` : String(ts);
|
|
543
|
+
if (!state.testTamper || state.testTamper.statKey !== statKey) {
|
|
544
|
+
state.testTamper = { statKey, failAt: ts, testDirtyAtFail: new Set(testDirtyOf(next.dirtyCode)), testChanged: false, testChangedFiles: new Set(), anchoredCommits: commitHashes };
|
|
545
|
+
} else {
|
|
546
|
+
const anchor = state.testTamper;
|
|
547
|
+
const dirtyNow = testDirtyOf(next.dirtyCode);
|
|
548
|
+
if (dirtyNow.some((p) => !anchor.testDirtyAtFail.has(p))) { anchor.testChanged = true; for (const p of dirtyNow) if (!anchor.testDirtyAtFail.has(p)) anchor.testChangedFiles.add(normPath(p)); }
|
|
549
|
+
for (const c of next.commits || []) {
|
|
550
|
+
if (anchor.anchoredCommits.has(c.hash)) continue;
|
|
551
|
+
for (const f of c.files || []) if (String(f).startsWith('test/')) { anchor.testChanged = true; anchor.testChangedFiles.add(normPath(f)); }
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
if (state.testTamper && status === 'passed') {
|
|
557
|
+
const anchor = state.testTamper;
|
|
558
|
+
state.testTamper = null;
|
|
559
|
+
const changed = Array.isArray(anchor.testChangedFiles) || anchor.testChangedFiles instanceof Set
|
|
560
|
+
? [...(anchor.testChangedFiles || new Set())].map(normPath)
|
|
561
|
+
: []; // 旧态锚(仅 testChanged 布尔,无清单)→ 无法定向,走 generic
|
|
562
|
+
if (!anchor.testChanged && changed.length === 0) return [];
|
|
563
|
+
const generic = (note) => [mkWarning('test-tamper', `测试账本 FAIL→PASS 且窗口内测试文件被改——改测试凑绿嫌疑(${note})(测试本身写错是合法场景,人判不阻断)`, ts)];
|
|
564
|
+
if (!resolveOwners || changed.length === 0) return generic('窗口级粗粒度');
|
|
565
|
+
let owners = null;
|
|
566
|
+
try { owners = resolveOwners(changed) } catch { owners = null }
|
|
567
|
+
if (!owners || owners.size === 0) return generic(`${changed.length} 个改动测试文件无绑定锚`);
|
|
568
|
+
// 窗口全文件面(脏面 ∪ 已提交文件)——来源变更 requirements.md 同窗改动则「场景正文未改」不成立,定向抑制
|
|
569
|
+
const windowFiles = new Set([
|
|
570
|
+
...((next.dirtyCode || []).map(normPath)),
|
|
571
|
+
...((next.commits || []).flatMap((c) => c.files || []).map(normPath)),
|
|
572
|
+
]);
|
|
573
|
+
const warnings = [];
|
|
574
|
+
const genericFiles = new Set();
|
|
575
|
+
for (const f of changed) {
|
|
576
|
+
const owns = owners.get(normPath(f)) || owners.get(f);
|
|
577
|
+
if (!owns || owns.length === 0) { genericFiles.add(f); continue }
|
|
578
|
+
const live = owns.filter((o) => o.source_change && ![...windowFiles].some((w) => w.includes(`/${o.source_change}/requirements.md`)))
|
|
579
|
+
if (live.length === 0) { genericFiles.add(f); continue }
|
|
580
|
+
warnings.push(mkWarning('test-tamper-bound', `${[...new Set(live.map((o) => o.anchor))].join('、')} 绑定测试被改(${f})∩ 场景正文未改 ∩ 账本 FAIL→PASS——定向改测试凑绿嫌疑(测试纠错仍合法,人判不阻断)`, ts))
|
|
581
|
+
}
|
|
582
|
+
if (genericFiles.size > 0) {
|
|
583
|
+
warnings.push(mkWarning('test-tamper', `测试账本 FAIL→PASS 且窗口内测试文件被改(${[...genericFiles].join('、')}——无绑定锚或场景正文同窗变更,定向不成立,窗口级嫌疑保留)(测试本身写错是合法场景,人判不阻断)`, ts))
|
|
584
|
+
}
|
|
585
|
+
return warnings;
|
|
586
|
+
}
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** 任务卡 frontmatter allowed_paths 列表项解析(反引号/尾注剥离走 normalizePath)。 */
|
|
591
|
+
function parseCardAllowedPaths(text) {
|
|
592
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
593
|
+
let fmEnd = -1;
|
|
594
|
+
let inFm = false;
|
|
595
|
+
for (let i = 0; i < lines.length; i++) {
|
|
596
|
+
if (lines[i].trim() === '---') {
|
|
597
|
+
if (!inFm) inFm = true;
|
|
598
|
+
else { fmEnd = i; break; }
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (fmEnd < 0) return [];
|
|
602
|
+
const out = [];
|
|
603
|
+
let inList = false;
|
|
604
|
+
for (let j = 0; j <= fmEnd; j++) {
|
|
605
|
+
if (/^allowed_paths:\s*$/.test(lines[j])) { inList = true; continue; }
|
|
606
|
+
if (!inList) continue;
|
|
607
|
+
const m = lines[j].match(/^\s+-\s+(.+?)\s*$/);
|
|
608
|
+
if (m) out.push(normalizePath(m[1]));
|
|
609
|
+
else if (/^\S/.test(lines[j])) inList = false;
|
|
610
|
+
}
|
|
611
|
+
return out.filter(Boolean);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** design.md「文件变更清单」章节条目(表格路径列+列表行;NEW: 前缀剥离后 normalizePath)。 */
|
|
615
|
+
export function parseDesignListText(text) {
|
|
616
|
+
const src = String(text || '');
|
|
617
|
+
const m = /##\s*文件变更清单/.exec(src);
|
|
618
|
+
if (!m) return [];
|
|
619
|
+
const rest = src.slice(m.index);
|
|
620
|
+
const endRe = /\n##\s/;
|
|
621
|
+
const endM = endRe.exec(rest.slice(1));
|
|
622
|
+
const section = endM ? rest.slice(0, endM.index + 1) : rest;
|
|
623
|
+
const raw = [];
|
|
624
|
+
for (const line of section.split(/\r?\n/)) {
|
|
625
|
+
const t = line.trim();
|
|
626
|
+
if (t.startsWith('|')) {
|
|
627
|
+
if (/^[-:\s|]+$/.test(t)) continue;
|
|
628
|
+
const cells = t.split('|').map((c) => c.trim()).filter(Boolean);
|
|
629
|
+
const pathCell = cells.find((c) => c.startsWith('NEW:')) || cells.find((c) => (/[/]/.test(c) && /\.[A-Za-z0-9]+/.test(c)));
|
|
630
|
+
if (pathCell) raw.push(pathCell);
|
|
631
|
+
} else if (t.startsWith('- ')) {
|
|
632
|
+
raw.push(t.slice(2));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return raw
|
|
636
|
+
.map((s) => normalizePath(String(s).replace(/^NEW:/, '')))
|
|
637
|
+
.filter((s) => s && s !== '.' && (s.includes('/') || /\.[A-Za-z0-9]+$/.test(s)));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* 声明面 = 任务卡 allowed_paths ∪ design.md 文件清单(FR-04 / D-004@v1)。两源皆空 →
|
|
642
|
+
* null(规则跳过,fail-open——无面可判不无事生非)。盘面读取注入化(测试零真 fs)。
|
|
643
|
+
*/
|
|
644
|
+
function loadDeclaredScope(changeDir, readImpl, readdirImpl) {
|
|
645
|
+
if (!changeDir) return null;
|
|
646
|
+
const scope = new Set();
|
|
647
|
+
try {
|
|
648
|
+
const tasksDir = join(changeDir, 'tasks');
|
|
649
|
+
for (const ent of readdirImpl(tasksDir)) {
|
|
650
|
+
if (!/^task-.*\.md$/.test(ent)) continue;
|
|
651
|
+
for (const p of parseCardAllowedPaths(readImpl(join(tasksDir, ent), 'utf8'))) scope.add(p);
|
|
652
|
+
}
|
|
653
|
+
} catch { /* tasks/ 不存在或读失败 → 该源贡献为空 */ }
|
|
654
|
+
try {
|
|
655
|
+
for (const p of parseDesignListText(readImpl(join(changeDir, 'design.md'), 'utf8'))) scope.add(p);
|
|
656
|
+
} catch { /* design.md 缺失 → 该源贡献为空 */ }
|
|
657
|
+
return scope.size > 0 ? [...scope] : null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* R3 范围漂移(FR-04):dirtyCode 中声明面外文件(精确相等或 globMatch 容差,复用
|
|
662
|
+
* change-list 匹配器);只对新漂移文件告警(lastDriftFiles 单调去重,活动恢复不改集)。
|
|
663
|
+
* 基线豁免(2026-09-23 R8 对撞实证 MP 假归因):观测起点已在脏面的文件 = 并行会话先在途
|
|
664
|
+
* 工作(quick 边界记录 / baseline checkpoint 同款语义),不归因本变更——首拍基线之外的
|
|
665
|
+
* 新出现声明面外文件才算漂移。
|
|
666
|
+
*/
|
|
667
|
+
function ruleScopeDrift(next, state, declaredScope, ts) {
|
|
668
|
+
if (!declaredScope || !Array.isArray(next.dirtyCode) || next.dirtyCode.length === 0) return [];
|
|
669
|
+
const baseline = Array.isArray(state.baselineDirty) ? state.baselineDirty : [];
|
|
670
|
+
const unmatched = next.dirtyCode.filter((p) =>
|
|
671
|
+
!baseline.includes(p) && !declaredScope.some((pat) => pat === p || globMatch(p, pat)));
|
|
672
|
+
const fresh = unmatched.filter((p) => !state.lastDriftFiles.includes(p));
|
|
673
|
+
state.lastDriftFiles = unmatched;
|
|
674
|
+
if (fresh.length === 0) return [];
|
|
675
|
+
return [mkWarning('scope-drift', `声明面之外的代码文件被改:${fresh.join('、')}——范围漂移嫌疑(并行会话改动/越界,人判)`, ts)];
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* R4 停滞(FR-05 / D-003@v1):相位 early→execute 单向锁存(task-done 事件,或 tasks.md
|
|
680
|
+
* 在场后出现新提交);阈值 20min/15min;episode 去重(告警一次,活动恢复才允许下一发)。
|
|
681
|
+
*/
|
|
682
|
+
function ruleStall(prev, next, baseEvents, state, ts) {
|
|
683
|
+
const hasTasksMd = Object.prototype.hasOwnProperty.call(next.files || {}, 'tasks.md');
|
|
684
|
+
if (baseEvents.some((e) => e && e.kind === 'task-done') || (hasTasksMd && newCommitsBetween(prev, next).length > 0)) {
|
|
685
|
+
state.phase = 'execute';
|
|
686
|
+
}
|
|
687
|
+
if (baseEvents.length > 0) {
|
|
688
|
+
state.lastActivityAt = ts;
|
|
689
|
+
state.stallOpen = false;
|
|
690
|
+
}
|
|
691
|
+
const threshold = state.phase === 'execute' ? STALL_EXECUTE_MS : STALL_EARLY_MS;
|
|
692
|
+
if (state.stallOpen || ts - state.lastActivityAt <= threshold) return [];
|
|
693
|
+
state.stallOpen = true;
|
|
694
|
+
const idleMin = Math.round((ts - state.lastActivityAt) / 60000);
|
|
695
|
+
const phaseLabel = state.phase === 'execute' ? 'execute 期' : 'brainstorm/plan 期';
|
|
696
|
+
return [mkWarning('stall', `${phaseLabel}已 ${idleMin} 分钟无${state.phase === 'execute' ? '提交无' : ''}事件——停滞嫌疑(区分在想/死了,人判)`, ts)];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* 哨兵规则引擎主入口(纯函数;prev 缺新字段按空集/null 容错 fail-open)。
|
|
701
|
+
* @returns {{warnings:Array, state:Object}} warnings 恒 provisional:true;archived 拍不判。
|
|
702
|
+
*/
|
|
703
|
+
export function applySentinelRules({ prev, next, baseEvents = [], state = null, now = Date.now(), changeDir = null, readImpl = readFileSync, readdirImpl = readdirSync, env = process.env, bindResolveImpl = null } = {}) {
|
|
704
|
+
const st = state || createSentinelState(next && next.ts);
|
|
705
|
+
const warnings = [];
|
|
706
|
+
if (!prev || !next || next.archived) return { warnings, state: st };
|
|
707
|
+
// 哨兵总阀(2026-09-23 用户降噪诉求):SILLYSPEC_SENTINEL=0 → 规则面零告警,watcher
|
|
708
|
+
// 照常记录中性事件(观测不中断)——比 SILLYSPEC_WATCHER=0 整体不拉起细一档;排障/
|
|
709
|
+
// 嫌吵时环境级一关即静。
|
|
710
|
+
if (env.SILLYSPEC_SENTINEL === '0') return { warnings, state: st };
|
|
711
|
+
// R3 基线脏面首拍(见 ruleScopeDrift 注释):首判轮以 prev 脏面为基线——水位重启场景
|
|
712
|
+
// 首判轮 prev 即水位快照,语义自洽(重启前已脏 = 先在途)。
|
|
713
|
+
if (!Array.isArray(st.baselineDirty)) {
|
|
714
|
+
st.baselineDirty = prev && Array.isArray(prev.dirtyCode) ? [...prev.dirtyCode] : [];
|
|
715
|
+
}
|
|
716
|
+
// 逐规则独立 fail-open:单规则抛异常只丢本轮该规则,其余照跑(引擎绝不杀 watcher)
|
|
717
|
+
try { warnings.push(...ruleTestTamper(prev, next, st, now, bindResolveImpl)); } catch { /* R2 本轮跳过 */ }
|
|
718
|
+
let declaredScope = null;
|
|
719
|
+
try { declaredScope = loadDeclaredScope(changeDir, readImpl, readdirImpl); } catch { declaredScope = null; }
|
|
720
|
+
try { warnings.push(...ruleScopeDrift(next, st, declaredScope, now)); } catch { /* R3 本轮跳过 */ }
|
|
721
|
+
try { warnings.push(...ruleStall(prev, next, baseEvents, st, now)); } catch { /* R4 本轮跳过 */ }
|
|
722
|
+
return { warnings, state: st };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** watcher 平台推送凭据(模式同 agent-session-log readPlatformPushConfig:env > local.yaml platform 段)。 */
|
|
726
|
+
function readWatcherPushConfig(specBase, env = process.env) {
|
|
727
|
+
if (env.SILLYHUB_PLATFORM_URL && env.SILLYHUB_PLATFORM_TOKEN) {
|
|
728
|
+
return { url: env.SILLYHUB_PLATFORM_URL, token: env.SILLYHUB_PLATFORM_TOKEN };
|
|
729
|
+
}
|
|
730
|
+
try {
|
|
731
|
+
const yamlPath = join(specBase, 'local.yaml');
|
|
732
|
+
if (!existsSync(yamlPath)) return null;
|
|
733
|
+
const lines = readFileSync(yamlPath, 'utf8').split(/\r?\n/);
|
|
734
|
+
let inPlatform = false;
|
|
735
|
+
const cfg = {};
|
|
736
|
+
for (const line of lines) {
|
|
737
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
738
|
+
if (!/^\s/.test(line)) inPlatform = line.startsWith('platform:');
|
|
739
|
+
else if (inPlatform) {
|
|
740
|
+
const m = line.match(/^\s+(url|token)\s*:\s*(.*)$/);
|
|
741
|
+
if (m) {
|
|
742
|
+
let v = m[2].trim();
|
|
743
|
+
if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) v = v.slice(1, -1);
|
|
744
|
+
if (v) cfg[m[1]] = v;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return cfg.url && cfg.token ? cfg : null;
|
|
749
|
+
} catch {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* watcher 事件 → 平台 ChangeEventPushRequest 单条契约映射(纯函数,导出供测试)。
|
|
756
|
+
* 2026-09-27-watcher-push-endpoint:旧 ObservationEventIn 批量契约随废弃分支
|
|
757
|
+
* (observation 模块不在平台 HEAD 线)消亡——对齐现端点 POST /api/changes/{name}/events
|
|
758
|
+
* 的单事件形态(kind/rule/severity/provisional/detail/ts/id)。id 铸稳定内容键
|
|
759
|
+
* ``ts|kind|stage|note``(>512 取 sha256,评审 P1:同拍多事件共享回退键 ts+rule 会被
|
|
760
|
+
* 平台去重静默吞掉除首条外全部——file 出现与 task-done 常同拍;内容键使同拍不同
|
|
761
|
+
* 事件各得其所、重推同键幂等吸收)。stage 无独立列——以「stage · note」前缀并入
|
|
762
|
+
* detail 文本(timeline 任务面推断与人类可读双用)。
|
|
763
|
+
*/
|
|
764
|
+
export function toPlatformChangeEvents(events) {
|
|
765
|
+
const out = [];
|
|
766
|
+
for (const rec of events || []) {
|
|
767
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
768
|
+
const numTs = typeof rec.ts === 'number' ? rec.ts : Number(rec.ts);
|
|
769
|
+
const iso = Number.isFinite(numTs)
|
|
770
|
+
? new Date(numTs).toISOString()
|
|
771
|
+
: typeof rec.ts === 'string' && !Number.isNaN(Date.parse(rec.ts))
|
|
772
|
+
? new Date(rec.ts).toISOString()
|
|
773
|
+
: new Date().toISOString();
|
|
774
|
+
const stageText = rec.stage ? String(rec.stage) : '';
|
|
775
|
+
const noteText = rec.detail != null ? String(rec.detail) : '';
|
|
776
|
+
const detail = noteText
|
|
777
|
+
? (stageText ? `${stageText} · ${noteText}` : noteText)
|
|
778
|
+
: (stageText || null);
|
|
779
|
+
const idSrc = `${iso}|${String(rec.kind || 'unknown')}|${stageText}|${noteText}`;
|
|
780
|
+
out.push({
|
|
781
|
+
id: idSrc.length > 512 ? createHash('sha256').update(idSrc).digest('hex') : idSrc,
|
|
782
|
+
kind: String(rec.kind || 'unknown').slice(0, 64),
|
|
783
|
+
rule: rec.rule ? String(rec.rule).slice(0, 128) : 'watcher',
|
|
784
|
+
severity: rec.severity ? String(rec.severity).slice(0, 16) : 'info',
|
|
785
|
+
provisional: true,
|
|
786
|
+
detail,
|
|
787
|
+
ts: iso,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
return out;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* 事件推平台(best-effort,唯一非本地副作用):单事件上行端点
|
|
795
|
+
* POST /api/changes/{name}/events(2026-09-26-change-events-r18-full 契约,
|
|
796
|
+
* 逐条 POST、恒 200 语义),每条 5s 熔断,任何失败只 warn——本地 jsonl 已是
|
|
797
|
+
* 事件唯一真相源,平台是展示面。失败即弃剩余(与旧语义一致,无重推水位);
|
|
798
|
+
* 平台侧去重键幂等(无 id 回退 ts+'|'+rule),后续若加重推也安全
|
|
799
|
+
* (at-least-once 上行)。
|
|
800
|
+
*/
|
|
801
|
+
export async function pushEventsToPlatform({ specBase, changeName, events, env = process.env, fetchImpl = fetch }) {
|
|
802
|
+
if (env.SILLYSPEC_WATCHER_PUSH === '0' || !events || events.length === 0) return { pushed: null };
|
|
803
|
+
const cfg = readWatcherPushConfig(specBase, env);
|
|
804
|
+
if (!cfg) return { pushed: null, reason: 'no-config' };
|
|
805
|
+
const base = cfg.url.replace(/\/+$/, '');
|
|
806
|
+
const url = `${base}/api/changes/${encodeURIComponent(changeName)}/events`;
|
|
807
|
+
let pushed = 0;
|
|
808
|
+
for (const ev of toPlatformChangeEvents(events)) {
|
|
809
|
+
const controller = new AbortController();
|
|
810
|
+
const timer = setTimeout(() => controller.abort(), PUSH_TIMEOUT_MS);
|
|
811
|
+
try {
|
|
812
|
+
const res = await fetchImpl(url, {
|
|
813
|
+
method: 'POST',
|
|
814
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.token}` },
|
|
815
|
+
body: JSON.stringify(ev),
|
|
816
|
+
signal: controller.signal,
|
|
817
|
+
});
|
|
818
|
+
if (!res.ok) {
|
|
819
|
+
console.warn(`[watcher] 事件推送 → HTTP ${res.status}(本地 jsonl 已兜底)`);
|
|
820
|
+
return { pushed: pushed > 0, reason: `http-${res.status}` };
|
|
821
|
+
}
|
|
822
|
+
pushed += 1;
|
|
823
|
+
} catch (err) {
|
|
824
|
+
const msg = err && err.name === 'AbortError' ? '超时' : (err && err.message ? err.message : err);
|
|
825
|
+
console.warn(`[watcher] 事件推送失败: ${msg}(本地 jsonl 已兜底)`);
|
|
826
|
+
return { pushed: pushed > 0, reason: 'network' };
|
|
827
|
+
} finally {
|
|
828
|
+
clearTimeout(timer);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return { pushed: true, count: pushed };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** 子进程起步截尾日志(>1MB 保后 512KB,整行边界起)。失败只影响日志体积。 */
|
|
835
|
+
function truncateLogIfNeeded(runtimeRoot) {
|
|
836
|
+
try {
|
|
837
|
+
const p = join(runtimeRoot, WATCHER_LOG_FILENAME);
|
|
838
|
+
if (statSync(p).size <= LOG_TRUNCATE_THRESHOLD) return;
|
|
839
|
+
const buf = readFileSync(p);
|
|
840
|
+
const keep = buf.subarray(Math.max(0, buf.length - LOG_TRUNCATE_KEEP));
|
|
841
|
+
const nl = keep.indexOf(10);
|
|
842
|
+
writeFileSync(p, nl >= 0 ? keep.subarray(nl + 1) : keep);
|
|
843
|
+
} catch { /* 截断失败:继续 append */ }
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** 子进程 bootstrap:node -e 动态 import 本模块(file URL 精确定位,不依赖 CLI 安装形态)。 */
|
|
847
|
+
function buildChildBootstrap() {
|
|
848
|
+
const selfUrl = new URL('./watcher.js', import.meta.url).href;
|
|
849
|
+
return `import(${JSON.stringify(selfUrl)}).then(m=>m.runWatcherFromEnv()).catch(e=>{console.error('[watcher] 监听进程异常退出:',(e&&e.message)||e)});`;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* 父侧入口(run/command.js 在 effectiveChange 解析后无条件调用,锁合并)。
|
|
854
|
+
* 未连接平台也 spawn(本地 jsonl 是主目标);SILLYSPEC_WATCHER=0 逃生阀。
|
|
855
|
+
* spawn 失败抛异常由调用方 catch 降级(绝不因观测旁路阻断主流程)。
|
|
856
|
+
* @returns {Promise<{status:'spawned'|'coalesced'|'disabled', logPath?:string}>}
|
|
857
|
+
*/
|
|
858
|
+
export async function spawnWatcher(cwd, changeName, opts = {}) {
|
|
859
|
+
const env = opts.env || process.env;
|
|
860
|
+
// 逃生阀/测试护栏:SILLYSPEC_WATCHER=0 显式关;NODE_TEST_CONTEXT(node:test 运行器注入)
|
|
861
|
+
// 下不拉起——防全量套件每条 run 用例孵化真 detached watcher 污染测试环境。
|
|
862
|
+
if (env.SILLYSPEC_WATCHER === '0' || env.NODE_TEST_CONTEXT) return { status: 'disabled' };
|
|
863
|
+
const { resolveRuntimeRoot } = await import('./run/shared.js');
|
|
864
|
+
const specBase = opts.specBase || join(cwd, '.sillyspec');
|
|
865
|
+
const runtimeRoot = resolveRuntimeRoot(opts, specBase);
|
|
866
|
+
|
|
867
|
+
const lock = readWatcherLock(runtimeRoot);
|
|
868
|
+
if (isWatcherLeaseLive(lock)) return { status: 'coalesced', logPath: join(runtimeRoot, WATCHER_LOG_FILENAME) };
|
|
869
|
+
|
|
870
|
+
const logPath = join(runtimeRoot, WATCHER_LOG_FILENAME);
|
|
871
|
+
let stdio = ['ignore', 'ignore', 'ignore'];
|
|
872
|
+
let logFd = null;
|
|
873
|
+
try {
|
|
874
|
+
mkdirSync(runtimeRoot, { recursive: true });
|
|
875
|
+
logFd = openSync(logPath, 'a');
|
|
876
|
+
stdio = ['ignore', logFd, logFd];
|
|
877
|
+
} catch {
|
|
878
|
+
logFd = null;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const childEnv = {
|
|
882
|
+
...env,
|
|
883
|
+
SILLYSPEC_WATCHER_CHANGE: String(changeName),
|
|
884
|
+
SILLYSPEC_WATCHER_CWD: String(cwd),
|
|
885
|
+
};
|
|
886
|
+
if (opts.specBase) childEnv.SILLYSPEC_WATCHER_SPEC_BASE = String(opts.specBase);
|
|
887
|
+
if (opts.runtimeRoot) childEnv.SILLYSPEC_WATCHER_RUNTIME_ROOT = String(opts.runtimeRoot);
|
|
888
|
+
|
|
889
|
+
try {
|
|
890
|
+
const child = (opts.spawnImpl || spawn)(process.execPath, ['-e', buildChildBootstrap()], {
|
|
891
|
+
cwd,
|
|
892
|
+
detached: true,
|
|
893
|
+
stdio,
|
|
894
|
+
windowsHide: true,
|
|
895
|
+
env: childEnv,
|
|
896
|
+
});
|
|
897
|
+
child.unref?.();
|
|
898
|
+
} finally {
|
|
899
|
+
if (logFd !== null) {
|
|
900
|
+
try { closeSync(logFd); } catch { /* 父侧 fd 副本关闭;子进程持有继承副本 */ }
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return { status: 'spawned', logPath };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* 子侧入口(bootstrap 经 env 传参调起)。循环:心跳租约(30s 刷新,锁被他人接管即让位)
|
|
908
|
+
* → 每 3s 快照 diff → 事件落 jsonl + best-effort 推平台 + 墙钟拆账重写。
|
|
909
|
+
* 终态自退:archived 事件 / 空闲 6h / 锁失守。退出前清本进程锁。
|
|
910
|
+
*/
|
|
911
|
+
export async function runWatcherFromEnv(env = process.env, opts = {}) {
|
|
912
|
+
const changeName = env.SILLYSPEC_WATCHER_CHANGE;
|
|
913
|
+
const cwd = env.SILLYSPEC_WATCHER_CWD;
|
|
914
|
+
if (!changeName || !cwd) return;
|
|
915
|
+
|
|
916
|
+
const specBase = env.SILLYSPEC_WATCHER_SPEC_BASE || join(cwd, '.sillyspec');
|
|
917
|
+
const { resolveRuntimeRoot } = await import('./run/shared.js');
|
|
918
|
+
const runtimeRoot = env.SILLYSPEC_WATCHER_RUNTIME_ROOT
|
|
919
|
+
? env.SILLYSPEC_WATCHER_RUNTIME_ROOT
|
|
920
|
+
: resolveRuntimeRoot({}, specBase);
|
|
921
|
+
const changeDir = join(specBase, 'changes', changeName);
|
|
922
|
+
|
|
923
|
+
truncateLogIfNeeded(runtimeRoot);
|
|
924
|
+
const startedAt = Date.now();
|
|
925
|
+
writeWatcherLock(runtimeRoot, { pid: process.pid, change: changeName, startedAt, heartbeatAt: startedAt });
|
|
926
|
+
console.log(`[watcher] 监听开始 ${new Date().toISOString()}: ${changeName}`);
|
|
927
|
+
|
|
928
|
+
const eventsPath = join(runtimeRoot, `watcher-events-${changeName}.jsonl`);
|
|
929
|
+
const timingPath = join(runtimeRoot, `watcher-stage-timing-${changeName}.json`);
|
|
930
|
+
let allEvents = [];
|
|
931
|
+
|
|
932
|
+
const refreshHeartbeat = () => {
|
|
933
|
+
const cur = readWatcherLock(runtimeRoot);
|
|
934
|
+
if (!cur || cur.pid !== process.pid) {
|
|
935
|
+
console.warn('[watcher] 锁已不属于本进程(被并行 spawn 接管),让位退出');
|
|
936
|
+
process.exitCode = 0;
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
writeWatcherLock(runtimeRoot, { ...cur, heartbeatAt: Date.now() });
|
|
940
|
+
return true;
|
|
941
|
+
};
|
|
942
|
+
const heartbeatTimer = setInterval(() => { refreshHeartbeat(); }, HEARTBEAT_INTERVAL_MS);
|
|
943
|
+
heartbeatTimer.unref?.();
|
|
944
|
+
|
|
945
|
+
// 水位回补(task-04):重启时以水位快照为 prev——首拍 diff 即停机期间变更的补发事件
|
|
946
|
+
//(置 backfill:true,事件时间=观测时间);水位缺失/损坏 → 全新启动(现行为)。
|
|
947
|
+
const watermark = loadSnapshotWatermark(runtimeRoot, changeName);
|
|
948
|
+
const useWatermark = Boolean(watermark) && !watermark.archived;
|
|
949
|
+
let backfillPending = useWatermark;
|
|
950
|
+
let prev = useWatermark ? watermark : buildSnapshot({ changeDir, cwd, runtimeRoot, changeName });
|
|
951
|
+
// ── 孤儿自愈三闸(2026-09-22 全套件实证漏 41 个孤儿 watcher——每 3s 轮询 git 的孤儿进程)──
|
|
952
|
+
// ①出生即死:首拍已 archived(change 目录不存在/已归档)没有可观测对象,立即退出;
|
|
953
|
+
// ②仓库蒸发:head 曾非空后连续 20 拍为 null(cwd/git 仓被删——测试临时目录清理)→ 退出;
|
|
954
|
+
// ③硬寿命帽:无论活跃与否,超过 MAX_LIFETIME_MS 绝对退出(孤儿绝对上限兜底)。
|
|
955
|
+
if (prev.archived) {
|
|
956
|
+
console.log('[watcher] 首拍即 archived(无可观测对象),不启动监听');
|
|
957
|
+
clearInterval(heartbeatTimer);
|
|
958
|
+
removeWatcherLock(runtimeRoot);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (useWatermark) console.log(`[watcher] 水位回补:自 ${new Date(watermark.ts).toISOString()} 的快照续观测`);
|
|
962
|
+
let headNullStreak = 0;
|
|
963
|
+
let everHadHead = prev.head != null;
|
|
964
|
+
const hardDeadline = startedAt + MAX_LIFETIME_MS;
|
|
965
|
+
let lastActivityAt = Date.now();
|
|
966
|
+
// 工件重推防抖锚(2026-10-07-allticked-gate-docs-resync)
|
|
967
|
+
let lastDocsResyncAt = 0;
|
|
968
|
+
// 哨兵引擎状态(计时锚=启动时刻;水位回补取 max(启动时刻, 水位 ts)——水位落后即 change
|
|
969
|
+
// 早已停滞,首拍就应告警而非再等满阈值,Grill 修正②)
|
|
970
|
+
let sentinelState = createSentinelState(Math.max(Date.now(), useWatermark ? watermark.ts : 0));
|
|
971
|
+
const watermarkCache = { last: null };
|
|
972
|
+
// eslint-disable-next-line no-constant-condition
|
|
973
|
+
while (true) {
|
|
974
|
+
await new Promise((r) => setTimeout(r, opts.pollIntervalMs ?? POLL_INTERVAL_MS));
|
|
975
|
+
if (!refreshHeartbeat()) break;
|
|
976
|
+
if (Date.now() > hardDeadline) {
|
|
977
|
+
console.log('[watcher] 硬寿命帽自退(绝对上限兜底,防永续孤儿)');
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
let snap;
|
|
982
|
+
try {
|
|
983
|
+
snap = buildSnapshot({ changeDir, cwd, runtimeRoot, changeName });
|
|
984
|
+
} catch (e) {
|
|
985
|
+
console.warn(`[watcher] 快照构建异常(本轮跳过): ${(e && e.message) || e}`);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
// 仓库蒸发闸:git 头从有到无且持续(临时仓被删)——无对象可观测,退
|
|
989
|
+
if (everHadHead) {
|
|
990
|
+
headNullStreak = snap.head == null ? headNullStreak + 1 : 0;
|
|
991
|
+
if (headNullStreak >= 20) {
|
|
992
|
+
console.log('[watcher] git 仓蒸发(头指针连续空拍),判定无可观测对象,自退');
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
const events = inferEvents(prev, snap);
|
|
997
|
+
// 回补标记:水位续观测的首拍 diff 事件即停机期间变更的补发(消费即复位,幂等)
|
|
998
|
+
if (backfillPending) {
|
|
999
|
+
for (const e of events) e.backfill = true;
|
|
1000
|
+
backfillPending = false;
|
|
1001
|
+
}
|
|
1002
|
+
// ── 哨兵规则引擎(2026-09-23-sentinel-rules task-02):warning 与基础事件同管线 ──
|
|
1003
|
+
// best-effort 双层包裹(引擎内逐规则 fail-open + 此处兜底)——引擎异常绝不杀 watcher。
|
|
1004
|
+
// warning 不计 lastActivityAt(不是活动)、不触发 archived 自退(终态只认基础事件)。
|
|
1005
|
+
let warnings = [];
|
|
1006
|
+
try {
|
|
1007
|
+
// R2 定向解析器(fr-test-binding §1.1):changeDir 推 specBase,绑定面(FR 条目+ql 机器面)
|
|
1008
|
+
// 解析被改测试文件归属锚——解析异常/无绑定回落窗口级 generic(ruleTestTamper 内兜底)
|
|
1009
|
+
const r = applySentinelRules({ prev, next: snap, baseEvents: events, state: sentinelState, now: Date.now(), changeDir, bindResolveImpl: changeDir ? (files) => resolveTestFileOwners({ specBase: resolve(changeDir, '..', '..'), files }) : null });
|
|
1010
|
+
sentinelState = r.state;
|
|
1011
|
+
warnings = r.warnings;
|
|
1012
|
+
} catch (e) {
|
|
1013
|
+
console.warn(`[watcher] 哨兵规则引擎异常(best-effort 跳过本轮): ${(e && e.message) || e}`);
|
|
1014
|
+
}
|
|
1015
|
+
// ── 预览进度账本(2026-09-23-watcher-preview-progress task-02):阶段级投影 best-effort ──
|
|
1016
|
+
// fail-open(writePreviewStages 全程吞异常):预览写失败不影响事件流主循环(D-005)。
|
|
1017
|
+
try {
|
|
1018
|
+
const { projectPreviewStages, writePreviewStages, projectPreviewSteps, writePreviewSteps } = await import('./preview-progress.js')
|
|
1019
|
+
const rows = projectPreviewStages({ snapshot: snap, prevSnapshot: prev })
|
|
1020
|
+
if (rows.length > 0) writePreviewStages({ specDir: specBase, changeName, rows })
|
|
1021
|
+
const stepRows = projectPreviewSteps({ snapshot: snap, prevSnapshot: prev })
|
|
1022
|
+
if (stepRows.length > 0) writePreviewSteps({ specDir: specBase, changeName, rows: stepRows })
|
|
1023
|
+
} catch { /* 预览投影 best-effort,绝不杀 watcher */ }
|
|
1024
|
+
|
|
1025
|
+
const batch = events.concat(warnings);
|
|
1026
|
+
prev = snap;
|
|
1027
|
+
// 水位每轮前移(内容去重;archived 早退路径已在上方 return 不达此处)
|
|
1028
|
+
writeSnapshotWatermark(runtimeRoot, changeName, snap, watermarkCache);
|
|
1029
|
+
if (batch.length === 0) {
|
|
1030
|
+
if (Date.now() - lastActivityAt > IDLE_EXIT_MS) {
|
|
1031
|
+
console.log('[watcher] 空闲超时自退(6h 无事件)');
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
if (events.length > 0) lastActivityAt = Date.now();
|
|
1037
|
+
allEvents = allEvents.concat(batch);
|
|
1038
|
+
try {
|
|
1039
|
+
for (const e of batch) appendFileSync(eventsPath, JSON.stringify(e) + '\n', 'utf8');
|
|
1040
|
+
writeFileSync(timingPath, JSON.stringify({ change: changeName, updatedAt: new Date().toISOString(), stages: aggregateStageTiming(allEvents) }, null, 2) + '\n', 'utf8');
|
|
1041
|
+
} catch (e) {
|
|
1042
|
+
console.warn(`[watcher] 事件落盘异常(best-effort 继续): ${(e && e.message) || e}`);
|
|
1043
|
+
}
|
|
1044
|
+
await pushEventsToPlatform({ specBase, changeName, events: batch, env });
|
|
1045
|
+
// 工件内容变更即时重推(2026-10-07-allticked-gate-docs-resync):tasks/design 等被改写
|
|
1046
|
+
// (含 Edit 勾格/任务面重写)→ 防抖后触发 spec-sync 文档重推——平台不再停留 start 快照。
|
|
1047
|
+
// best-effort:triggerSync 内部自带未连接预判/单飞锁/后台化,此处只挡异常不挡语义。
|
|
1048
|
+
if (shouldResyncDocs(events) && Date.now() - lastDocsResyncAt > DOCS_RESYNC_COOLDOWN_MS) {
|
|
1049
|
+
lastDocsResyncAt = Date.now();
|
|
1050
|
+
try {
|
|
1051
|
+
const { triggerSync } = await import('./run/shared.js');
|
|
1052
|
+
await triggerSync(cwd, changeName, { specRoot: specBase });
|
|
1053
|
+
} catch { /* 重推 best-effort:绝不影响事件流主循环 */ }
|
|
1054
|
+
}
|
|
1055
|
+
if (events.some((e) => e.kind === 'archived')) {
|
|
1056
|
+
console.log(`[watcher] archived 终态,监听结束: ${changeName}`);
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
clearInterval(heartbeatTimer);
|
|
1061
|
+
const cur = readWatcherLock(runtimeRoot);
|
|
1062
|
+
if (cur && cur.pid === process.pid) removeWatcherLock(runtimeRoot);
|
|
1063
|
+
console.log(`[watcher] 监听结束 ${new Date().toISOString()}: ${changeName}`);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export { isPidAlive };
|
|
1067
|
+
export default { spawnWatcher, runWatcherFromEnv, readWatcherLock, isWatcherLeaseLive, isPidAlive, buildSnapshot, inferEvents, aggregateStageTiming, applySentinelRules, createSentinelState, parseGitLogWithFiles, parsePorcelainCodePaths, parseDesignListText, watcherSnapshotPath, loadSnapshotWatermark, writeSnapshotWatermark, watcherEventsPath, readWatcherEvents, toPlatformChangeEvents, pushEventsToPlatform, shouldResyncDocs };
|