sillyspec 3.28.7 → 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-quick/SKILL.md +2 -0
- package/.husky/pre-push +3 -1
- 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/agent-session-log.js +446 -35
- package/src/change-risk-profile.js +20 -0
- package/src/config-cat.js +6 -3
- package/src/config-schema.js +32 -0
- package/src/constants.js +3 -0
- package/src/db.js +23 -6
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +33 -3
- package/src/docs-check.js +137 -12
- package/src/docs-gate.js +108 -6
- package/src/doctor-diagnostics.js +243 -9
- package/src/friction-ledger.js +171 -0
- package/src/friction-tally.js +7 -2
- package/src/git-helper.js +6 -4
- package/src/hooks/worktree-guard.js +72 -30
- package/src/index.js +356 -81
- package/src/knowledge-classify.js +363 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/local-detect.js +30 -15
- package/src/local-register.js +13 -4
- package/src/mcp-server.js +38 -8
- package/src/progress/change-registry.js +224 -43
- package/src/progress/consistency-doctor.js +11 -4
- package/src/progress/shared.js +3 -2
- package/src/progress/stage-machine.js +3 -1
- package/src/progress/step-store.js +30 -17
- package/src/progress.js +123 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +199 -32
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +124 -15
- package/src/run/complete-handlers.js +514 -24
- package/src/run/complete.js +82 -25
- package/src/run/concurrent-detect.js +99 -1
- package/src/run/gate-snapshot.js +115 -5
- package/src/run/gates.js +85 -14
- package/src/run/prompt.js +143 -6
- package/src/run/quick-audit.js +136 -15
- package/src/run/shared.js +174 -20
- package/src/run/stage.js +6 -0
- package/src/run/verify-quality-scan.js +14 -3
- 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/spec-sync.js +19 -14
- package/src/stage-contract-spec.js +4 -3
- package/src/stage-contract.js +185 -2
- package/src/stage-review.js +29 -12
- package/src/stages/brainstorm.js +7 -1
- package/src/stages/execute.js +91 -4
- package/src/stages/knowledge.js +52 -3
- package/src/stages/plan-postcheck.js +57 -12
- 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 +201 -11
- package/src/task-review.js +333 -27
- package/src/verify-facts-schema.js +10 -3
- package/src/verify-postcheck.js +41 -6
- package/src/verify-probes.js +348 -7
- package/src/worktree-apply.js +594 -62
- package/src/worktree-cross.js +12 -18
- package/src/worktree-deps.js +100 -2
- package/src/worktree.js +255 -30
- package/src/wt-commit.js +128 -0
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/sa-rename-probe/src/new.js +0 -4
package/src/sync.js
CHANGED
|
@@ -12,12 +12,13 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlink
|
|
|
12
12
|
import { createHash } from 'crypto';
|
|
13
13
|
import { writeAtomicSync } from './fs-atomic.js';
|
|
14
14
|
import { join, dirname, basename, isAbsolute, relative } from 'path';
|
|
15
|
+
import { homedir } from 'os';
|
|
15
16
|
import { resolvePlatformSpecDir } from './progress.js';
|
|
16
17
|
import { safeGit } from './git-helper.js';
|
|
17
18
|
import { openDatabase } from './db-engine.js';
|
|
18
19
|
import { PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
|
|
19
20
|
import { syncSpecTree } from './spec-sync.js';
|
|
20
|
-
import { bindSyncNoiseFromCwd, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess } from './sync-noise.js';
|
|
21
|
+
import { bindSyncNoiseFromCwd, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess, syncChangeDeletedLog, syncChangeDeletedWarn, noteChangeDeletedResolved } from './sync-noise.js';
|
|
21
22
|
|
|
22
23
|
// sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
|
|
23
24
|
function safePlatformSpecDir(cwd) {
|
|
@@ -534,7 +535,20 @@ export class SyncManager {
|
|
|
534
535
|
* 不删后两者则"disconnect 后恢复本地模式"不可达(指针健在时 resolvePlatformSpecDir
|
|
535
536
|
* 仍解析平台 specRoot;声明健在时指针缺失会 fail-closed)。
|
|
536
537
|
*/
|
|
537
|
-
disconnect() {
|
|
538
|
+
disconnect(opts = {}) {
|
|
539
|
+
const { keepPointer = false } = opts
|
|
540
|
+
// 坑 platform-sync-progress-rollback-and-db-corruption 坑1③(2026-09-08 实证):三清里的
|
|
541
|
+
// 指针/声明是 daemon 内嵌 CLI 的 specDir 解析依据——指针缺席期间 daemon 心跳读仓库
|
|
542
|
+
// .sillyspec(空)并把空状态推上平台,反而放大回滚。两道防线:
|
|
543
|
+
// ① keepPointer(CLI `platform disconnect --keep-pointer`):只清 local.yaml platform
|
|
544
|
+
// 段(自己 CLI 的同步断开),指针/声明保留给 daemon——回滚环恢复序列的正规化出口;
|
|
545
|
+
// ② 默认三清前检测 daemon 主目录存在 → 醒目警告(在线 daemon 会失锚)。
|
|
546
|
+
const daemonHome = join(homedir(), '.sillyhub', 'daemon')
|
|
547
|
+
if (!keepPointer && existsSync(daemonHome)) {
|
|
548
|
+
console.warn(`⚠️ 检测到本机存在 SillyHub daemon(${daemonHome})——三清将删除平台指针,daemon 内嵌 CLI 将失锚:`)
|
|
549
|
+
console.warn(` 指针缺席期间 daemon 心跳读仓库本地 .sillyspec(可能为空)并把空状态推上平台,会放大进度回滚。`)
|
|
550
|
+
console.warn(` 若只想断开自己 CLI 的同步(保留 daemon 锚点):sillyspec platform disconnect --keep-pointer`)
|
|
551
|
+
}
|
|
538
552
|
const p = join(this.cwd, LOCAL_YAML);
|
|
539
553
|
if (existsSync(p)) {
|
|
540
554
|
const text = readFileSync(p, 'utf8');
|
|
@@ -549,6 +563,10 @@ export class SyncManager {
|
|
|
549
563
|
}
|
|
550
564
|
}
|
|
551
565
|
}
|
|
566
|
+
if (keepPointer) {
|
|
567
|
+
console.log('[sync] 已断开连接(--keep-pointer:仅清 local.yaml platform 段,指针与接管声明保留给 daemon 内嵌 CLI)');
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
552
570
|
// 三清之二/三:指针 + 接管声明(disconnect 是声明的唯一退出路径——design.md §5.4)。
|
|
553
571
|
// HUB-12 有意不清 .sillyspec-platform-cleaned marker:它防的是「重新接入平台时 init
|
|
554
572
|
// 重复做残留清理」——disconnect 后重连若清掉 marker,init 会对现在装着本地数据的
|
|
@@ -577,7 +595,7 @@ export class SyncManager {
|
|
|
577
595
|
* 外来推送(他机/他用户)不可能推进本机 DB 的 base_ts → 走原冲突路径,无误放行。
|
|
578
596
|
*/
|
|
579
597
|
async sync(changeName, opts = {}) {
|
|
580
|
-
const { fromResolve = false } = opts;
|
|
598
|
+
const { fromResolve = false, manual = false } = opts;
|
|
581
599
|
const platform = this._getPlatform();
|
|
582
600
|
if (!platform) {
|
|
583
601
|
debugLog('[sync] 未连接平台(本地合法状态);如需平台同步:sillyspec platform connect');
|
|
@@ -597,8 +615,30 @@ export class SyncManager {
|
|
|
597
615
|
if (!fromResolve) {
|
|
598
616
|
const pendingConflict = this.readConflictFile(changeName);
|
|
599
617
|
if (pendingConflict) {
|
|
600
|
-
|
|
601
|
-
|
|
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
|
+
}
|
|
602
642
|
}
|
|
603
643
|
}
|
|
604
644
|
|
|
@@ -616,7 +656,9 @@ export class SyncManager {
|
|
|
616
656
|
// change-delete(2026-08-30):DB status='deleted' 的行目录已随删除移除,属正常时序,
|
|
617
657
|
// 单行 info 提示后继续推删除终态/墓碑(不落入「目录不存在」warn)
|
|
618
658
|
if (this._changeDbStatus(changeName) === 'deleted') {
|
|
619
|
-
|
|
659
|
+
// 变更级回执噪音闸(sync-noise.js change_deleted 闸):status=deleted 是终态,此后每步
|
|
660
|
+
// 自动同步都会走到这里打同一行——首报后跨进程窗口内静默;手动 platform sync 旁路。
|
|
661
|
+
syncChangeDeletedLog(changeName, `[sync] 变更已删除(DB status=deleted),继续推送删除终态/墓碑: ${changeName}`, { noMute: manual });
|
|
620
662
|
} else {
|
|
621
663
|
archivedQuietly = existsSync(join(specBase, 'changes', 'archive', changeName))
|
|
622
664
|
|| this._changeDbStatus(changeName) === 'archived';
|
|
@@ -710,9 +752,10 @@ export class SyncManager {
|
|
|
710
752
|
|| progressData?.changes?.[0]?.status === 'archived'
|
|
711
753
|
|| progressData?.changes?.[0]?.status === 'deleted'
|
|
712
754
|
if (localTombstone) {
|
|
713
|
-
|
|
755
|
+
syncChangeDeletedLog(changeName, `ℹ️ [sync] 平台已删除变更「${changeName}」——本地注销已由 CLI 完成,墓碑上行 409 属预期回执,无需动作`, { noMute: manual });
|
|
714
756
|
} else {
|
|
715
|
-
|
|
757
|
+
// 可行动信号(本地仍 active + 平台已删):首报必须醒目,重复回执走变更级噪音闸
|
|
758
|
+
syncChangeDeletedWarn(changeName, `⚠️ [sync] 平台已删除变更「${changeName}」,本次进度上行被拒收(change_deleted);本地如仍需推进请在平台侧确认`, { noMute: manual });
|
|
716
759
|
}
|
|
717
760
|
return { synced: 0, errors: [], platformDeleted: true, reason: '平台已删除(change_deleted 拒收)' };
|
|
718
761
|
}
|
|
@@ -721,6 +764,8 @@ export class SyncManager {
|
|
|
721
764
|
// 平台连接恢复信号(sync-noise.js):此前静默窗口内的失败在恢复的第一笔成功时
|
|
722
765
|
// 打一行提示并清 marker;之后照常「成功不打扰」。
|
|
723
766
|
noteSyncSuccess();
|
|
767
|
+
// 该变更推送成功 = 平台侧未删/已重建,change_deleted 回执噪音窗按变更名精清
|
|
768
|
+
noteChangeDeletedResolved(changeName);
|
|
724
769
|
// 更新 platform_last_sync + 推进 base_ts(ql-20260818-008:值优先平台回执 last_pushed_at,
|
|
725
770
|
// 缺省用本次 pushedAt——服务器 _apply 存的就是客户端 X-SillySpec-Pushed-At 原值,回写一致。
|
|
726
771
|
// 修复前该列从不写,下次 push 永不带 X-SillySpec-Base-Ts、pull 脏度检测恒 false)
|
|
@@ -791,6 +836,9 @@ export class SyncManager {
|
|
|
791
836
|
// 平台 409 响应 { conflict:true, platform_progress, last_pushed_at },platform_progress 即平台最新 progress JSON
|
|
792
837
|
const platformProgress = res.body && res.body.platform_progress ? res.body.platform_progress : res.body;
|
|
793
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;
|
|
794
842
|
|
|
795
843
|
// 自竞态自愈:fresh 重读本机 DB base_ts,若已被并发进程回填到 ≥ 平台 409 回执 ts,
|
|
796
844
|
// 说明赢者是本机自己人——刷新 base_ts 重试一次即收敛,不落冲突文件(外来推送不可能
|
|
@@ -820,6 +868,42 @@ export class SyncManager {
|
|
|
820
868
|
}
|
|
821
869
|
} catch { /* 比对失败维持原判(fail-closed 到真冲突分支) */ }
|
|
822
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
|
+
|
|
823
907
|
// resolve --keep-local 的自动重推再撞 409:不落新冲突文件、不打「已卡死」横幅——
|
|
824
908
|
// 原 conflict 已按用户裁决处理完(base_ts 已推进),此刻的 409 只是「平台在用户裁决期间
|
|
825
909
|
// 又动了」;立即落新文件会破坏「keep-local 清冲突文件回 clean」的生命周期契约(冲突文件
|
|
@@ -1175,6 +1259,14 @@ export class SyncManager {
|
|
|
1175
1259
|
'last_active', 'last_synced_platform_ts', 'last_local_modified_ts',
|
|
1176
1260
|
'started_at', 'completed_at', 'pushed_at', 'last_pushed_at', 'created_at',
|
|
1177
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',
|
|
1178
1270
|
]);
|
|
1179
1271
|
const strip = (v) => {
|
|
1180
1272
|
if (Array.isArray(v)) return v.map(strip);
|
|
@@ -1197,6 +1289,31 @@ export class SyncManager {
|
|
|
1197
1289
|
} catch { return false }
|
|
1198
1290
|
}
|
|
1199
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
|
+
|
|
1200
1317
|
_writeConflictFile(changeName, info = {}) {
|
|
1201
1318
|
const specDir = safePlatformSpecDir(this.cwd);
|
|
1202
1319
|
if (!specDir) return null;
|
|
@@ -1342,6 +1459,10 @@ export class SyncManager {
|
|
|
1342
1459
|
// 平台响应:serializeForSync 六表 + 顶层 last_pushed_at(兼容 { progress: {...} } 包裹)
|
|
1343
1460
|
const platformProgress = (result && result.project && result.changes) ? result : (result.progress || result);
|
|
1344
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;
|
|
1345
1466
|
|
|
1346
1467
|
// 2. 本地脏度比对(非 force 时):本地脏 AND 平台更新 → 冲突
|
|
1347
1468
|
if (!force) {
|
|
@@ -1405,6 +1526,31 @@ export class SyncManager {
|
|
|
1405
1526
|
}
|
|
1406
1527
|
} catch { /* 比对失败维持原判(fail-closed 到真冲突分支) */ }
|
|
1407
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
|
+
}
|
|
1408
1554
|
if (localDirty && platformNewer) {
|
|
1409
1555
|
console.warn('');
|
|
1410
1556
|
console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');
|
|
@@ -1723,7 +1869,51 @@ export class SyncManager {
|
|
|
1723
1869
|
const cf = this.readConflictFile(changeName);
|
|
1724
1870
|
const specCf = this.readSpecConflictFile(changeName);
|
|
1725
1871
|
if (!cf && !specCf) {
|
|
1726
|
-
|
|
1872
|
+
// 坑 platform-sync-progress-rollback-and-db-corruption 坑1②(2026-09-08 实证):回滚环
|
|
1873
|
+
// 中间态——冲突文件已被某次常规同步静默清除,但 DB 的 base_ts 仍落后,keep-local
|
|
1874
|
+
// 找不到裁决目标被拒(「无可解决冲突」),恢复序列被迫走人肉 SQL。显式出口:keep-local
|
|
1875
|
+
// 在无冲突文件时降级为「无冲突强推」——拉平台 last_pushed_at 推进 base_ts(与有冲突
|
|
1876
|
+
// 路径同款 MAX/COALESCE 单调 SQL)+ 自动重推闭环,等价完成裁决意图。take-platform
|
|
1877
|
+
// 依赖冲突文件里的 platform_progress 快照,无文件时仍拒绝(无可覆盖源)。
|
|
1878
|
+
if (mode !== 'keep-local') {
|
|
1879
|
+
return { ok: false, resolved: false, reason: `无可解决冲突: ${changeName}(无 sync-conflict / spec-sync-conflict 文件)` };
|
|
1880
|
+
}
|
|
1881
|
+
const platform = this._getPlatform();
|
|
1882
|
+
if (!platform) {
|
|
1883
|
+
return { ok: false, resolved: false, reason: `无可解决冲突且未连接平台: ${changeName}——keep-local 无冲突强推需拉平台 last_pushed_at` };
|
|
1884
|
+
}
|
|
1885
|
+
let pushedAt = null;
|
|
1886
|
+
try {
|
|
1887
|
+
const res = await fetchJson(
|
|
1888
|
+
`${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`,
|
|
1889
|
+
{ headers: { Authorization: `Bearer ${platform.token}` } },
|
|
1890
|
+
);
|
|
1891
|
+
pushedAt = (res && res.last_pushed_at) || null;
|
|
1892
|
+
} catch { /* 拉取失败按 null:仅跳过 base_ts 推进,重推仍可尝试 */ }
|
|
1893
|
+
if (pushedAt) {
|
|
1894
|
+
try {
|
|
1895
|
+
const { ProgressManager } = await import('./progress.js');
|
|
1896
|
+
const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
|
|
1897
|
+
const db = pm._ensureDB(this.cwd).getDb();
|
|
1898
|
+
db.prepare('UPDATE changes SET last_synced_platform_ts = MAX(COALESCE(?, last_synced_platform_ts), COALESCE(last_synced_platform_ts, ?)) WHERE name = ?')
|
|
1899
|
+
.run(pushedAt, pushedAt, changeName);
|
|
1900
|
+
} catch (err) {
|
|
1901
|
+
return { ok: false, resolved: false, reason: `无冲突 keep-local 推进 base_ts 失败: ${err.message}` };
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
let repush = null;
|
|
1905
|
+
try {
|
|
1906
|
+
repush = await this.sync(changeName, { fromResolve: true });
|
|
1907
|
+
} catch (err) {
|
|
1908
|
+
debugLog(`[sync] 无冲突 keep-local 重推异常: ${changeName}: ${err.message}`);
|
|
1909
|
+
}
|
|
1910
|
+
if (repush && repush.synced === 1) {
|
|
1911
|
+
return { ok: true, resolved: true, mode: 'keep-local-noconflict', reason: '无冲突显式 keep-local:base_ts 已对齐平台最新,本地已重推——回滚环收敛出口' };
|
|
1912
|
+
}
|
|
1913
|
+
if (repush && repush.conflict) {
|
|
1914
|
+
return { ok: true, resolved: true, mode: 'keep-local-noconflict', reason: '无冲突显式 keep-local:base_ts 已推进;重推被拒(平台刚又有更新,已落冲突文件)——再跑一次 resolve --keep-local 即可' };
|
|
1915
|
+
}
|
|
1916
|
+
return { ok: true, resolved: true, mode: 'keep-local-noconflict', reason: '无冲突显式 keep-local:base_ts ' + (pushedAt ? '已推进' : '未获取(平台拉取失败)') + ';重推未成功(未连接/网络),请手动 sillyspec platform sync --change ' + changeName };
|
|
1727
1917
|
}
|
|
1728
1918
|
const platformPushedAt = (cf && cf.platform_last_pushed_at) || null;
|
|
1729
1919
|
|
|
@@ -1954,8 +2144,8 @@ export async function connect(url, token, cwd) {
|
|
|
1954
2144
|
return new SyncManager(cwd).connect(url, token);
|
|
1955
2145
|
}
|
|
1956
2146
|
|
|
1957
|
-
export async function disconnect(cwd) {
|
|
1958
|
-
return new SyncManager(cwd).disconnect();
|
|
2147
|
+
export async function disconnect(cwd, opts = {}) {
|
|
2148
|
+
return new SyncManager(cwd).disconnect(opts);
|
|
1959
2149
|
}
|
|
1960
2150
|
|
|
1961
2151
|
export async function sync(changeName, cwd, opts) {
|