fix(sleep): sleeper 讀得到自己的資料、sync 自己清殘留鎖(v0.1.1)

睡眠的「判斷式那半」實際上跑不起來,兩個原因:

1. `requireMember` 沒有 sleeper 分支——`requireOwner` 早就放 sleeper 寫了,
   讀(brief/show/recall)反而被當成跨人格存取擋下,等於要它閉著眼睛
   決定要固化什麼。補上分支,回傳 role `sleeper`(下游只判斷 `guest`,
   所以行為與 owner 一致)。

2. `sync` 遇到殘留的 `.git/index.lock` 會讓整區永遠失敗,而 sleeper 被隔離
   hook 擋著、連自己的鎖都刪不掉(CLI 也沒有清鎖的功能)。這些 clone 只有
   CLI 會動且每個 git 都同步跑完,所以超過 30 秒還在的鎖一定是殘骸——
   `pushArea`(兩處)與 `pullArea` 現在會自己清掉再繼續。

技能文件補上先前沒寫的部分:

- `persona-relation`:整個 bond/語氣層章節。這個功能 7c46db8 就上線,但技能
  文件從頭到尾沒提過,於是每個新建的至親都預設是生人(`note` 寫「妻子」猜不出
  `partner`,關鍵詞只認夫妻/老婆/伴侶/戀人)——症狀是「講話像在對戰友報告」。
  一併補上 `relation style` 與 `relation speaker` 的用法。
- `persona-sleep`:說明帶了身分旗標之後讀寫都算自己的資料;新增第 4 條硬相依
  ——判斷式失敗就不要跑機械性那半,`sleep` 會裁短期記憶,否則等於沒判斷就把
  今天清掉。
- `persona-sync`:新增「殘留的 git 鎖」一節。

版本號 0.0.5 → 0.1.1(master 上是 0.1.0,要跨過它),兩份 manifest 一起改。
selftest 238 項全過;另以獨立腳本(暫時 PERSONA_HOME)驗證修補前後 sleeper
的 brief/show/recall 由全數被擋變為全數通過。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 09:09:36 +00:00
co-authored by Claude Opus 5
parent 7c46db8e9a
commit 41e57432ea
7 changed files with 102 additions and 5 deletions
+26
View File
@@ -258,6 +258,29 @@ function gitOrThrow(args, cwd, what) {
return res;
}
// 這些 clone 只有本 CLI 會動,而且每個 git 都是同步跑完才回來,所以一個超過
// STALE_LOCK_SECONDS 還在的 index.lock 一定是上一次跑到一半被砍掉留下的殘骸。
// 不自己清的話整區會一直失敗,而 sleeper 被隔離 hook 擋著、連自己的鎖都刪不掉。
const STALE_LOCK_SECONDS = 30;
export function clearStaleIndexLock(dir) {
const lock = path.join(dir, ".git", "index.lock");
let stat;
try {
stat = fs.statSync(lock);
} catch {
return null;
}
const ageSeconds = (Date.now() - stat.mtimeMs) / 1000;
if (ageSeconds < STALE_LOCK_SECONDS) return null;
try {
fs.rmSync(lock);
} catch (err) {
return { cleared: false, ageSeconds, reason: String(err.message || err) };
}
return { cleared: true, ageSeconds };
}
/** 確保 `<persona>/.sync/<area>/` 是該區的 clone;空存取庫也能處理。 */
export function ensureClone(slug, area, url) {
const dir = syncDir(slug, area);
@@ -546,6 +569,7 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
}
const staged = stageArea(slug, area, dir);
clearStaleIndexLock(dir);
gitOrThrow(["add", "-A"], dir, "git add");
const dirty = git(["diff", "--cached", "--quiet"], dir);
if (dirty.ok) {
@@ -567,6 +591,7 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
}
clearStaleIndexLock(dir);
git(["add", "-A"], dir);
if (!git(["diff", "--cached", "--quiet"], dir).ok) {
git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir);
@@ -596,6 +621,7 @@ export async function pullArea(slug, area, { code = null, owner = null, force =
const { host } = giteaEnv();
const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
stageArea(slug, area, dir); // 先把本機現況放進 clone,才看得出本機動過什麼
clearStaleIndexLock(dir);
const localChanged = new Set(
git(["status", "--porcelain"], dir)
.stdout.split("\n")
+7
View File
@@ -159,6 +159,13 @@ function requireMember(slug, sessionId, asGuest = false) {
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。`);
const data = pl.loadSession(sessionId);
// sleeper 讀自己的資料:`requireOwner` 早就放它寫了,讀反而被擋在外面——
// 少了這個分支,它就得在看不到 brief/recall 的情況下決定要固化什麼。
if (CURRENT_FLAGS["as-sleeper"]) {
if (asGuest) die("`--as-sleeper` 與 `--as-guest` 不能一起用(那是兩種不同的身分)。");
sleeperAccess(slug, sessionId, str(CURRENT_FLAGS["agent-id"]));
return [data, "sleeper"];
}
if (data.host === slug) {
if (asGuest) die(`\`${slug}\` 是本 session 的 host 人格,不需要也不得使用 \`--as-guest\``);
pl.heartbeatLock(slug, sessionId);