Merge pull request 'fix: feat: Matrix notifications — contextual, linked, conversational (#76)' (#102) from fix/issue-76 into main
This commit is contained in:
commit
f1959101e6
3 changed files with 151 additions and 18 deletions
|
|
@ -44,6 +44,10 @@ PHASE_FILE="/tmp/dev-session-${PROJECT_NAME}-${ISSUE}.phase"
|
|||
SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE}"
|
||||
IMPL_SUMMARY_FILE="/tmp/dev-impl-summary-${PROJECT_NAME}-${ISSUE}.txt"
|
||||
|
||||
# Matrix thread tracking — one thread per issue for conversational notifications
|
||||
THREAD_FILE="/tmp/dev-thread-${PROJECT_NAME}-${ISSUE}"
|
||||
CODEBERG_WEB="https://codeberg.org/${CODEBERG_REPO}"
|
||||
|
||||
# Timing
|
||||
PHASE_POLL_INTERVAL=30 # seconds between phase checks
|
||||
IDLE_TIMEOUT=7200 # 2h: kill session if phase stale this long
|
||||
|
|
@ -71,7 +75,23 @@ status() {
|
|||
}
|
||||
|
||||
notify() {
|
||||
matrix_send "dev" "🔧 #${ISSUE}: $*" 2>/dev/null || true
|
||||
local thread_id=""
|
||||
[ -f "${THREAD_FILE}" ] && thread_id=$(cat "$THREAD_FILE" 2>/dev/null || true)
|
||||
matrix_send "dev" "🔧 #${ISSUE}: $*" "${thread_id}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# notify_ctx — Send rich notification with HTML links/context into the issue thread
|
||||
# Falls back to plain matrix_send (which registers a thread root) when no thread exists.
|
||||
notify_ctx() {
|
||||
local plain="$1" html="$2"
|
||||
local thread_id=""
|
||||
[ -f "${THREAD_FILE}" ] && thread_id=$(cat "$THREAD_FILE" 2>/dev/null || true)
|
||||
if [ -n "$thread_id" ]; then
|
||||
matrix_send_ctx "dev" "🔧 #${ISSUE}: ${plain}" "🔧 #${ISSUE}: ${html}" "${thread_id}" 2>/dev/null || true
|
||||
else
|
||||
# No thread — fall back to plain send so a thread root is registered
|
||||
matrix_send "dev" "🔧 #${ISSUE}: ${plain}" "" "${ISSUE}" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Phase helpers ---
|
||||
|
|
@ -246,10 +266,12 @@ do_merge() {
|
|||
"${API}/issues/${ISSUE}" \
|
||||
-d '{"state":"closed"}' >/dev/null 2>&1 || true
|
||||
cleanup_labels
|
||||
notify "✅ PR #${pr} merged! Issue #${ISSUE} done."
|
||||
notify_ctx \
|
||||
"✅ PR #${pr} merged! Issue #${ISSUE} done." \
|
||||
"✅ PR <a href='${CODEBERG_WEB}/pulls/${pr}'>#${pr}</a> merged! <a href='${CODEBERG_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done."
|
||||
kill_tmux_session
|
||||
cleanup_worktree
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE"
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE"
|
||||
exit 0
|
||||
else
|
||||
log "merge failed (HTTP ${http_code}) — attempting rebase and retry"
|
||||
|
|
@ -285,14 +307,16 @@ do_merge() {
|
|||
-d '{"Do":"merge","delete_branch_after_merge":true}')
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
|
||||
log "PR #${pr} merged after rebase!"
|
||||
notify "✅ PR #${pr} merged! Issue #${ISSUE} done."
|
||||
notify_ctx \
|
||||
"✅ PR #${pr} merged! Issue #${ISSUE} done." \
|
||||
"✅ PR <a href='${CODEBERG_WEB}/pulls/${pr}'>#${pr}</a> merged! <a href='${CODEBERG_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done."
|
||||
curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
|
||||
cleanup_labels
|
||||
kill_tmux_session
|
||||
cleanup_worktree
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE"
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
|
|
@ -853,6 +877,18 @@ log "initial prompt sent to tmux session"
|
|||
|
||||
# Signal to dev-poll.sh that we're running (session is up)
|
||||
echo '{"status":"ready"}' > "$PREFLIGHT_RESULT"
|
||||
# Create Matrix thread for this issue (or reuse existing one)
|
||||
if [ ! -f "${THREAD_FILE}" ] || [ -z "$(cat "$THREAD_FILE" 2>/dev/null)" ]; then
|
||||
ISSUE_URL="${CODEBERG_WEB}/issues/${ISSUE}"
|
||||
_thread_id=$(matrix_send_ctx "dev" \
|
||||
"🔧 Issue #${ISSUE}: ${ISSUE_TITLE} — ${ISSUE_URL}" \
|
||||
"🔧 <a href='${ISSUE_URL}'>Issue #${ISSUE}</a>: ${ISSUE_TITLE}") || true
|
||||
if [ -n "${_thread_id:-}" ]; then
|
||||
printf '%s' "$_thread_id" > "$THREAD_FILE"
|
||||
# Register thread root in map for listener dispatch
|
||||
printf '%s\t%s\t%s\t%s\n' "$_thread_id" "dev" "$(date +%s)" "${ISSUE}" >> "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
notify "tmux session ${SESSION_NAME} started for issue #${ISSUE}: ${ISSUE_TITLE}"
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -972,7 +1008,10 @@ Phase file: ${PHASE_FILE}"
|
|||
if [ "$PR_HTTP_CODE" = "201" ] || [ "$PR_HTTP_CODE" = "200" ]; then
|
||||
PR_NUMBER=$(echo "$PR_RESPONSE_BODY" | jq -r '.number')
|
||||
log "created PR #${PR_NUMBER}"
|
||||
notify "PR #${PR_NUMBER} created for issue #${ISSUE}: ${ISSUE_TITLE}"
|
||||
PR_URL="${CODEBERG_WEB}/pulls/${PR_NUMBER}"
|
||||
notify_ctx \
|
||||
"PR #${PR_NUMBER} created: ${ISSUE_TITLE}" \
|
||||
"PR <a href='${PR_URL}'>#${PR_NUMBER}</a> created: ${ISSUE_TITLE}"
|
||||
elif [ "$PR_HTTP_CODE" = "409" ]; then
|
||||
# PR already exists (race condition) — find it
|
||||
FOUND_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
||||
|
|
@ -1081,11 +1120,14 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee
|
|||
fi
|
||||
|
||||
CI_FIX_COUNT=$(( CI_FIX_COUNT + 1 ))
|
||||
_ci_pipeline_url="${WOODPECKER_SERVER}/repos/${WOODPECKER_REPO_ID}/pipeline/${PIPELINE_NUM:-0}"
|
||||
if [ "$CI_FIX_COUNT" -gt "$MAX_CI_FIXES" ]; then
|
||||
log "CI failure not recoverable after ${CI_FIX_COUNT} fix attempts — escalating"
|
||||
echo "{\"issue\":${ISSUE},\"pr\":${PR_NUMBER},\"reason\":\"ci_exhausted\",\"step\":\"${FAILED_STEP:-unknown}\",\"attempts\":${CI_FIX_COUNT},\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
|
||||
>> "${FACTORY_ROOT}/supervisor/escalations.jsonl"
|
||||
notify "CI exhausted after ${CI_FIX_COUNT} attempts — escalated to supervisor"
|
||||
notify_ctx \
|
||||
"CI exhausted after ${CI_FIX_COUNT} attempts — escalated to supervisor" \
|
||||
"CI exhausted after ${CI_FIX_COUNT} attempts on PR <a href='${PR_URL:-${CODEBERG_WEB}/pulls/${PR_NUMBER}}'>#${PR_NUMBER}</a> | <a href='${_ci_pipeline_url}'>Pipeline</a><br>Step: <code>${FAILED_STEP:-unknown}</code> — escalated to supervisor"
|
||||
printf 'PHASE:failed\nReason: ci_exhausted after %d attempts\n' "$CI_FIX_COUNT" > "$PHASE_FILE"
|
||||
LAST_PHASE_MTIME=$(stat -c %Y "$PHASE_FILE" 2>/dev/null || echo 0)
|
||||
continue
|
||||
|
|
@ -1101,6 +1143,12 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee
|
|||
"$CI_FIX_COUNT" "$MAX_CI_FIXES" "${FAILED_STEP:-unknown}" "${FAILED_EXIT:-?}" "$CI_ERROR_LOG" \
|
||||
> "/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt" 2>/dev/null || true
|
||||
|
||||
# Notify Matrix with rich CI failure context
|
||||
_ci_snippet=$(printf '%s' "${CI_ERROR_LOG:-}" | tail -5 | head -c 500 | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
|
||||
notify_ctx \
|
||||
"CI failed on PR #${PR_NUMBER}: step=${FAILED_STEP:-unknown} (attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES})" \
|
||||
"CI failed on PR <a href='${PR_URL:-${CODEBERG_WEB}/pulls/${PR_NUMBER}}'>#${PR_NUMBER}</a> | <a href='${_ci_pipeline_url}'>Pipeline #${PIPELINE_NUM:-?}</a><br>Step: <code>${FAILED_STEP:-unknown}</code> (exit ${FAILED_EXIT:-?})<br>Attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES}<br><pre>${_ci_snippet:-no logs}</pre>"
|
||||
|
||||
inject_into_session "CI failed on PR #${PR_NUMBER} (attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES}).
|
||||
|
||||
Failed step: ${FAILED_STEP:-unknown} (exit code ${FAILED_EXIT:-?}, pipeline #${PIPELINE_NUM:-?})
|
||||
|
|
@ -1238,14 +1286,16 @@ Instructions:
|
|||
if [ "$PR_STATE" != "open" ]; then
|
||||
if [ "$PR_MERGED" = "true" ]; then
|
||||
log "PR #${PR_NUMBER} was merged externally"
|
||||
notify "✅ PR #${PR_NUMBER} merged externally! Issue #${ISSUE} done."
|
||||
notify_ctx \
|
||||
"✅ PR #${PR_NUMBER} merged externally! Issue #${ISSUE} done." \
|
||||
"✅ PR <a href='${CODEBERG_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a> merged externally! <a href='${CODEBERG_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done."
|
||||
curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
|
||||
cleanup_labels
|
||||
kill_tmux_session
|
||||
cleanup_worktree
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE"
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE"
|
||||
exit 0
|
||||
else
|
||||
log "PR #${PR_NUMBER} was closed WITHOUT merge — NOT closing issue"
|
||||
|
|
@ -1270,7 +1320,12 @@ Instructions:
|
|||
elif [ "$CURRENT_PHASE" = "PHASE:needs_human" ]; then
|
||||
status "needs human input on issue #${ISSUE}"
|
||||
HUMAN_REASON=$(sed -n '2p' "$PHASE_FILE" 2>/dev/null | sed 's/^Reason: //' || echo "")
|
||||
notify "⚠️ Issue #${ISSUE} (PR #${PR_NUMBER:-none}) needs human input.${HUMAN_REASON:+ Reason: ${HUMAN_REASON}}"
|
||||
_issue_url="${CODEBERG_WEB}/issues/${ISSUE}"
|
||||
_pr_link=""
|
||||
[ -n "${PR_NUMBER:-}" ] && _pr_link=" | PR <a href='${CODEBERG_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>"
|
||||
notify_ctx \
|
||||
"⚠️ Issue #${ISSUE} (PR #${PR_NUMBER:-none}) needs human input.${HUMAN_REASON:+ Reason: ${HUMAN_REASON}}" \
|
||||
"⚠️ <a href='${_issue_url}'>Issue #${ISSUE}</a>${_pr_link} needs human input.${HUMAN_REASON:+ Reason: ${HUMAN_REASON}}<br>Reply in this thread to send guidance to the dev agent."
|
||||
log "phase: needs_human — notified via Matrix, waiting for external injection"
|
||||
# Don't inject anything — supervisor-poll.sh (#81) injects human replies, gardener-poll.sh as backup
|
||||
|
||||
|
|
@ -1383,13 +1438,15 @@ $(printf '%s' "$REFUSAL_JSON" | head -c 2000)
|
|||
CLAIMED=false # Don't unclaim again in cleanup()
|
||||
kill_tmux_session
|
||||
cleanup_worktree
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE"
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE"
|
||||
break
|
||||
|
||||
else
|
||||
# Genuine unrecoverable failure — escalate to supervisor
|
||||
log "session failed: ${FAILURE_REASON}"
|
||||
notify "❌ Issue #${ISSUE} session failed: ${FAILURE_REASON}"
|
||||
notify_ctx \
|
||||
"❌ Issue #${ISSUE} session failed: ${FAILURE_REASON}" \
|
||||
"❌ <a href='${CODEBERG_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> session failed: ${FAILURE_REASON}${PR_NUMBER:+ | PR <a href='${CODEBERG_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}"
|
||||
echo "{\"issue\":${ISSUE},\"pr\":${PR_NUMBER:-0},\"reason\":\"${FAILURE_REASON}\",\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
|
||||
>> "${FACTORY_ROOT}/supervisor/escalations.jsonl"
|
||||
|
||||
|
|
@ -1408,7 +1465,7 @@ $(printf '%s' "$REFUSAL_JSON" | head -c 2000)
|
|||
else
|
||||
cleanup_worktree
|
||||
fi
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE"
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE"
|
||||
break
|
||||
fi
|
||||
|
||||
|
|
|
|||
42
lib/env.sh
42
lib/env.sh
|
|
@ -74,12 +74,13 @@ wpdb() {
|
|||
-t "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
# Matrix messaging helper — usage: matrix_send <prefix> <message> [thread_event_id]
|
||||
# Matrix messaging helper — usage: matrix_send <prefix> <message> [thread_event_id] [context_tag]
|
||||
# Returns event_id on stdout. Registers threads for listener dispatch.
|
||||
# context_tag is stored in the thread map (e.g. issue number) for routing replies.
|
||||
MATRIX_THREAD_MAP="${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}"
|
||||
matrix_send() {
|
||||
[ -z "${MATRIX_TOKEN:-}" ] && return 0
|
||||
local prefix="$1" msg="$2" thread_id="${3:-}"
|
||||
local prefix="$1" msg="$2" thread_id="${3:-}" ctx_tag="${4:-}"
|
||||
local room_encoded="${MATRIX_ROOM_ID//!/%21}"
|
||||
local txn="$(date +%s%N)$$"
|
||||
local body
|
||||
|
|
@ -101,7 +102,42 @@ matrix_send() {
|
|||
printf '%s' "$event_id"
|
||||
# Register thread root for listener dispatch (escalations only)
|
||||
if [ -z "$thread_id" ]; then
|
||||
printf '%s\t%s\t%s\n' "$event_id" "$prefix" "$(date +%s)" >> "$MATRIX_THREAD_MAP" 2>/dev/null || true
|
||||
printf '%s\t%s\t%s\t%s\n' "$event_id" "$prefix" "$(date +%s)" "${ctx_tag}" >> "$MATRIX_THREAD_MAP" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# matrix_send_ctx — Send rich Matrix message with HTML formatting
|
||||
# Usage: matrix_send_ctx <prefix> <plain_text> <html_body> [thread_event_id]
|
||||
# Use for notifications that benefit from links, code blocks, or structured content.
|
||||
matrix_send_ctx() {
|
||||
[ -z "${MATRIX_TOKEN:-}" ] && return 0
|
||||
local prefix="$1" plain="$2" html="$3" thread_id="${4:-}"
|
||||
local room_encoded="${MATRIX_ROOM_ID//!/%21}"
|
||||
local txn
|
||||
txn="$(date +%s%N)$$"
|
||||
local body
|
||||
if [ -n "$thread_id" ]; then
|
||||
body=$(jq -nc \
|
||||
--arg m "[${prefix}] ${plain}" \
|
||||
--arg h "<b>[${prefix}]</b> ${html}" \
|
||||
--arg t "$thread_id" \
|
||||
'{msgtype:"m.text",body:$m,format:"org.matrix.custom.html",formatted_body:$h,"m.relates_to":{rel_type:"m.thread",event_id:$t}}')
|
||||
else
|
||||
body=$(jq -nc \
|
||||
--arg m "[${prefix}] ${plain}" \
|
||||
--arg h "<b>[${prefix}]</b> ${html}" \
|
||||
'{msgtype:"m.text",body:$m,format:"org.matrix.custom.html",formatted_body:$h}')
|
||||
fi
|
||||
local response
|
||||
response=$(curl -s -X PUT \
|
||||
-H "Authorization: Bearer ${MATRIX_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${MATRIX_HOMESERVER}/_matrix/client/v3/rooms/${room_encoded}/send/m.room.message/${txn}" \
|
||||
-d "$body" 2>/dev/null) || return 0
|
||||
local event_id
|
||||
event_id=$(printf '%s' "$response" | jq -r '.event_id // empty' 2>/dev/null)
|
||||
if [ -n "$event_id" ]; then
|
||||
printf '%s' "$event_id"
|
||||
fi
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,8 +142,48 @@ while true; do
|
|||
matrix_send "gardener" "✓ received, will act on next poll" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
||||
;;
|
||||
dev)
|
||||
printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/dev-escalation-reply
|
||||
matrix_send "dev" "✓ received, will inject on next poll" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
||||
# Route reply into the dev tmux session using context_tag (issue number)
|
||||
DEV_ISSUE=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $4}' "$THREAD_MAP" 2>/dev/null || true)
|
||||
DEV_INJECTED=false
|
||||
if [ -n "$DEV_ISSUE" ]; then
|
||||
DEV_SESSION="dev-${PROJECT_NAME}-${DEV_ISSUE}"
|
||||
DEV_PHASE_FILE="/tmp/dev-session-${PROJECT_NAME}-${DEV_ISSUE}.phase"
|
||||
if tmux has-session -t "$DEV_SESSION" 2>/dev/null; then
|
||||
DEV_CUR_PHASE=$(head -1 "$DEV_PHASE_FILE" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
if [ "$DEV_CUR_PHASE" = "PHASE:needs_human" ] || [ "$DEV_CUR_PHASE" = "PHASE:awaiting_review" ]; then
|
||||
DEV_INJECT_MSG="Human guidance from ${SENDER} in Matrix:
|
||||
|
||||
${BODY}
|
||||
|
||||
Consider this guidance for your current work."
|
||||
DEV_INJECT_TMP=$(mktemp /tmp/dev-q-inject-XXXXXX)
|
||||
printf '%s' "$DEV_INJECT_MSG" > "$DEV_INJECT_TMP"
|
||||
tmux load-buffer -b "dev-q-${DEV_ISSUE}" "$DEV_INJECT_TMP" || true
|
||||
tmux paste-buffer -t "$DEV_SESSION" -b "dev-q-${DEV_ISSUE}" || true
|
||||
sleep 0.5
|
||||
tmux send-keys -t "$DEV_SESSION" "" Enter || true
|
||||
tmux delete-buffer -b "dev-q-${DEV_ISSUE}" 2>/dev/null || true
|
||||
rm -f "$DEV_INJECT_TMP"
|
||||
DEV_INJECTED=true
|
||||
log "human guidance from ${SENDER} injected into ${DEV_SESSION}"
|
||||
matrix_send "dev" "✓ guidance forwarded to dev session for issue #${DEV_ISSUE}" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
||||
else
|
||||
log "dev session ${DEV_SESSION} is busy (phase: ${DEV_CUR_PHASE:-active}), queuing"
|
||||
matrix_send "dev" "✓ received — session is busy, will be available when dev pauses" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
||||
fi
|
||||
else
|
||||
log "dev session ${DEV_SESSION} not found for issue #${DEV_ISSUE}"
|
||||
matrix_send "dev" "dev session not active for issue #${DEV_ISSUE}" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
||||
fi
|
||||
else
|
||||
log "dev thread ${THREAD_ROOT:0:20} has no issue mapping"
|
||||
matrix_send "dev" "✓ received, will act on next poll" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
||||
fi
|
||||
# Only write to flat file when direct injection didn't happen,
|
||||
# to avoid supervisor/gardener poll re-injecting the same message.
|
||||
if [ "$DEV_INJECTED" = false ]; then
|
||||
printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/dev-escalation-reply
|
||||
fi
|
||||
;;
|
||||
review)
|
||||
# Route human questions to persistent review tmux session
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue