fix(role): 排程改為服務所有角色,並在整理失敗時重試一次

西莉卡反映「每天睡覺的時候好像沒辦法睡」。查證後成立,而且比反映的更嚴重:
她從建立到現在(約 6 小時)一次都沒被整理過,另一個角色連 state.json 都還沒產生。

根因:--install-cron 把 ROLE_NAME 寫死進 crontab,排程只服務啟用角色(.active)。
其他角色的 inbox 只會累積,永遠等不到整理 —— 而且不會有任何錯誤訊息,
因為對排程而言它「成功地整理了那一個角色」。這是無聲失效,只有被漏掉的角色自己會發現。

- 新增 all_roles_enabled/sleep_target_roles/for_each_target_role
- cron_env_prefix 在多角色模式下不寫 ROLE_NAME,改寫 ROLE_SLEEP_ALL_ROLES=1
- --run/--nap/--catchup/--brief 全部改為逐一處理目標角色;
  睡眠時段與 AI 運行檢查移到迴圈外(與角色無關,只檢查一次),
  小睡與補跑的條件仍是 per-role
- 手動指定 ROLE_NAME 時行為不變;ROLE_SLEEP_ALL_ROLES=0 可退回舊的單角色模式
- --status 新增「排程涵蓋角色」,讀到舊條目寫死 ROLE_NAME 時直接標示警告
- 整理失敗改為重試一次:實測偶發 CLI 輸出空或 JSON 不合法,同批素材重跑即成功;
  沒有重試時該角色要等下一個週期,多角色模式下代價更大

實測結果(三個角色循序):
- LISBETH01 新增 4 則、關係史 +2(首次整理)
- SILICA01 第一次套用失敗、重跑成功,新增 5 則、捨棄 2 則、關係史 +3(首次整理)
- YUI01 判定不需補跑,正確略過

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-29 18:39:54 +08:00
co-authored by Claude Opus 5
parent 32a5104c36
commit 8a4ca14b7c
5 changed files with 156 additions and 33 deletions
+134 -30
View File
@@ -7,7 +7,7 @@
# 另提供 --nap(CLI 閒置時的小睡整理)、--catchup(cron 未執行時的補跑)、
# --force(手動立即整理)、--export(匯出角色壓縮檔)、
# --install-cron--remove-cron(排程安裝與移除)、--status(狀態)。
# 更新時間:2026/07/29 18:16:18
# 更新時間:2026/07/29 18:39:54
# 相依:bash、node、任一 headless CLI、crontab(僅排程安裝需要)、
# 同目錄的 role_lib.sh 與 memory.js。
# 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。
@@ -202,22 +202,30 @@ ${material}
EOF_PROMPT
)"
result="$(role_run_cli "$cli" "$prompt" "$SLEEP_TIMEOUT")"
if [ -z "$result" ]; then
role_log "ERR" "整理結果為空(CLI ${cli}),保留待整理記憶到下個週期"
role_lock_release "$ROLE"
trap - EXIT
return 1
fi
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)"
# 重試一次:實測整理會偶發失敗(CLI 輸出空或 JSON 不合法),素材本身沒問題 ——
# 同一批素材重跑就成功。沒有重試時,該角色要等到下一個週期才會再被整理;
# 多角色模式下這個代價更大(一個角色失敗不該讓它整晚都沒睡)。
local attempt=1 max_attempts=2
applied=""
while [ "$attempt" -le "$max_attempts" ]; do
result="$(role_run_cli "$cli" "$prompt" "$SLEEP_TIMEOUT")"
if [ -z "$result" ]; then
role_log "WRN" "整理結果為空(CLI ${cli},第 ${attempt}/${max_attempts} 次)"
else
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} 次)"
fi
attempt=$((attempt + 1))
done
if [ -z "$applied" ]; then
role_log "ERR" "整理結果無法套用(角色 ${ROLE}),保留待整理記憶到下個週期"
role_log "ERR" "整理失敗 ${max_attempts}(角色 ${ROLE}),保留待整理記憶到下個週期"
role_lock_release "$ROLE"
trap - EXIT
return 1
fi
[ "$attempt" -gt 1 ] && role_log "INF" "${attempt} 次嘗試成功(角色 ${ROLE}"
role_log "INF" "記憶整理完成(角色 ${ROLE},觸發:${reason}):${applied}"
forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null | tr '\n' '')"
@@ -248,6 +256,63 @@ cron_path_append() {
esac
}
all_roles_enabled() {
# 排程是否服務所有角色(預設是)。設 ROLE_SLEEP_ALL_ROLES=0 可退回只服務啟用角色。
case "${ROLE_SLEEP_ALL_ROLES:-1}" in
0|false|no) return 1 ;;
esac
return 0
}
sleep_target_roles() {
# 本次要處理哪些角色。
#
# 為什麼需要這個:舊版把 ROLE_NAME 寫死進 crontab,於是只有「啟用角色」會睡,
# 其他角色的 inbox 永遠累積、state.json 連 last_sleep 都不會出現 —— 而且不會有任何錯誤訊息,
# 因為對排程而言它「成功地整理了那一個角色」。多角色系統必須掃描角色目錄。
#
# 明確指定 ROLE_NAME 時仍只處理該角色,手動操作與單角色模式的行為不變。
if ! all_roles_enabled || [ -n "${ROLE_NAME:-}" ]; then
printf '%s\n' "$(role_resolve_name)"
return 0
fi
local home file id seen=""
home="$(role_home)"
[ -d "$home" ] || return 0
for file in "$home"/*.identity.md "$home"/*.md; do
[ -f "$file" ] || continue
case "$file" in
*.soul.md) continue ;; # soul 不是主定義檔
*.identity.md) id="$(basename "$file" .identity.md)" ;;
*) id="$(basename "$file" .md)"
[ -f "$(role_identity_file "$id")" ] && continue ;; # 已有新格式就不重複列
esac
case " ${seen} " in *" ${id} "*) continue ;; esac
seen="${seen} ${id}"
printf '%s\n' "$id"
done
}
for_each_target_role() {
# 對每個目標角色依序執行 $1;角色鎖是 per-role,循序執行不會互相搶鎖。
local fn="$1" r handled=0
for r in $(sleep_target_roles); do
[ -n "$r" ] || continue
ROLE="$r"
if [ ! -f "$(role_file "$ROLE")" ]; then
role_log "DBG" "略過 ${ROLE}:找不到角色定義檔"
continue
fi
handled=$((handled + 1))
"$fn"
done
if [ "$handled" = "0" ]; then
role_log "WRN" "沒有可處理的角色(角色目錄:$(role_home)"
return 1
fi
return 0
}
cron_path() {
# cron 只需要系統工具、node 與摘要 CLI;避免把互動 shell 的超長 PATH 原樣寫入。
local value="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
@@ -265,8 +330,15 @@ cron_path() {
cron_env_prefix() {
# 組出 cron 需要的精簡環境變數
local env_prefix="PATH=$(cron_quote "$(cron_path)")"
local var
for var in ROLE_ENABLED ROLE_NAME ROLE_HOME ROLE_MEMORY_HOME ROLE_CLI ROLE_MODEL ROLE_SLEEP_START ROLE_SLEEP_END ROLE_SCOPE ROLE_NAP_ENABLED ROLE_NAP_IDLE_MINUTES ROLE_NAP_MIN_INBOX ROLE_NAP_INTERVAL_MINUTES; do
local var vars="ROLE_ENABLED ROLE_HOME ROLE_MEMORY_HOME ROLE_CLI ROLE_MODEL ROLE_SLEEP_START ROLE_SLEEP_END ROLE_SCOPE ROLE_NAP_ENABLED ROLE_NAP_IDLE_MINUTES ROLE_NAP_MIN_INBOX ROLE_NAP_INTERVAL_MINUTES"
# 多角色模式(預設)刻意**不寫入 ROLE_NAME** —— 寫死角色會讓其他角色永遠不被整理。
# 單角色模式才綁定 ROLE_NAME。
if all_roles_enabled; then
env_prefix="${env_prefix} ROLE_SLEEP_ALL_ROLES=1"
else
vars="ROLE_NAME ${vars}"
fi
for var in $vars; do
if [ -n "${!var:-}" ]; then
env_prefix="${env_prefix} ${var}=$(cron_quote "${!var}")"
fi
@@ -463,6 +535,33 @@ EOF_LAUNCHER
return 0
}
cycle_for_cron() {
# cron 觸發的單一角色整理(時段與 AI 檢查已在呼叫端做過,與角色無關)
sleep_cycle "cron"
}
cycle_for_nap() {
# 小睡條件是 per-role 的(各自的閒置時間與 inbox 筆數)
if [ "$(node "${SCRIPT_DIR}/memory.js" need-nap --role "$ROLE" --idle-minutes "$(nap_idle_minutes)" --min-inbox "$(nap_min_inbox)" 2>/dev/null)" != "yes" ]; then
role_log "DBG" "${ROLE} 尚未達小睡條件(閒置滿 $(nap_idle_minutes) 分鐘且 inbox ≥ $(nap_min_inbox) 則),略過"
return 0
fi
sleep_cycle "小睡"
}
cycle_for_catchup() {
if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" != "yes" ]; then
role_log "DBG" "${ROLE} 不需補跑整理"
return 0
fi
sleep_cycle "補跑"
}
cycle_for_brief() {
# run_brief 內部會在沒有 <角色>.checks 目錄時自行略過,沒設定的角色零影響
run_brief
}
install_cron() {
# 安裝或更新睡眠排程;以 marker 註解辨識自己的條目,不動使用者其他排程
command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab,無法安裝排程"; return 1; }
@@ -726,6 +825,22 @@ cron_target_state() {
fi
}
cron_scope_state() {
# 排程實際涵蓋哪些角色。舊條目把 ROLE_NAME 寫死,只有那一個角色會睡,
# 其餘角色不會有任何錯誤訊息 —— 這裡把它顯示出來,避免又是無聲失效。
local line pinned
line="$(crontab -l 2>/dev/null | grep -F "$CRON_MARKER" | head -n 1)"
[ -n "$line" ] || { printf '未安裝'; return 0; }
pinned="$(printf '%s' "$line" | sed -n "s/.*ROLE_NAME='\([^']*\)'.*/\1/p")"
if [ -n "$pinned" ]; then
printf '⚠ 只有 %s(條目寫死 ROLE_NAME,其他角色不會被整理;重跑 --install-cron 可改為全部)' "$pinned"
elif printf '%s' "$line" | grep -q 'ROLE_SLEEP_ALL_ROLES=1'; then
printf '全部(%s' "$(sleep_target_roles | tr '\n' ' ' | sed 's/ $//')"
else
printf '未指定(腳本會退回啟用角色 %s)' "$(role_resolve_name)"
fi
}
show_status() {
# 以表格輸出目前角色與記憶狀態(供 skill 的 --status 使用)
local cron_state="未安裝" nap_state="未安裝" brief_state="未安裝" cron_service="未執行" window="否" checks_state instance_state
@@ -764,6 +879,7 @@ show_status() {
printf '| 小睡排程 | %s |\n' "$nap_state"
printf '| 晨間檢查排程 | %s |\n' "$brief_state"
printf '| 排程指向 | %s |\n' "$(cron_target_state)"
printf '| 排程涵蓋角色 | %s |\n' "$(cron_scope_state)"
printf '| 角色載入鎖 | %s |\n' "$instance_state"
printf '| 檢查腳本目錄 | %s |\n' "$checks_state"
printf '| 小睡啟用 | %s |\n' "$(nap_enabled && printf '是' || printf '否')"
@@ -863,7 +979,6 @@ MODE="${1:---status}"
case "$MODE" in
--run)
role_enabled || exit 0
require_role
if ! role_in_sleep_window; then
role_log "DBG" "目前不在睡眠時段($(role_sleep_start)$(role_sleep_end)),略過"
exit 0
@@ -872,11 +987,10 @@ case "$MODE" in
role_log "INF" "偵測到 AI 正在運行,本小時不進入睡眠,下個整點再檢查"
exit 0
fi
sleep_cycle "cron"
for_each_target_role cycle_for_cron
;;
--nap)
role_enabled || exit 0
require_role
if ! nap_enabled; then
role_log "DBG" "小睡已停用(ROLE_NAP_ENABLED=${ROLE_NAP_ENABLED:-1}),略過"
exit 0
@@ -885,20 +999,11 @@ case "$MODE" in
role_log "INF" "偵測到整理用 headless CLI 正在運行,本次小睡略過"
exit 0
fi
if [ "$(node "${SCRIPT_DIR}/memory.js" need-nap --role "$ROLE" --idle-minutes "$(nap_idle_minutes)" --min-inbox "$(nap_min_inbox)" 2>/dev/null)" != "yes" ]; then
role_log "DBG" "尚未達小睡條件(閒置滿 $(nap_idle_minutes) 分鐘且 inbox ≥ $(nap_min_inbox) 則),略過"
exit 0
fi
sleep_cycle "小睡"
for_each_target_role cycle_for_nap
;;
--catchup)
role_enabled || exit 0
require_role
if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" != "yes" ]; then
role_log "DBG" "不需補跑整理"
exit 0
fi
sleep_cycle "補跑"
for_each_target_role cycle_for_catchup
;;
--force)
require_role
@@ -924,12 +1029,11 @@ case "$MODE" in
;;
--brief)
role_enabled || exit 0
require_role
if ! brief_enabled; then
role_log "DBG" "晨間狀態檢查已停用(ROLE_BRIEF_ENABLED=${ROLE_BRIEF_ENABLED:-1}),略過"
exit 0
fi
run_brief
for_each_target_role cycle_for_brief
;;
--export)
if [ -n "${3:-}" ]; then