From 5d2705129337ab994a4d09f5288ee1bf02884ecd Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 09:34:29 +0000 Subject: [PATCH] =?UTF-8?q?fix(gitea):=20push=20=E6=92=9E=E5=88=B0?= =?UTF-8?q?=E5=88=A5=E5=8F=B0=E6=A9=9F=E5=99=A8=E6=99=82=E6=98=AF=E6=9C=AC?= =?UTF-8?q?=E6=A9=9F=E8=B4=8F=EF=BC=8C=E4=BD=86=E4=B8=8D=E8=83=BD=E9=9D=9C?= =?UTF-8?q?=E9=9D=9C=E5=9C=B0=E8=B4=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pushArea` 遇到 non-fast-forward 時會 `git reset --hard origin/`、 把本機工作副本重新疊上去再推一次,然後回 `{ok:true, changed:true}`—— 沒有任何衝突訊號。而 Stop hook 每一輪都在背景 push,所以兩台機器同時聊同一個 人格時,對方的 emotion.json/short-term.jsonl 會被靜默取代,誰都不知道。 「本機工作副本是這台機器的真相來源」這個設計選擇保留,但那條路徑現在要記帳: * 算出「對方在分歧後改過、而我們正要蓋掉」的檔案交集,連同覆蓋前的遠端 sha 一起回傳 `overwrote`,並寫進 state/sync.json(留最近 10 筆)。 * `sync push` 一律往 stderr 寫一行警告(--quiet 也寫,背景 push 才有痕跡), 正常輸出與 `sync status` 都列得出「蓋掉幾個檔案、上一版是誰」。 * 背景 push 是 detached、輸出丟掉的,所以由 Stop hook 認領未回報的紀錄, 講給使用者聽一次(劇場模式也照講——那是資料被蓋掉)。 * commit 訊息也寫進「覆蓋 N 個檔案,上一版 」,被蓋掉的內容仍可用 `git -C <人格>/.sync/<區> show :<檔案>` 取回。 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 ++++ hooks/turn_end.mjs | 13 ++++++++++ scripts/persona-gitea.mjs | 46 +++++++++++++++++++++++++++++++++--- scripts/persona.mjs | 25 +++++++++++++++++++- skills/persona-sync/SKILL.md | 12 ++++++++++ 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2dae01e..6d10c2e 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,10 @@ node scripts/persona.mjs sync verify --session --area wiki # 確認 Wiki - **本機永遠是工作副本**:hook 每輪讀寫本機檔案,不經網路;Gitea 掛掉照樣能聊天。 **同步失敗永遠不阻斷對話。** - 載入人格時會先 `pull`;兩邊都改過同一個檔案就**停下來不覆蓋本機**,由使用者決定保留哪一邊。 +- `push` 撞到「遠端比較新」時以**本機為準**覆蓋遠端(本機才是這台機器的真相來源), + 但會回報**蓋掉哪些檔案、上一版是哪個 commit**,記在 `state/sync.json`, + `sync status` 列得出來、下一輪的 `Stop` hook 提醒一次;舊版仍可從 clone 的歷史取回。 +- 發新編號前會先問遠端已經用掉哪些編號——`nextCode` 只看本機,換一台機器會重複發號。 - Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面(1.27 實測子目錄頁面 404),所以 `memory/long-term/xxx.md` 攤平成 `Memory-xxx.md`,原始路徑記在 `_paths.json`; Wiki 首頁自動列出所有長期記憶的連結,變成真的讀得下去的「設定百科」。 diff --git a/hooks/turn_end.mjs b/hooks/turn_end.mjs index 26bc45e..348a136 100644 --- a/hooks/turn_end.mjs +++ b/hooks/turn_end.mjs @@ -45,11 +45,24 @@ if (!gt.giteaProblem() && gt.personaCode(host) && gt.pushDue(host, "files")) { } const out = { suppressOutput: true }; +// 背景 push 撞到別台機器時是「本機覆蓋遠端」。那條路徑會把帳記在 sync.json, +// 但它是 detached 跑的、輸出丟掉,所以由這裡認領並回報一次——劇場模式也照報(那是資料被蓋掉)。 +const overwrites = gt.pendingOverwrites(host); +if (overwrites.length) { + gt.markOverwritesReported(host); + const files = [...new Set(overwrites.flatMap((o) => o.files || []))]; + const last = overwrites.at(-1); + out.systemMessage = + `[jsc-persona] ⚠ \`${host}\` 同步到 Gitea 時**以本機為準覆蓋了遠端** ${files.length} 個檔案` + + `(${files.slice(0, 5).join(", ")}${files.length > 5 ? "…" : ""}),上一版是 ${String(last.previous).slice(0, 8)}。` + + "別台機器可能正在用同一個人格;細節見 `sync status`。"; +} if (!theater) { const { total, candidates } = pl.promotionCandidates(host); if (candidates.length) { const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/"); out.systemMessage = + `${out.systemMessage ? `${out.systemMessage}\n` : ""}` + `[jsc-persona] \`${host}\` 短期記憶 ${total} 筆,${candidates.length} 組已達固化條件(${rules})` + "→ 建議執行 /jsc-persona:persona-memory。"; } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index d0a3230..bb2126c 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -19,6 +19,8 @@ import * as pl from "./persona-lib.mjs"; export const SYNC_DIRNAME = ".sync"; export const DEFAULT_MIN_PUSH_SECONDS = 60; +// sync.json 裡「本機覆蓋遠端」的紀錄留幾筆 +export const OVERWRITE_LOG_KEEP = 10; // --------------------------------------------------------------------------- // // 人格編號:英文名全大寫 + 兩位索引(同名才遞增) @@ -608,11 +610,21 @@ export async function pushArea(slug, area, { message = "", code = null, owner = } gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit"); let pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); + let overwrote = null; if (!pushed.ok) { // 通常是別台機器先推了(non-fast-forward)。工作副本才是這台機器的真相來源, // 所以對齊遠端後把本機內容重新疊上去再推一次;真的有人同時在用,load 時的 pull 會擋下來。 + // + // 但這條路徑**等同 force**:對方推上去的內容會被本機取代。所以要算出「蓋掉了哪幾個檔案、 + // 上一版是哪個 commit」,一路回報到 sync 狀態裡——這條路可以走,但不能安靜地走。 const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) { + const previous = git(["rev-parse", `origin/${branch}`], dir).stdout; + const base = git(["merge-base", "HEAD", `origin/${branch}`], dir).stdout; + // 分歧之後「對方」動過的檔案 + const theirs = new Set( + base ? diffPaths(dir, [base, `origin/${branch}`]) : [], + ); git(["reset", "--hard", "--quiet", `origin/${branch}`], dir); stageArea(slug, area, dir); if (area === "wiki") { @@ -621,8 +633,13 @@ export async function pushArea(slug, area, { message = "", code = null, owner = } clearStaleIndexLock(dir); git(["add", "-A"], dir); - if (!git(["diff", "--cached", "--quiet"], dir).ok) { - git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir); + // 疊上本機工作副本後仍與遠端不同的檔案 = 這次要改寫的;其中對方也動過的 = 真的被蓋掉的 + const ours = diffPaths(dir, ["--cached"]); + const clobbered = ours.filter((f) => theirs.has(f)).sort(); + if (clobbered.length) overwrote = { files: clobbered, previous, branch, area }; + if (ours.length) { + git(["commit", "-q", "-m", + `${message || "sync"}(以本機為準覆蓋遠端 ${clobbered.length} 個檔案,上一版 ${previous.slice(0, 8)})`], dir); } } pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); @@ -632,8 +649,31 @@ export async function pushArea(slug, area, { message = "", code = null, owner = state.code = theCode; state.owner = theOwner; state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length }; + if (overwrote) { + // 每輪對話後的 push 是背景執行、輸出丟掉的,所以覆蓋紀錄一定要落地: + // 留在 sync.json 裡等人來認領(`sync status` 會列,Stop hook 會提醒一次)。 + state.overwrites = [...(state.overwrites || []), { at: pl.nowIso(), ...overwrote }].slice(-OVERWRITE_LOG_KEEP); + } saveSyncState(slug, state); - return { ok: true, changed: true, files: staged.length, area, code: theCode }; + return { ok: true, changed: true, files: staged.length, area, code: theCode, overwrote }; +} + +/** 還沒回報給使用者的「本機覆蓋遠端」紀錄。 */ +export function pendingOverwrites(slug) { + return (loadSyncState(slug).overwrites || []).filter((entry) => !entry.reported_at); +} + +/** 標記為已回報(同一次覆蓋只吵一次)。回傳這次標掉幾筆。 */ +export function markOverwritesReported(slug) { + const state = loadSyncState(slug); + let marked = 0; + for (const entry of state.overwrites || []) { + if (entry.reported_at) continue; + entry.reported_at = pl.nowIso(); + marked += 1; + } + if (marked) saveSyncState(slug, state); + return marked; } /** diff --git a/scripts/persona.mjs b/scripts/persona.mjs index b45046a..20b9ea5 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -1853,6 +1853,7 @@ commands.sync = async ({ flags, positional }) => { if (action === "status") { const state = gt.loadSyncState(slug); const problem = gt.giteaProblem(); + const overwrites = state.overwrites || []; emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [ `人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}|Gitea ${problem ? `⚠ ${problem}` : "✔ 已設定"}`, ` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`)"}`, @@ -1861,6 +1862,15 @@ commands.sync = async ({ flags, positional }) => { return ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):` + `最後 push ${info.pushed_at || "—"}|最後 pull ${info.pulled_at || "—"}|${info.files ?? "?"} 個檔案`; }), + // 「本機是真相來源」的代價:push 撞到別台機器時是本機贏。贏了要記帳。 + ...(overwrites.length + ? [" ⚠ 本機曾覆蓋遠端(別台機器同時在用同一個人格):"] + .concat(overwrites.slice(-5).map((o) => + ` ${o.at} ${gt.AREAS[o.area]?.label || o.area} ${o.files.length} 個檔案` + + `(${o.files.slice(0, 4).join(", ")}${o.files.length > 4 ? "…" : ""})` + + ` 上一版 ${String(o.previous || "").slice(0, 8)}`)) + .concat([` 要看回被蓋掉的內容:\`git -C ${gt.syncDir(slug, overwrites.at(-1).area)} show <上一版>:<檔案>\``]) + : []), ]); return; } @@ -1892,9 +1902,20 @@ commands.sync = async ({ flags, positional }) => { out.push({ area: key, ok: false, reason: err.message }); } } + // 覆蓋遠端這件事在 --quiet(背景 push)下也要留下痕跡:stderr 一定寫,sync.json 也記著 + for (const r of out) { + if (!r.overwrote) continue; + process.stderr.write( + `⚠ ${gt.AREAS[r.area].label}:以本機為準覆蓋了遠端 ${r.overwrote.files.length} 個檔案` + + `(${r.overwrote.files.slice(0, 6).join(", ")}),上一版是 ${String(r.overwrote.previous).slice(0, 8)}。\n` + + ` 別台機器可能正在用同一個人格。要看回被蓋掉的內容:` + + `git -C ${gt.syncDir(slug, r.area)} show ${String(r.overwrote.previous).slice(0, 8)}:<檔案>\n`, + ); + } emit({ persona: slug, results: out }, flags.json, out.map((r) => r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason})` - : r.ok ? `✔ ${gt.AREAS[r.area].label}:${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}` + : r.ok ? `✔ ${gt.AREAS[r.area].label}:${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}` + + (r.overwrote ? ` ⚠ 其中覆蓋了遠端 ${r.overwrote.files.length} 個檔案(上一版 ${String(r.overwrote.previous).slice(0, 8)})` : "") : `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`)); return; } @@ -2045,6 +2066,8 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步) 檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push + push 撞到別台機器時以**本機為準**覆蓋遠端,並回報蓋掉哪些檔案與上一版 sha + (sync.json 記帳、sync status 列得出來、下一輪的 Stop hook 會提醒一次) 環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER), PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔 diff --git a/skills/persona-sync/SKILL.md b/skills/persona-sync/SKILL.md index b4cb5eb..8fb4011 100644 --- a/skills/persona-sync/SKILL.md +++ b/skills/persona-sync/SKILL.md @@ -104,6 +104,18 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync pull --session /.sync/files show <上一版 sha>:<檔案>`。 + +看到這種回報就**告訴使用者**:另一台機器可能正在用同一個人格。要救回舊版就從上面那行取出來。 + ## 殘留的 git 鎖 `push`/`pull` 跑到一半被中斷(sub agent 被砍、視窗關掉)會在 clone 裡留下 `.git/index.lock`,