feat: implement loop and discuss task delegation types in multi-agent-mux-delegate-job

This commit is contained in:
2026-06-27 08:28:47 +09:00
parent 3b8db1eca2
commit dfd0a9483d
4 changed files with 417 additions and 50 deletions
@@ -0,0 +1,116 @@
# Task Delegation Types (작업 위임 타입) Design Specification
이 문서는 `multi-agent-mux-delegate-job` 스킬에 **작업 위임 타입 (Task Delegation Types)**을 정의하고, 단일 에이전트 실행을 넘어 에이전트 협업 구조(루프, 토론 등)를 체계적으로 오케스트레이션하기 위한 설계 명세입니다.
---
## 1. 개요 및 필요성
기존의 잡 위임 시스템은 단일 에이전트에 지시사항(Prompt)을 전달하고 완료(`completed`) 또는 에러(`error`) 이벤트를 수신하면 작업을 종료하는 **단방향 직접 위임(Direct)** 구조였습니다.
하지만 실제 협업 환경에서는 다음과 같은 유형의 고도화된 협업 흐름이 필요합니다:
1. **자료조사 및 토론 (Research & Discussion)**: 계획 수립 또는 개념 검토를 위해 여러 에이전트가 협의에 이를 때까지 논의를 주고받음.
2. **작업자-리뷰어 루프 (Worker-Reviewer Loop)**: 작업자(Worker)가 코드를 수정하면, 리뷰어(Reviewer)가 검토하여 `PASS`를 줄 때까지 피드백 반영 및 수정을 반복함.
이러한 협업 워크플로우를 개별 에이전트의 내부 코드 수정 없이 **오케스트레이터(위임 스크립트) 레이어에서 제어**할 수 있도록 작업 타입을 도입합니다.
---
## 2. 작업 위임 타입 정의
| 타입명 (`--type`) | 설명 | 워크플로우 흐름 |
|------------------|------|----------------|
| `direct` (기본값) | 단일 에이전트에 대한 직접 위임 | 지시 → 에이전트 수행 → 완료/에러 수신 후 종료 |
| `loop` | 작업자-리뷰어 피드백 루프 | 작업자 실행 → 완료 시 리뷰어 자동 호출 → 리뷰 통과 시 종료, 실패 시 피드백과 함께 작업자 재호출 (반복) |
| `discuss` | 자료조사 및 상호 토론 | 에이전트 A(초안 작성) → 에이전트 B(검토 및 의견 제시) → 에이전트 A(반영 및 수정) → 합의 도달 시 종료 |
---
## 3. CLI 명세 확장
`multi-agent-mux-delegate-job submit` 명령어에 다음 옵션들이 추가됩니다.
```bash
multi-agent-mux-delegate-job submit \
--prompt <text> \
--agent <worker_agent> \
--agent-session <worker_session> \
[--type <direct|loop|discuss>] \
[--reviewer <reviewer_agent>] \
[--reviewer-session <reviewer_session>] \
[--max-iterations <count>] \
[--validate <script>]
```
### 신규 옵션 상세:
* `--type`: 작업 위임 타입을 지정합니다. (`direct`, `loop`, `discuss`)
* `--reviewer`: 리뷰를 담당할 에이전트 이름입니다 (기본값: `hermes`).
* `--reviewer-session`: 리뷰어 에이전트가 돌고 있는 tmux 세션 이름입니다 (기본값: `tmux:hermes`).
* `--max-iterations`: 루프 또는 토론의 최대 반복 횟수입니다 (기본값: `5`).
---
## 4. 작업자-리뷰어 루프 (`loop`) 상태 머신
오케스트레이터는 다음 상태 다이어그램에 따라 작업을 순차적으로 위임하고 이벤트를 구독합니다.
```mermaid
stateDiagram-v2
[*] --> Worker_Pending : Submit Job
Worker_Pending --> Worker_Running : Worker picks job
Worker_Running --> Reviewer_Pending : Worker emits "completed"
Worker_Running --> Error_Terminal : Worker emits "error"
Reviewer_Pending --> Reviewer_Running : Reviewer picks job
Reviewer_Running --> Success_Terminal : Reviewer emits "completed" (PASS)
Reviewer_Running --> Worker_Pending : Reviewer emits "error" / "completed" (Feedback) / Increment Iteration
Reviewer_Running --> Error_Terminal : Reviewer emits "error" & Iteration > Max
Success_Terminal --> [*]
Error_Terminal --> [*]
```
### 단계별 상세 동작 프로토콜:
1. **작업자(Worker) 실행**:
* 오케스트레이터는 작업을 `pending`으로 등록하고, `agent_session`을 작업자 세션(예: `tmux:claude`)으로 설정하여 전달합니다.
* 작업자가 수행을 완료하고 `completed` 이벤트를 발행하면 오케스트레이터가 이를 가로챕니다.
2. **리뷰어(Reviewer)로 스위칭**:
* 오케스트레이터는 전체 작업을 종료하지 않고, 작업 레코드의 `agent_session`을 리뷰어 세션(예: `tmux:hermes`)으로 변경합니다.
* 리뷰어에게 전달할 프롬프트를 자동으로 조립합니다:
> *"Review the changes/artifacts generated for job $JOB_ID. Check if they meet the requirements. If correct, publish completed event with 'PASS'. If there are issues, publish error event with detailed feedback/nits."*
* 상태를 다시 `pending`으로 리셋하여 리뷰어 세션이 잡을 집어갈 수 있도록 합니다.
3. **리뷰 결과 판정**:
* **PASS인 경우**: 리뷰어가 `completed` 이벤트와 함께 `"PASS"` 메시지를 주면 잡이 최종 `completed` 처리되며 오케스트레이터가 종료됩니다.
* **피드백 발생 시**: 리뷰어가 `error` 또는 일반 `completed`와 함께 피드백 내용을 발행하면, 오케스트레이터는 반복 횟수(`iteration`)를 1 증가시킵니다.
* 최대 반복 횟수(`max-iterations`)를 초과한 경우 최종 `error` 종료됩니다.
* 그렇지 않다면, 다시 `agent_session`을 작업자 세션으로 돌리고 프롬프트를 조립하여 `pending` 상태로 돌려보냅니다:
> *"The reviewer provided the following feedback for job $JOB_ID: <리뷰어 피드백>. Please modify the code/artifacts to address these comments."*
---
## 5. 토론 및 협의 (`discuss`) 상태 머신
토론 타입은 작업자와 리뷰어가 동등한 관계(예: PM/기획 에이전트와 설계 에이전트)에서 상호 계획안을 다듬어 나가는 구조입니다.
1. **초안 작성 (PM/Researcher)**:
* PM 세션에 최초 프롬프트를 보냅니다. PM은 요구사항 분석 및 초안을 파일(예: `draft_plan.md`)로 작성하고 `completed`를 발행합니다.
2. **의견 검토 (Designer/Developer)**:
* 작업을 설계자 세션으로 전환하고 다음 프롬프트를 줍니다:
> *"Read draft_plan.md. Review its technical feasibility. Write your feedback/objections to draft_plan.md or comments.md. If you agree with the plan, reply with 'AGREE'."*
3. **합의 도달 여부 검토**:
* 상대방이 `"AGREE"`를 보내면 토론 합의가 성립되어 최종 완료됩니다.
* 반대 의견이 있으면 PM 세션으로 다시 넘겨 계획을 개정하도록 유도합니다.
---
## 6. 구현 계획
1. **`registry.py` 확장**:
* `job_type` (기본값 `"direct"`), `reviewer`, `reviewer_session`, `max_iterations`, `iteration` 필드를 잡 레코드 모델에 추가합니다.
* `register_job` 함수와 CLI 파서에 신규 매개변수를 등록합니다.
2. **`multi-agent-mux-delegate-job` 래퍼 스크립트 수정**:
* `cmd_submit`에서 위임 타입(`--type`)을 받아 루프를 도는 셸 스크립트 상태 기계를 작성합니다.
* 각 에피소드가 끝날 때마다 상태를 변경하여 작업자/리뷰어 간에 소유권을 주고받도록 구현합니다.
3. **검증**:
* 가상의 worker/reviewer 시나리오를 만들거나 claude/hermes 세션에서 직접 상호 검증 루프를 돌려 정상 수렴하는지 테스트합니다.
@@ -16,6 +16,13 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Load local .env if it exists in current dir or workspace root
if [[ -f .env ]]; then
set -a; source .env; set +a
elif [[ -f "$SCRIPT_DIR/../../.env" ]]; then
set -a; source "$SCRIPT_DIR/../../.env"; set +a
fi
# Pick an interpreter: prefer a project .venv, else python3. # Pick an interpreter: prefer a project .venv, else python3.
pick_python() { pick_python() {
local py_bin local py_bin
@@ -46,6 +53,8 @@ multi-agent-mux-delegate-job <command> [options]
submit --agent <name> --prompt <text> [--workdir <dir>] [--agent-session <label>] submit --agent <name> --prompt <text> [--workdir <dir>] [--agent-session <label>]
[--timeout <sec>] [--idle-timeout <sec>] [--validate <script>] [--timeout <sec>] [--idle-timeout <sec>] [--validate <script>]
[--registry-dir <dir>] [--dry-run] [--registry-dir <dir>] [--dry-run]
[--type <direct|loop|discuss>] [--reviewer <reviewer_agent>]
[--reviewer-session <reviewer_session>] [--max-iterations <count>]
# The skill is tmux-interactive only; --mode print was removed. # The skill is tmux-interactive only; --mode print was removed.
status --job <id> [--registry-dir <dir>] status --job <id> [--registry-dir <dir>]
list [--registry-dir <dir>] list [--registry-dir <dir>]
@@ -59,6 +68,7 @@ EOF
AGENT="claude-code"; PROMPT=""; WORKDIR="$(pwd)"; AGENT_SESSION="tmux:claude" AGENT="claude-code"; PROMPT=""; WORKDIR="$(pwd)"; AGENT_SESSION="tmux:claude"
TIMEOUT=3600; IDLE_TIMEOUT=120; VALIDATE=""; DRY_RUN=0 TIMEOUT=3600; IDLE_TIMEOUT=120; VALIDATE=""; DRY_RUN=0
JOB_ID=""; REGISTRY_DIR="$REGISTRY_DIR_DEFAULT" JOB_ID=""; REGISTRY_DIR="$REGISTRY_DIR_DEFAULT"
TYPE="direct"; REVIEWER="hermes"; REVIEWER_SESSION="tmux:hermes"; MAX_ITERATIONS=5
parse_opts() { parse_opts() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -73,6 +83,10 @@ parse_opts() {
--job) JOB_ID="$2"; shift 2;; --job) JOB_ID="$2"; shift 2;;
--registry-dir) REGISTRY_DIR="$2"; shift 2;; --registry-dir) REGISTRY_DIR="$2"; shift 2;;
--dry-run) DRY_RUN=1; shift;; --dry-run) DRY_RUN=1; shift;;
--type) TYPE="$2"; shift 2;;
--reviewer) REVIEWER="$2"; shift 2;;
--reviewer-session) REVIEWER_SESSION="$2"; shift 2;;
--max-iterations) MAX_ITERATIONS="$2"; shift 2;;
*) echo "unknown option: $1" >&2; usage; exit 1;; *) echo "unknown option: $1" >&2; usage; exit 1;;
esac esac
done done
@@ -88,9 +102,12 @@ cmd_submit() {
# 1) register job (prints the new job id) # 1) register job (prints the new job id)
JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \ JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \
--prompt "$PROMPT" --agent "$AGENT" --agent-session "$AGENT_SESSION" \ --prompt "$PROMPT" --agent "$AGENT" --agent-session "$AGENT_SESSION" \
--timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT")" --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
--job-type "$TYPE" --reviewer "$REVIEWER" --reviewer-session "$REVIEWER_SESSION" \
--max-iterations "$MAX_ITERATIONS")"
echo "registered job: $JOB_ID" echo "registered job: $JOB_ID"
if [[ "$TYPE" == "direct" ]]; then
# 2) START THE SUBSCRIBER FIRST (ordering dependency — MQTT does not queue # 2) START THE SUBSCRIBER FIRST (ordering dependency — MQTT does not queue
# non-retained messages for absent subscribers). # non-retained messages for absent subscribers).
local logf="$REGISTRY_DIR/$JOB_ID.subscriber.out" local logf="$REGISTRY_DIR/$JOB_ID.subscriber.out"
@@ -149,10 +166,157 @@ Task: $PROMPT"
# "Audit Logs"). Callers can scrape `tail -n1` to find it. # "Audit Logs"). Callers can scrape `tail -n1` to find it.
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}" local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID" echo "$logs_root/$JOB_ID"
else
# Implement loop/discuss orchestrator
local iteration=1
local current_prompt="$PROMPT"
local current_session="$AGENT_SESSION"
local current_role="worker"
if [[ "$DRY_RUN" == "1" ]]; then
echo "[dry-run] orchestrator loop would start for job: $JOB_ID type: $TYPE"
echo "worker session: $AGENT_SESSION, reviewer session: $REVIEWER_SESSION"
local logs_root_dry="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root_dry/$JOB_ID"
return 0
fi
while true; do
echo "=================================================="
echo "Iteration $iteration - Role: $current_role"
echo "Session: $current_session"
echo "=================================================="
# Update job details in registry
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" update \
--job "$JOB_ID" \
--agent-session "$current_session" \
--prompt "$current_prompt" \
--iteration "$iteration" \
--status "pending"
# Start subscriber
local logf="$REGISTRY_DIR/${JOB_ID}.iter_${iteration}_${current_role}.subscriber.out"
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
--job "$JOB_ID" --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
>"$logf" 2>&1 &
local sub_pid=$!
echo "subscriber pid: $sub_pid (log: $logf)"
sleep 1
# Format instruction block
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID"
local instructions="Your job_id is \"$JOB_ID\" (the one just registered for THIS delegation — read it from the registry record, do NOT reuse any job_id you saw in earlier runs).
On start run: $pub --event started.
On permission/tool prompt run: $pub --event permission_required --detail '<tool>:<what>'.
On progress (optional): $pub --event progress --detail '<short status>'.
On success run: $pub --event completed --detail '<one-line summary>'.
On failure run: $pub --event error --detail '<one-line reason>'.
The subscriber for this job_id is already running; your completed/error event ends the job. Exit codes: 0 completed, 1 error, 2 publish failure.
Task: $current_prompt"
# Trigger agent
run_agent "$JOB_ID" "$instructions" "$current_session"
# Wait for subscriber
# Wait for subscriber
local sub_rc=0
wait "$sub_pid" || sub_rc=$?
echo "subscriber output:"; cat "$logf" || true
# Check job status based on subscriber exit code
local job_status="running"
if [[ $sub_rc -eq 0 ]]; then
job_status="completed"
elif [[ $sub_rc -eq 1 ]]; then
job_status="error"
else
job_status="timeout"
fi
echo "Job role $current_role finished with status: $job_status"
# Retrieve feedback from the last event
local feedback
feedback="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get-feedback --job "$JOB_ID")"
echo "Feedback/Detail: $feedback"
if [[ "$current_role" == "worker" ]]; then
if [[ "$job_status" != "completed" ]]; then
echo "Worker did not complete successfully (status: $job_status). Terminating workflow."
break
fi
# Worker completed successfully, now switch to reviewer
current_role="reviewer"
current_session="$REVIEWER_SESSION"
# Build reviewer prompt based on type
if [[ "$TYPE" == "loop" ]]; then
current_prompt="Review the changes/artifacts generated for job $JOB_ID. Check if they meet the requirements. If correct, publish completed event with 'PASS'. If there are issues, publish error event with detailed feedback/nits."
elif [[ "$TYPE" == "discuss" ]]; then
current_prompt="Read draft/documents generated for job $JOB_ID. Review the feasibility and content. Write your feedback/objections. If you agree with the plan, reply with 'AGREE'."
fi
else
if [[ "$job_status" != "completed" ]]; then
echo "Reviewer did not complete successfully (status: $job_status). Terminating workflow."
break
fi
# Reviewer finished. Check if pass/agree
local success=0
if [[ "$TYPE" == "loop" ]]; then
if [[ "${feedback,,}" == *"pass"* ]]; then
success=1
fi
elif [[ "$TYPE" == "discuss" ]]; then
if [[ "${feedback,,}" == *"agree"* ]]; then
success=1
fi
fi
if [[ "$success" == "1" ]]; then
echo "Reviewer approved the work. Finalizing job as completed."
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" status --job "$JOB_ID" --set "completed"
break
else
# Reviewer rejected/provided feedback. Increment & check max iterations
if [[ $iteration -ge $MAX_ITERATIONS ]]; then
echo "Max iterations ($MAX_ITERATIONS) reached without approval. Terminating workflow."
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" status --job "$JOB_ID" --set "error"
break
fi
iteration=$((iteration + 1))
current_role="worker"
current_session="$AGENT_SESSION"
current_prompt="The reviewer provided the following feedback for job $JOB_ID: $feedback. Please modify the code/artifacts to address these comments."
fi
fi
done
# 4) optional validation hook
if [[ -n "$VALIDATE" ]]; then
echo "running validation: $VALIDATE"
if JOB_ID="$JOB_ID" REGISTRY_DIR="$REGISTRY_DIR" bash "$VALIDATE"; then
echo "validation: PASS"
else
local rc=$?
echo "validation: FAIL (exit $rc)"
fi
fi
# Last stdout line: the persistent audit-log dir
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
fi
} }
run_agent() { run_agent() {
local job_id="$1"; local instructions="$2" local job_id="$1"; local instructions="$2"; local target_session="${3:-$AGENT_SESSION}"
# The skill is INTERACTIVE-ONLY. We never invoke `claude -p` or any other # The skill is INTERACTIVE-ONLY. We never invoke `claude -p` or any other
# one-shot print mode, because: # one-shot print mode, because:
# - claude -p exits the moment stdin is drained, so there's nothing to # - claude -p exits the moment stdin is drained, so there's nothing to
@@ -168,7 +332,7 @@ run_agent() {
echo "[human agent] complete the task, then run publish_event.py --event completed" echo "[human agent] complete the task, then run publish_event.py --event completed"
return return
fi fi
local sess="${AGENT_SESSION#tmux:}" local sess="${target_session#tmux:}"
if [[ "$DRY_RUN" == "1" ]]; then if [[ "$DRY_RUN" == "1" ]]; then
echo "[dry-run] would delegate task to running agent '$AGENT' in tmux session '$sess' with instructions:" echo "[dry-run] would delegate task to running agent '$AGENT' in tmux session '$sess' with instructions:"
@@ -59,11 +59,11 @@ def _format_line(topic: str, payload: Dict[str, Any]) -> str:
class _Watcher: class _Watcher:
"""Holds the shared queue + the set of job_ids we accept events for.""" """Holds the shared queue + the set of job_ids we accept events for."""
def __init__(self, expected_job_ids: Set[str], expected_tokens: Dict[str, Optional[str]]): def __init__(self, expected_job_ids: Set[str], expected_tokens: Dict[str, Optional[str]], expected_seqs: Dict[str, int]):
self.events: "queue.Queue[Tuple[str, Dict[str, Any]]]" = queue.Queue() self.events: "queue.Queue[Tuple[str, Dict[str, Any]]]" = queue.Queue()
self.expected = set(expected_job_ids) self.expected = set(expected_job_ids)
self.tokens = expected_tokens # job_id -> expected auth_token (or None) self.tokens = expected_tokens # job_id -> expected auth_token (or None)
self.last_seq: Dict[str, int] = {jid: 0 for jid in expected_job_ids} self.last_seq = dict(expected_seqs)
def on_message(self, _client, _userdata, msg) -> None: def on_message(self, _client, _userdata, msg) -> None:
# --- defensive parsing ------------------------------------------- # --- defensive parsing -------------------------------------------
@@ -153,7 +153,8 @@ def main(argv=None) -> int:
expected_ids: Set[str] = {j["job_id"] for j in jobs} expected_ids: Set[str] = {j["job_id"] for j in jobs}
tokens = {j["job_id"]: j.get("auth_token") for j in jobs} tokens = {j["job_id"]: j.get("auth_token") for j in jobs}
watcher = _Watcher(expected_ids, tokens) seqs = {j["job_id"]: int(j.get("last_seq", 0)) for j in jobs}
watcher = _Watcher(expected_ids, tokens, seqs)
# Resolve timeouts from CLI, falling back to the (first) job's settings. # Resolve timeouts from CLI, falling back to the (first) job's settings.
base_job = jobs[0] base_job = jobs[0]
@@ -59,6 +59,10 @@ def register_job(
expected_artifacts: Optional[List[str]] = None, expected_artifacts: Optional[List[str]] = None,
bits: int = 32, bits: int = 32,
auth_token: Optional[str] = None, auth_token: Optional[str] = None,
job_type: str = "direct",
reviewer: Optional[str] = None,
reviewer_session: Optional[str] = None,
max_iterations: int = 5,
) -> str: ) -> str:
"""Create a new ``pending`` job record and return its id. """Create a new ``pending`` job record and return its id.
@@ -90,6 +94,11 @@ def register_job(
"expected_artifacts": expected_artifacts or [], "expected_artifacts": expected_artifacts or [],
"last_seq": 0, "last_seq": 0,
"auth_token": auth_token, "auth_token": auth_token,
"job_type": job_type,
"reviewer": reviewer,
"reviewer_session": reviewer_session,
"max_iterations": int(max_iterations),
"iteration": 1,
} }
with registry_lock(registry_dir): with registry_lock(registry_dir):
if mqtt_common._job_path(job_id, registry_dir).exists(): if mqtt_common._job_path(job_id, registry_dir).exists():
@@ -164,7 +173,7 @@ def append_event(job_id: str, registry_dir: str, payload: Dict[str, Any]) -> Non
# convenience re-export so callers can `from registry import load_job` # convenience re-export so callers can `from registry import load_job`
__all__ = [ __all__ = [
"register_job", "pick_pending", "update_status", "load_job", "register_job", "pick_pending", "update_status", "load_job",
"list_jobs", "append_event", "generate_job_id", "list_jobs", "append_event", "generate_job_id", "get_feedback",
] ]
@@ -180,6 +189,44 @@ def _iter_records(registry_dir: str):
logger.warning("skipping unreadable record %s: %s", path, exc) logger.warning("skipping unreadable record %s: %s", path, exc)
def get_feedback(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> str:
"""Read the job's audit log or events log and return the detail of the last completed/error event."""
# 1) Try the unified audit log first (ndjson) since it's written synchronously by the subscriber
try:
import mqtt_common
logs_dir = mqtt_common.LOGS_DIR
events = list(mqtt_common.iter_logged_events(job_id, logs_dir))
for e in reversed(events):
if e.get("source_event") in ("completed", "error"):
return e.get("detail", "")
if e.get("event") in ("completed", "error"):
return e.get("detail", "")
except Exception:
pass
# 2) Fallback to local .events.log
log_path = Path(registry_dir) / f"{job_id}.events.log"
if log_path.exists():
feedback = ""
try:
with open(log_path, "r", encoding="utf-8") as fh:
for line in fh:
if not line.strip():
continue
try:
payload = json.loads(line)
if payload.get("event") in ("completed", "error"):
feedback = payload.get("detail", "")
except json.JSONDecodeError:
continue
except OSError:
pass
if feedback:
return feedback
return ""
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# CLI (so the bash wrapper can shell out without inline python) # CLI (so the bash wrapper can shell out without inline python)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -197,6 +244,10 @@ def _build_parser() -> argparse.ArgumentParser:
p_reg.add_argument("--bits", type=int, default=32, help="32 (PoC) or 128 (prod)") p_reg.add_argument("--bits", type=int, default=32, help="32 (PoC) or 128 (prod)")
p_reg.add_argument("--artifact", action="append", default=[], dest="artifacts") p_reg.add_argument("--artifact", action="append", default=[], dest="artifacts")
p_reg.add_argument("--auth-token", default=None, help="HMAC auth token for the job (auto-generated if secure broker is detected)") p_reg.add_argument("--auth-token", default=None, help="HMAC auth token for the job (auto-generated if secure broker is detected)")
p_reg.add_argument("--job-type", default="direct", choices=["direct", "loop", "discuss"])
p_reg.add_argument("--reviewer", default=None)
p_reg.add_argument("--reviewer-session", default=None)
p_reg.add_argument("--max-iterations", type=int, default=5)
p_list = sub.add_parser("list", help="list jobs (optionally by status)") p_list = sub.add_parser("list", help="list jobs (optionally by status)")
p_list.add_argument("--status", default=None) p_list.add_argument("--status", default=None)
@@ -209,6 +260,16 @@ def _build_parser() -> argparse.ArgumentParser:
p_status.add_argument("--job", required=True) p_status.add_argument("--job", required=True)
p_status.add_argument("--set", required=True, dest="status") p_status.add_argument("--set", required=True, dest="status")
p_update = sub.add_parser("update", help="update a job record")
p_update.add_argument("--job", required=True)
p_update.add_argument("--status", default=None)
p_update.add_argument("--agent-session", default=None)
p_update.add_argument("--prompt", default=None)
p_update.add_argument("--iteration", type=int, default=None)
p_feedback = sub.add_parser("get-feedback", help="get the last feedback detail (completed/error) for a job")
p_feedback.add_argument("--job", required=True)
p_pick = sub.add_parser("pick", help="claim a pending job for a session; prints id") p_pick = sub.add_parser("pick", help="claim a pending job for a session; prints id")
p_pick.add_argument("--agent-session", default="tmux:claude") p_pick.add_argument("--agent-session", default="tmux:claude")
@@ -247,6 +308,10 @@ def main(argv: Optional[List[str]] = None) -> int:
expected_artifacts=args.artifacts, expected_artifacts=args.artifacts,
bits=args.bits, bits=args.bits,
auth_token=args.auth_token, auth_token=args.auth_token,
job_type=args.job_type,
reviewer=args.reviewer,
reviewer_session=args.reviewer_session,
max_iterations=args.max_iterations,
) )
print(job_id) print(job_id)
return 0 return 0
@@ -279,6 +344,27 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1 return 1
return 0 return 0
if args.command == "update":
fields = {}
if args.status is not None:
fields["status"] = args.status
if args.agent_session is not None:
fields["agent_session"] = args.agent_session
if args.prompt is not None:
fields["prompt"] = args.prompt
if args.iteration is not None:
fields["iteration"] = args.iteration
try:
mqtt_common.update_job_status(args.job, rd, **fields)
except FileNotFoundError as exc:
print(str(exc), file=sys.stderr)
return 1
return 0
if args.command == "get-feedback":
print(get_feedback(args.job, rd))
return 0
if args.command == "pick": if args.command == "pick":
job_id = pick_pending(args.agent_session, rd) job_id = pick_pending(args.agent_session, rd)
if job_id is None: if job_id is None: