sillyspec 3.26.11 → 3.26.14
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-archive/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
- package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
- package/README.md +7 -5
- package/SKILL.md +5 -4
- package/package.json +1 -1
- package/packages/dashboard/server/executor.js +10 -1
- package/packages/dashboard/server/index.js +108 -46
- package/src/config-schema.js +3 -0
- package/src/contract-matrix.js +22 -13
- package/src/db.js +24 -4
- package/src/dispatch/probe.js +99 -63
- package/src/docs-check.js +58 -13
- package/src/docs-debt.js +6 -2
- package/src/docs-gate.js +1 -0
- package/src/doctor-diagnostics.js +208 -21
- package/src/fs-atomic.js +3 -1
- package/src/git-helper.js +7 -4
- package/src/hooks/worktree-guard.js +8 -2
- package/src/index.js +200 -27
- package/src/init.js +3 -2
- package/src/local-detect.js +9 -1
- package/src/modules.js +7 -5
- package/src/progress/stage-machine.js +589 -578
- package/src/progress/step-store.js +1 -1
- package/src/progress.js +187 -30
- package/src/quicklog.js +47 -16
- package/src/run/command.js +88 -46
- package/src/run/complete-handlers.js +27 -15
- package/src/run/complete.js +1186 -1121
- package/src/run/concurrent-detect.js +3 -1
- package/src/run/gates.js +172 -25
- package/src/run/prompt.js +17 -9
- package/src/run/scan-profile.js +35 -23
- package/src/run/shared.js +164 -63
- package/src/run/stage.js +41 -1
- package/src/setup.js +8 -10
- package/src/sillyhub-mcp/client.js +74 -6
- package/src/spec-dir-typo.js +3 -24
- package/src/spec-sync.js +43 -8
- package/src/stage-contract.js +12 -38
- package/src/stage-review.js +40 -1
- package/src/stages/archive.js +4 -3
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +27 -13
- package/src/stages/doctor.js +29 -16
- package/src/stages/execute.js +170 -154
- package/src/stages/knowledge.js +18 -3
- package/src/stages/plan-postcheck.js +72 -37
- package/src/stages/plan.js +70 -48
- package/src/stages/quick.js +3 -1
- package/src/stages/verify.js +1 -1
- package/src/sync.js +339 -225
- package/src/task-review.js +281 -64
- package/src/taskcard.js +178 -0
- package/src/verify-postcheck.js +48 -35
- package/src/worktree-apply.js +58 -34
- package/src/worktree-deps.js +120 -20
- package/src/worktree.js +55 -9
- package/.npmrc-publish +0 -2
|
@@ -150,7 +150,7 @@ export class StepStore {
|
|
|
150
150
|
const tDb = db.getDb();
|
|
151
151
|
const now = new Date().toISOString();
|
|
152
152
|
if (status) {
|
|
153
|
-
tDb.prepare('UPDATE steps SET status = ?, completed_at = ? WHERE id = ? AND name = ?').run(status, now, stepId, stepName);
|
|
153
|
+
tDb.prepare('UPDATE steps SET status = ?, completed_at = ? WHERE id = ? AND name = ?').run(status, status === 'completed' ? now : null, stepId, stepName);
|
|
154
154
|
}
|
|
155
155
|
if (output !== undefined) {
|
|
156
156
|
tDb.prepare('UPDATE steps SET output = ? WHERE id = ? AND name = ?').run(output, stepId, stepName);
|
package/src/progress.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* worktree-guard hook 直读 sillyspec.db,不再有 gate-status.json 缓存双源(task-10 废除)。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync } from 'fs';
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync } from 'fs';
|
|
14
14
|
import { join, basename, dirname, resolve, sep } from 'path';
|
|
15
15
|
import { tmpdir } from 'os';
|
|
16
16
|
import { DB } from './db.js';
|
|
@@ -148,28 +148,13 @@ const CHANGES_SUBDIR = 'changes';
|
|
|
148
148
|
// VALID_STAGES → ./progress/shared.js(W6 Step9c)
|
|
149
149
|
// VALID_STATUSES → ./progress/step-store.js(updateStep 专用,本地化)
|
|
150
150
|
|
|
151
|
-
// Stage statuses (superset of step statuses)
|
|
152
|
-
const VALID_STAGE_STATUSES = ['pending', 'in-progress', 'completed', 'failed', 'blocked', 'revising', 'stale'];
|
|
153
|
-
|
|
154
|
-
// STAGE_ORDER / MAIN_FLOW_ORDER → ./progress/shared.js(W6 Step9a:facade + 子模块共用)
|
|
155
|
-
|
|
156
|
-
// STAGE_LABELS → ./progress/shared.js(W6 Step9c)
|
|
157
|
-
|
|
158
|
-
// emptyStage → ./progress/shared.js(W6 Step9d)
|
|
159
|
-
|
|
160
|
-
function makeInitialProgress(project) {
|
|
161
|
-
const stages = {};
|
|
162
|
-
for (const s of VALID_STAGES) stages[s] = emptyStage();
|
|
163
|
-
return { _version: CURRENT_VERSION, project: project || '', currentStage: '', currentChange: null, stages, lastActive: null };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function makeInitialGlobal(project) {
|
|
167
|
-
return { _version: CURRENT_VERSION, project: project || '', activeChanges: [] };
|
|
168
|
-
}
|
|
169
151
|
|
|
170
152
|
// ── ProgressManager ──
|
|
171
153
|
|
|
172
154
|
export class ProgressManager {
|
|
155
|
+
/** 模块级 DB 连接池:Map<dbPath, DB>(SEC-09/PERF-10,见 _ensureDB 注释) */
|
|
156
|
+
static _dbPool = new Map();
|
|
157
|
+
|
|
173
158
|
/**
|
|
174
159
|
* @param {object} [opts]
|
|
175
160
|
* @param {string} [opts.specDir] - 规范目录绝对路径(默认 cwd/.sillyspec)
|
|
@@ -215,11 +200,26 @@ export class ProgressManager {
|
|
|
215
200
|
}
|
|
216
201
|
}
|
|
217
202
|
|
|
218
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* 懒初始化 DB 连接,缓存在实例上(better-sqlite3 同步 API,init 无 async)。
|
|
205
|
+
*
|
|
206
|
+
* SEC-09/PERF-10(2026-08-20 体检):连接额外进模块级 Map<dbPath, DB> 单例——
|
|
207
|
+
* 一条平台命令此前会 new 多个 ProgressManager(triggerPull / sync / import 各建),
|
|
208
|
+
* 同一 sillyspec.db 被 open 3-5 次,每次重跑 PRAGMA + schema 探测,且多连接
|
|
209
|
+
* 提升 SQLITE_BUSY 概率、Windows 残留句柄影响文件替换。CLI 短进程生命周期内
|
|
210
|
+
* dbPath 不变,进程退出由 OS 回收句柄(node:sqlite 无显式泄漏);进程内共享
|
|
211
|
+
* 单连接后 WAL 写竞争面反而缩小。
|
|
212
|
+
*/
|
|
219
213
|
_ensureDB(cwd) {
|
|
220
214
|
if (!this._db) {
|
|
221
|
-
|
|
222
|
-
|
|
215
|
+
const dbPath = this._runtimePath(cwd, 'sillyspec.db');
|
|
216
|
+
let db = ProgressManager._dbPool.get(dbPath);
|
|
217
|
+
if (!db) {
|
|
218
|
+
db = new DB(dbPath);
|
|
219
|
+
db.init();
|
|
220
|
+
ProgressManager._dbPool.set(dbPath, db);
|
|
221
|
+
}
|
|
222
|
+
this._db = db;
|
|
223
223
|
}
|
|
224
224
|
return this._db;
|
|
225
225
|
}
|
|
@@ -288,7 +288,7 @@ export class ProgressManager {
|
|
|
288
288
|
for (const row of stageRows) {
|
|
289
289
|
const { id: sId, stage, status, started_at: startedAt, completed_at: completedAt, revision, reopened_from_step: reopenedFromStep, reopened_at: reopenedAt, stale_reason: staleReason } = row;
|
|
290
290
|
stageMap[stage] = { _dbId: sId, status, startedAt, completedAt,
|
|
291
|
-
...(revision ? { revision } : {}),
|
|
291
|
+
...(revision != null ? { revision } : {}),
|
|
292
292
|
...(reopenedFromStep ? { reopenedFromStep } : {}),
|
|
293
293
|
...(reopenedAt ? { reopenedAt } : {}),
|
|
294
294
|
...(staleReason ? { staleReason } : {}),
|
|
@@ -309,7 +309,7 @@ export class ProgressManager {
|
|
|
309
309
|
if (!stepsByStage[stageId]) stepsByStage[stageId] = [];
|
|
310
310
|
let waitAnswers = null;
|
|
311
311
|
if (waitAnswersJson) {
|
|
312
|
-
try { waitAnswers = JSON.parse(waitAnswersJson); } catch {}
|
|
312
|
+
try { waitAnswers = JSON.parse(waitAnswersJson); } catch (e) { console.warn(`[progress] waitAnswers JSON 损坏,已跳过: ${e.message}`); }
|
|
313
313
|
}
|
|
314
314
|
stepsByStage[stageId].push({
|
|
315
315
|
name, status, output, completedAt,
|
|
@@ -564,7 +564,17 @@ export class ProgressManager {
|
|
|
564
564
|
}
|
|
565
565
|
const bakTs = new Date().toISOString().replace(/[:.]/g, '-');
|
|
566
566
|
const bakPath = this._runtimePath(cwd, `sillyspec.db.pre-import-${bakTs}.bak`);
|
|
567
|
+
// 体检 BUG-18:WAL 模式下最近已提交事务可能仍在 -wal 侧车,直接 copy 主库文件会拿到
|
|
568
|
+
// 缺尾部提交的快照(恢复时静默回退进度)。先尽力 checkpoint(TRUNCATE) 把 WAL 合并回主库;
|
|
569
|
+
// 他进程持连接致 checkpoint 不完全时,把 -wal 侧车一并备份,恢复侧才有完整提交
|
|
570
|
+
try {
|
|
571
|
+
this._ensureDB(cwd).getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
572
|
+
} catch { /* best-effort:失败退化为连 -wal 一起备份 */ }
|
|
567
573
|
copyFileSync(dbPath, bakPath);
|
|
574
|
+
const walSidecar = `${dbPath}-wal`;
|
|
575
|
+
if (existsSync(walSidecar)) {
|
|
576
|
+
try { copyFileSync(walSidecar, `${bakPath}-wal`); } catch { /* 侧车消失=已 checkpoint,无碍 */ }
|
|
577
|
+
}
|
|
568
578
|
|
|
569
579
|
const pushedAt = progressObj.pushed_at ?? null;
|
|
570
580
|
const now = new Date().toISOString();
|
|
@@ -1002,22 +1012,25 @@ export class ProgressManager {
|
|
|
1002
1012
|
// ── plan.md 对齐(doctor --align-execute-progress 入口)──
|
|
1003
1013
|
|
|
1004
1014
|
/**
|
|
1005
|
-
* 解析 changeDir/
|
|
1015
|
+
* 解析 changeDir/tasks.md(任务注册表唯一真相)的 task checkbox 统计。
|
|
1016
|
+
* 2026-08-20-task-truth-unify:源从「plan.md 优先回退 tasks.md」改为 tasks.md 唯一源——
|
|
1017
|
+
* 旧顺序下新契约 plan.md(纯 ID 引用行)恒 total=0,doctor --align-execute-progress 会静默失效。
|
|
1018
|
+
* tasks.md 缺失时回退 plan.md(旧归档变更兼容读侧,不写侧迁移)。
|
|
1006
1019
|
* 仅匹配 `- [ ] task-NN` / `- [x] task-NN` 形态的行(task- 前缀锚定,避免误捞非任务项)。
|
|
1007
|
-
* @param {string} changeDir - 变更目录绝对路径(含
|
|
1020
|
+
* @param {string} changeDir - 变更目录绝对路径(含 tasks.md/plan.md)
|
|
1008
1021
|
* @returns {{ total: number, checked: number }}
|
|
1009
1022
|
*/
|
|
1010
1023
|
readPlanCheckboxStatus(changeDir) {
|
|
1011
1024
|
if (!changeDir || typeof changeDir !== 'string') {
|
|
1012
1025
|
throw new Error('changeDir 不能为空');
|
|
1013
1026
|
}
|
|
1014
|
-
const planPath = join(changeDir, 'plan.md');
|
|
1015
1027
|
const tasksPath = join(changeDir, 'tasks.md');
|
|
1028
|
+
const planPath = join(changeDir, 'plan.md');
|
|
1016
1029
|
let content = null;
|
|
1017
|
-
if (existsSync(
|
|
1018
|
-
content = readFileSync(planPath, 'utf8');
|
|
1019
|
-
} else if (existsSync(tasksPath)) {
|
|
1030
|
+
if (existsSync(tasksPath)) {
|
|
1020
1031
|
content = readFileSync(tasksPath, 'utf8');
|
|
1032
|
+
} else if (existsSync(planPath)) {
|
|
1033
|
+
content = readFileSync(planPath, 'utf8');
|
|
1021
1034
|
} else {
|
|
1022
1035
|
return { total: 0, checked: 0 };
|
|
1023
1036
|
}
|
|
@@ -1128,4 +1141,148 @@ export class ProgressManager {
|
|
|
1128
1141
|
|
|
1129
1142
|
return { ok: true, aligned, skipped, planTotal, planChecked };
|
|
1130
1143
|
}
|
|
1144
|
+
|
|
1145
|
+
// ── 只读 dump(task-01/02/03:daemon 进度读取)──
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* dump 输出的时间戳规范化:DB 内部两类形态(步骤推进时 JS Date 变量序列化
|
|
1149
|
+
* 的 ISO 形态、部分历史路径写入的 `YYYY/M/D H:mm:ss` 斜杠形态)统一转 ISO。
|
|
1150
|
+
* backend pydantic datetime 只吃 ISO(斜杠形态校验失败),斜杠 → new Date
|
|
1151
|
+
* 可解析;解析失败原样返回(backend 侧按无效丢弃)。
|
|
1152
|
+
*/
|
|
1153
|
+
_dumpIso(ts) {
|
|
1154
|
+
if (!ts) return ts;
|
|
1155
|
+
if (typeof ts === 'string' && /^\d{4}-\d{2}-\d{2}/.test(ts)) return ts;
|
|
1156
|
+
const d = new Date(ts);
|
|
1157
|
+
if (Number.isNaN(d.getTime())) return ts;
|
|
1158
|
+
return d.toISOString();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* 只读导出进度数据,供 daemon 通过 `sillyspec progress dump --json` 消费。
|
|
1163
|
+
*
|
|
1164
|
+
* 纯读路径:打开 DB 只读、读 user-inputs.md、列 artifacts 目录,不写任何状态。
|
|
1165
|
+
* 无活跃变更或 DB 不存在时返回 null(不抛异常,daemon 按 null 处理空数据)。
|
|
1166
|
+
*
|
|
1167
|
+
* 输出字段 snake_case + ISO 时间戳(2026-08-19-runtime-live-daemon-read
|
|
1168
|
+
* acceptance review P0 修复):契约消费端 backend RuntimeProgress pydantic
|
|
1169
|
+
* 是 snake_case(current_stage/last_active/started_at/size_bytes),
|
|
1170
|
+
* dump 早期误用内部 camelCase;跨端字段名断裂会让 pydantic 静默忽略
|
|
1171
|
+
* 未知字段导致前端核心字段全空。
|
|
1172
|
+
*
|
|
1173
|
+
* @param {string} cwd - specDir(`.sillyspec` 目录),由 CLI 层 --spec-dir 传入
|
|
1174
|
+
* @returns {object|null} 结构化进度数据,格式见 design.md §6.2
|
|
1175
|
+
*/
|
|
1176
|
+
dump(cwd) {
|
|
1177
|
+
const specRoot = this._getSpecDir(cwd);
|
|
1178
|
+
const dbPath = this._runtimePath(cwd, 'sillyspec.db');
|
|
1179
|
+
|
|
1180
|
+
// DB 不存在 → 返回 null(项目未初始化进度)
|
|
1181
|
+
if (!existsSync(dbPath)) return null;
|
|
1182
|
+
|
|
1183
|
+
const db = this._ensureDB(cwd);
|
|
1184
|
+
const sqlDb = db.getDb();
|
|
1185
|
+
|
|
1186
|
+
// 1. project 行
|
|
1187
|
+
const projectRow = sqlDb.prepare('SELECT name FROM project WHERE id = 1').get();
|
|
1188
|
+
if (projectRow === undefined) return null;
|
|
1189
|
+
const projectName = projectRow.name;
|
|
1190
|
+
|
|
1191
|
+
// 2. 活跃变更(取 last_active 最新的一个)
|
|
1192
|
+
// ql-20260821-003:多活跃仓(变更隔离是常态,单仓可同时存在 N 个 active)下
|
|
1193
|
+
// 原 ORDER BY name 恒取字典序最前(通常是最老的变更),「当前变更/最后活动」
|
|
1194
|
+
// 停留在历史数据。dump 语义是「当前工作流状态」→ 取最近活跃的那个。
|
|
1195
|
+
const changeRows = sqlDb.prepare("SELECT id, name, current_stage, last_active FROM changes WHERE status = 'active' ORDER BY last_active DESC").all();
|
|
1196
|
+
if (changeRows.length === 0) {
|
|
1197
|
+
// 无活跃变更 → 返回仅含 project 的骨架
|
|
1198
|
+
return {
|
|
1199
|
+
project: projectName,
|
|
1200
|
+
current_stage: null,
|
|
1201
|
+
current_change: null,
|
|
1202
|
+
last_active: null,
|
|
1203
|
+
stages: {},
|
|
1204
|
+
user_inputs: this._readUserInputs(cwd),
|
|
1205
|
+
artifacts: this._listArtifacts(cwd),
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const change = changeRows[0];
|
|
1210
|
+
const { id: changeId, name: changeName, current_stage: currentStage, last_active: lastActive } = change;
|
|
1211
|
+
|
|
1212
|
+
// 3. stages
|
|
1213
|
+
const stageRows = sqlDb.prepare(
|
|
1214
|
+
'SELECT id, stage, status, started_at, completed_at, revision FROM stages WHERE change_id = ? ORDER BY id'
|
|
1215
|
+
).all(changeId);
|
|
1216
|
+
|
|
1217
|
+
const stages = {};
|
|
1218
|
+
for (const s of VALID_STAGES) {
|
|
1219
|
+
stages[s] = { status: 'pending', steps: [] };
|
|
1220
|
+
}
|
|
1221
|
+
// PERF-06:一次 IN (...) 批量取全部 steps 再按 stage_id 分组(原逐 stage 查询是 N+1,
|
|
1222
|
+
// dump 是 daemon 轮询接口;与 read() :283-287 的批量读法对齐)
|
|
1223
|
+
const stepsByStage = new Map();
|
|
1224
|
+
if (stageRows.length > 0) {
|
|
1225
|
+
const placeholders = stageRows.map(() => '?').join(', ');
|
|
1226
|
+
const allStepRows = sqlDb.prepare(
|
|
1227
|
+
`SELECT stage_id, name, status, output, completed_at FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`
|
|
1228
|
+
).all(...stageRows.map(r => r.id));
|
|
1229
|
+
for (const sr of allStepRows) {
|
|
1230
|
+
if (!stepsByStage.has(sr.stage_id)) stepsByStage.set(sr.stage_id, []);
|
|
1231
|
+
stepsByStage.get(sr.stage_id).push(sr);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
for (const row of stageRows) {
|
|
1235
|
+
const stepRows = stepsByStage.get(row.id) || [];
|
|
1236
|
+
stages[row.stage] = {
|
|
1237
|
+
status: row.status,
|
|
1238
|
+
started_at: this._dumpIso(row.started_at) || undefined,
|
|
1239
|
+
completed_at: this._dumpIso(row.completed_at) || undefined,
|
|
1240
|
+
...(row.revision ? { revision: row.revision } : {}),
|
|
1241
|
+
steps: stepRows.map(sr => ({
|
|
1242
|
+
name: sr.name,
|
|
1243
|
+
status: sr.status,
|
|
1244
|
+
...(sr.output ? { output: sr.output } : {}),
|
|
1245
|
+
...(sr.completed_at ? { completed_at: this._dumpIso(sr.completed_at) } : {}),
|
|
1246
|
+
})),
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
return {
|
|
1251
|
+
project: projectName,
|
|
1252
|
+
current_stage: currentStage || null,
|
|
1253
|
+
current_change: changeName,
|
|
1254
|
+
last_active: this._dumpIso(lastActive) || null,
|
|
1255
|
+
stages,
|
|
1256
|
+
user_inputs: this._readUserInputs(cwd),
|
|
1257
|
+
artifacts: this._listArtifacts(cwd),
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/** 只读 user-inputs.md 内容(不存在返回 null) */
|
|
1262
|
+
_readUserInputs(cwd) {
|
|
1263
|
+
const p = this._runtimePath(cwd, 'user-inputs.md');
|
|
1264
|
+
if (!existsSync(p)) return null;
|
|
1265
|
+
return readFileSync(p, 'utf8');
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** 列 artifacts 目录(不存在返回空数组) */
|
|
1269
|
+
_listArtifacts(cwd) {
|
|
1270
|
+
const dir = this._runtimePath(cwd, 'artifacts');
|
|
1271
|
+
if (!existsSync(dir)) return [];
|
|
1272
|
+
try {
|
|
1273
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
1274
|
+
.filter(e => e.isFile())
|
|
1275
|
+
.map(e => {
|
|
1276
|
+
const fullPath = join(dir, e.name);
|
|
1277
|
+
const st = statSync(fullPath);
|
|
1278
|
+
return {
|
|
1279
|
+
filename: e.name,
|
|
1280
|
+
size_bytes: st.size,
|
|
1281
|
+
last_modified: st.mtime.toISOString(),
|
|
1282
|
+
};
|
|
1283
|
+
});
|
|
1284
|
+
} catch {
|
|
1285
|
+
return [];
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1131
1288
|
}
|
package/src/quicklog.js
CHANGED
|
@@ -14,15 +14,22 @@ import { join, dirname, basename } from 'path'
|
|
|
14
14
|
import { getRule } from './stage-contract-spec.js'
|
|
15
15
|
import {
|
|
16
16
|
openSync, closeSync, unlinkSync, statSync, mkdirSync, existsSync,
|
|
17
|
-
readFileSync, writeFileSync, readdirSync, renameSync, appendFileSync,
|
|
17
|
+
readFileSync, writeFileSync, writeSync, readdirSync, renameSync, appendFileSync,
|
|
18
18
|
} from 'fs'
|
|
19
19
|
import { randomBytes } from 'crypto'
|
|
20
20
|
|
|
21
21
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* O_EXCL 文件锁。`openSync(lockPath,'wx')`
|
|
25
|
-
*
|
|
24
|
+
* O_EXCL 文件锁。`openSync(lockPath,'wx')` 独占创建并写入持有者唯一 id;已存在则按
|
|
25
|
+
* mtime 判 stale 偷锁(rename 原子抢占 + 内容校验),否则退避重试至超时。
|
|
26
|
+
* CLI 短进程、低频写入,stale 阈值 30s 足够。
|
|
27
|
+
*
|
|
28
|
+
* 偷锁为何用 rename 而非 unlink(2026-08-20 体检 BUG-02):两进程同偷一把 stale 锁时,
|
|
29
|
+
* 后到者的 unlink 会把先到者刚重建的新锁删掉(TOCTOU)→ 双进程同时进临界区。
|
|
30
|
+
* rename 到唯一名后比对内容:只有"观察时看到的那把锁"才允许删除;偷到并发者
|
|
31
|
+
* 新建的锁则原路放回。残余窗口仅剩"纳秒级三方精确竞争"(放回前 existsSync 检查
|
|
32
|
+
* 与 rename 之间),相比旧实现的必现双进已可忽略。
|
|
26
33
|
* @param {string} lockPath 锁文件路径(建议与被保护文件同目录)
|
|
27
34
|
* @param {() => (any|Promise<any>)} fn 临界区
|
|
28
35
|
* @param {{staleMs?:number, timeoutMs?:number, retryMs?:number}} opts
|
|
@@ -31,11 +38,13 @@ export async function withFileLock(lockPath, fn, opts = {}) {
|
|
|
31
38
|
const { staleMs = 30000, timeoutMs = 10000, retryMs = 50 } = opts
|
|
32
39
|
mkdirSync(dirname(lockPath), { recursive: true })
|
|
33
40
|
const start = Date.now()
|
|
41
|
+
const myId = `${process.pid}-${randomBytes(6).toString('hex')}`
|
|
34
42
|
let fd = null
|
|
35
43
|
// 抢锁循环
|
|
36
44
|
while (true) {
|
|
37
45
|
try {
|
|
38
46
|
fd = openSync(lockPath, 'wx') // O_EXCL:已存在抛 EEXIST
|
|
47
|
+
writeSync(fd, myId)
|
|
39
48
|
break
|
|
40
49
|
} catch (e) {
|
|
41
50
|
if (e.code !== 'EEXIST') throw e
|
|
@@ -43,7 +52,19 @@ export async function withFileLock(lockPath, fn, opts = {}) {
|
|
|
43
52
|
try {
|
|
44
53
|
const mtime = statSync(lockPath).mtimeMs
|
|
45
54
|
if (Date.now() - mtime > staleMs) {
|
|
46
|
-
try {
|
|
55
|
+
try {
|
|
56
|
+
const observed = readFileSync(lockPath, 'utf8') // 观察到的持有者 id
|
|
57
|
+
const claim = `${lockPath}.steal-${randomBytes(6).toString('hex')}`
|
|
58
|
+
renameSync(lockPath, claim) // 原子抢占:并发者只可能有一个 rename 成功
|
|
59
|
+
if (readFileSync(claim, 'utf8') === observed) {
|
|
60
|
+
try { unlinkSync(claim) } catch {} // 确实是那把 stale 锁 → 回收
|
|
61
|
+
} else {
|
|
62
|
+
// 偷到并发者新建的锁:放回(原位已被占则放弃,先占者保留)
|
|
63
|
+
let restored = false
|
|
64
|
+
try { if (!existsSync(lockPath)) { renameSync(claim, lockPath); restored = true } } catch {}
|
|
65
|
+
if (!restored) { try { unlinkSync(claim) } catch {} }
|
|
66
|
+
}
|
|
67
|
+
} catch {}
|
|
47
68
|
continue
|
|
48
69
|
}
|
|
49
70
|
} catch {}
|
|
@@ -339,7 +360,7 @@ function listQuicklogFiles(quicklogDir) {
|
|
|
339
360
|
// reader 句柄是瞬时的,故对占用类错误做异步重试+退避;非占用错误立即抛。
|
|
340
361
|
async function writeAtomic(filePath, content, opts = {}) {
|
|
341
362
|
const { retries = 100, baseDelayMs = 5, maxDelayMs = 100 } = opts
|
|
342
|
-
const tmp = join(dirname(filePath), basename(filePath) + '.tmp-' + process.pid)
|
|
363
|
+
const tmp = join(dirname(filePath), basename(filePath) + '.tmp-' + process.pid + '-' + randomBytes(4).toString('hex')) // 随机段:Windows PID 重用激进,仅 pid 会撞名(对齐 fs-atomic.js 的同款修复)
|
|
343
364
|
writeFileSync(tmp, content) // 同目录 = 同文件系统,rename 才原子(跨 fs 会退化 copy+delete)
|
|
344
365
|
for (let attempt = 0; ; attempt++) {
|
|
345
366
|
try {
|
|
@@ -417,24 +438,34 @@ function tasksPath(specBase, change) {
|
|
|
417
438
|
// 关联变更目录不存在(笔误 / 未建 / 仅作标签关联)→ 不 fabricate stub 目录(历史坑 quick-change-phantom:
|
|
418
439
|
// mkdirSync 硬造 changes/<名>/tasks.md,致 quick --done 边界审计自造自拦 BLOCK)。关联仍记入
|
|
419
440
|
// QUICKLOG「关联变更」行(allocateQuicklogEntry 独立写入,不依赖本函数)。
|
|
420
|
-
|
|
441
|
+
// 体检 BUG-17:tasks.md 是跨用户共享文件——QUICKLOG 按 <user> 分锁只串行化同用户会话,
|
|
442
|
+
// 用户 A 勾选与用户 B 追加跨用户并发时须按目标文件加锁,否则读-改-写互相覆盖。
|
|
443
|
+
function tasksLockPath(p) {
|
|
444
|
+
return p + '.tasklock'
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function appendTaskCheckbox(specBase, change, qlId, desc) {
|
|
421
448
|
const dir = join(specBase, 'changes', change)
|
|
422
449
|
if (!existsSync(dir)) return
|
|
423
450
|
const p = tasksPath(specBase, change)
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
451
|
+
await withFileLock(tasksLockPath(p), () => {
|
|
452
|
+
let content = existsSync(p) ? readFileSync(p, 'utf8') : ''
|
|
453
|
+
if (content.includes(qlId)) return
|
|
454
|
+
const prefix = content && !content.endsWith('\n') ? '\n' : ''
|
|
455
|
+
appendFileSync(p, `${prefix}- [ ] ${qlId} ${desc}\n`)
|
|
456
|
+
})
|
|
428
457
|
}
|
|
429
458
|
|
|
430
|
-
// 勾选该 qlId 对应 task:- [ ] → - [x]
|
|
459
|
+
// 勾选该 qlId 对应 task:- [ ] → - [x](同 BUG-17:按 tasks.md 文件锁串行化跨用户读-改-写)
|
|
431
460
|
async function checkTaskCheckbox(specBase, change, qlId) {
|
|
432
461
|
const p = tasksPath(specBase, change)
|
|
433
462
|
if (!existsSync(p)) return
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
463
|
+
await withFileLock(tasksLockPath(p), async () => {
|
|
464
|
+
let content = readFileSync(p, 'utf8')
|
|
465
|
+
const re = new RegExp(`- \\[ \\] (${escapeRe(qlId)})`)
|
|
466
|
+
content = content.replace(re, '- [x] $1')
|
|
467
|
+
await writeAtomic(p, content)
|
|
468
|
+
})
|
|
438
469
|
}
|
|
439
470
|
|
|
440
471
|
// 就地翻某 qlId 条目:状态进行中→已完成,追加结果行
|
|
@@ -606,7 +637,7 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
|
|
|
606
637
|
].join('\n')
|
|
607
638
|
appendFileSync(userFile, entry)
|
|
608
639
|
|
|
609
|
-
for (const c of linked) appendTaskCheckbox(specBase, c, qlId, desc)
|
|
640
|
+
for (const c of linked) await appendTaskCheckbox(specBase, c, qlId, desc)
|
|
610
641
|
|
|
611
642
|
// 平台推送(task-06 / FR-02 / D-003):锁外语义等同(推送不回写本地),但放锁内
|
|
612
643
|
// 保证「分配即推送」顺序一致;best-effort 失败不影响返回。
|