fix: feat: SessionStart compact hook re-injects phase protocol after context compaction (#274)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c960f1b6e
commit
e3895ad3ac
7 changed files with 86 additions and 8 deletions
|
|
@ -310,7 +310,7 @@ sourced as needed.
|
|||
| `lib/matrix_listener.sh` | Long-poll Matrix sync daemon. Dispatches thread replies to the correct agent via well-known files (`/tmp/{agent}-escalation-reply`). Handles supervisor, gardener, dev, review, vault, and action reply routing. Run as systemd service. | Standalone daemon |
|
||||
| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `build_context_block()`, `start_formula_session()`, `formula_phase_callback()`, `build_prompt_footer()`, `run_formula_and_monitor()` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). | planner-run.sh, predictor-run.sh |
|
||||
| `lib/file-action-issue.sh` | `file_action_issue()` — dedup check, label lookup, and issue creation for formula-driven cron wrappers. Sets `FILED_ISSUE_NUM` on success. | gardener-run.sh |
|
||||
| `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. When `MATRIX_THREAD_ID` is exported, also installs a Stop hook (`on-stop-matrix.sh`) that streams each Claude response to the Matrix thread. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `❯` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or a `PHASE:*` string. **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` § idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh, gardener-agent.sh, action-agent.sh |
|
||||
| `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`, `write_compact_context()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `MATRIX_THREAD_ID` is exported, also installs a Stop hook (`on-stop-matrix.sh`) that streams each Claude response to the Matrix thread. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `❯` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or a `PHASE:*` string. **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` § idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh, gardener-agent.sh, action-agent.sh |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ cleanup() {
|
|||
agent_kill_session "$SESSION_NAME"
|
||||
# Best-effort docker cleanup for containers started during this action
|
||||
(cd "${PROJECT_REPO_ROOT}" 2>/dev/null && docker compose down 2>/dev/null) || true
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$PREFLIGHT_RESULT"
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$PREFLIGHT_RESULT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
|
@ -193,6 +193,9 @@ fi
|
|||
# Build phase protocol from shared function (Path B covered in Instructions section above)
|
||||
PHASE_PROTOCOL_INSTRUCTIONS="$(build_phase_protocol_prompt "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$BRANCH")"
|
||||
|
||||
# Write phase protocol to context file for compaction survival
|
||||
write_compact_context "$PHASE_FILE" "$PHASE_PROTOCOL_INSTRUCTIONS"
|
||||
|
||||
INITIAL_PROMPT="You are an action agent. Your job is to execute the action formula
|
||||
in the issue below.
|
||||
|
||||
|
|
@ -272,16 +275,16 @@ case "${_MONITOR_LOOP_EXIT:-}" in
|
|||
# Escalate to supervisor (idle_prompt already escalated via _on_phase_change callback)
|
||||
echo "{\"issue\":${ISSUE},\"pr\":${PR_NUMBER:-0},\"reason\":\"idle_timeout\",\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
|
||||
>> "${FACTORY_ROOT}/supervisor/escalations-${PROJECT_NAME}.jsonl"
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE"
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE"
|
||||
;;
|
||||
idle_prompt)
|
||||
# Notification + escalation already handled by _on_phase_change(PHASE:failed) callback
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE"
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE"
|
||||
;;
|
||||
done)
|
||||
# Belt-and-suspenders: callback handles primary cleanup,
|
||||
# but ensure sentinel files are removed if callback was interrupted
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE"
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE"
|
||||
;;
|
||||
esac
|
||||
|
||||
|
|
|
|||
|
|
@ -588,6 +588,9 @@ printf 'PHASE:failed\nReason: refused\n' > \"${PHASE_FILE}\"
|
|||
printf 'PHASE:failed\nReason: %s\n' \"describe what failed\" > \"${PHASE_FILE}\"
|
||||
\`\`\`"
|
||||
|
||||
# Write phase protocol to context file for compaction survival
|
||||
write_compact_context "$PHASE_FILE" "$PHASE_PROTOCOL_INSTRUCTIONS"
|
||||
|
||||
if [ "$RECOVERY_MODE" = true ]; then
|
||||
# Build recovery context
|
||||
GIT_DIFF_STAT=$(git -C "$WORKTREE" diff "origin/${PRIMARY_BRANCH}..HEAD" --stat 2>/dev/null | head -20 || echo "(no diff)")
|
||||
|
|
@ -759,7 +762,8 @@ case "${_MONITOR_LOOP_EXIT:-}" in
|
|||
else
|
||||
cleanup_worktree
|
||||
fi
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE" \
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" \
|
||||
"$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE" \
|
||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||
;;
|
||||
|
|
@ -769,7 +773,8 @@ case "${_MONITOR_LOOP_EXIT:-}" in
|
|||
done)
|
||||
# Belt-and-suspenders: callback in phase-handler.sh handles primary cleanup,
|
||||
# but ensure sentinel files are removed if callback was interrupted
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE" \
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" \
|
||||
"$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE" \
|
||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||
CLAIMED=false
|
||||
|
|
|
|||
|
|
@ -267,9 +267,16 @@ When all work is done and verify confirms zero tech-debt:
|
|||
On unrecoverable error:
|
||||
printf 'PHASE:failed\nReason: %s\n' 'describe error' > '${PHASE_FILE}'"
|
||||
|
||||
# Write phase protocol to context file for compaction survival
|
||||
write_compact_context "$PHASE_FILE" "## Phase protocol (REQUIRED)
|
||||
When all work is done and verify confirms zero tech-debt:
|
||||
echo 'PHASE:done' > '${PHASE_FILE}'
|
||||
On unrecoverable error:
|
||||
printf 'PHASE:failed\nReason: %s\n' 'describe error' > '${PHASE_FILE}'"
|
||||
|
||||
# ── Reset phase + result files ────────────────────────────────────────────
|
||||
agent_kill_session "$SESSION_NAME"
|
||||
rm -f "$PHASE_FILE" "$RESULT_FILE"
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$RESULT_FILE"
|
||||
touch "$RESULT_FILE"
|
||||
|
||||
# ── Create tmux session ───────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -218,6 +218,39 @@ create_agent_session() {
|
|||
fi
|
||||
rm -f "$exit_marker"
|
||||
|
||||
# Install SessionStart hook for context re-injection after compaction:
|
||||
# when Claude Code compacts context during long sessions, the phase protocol
|
||||
# instructions are lost. This hook fires after each compaction and outputs
|
||||
# the content of a context file so Claude retains critical instructions.
|
||||
# The context file is written by callers via write_compact_context().
|
||||
if [ -n "$phase_file" ]; then
|
||||
local compact_hook_script="${FACTORY_ROOT}/lib/hooks/on-compact-reinject.sh"
|
||||
if [ -x "$compact_hook_script" ]; then
|
||||
local context_file="${phase_file%.phase}.context"
|
||||
local compact_hook_cmd="${compact_hook_script} ${context_file}"
|
||||
if [ -f "$settings" ]; then
|
||||
jq --arg cmd "$compact_hook_cmd" '
|
||||
if (.hooks.SessionStart // [] | any(.[]; .hooks[]?.command == $cmd))
|
||||
then .
|
||||
else .hooks.SessionStart = (.hooks.SessionStart // []) + [{
|
||||
matcher: "compact",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
end
|
||||
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
|
||||
else
|
||||
jq -n --arg cmd "$compact_hook_cmd" '{
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
matcher: "compact",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
}
|
||||
}' > "$settings"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install Stop hook for Matrix streaming: when MATRIX_THREAD_ID is set,
|
||||
# each Claude response is posted to the Matrix thread so humans can follow.
|
||||
local matrix_hook_script="${FACTORY_ROOT}/lib/hooks/on-stop-matrix.sh"
|
||||
|
|
@ -392,6 +425,16 @@ monitor_phase_loop() {
|
|||
done
|
||||
}
|
||||
|
||||
# Write context to a file for re-injection after context compaction.
|
||||
# The SessionStart compact hook reads this file and outputs it to stdout.
|
||||
# Args: phase_file content
|
||||
write_compact_context() {
|
||||
local phase_file="$1"
|
||||
local content="$2"
|
||||
local context_file="${phase_file%.phase}.context"
|
||||
printf '%s\n' "$content" > "$context_file"
|
||||
}
|
||||
|
||||
# Kill a tmux session gracefully (no-op if not found).
|
||||
agent_kill_session() {
|
||||
local session="${1:-}"
|
||||
|
|
|
|||
|
|
@ -202,6 +202,11 @@ run_formula_and_monitor() {
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Write phase protocol to context file for compaction survival
|
||||
if [ -n "${PROMPT_FOOTER:-}" ]; then
|
||||
write_compact_context "$PHASE_FILE" "$PROMPT_FOOTER"
|
||||
fi
|
||||
|
||||
agent_inject_into_session "$SESSION_NAME" "$PROMPT"
|
||||
log "Prompt sent to tmux session"
|
||||
matrix_send "$agent_name" "${agent_name^} session started for ${CODEBERG_REPO}" 2>/dev/null || true
|
||||
|
|
|
|||
15
lib/hooks/on-compact-reinject.sh
Executable file
15
lib/hooks/on-compact-reinject.sh
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
#!/bin/bash
|
||||
# on-compact-reinject.sh — SessionStart (compact) hook for dark-factory agent sessions.
|
||||
#
|
||||
# Called by Claude Code after context compaction. Reads a context file and
|
||||
# outputs its content to stdout, which Claude Code injects as system context.
|
||||
# No-op if the context file doesn't exist.
|
||||
#
|
||||
# Usage (in .claude/settings.json):
|
||||
# {"type": "command", "command": "this-script /tmp/dev-session-PROJECT-ISSUE.context"}
|
||||
#
|
||||
# Args: $1 = context file path
|
||||
|
||||
cat > /dev/null # consume hook JSON from stdin
|
||||
[ -n "${1:-}" ] && [ -f "$1" ] && cat "$1"
|
||||
exit 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue