diff --git a/AGENTS.md b/AGENTS.md index 59a0201..1bc24d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | --- diff --git a/action/action-agent.sh b/action/action-agent.sh index a1fd470..f0920a4 100644 --- a/action/action-agent.sh +++ b/action/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 diff --git a/dev/dev-agent.sh b/dev/dev-agent.sh index 6d3e463..4beaf95 100755 --- a/dev/dev-agent.sh +++ b/dev/dev-agent.sh @@ -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 diff --git a/gardener/gardener-agent.sh b/gardener/gardener-agent.sh index 48e0091..d4cb1ac 100644 --- a/gardener/gardener-agent.sh +++ b/gardener/gardener-agent.sh @@ -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 ─────────────────────────────────────────────────── diff --git a/lib/agent-session.sh b/lib/agent-session.sh index 81d6aae..501d955 100644 --- a/lib/agent-session.sh +++ b/lib/agent-session.sh @@ -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:-}" diff --git a/lib/formula-session.sh b/lib/formula-session.sh index f09eec1..21622e7 100644 --- a/lib/formula-session.sh +++ b/lib/formula-session.sh @@ -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 diff --git a/lib/hooks/on-compact-reinject.sh b/lib/hooks/on-compact-reinject.sh new file mode 100755 index 0000000..320f2a1 --- /dev/null +++ b/lib/hooks/on-compact-reinject.sh @@ -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