fix(role): 修好睡眠整理死鎖 —— 素材不再硬切、批次降為 4、錯誤訊息可診斷

角色整晚沒睡:05:03 到 08:33 每 10 分鐘試一次,連續 22 次全部失敗,
last_sleep 停在前一天 15:02,inbox 從 12 累積到 23 則。
錯誤訊息只有一句「整理結果無法套用」,看不出任何原因。

診斷過程中推論錯了三次,過程留在註解裡(每一次都很容易再犯):

1. 推論「輸出被 SLEEP_OUTPUT_LIMIT 截斷」→ 實測輸出僅 5772~6224,遠未達 8000。錯。
2. 推論「素材字元數過大」→ 實測素材 8815(6 則)失敗、8940(3 則)成功,
   字元數幾乎相同。錯。
3. 推論「純粹是則數問題」→ 對一半:另有一種失敗是 CLI 回傳 Execution error。

真相是兩種失敗混在一起,而錯誤訊息把它們蓋成同一句話:
- CLI 偶發 Execution error → 需要重試
- 一次要求模型輸出太多筆 JSON → 需要降批

修正:

- 錯誤訊息附上素材大小、輸出長度、批次與輸出前 200 字元(已 redact)
  —— 這是最先做的一步,沒有它只能靠猜
- cmdCollect 不再 slice() 硬切素材:改為逐則累加、超出預算留到下批,
  並替 EXISTING 保留固定比例預算。舊版會切在記憶中間、甚至切掉整個 EXISTING 區塊,
  而批次固定時每輪都收到同樣殘缺的素材 → 死鎖
- SLEEP_BATCH 12 → 4(實測 3~4 則穩定、6 則以上開始失敗)
- 失敗時逐次降批(4→2→1)作為保險,仍失敗才留到下個週期
- 批次預設值統一來源:role_sleep.sh 首次收集不傳 --batch,實際則數由素材反推
  —— 原本兩邊各寫一個預設,改了 memory.js 的 SLEEP_BATCH 卻不會生效

實測:修正後第一次嘗試即成功,未觸發降批;連續整理清空 inbox(23 → 0),
情緒型態記憶由 1 增至 9,關係史累積 13 則。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-30 10:22:23 +08:00
co-authored by Claude Opus 5
parent adc5521302
commit 9512293dc9
6 changed files with 178 additions and 71 deletions
+68 -36
View File
@@ -7,7 +7,7 @@
# 另提供 --nap(CLI 閒置時的小睡整理)、--catchup(cron 未執行時的補跑)、
# --force(手動立即整理)、--export(匯出角色壓縮檔)、
# --install-cron--remove-cron(排程安裝與移除)、--status(狀態)。
# 更新時間:2026/07/29 18:39:54
# 更新時間:2026/07/30 10:22:03
# 相依:bash、node、任一 headless CLI、crontab(僅排程安裝需要)、
# 同目錄的 role_lib.sh 與 memory.js。
# 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。
@@ -103,31 +103,9 @@ role_sleep_child_running() {
# 整理主流程
# ------------------------------------------------------------------------------
sleep_cycle() {
# 執行一次完整記憶整理:收集素材 → NREM 鞏固 → REM 整合 → 落檔歸檔 → 遺忘
local reason="$1" cli material prompt result applied forgotten
command -v node >/dev/null 2>&1 || { role_log "ERR" "找不到 node,無法整理記憶"; return 1; }
if ! role_lock_acquire "$ROLE"; then
role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE}"
return 0
fi
trap 'role_lock_release "$ROLE"' EXIT
material="$(node "${SCRIPT_DIR}/memory.js" collect --role "$ROLE" 2>/dev/null)"
if [ -z "$material" ]; then
role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason}"
node "${SCRIPT_DIR}/memory.js" mark-sleep --role "$ROLE" >/dev/null 2>&1
forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null)"
role_log "INF" "遺忘檢查:${forgotten}"
role_lock_release "$ROLE"
trap - EXIT
return 0
fi
cli="$(role_select_cli)" || { role_lock_release "$ROLE"; trap - EXIT; return 1; }
prompt="$(cat <<EOF_PROMPT
build_sleep_prompt() {
# 依目前的 $material 組出整理提示詞。抽成函式是為了讓降批重試能重新組 prompt。
cat <<EOF_PROMPT
你是角色「${ROLE}」的睡眠記憶整理器。輸入包含兩段:INBOX(本次待整理的記憶)與 EXISTING(既有記憶索引)。
請模擬睡眠中的兩階段記憶整理,但最後只輸出一個 JSON 物件。
@@ -200,32 +178,86 @@ sleep_cycle() {
素材:
${material}
EOF_PROMPT
)"
}
# 重試一次:實測整理會偶發失敗(CLI 輸出空或 JSON 不合法),素材本身沒問題 ——
# 同一批素材重跑就成功。沒有重試時,該角色要等到下一個週期才會再被整理;
# 多角色模式下這個代價更大(一個角色失敗不該讓它整晚都沒睡)。
local attempt=1 max_attempts=2
sleep_cycle() {
# 執行一次完整記憶整理:收集素材 → NREM 鞏固 → REM 整合 → 落檔歸檔 → 遺忘
local reason="$1" cli material prompt result applied forgotten
command -v node >/dev/null 2>&1 || { role_log "ERR" "找不到 node,無法整理記憶"; return 1; }
if ! role_lock_acquire "$ROLE"; then
role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE}"
return 0
fi
trap 'role_lock_release "$ROLE"' EXIT
# 首次收集刻意**不傳 --batch**:批次預設值只能有一個來源(memory.js 的 SLEEP_BATCH),
# 這裡再寫一個預設就會變成兩個各自為政的值 —— 實測就發生過:memory.js 已改成 4
# 但本檔硬編碼的 12 讓實際批次還是 12,改了等於沒改。
# 實際則數改由素材反推,降批時才明確指定。
local batch
material="$(node "${SCRIPT_DIR}/memory.js" collect --role "$ROLE" 2>/dev/null)"
batch="$(printf '%s' "$material" | grep -c '^--- id:')"
[ "$batch" -ge 1 ] 2>/dev/null || batch=1
if [ -z "$material" ]; then
role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason}"
node "${SCRIPT_DIR}/memory.js" mark-sleep --role "$ROLE" >/dev/null 2>&1
forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null)"
role_log "INF" "遺忘檢查:${forgotten}"
role_lock_release "$ROLE"
trap - EXIT
return 0
fi
cli="$(role_select_cli)" || { role_lock_release "$ROLE"; trap - EXIT; return 1; }
prompt="$(build_sleep_prompt)"
# 重試並**逐次降批**。
#
# 為什麼一定要降批:整理結果會被 head -c SLEEP_OUTPUT_LIMIT 截斷,截斷後 JSON 不合法、
# apply 必然失敗。若批次大小固定,下一輪又收集到同一批素材、產生同樣過長的輸出 ——
# 於是變成**死鎖**:實測連續 22 次全部失敗,inbox 從 12 累積到 23 則,
# 角色整晚沒睡而且錯誤訊息只寫「無法套用」,看不出原因。
# 單純重試只能救偶發失敗;素材過大屬於必然失敗,只有把批次變小才會前進。
local attempt=1 max_attempts=4 out_len=0
applied=""
while [ "$attempt" -le "$max_attempts" ]; do
while :; do
result="$(role_run_cli "$cli" "$prompt" "$SLEEP_TIMEOUT")"
if [ -z "$result" ]; then
role_log "WRN" "整理結果為空(CLI ${cli},第 ${attempt}/${max_attempts} 次)"
role_log "WRN" "整理結果為空(CLI ${cli},第 ${attempt}/${max_attempts},批次 ${batch}"
else
out_len="${#result}"
result="$(printf '%s' "$result" | head -c "$SLEEP_OUTPUT_LIMIT" | node "${SCRIPT_DIR}/transcript.js" redact 2>/dev/null)"
applied="$(printf '%s' "$result" | node "${SCRIPT_DIR}/memory.js" apply --role "$ROLE" 2>/dev/null)"
[ -n "$applied" ] && break
role_log "WRN" "整理結果無法套用(角色 ${ROLE},第 ${attempt}/${max_attempts} 次)"
# 明確指出「是不是被截斷」,否則下一個人只會看到「無法套用」而查不出原因
if [ "$out_len" -ge "$SLEEP_OUTPUT_LIMIT" ]; then
role_log "WRN" "整理結果達輸出上限 ${SLEEP_OUTPUT_LIMIT} 被截斷(角色 ${ROLE},素材 ${#material} 字元,批次 ${batch}),JSON 不完整"
else
# 附上輸出開頭:只寫「無法套用」的話,下一個人無從判斷是 JSON 壞了、
# 模型答非所問,還是模型根本拒絕處理。已經過 redact,不含憑證與個資。
role_log "WRN" "整理結果無法套用(角色 ${ROLE},輸出 ${out_len} 字元,素材 ${#material} 字元,批次 ${batch},第 ${attempt}/${max_attempts} 次):$(printf '%s' "$result" | tr '\n' ' ' | head -c 200)"
fi
fi
attempt=$((attempt + 1))
[ "$attempt" -gt "$max_attempts" ] && break
if [ "$batch" -gt 1 ]; then
batch=$((batch / 2))
[ "$batch" -lt 1 ] && batch=1
material="$(node "${SCRIPT_DIR}/memory.js" collect --role "$ROLE" --batch "$batch" 2>/dev/null)"
[ -n "$material" ] || break
prompt="$(build_sleep_prompt)"
role_log "INF" "改用較小批次重試(角色 ${ROLE},批次 ${batch},素材 ${#material} 字元)"
fi
done
if [ -z "$applied" ]; then
role_log "ERR" "整理失敗 ${max_attempts} 次(角色 ${ROLE}),保留待整理記憶到下個週期"
role_log "ERR" "整理失敗(角色 ${ROLE},已降到批次 ${batch}),保留待整理記憶到下個週期"
role_lock_release "$ROLE"
trap - EXIT
return 1
fi
[ "$attempt" -gt 1 ] && role_log "INF" "${attempt} 次嘗試成功(角色 ${ROLE}"
[ "$attempt" -gt 1 ] && role_log "INF" "${attempt} 次嘗試成功(角色 ${ROLE},批次 ${batch}"
role_log "INF" "記憶整理完成(角色 ${ROLE},觸發:${reason}):${applied}"
forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null | tr '\n' '')"