sillyspec 3.28.8 → 3.28.9
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-execute/SKILL.md +222 -224
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
- package/AGENTS.md +3 -2
- package/README.md +35 -11
- package/SKILL.md +24 -10
- package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
- package/package.json +1 -1
- package/src/change-risk-profile.js +20 -0
- package/src/config-schema.js +32 -0
- package/src/db.js +11 -3
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +33 -3
- package/src/docs-gate.js +105 -6
- package/src/doctor-diagnostics.js +224 -2
- package/src/friction-ledger.js +171 -0
- package/src/hooks/worktree-guard.js +13 -1
- package/src/index.js +188 -17
- package/src/knowledge-classify.js +363 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/progress/change-registry.js +183 -8
- package/src/progress/shared.js +3 -2
- package/src/progress.js +100 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +155 -14
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +107 -12
- package/src/run/complete-handlers.js +511 -21
- package/src/run/complete.js +54 -16
- package/src/run/concurrent-detect.js +98 -0
- package/src/run/gates.js +50 -9
- package/src/run/prompt.js +134 -5
- package/src/run/quick-audit.js +111 -13
- package/src/run/shared.js +76 -10
- package/src/run/stage.js +6 -0
- package/src/scan-diff.js +61 -30
- package/src/scan-postcheck.js +74 -0
- package/src/scan-refresh.js +477 -0
- package/src/scan-staleness.js +25 -13
- package/src/scope-audit.js +365 -35
- package/src/stage-contract.js +185 -2
- package/src/stage-review.js +23 -7
- package/src/stages/brainstorm.js +6 -0
- package/src/stages/execute.js +85 -3
- package/src/stages/knowledge.js +52 -3
- package/src/stages/quick.js +1 -1
- package/src/stages/verify.js +1 -0
- package/src/sync-noise.js +114 -6
- package/src/sync.js +135 -7
- package/src/task-review.js +292 -21
- package/src/verify-postcheck.js +20 -0
- package/src/verify-probes.js +340 -4
- package/src/worktree-apply.js +538 -44
- package/src/worktree-deps.js +100 -2
- package/src/worktree.js +117 -5
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/.patch16-tmp.mjs +0 -169
- package/sa-rename-probe/src/new.js +0 -4
package/src/sync-noise.js
CHANGED
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
* - 任意一次同步成功清 marker,且此前在闸内时打一行恢复提示;
|
|
16
16
|
* - SILLYSPEC_DEBUG_SYNC=1 全程不静默。
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
* 4xx
|
|
20
|
-
* 且已有各自的冲突降噪机制(sync-conflict-banner-spam
|
|
21
|
-
*
|
|
18
|
+
* 边界:连接类闸只覆盖「连接类」失败(HTTP 404/5xx、网络错、超时/abort)。409 与其余
|
|
19
|
+
* 4xx 业务态(平台就绪但状态冲突/鉴权问题)不走连接类闸——那些是每轮都该看见的语义信号,
|
|
20
|
+
* 且已有各自的冲突降噪机制(sync-conflict-banner-spam 一族)。例外:409 code='change_deleted'
|
|
21
|
+
* 是终态幂等回执(平台侧已删该变更 key,重推永远同回执、永不自愈),走下方按变更名键控的
|
|
22
|
+
* change_deleted 闸(2026-09-14 用户反馈②):首报可见 + 跨进程窗口静默 + 推送成功清窗。
|
|
23
|
+
* 用户显式命令(platform connect 的 health ping / platform sync 手动推送)不应被静默——
|
|
24
|
+
* 调用方传 noMute 绕过。
|
|
22
25
|
*/
|
|
23
26
|
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs'
|
|
24
27
|
import { join } from 'path'
|
|
@@ -130,13 +133,116 @@ export function syncConnectionWarn(msg) {
|
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
/**
|
|
133
|
-
* 判定 HTTP 状态码是否属连接类(可静默):404
|
|
136
|
+
* 判定 HTTP 状态码是否属连接类(可静默):404(端点未就绪/未部署)与 5xx(服务端故障)。
|
|
134
137
|
* 409 与其余 4xx 不在此列(见模块头注释边界)。
|
|
135
138
|
*/
|
|
136
139
|
export function isConnectionClassStatus(status) {
|
|
137
140
|
return status === 404 || (typeof status === 'number' && status >= 500)
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
// ── 变更级 change_deleted 回执噪音闸(2026-09-14 用户反馈②:--done 的真实报错被
|
|
144
|
+
// default 墓碑 409 回执刷屏淹没)──
|
|
145
|
+
// 与连接类闸同构但按变更名键控。409 code='change_deleted' 是终态幂等回执:平台侧已删
|
|
146
|
+
// 该变更 key 后,本地同名变更每次自动 push(每步 triggerSync/triggerStepStartSync)都收到
|
|
147
|
+
// 同样的回执——多步流程下逐条重打属零新信息刷屏;且与 base_ts 409 不同,它永远不会自愈
|
|
148
|
+
// (平台侧删除需人工介入)。语义边界(区别于连接类闸的「每轮都该看见」):
|
|
149
|
+
// - 首报完整可见(含 ⚠️ 本地仍 active 的可行动信号);
|
|
150
|
+
// - 跨进程窗口内同变更的回执静默(窗口过期后照常重报——保活信号,不静默死);
|
|
151
|
+
// - 开窗进程内不同回执行照打(诊断完整性,与连接类闸同款);
|
|
152
|
+
// - 该变更推送成功(平台侧重建/恢复)→ noteChangeDeletedResolved 清窗;
|
|
153
|
+
// - 手动 platform sync(opts.manual)与 SILLYSPEC_DEBUG_SYNC=1 旁路。
|
|
154
|
+
const DELETED_MARKER = 'sync-noise-change-deleted.json'
|
|
155
|
+
// 本进程开窗的变更名集合(区分「窗口是本进程开的」与「先前进程留的」,同 _openedByThisProcess)
|
|
156
|
+
let _openedDeletedByThisProcess = new Set()
|
|
157
|
+
// 本进程已见(打印/静默)过的 `${changeName}\u0000${msg}` 集合:同进程内逐字重复只打一次
|
|
158
|
+
const _seenDeletedInProcess = new Set()
|
|
159
|
+
|
|
160
|
+
function _readDeletedWindows() {
|
|
161
|
+
const p = _runtimeDir ? join(_runtimeDir, DELETED_MARKER) : null
|
|
162
|
+
if (!p) return null
|
|
163
|
+
try {
|
|
164
|
+
const s = JSON.parse(readFileSync(p, 'utf8'))
|
|
165
|
+
return s && typeof s === 'object' && !Array.isArray(s) ? s : null
|
|
166
|
+
} catch { return null }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _writeDeletedWindows(map) {
|
|
170
|
+
const p = _runtimeDir ? join(_runtimeDir, DELETED_MARKER) : null
|
|
171
|
+
if (!p) return
|
|
172
|
+
try {
|
|
173
|
+
mkdirSync(_runtimeDir, { recursive: true })
|
|
174
|
+
const now = Date.now()
|
|
175
|
+
const pruned = {}
|
|
176
|
+
for (const [k, v] of Object.entries(map)) if (typeof v === 'number' && v > now) pruned[k] = v
|
|
177
|
+
if (Object.keys(pruned).length === 0) {
|
|
178
|
+
try { unlinkSync(p) } catch { /* 无文件/占用:留空 map 文件也等效 */ }
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
writeFileSync(p, JSON.stringify(pruned) + '\n', 'utf8')
|
|
182
|
+
} catch { /* marker 写失败 = 闸门失效退化为直通,仅损失降噪 */ }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _deletedLineVisible(changeName, msg, noMute) {
|
|
186
|
+
if (!changeName || noMute || process.env.SILLYSPEC_DEBUG_SYNC) return true
|
|
187
|
+
const key = changeName + '\u0000' + msg
|
|
188
|
+
if (_seenDeletedInProcess.has(key)) return false
|
|
189
|
+
const now = Date.now()
|
|
190
|
+
const windows = _readDeletedWindows()
|
|
191
|
+
const until = windows ? windows[changeName] : undefined
|
|
192
|
+
if (typeof until === 'number' && now < until) {
|
|
193
|
+
_seenDeletedInProcess.add(key)
|
|
194
|
+
// 开窗进程内不同回执行可见(诊断完整性),先前进程留的窗口静默
|
|
195
|
+
return _openedDeletedByThisProcess.has(changeName)
|
|
196
|
+
}
|
|
197
|
+
_seenDeletedInProcess.add(key)
|
|
198
|
+
const next = windows || {}
|
|
199
|
+
next[changeName] = now + MUTE_WINDOW_MS
|
|
200
|
+
_writeDeletedWindows(next)
|
|
201
|
+
_openedDeletedByThisProcess.add(changeName)
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* change_deleted 回执行(info 口吻:本地已注销的墓碑上行 409 属预期回执)。
|
|
207
|
+
* @returns {boolean} true=已输出
|
|
208
|
+
*/
|
|
209
|
+
export function syncChangeDeletedLog(changeName, msg, { noMute = false } = {}) {
|
|
210
|
+
if (_deletedLineVisible(changeName, msg, noMute)) {
|
|
211
|
+
console.log(msg)
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* change_deleted 回执行(warn 口吻:本地仍 active,上行被拒收的可行动信号)。
|
|
219
|
+
* @returns {boolean} true=已输出
|
|
220
|
+
*/
|
|
221
|
+
export function syncChangeDeletedWarn(changeName, msg, { noMute = false } = {}) {
|
|
222
|
+
if (_deletedLineVisible(changeName, msg, noMute)) {
|
|
223
|
+
console.warn(msg)
|
|
224
|
+
return true
|
|
225
|
+
}
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 该变更推送成功(平台侧未删/已重建):清其回执噪音窗与进程内状态。
|
|
231
|
+
* 与 noteSyncSuccess 的「任意成功清连接闸」不同——本闸按变更名精清(他变更的窗口不受影响)。
|
|
232
|
+
*/
|
|
233
|
+
export function noteChangeDeletedResolved(changeName) {
|
|
234
|
+
if (!changeName) return
|
|
235
|
+
_openedDeletedByThisProcess.delete(changeName)
|
|
236
|
+
for (const k of _seenDeletedInProcess) {
|
|
237
|
+
if (k.startsWith(changeName + '\u0000')) _seenDeletedInProcess.delete(k)
|
|
238
|
+
}
|
|
239
|
+
const windows = _readDeletedWindows()
|
|
240
|
+
if (windows && windows[changeName] !== undefined) {
|
|
241
|
+
delete windows[changeName]
|
|
242
|
+
_writeDeletedWindows(windows)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
140
246
|
/**
|
|
141
247
|
* 任意一次同步成功:清 marker。此前处于闸内(marker 存在)时打一行恢复提示——
|
|
142
248
|
* 平台恢复的第一个信号应该可见,之后的成功照常静默(成功不打扰)。
|
|
@@ -150,9 +256,11 @@ export function noteSyncSuccess() {
|
|
|
150
256
|
console.log('[sync] 平台连接已恢复(此前静默的同步失败已停止)')
|
|
151
257
|
}
|
|
152
258
|
|
|
153
|
-
/**
|
|
259
|
+
/** 测试复位:清进程内绑定与开窗状态(连接类闸 + change_deleted 变更级闸)。 */
|
|
154
260
|
export function _resetSyncNoiseForTest() {
|
|
155
261
|
_runtimeDir = null
|
|
156
262
|
_openedByThisProcess = false
|
|
157
263
|
_seenInProcess.clear()
|
|
264
|
+
_openedDeletedByThisProcess = new Set()
|
|
265
|
+
_seenDeletedInProcess.clear()
|
|
158
266
|
}
|
package/src/sync.js
CHANGED
|
@@ -18,7 +18,7 @@ import { safeGit } from './git-helper.js';
|
|
|
18
18
|
import { openDatabase } from './db-engine.js';
|
|
19
19
|
import { PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
|
|
20
20
|
import { syncSpecTree } from './spec-sync.js';
|
|
21
|
-
import { bindSyncNoiseFromCwd, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess } from './sync-noise.js';
|
|
21
|
+
import { bindSyncNoiseFromCwd, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess, syncChangeDeletedLog, syncChangeDeletedWarn, noteChangeDeletedResolved } from './sync-noise.js';
|
|
22
22
|
|
|
23
23
|
// sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
|
|
24
24
|
function safePlatformSpecDir(cwd) {
|
|
@@ -595,7 +595,7 @@ export class SyncManager {
|
|
|
595
595
|
* 外来推送(他机/他用户)不可能推进本机 DB 的 base_ts → 走原冲突路径,无误放行。
|
|
596
596
|
*/
|
|
597
597
|
async sync(changeName, opts = {}) {
|
|
598
|
-
const { fromResolve = false } = opts;
|
|
598
|
+
const { fromResolve = false, manual = false } = opts;
|
|
599
599
|
const platform = this._getPlatform();
|
|
600
600
|
if (!platform) {
|
|
601
601
|
debugLog('[sync] 未连接平台(本地合法状态);如需平台同步:sillyspec platform connect');
|
|
@@ -615,8 +615,30 @@ export class SyncManager {
|
|
|
615
615
|
if (!fromResolve) {
|
|
616
616
|
const pendingConflict = this.readConflictFile(changeName);
|
|
617
617
|
if (pendingConflict) {
|
|
618
|
-
|
|
619
|
-
|
|
618
|
+
// 过期自清(坑 sync-self-echo-false-conflict 附带,2026-09-14 实证 ctx-usage:base_ts 已被
|
|
619
|
+
// 后续 resolve/自愈推进到 ≥ 冲突文件记录的平台 ts——冲突条件早已消散,但文件残留仍压着
|
|
620
|
+
// 自动推送(「文件在即抑制」只认文件存在,永不重判)。此时清除文件继续推送是安全的:
|
|
621
|
+
// base ≥ 平台 ts 意味着本地已同步到冲突点之后,重推只会更新镜像不会丢数据。判不出
|
|
622
|
+
// (无平台 ts / DB 读失败)维持抑制,fail-closed。
|
|
623
|
+
const stalePlatTs = pendingConflict.platform_last_pushed_at || null;
|
|
624
|
+
let stale = false;
|
|
625
|
+
if (stalePlatTs) {
|
|
626
|
+
try {
|
|
627
|
+
const { ProgressManager } = await import('./progress.js');
|
|
628
|
+
const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
|
|
629
|
+
const row = pm._ensureDB(this.cwd).getDb().prepare(
|
|
630
|
+
'SELECT last_synced_platform_ts FROM changes WHERE name = ?'
|
|
631
|
+
).get(changeName);
|
|
632
|
+
if (row && row.last_synced_platform_ts && row.last_synced_platform_ts >= stalePlatTs) stale = true;
|
|
633
|
+
} catch { /* 判定失败维持抑制(fail-closed) */ }
|
|
634
|
+
}
|
|
635
|
+
if (stale) {
|
|
636
|
+
this.clearConflictFile(changeName);
|
|
637
|
+
console.warn(`⚠️ [sync] 变更 ${changeName} 的未决冲突已过期(base_ts ≥ 平台 ts=${stalePlatTs}),自动清除并恢复推送`);
|
|
638
|
+
} else {
|
|
639
|
+
console.warn(`⚠️ [sync] 变更 ${changeName} 存在未决平台冲突,跳过自动推送。处理:sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort`);
|
|
640
|
+
return { synced: 0, errors: [`conflict pending: ${changeName}`], conflict: true, suppressed: true };
|
|
641
|
+
}
|
|
620
642
|
}
|
|
621
643
|
}
|
|
622
644
|
|
|
@@ -634,7 +656,9 @@ export class SyncManager {
|
|
|
634
656
|
// change-delete(2026-08-30):DB status='deleted' 的行目录已随删除移除,属正常时序,
|
|
635
657
|
// 单行 info 提示后继续推删除终态/墓碑(不落入「目录不存在」warn)
|
|
636
658
|
if (this._changeDbStatus(changeName) === 'deleted') {
|
|
637
|
-
|
|
659
|
+
// 变更级回执噪音闸(sync-noise.js change_deleted 闸):status=deleted 是终态,此后每步
|
|
660
|
+
// 自动同步都会走到这里打同一行——首报后跨进程窗口内静默;手动 platform sync 旁路。
|
|
661
|
+
syncChangeDeletedLog(changeName, `[sync] 变更已删除(DB status=deleted),继续推送删除终态/墓碑: ${changeName}`, { noMute: manual });
|
|
638
662
|
} else {
|
|
639
663
|
archivedQuietly = existsSync(join(specBase, 'changes', 'archive', changeName))
|
|
640
664
|
|| this._changeDbStatus(changeName) === 'archived';
|
|
@@ -728,9 +752,10 @@ export class SyncManager {
|
|
|
728
752
|
|| progressData?.changes?.[0]?.status === 'archived'
|
|
729
753
|
|| progressData?.changes?.[0]?.status === 'deleted'
|
|
730
754
|
if (localTombstone) {
|
|
731
|
-
|
|
755
|
+
syncChangeDeletedLog(changeName, `ℹ️ [sync] 平台已删除变更「${changeName}」——本地注销已由 CLI 完成,墓碑上行 409 属预期回执,无需动作`, { noMute: manual });
|
|
732
756
|
} else {
|
|
733
|
-
|
|
757
|
+
// 可行动信号(本地仍 active + 平台已删):首报必须醒目,重复回执走变更级噪音闸
|
|
758
|
+
syncChangeDeletedWarn(changeName, `⚠️ [sync] 平台已删除变更「${changeName}」,本次进度上行被拒收(change_deleted);本地如仍需推进请在平台侧确认`, { noMute: manual });
|
|
734
759
|
}
|
|
735
760
|
return { synced: 0, errors: [], platformDeleted: true, reason: '平台已删除(change_deleted 拒收)' };
|
|
736
761
|
}
|
|
@@ -739,6 +764,8 @@ export class SyncManager {
|
|
|
739
764
|
// 平台连接恢复信号(sync-noise.js):此前静默窗口内的失败在恢复的第一笔成功时
|
|
740
765
|
// 打一行提示并清 marker;之后照常「成功不打扰」。
|
|
741
766
|
noteSyncSuccess();
|
|
767
|
+
// 该变更推送成功 = 平台侧未删/已重建,change_deleted 回执噪音窗按变更名精清
|
|
768
|
+
noteChangeDeletedResolved(changeName);
|
|
742
769
|
// 更新 platform_last_sync + 推进 base_ts(ql-20260818-008:值优先平台回执 last_pushed_at,
|
|
743
770
|
// 缺省用本次 pushedAt——服务器 _apply 存的就是客户端 X-SillySpec-Pushed-At 原值,回写一致。
|
|
744
771
|
// 修复前该列从不写,下次 push 永不带 X-SillySpec-Base-Ts、pull 脏度检测恒 false)
|
|
@@ -809,6 +836,9 @@ export class SyncManager {
|
|
|
809
836
|
// 平台 409 响应 { conflict:true, platform_progress, last_pushed_at },platform_progress 即平台最新 progress JSON
|
|
810
837
|
const platformProgress = res.body && res.body.platform_progress ? res.body.platform_progress : res.body;
|
|
811
838
|
const platformLastPushedAt = (res.body && res.body.last_pushed_at) || null;
|
|
839
|
+
// 身份归属(ql-20260914 第二批):服务端 409 body 回传平台行既有 last_pusher——
|
|
840
|
+
// 与本地 pushUser 同口径比对(platform.user || resolvePlatformUser,与发送侧一致)
|
|
841
|
+
const platformLastPusher = (res.body && res.body.last_pusher) || null;
|
|
812
842
|
|
|
813
843
|
// 自竞态自愈:fresh 重读本机 DB base_ts,若已被并发进程回填到 ≥ 平台 409 回执 ts,
|
|
814
844
|
// 说明赢者是本机自己人——刷新 base_ts 重试一次即收敛,不落冲突文件(外来推送不可能
|
|
@@ -838,6 +868,42 @@ export class SyncManager {
|
|
|
838
868
|
}
|
|
839
869
|
} catch { /* 比对失败维持原判(fail-closed 到真冲突分支) */ }
|
|
840
870
|
|
|
871
|
+
// ── 自回声归属(坑 sync-self-echo-false-conflict,2026-09-14 multi-agent-platform
|
|
872
|
+
// 实证 6 个假冲突全为此形;第二批升级身份优先):平台快照自带 changes[0].
|
|
873
|
+
// last_local_modified_ts(推送方序列化时的本地脏度血统标记)。归属判定走
|
|
874
|
+
// _isSelfEchoAttribution——服务端回传 last_pusher 时身份优先(≠本人一律真冲突,
|
|
875
|
+
// ==本人仅保血统上界),身份缺失回退 [base_ts, local_modified] 双界窗口(窗口内
|
|
876
|
+
// = 本机自推过的状态:并发推送乱序/失败回填让 base 没跟上,自家回执被当他端
|
|
877
|
+
// 更新)。判为自回声 ⇒ 推进 base_ts 到平台 ts 后重试推送即收敛,不落冲突文件;
|
|
878
|
+
// 判为外来 ⇒ 维持原冲突路径:本地永不覆盖血统比自己新的平台状态。
|
|
879
|
+
if (!fromResolve && platformProgress && platformProgress.changes && platformProgress.changes[0]
|
|
880
|
+
&& platformProgress.changes[0].last_local_modified_ts) {
|
|
881
|
+
const platLin = platformProgress.changes[0].last_local_modified_ts;
|
|
882
|
+
try {
|
|
883
|
+
const { ProgressManager } = await import('./progress.js');
|
|
884
|
+
const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
|
|
885
|
+
const row = pm._ensureDB(this.cwd).getDb().prepare(
|
|
886
|
+
'SELECT last_local_modified_ts, last_synced_platform_ts FROM changes WHERE name = ?'
|
|
887
|
+
).get(changeName);
|
|
888
|
+
const localMod = row && row.last_local_modified_ts;
|
|
889
|
+
const baseNow = row && row.last_synced_platform_ts;
|
|
890
|
+
if (this._isSelfEchoAttribution({
|
|
891
|
+
platformPusher: platformLastPusher, myPusher: pushUser,
|
|
892
|
+
platLin, baseTs: baseNow, localModified: localMod,
|
|
893
|
+
})) {
|
|
894
|
+
const healTs = platformLastPushedAt || pushedAt;
|
|
895
|
+
pm._ensureDB(this.cwd).getDb().prepare(
|
|
896
|
+
'UPDATE changes SET last_synced_platform_ts = MAX(?, COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
|
|
897
|
+
).run(healTs, healTs, changeName);
|
|
898
|
+
console.warn(`⚠️ [sync] push 409 自回声判定:平台 ts=${platformLastPushedAt} 为本机自推回声(血统 ${platLin},推送者 ${platformLastPusher || '未回传·窗口判定'}),base_ts 已推进到 ${healTs},自动重推`);
|
|
899
|
+
if (attempt < MAX_PUSH_ATTEMPTS) continue;
|
|
900
|
+
// 重试额度耗尽(attempt=2 仍进此分支,理论少见):不落冲突文件——base 已推进,
|
|
901
|
+
// 下次常规同步按新 base 直接推送收敛
|
|
902
|
+
return { synced: 0, errors: [], conflict: false, selfHealed: true, reason: 'push 409 自回声判定:base_ts 已推进,重试额度耗尽,下次常规同步收敛' };
|
|
903
|
+
}
|
|
904
|
+
} catch { /* 归属判定失败维持原判(fail-closed 到真冲突分支) */ }
|
|
905
|
+
}
|
|
906
|
+
|
|
841
907
|
// resolve --keep-local 的自动重推再撞 409:不落新冲突文件、不打「已卡死」横幅——
|
|
842
908
|
// 原 conflict 已按用户裁决处理完(base_ts 已推进),此刻的 409 只是「平台在用户裁决期间
|
|
843
909
|
// 又动了」;立即落新文件会破坏「keep-local 清冲突文件回 clean」的生命周期契约(冲突文件
|
|
@@ -1193,6 +1259,14 @@ export class SyncManager {
|
|
|
1193
1259
|
'last_active', 'last_synced_platform_ts', 'last_local_modified_ts',
|
|
1194
1260
|
'started_at', 'completed_at', 'pushed_at', 'last_pushed_at', 'created_at',
|
|
1195
1261
|
'deps_checked_at', 'checked_at', 'waited_at', 'completedat',
|
|
1262
|
+
// last_pusher:服务端 GET 顶层回传的推送者元字段(ql-20260914 第二批)——与
|
|
1263
|
+
// last_pushed_at 同理是同步元数据非六表内容,不忽略会让内容一致自愈恒 false。
|
|
1264
|
+
'last_pusher',
|
|
1265
|
+
// owner_session:所有权列(v6,2026-09-14-change-ownership-guards task-02)——与
|
|
1266
|
+
// last_pusher 同理是归属元数据非六表内容:版本切换窗口(平台旧 v5 payload × 本地 v6
|
|
1267
|
+
// 脏态)owner 差异会把内容一致判不等,部署噪声落人工 conflict(last_pusher 先例
|
|
1268
|
+
// ql-20260914-006 同款一行)。
|
|
1269
|
+
'owner_session',
|
|
1196
1270
|
]);
|
|
1197
1271
|
const strip = (v) => {
|
|
1198
1272
|
if (Array.isArray(v)) return v.map(strip);
|
|
@@ -1215,6 +1289,31 @@ export class SyncManager {
|
|
|
1215
1289
|
} catch { return false }
|
|
1216
1290
|
}
|
|
1217
1291
|
|
|
1292
|
+
/**
|
|
1293
|
+
* 自回声归属判定(坑 sync-self-echo-false-conflict 身份加固,2026-09-14 第二批)。
|
|
1294
|
+
*
|
|
1295
|
+
* 身份优先——服务端回传 last_pusher(409 body / GET 顶层,服务端 ql-20260914-006-e395
|
|
1296
|
+
* 起;last_pushed_at 亦改存服务器权威钟)且本机 pushUser 可解析时:
|
|
1297
|
+
* - pusher ≠ 本人 ⇒ 一律非自回声(外来否决)——堵跨机时钟偏差把他机更新伪装进
|
|
1298
|
+
* [base, local_modified] 血统窗口的盲区(慢钟他机的血统可落窗口内,但身份对不上);
|
|
1299
|
+
* - pusher == 本人 ⇒ 只保血统上界 platLin ≤ local_modified(永不覆盖血统比自己新的
|
|
1300
|
+
* 平台状态——含本人另一台机器的新进度),下界放开(身份已证自家推送,老于 base
|
|
1301
|
+
* 的快照也只是自家旧态,重推无损)。
|
|
1302
|
+
* 任一侧身份缺失(老服务端不回传 / 本地 user 解析不出)⇒ 回退第一批双界窗口
|
|
1303
|
+
* [baseTs, localModified](纯时间血统,已是当时最优)。platLin 缺失一律 false
|
|
1304
|
+
* (fail-closed:无血统不判自回声)。
|
|
1305
|
+
* @param {{platformPusher?: string|null, myPusher?: string|null, platLin?: string|null, baseTs?: string|null, localModified?: string|null}} a
|
|
1306
|
+
* @returns {boolean} true=判为本机自推回声(可推进 base_ts 重推本地)
|
|
1307
|
+
*/
|
|
1308
|
+
_isSelfEchoAttribution({ platformPusher, myPusher, platLin, baseTs, localModified } = {}) {
|
|
1309
|
+
if (!platLin || !localModified) return false;
|
|
1310
|
+
if (platformPusher != null && myPusher != null) {
|
|
1311
|
+
if (platformPusher !== myPusher) return false; // 身份否决:外来更新
|
|
1312
|
+
return platLin <= localModified; // 本人推送:仅保上界
|
|
1313
|
+
}
|
|
1314
|
+
return !!(baseTs && platLin >= baseTs && platLin <= localModified); // 窗口回退
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1218
1317
|
_writeConflictFile(changeName, info = {}) {
|
|
1219
1318
|
const specDir = safePlatformSpecDir(this.cwd);
|
|
1220
1319
|
if (!specDir) return null;
|
|
@@ -1360,6 +1459,10 @@ export class SyncManager {
|
|
|
1360
1459
|
// 平台响应:serializeForSync 六表 + 顶层 last_pushed_at(兼容 { progress: {...} } 包裹)
|
|
1361
1460
|
const platformProgress = (result && result.project && result.changes) ? result : (result.progress || result);
|
|
1362
1461
|
const platformPushedAt = (result && result.last_pushed_at) || null;
|
|
1462
|
+
// 身份归属(ql-20260914 第二批):服务端 GET 顶层回传 last_pusher(与发送侧同口径
|
|
1463
|
+
// 的本机用户比对——platform.user || resolvePlatformUser)
|
|
1464
|
+
const platformPusher = (result && result.last_pusher) || null;
|
|
1465
|
+
const myPusher = (platform && platform.user) || resolvePlatformUser(this.cwd) || null;
|
|
1363
1466
|
|
|
1364
1467
|
// 2. 本地脏度比对(非 force 时):本地脏 AND 平台更新 → 冲突
|
|
1365
1468
|
if (!force) {
|
|
@@ -1423,6 +1526,31 @@ export class SyncManager {
|
|
|
1423
1526
|
}
|
|
1424
1527
|
} catch { /* 比对失败维持原判(fail-closed 到真冲突分支) */ }
|
|
1425
1528
|
}
|
|
1529
|
+
// ── 自回声归属(坑 sync-self-echo-false-conflict,与 push 409 路径同族,2026-09-14;
|
|
1530
|
+
// 第二批升级身份优先):平台「更新」的血统标记(changes[0].last_local_modified_ts)
|
|
1531
|
+
// 经 _isSelfEchoAttribution 判定——服务端回传 last_pusher 时身份优先(≠本人一律
|
|
1532
|
+
// 真冲突;==本人仅保血统上界 ≤ local_modified),身份缺失回退 [base, local_modified]
|
|
1533
|
+
// 双界窗口(本机自推回声:并发推送回填竞态窗口内读到自家回执)。判为自回声:
|
|
1534
|
+
// 不 import、不落冲突文件,推进 base_ts 到平台 ts——本地内容 ≥ 回声状态,下次
|
|
1535
|
+
// triggerSync 按新 base 正常推送,无损收敛。判为外来维持原冲突判定。
|
|
1536
|
+
if (localDirty && platformNewer && platformProgress && platformProgress.changes
|
|
1537
|
+
&& platformProgress.changes[0] && platformProgress.changes[0].last_local_modified_ts) {
|
|
1538
|
+
const platLin = platformProgress.changes[0].last_local_modified_ts;
|
|
1539
|
+
if (this._isSelfEchoAttribution({
|
|
1540
|
+
platformPusher: platformPusher, myPusher: myPusher,
|
|
1541
|
+
platLin, baseTs: localLastSynced, localModified: localLastModified,
|
|
1542
|
+
})) {
|
|
1543
|
+
try {
|
|
1544
|
+
const { ProgressManager } = await import('./progress.js');
|
|
1545
|
+
const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
|
|
1546
|
+
pm._ensureDB(this.cwd).getDb().prepare(
|
|
1547
|
+
'UPDATE changes SET last_synced_platform_ts = MAX(?, COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
|
|
1548
|
+
).run(platformPushedAt, platformPushedAt, changeName);
|
|
1549
|
+
} catch { /* 推进失败不影响判定结论:不 import 不落冲突,下次同步重判 */ }
|
|
1550
|
+
debugLog(`[sync] pull 自回声判定: 平台 ts ${platformPushedAt} 为本机自推回声(血统 ${platLin},推送者 ${platformPusher || '未回传·窗口判定'}),base_ts 已推进`);
|
|
1551
|
+
return { ok: true, imported: false, conflict: false, reason: '平台更新为本机自推回声(归属判定:身份/血统),base_ts 已推进' };
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1426
1554
|
if (localDirty && platformNewer) {
|
|
1427
1555
|
console.warn('');
|
|
1428
1556
|
console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');
|