- Update orchestrate-agent-task-loop skill and dispatch scripts - Add 8 new milestone directories (07+06 through 12+08,11) - Archive completed tasks for 05+04 and 06+05 - Update test configs, docs, and edge-smoke/node-smoke - Add WORK_LOG.md for runtime target selector
6761 lines
283 KiB
Python
6761 lines
283 KiB
Python
import asyncio
|
||
from datetime import datetime, timezone, timedelta
|
||
import importlib.util
|
||
import json
|
||
import os
|
||
import re
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import unittest
|
||
import uuid
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest import mock
|
||
|
||
|
||
SCRIPT = Path(__file__).parents[1] / "scripts" / "dispatch.py"
|
||
SPEC = importlib.util.spec_from_file_location("agent_task_dispatch", SCRIPT)
|
||
assert SPEC and SPEC.loader
|
||
dispatch = importlib.util.module_from_spec(SPEC)
|
||
sys.modules[SPEC.name] = dispatch
|
||
SPEC.loader.exec_module(dispatch)
|
||
|
||
|
||
def pi_session_jsonl(events, version=dispatch.PI_SESSION_SCHEMA_VERSION):
|
||
values = [
|
||
{
|
||
"type": "session",
|
||
"version": version,
|
||
"id": "test-session",
|
||
"timestamp": "2026-07-25T00:00:00.000Z",
|
||
"cwd": "/tmp/test",
|
||
}
|
||
]
|
||
parent_id = None
|
||
for index, event in enumerate(events):
|
||
value = dict(event)
|
||
value.setdefault("id", f"entry-{index}")
|
||
value.setdefault("parentId", parent_id)
|
||
values.append(value)
|
||
parent_id = value["id"]
|
||
return "".join(json.dumps(value) + "\n" for value in values)
|
||
|
||
|
||
def write_legacy_quota_attempts(
|
||
runs: Path,
|
||
task: dispatch.Task,
|
||
*,
|
||
cli: str = "claude",
|
||
model: str = "claude-opus-4-8",
|
||
reasoning_effort: str | None = "xhigh",
|
||
dispatcher_sha256: str = "older-dispatcher",
|
||
) -> list[Path]:
|
||
locators = []
|
||
event = json.dumps(
|
||
{
|
||
"type": "rate_limit_event",
|
||
"rate_limit_info": {"status": "rejected"},
|
||
}
|
||
)
|
||
for attempt_number in range(dispatch.RECOVERY_FAILURE_LIMIT):
|
||
attempt = runs / f"legacy-attempt-{attempt_number}"
|
||
attempt.mkdir(parents=True)
|
||
locator = attempt / "locator.json"
|
||
locator.write_text(
|
||
json.dumps(
|
||
{
|
||
"task": task.name,
|
||
"plan_number": dispatch.plan_number(task),
|
||
"role": "worker",
|
||
"attempt": attempt_number,
|
||
"status": "failed",
|
||
"failure_class": "generic-error",
|
||
"cli": cli,
|
||
"model": model,
|
||
"reasoning_effort": reasoning_effort,
|
||
"dispatcher_source_sha256": dispatcher_sha256,
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
if cli == "agy":
|
||
(attempt / "stream.log").write_text(
|
||
"[stdout] AGY request failed\n",
|
||
encoding="utf-8",
|
||
)
|
||
(attempt / "agy-cli.log").write_text(
|
||
(
|
||
"rpc failed: code = ResourceExhausted "
|
||
"status=RESOURCE_EXHAUSTED HTTP 429 quota exceeded\n"
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
else:
|
||
(attempt / "stream.log").write_text(
|
||
f"[stdout] {event}\n",
|
||
encoding="utf-8",
|
||
)
|
||
locators.append(locator)
|
||
return locators
|
||
|
||
|
||
class CommandConstructionTest(unittest.TestCase):
|
||
def test_agy_print_receives_prompt_before_timeout_option(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
prompt = "Implement the active plan."
|
||
command = dispatch.build_command(
|
||
dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)"),
|
||
prompt, workspace, "test-session", workspace / "attempt",
|
||
)
|
||
|
||
self.assertEqual(command[:5], ["agy", "--print", prompt, "--print-timeout", "8h"])
|
||
self.assertEqual(
|
||
command[-2:], ["--log-file", str(workspace / "attempt" / "agy-cli.log")]
|
||
)
|
||
|
||
|
||
class TaskStageTest(unittest.TestCase):
|
||
def make_task(self, root: Path, review_text: str = ""):
|
||
plan = root / "PLAN-local-G05.md"
|
||
review = root / "CODE_REVIEW-local-G05.md"
|
||
plan.write_text("<!-- task=test plan=0 tag=TEST -->\n", encoding="utf-8")
|
||
review.write_text("<!-- task=test plan=0 tag=TEST -->\n" + review_text, encoding="utf-8")
|
||
return dispatch.Task(
|
||
name="test",
|
||
directory=root,
|
||
plan=plan,
|
||
review=review,
|
||
user_review=None,
|
||
recovery=False,
|
||
lane="local",
|
||
grade=5,
|
||
)
|
||
|
||
|
||
@staticmethod
|
||
def blocking_user_review_text():
|
||
return (
|
||
"# User Review Required - test\n\n"
|
||
"## 상태\n\nUSER_REVIEW\n\n"
|
||
"## 사유\n\n"
|
||
"- 유형: milestone-lock\n"
|
||
"- 연결 대상: agent-roadmap/phase/p/milestones/m.md\n\n"
|
||
"## 차단 근거\n\n"
|
||
"- 차단 판단 근거: API ownership decision blocks implementation.\n\n"
|
||
"## 연결 결정 필요\n\n"
|
||
"- [ ] API ownership 선택\n\n"
|
||
"## 재개 조건\n\n"
|
||
"- Milestone 결정 반영 후 재개\n"
|
||
)
|
||
|
||
def test_default_or_arbitrary_status_text_does_not_start_review(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(
|
||
root,
|
||
"## 사용자 리뷰 요청\n- 상태: 없음\n"
|
||
"## unrelated\n- 상태: 확인 필요\n",
|
||
)
|
||
self.assertEqual(dispatch.task_stage(task, {}), "worker")
|
||
|
||
def test_verdict_text_outside_official_section_does_not_start_review(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(
|
||
root,
|
||
"## 검증 결과\n"
|
||
"명령 출력 예시: 종합 판정: PASS\n",
|
||
)
|
||
self.assertEqual(dispatch.task_stage(task, {}), "worker")
|
||
|
||
def test_exact_official_verdict_section_starts_review_recovery(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(
|
||
root,
|
||
"## 코드리뷰 결과\n"
|
||
"- **종합 판정**: WARN\n",
|
||
)
|
||
self.assertEqual(dispatch.task_stage(task, {}), "review")
|
||
|
||
def test_only_explicit_user_review_file_stops_the_loop(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root, "- 상태: 없음\n")
|
||
task.user_review = root / "USER_REVIEW.md"
|
||
task.user_review.write_text(
|
||
self.blocking_user_review_text(), encoding="utf-8"
|
||
)
|
||
task.plan = None
|
||
task.review = None
|
||
task.recovery = True
|
||
self.assertEqual(dispatch.task_stage(task, {}), "user-review")
|
||
|
||
def test_user_review_without_blocking_contract_is_state_blocked(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
task.user_review = root / "USER_REVIEW.md"
|
||
task.user_review.write_text(
|
||
"## 상태\n\nUSER_REVIEW\n", encoding="utf-8"
|
||
)
|
||
task.plan = None
|
||
task.review = None
|
||
task.recovery = True
|
||
self.assertEqual(dispatch.task_stage(task, {}), "blocked")
|
||
|
||
def test_user_review_with_active_pair_is_state_blocked(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
task.user_review = root / "USER_REVIEW.md"
|
||
task.user_review.write_text(
|
||
self.blocking_user_review_text(), encoding="utf-8"
|
||
)
|
||
self.assertEqual(dispatch.task_stage(task, {}), "blocked")
|
||
|
||
def test_user_review_only_directory_without_logs_is_readable(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
directory = workspace / "agent-task" / "group" / "01_gate"
|
||
directory.mkdir(parents=True)
|
||
user_review = directory / "USER_REVIEW.md"
|
||
user_review.write_text(
|
||
self.blocking_user_review_text(), encoding="utf-8"
|
||
)
|
||
task = dispatch.read_task_directory(workspace, directory)
|
||
self.assertIsNotNone(task)
|
||
self.assertEqual(dispatch.task_stage(task, {}), "user-review")
|
||
|
||
def test_user_review_none_values_do_not_form_a_valid_stop(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
task.user_review = root / "USER_REVIEW.md"
|
||
task.user_review.write_text(
|
||
"## 상태\n\nUSER_REVIEW\n\n"
|
||
"## 사유\n\n"
|
||
"- 유형: milestone-lock\n"
|
||
"- 연결 대상: 없음\n\n"
|
||
"## 차단 근거\n\n"
|
||
"- 차단 판단 근거: 없음\n\n"
|
||
"## 연결 결정 필요\n\n"
|
||
"- [ ] 없음\n\n"
|
||
"## 재개 조건\n\n"
|
||
"- 없음\n",
|
||
encoding="utf-8",
|
||
)
|
||
task.plan = None
|
||
task.review = None
|
||
task.recovery = True
|
||
self.assertEqual(dispatch.task_stage(task, {}), "blocked")
|
||
|
||
def test_completed_implementation_checklist_starts_review(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(
|
||
root,
|
||
"- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채운다.\n",
|
||
)
|
||
self.assertEqual(dispatch.task_stage(task, {}), "review")
|
||
|
||
def test_pi_worker_success_requires_selfcheck_before_review(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
self.assertEqual(
|
||
dispatch.task_stage(task, {"worker_done": True, "selfcheck_done": False}),
|
||
"selfcheck",
|
||
)
|
||
self.assertEqual(
|
||
dispatch.task_stage(task, {"worker_done": True, "selfcheck_done": True}),
|
||
"review",
|
||
)
|
||
|
||
def test_local_route_grade_boundaries(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
task = self.make_task(Path(temporary))
|
||
expected = {
|
||
5: ("pi", "ornith:35b", True),
|
||
6: ("pi", "ornith:35b", True),
|
||
9: ("claude", "claude-opus-4-8", False),
|
||
10: ("claude", "claude-opus-4-8", False),
|
||
}
|
||
for grade, (cli, model, local_pi) in expected.items():
|
||
with self.subTest(grade=grade):
|
||
assert task.plan is not None
|
||
graded_plan = task.plan.with_name(f"PLAN-local-G{grade:02d}.md")
|
||
task.plan.rename(graded_plan)
|
||
task.plan = graded_plan
|
||
task.grade = grade
|
||
decision = dispatch.select_execution_decision(task, stage="worker")
|
||
spec = dispatch.agent_spec_from_decision(decision)
|
||
self.assertEqual(spec.cli, cli)
|
||
self.assertEqual(spec.model, model)
|
||
self.assertEqual(spec.local_pi, local_pi)
|
||
|
||
def test_local_g07_g08_route_uses_explicit_kst_boundaries(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
nighttime = datetime(2026, 7, 26, 1, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
task = self.make_task(Path(temporary))
|
||
for grade in (7, 8):
|
||
assert task.plan is not None
|
||
graded_plan = task.plan.with_name(f"PLAN-local-G{grade:02d}.md")
|
||
task.plan.rename(graded_plan)
|
||
task.plan = graded_plan
|
||
task.grade = grade
|
||
|
||
day_dec = dispatch.select_execution_decision(task, stage="worker", evaluated_at=daytime)
|
||
self.assertEqual(day_dec["selected"]["adapter"], "agy")
|
||
self.assertEqual(day_dec["selected"]["target"], "Gemini 3.6 Flash (Medium)")
|
||
|
||
night_dec = dispatch.select_execution_decision(task, stage="worker", evaluated_at=nighttime)
|
||
self.assertEqual(night_dec["selected"]["adapter"], "pi")
|
||
self.assertEqual(night_dec["selected"]["target"], "iop/laguna-s:2.1")
|
||
|
||
def test_new_canonical_pi_target_gets_default_invocation_semaphore(self):
|
||
existing = asyncio.Semaphore(3)
|
||
semaphores = {"pi:ornith:35b": existing}
|
||
ornith = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
laguna = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi", local_pi=True)
|
||
|
||
self.assertIs(dispatch.invocation_semaphore_for(semaphores, ornith), existing)
|
||
created = dispatch.invocation_semaphore_for(semaphores, laguna)
|
||
self.assertIs(created, semaphores["pi:laguna-s:2.1"])
|
||
self.assertEqual(created._value, 64)
|
||
|
||
def test_selfcheck_requires_all_implementation_review_fields(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
task = self.make_task(
|
||
Path(temporary),
|
||
"## 구현 항목별 완료 여부\n\n"
|
||
"| 항목 | 완료 여부 |\n|---|---|\n| TEST-1 | [ ] |\n\n"
|
||
"## 구현 체크리스트\n\n- [ ] TEST-1\n"
|
||
"- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채운다.\n\n"
|
||
"## 계획 대비 변경 사항\n\n_미작성_\n\n"
|
||
"## 주요 설계 결정\n\n_미작성_\n\n"
|
||
"## 검증 결과\n\n결과:\n_미실행_\n",
|
||
)
|
||
self.assertEqual(
|
||
dispatch.implementation_review_errors(task),
|
||
[
|
||
"구현 체크리스트 미완료",
|
||
"구현 항목 완료 여부 미작성",
|
||
"계획 대비 변경 사항 미작성",
|
||
"주요 설계 결정 미작성",
|
||
"검증 결과 미작성",
|
||
"CODE_REVIEW 동기화 체크 미완료",
|
||
],
|
||
)
|
||
|
||
def test_selfcheck_accepts_filled_implementation_review_fields(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
task = self.make_task(
|
||
Path(temporary),
|
||
"## 구현 항목별 완료 여부\n\n"
|
||
"| 항목 | 완료 여부 |\n|---|---|\n| TEST-1 | [x] |\n\n"
|
||
"## 구현 체크리스트\n\n- [x] TEST-1\n"
|
||
"- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채운다.\n\n"
|
||
"## 계획 대비 변경 사항\n\n계획과 동일하게 구현했다.\n\n"
|
||
"## 주요 설계 결정\n\n상태 전이를 직렬화했다.\n\n"
|
||
"## 검증 결과\n\n결과: PASS (exit 0)\n",
|
||
)
|
||
self.assertEqual(dispatch.implementation_review_errors(task), [])
|
||
|
||
|
||
class LegacyWorkLogContractHelpers:
|
||
@staticmethod
|
||
def completed_replacements():
|
||
return {
|
||
"### 목표와 범위\n\n- 미작성": (
|
||
"### 목표와 범위\n\n- PLAN 범위 구현 및 검증"
|
||
),
|
||
"### 체크포인트\n\n- 기록 없음": (
|
||
"### 체크포인트\n\n"
|
||
"- 2026-07-24T00:01:00Z | 구현 | 완료 | 핵심 경로 수정 | "
|
||
"evidence=`src/test.go` | next=검증"
|
||
),
|
||
"### 예상 밖 이슈\n\n- 기록 없음": (
|
||
"### 예상 밖 이슈\n\n"
|
||
"- 2026-07-24T00:02:00Z | correctness | 계획 밖 race 가능성 | "
|
||
"impact=동시성 오류 | action=수정 및 테스트 | disposition=해결"
|
||
),
|
||
"### 검증\n\n- 기록 없음": (
|
||
"### 검증\n\n- `go test ./...` - PASS"
|
||
),
|
||
"- 상태: 미작성": "- 상태: 완료",
|
||
"- 요약: 미작성": "- 요약: 구현 및 검증 완료",
|
||
"- 완료 항목: 미작성": "- 완료 항목: 계획 체크리스트 전체",
|
||
"- 변경 파일: 미작성": "- 변경 파일: `src/test.go`",
|
||
"- 검증: 미작성": "- 검증: `go test ./...` PASS",
|
||
"- 미해결/후속: 미작성": "- 미해결/후속: 없음",
|
||
"- 예상 밖 이슈 요약: 미작성": (
|
||
"- 예상 밖 이슈 요약: race 가능성 수정 완료"
|
||
),
|
||
"- CODE_REVIEW 동기화: 미작성": "- CODE_REVIEW 동기화: 완료",
|
||
}
|
||
|
||
def make_completed_log(self, root: Path, task=None):
|
||
task = task or TaskStageTest().make_task(root)
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
execution_id = "test__p0__worker__a00"
|
||
path = dispatch.append_work_log_attempt(
|
||
task,
|
||
execution_id,
|
||
"worker",
|
||
spec,
|
||
root / "locator.json",
|
||
"2026-07-24T00:00:00+00:00",
|
||
)
|
||
text = path.read_text(encoding="utf-8")
|
||
for before, after in self.completed_replacements().items():
|
||
self.assertIn(before, text)
|
||
text = text.replace(before, after, 1)
|
||
path.write_text(text, encoding="utf-8")
|
||
return task, path, execution_id
|
||
|
||
def test_template_and_attempt_require_checkpoints_and_final_report(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = TaskStageTest().make_task(root)
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
execution_id = "test__p0__worker__a00"
|
||
path = dispatch.append_work_log_attempt(
|
||
task,
|
||
execution_id,
|
||
"worker",
|
||
spec,
|
||
root / "locator.json",
|
||
"2026-07-24T00:00:00+00:00",
|
||
)
|
||
rendered = path.read_text(encoding="utf-8")
|
||
self.assertNotIn(dispatch.WORK_LOG_TEMPLATE_START, rendered)
|
||
self.assertIn(f"## 실행 `{execution_id}`", rendered)
|
||
status, errors = dispatch.work_log_attempt_result(path, execution_id)
|
||
self.assertIsNone(status)
|
||
self.assertIn("체크포인트 미작성", errors)
|
||
self.assertIn("최종 리포트 상태 미작성", errors)
|
||
|
||
def test_completed_attempt_preserves_unexpected_issue_and_runtime_result(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
_, path, execution_id = self.make_completed_log(root)
|
||
status, errors = dispatch.work_log_attempt_result(path, execution_id)
|
||
self.assertEqual(status, "완료")
|
||
self.assertEqual(errors, [])
|
||
dispatch.append_work_log_runtime_result(
|
||
path,
|
||
execution_id,
|
||
exit_code=0,
|
||
failure_class=None,
|
||
locator=root / "locator.json",
|
||
)
|
||
text = path.read_text(encoding="utf-8")
|
||
self.assertIn("계획 밖 race 가능성", text)
|
||
self.assertIn(f"### 런타임 종료 기록 `{execution_id}`", text)
|
||
self.assertIn("- failure_class: `none`", text)
|
||
|
||
def test_checkpoint_cannot_be_replaced_with_none(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
_, path, execution_id = self.make_completed_log(root)
|
||
text = path.read_text(encoding="utf-8")
|
||
checkpoint = self.completed_replacements()[
|
||
"### 체크포인트\n\n- 기록 없음"
|
||
]
|
||
path.write_text(
|
||
text.replace(checkpoint, "### 체크포인트\n\n- 없음", 1),
|
||
encoding="utf-8",
|
||
)
|
||
status, errors = dispatch.work_log_attempt_result(path, execution_id)
|
||
self.assertEqual(status, "완료")
|
||
self.assertIn("체크포인트 형식 불일치", errors)
|
||
|
||
def test_unexpected_issue_accepts_explicit_none(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
_, path, execution_id = self.make_completed_log(root)
|
||
text = path.read_text(encoding="utf-8")
|
||
unexpected = self.completed_replacements()[
|
||
"### 예상 밖 이슈\n\n- 기록 없음"
|
||
]
|
||
path.write_text(
|
||
text.replace(unexpected, "### 예상 밖 이슈\n\n- 없음", 1),
|
||
encoding="utf-8",
|
||
)
|
||
status, errors = dispatch.work_log_attempt_result(path, execution_id)
|
||
self.assertEqual(status, "완료")
|
||
self.assertEqual(errors, [])
|
||
|
||
def test_code_review_sync_must_be_exactly_complete(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
_, path, execution_id = self.make_completed_log(root)
|
||
text = path.read_text(encoding="utf-8")
|
||
path.write_text(
|
||
text.replace(
|
||
"- CODE_REVIEW 동기화: 완료",
|
||
"- CODE_REVIEW 동기화: 실패",
|
||
1,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
status, errors = dispatch.work_log_attempt_result(path, execution_id)
|
||
self.assertEqual(status, "완료")
|
||
self.assertIn(
|
||
"최종 리포트 CODE_REVIEW 동기화는 완료여야 한다",
|
||
errors,
|
||
)
|
||
|
||
def test_completed_review_checklist_does_not_depend_on_worker_log(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = TaskStageTest().make_task(
|
||
root,
|
||
"- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 "
|
||
"실제 구현 내용과 검증 출력으로 채운다.\n"
|
||
"- [x] `WORK_LOG.md` 현재 실행 블록의 체크포인트, 예상 밖 이슈, "
|
||
"검증, 최종 리포트를 모두 채운다. 이 항목이 완료되기 전에는 "
|
||
"구현이 완료된 것이 아니다.\n",
|
||
)
|
||
task.plan.write_text(
|
||
task.plan.read_text(encoding="utf-8")
|
||
+ "## 작업 로그 계약\n",
|
||
encoding="utf-8",
|
||
)
|
||
self.assertEqual(dispatch.task_stage(task, {}), "review")
|
||
|
||
def test_prompt_requires_checkpoints_unexpected_issues_and_final_report(self):
|
||
path = Path("/tmp/task/WORK_LOG.md")
|
||
prompt = dispatch.work_log_prompt(path, "task__p0__worker__a00")
|
||
self.assertIn("checkpoint after each meaningful phase", prompt)
|
||
self.assertIn("unexpected issues", prompt)
|
||
self.assertIn("최종 리포트", prompt)
|
||
self.assertIn("CODE_REVIEW", prompt)
|
||
|
||
def test_state_loss_skips_execution_id_already_present_in_work_log(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task, _, _ = self.make_completed_log(root)
|
||
store = mock.Mock()
|
||
store.next_attempt.side_effect = [0, 1]
|
||
attempt, execution_id = dispatch.next_execution_identity(
|
||
store,
|
||
task,
|
||
"worker",
|
||
)
|
||
self.assertEqual(attempt, 1)
|
||
self.assertEqual(execution_id, "test__p0__worker__a01")
|
||
self.assertEqual(store.next_attempt.call_count, 2)
|
||
|
||
|
||
class WorkLogInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
||
def test_work_log_timestamp_uses_compact_kst_format(self):
|
||
fixed_kst = datetime(2026, 7, 26, 7, 40, 15, tzinfo=dispatch.KST)
|
||
with mock.patch.object(dispatch, "datetime") as datetime_mock:
|
||
datetime_mock.now.return_value = fixed_kst
|
||
self.assertEqual(dispatch.work_log_now_kst(), "26-07-26 07:40:15")
|
||
datetime_mock.now.assert_called_once_with(dispatch.KST)
|
||
|
||
self.assertRegex(dispatch.now_iso(), r"\+00:00$")
|
||
|
||
async def test_invoke_writes_dispatcher_owned_milestone_timeline(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
command = [
|
||
sys.executable,
|
||
"-c",
|
||
(
|
||
"import os;"
|
||
f"assert os.environ.get('{dispatch.AGENT_PROCESS_MARKER_ENV}');"
|
||
"print('work complete', flush=True)"
|
||
),
|
||
]
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"build_command",
|
||
return_value=command,
|
||
) as build_command:
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace,
|
||
store,
|
||
task,
|
||
"worker",
|
||
spec,
|
||
"Read the plan.",
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 0)
|
||
self.assertIsNone(failure)
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(
|
||
record["work_log"],
|
||
str((workspace / dispatch.WORK_LOG_NAME).resolve()),
|
||
)
|
||
self.assertTrue(
|
||
record["agent_process_marker"].startswith(
|
||
"test__p0__worker__a00__"
|
||
)
|
||
)
|
||
self.assertEqual(record["status"], "succeeded")
|
||
prompt = build_command.call_args.args[1]
|
||
self.assertEqual(prompt, "Read the plan.")
|
||
self.assertNotIn("checkpoint after each meaningful phase", prompt)
|
||
log = (workspace / dispatch.WORK_LOG_NAME).read_text(encoding="utf-8")
|
||
self.assertIn("Dispatcher-owned execution timeline", log)
|
||
self.assertRegex(log, r"\| \d+ \| \d{2}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \| START \|")
|
||
self.assertRegex(log, r"\| \d+ \| \d{2}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \| FINISH \|")
|
||
self.assertIn("| START | test | worker | 0 | pi | running |", log)
|
||
self.assertIn("| FINISH | test | worker | 0 | pi | succeeded:0 |", log)
|
||
|
||
async def test_invoke_does_not_require_model_written_work_log(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
command = [sys.executable, "-c", "print('done without report')"]
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"build_command",
|
||
return_value=command,
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace,
|
||
store,
|
||
task,
|
||
"worker",
|
||
spec,
|
||
"Read the plan.",
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 0)
|
||
self.assertIsNone(failure)
|
||
|
||
async def test_locator_refresh_failure_does_not_abort_live_model(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
real_write_json = dispatch.write_json
|
||
failed_once = False
|
||
|
||
def flaky_write_json(path, value):
|
||
nonlocal failed_once
|
||
if (
|
||
path.name == "locator.json"
|
||
and value.get("agent_pid") is not None
|
||
and not failed_once
|
||
):
|
||
failed_once = True
|
||
raise OSError("transient locator write failure")
|
||
return real_write_json(path, value)
|
||
|
||
spec = dispatch.AgentSpec(
|
||
"pi", "ornith:35b", "pi", local_pi=True
|
||
)
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"build_command",
|
||
return_value=[
|
||
sys.executable,
|
||
"-c",
|
||
"print('completed', flush=True)",
|
||
],
|
||
),
|
||
mock.patch.object(
|
||
dispatch, "write_json", side_effect=flaky_write_json
|
||
),
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace, store, task, "worker", spec, "Work."
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertTrue(failed_once)
|
||
self.assertEqual(rc, 0)
|
||
self.assertIsNone(failure)
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["status"], "succeeded")
|
||
self.assertIn("transient locator write failure", record["locator_write_error"])
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["status"], "succeeded")
|
||
self.assertNotIn("work_log_contract_errors", record)
|
||
|
||
async def test_existing_milestone_log_is_preserved_and_extended(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
(workspace / dispatch.WORK_LOG_NAME).write_text(
|
||
"# malformed\n",
|
||
encoding="utf-8",
|
||
)
|
||
store = dispatch.StateStore(workspace)
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
command = [sys.executable, "-c", "print('done')"]
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch, "build_command", return_value=command
|
||
) as build_command:
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace,
|
||
store,
|
||
task,
|
||
"worker",
|
||
spec,
|
||
"Read the plan.",
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 0)
|
||
self.assertIsNone(failure)
|
||
build_command.assert_called_once()
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["status"], "succeeded")
|
||
log = (workspace / dispatch.WORK_LOG_NAME).read_text(encoding="utf-8")
|
||
self.assertIn("# malformed", log)
|
||
self.assertIn("## Dispatcher Timeline", log)
|
||
|
||
async def test_runtime_log_write_failure_finishes_locator_as_blocked_failure(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
command = [sys.executable, "-c", "print('done')"]
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"build_command",
|
||
return_value=command,
|
||
),
|
||
mock.patch.object(
|
||
dispatch,
|
||
"append_milestone_event",
|
||
side_effect=[
|
||
workspace / dispatch.WORK_LOG_NAME,
|
||
OSError("disk full"),
|
||
],
|
||
),
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace,
|
||
store,
|
||
task,
|
||
"worker",
|
||
spec,
|
||
"Read the plan.",
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 0)
|
||
self.assertEqual(failure, "work-log-runtime-write")
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["status"], "failed")
|
||
self.assertEqual(record["failure_class"], "work-log-runtime-write")
|
||
self.assertEqual(record["work_log_runtime_error"], "disk full")
|
||
|
||
async def test_cancelled_invoke_finishes_locator_and_runtime_record(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
command = [
|
||
sys.executable,
|
||
"-c",
|
||
"import time; print('ready', flush=True); time.sleep(60)",
|
||
]
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
invocation = None
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"build_command",
|
||
return_value=command,
|
||
):
|
||
invocation = asyncio.create_task(
|
||
dispatch.invoke(
|
||
workspace,
|
||
store,
|
||
task,
|
||
"worker",
|
||
spec,
|
||
"Read the plan.",
|
||
)
|
||
)
|
||
locator = None
|
||
for _ in range(100):
|
||
candidates = list(store.runs.glob("*/locator.json"))
|
||
if candidates:
|
||
candidate = candidates[0]
|
||
record = json.loads(
|
||
candidate.read_text(encoding="utf-8")
|
||
)
|
||
output = Path(record["output_log"])
|
||
if (
|
||
output.is_file()
|
||
and "ready" in output.read_text(encoding="utf-8")
|
||
):
|
||
locator = candidate
|
||
break
|
||
await asyncio.sleep(0.01)
|
||
self.assertIsNotNone(locator)
|
||
invocation.cancel()
|
||
with self.assertRaises(asyncio.CancelledError):
|
||
await invocation
|
||
finally:
|
||
if invocation is not None and not invocation.done():
|
||
invocation.cancel()
|
||
await asyncio.gather(invocation, return_exceptions=True)
|
||
store.close()
|
||
|
||
assert locator is not None
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["status"], "failed")
|
||
self.assertEqual(record["exit_code"], "cancelled")
|
||
self.assertEqual(record["failure_class"], "cancelled")
|
||
log = (workspace / dispatch.WORK_LOG_NAME).read_text(encoding="utf-8")
|
||
self.assertIn("| FINISH | test | worker | 0 | pi | failed:cancelled |", log)
|
||
|
||
async def test_heartbeat_updates_output_and_locator_with_pi_native_session(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
session_id = "11111111-1111-1111-1111-111111111111"
|
||
|
||
def command_for(
|
||
spec,
|
||
prompt,
|
||
cwd,
|
||
actual_session_id,
|
||
attempt_dir,
|
||
pi_resume_session=None,
|
||
):
|
||
self.assertEqual(actual_session_id, session_id)
|
||
native = attempt_dir / "pi-sessions" / f"session_{session_id}.jsonl"
|
||
child = (
|
||
"from pathlib import Path\n"
|
||
"import sys,time\n"
|
||
"path = Path(sys.argv[1])\n"
|
||
"path.parent.mkdir(parents=True, exist_ok=True)\n"
|
||
"path.write_text("
|
||
"'{\"type\":\"session\",\"version\":3,\"id\":\"test\","
|
||
"\"timestamp\":\"2026-07-25T00:00:00.000Z\","
|
||
"\"cwd\":\"/tmp/test\"}\\n', encoding='utf-8')\n"
|
||
"time.sleep(0.05)\n"
|
||
"print('done', flush=True)\n"
|
||
)
|
||
return [sys.executable, "-c", child, str(native)]
|
||
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
try:
|
||
with (
|
||
mock.patch.object(dispatch, "build_command", side_effect=command_for),
|
||
mock.patch.object(dispatch.uuid, "uuid4", return_value=session_id),
|
||
mock.patch.object(dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01),
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace, store, task, "review", spec, "Reply briefly."
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 0)
|
||
self.assertIsNone(failure)
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertTrue(record["native_session_path"].endswith(f"{session_id}.jsonl"))
|
||
self.assertIsInstance(record["native_session_mtime_ns"], int)
|
||
heartbeat = Path(record["heartbeat_log"]).read_text(encoding="utf-8")
|
||
self.assertIn("[heartbeat] 작업중...", heartbeat)
|
||
self.assertIn("native_session=", heartbeat)
|
||
self.assertIn("native_mtime_ns=", heartbeat)
|
||
stream = Path(record["stream_log"]).read_text(encoding="utf-8")
|
||
self.assertIn("[stdout] done", stream)
|
||
self.assertNotIn("[heartbeat]", stream)
|
||
|
||
async def test_pi_silent_awaiting_model_is_inspected_without_termination(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
session_id = "22222222-2222-2222-2222-222222222222"
|
||
|
||
def command_for(
|
||
spec,
|
||
prompt,
|
||
cwd,
|
||
actual_session_id,
|
||
attempt_dir,
|
||
pi_resume_session=None,
|
||
):
|
||
native = attempt_dir / "pi-sessions" / f"session_{session_id}.jsonl"
|
||
child = (
|
||
"from pathlib import Path\n"
|
||
"import sys,time\n"
|
||
"path = Path(sys.argv[1])\n"
|
||
"path.parent.mkdir(parents=True, exist_ok=True)\n"
|
||
"path.write_text("
|
||
"'{\"type\":\"session\",\"version\":3,\"id\":\"test\","
|
||
"\"timestamp\":\"2026-07-25T00:00:00.000Z\","
|
||
"\"cwd\":\"/tmp/test\"}\\n"
|
||
"{\"type\":\"message\",\"id\":\"assistant-1\","
|
||
"\"parentId\":null,\"message\":{\"role\":\"assistant\","
|
||
"\"content\":[{\"type\":\"toolCall\",\"id\":\"call-1\","
|
||
"\"name\":\"read\"}]}}\\n"
|
||
"{\"type\":\"message\",\"id\":\"result-1\","
|
||
"\"parentId\":\"assistant-1\","
|
||
"\"message\":{\"role\":\"toolResult\","
|
||
"\"toolCallId\":\"call-1\",\"content\":[]}}\\n', "
|
||
"encoding='utf-8')\n"
|
||
"time.sleep(0.08)\n"
|
||
)
|
||
return [sys.executable, "-c", child, str(native)]
|
||
|
||
spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi", local_pi=True)
|
||
try:
|
||
with (
|
||
mock.patch.object(dispatch, "build_command", side_effect=command_for),
|
||
mock.patch.object(dispatch.uuid, "uuid4", return_value=session_id),
|
||
mock.patch.object(dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01),
|
||
mock.patch.object(
|
||
dispatch, "PI_MODEL_RESPONSE_STALL_SECONDS", 0.03
|
||
),
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace, store, task, "review", spec, "Reply briefly."
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 0)
|
||
self.assertIsNone(failure)
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["status"], "succeeded")
|
||
self.assertEqual(record["pi_session_phase"], "awaiting-model")
|
||
self.assertEqual(
|
||
record["pi_session_phase_reason"],
|
||
"all-tool-results-recorded",
|
||
)
|
||
self.assertEqual(record["pi_expected_tool_call_ids"], ["call-1"])
|
||
self.assertEqual(record["pi_completed_tool_call_ids"], ["call-1"])
|
||
self.assertEqual(record["pi_pending_tool_call_ids"], [])
|
||
inspection = record["pi_silence_inspection"]
|
||
self.assertGreaterEqual(inspection["silence_seconds"], 0.03)
|
||
self.assertIn("stream_tail", inspection)
|
||
heartbeat = Path(record["heartbeat_log"]).read_text(encoding="utf-8")
|
||
self.assertIn("[silence-inspection]", heartbeat)
|
||
|
||
async def test_pi_silent_starting_state_is_inspected_without_termination(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
session_id = "24242424-2424-2424-2424-242424242424"
|
||
|
||
def command_for(
|
||
spec,
|
||
prompt,
|
||
cwd,
|
||
actual_session_id,
|
||
attempt_dir,
|
||
pi_resume_session=None,
|
||
):
|
||
native = attempt_dir / "pi-sessions" / f"session_{session_id}.jsonl"
|
||
child = (
|
||
"from pathlib import Path\n"
|
||
"import sys,time\n"
|
||
"path = Path(sys.argv[1])\n"
|
||
"path.parent.mkdir(parents=True, exist_ok=True)\n"
|
||
"path.write_text("
|
||
"'{\"type\":\"session\",\"version\":3,\"id\":\"test\","
|
||
"\"timestamp\":\"2026-07-25T00:00:00.000Z\","
|
||
"\"cwd\":\"/tmp/test\"}\\n', encoding='utf-8')\n"
|
||
"time.sleep(0.08)\n"
|
||
)
|
||
return [sys.executable, "-c", child, str(native)]
|
||
|
||
spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi", local_pi=True)
|
||
try:
|
||
with (
|
||
mock.patch.object(dispatch, "build_command", side_effect=command_for),
|
||
mock.patch.object(dispatch.uuid, "uuid4", return_value=session_id),
|
||
mock.patch.object(dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01),
|
||
mock.patch.object(
|
||
dispatch, "PI_MODEL_RESPONSE_STALL_SECONDS", 0.03
|
||
),
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace, store, task, "review", spec, "Reply briefly."
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 0)
|
||
self.assertIsNone(failure)
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["status"], "succeeded")
|
||
self.assertEqual(record["pi_session_phase"], "starting")
|
||
self.assertIsNone(record["pi_stall_timeout_seconds"])
|
||
self.assertIn("pi_silence_inspection", record)
|
||
heartbeat = Path(record["heartbeat_log"]).read_text(encoding="utf-8")
|
||
self.assertNotIn("[session-stall]", heartbeat)
|
||
|
||
async def test_pi_json_stream_progress_prevents_native_only_stall(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
session_id = "23232323-2323-2323-2323-232323232323"
|
||
|
||
def command_for(
|
||
spec,
|
||
prompt,
|
||
cwd,
|
||
actual_session_id,
|
||
attempt_dir,
|
||
pi_resume_session=None,
|
||
):
|
||
native = attempt_dir / "pi-sessions" / f"session_{session_id}.jsonl"
|
||
child = (
|
||
"from pathlib import Path\n"
|
||
"import json,sys,time\n"
|
||
"path = Path(sys.argv[1])\n"
|
||
"path.parent.mkdir(parents=True, exist_ok=True)\n"
|
||
"path.write_text("
|
||
"'{\"type\":\"session\",\"version\":3,\"id\":\"test\","
|
||
"\"timestamp\":\"2026-07-25T00:00:00.000Z\","
|
||
"\"cwd\":\"/tmp/test\"}\\n', encoding='utf-8')\n"
|
||
"for _ in range(8):\n"
|
||
" print(json.dumps({'type': 'message_update'}), flush=True)\n"
|
||
" time.sleep(0.015)\n"
|
||
)
|
||
return [sys.executable, "-c", child, str(native)]
|
||
|
||
spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi", local_pi=True)
|
||
try:
|
||
with (
|
||
mock.patch.object(dispatch, "build_command", side_effect=command_for),
|
||
mock.patch.object(dispatch.uuid, "uuid4", return_value=session_id),
|
||
mock.patch.object(dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01),
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace, store, task, "review", spec, "Reply briefly."
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 0)
|
||
self.assertIsNone(failure)
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["status"], "succeeded")
|
||
self.assertEqual(record["pi_activity_state"], "streaming")
|
||
stream = Path(record["stream_log"]).read_text(encoding="utf-8")
|
||
self.assertIn('[stdout] {"type": "message_update"}', stream)
|
||
|
||
async def test_pi_incomplete_tool_batch_has_no_automatic_timeout(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
session_id = "33333333-3333-3333-3333-333333333333"
|
||
|
||
def command_for(
|
||
spec,
|
||
prompt,
|
||
cwd,
|
||
actual_session_id,
|
||
attempt_dir,
|
||
pi_resume_session=None,
|
||
):
|
||
native = attempt_dir / "pi-sessions" / f"session_{session_id}.jsonl"
|
||
child = (
|
||
"from pathlib import Path\n"
|
||
"import sys,time\n"
|
||
"path = Path(sys.argv[1])\n"
|
||
"path.parent.mkdir(parents=True, exist_ok=True)\n"
|
||
"path.write_text("
|
||
"'{\"type\":\"session\",\"version\":3,\"id\":\"test\","
|
||
"\"timestamp\":\"2026-07-25T00:00:00.000Z\","
|
||
"\"cwd\":\"/tmp/test\"}\\n"
|
||
"{\"type\":\"message\",\"id\":\"assistant-1\","
|
||
"\"parentId\":null,\"message\":{\"role\":\"assistant\","
|
||
"\"content\":[{\"type\":\"toolCall\",\"id\":\"call-a\","
|
||
"\"name\":\"read\"},{\"type\":\"toolCall\",\"id\":\"call-b\","
|
||
"\"name\":\"bash\"}]}}\\n"
|
||
"{\"type\":\"message\",\"id\":\"result-a\","
|
||
"\"parentId\":\"assistant-1\","
|
||
"\"message\":{\"role\":\"toolResult\","
|
||
"\"toolCallId\":\"call-a\",\"content\":[]}}\\n', "
|
||
"encoding='utf-8')\n"
|
||
"time.sleep(0.08)\n"
|
||
"with path.open('a', encoding='utf-8') as stream:\n"
|
||
" stream.write("
|
||
"'{\"type\":\"message\",\"id\":\"result-b\","
|
||
"\"parentId\":\"result-a\","
|
||
"\"message\":{\"role\":\"toolResult\","
|
||
"\"toolCallId\":\"call-b\",\"content\":[]}}\\n')\n"
|
||
"print('done', flush=True)\n"
|
||
)
|
||
return [sys.executable, "-c", child, str(native)]
|
||
|
||
spec = dispatch.AgentSpec(
|
||
"pi", "laguna-s:2.1", "pi", local_pi=True
|
||
)
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch, "build_command", side_effect=command_for
|
||
),
|
||
mock.patch.object(
|
||
dispatch.uuid, "uuid4", return_value=session_id
|
||
),
|
||
mock.patch.object(
|
||
dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01
|
||
),
|
||
mock.patch.object(
|
||
dispatch, "PI_MODEL_RESPONSE_STALL_SECONDS", 0.02
|
||
),
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace, store, task, "review", spec, "Reply briefly."
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 0)
|
||
self.assertIsNone(failure)
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertIsNone(record["pi_stall_timeout_seconds"])
|
||
heartbeat = Path(record["heartbeat_log"]).read_text(encoding="utf-8")
|
||
self.assertNotIn("[session-stall]", heartbeat)
|
||
|
||
async def test_resume_heartbeat_preserves_prior_native_session_path(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
native = workspace / "prior-session.jsonl"
|
||
native.write_text(pi_session_jsonl([]), encoding="utf-8")
|
||
prior_locator = workspace / "prior-locator.json"
|
||
prior_locator.write_text(
|
||
json.dumps(
|
||
{
|
||
"session_id": "resume-session",
|
||
"native_session_path": str(native),
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
def command_for(
|
||
spec,
|
||
prompt,
|
||
cwd,
|
||
actual_session_id,
|
||
attempt_dir,
|
||
pi_resume_session=None,
|
||
):
|
||
self.assertEqual(pi_resume_session, native)
|
||
return [
|
||
sys.executable,
|
||
"-c",
|
||
"import time; time.sleep(0.05); print('done', flush=True)",
|
||
]
|
||
|
||
spec = dispatch.AgentSpec(
|
||
"pi", "laguna-s:2.1", "pi", local_pi=True
|
||
)
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch, "build_command", side_effect=command_for
|
||
),
|
||
mock.patch.object(
|
||
dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01
|
||
),
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace,
|
||
store,
|
||
task,
|
||
"review",
|
||
spec,
|
||
"Continue.",
|
||
resume_locator=prior_locator,
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 0)
|
||
self.assertIsNone(failure)
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["native_session_path"], str(native))
|
||
self.assertEqual(
|
||
record["resumed_from_locator"], str(prior_locator)
|
||
)
|
||
|
||
async def test_provider_stderr_requires_and_preserves_exact_evidence(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
provider_line = (
|
||
"provider_tunnel_error: dial tcp 192.0.2.1:8001: "
|
||
"connect: connection refused"
|
||
)
|
||
command = [
|
||
sys.executable,
|
||
"-c",
|
||
"import sys; sys.stderr.write(sys.argv[1] + '\\n'); "
|
||
"raise SystemExit(1)",
|
||
provider_line,
|
||
]
|
||
spec = dispatch.AgentSpec(
|
||
"pi", "ornith:35b", "pi", local_pi=True
|
||
)
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"build_command",
|
||
return_value=command,
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace, store, task, "worker", spec, "Read the plan."
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 1)
|
||
self.assertEqual(failure, "provider-connection")
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(
|
||
record["failure_source"], "provider-terminal-diagnostic"
|
||
)
|
||
self.assertTrue(record["provider_transport_failure_confirmed"])
|
||
self.assertEqual(record["failure_evidence_source"], "pi:stderr")
|
||
self.assertEqual(record["failure_evidence_excerpt"], provider_line)
|
||
self.assertEqual(record["dispatcher_pid"], dispatch.os.getpid())
|
||
self.assertEqual(
|
||
record["dispatcher_source_path"], str(dispatch.DISPATCHER_SOURCE_PATH)
|
||
)
|
||
self.assertEqual(
|
||
record["dispatcher_source_sha256"],
|
||
dispatch.DISPATCHER_SOURCE_SHA256,
|
||
)
|
||
self.assertEqual(
|
||
record["dispatcher_source_current_sha256"],
|
||
dispatch.DISPATCHER_SOURCE_SHA256,
|
||
)
|
||
self.assertTrue(record["dispatcher_source_matches_loaded"])
|
||
self.assertEqual(
|
||
dispatch.DISPATCHER_SOURCE_SHA256,
|
||
dispatch.sha256_file(SCRIPT),
|
||
)
|
||
report = dispatch.failure_report_lines(failure, locator)
|
||
self.assertIn("provider_transport_failure_confirmed=true", report)
|
||
self.assertIn(f"dispatcher_pid={dispatch.os.getpid()}", report)
|
||
self.assertIn(
|
||
f"dispatcher_source_sha256={dispatch.DISPATCHER_SOURCE_SHA256}",
|
||
report,
|
||
)
|
||
self.assertIn(
|
||
"dispatcher_source_matches_loaded=true",
|
||
report,
|
||
)
|
||
self.assertIn(f"provider_evidence={provider_line}", report)
|
||
|
||
async def test_claude_session_limit_stderr_records_provider_quota(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
diagnostic = (
|
||
"You've hit your session limit · resets 9pm (Asia/Seoul)"
|
||
)
|
||
command = [
|
||
sys.executable,
|
||
"-c",
|
||
"import sys; sys.stderr.write(sys.argv[1] + '\\n'); "
|
||
"raise SystemExit(1)",
|
||
diagnostic,
|
||
]
|
||
spec = dispatch.AgentSpec(
|
||
"claude",
|
||
"claude-opus-4-8",
|
||
"claude/claude-opus-4-8 xhigh",
|
||
)
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"build_command",
|
||
return_value=command,
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace,
|
||
store,
|
||
task,
|
||
"worker",
|
||
spec,
|
||
"Read the plan.",
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 1)
|
||
self.assertEqual(failure, "provider-quota")
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["failure_source"], "cli-terminal-diagnostic")
|
||
self.assertEqual(record["failure_evidence_source"], "claude:stderr")
|
||
self.assertEqual(record["failure_evidence_excerpt"], diagnostic)
|
||
self.assertEqual(record["reasoning_effort"], "xhigh")
|
||
self.assertFalse(record["provider_transport_failure_confirmed"])
|
||
|
||
async def test_claude_structured_rate_limit_stdout_records_provider_quota(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
rate_limit_event = json.dumps(
|
||
{
|
||
"type": "rate_limit_event",
|
||
"rate_limit_info": {
|
||
"status": "rejected",
|
||
"rateLimitType": "five_hour",
|
||
"overageStatus": "rejected",
|
||
},
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
result_event = json.dumps(
|
||
{
|
||
"type": "result",
|
||
"subtype": "success",
|
||
"is_error": True,
|
||
"terminal_reason": "api_error",
|
||
"api_error_status": 429,
|
||
"result": (
|
||
"You've hit your session limit · "
|
||
"resets 9pm (Asia/Seoul)"
|
||
),
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
command = [
|
||
sys.executable,
|
||
"-c",
|
||
"import sys; print(sys.argv[1]); print(sys.argv[2]); "
|
||
"raise SystemExit(1)",
|
||
rate_limit_event,
|
||
result_event,
|
||
]
|
||
spec = dispatch.AgentSpec(
|
||
"claude",
|
||
"claude-opus-4-8",
|
||
"claude/claude-opus-4-8 xhigh",
|
||
)
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"build_command",
|
||
return_value=command,
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace,
|
||
store,
|
||
task,
|
||
"worker",
|
||
spec,
|
||
"Read the plan.",
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 1)
|
||
self.assertEqual(failure, "provider-quota")
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["failure_source"], "cli-terminal-diagnostic")
|
||
self.assertEqual(record["failure_evidence_source"], "claude:stdout")
|
||
self.assertIn('"api_error_status": 429', record["failure_evidence_excerpt"])
|
||
self.assertFalse(record["provider_transport_failure_confirmed"])
|
||
|
||
async def test_agy_cli_log_quota_records_provider_quota(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
diagnostic = (
|
||
"rpc failed: code=ResourceExhausted "
|
||
"status=RESOURCE_EXHAUSTED HTTP 429 quota exceeded"
|
||
)
|
||
spec = dispatch.AgentSpec(
|
||
"agy",
|
||
"Gemini 3.6 Flash (High)",
|
||
"agy/Gemini 3.6 Flash (High)",
|
||
)
|
||
|
||
def build_agy_command(
|
||
_spec,
|
||
_prompt,
|
||
_workspace,
|
||
_session_id,
|
||
attempt_dir,
|
||
**_kwargs,
|
||
):
|
||
return [
|
||
sys.executable,
|
||
"-c",
|
||
(
|
||
"from pathlib import Path; "
|
||
"Path(__import__('sys').argv[1]).write_text("
|
||
"__import__('sys').argv[2] + '\\n', encoding='utf-8'); "
|
||
"raise SystemExit(1)"
|
||
),
|
||
str(attempt_dir / "agy-cli.log"),
|
||
diagnostic,
|
||
]
|
||
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"build_command",
|
||
side_effect=build_agy_command,
|
||
),
|
||
mock.patch.object(
|
||
dispatch,
|
||
"agy_conversations",
|
||
return_value={},
|
||
),
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace,
|
||
store,
|
||
task,
|
||
"worker",
|
||
spec,
|
||
"Read the plan.",
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 1)
|
||
self.assertEqual(failure, "provider-quota")
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["failure_source"], "cli-terminal-diagnostic")
|
||
self.assertEqual(
|
||
record["failure_evidence_source"],
|
||
"agy:cli-log",
|
||
)
|
||
self.assertEqual(record["failure_evidence_excerpt"], diagnostic)
|
||
|
||
async def test_exit_143_is_process_termination_not_provider_failure(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = TaskStageTest().make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
provider_line = (
|
||
"provider_tunnel_error: dial tcp 192.0.2.1:8001: "
|
||
"connect: connection refused"
|
||
)
|
||
spec = dispatch.AgentSpec(
|
||
"pi", "ornith:35b", "pi", local_pi=True
|
||
)
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"build_command",
|
||
return_value=[
|
||
sys.executable,
|
||
"-c",
|
||
"import sys; sys.stderr.write(sys.argv[1] + '\\n'); "
|
||
"raise SystemExit(143)",
|
||
provider_line,
|
||
],
|
||
):
|
||
rc, failure, locator = await dispatch.invoke(
|
||
workspace, store, task, "worker", spec, "Read the plan."
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertEqual(rc, 143)
|
||
self.assertEqual(failure, "process-terminated")
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
self.assertEqual(record["failure_source"], "process-termination")
|
||
self.assertFalse(record["provider_transport_failure_confirmed"])
|
||
self.assertEqual(record["termination_signal"], "SIGTERM")
|
||
self.assertTrue(record["termination_signal_inferred"])
|
||
self.assertEqual(record["termination_initiator"], "unknown")
|
||
|
||
|
||
class ReviewControlTest(unittest.TestCase):
|
||
def test_classifies_claude_session_limit_as_provider_quota(self):
|
||
diagnostic = (
|
||
"You've hit your session limit · resets 9pm (Asia/Seoul)"
|
||
)
|
||
self.assertEqual(
|
||
dispatch.classify_failure_with_evidence(diagnostic),
|
||
("provider-quota", diagnostic),
|
||
)
|
||
|
||
def test_claude_assistant_text_is_not_a_terminal_diagnostic(self):
|
||
assistant_event = json.dumps(
|
||
{
|
||
"type": "assistant",
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": [
|
||
{
|
||
"type": "text",
|
||
"text": "You've hit your session limit",
|
||
}
|
||
],
|
||
},
|
||
}
|
||
)
|
||
self.assertIsNone(
|
||
dispatch.terminal_diagnostic("claude", "stdout", assistant_event)
|
||
)
|
||
|
||
def test_claude_rejected_rate_limit_event_is_terminal_diagnostic(self):
|
||
event = json.dumps(
|
||
{
|
||
"type": "rate_limit_event",
|
||
"rate_limit_info": {"status": "rejected"},
|
||
}
|
||
)
|
||
diagnostic = dispatch.terminal_diagnostic("claude", "stdout", event)
|
||
self.assertIsNotNone(diagnostic)
|
||
self.assertEqual(
|
||
dispatch.classify_failure_with_evidence(diagnostic or ""),
|
||
("provider-quota", diagnostic),
|
||
)
|
||
|
||
def test_agy_structured_resource_exhausted_is_terminal_diagnostic(self):
|
||
event = json.dumps(
|
||
{
|
||
"type": "error",
|
||
"error": {
|
||
"code": 429,
|
||
"status": "RESOURCE_EXHAUSTED",
|
||
"message": "Quota exceeded",
|
||
},
|
||
}
|
||
)
|
||
|
||
diagnostic = dispatch.terminal_diagnostic("agy", "stdout", event)
|
||
|
||
self.assertIsNotNone(diagnostic)
|
||
self.assertEqual(
|
||
dispatch.classify_failure_with_evidence(diagnostic or ""),
|
||
("provider-quota", diagnostic),
|
||
)
|
||
|
||
def test_agy_assistant_quota_text_is_not_a_terminal_diagnostic(self):
|
||
event = json.dumps(
|
||
{
|
||
"type": "assistant",
|
||
"status": "rejected",
|
||
"error": {"code": 429},
|
||
"content": "The quota exceeded message is handled in the code.",
|
||
}
|
||
)
|
||
|
||
self.assertIsNone(
|
||
dispatch.terminal_diagnostic("agy", "stdout", event)
|
||
)
|
||
|
||
def test_agy_log_diagnostic_requires_strong_quota_evidence(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
log = Path(temporary) / "agy-cli.log"
|
||
log.write_text(
|
||
"quota configuration loaded\n"
|
||
"ERROR quota configuration refresh failed\n"
|
||
"status=RESOURCE_EXHAUSTED HTTP 429 quota exceeded\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
self.assertEqual(
|
||
dispatch.agy_log_diagnostics(log),
|
||
["status=RESOURCE_EXHAUSTED HTTP 429 quota exceeded"],
|
||
)
|
||
|
||
def test_claude_promotion_targets_terra_high(self):
|
||
claude = dispatch.AgentSpec(
|
||
"claude",
|
||
"claude-opus-4-8",
|
||
"claude/claude-opus-4-8 xhigh",
|
||
)
|
||
promoted = dispatch.promoted_spec(claude, recovery_count=0)
|
||
|
||
self.assertEqual(
|
||
promoted,
|
||
dispatch.AgentSpec(
|
||
"codex",
|
||
"gpt-5.6-terra",
|
||
"codex/gpt-5.6-terra high",
|
||
reasoning_effort="high",
|
||
),
|
||
)
|
||
assert promoted is not None
|
||
command = dispatch.build_command(
|
||
promoted,
|
||
"Read the plan.",
|
||
Path("/workspace"),
|
||
"session-id",
|
||
Path("/attempt"),
|
||
)
|
||
self.assertIn("gpt-5.6-terra", command)
|
||
self.assertIn('model_reasoning_effort="high"', command)
|
||
self.assertEqual(
|
||
dispatch.effective_reasoning_effort(promoted),
|
||
"high",
|
||
)
|
||
self.assertIs(
|
||
dispatch.promoted_spec(promoted, recovery_count=0),
|
||
promoted,
|
||
)
|
||
|
||
def test_regular_codex_and_claude_routes_keep_xhigh_effort(self):
|
||
codex = dispatch.AgentSpec(
|
||
"codex",
|
||
"gpt-5.6-sol",
|
||
"codex/gpt-5.6-sol xhigh",
|
||
)
|
||
claude = dispatch.AgentSpec(
|
||
"claude",
|
||
"claude-opus-4-8",
|
||
"claude/claude-opus-4-8 xhigh",
|
||
)
|
||
self.assertEqual(dispatch.effective_reasoning_effort(codex), "xhigh")
|
||
self.assertEqual(dispatch.effective_reasoning_effort(claude), "xhigh")
|
||
|
||
def test_classifies_provider_tunnel_connection_refusal(self):
|
||
provider_line = (
|
||
"provider_tunnel_error: dial tcp 192.0.2.1:8001: "
|
||
"connect: connection refused"
|
||
)
|
||
self.assertEqual(
|
||
dispatch.classify_failure(provider_line),
|
||
"provider-connection",
|
||
)
|
||
self.assertEqual(
|
||
dispatch.classify_failure_with_evidence(
|
||
f"unrelated warning\n{provider_line}"
|
||
),
|
||
("provider-connection", provider_line),
|
||
)
|
||
|
||
def test_generic_tool_stderr_is_not_provider_transport_evidence(self):
|
||
weak_lines = [
|
||
"pytest setup failed: connection refused while opening fixture",
|
||
"dial tcp 127.0.0.1:9999: connect: connection refused",
|
||
"curl error: failure when receiving data from the peer",
|
||
]
|
||
for line in weak_lines:
|
||
with self.subTest(line=line):
|
||
self.assertEqual(
|
||
dispatch.classify_failure_with_evidence(line),
|
||
("generic-error", None),
|
||
)
|
||
|
||
def test_provider_stream_requires_strong_backend_or_sse_context(self):
|
||
line = (
|
||
"Backend for model crashed before streaming started: "
|
||
"SSE stream before DONE"
|
||
)
|
||
self.assertEqual(
|
||
dispatch.classify_failure_with_evidence(line),
|
||
("provider-stream-disconnect", line),
|
||
)
|
||
|
||
def test_pi_stdout_provider_words_are_not_terminal_diagnostics(self):
|
||
line = "provider_tunnel_error: connection refused"
|
||
self.assertIsNone(dispatch.terminal_diagnostic("pi", "stdout", line))
|
||
|
||
def test_dispatcher_source_provenance_detects_hot_edit(self):
|
||
changed_sha256 = "f" * 64
|
||
self.assertNotEqual(changed_sha256, dispatch.DISPATCHER_SOURCE_SHA256)
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"sha256_file",
|
||
return_value=changed_sha256,
|
||
):
|
||
provenance = dispatch.dispatcher_source_provenance()
|
||
self.assertEqual(
|
||
provenance["dispatcher_source_sha256"],
|
||
dispatch.DISPATCHER_SOURCE_SHA256,
|
||
)
|
||
self.assertEqual(
|
||
provenance["dispatcher_source_current_sha256"], changed_sha256
|
||
)
|
||
self.assertFalse(provenance["dispatcher_source_matches_loaded"])
|
||
|
||
def test_pi_phase_reads_large_last_jsonl_event(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
path = Path(temporary) / "session.jsonl"
|
||
path.write_text(
|
||
pi_session_jsonl(
|
||
[
|
||
{
|
||
"type": "message",
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": [
|
||
{
|
||
"type": "toolCall",
|
||
"id": "large-result",
|
||
"name": "read",
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"type": "message",
|
||
"message": {
|
||
"role": "toolResult",
|
||
"toolCallId": "large-result",
|
||
"content": [
|
||
{"type": "text", "text": "x" * 20000}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
self.assertEqual(
|
||
dispatch.pi_native_session_phase(str(path)),
|
||
"awaiting-model",
|
||
)
|
||
|
||
def test_pi_phase_keeps_incomplete_sequential_batch_tool_running(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
path = Path(temporary) / "session.jsonl"
|
||
events = [
|
||
{
|
||
"type": "message",
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "toolCall", "id": "call-a", "name": "read"},
|
||
{"type": "toolCall", "id": "call-b", "name": "bash"},
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"type": "message",
|
||
"message": {
|
||
"role": "toolResult",
|
||
"toolCallId": "call-a",
|
||
"content": [],
|
||
},
|
||
},
|
||
]
|
||
path.write_text(
|
||
pi_session_jsonl(events),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
state = dispatch.pi_native_session_state(str(path))
|
||
|
||
self.assertEqual(state.phase, "tool-running")
|
||
self.assertEqual(state.expected_tool_call_ids, ("call-a", "call-b"))
|
||
self.assertEqual(state.completed_tool_call_ids, ("call-a",))
|
||
self.assertEqual(state.pending_tool_call_ids, ("call-b",))
|
||
|
||
def test_pi_phase_waits_for_model_only_after_entire_batch_completes(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
path = Path(temporary) / "session.jsonl"
|
||
events = [
|
||
{
|
||
"type": "message",
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "toolCall", "id": "call-a", "name": "read"},
|
||
{"type": "toolCall", "id": "call-b", "name": "bash"},
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"type": "message",
|
||
"message": {
|
||
"role": "toolResult",
|
||
"toolCallId": "call-a",
|
||
"content": [],
|
||
},
|
||
},
|
||
{
|
||
"type": "message",
|
||
"message": {
|
||
"role": "toolResult",
|
||
"toolCallId": "call-b",
|
||
"content": [],
|
||
},
|
||
},
|
||
]
|
||
path.write_text(
|
||
pi_session_jsonl(events),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
state = dispatch.pi_native_session_state(str(path))
|
||
|
||
self.assertEqual(state.phase, "awaiting-model")
|
||
self.assertEqual(state.completed_tool_call_ids, ("call-a", "call-b"))
|
||
self.assertEqual(state.pending_tool_call_ids, ())
|
||
|
||
def test_pi_phase_marks_unknown_schema_without_assuming_tool_completion(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
path = Path(temporary) / "session.jsonl"
|
||
path.write_text(
|
||
pi_session_jsonl(
|
||
[
|
||
{
|
||
"type": "message",
|
||
"message": {
|
||
"role": "toolResult",
|
||
"content": [],
|
||
},
|
||
},
|
||
]
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
state = dispatch.pi_native_session_state(str(path))
|
||
|
||
self.assertEqual(state.phase, "unknown")
|
||
self.assertEqual(state.reason, "tool-result-id-missing")
|
||
|
||
def test_pi_phase_does_not_treat_unknown_assistant_content_as_final(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
path = Path(temporary) / "session.jsonl"
|
||
path.write_text(
|
||
pi_session_jsonl(
|
||
[
|
||
{
|
||
"type": "message",
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": [
|
||
{
|
||
"type": "futureToolCall",
|
||
"id": "unknown-call",
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
state = dispatch.pi_native_session_state(str(path))
|
||
|
||
self.assertEqual(state.phase, "unknown")
|
||
self.assertEqual(state.reason, "unsupported-assistant-content")
|
||
|
||
def test_pi_phase_marks_future_session_version_unknown(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
path = Path(temporary) / "session.jsonl"
|
||
path.write_text(
|
||
pi_session_jsonl(
|
||
[
|
||
{
|
||
"type": "message",
|
||
"message": {
|
||
"role": "user",
|
||
"content": [],
|
||
},
|
||
},
|
||
],
|
||
version=dispatch.PI_SESSION_SCHEMA_VERSION + 1,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
state = dispatch.pi_native_session_state(str(path))
|
||
|
||
self.assertEqual(state.phase, "unknown")
|
||
self.assertEqual(
|
||
state.reason,
|
||
f"unsupported-session-version:"
|
||
f"{dispatch.PI_SESSION_SCHEMA_VERSION + 1}",
|
||
)
|
||
|
||
def test_pi_phase_marks_corrupt_session_entries_unknown(self):
|
||
header = pi_session_jsonl([]).encode()
|
||
cases = {
|
||
"missing-parent": header
|
||
+ json.dumps(
|
||
{
|
||
"type": "message",
|
||
"id": "message-without-parent",
|
||
"message": {
|
||
"role": "user",
|
||
"content": [],
|
||
},
|
||
}
|
||
).encode()
|
||
+ b"\n",
|
||
"invalid-utf8": header + b'{"type":"message","id":"bad-\\xff"}\n',
|
||
}
|
||
for name, content in cases.items():
|
||
with self.subTest(name=name), tempfile.TemporaryDirectory() as temporary:
|
||
path = Path(temporary) / "session.jsonl"
|
||
path.write_bytes(content)
|
||
|
||
state = dispatch.pi_native_session_state(str(path))
|
||
|
||
self.assertEqual(state.phase, "unknown")
|
||
|
||
def test_pi_phase_follows_only_the_active_session_branch(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
path = Path(temporary) / "session.jsonl"
|
||
path.write_text(
|
||
pi_session_jsonl(
|
||
[
|
||
{
|
||
"type": "message",
|
||
"id": "root-user",
|
||
"parentId": None,
|
||
"message": {
|
||
"role": "user",
|
||
"content": [],
|
||
},
|
||
},
|
||
{
|
||
"type": "message",
|
||
"id": "abandoned-assistant",
|
||
"parentId": "root-user",
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": [{"type": "text", "text": "done"}],
|
||
},
|
||
},
|
||
{
|
||
"type": "custom",
|
||
"id": "active-branch-marker",
|
||
"parentId": "root-user",
|
||
},
|
||
]
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
state = dispatch.pi_native_session_state(str(path))
|
||
|
||
self.assertEqual(state.phase, "awaiting-model")
|
||
self.assertEqual(state.reason, "user-message")
|
||
|
||
def test_detects_codex_collaboration_wait(self):
|
||
line = (
|
||
'{"type":"item.started","item":{"type":"collab_tool_call",'
|
||
'"tool":"wait"}}'
|
||
)
|
||
self.assertEqual(dispatch.codex_collaboration_tool(line), "wait")
|
||
|
||
def test_ignores_completed_or_non_json_events(self):
|
||
self.assertIsNone(
|
||
dispatch.codex_collaboration_tool(
|
||
'{"type":"item.completed","item":{"type":"collab_tool_call",'
|
||
'"tool":"wait"}}'
|
||
)
|
||
)
|
||
self.assertIsNone(dispatch.codex_collaboration_tool("not json"))
|
||
|
||
def test_prompts_keep_local_work_and_official_review_roles_separate(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = TaskStageTest().make_task(root)
|
||
pi = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
selfcheck = dispatch.base_prompt(task, "selfcheck", pi)
|
||
review = dispatch.base_prompt(
|
||
task,
|
||
"review",
|
||
dispatch.AgentSpec("codex", "gpt-5.6-sol", "codex"),
|
||
)
|
||
self.assertNotIn("USER_REVIEW", selfcheck)
|
||
self.assertNotIn("user review", selfcheck.lower())
|
||
self.assertEqual(
|
||
selfcheck,
|
||
f"Think in English. Final in Korean. Read {task.review.resolve()} "
|
||
"and fill every missing implementation field. Do not finish until "
|
||
"all implementation fields are complete. This is a self-check of "
|
||
f"completed work, not a review. Read {task.plan.resolve()} and "
|
||
"finish any missing work. Recheck and fix your work.",
|
||
)
|
||
self.assertEqual(
|
||
review,
|
||
f"Read {task.review.resolve()} and start the review. Final in Korean.",
|
||
)
|
||
|
||
def test_local_review_stub_has_no_user_review_control_plane_content(self):
|
||
template = (
|
||
Path(__file__).parents[3]
|
||
/ "common"
|
||
/ "plan"
|
||
/ "templates"
|
||
/ "review-stub-template.md"
|
||
).read_text(encoding="utf-8")
|
||
self.assertNotIn("USER_REVIEW", template)
|
||
self.assertNotIn("사용자 리뷰", template)
|
||
self.assertNotIn("user-review", template)
|
||
self.assertNotIn("## 작업 로그 계약", template)
|
||
self.assertNotIn("WORK_LOG.md", template)
|
||
|
||
def test_final_channel_contract_is_top_level_and_singular(self):
|
||
skill = (
|
||
Path(__file__).parents[1] / "SKILL.md"
|
||
).read_text(encoding="utf-8")
|
||
heading = "## 🚨 ABSOLUTE PRIORITY — NEVER SEND `final` EXCEPT IN THE TWO CASES BELOW"
|
||
self.assertEqual(skill.count(heading), 1)
|
||
priority, lower_contract = skill.split("\n## Purpose\n", 1)
|
||
self.assertEqual(priority.count("### `final` Permission"), 2)
|
||
self.assertEqual(priority.count("Allow `final`"), 2)
|
||
self.assertIn("unless at least one of the two titled permissions", priority)
|
||
self.assertNotIn("unless exactly one of the two titled permissions", priority)
|
||
self.assertNotIn("`final`", lower_contract)
|
||
self.assertIn(
|
||
"### Every Other User-Visible Message Must Use `commentary`",
|
||
priority,
|
||
)
|
||
self.assertIn(
|
||
"### Child Prompt Text Never Grants Caller `final` Permission",
|
||
priority,
|
||
)
|
||
|
||
def test_skill_narrative_is_english_except_exact_protocol_literals(self):
|
||
skill = (
|
||
Path(__file__).parents[1] / "SKILL.md"
|
||
).read_text(encoding="utf-8")
|
||
self.assertIn(
|
||
"Treat Korean text inside code spans or fenced examples as exact runtime or file-contract literals",
|
||
skill,
|
||
)
|
||
in_fence = False
|
||
for line_number, line in enumerate(skill.splitlines(), start=1):
|
||
if line.strip().startswith("```"):
|
||
in_fence = not in_fence
|
||
continue
|
||
if in_fence:
|
||
continue
|
||
narrative = re.sub(r"`[^`]*`", "", line)
|
||
self.assertIsNone(
|
||
re.search(r"[가-힣]", narrative),
|
||
f"line {line_number} has non-literal Korean narrative: {line}",
|
||
)
|
||
|
||
def test_caller_lifecycle_is_not_bound_to_child_dispatcher_exit(self):
|
||
skill = (
|
||
Path(__file__).parents[1] / "SKILL.md"
|
||
).read_text(encoding="utf-8")
|
||
self.assertIn(
|
||
"caller agent running this skill—not the child dispatcher process—as the lifecycle owner",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"Child exit, tool yield, or loss of a session/cell never ends the caller lifecycle by itself",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"Exit code `3` is a non-terminal tracking state, including another dispatcher's workspace lock, "
|
||
"a live external agent, or an unexpected dispatcher interruption",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"every CLI's health/progress primarily from actual stdout/stderr in `stream.log`, "
|
||
"plus native session events when available",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"dispatcher PID, agent PID, each process start token, and the per-attempt "
|
||
"process environment marker",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"use only an actual terminal error or confirmed process exit as recovery "
|
||
"evidence for every model",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"every `toolCall.id` in the preceding assistant event matches a later `toolResult.toolCallId`",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"stream stops for three minutes outside tool execution",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"locator lacks an agent PID during this interval, never classify it as stale or "
|
||
"duplicate recovery based on log age",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"original exception is a persistent-state error, do not convert it to exit `2` if any agent was running",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"do not return successful exit `0` while any attempt directory remains",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"share a budget of 10 consecutive automatic recovery failures for the same task stage",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"On the 10th failure, block that task and do not auto-resume after cooldown",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"legacy locator `session-stall` as a record of an earlier dispatcher timeout policy, not as provider failure",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"Never classify exit code `143` as provider failure without actual provider terminal evidence",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"Do not generalize one `pi -p` fresh/isolated session attempt to a Pi TUI or system-wide provider outage",
|
||
skill,
|
||
)
|
||
self.assertIn("provider_transport_failure_confirmed", skill)
|
||
self.assertIn(
|
||
"Do not infer provider failure from `connection refused`, `dial tcp`, or `curl` peer failure in ordinary tool/test stderr",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"A running Python dispatcher does not hot-reload source edits",
|
||
skill,
|
||
)
|
||
self.assertIn("dispatcher_source_sha256", skill)
|
||
self.assertIn("`dispatcher_source_matches_loaded=false`", skill)
|
||
self.assertIn(
|
||
"KST-night `local-G07`–`local-G08` Laguna locator `context-limit`/`session-stall`",
|
||
skill,
|
||
)
|
||
self.assertIn(
|
||
"fresh session and `세션응답복구재시도` only for other legacy Pi `session-stall` recovery",
|
||
skill,
|
||
)
|
||
|
||
def test_work_log_archive_ownership_is_consistent_across_skills(self):
|
||
skills_root = Path(__file__).parents[3]
|
||
dispatcher_skill = (
|
||
Path(__file__).parents[1] / "SKILL.md"
|
||
).read_text(encoding="utf-8")
|
||
review_skill = (
|
||
skills_root / "common" / "code-review" / "SKILL.md"
|
||
).read_text(encoding="utf-8")
|
||
plan_skill = (
|
||
skills_root / "common" / "plan" / "SKILL.md"
|
||
).read_text(encoding="utf-8")
|
||
|
||
self.assertIn(
|
||
"append the final `FINISH` and move the generated `WORK_LOG.md`",
|
||
dispatcher_skill,
|
||
)
|
||
self.assertIn("work_log_N.log", dispatcher_skill)
|
||
self.assertIn(
|
||
"append `FINISH` with `reconciled:verified-complete-archive`",
|
||
dispatcher_skill,
|
||
)
|
||
self.assertIn(
|
||
"pidless stream/native evidence remains live",
|
||
dispatcher_skill,
|
||
)
|
||
self.assertIn(
|
||
"task-group `agent-task/{task_group}/WORK_LOG.md`는 "
|
||
"이동·복사·삭제·이름 변경하지 않는다",
|
||
review_skill,
|
||
)
|
||
self.assertIn(
|
||
"review process 종료 뒤 dispatcher가 마지막 `FINISH`를 append",
|
||
review_skill,
|
||
)
|
||
self.assertIn(
|
||
"dispatcher가 마지막 `FINISH` 기록과 group 완료 확인 뒤 "
|
||
"`work_log_N.log`로 archive한다",
|
||
plan_skill,
|
||
)
|
||
|
||
|
||
class ProcessTerminationTest(unittest.IsolatedAsyncioTestCase):
|
||
async def test_terminates_the_exact_process_group(self):
|
||
class Process:
|
||
pid = 12345
|
||
returncode = None
|
||
|
||
async def wait(self):
|
||
self.returncode = -signal.SIGTERM
|
||
return self.returncode
|
||
|
||
process = Process()
|
||
with mock.patch.object(
|
||
dispatch.os,
|
||
"killpg",
|
||
side_effect=[None, ProcessLookupError],
|
||
) as killpg:
|
||
await dispatch.terminate_process_group(process)
|
||
|
||
self.assertEqual(killpg.call_args_list[0], mock.call(12345, signal.SIGTERM))
|
||
self.assertEqual(killpg.call_args_list[1], mock.call(12345, 0))
|
||
|
||
async def test_kills_sigterm_ignoring_descendant_and_closes_pipe(self):
|
||
child_script = (
|
||
"import signal,time;"
|
||
"signal.signal(signal.SIGTERM,signal.SIG_IGN);"
|
||
"time.sleep(60)"
|
||
)
|
||
parent_script = (
|
||
"import signal,subprocess,sys,time;"
|
||
"signal.signal(signal.SIGTERM,signal.SIG_IGN);"
|
||
f"subprocess.Popen([sys.executable,'-c',{child_script!r}]);"
|
||
"print('ready',flush=True);"
|
||
"time.sleep(60)"
|
||
)
|
||
process = await asyncio.create_subprocess_exec(
|
||
sys.executable,
|
||
"-c",
|
||
parent_script,
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
start_new_session=True,
|
||
)
|
||
try:
|
||
assert process.stdout is not None
|
||
self.assertEqual(
|
||
await asyncio.wait_for(process.stdout.readline(), timeout=1),
|
||
b"ready\n",
|
||
)
|
||
await dispatch.terminate_process_group(process, grace_seconds=0.05)
|
||
self.assertEqual(process.returncode, -signal.SIGKILL)
|
||
self.assertEqual(
|
||
await asyncio.wait_for(process.stdout.read(), timeout=1),
|
||
b"",
|
||
)
|
||
finally:
|
||
if process.returncode is None:
|
||
await dispatch.terminate_process_group(process, grace_seconds=0.05)
|
||
|
||
|
||
class ReviewRetryTest(unittest.IsolatedAsyncioTestCase):
|
||
def make_task(self, root: Path):
|
||
return TaskStageTest().make_task(root)
|
||
|
||
async def test_claude_provider_quota_promotes_to_terra_high(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
claude = dispatch.AgentSpec(
|
||
"claude",
|
||
"claude-opus-4-8",
|
||
"claude/claude-opus-4-8 xhigh",
|
||
)
|
||
terra = dispatch.AgentSpec(
|
||
"codex",
|
||
"gpt-5.6-terra",
|
||
"codex/gpt-5.6-terra high",
|
||
reasoning_effort="high",
|
||
)
|
||
locators = [root / "locator-0.json", root / "locator-1.json"]
|
||
results = [
|
||
(1, "provider-quota", locators[0]),
|
||
(0, None, locators[1]),
|
||
]
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"invoke",
|
||
new=mock.AsyncMock(side_effect=results),
|
||
) as invoke:
|
||
success, locator = await dispatch.run_escalating(
|
||
root,
|
||
mock.Mock(),
|
||
task,
|
||
"worker",
|
||
claude,
|
||
)
|
||
|
||
self.assertTrue(success)
|
||
self.assertEqual(locator, locators[1])
|
||
self.assertEqual(invoke.await_count, 2)
|
||
self.assertEqual(invoke.await_args_list[0].args[4], claude)
|
||
self.assertEqual(invoke.await_args_list[1].args[4], terra)
|
||
|
||
async def test_agy_and_claude_quota_chain_promotes_to_terra(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
agy = dispatch.AgentSpec(
|
||
"agy",
|
||
"Gemini 3.6 Flash (High)",
|
||
"agy/Gemini 3.6 Flash (High)",
|
||
)
|
||
claude = dispatch.AgentSpec(
|
||
"claude",
|
||
"claude-opus-4-8",
|
||
"claude/claude-opus-4-8 xhigh",
|
||
)
|
||
terra = dispatch.AgentSpec(
|
||
"codex",
|
||
"gpt-5.6-terra",
|
||
"codex/gpt-5.6-terra high",
|
||
reasoning_effort="high",
|
||
)
|
||
locators = [
|
||
root / "locator-0.json",
|
||
root / "locator-1.json",
|
||
root / "locator-2.json",
|
||
]
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"invoke",
|
||
new=mock.AsyncMock(
|
||
side_effect=[
|
||
(1, "provider-quota", locators[0]),
|
||
(1, "provider-quota", locators[1]),
|
||
(0, None, locators[2]),
|
||
]
|
||
),
|
||
) as invoke:
|
||
success, locator = await dispatch.run_escalating(
|
||
root,
|
||
mock.Mock(),
|
||
task,
|
||
"worker",
|
||
agy,
|
||
)
|
||
|
||
self.assertTrue(success)
|
||
self.assertEqual(locator, locators[2])
|
||
self.assertEqual(
|
||
[call.args[4] for call in invoke.await_args_list],
|
||
[agy, claude, terra],
|
||
)
|
||
|
||
async def test_process_termination_retries_same_claude_target(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
claude = dispatch.AgentSpec(
|
||
"claude",
|
||
"claude-opus-4-8",
|
||
"claude/claude-opus-4-8 xhigh",
|
||
)
|
||
locators = [root / "locator-0.json", root / "locator-1.json"]
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"invoke",
|
||
new=mock.AsyncMock(
|
||
side_effect=[
|
||
(1, "process-terminated", locators[0]),
|
||
(0, None, locators[1]),
|
||
]
|
||
),
|
||
) as invoke,
|
||
mock.patch.object(
|
||
dispatch.asyncio,
|
||
"sleep",
|
||
new=mock.AsyncMock(),
|
||
),
|
||
):
|
||
success, locator = await dispatch.run_escalating(
|
||
root,
|
||
mock.Mock(),
|
||
task,
|
||
"worker",
|
||
claude,
|
||
)
|
||
|
||
self.assertTrue(success)
|
||
self.assertEqual(locator, locators[1])
|
||
self.assertEqual(
|
||
[call.args[4] for call in invoke.await_args_list],
|
||
[claude, claude],
|
||
)
|
||
|
||
async def test_legacy_generic_quota_blocker_resumes_directly_on_terra(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
(root / ".git").mkdir()
|
||
task = self.make_task(root)
|
||
store = dispatch.StateStore(root)
|
||
locator = write_legacy_quota_attempts(
|
||
store.runs,
|
||
task,
|
||
)[-1]
|
||
state = store.task_state(task)
|
||
state.update(
|
||
blocked=(
|
||
"worker recovery failure limit exhausted: 10/10 "
|
||
f"locator={locator}"
|
||
),
|
||
recovery_failures={"worker": 10},
|
||
)
|
||
recovery = dispatch.legacy_promotion_recovery(
|
||
store.runs,
|
||
task,
|
||
state,
|
||
)
|
||
self.assertIsNotNone(recovery)
|
||
initial_route = dispatch.AgentSpec(
|
||
"agy",
|
||
"Gemini 3.6 Flash (High)",
|
||
"agy/Gemini 3.6 Flash (High)",
|
||
)
|
||
terra = dispatch.AgentSpec(
|
||
"codex",
|
||
"gpt-5.6-terra",
|
||
"codex/gpt-5.6-terra high",
|
||
reasoning_effort="high",
|
||
)
|
||
completed_locator = root / "completed-locator.json"
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"invoke",
|
||
new=mock.AsyncMock(
|
||
return_value=(0, None, completed_locator)
|
||
),
|
||
) as invoke:
|
||
success, actual_locator = await dispatch.run_escalating(
|
||
root,
|
||
store,
|
||
task,
|
||
"worker",
|
||
initial_route,
|
||
initial_resume_locator=locator,
|
||
)
|
||
recovered_state = store.task_state(task)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertTrue(success)
|
||
self.assertEqual(actual_locator, completed_locator)
|
||
self.assertEqual(invoke.await_count, 1)
|
||
self.assertEqual(invoke.await_args.args[4], terra)
|
||
self.assertIsNone(recovered_state["blocked"])
|
||
self.assertEqual(
|
||
recovered_state["recovery_failures"],
|
||
{},
|
||
)
|
||
self.assertEqual(
|
||
recovered_state["legacy_terminal_reclassification"][
|
||
"failed_cli"
|
||
],
|
||
"claude",
|
||
)
|
||
|
||
async def test_legacy_agy_quota_log_resumes_on_claude_then_terra(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
(root / ".git").mkdir()
|
||
task = self.make_task(root)
|
||
store = dispatch.StateStore(root)
|
||
locator = write_legacy_quota_attempts(
|
||
store.runs,
|
||
task,
|
||
cli="agy",
|
||
model="Gemini 3.6 Flash (High)",
|
||
reasoning_effort=None,
|
||
)[-1]
|
||
state = store.task_state(task)
|
||
state.update(
|
||
blocked=(
|
||
"worker recovery failure limit exhausted: 10/10 "
|
||
f"locator={locator}"
|
||
),
|
||
recovery_failures={"worker": 10},
|
||
)
|
||
recovery = dispatch.legacy_promotion_recovery(
|
||
store.runs,
|
||
task,
|
||
state,
|
||
)
|
||
self.assertIsNotNone(recovery)
|
||
assert recovery is not None
|
||
self.assertEqual(recovery.failed_cli, "agy")
|
||
self.assertEqual(recovery.evidence_source, "agy:cli-log")
|
||
initial_route = dispatch.AgentSpec(
|
||
"agy",
|
||
"Gemini 3.6 Flash (High)",
|
||
"agy/Gemini 3.6 Flash (High)",
|
||
)
|
||
claude = dispatch.AgentSpec(
|
||
"claude",
|
||
"claude-opus-4-8",
|
||
"claude/claude-opus-4-8 xhigh",
|
||
)
|
||
terra = dispatch.AgentSpec(
|
||
"codex",
|
||
"gpt-5.6-terra",
|
||
"codex/gpt-5.6-terra high",
|
||
reasoning_effort="high",
|
||
)
|
||
locators = [
|
||
root / "claude-locator.json",
|
||
root / "terra-locator.json",
|
||
]
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"invoke",
|
||
new=mock.AsyncMock(
|
||
side_effect=[
|
||
(1, "provider-quota", locators[0]),
|
||
(0, None, locators[1]),
|
||
]
|
||
),
|
||
) as invoke:
|
||
success, actual_locator = await dispatch.run_escalating(
|
||
root,
|
||
store,
|
||
task,
|
||
"worker",
|
||
initial_route,
|
||
initial_resume_locator=locator,
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertTrue(success)
|
||
self.assertEqual(actual_locator, locators[1])
|
||
self.assertEqual(
|
||
[call.args[4] for call in invoke.await_args_list],
|
||
[claude, terra],
|
||
)
|
||
|
||
async def test_worker_persists_the_actual_promoted_target(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
(root / ".git").mkdir()
|
||
task = self.make_task(root)
|
||
store = dispatch.StateStore(root)
|
||
initial_route = dispatch.AgentSpec(
|
||
"agy",
|
||
"Gemini 3.6 Flash (High)",
|
||
"agy/Gemini 3.6 Flash (High)",
|
||
)
|
||
attempt = store.runs / "completed-attempt"
|
||
attempt.mkdir()
|
||
locator = attempt / "locator.json"
|
||
locator.write_text(
|
||
json.dumps(
|
||
{
|
||
"cli": "codex",
|
||
"model": "gpt-5.6-terra",
|
||
"reasoning_effort": "high",
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"persisted_execution_decision",
|
||
return_value=({}, initial_route),
|
||
),
|
||
mock.patch.object(
|
||
dispatch,
|
||
"run_escalating",
|
||
new=mock.AsyncMock(return_value=(True, locator)),
|
||
),
|
||
):
|
||
await dispatch.run_worker(
|
||
root,
|
||
store,
|
||
task,
|
||
{"agy": asyncio.Semaphore(1)},
|
||
)
|
||
state = store.task_state(task)
|
||
finally:
|
||
store.close()
|
||
|
||
self.assertTrue(state["worker_done"])
|
||
self.assertEqual(state["worker_cli"], "codex")
|
||
self.assertEqual(state["worker_model"], "gpt-5.6-terra")
|
||
self.assertTrue(state["selfcheck_done"])
|
||
|
||
async def test_retries_two_control_violations_then_succeeds(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
spec = dispatch.AgentSpec("codex", "gpt-5.6-sol", "codex")
|
||
locators = [root / f"locator-{index}.json" for index in range(3)]
|
||
results = [
|
||
(1, "review-control-violation", locators[0]),
|
||
(1, "review-control-violation", locators[1]),
|
||
(0, None, locators[2]),
|
||
]
|
||
with mock.patch.object(
|
||
dispatch, "invoke", new=mock.AsyncMock(side_effect=results)
|
||
) as invoke:
|
||
success, locator = await dispatch.run_escalating(
|
||
root, mock.Mock(), task, "review", spec
|
||
)
|
||
self.assertTrue(success)
|
||
self.assertEqual(locator, locators[2])
|
||
self.assertEqual(invoke.await_count, 3)
|
||
|
||
async def test_review_control_retries_do_not_create_a_blocker(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
spec = dispatch.AgentSpec("codex", "gpt-5.6-sol", "codex")
|
||
locators = [root / f"locator-{index}.json" for index in range(4)]
|
||
results = [
|
||
(1, "review-control-violation", locators[0]),
|
||
(1, "review-control-violation", locators[1]),
|
||
(1, "review-control-violation", locators[2]),
|
||
(0, None, locators[3]),
|
||
]
|
||
with (
|
||
mock.patch.object(
|
||
dispatch, "invoke", new=mock.AsyncMock(side_effect=results)
|
||
) as invoke,
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
success, locator = await dispatch.run_escalating(
|
||
root, mock.Mock(), task, "review", spec
|
||
)
|
||
self.assertTrue(success)
|
||
self.assertEqual(locator, locators[3])
|
||
self.assertEqual(invoke.await_count, 4)
|
||
|
||
async def test_does_not_retry_obsolete_model_work_log_failure(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
locators = [root / "locator-0.json", root / "locator-1.json"]
|
||
results = [
|
||
(0, "work-log-incomplete", locators[0]),
|
||
(0, None, locators[1]),
|
||
]
|
||
with mock.patch.object(
|
||
dispatch, "invoke", new=mock.AsyncMock(side_effect=results)
|
||
) as invoke:
|
||
success, locator = await dispatch.run_escalating(
|
||
root, mock.Mock(), task, "worker", spec
|
||
)
|
||
self.assertFalse(success)
|
||
self.assertEqual(locator, locators[0])
|
||
self.assertEqual(invoke.await_count, 1)
|
||
|
||
async def test_retries_pi_session_stall_twice_with_same_model(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi", local_pi=True)
|
||
locators = [root / f"locator-{index}.json" for index in range(3)]
|
||
results = [
|
||
(1, "session-stall", locators[0]),
|
||
(1, "session-stall", locators[1]),
|
||
(0, None, locators[2]),
|
||
]
|
||
with (
|
||
mock.patch.object(
|
||
dispatch, "invoke", new=mock.AsyncMock(side_effect=results)
|
||
) as invoke,
|
||
mock.patch.object(
|
||
dispatch.asyncio, "sleep", new=mock.AsyncMock()
|
||
),
|
||
):
|
||
success, locator = await dispatch.run_escalating(
|
||
root, mock.Mock(), task, "worker", spec
|
||
)
|
||
self.assertTrue(success)
|
||
self.assertEqual(locator, locators[2])
|
||
self.assertEqual(invoke.await_count, 3)
|
||
self.assertTrue(all(call.args[4] == spec for call in invoke.await_args_list))
|
||
self.assertEqual(
|
||
invoke.await_args_list[1].args[-1],
|
||
"Think in English. Final in Korean. Continue this session and "
|
||
"complete the current task.",
|
||
)
|
||
self.assertEqual(
|
||
invoke.await_args_list[1].kwargs["resume_locator"],
|
||
locators[0],
|
||
)
|
||
self.assertEqual(
|
||
invoke.await_args_list[2].kwargs["resume_locator"],
|
||
locators[1],
|
||
)
|
||
|
||
def test_failed_laguna_locator_is_recovered_after_dispatcher_restart(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
native = root / "session.jsonl"
|
||
native.write_text("{}\n", encoding="utf-8")
|
||
locator = root / "locator.json"
|
||
locator.write_text(
|
||
json.dumps(
|
||
{
|
||
"cli": "pi",
|
||
"model": "laguna-s:2.1",
|
||
"status": "failed",
|
||
"failure_class": "session-stall",
|
||
"native_session_path": str(native),
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
self.assertEqual(
|
||
dispatch.laguna_resume_locator(
|
||
{"active_locator": str(locator)}
|
||
),
|
||
locator,
|
||
)
|
||
|
||
async def test_retries_pi_connection_and_generic_failures(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
locators = [root / f"locator-{index}.json" for index in range(3)]
|
||
results = [
|
||
(1, "provider-connection", locators[0]),
|
||
(1, "generic-error", locators[1]),
|
||
(0, None, locators[2]),
|
||
]
|
||
with (
|
||
mock.patch.object(
|
||
dispatch, "invoke", new=mock.AsyncMock(side_effect=results)
|
||
) as invoke,
|
||
mock.patch.object(
|
||
dispatch.asyncio, "sleep", new=mock.AsyncMock()
|
||
) as sleep,
|
||
):
|
||
success, locator = await dispatch.run_escalating(
|
||
root, mock.Mock(), task, "worker", spec
|
||
)
|
||
self.assertTrue(success)
|
||
self.assertEqual(locator, locators[2])
|
||
self.assertEqual(invoke.await_count, 3)
|
||
self.assertEqual(sleep.await_count, 2)
|
||
|
||
async def test_pi_tenth_failure_blocks_without_cooldown(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
locators = [
|
||
root / f"locator-{index}.json"
|
||
for index in range(dispatch.RECOVERY_FAILURE_LIMIT)
|
||
]
|
||
results = [
|
||
(1, "provider-stream-disconnect", locator)
|
||
for locator in locators
|
||
]
|
||
semaphore = asyncio.Semaphore(1)
|
||
sleep_observations = []
|
||
|
||
async def observe_sleep(delay):
|
||
sleep_observations.append((delay, semaphore.locked()))
|
||
|
||
with (
|
||
mock.patch.object(
|
||
dispatch, "invoke", new=mock.AsyncMock(side_effect=results)
|
||
) as invoke,
|
||
mock.patch.object(
|
||
dispatch.asyncio,
|
||
"sleep",
|
||
new=mock.AsyncMock(side_effect=observe_sleep),
|
||
),
|
||
):
|
||
success, locator = await dispatch.run_escalating(
|
||
root,
|
||
mock.Mock(),
|
||
task,
|
||
"worker",
|
||
spec,
|
||
invocation_semaphore=semaphore,
|
||
)
|
||
|
||
self.assertFalse(success)
|
||
self.assertEqual(locator, locators[-1])
|
||
self.assertEqual(invoke.await_count, dispatch.RECOVERY_FAILURE_LIMIT)
|
||
self.assertEqual(
|
||
len(sleep_observations), dispatch.RECOVERY_FAILURE_LIMIT - 1
|
||
)
|
||
self.assertTrue(all(not locked for _, locked in sleep_observations))
|
||
|
||
async def test_recovery_failure_limit_survives_dispatcher_restart(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
(root / ".git").mkdir()
|
||
task = self.make_task(root)
|
||
store = dispatch.StateStore(root)
|
||
locator = root / "locator.json"
|
||
store.update_task(
|
||
task, recovery_failures={"worker": dispatch.RECOVERY_FAILURE_LIMIT - 1}
|
||
)
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"invoke",
|
||
new=mock.AsyncMock(
|
||
return_value=(1, "generic-error", locator)
|
||
),
|
||
) as invoke:
|
||
success, actual_locator = await dispatch.run_escalating(
|
||
root, store, task, "worker", spec
|
||
)
|
||
self.assertFalse(success)
|
||
self.assertEqual(actual_locator, locator)
|
||
self.assertEqual(invoke.await_count, 1)
|
||
self.assertIn(
|
||
"recovery failure limit exhausted",
|
||
store.task_state(task)["blocked"],
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_already_exhausted_recovery_budget_does_not_invoke_model(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
(root / ".git").mkdir()
|
||
task = self.make_task(root)
|
||
store = dispatch.StateStore(root)
|
||
locator = root / "locator.json"
|
||
store.update_task(
|
||
task, recovery_failures={"worker": dispatch.RECOVERY_FAILURE_LIMIT}
|
||
)
|
||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch, "invoke", new=mock.AsyncMock()
|
||
) as invoke:
|
||
success, actual_locator = await dispatch.run_escalating(
|
||
root,
|
||
store,
|
||
task,
|
||
"worker",
|
||
spec,
|
||
initial_resume_locator=locator,
|
||
)
|
||
self.assertFalse(success)
|
||
self.assertEqual(actual_locator, locator)
|
||
self.assertEqual(invoke.await_count, 0)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_does_not_promote_work_log_infrastructure_failure(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
task = self.make_task(root)
|
||
spec = dispatch.AgentSpec("codex", "gpt-5.6-sol", "codex")
|
||
locator = root / "locator.json"
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"invoke",
|
||
new=mock.AsyncMock(
|
||
return_value=(1, "work-log-runtime-write", locator)
|
||
),
|
||
) as invoke:
|
||
success, actual_locator = await dispatch.run_escalating(
|
||
root,
|
||
mock.Mock(),
|
||
task,
|
||
"worker",
|
||
spec,
|
||
)
|
||
self.assertFalse(success)
|
||
self.assertEqual(actual_locator, locator)
|
||
self.assertEqual(invoke.await_count, 1)
|
||
|
||
|
||
class RepetitionLimitTest(unittest.IsolatedAsyncioTestCase):
|
||
async def test_exhausted_selfcheck_budget_does_not_invoke_model(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
(root / ".git").mkdir()
|
||
task = TaskStageTest().make_task(root)
|
||
store = dispatch.StateStore(root)
|
||
store.update_task(
|
||
task, selfcheck_incomplete=dispatch.SELF_CHECK_INCOMPLETE_LIMIT
|
||
)
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch, "run_escalating", new=mock.AsyncMock()
|
||
) as run_escalating:
|
||
await dispatch.run_selfcheck(
|
||
root,
|
||
store,
|
||
task,
|
||
{"pi:ornith:35b": asyncio.Semaphore(1)},
|
||
)
|
||
self.assertEqual(run_escalating.await_count, 0)
|
||
self.assertIn(
|
||
"limit already exhausted", store.task_state(task)["blocked"]
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_exhausted_review_budget_does_not_invoke_model(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
(root / ".git").mkdir()
|
||
task = TaskStageTest().make_task(root)
|
||
store = dispatch.StateStore(root)
|
||
store.update_task(
|
||
task, review_no_progress=dispatch.REVIEW_NO_PROGRESS_LIMIT
|
||
)
|
||
try:
|
||
with mock.patch.object(
|
||
dispatch, "run_escalating", new=mock.AsyncMock()
|
||
) as run_escalating:
|
||
result = await dispatch.run_review(root, store, task)
|
||
self.assertIsNone(result)
|
||
self.assertEqual(run_escalating.await_count, 0)
|
||
self.assertIn(
|
||
"limit already exhausted", store.task_state(task)["blocked"]
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_selfcheck_incomplete_tenth_pass_blocks_task(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
(root / ".git").mkdir()
|
||
task = TaskStageTest().make_task(root)
|
||
store = dispatch.StateStore(root)
|
||
store.update_task(
|
||
task,
|
||
selfcheck_incomplete=dispatch.SELF_CHECK_INCOMPLETE_LIMIT - 1,
|
||
)
|
||
locator = root / "locator.json"
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"run_escalating",
|
||
new=mock.AsyncMock(return_value=(True, locator)),
|
||
) as run_escalating,
|
||
mock.patch.object(
|
||
dispatch,
|
||
"implementation_review_errors",
|
||
return_value=["검증 결과 미완성"],
|
||
),
|
||
):
|
||
await dispatch.run_selfcheck(
|
||
root,
|
||
store,
|
||
task,
|
||
{"pi:ornith:35b": asyncio.Semaphore(1)},
|
||
)
|
||
self.assertEqual(run_escalating.await_count, 1)
|
||
self.assertIn(
|
||
"selfcheck implementation fields remain incomplete",
|
||
store.task_state(task)["blocked"],
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_review_tenth_no_progress_pass_blocks_task(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
(root / ".git").mkdir()
|
||
task = TaskStageTest().make_task(root)
|
||
store = dispatch.StateStore(root)
|
||
store.update_task(
|
||
task,
|
||
review_no_progress=dispatch.REVIEW_NO_PROGRESS_LIMIT - 1,
|
||
)
|
||
locator = root / "locator.json"
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"run_escalating",
|
||
new=mock.AsyncMock(return_value=(True, locator)),
|
||
),
|
||
mock.patch.object(
|
||
dispatch, "task_signature", return_value="unchanged"
|
||
),
|
||
mock.patch.object(
|
||
dispatch, "review_fingerprints", return_value=set()
|
||
),
|
||
):
|
||
result = await dispatch.run_review(root, store, task)
|
||
self.assertIsNone(result)
|
||
self.assertIn(
|
||
"review made no progress", store.task_state(task)["blocked"]
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
class BlockerDrainTest(unittest.IsolatedAsyncioTestCase):
|
||
async def test_user_review_only_holds_its_dependency_closure(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
group = workspace / "agent-task" / "m-test"
|
||
gate_dir = group / "01_gate"
|
||
dependent_dir = group / "02+01_dependent"
|
||
independent_dir = group / "03_independent"
|
||
for directory in (gate_dir, dependent_dir, independent_dir):
|
||
directory.mkdir(parents=True)
|
||
|
||
user_review = gate_dir / "USER_REVIEW.md"
|
||
user_review.write_text(
|
||
TaskStageTest.blocking_user_review_text(), encoding="utf-8"
|
||
)
|
||
gate = dispatch.Task(
|
||
name="m-test/01_gate",
|
||
directory=gate_dir,
|
||
plan=None,
|
||
review=None,
|
||
user_review=user_review,
|
||
recovery=True,
|
||
index=1,
|
||
)
|
||
|
||
def runnable_task(name, directory, index, deps=()):
|
||
plan = directory / "PLAN-local-G05.md"
|
||
review = directory / "CODE_REVIEW-local-G05.md"
|
||
plan.write_text(
|
||
f"<!-- task={name} plan=0 tag=TEST -->\n",
|
||
encoding="utf-8",
|
||
)
|
||
review.write_text("", encoding="utf-8")
|
||
return dispatch.Task(
|
||
name=name,
|
||
directory=directory,
|
||
plan=plan,
|
||
review=review,
|
||
user_review=None,
|
||
recovery=False,
|
||
index=index,
|
||
deps=deps,
|
||
lane="local",
|
||
grade=5,
|
||
plan_hash=f"{name}-hash",
|
||
)
|
||
|
||
dependent = runnable_task(
|
||
"m-test/02+01_dependent", dependent_dir, 2, ("01",)
|
||
)
|
||
independent = runnable_task(
|
||
"m-test/03_independent", independent_dir, 3
|
||
)
|
||
completed_archive = workspace / "completed-independent"
|
||
completed_archive.mkdir()
|
||
(completed_archive / "complete.log").write_text(
|
||
"complete\n", encoding="utf-8"
|
||
)
|
||
args = SimpleNamespace(
|
||
task_group="m-test", retry_blocked=False, dry_run=False
|
||
)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"scan_tasks",
|
||
side_effect=[
|
||
[gate, dependent, independent],
|
||
[gate, dependent],
|
||
],
|
||
),
|
||
mock.patch.object(
|
||
dispatch,
|
||
"run_worker",
|
||
new=mock.AsyncMock(return_value=str(completed_archive)),
|
||
) as run_worker,
|
||
):
|
||
result = await dispatch.dispatch_with_store(
|
||
args, workspace, store
|
||
)
|
||
self.assertEqual(result, 2)
|
||
self.assertEqual(run_worker.await_count, 1)
|
||
self.assertEqual(
|
||
run_worker.await_args.args[2].name, independent.name
|
||
)
|
||
orchestration = store.data["orchestrations"]["m-test"]["tasks"]
|
||
self.assertEqual(orchestration[gate.name]["status"], "blocked")
|
||
self.assertEqual(
|
||
orchestration[dependent.name]["status"], "waiting"
|
||
)
|
||
self.assertEqual(
|
||
orchestration[independent.name]["status"], "complete"
|
||
)
|
||
self.assertEqual(
|
||
store.data["orchestrations"]["m-test"]["status"],
|
||
"blocked",
|
||
)
|
||
self.assertEqual(
|
||
orchestration[independent.name]["archive"],
|
||
str(completed_archive.resolve()),
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_runtime_blocker_still_drains_independent_task(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
group = workspace / "agent-task" / "m-test"
|
||
gate_dir = group / "01_gate"
|
||
independent_dir = group / "02_independent"
|
||
gate_dir.mkdir(parents=True)
|
||
independent_dir.mkdir(parents=True)
|
||
|
||
gate = TaskStageTest().make_task(gate_dir)
|
||
gate.name = "m-test/01_gate"
|
||
gate.index = 1
|
||
gate.plan_hash = "gate-hash"
|
||
independent = TaskStageTest().make_task(independent_dir)
|
||
independent.name = "m-test/02_independent"
|
||
independent.index = 2
|
||
independent.plan_hash = "independent-hash"
|
||
|
||
completed_archive = workspace / "completed-independent"
|
||
completed_archive.mkdir()
|
||
(completed_archive / "complete.log").write_text(
|
||
"complete\n", encoding="utf-8"
|
||
)
|
||
completed_tasks: list[str] = []
|
||
|
||
async def fake_worker(workspace_path, state_store, task, semaphores):
|
||
if task.name == gate.name:
|
||
state_store.update_task(
|
||
task,
|
||
blocked="worker recovery failure limit exhausted: 10/10",
|
||
)
|
||
return None
|
||
completed_tasks.append(task.name)
|
||
return str(completed_archive)
|
||
|
||
args = SimpleNamespace(
|
||
task_group="m-test", retry_blocked=False, dry_run=False
|
||
)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"scan_tasks",
|
||
side_effect=[[gate, independent], [gate]],
|
||
),
|
||
mock.patch.object(dispatch, "run_worker", new=fake_worker),
|
||
):
|
||
result = await dispatch.dispatch_with_store(
|
||
args, workspace, store
|
||
)
|
||
self.assertEqual(result, 2)
|
||
self.assertEqual(completed_tasks, [independent.name])
|
||
group_state = store.data["orchestrations"]["m-test"]
|
||
orchestration = group_state["tasks"]
|
||
self.assertEqual(group_state["status"], "blocked")
|
||
self.assertEqual(orchestration[gate.name]["status"], "blocked")
|
||
self.assertEqual(
|
||
orchestration[independent.name]["status"], "complete"
|
||
)
|
||
store.prepare_orchestration("m-test", [gate], workspace)
|
||
group_state = store.data["orchestrations"]["m-test"]
|
||
self.assertEqual(group_state["status"], "running")
|
||
self.assertEqual(
|
||
group_state["tasks"][gate.name]["status"], "active"
|
||
)
|
||
self.assertNotIn(
|
||
"reason", group_state["tasks"][gate.name]
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_unexpected_agent_exception_drains_sibling_then_returns_three(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
group = workspace / "agent-task" / "m-test"
|
||
failed_dir = group / "01_failed"
|
||
sibling_dir = group / "02_sibling"
|
||
failed_dir.mkdir(parents=True)
|
||
sibling_dir.mkdir(parents=True)
|
||
|
||
failed = TaskStageTest().make_task(failed_dir)
|
||
failed.name = "m-test/01_failed"
|
||
failed.index = 1
|
||
failed.plan_hash = "failed-hash"
|
||
sibling = TaskStageTest().make_task(sibling_dir)
|
||
sibling.name = "m-test/02_sibling"
|
||
sibling.index = 2
|
||
sibling.plan_hash = "sibling-hash"
|
||
|
||
completed_archive = workspace / "completed-sibling"
|
||
completed_archive.mkdir()
|
||
(completed_archive / "complete.log").write_text(
|
||
"complete\n", encoding="utf-8"
|
||
)
|
||
events: list[str] = []
|
||
|
||
async def fake_worker(workspace_path, state_store, task, semaphores):
|
||
if task.name == failed.name:
|
||
raise RuntimeError("unexpected control failure")
|
||
events.append("sibling-started")
|
||
await asyncio.sleep(0.02)
|
||
events.append("sibling-finished")
|
||
return str(completed_archive)
|
||
|
||
args = SimpleNamespace(
|
||
task_group="m-test", retry_blocked=False, dry_run=False
|
||
)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"scan_tasks",
|
||
side_effect=[[failed, sibling], [failed]],
|
||
),
|
||
mock.patch.object(dispatch, "run_worker", new=fake_worker),
|
||
):
|
||
result = await dispatch.dispatch_with_store(
|
||
args, workspace, store
|
||
)
|
||
|
||
self.assertEqual(result, 3)
|
||
self.assertEqual(events, ["sibling-started", "sibling-finished"])
|
||
group_state = store.data["orchestrations"]["m-test"]
|
||
self.assertEqual(group_state["status"], "running")
|
||
self.assertNotEqual(
|
||
group_state["tasks"][failed.name]["status"],
|
||
"blocked",
|
||
)
|
||
self.assertEqual(
|
||
group_state["tasks"][sibling.name]["status"],
|
||
"complete",
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_review_preflight_failure_still_drains_independent_worker(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
review_dir = workspace / "agent-task" / "m-test" / "01_review"
|
||
worker_dir = workspace / "agent-task" / "m-test" / "02_worker"
|
||
review_dir.mkdir(parents=True)
|
||
worker_dir.mkdir(parents=True)
|
||
|
||
review = TaskStageTest().make_task(review_dir)
|
||
review.name = "m-test/01_review"
|
||
review.index = 1
|
||
review.plan_hash = "review-hash"
|
||
worker = TaskStageTest().make_task(worker_dir)
|
||
worker.name = "m-test/02_worker"
|
||
worker.index = 2
|
||
worker.plan_hash = "worker-hash"
|
||
|
||
completed_archive = workspace / "completed-worker"
|
||
completed_archive.mkdir()
|
||
(completed_archive / "complete.log").write_text(
|
||
"complete\n", encoding="utf-8"
|
||
)
|
||
args = SimpleNamespace(
|
||
task_group="m-test", retry_blocked=False, dry_run=False
|
||
)
|
||
store = dispatch.StateStore(workspace)
|
||
store.update_task(
|
||
review,
|
||
worker_done=True,
|
||
selfcheck_done=True,
|
||
)
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"scan_tasks",
|
||
side_effect=[[review, worker], [review]],
|
||
),
|
||
mock.patch.object(
|
||
dispatch,
|
||
"ensure_review_shared_state",
|
||
side_effect=RuntimeError("shared helper unavailable"),
|
||
),
|
||
mock.patch.object(
|
||
dispatch,
|
||
"run_worker",
|
||
new=mock.AsyncMock(return_value=str(completed_archive)),
|
||
) as run_worker,
|
||
mock.patch.object(
|
||
dispatch, "run_review", new=mock.AsyncMock()
|
||
) as run_review,
|
||
):
|
||
result = await dispatch.dispatch_with_store(
|
||
args, workspace, store
|
||
)
|
||
self.assertEqual(result, 2)
|
||
self.assertEqual(run_worker.await_count, 1)
|
||
self.assertEqual(run_review.await_count, 0)
|
||
orchestration = store.data["orchestrations"]["m-test"]["tasks"]
|
||
self.assertEqual(orchestration[review.name]["status"], "blocked")
|
||
self.assertEqual(orchestration[worker.name]["status"], "complete")
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_invalidated_complete_archive_cannot_end_with_success(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task_dir = workspace / "agent-task" / "m-test" / "01_task"
|
||
task_dir.mkdir(parents=True)
|
||
task = TaskStageTest().make_task(task_dir)
|
||
task.name = "m-test/01_task"
|
||
task.index = 1
|
||
task.plan_hash = "task-hash"
|
||
|
||
archive = workspace / "completed-task"
|
||
archive.mkdir()
|
||
complete_log = archive / "complete.log"
|
||
complete_log.write_text("complete\n", encoding="utf-8")
|
||
scan_count = 0
|
||
|
||
def scan_side_effect(*args, **kwargs):
|
||
nonlocal scan_count
|
||
scan_count += 1
|
||
if scan_count == 1:
|
||
return [task]
|
||
complete_log.unlink()
|
||
return []
|
||
|
||
args = SimpleNamespace(
|
||
task_group="m-test", retry_blocked=False, dry_run=False
|
||
)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch, "scan_tasks", side_effect=scan_side_effect
|
||
),
|
||
mock.patch.object(
|
||
dispatch,
|
||
"run_worker",
|
||
new=mock.AsyncMock(return_value=str(archive)),
|
||
),
|
||
):
|
||
result = await dispatch.dispatch_with_store(
|
||
args, workspace, store
|
||
)
|
||
self.assertEqual(result, 2)
|
||
self.assertEqual(
|
||
store.data["orchestrations"]["m-test"]["status"],
|
||
"blocked",
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
async def test_external_active_task_returns_non_terminal_exit_three(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
directory = workspace / "agent-task" / "m-test" / "01_active"
|
||
directory.mkdir(parents=True)
|
||
task = TaskStageTest().make_task(directory)
|
||
task.name = "m-test/01_active"
|
||
task.index = 1
|
||
task.plan_hash = "active-hash"
|
||
locator = workspace / "locator.json"
|
||
args = SimpleNamespace(
|
||
task_group="m-test", retry_blocked=False, dry_run=False
|
||
)
|
||
store = dispatch.StateStore(workspace)
|
||
store.mark_active(task, "worker", locator)
|
||
try:
|
||
with (
|
||
mock.patch.object(
|
||
dispatch, "scan_tasks", return_value=[task]
|
||
),
|
||
mock.patch.object(
|
||
dispatch,
|
||
"external_active_is_live",
|
||
return_value=(True, "pid=123"),
|
||
),
|
||
mock.patch.object(
|
||
dispatch, "run_worker", new=mock.AsyncMock()
|
||
) as run_worker,
|
||
):
|
||
result = await dispatch.dispatch_with_store(
|
||
args, workspace, store
|
||
)
|
||
self.assertEqual(result, 3)
|
||
self.assertEqual(run_worker.await_count, 0)
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
class ReviewSchedulingTest(unittest.TestCase):
|
||
def test_all_ready_reviews_are_selected_without_numeric_cap(self):
|
||
reviews = [
|
||
(mock.sentinel.review_a, "review"),
|
||
(mock.sentinel.review_b, "review"),
|
||
(mock.sentinel.review_c, "review"),
|
||
]
|
||
worker = (mock.sentinel.worker, "worker")
|
||
selected, deferred, reason = dispatch.select_dispatch_candidates(
|
||
[*reviews, worker]
|
||
)
|
||
self.assertEqual(selected, [*reviews, worker])
|
||
self.assertEqual(deferred, [])
|
||
self.assertEqual(reason, "")
|
||
|
||
def test_new_reviews_join_already_running_review_phase(self):
|
||
reviews = [
|
||
(mock.sentinel.review_a, "review"),
|
||
(mock.sentinel.review_b, "review"),
|
||
]
|
||
worker = (mock.sentinel.worker, "selfcheck")
|
||
selected, deferred, reason = dispatch.select_dispatch_candidates(
|
||
[*reviews, worker]
|
||
)
|
||
self.assertEqual(selected, [*reviews, worker])
|
||
self.assertEqual(deferred, [])
|
||
self.assertEqual(reason, "")
|
||
|
||
def test_only_declared_same_group_live_predecessors_delay_task(self):
|
||
task = dispatch.Task(
|
||
name="group/03+01,02_join",
|
||
directory=Path("/tmp/group/03+01,02_join"),
|
||
plan=None,
|
||
review=None,
|
||
user_review=None,
|
||
recovery=False,
|
||
deps=("01", "02"),
|
||
)
|
||
|
||
live = dispatch.live_predecessors(
|
||
task,
|
||
{
|
||
"group/01_core",
|
||
"other/02_unrelated",
|
||
"group/04_parallel",
|
||
},
|
||
)
|
||
|
||
self.assertEqual(live, ["01"])
|
||
|
||
def test_task_without_declared_dependency_ignores_live_siblings(self):
|
||
task = dispatch.Task(
|
||
name="group/04_parallel",
|
||
directory=Path("/tmp/group/04_parallel"),
|
||
plan=None,
|
||
review=None,
|
||
user_review=None,
|
||
recovery=False,
|
||
)
|
||
|
||
self.assertEqual(
|
||
dispatch.live_predecessors(
|
||
task,
|
||
{"group/01_core", "group/02_other"},
|
||
),
|
||
[],
|
||
)
|
||
|
||
|
||
class WriteSetTest(unittest.TestCase):
|
||
def test_normalizes_relative_and_absolute_aliases(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
source = workspace / "src" / "shared.go"
|
||
source.parent.mkdir()
|
||
source.write_text("package src\n", encoding="utf-8")
|
||
relative_plan = workspace / "relative.md"
|
||
absolute_plan = workspace / "absolute.md"
|
||
relative_plan.write_text(
|
||
"## 수정 파일 요약\n\n| 파일 | 항목 |\n|---|---|\n"
|
||
"| `./src/shared.go` | TEST-1 |\n",
|
||
encoding="utf-8",
|
||
)
|
||
absolute_plan.write_text(
|
||
"## 수정 파일 요약\n\n| 파일 | 항목 |\n|---|---|\n"
|
||
f"| `{source}` | TEST-1 |\n",
|
||
encoding="utf-8",
|
||
)
|
||
relative, relative_known = dispatch.extract_write_set(
|
||
relative_plan, workspace
|
||
)
|
||
absolute, absolute_known = dispatch.extract_write_set(
|
||
absolute_plan, workspace
|
||
)
|
||
self.assertTrue(relative_known)
|
||
self.assertTrue(absolute_known)
|
||
self.assertEqual(relative, absolute)
|
||
self.assertEqual(relative, {str(source.resolve())})
|
||
|
||
def test_recovery_restores_write_set_from_matching_archived_plan(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
task = workspace / "agent-task" / "recovery"
|
||
task.mkdir(parents=True)
|
||
header = "<!-- task=recovery plan=2 tag=REVIEW_TEST -->\n"
|
||
(task / "plan_local_G05_2.log").write_text(
|
||
header
|
||
+ "## 수정 파일 요약\n\n| 파일 | 항목 |\n|---|---|\n"
|
||
"| `src/recovery.go` | TEST-1 |\n",
|
||
encoding="utf-8",
|
||
)
|
||
(task / "code_review_local_G05_2.log").write_text(
|
||
header + "## 코드리뷰 결과\n- 종합 판정: WARN\n",
|
||
encoding="utf-8",
|
||
)
|
||
(task / "plan_cloud_G09_3.log").write_text(
|
||
"<!-- task=recovery plan=3 tag=OTHER -->\n"
|
||
"## 수정 파일 요약\n\n| 파일 | 항목 |\n|---|---|\n"
|
||
"| `src/unrelated.go` | OTHER-1 |\n",
|
||
encoding="utf-8",
|
||
)
|
||
[scanned] = dispatch.scan_tasks(workspace, None)
|
||
self.assertTrue(scanned.write_set_known)
|
||
self.assertEqual(
|
||
scanned.write_set,
|
||
{str((workspace / "src" / "recovery.go").resolve())},
|
||
)
|
||
self.assertEqual(scanned.errors, [])
|
||
|
||
def test_recovery_without_matching_plan_does_not_block_dispatch(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
task = workspace / "agent-task" / "recovery"
|
||
task.mkdir(parents=True)
|
||
(task / "code_review_local_G05_2.log").write_text(
|
||
"<!-- task=recovery plan=2 tag=REVIEW_TEST -->\n"
|
||
"## 코드리뷰 결과\n"
|
||
"- 종합 판정: WARN\n",
|
||
encoding="utf-8",
|
||
)
|
||
[scanned] = dispatch.scan_tasks(workspace, None)
|
||
self.assertFalse(scanned.write_set_known)
|
||
self.assertEqual(scanned.errors, [])
|
||
|
||
def test_rejects_broad_or_outside_workspace_write_sets(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / "src").mkdir()
|
||
plan = workspace / "unsafe.md"
|
||
plan.write_text(
|
||
"## 수정 파일 요약\n\n| 파일 | 항목 |\n|---|---|\n"
|
||
"| `src/` | TEST-1 |\n"
|
||
"| `../outside.go` | TEST-2 |\n"
|
||
"| `src/*.go` | TEST-3 |\n",
|
||
encoding="utf-8",
|
||
)
|
||
write_set, known = dispatch.extract_write_set(plan, workspace)
|
||
self.assertFalse(known)
|
||
self.assertEqual(write_set, set())
|
||
|
||
def test_review_progress_signature_ignores_dispatcher_work_log(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
task = TaskStageTest().make_task(workspace)
|
||
before = dispatch.task_signature(workspace, task)
|
||
|
||
(workspace / dispatch.WORK_LOG_NAME).write_text(
|
||
"| FINISH | test | review |\n", encoding="utf-8"
|
||
)
|
||
after_work_log = dispatch.task_signature(workspace, task)
|
||
self.assertEqual(after_work_log, before)
|
||
|
||
assert task.review is not None
|
||
task.review.write_text(
|
||
"## 코드리뷰 결과\n- 종합 판정: WARN\n",
|
||
encoding="utf-8",
|
||
)
|
||
after_review = dispatch.task_signature(workspace, task)
|
||
self.assertNotEqual(after_review, before)
|
||
|
||
|
||
class WorkLogArchiveTest(unittest.TestCase):
|
||
def complete_archive(
|
||
self,
|
||
workspace: Path,
|
||
task_name: str,
|
||
month: str = "07",
|
||
suffix: str = "",
|
||
) -> Path:
|
||
archive = (
|
||
workspace
|
||
/ "agent-task"
|
||
/ "archive"
|
||
/ "2026"
|
||
/ month
|
||
/ f"{task_name}{suffix}"
|
||
)
|
||
archive.mkdir(parents=True)
|
||
(archive / "complete.log").write_text(
|
||
f"complete {task_name}\n",
|
||
encoding="utf-8",
|
||
)
|
||
return archive
|
||
|
||
def test_archives_split_group_log_with_next_cross_month_number(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
active_group = workspace / "agent-task" / "group"
|
||
active_group.mkdir(parents=True)
|
||
source = active_group / dispatch.WORK_LOG_NAME
|
||
source.write_text("final timeline\n", encoding="utf-8")
|
||
(active_group / "01_done").mkdir()
|
||
old_group = (
|
||
workspace
|
||
/ "agent-task"
|
||
/ "archive"
|
||
/ "2026"
|
||
/ "06"
|
||
/ "group"
|
||
)
|
||
old_group.mkdir(parents=True)
|
||
(old_group / "work_log_0.log").write_text(
|
||
"old timeline\n",
|
||
encoding="utf-8",
|
||
)
|
||
archive = self.complete_archive(
|
||
workspace,
|
||
"group/01_done",
|
||
)
|
||
|
||
archived, errors = dispatch.archive_completed_group_work_logs(
|
||
workspace,
|
||
{"group/01_done"},
|
||
{"group/01_done": str(archive)},
|
||
set(),
|
||
)
|
||
|
||
destination = archive.parent / "work_log_1.log"
|
||
self.assertEqual(errors, {})
|
||
self.assertEqual(
|
||
archived,
|
||
{"group": str(destination.resolve())},
|
||
)
|
||
self.assertEqual(
|
||
destination.read_text(encoding="utf-8"),
|
||
"final timeline\n",
|
||
)
|
||
self.assertFalse(source.exists())
|
||
self.assertFalse(active_group.exists())
|
||
|
||
def test_does_not_archive_until_every_group_task_is_complete_and_idle(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
active_group = workspace / "agent-task" / "group"
|
||
active_group.mkdir(parents=True)
|
||
source = active_group / dispatch.WORK_LOG_NAME
|
||
source.write_text("in progress\n", encoding="utf-8")
|
||
archive = self.complete_archive(
|
||
workspace,
|
||
"group/01_done",
|
||
)
|
||
|
||
archived, errors = dispatch.archive_completed_group_work_logs(
|
||
workspace,
|
||
{"group/01_done", "group/02_open"},
|
||
{"group/01_done": str(archive)},
|
||
{"group/02_open"},
|
||
)
|
||
|
||
self.assertEqual(archived, {})
|
||
self.assertEqual(errors, {})
|
||
self.assertEqual(
|
||
source.read_text(encoding="utf-8"),
|
||
"in progress\n",
|
||
)
|
||
self.assertFalse((archive.parent / "work_log_0.log").exists())
|
||
|
||
def test_archives_single_task_log_inside_its_suffixed_archive(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
active_group = workspace / "agent-task" / "single"
|
||
active_group.mkdir(parents=True)
|
||
source = active_group / dispatch.WORK_LOG_NAME
|
||
source.write_text("single timeline\n", encoding="utf-8")
|
||
archive = self.complete_archive(
|
||
workspace,
|
||
"single",
|
||
suffix="_1",
|
||
)
|
||
|
||
archived, errors = dispatch.archive_completed_group_work_logs(
|
||
workspace,
|
||
{"single"},
|
||
{"single": str(archive)},
|
||
set(),
|
||
)
|
||
|
||
destination = archive / "work_log_0.log"
|
||
self.assertEqual(errors, {})
|
||
self.assertEqual(
|
||
archived,
|
||
{"single": str(destination.resolve())},
|
||
)
|
||
self.assertEqual(
|
||
destination.read_text(encoding="utf-8"),
|
||
"single timeline\n",
|
||
)
|
||
self.assertFalse(active_group.exists())
|
||
|
||
def test_closes_unmatched_start_before_archiving_completed_group(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
source = (
|
||
workspace
|
||
/ "agent-task"
|
||
/ "group"
|
||
/ dispatch.WORK_LOG_NAME
|
||
)
|
||
locator = workspace / "runs" / "attempt" / "locator.json"
|
||
dispatch.append_work_log_event(
|
||
source,
|
||
task_name="group/01_done",
|
||
event="START",
|
||
execution_id="group__01_done__p0__review__a00",
|
||
role="review",
|
||
attempt=0,
|
||
model="codex/gpt-5.6-sol xhigh",
|
||
result="running",
|
||
locator=locator,
|
||
)
|
||
archive = self.complete_archive(
|
||
workspace,
|
||
"group/01_done",
|
||
)
|
||
|
||
archived, errors = dispatch.archive_completed_group_work_logs(
|
||
workspace,
|
||
{"group/01_done"},
|
||
{"group/01_done": str(archive)},
|
||
set(),
|
||
)
|
||
|
||
destination = archive.parent / "work_log_0.log"
|
||
text = destination.read_text(encoding="utf-8")
|
||
self.assertEqual(errors, {})
|
||
self.assertEqual(
|
||
archived,
|
||
{"group": str(destination.resolve())},
|
||
)
|
||
self.assertEqual(text.count("| START |"), 1)
|
||
self.assertEqual(text.count("| FINISH |"), 1)
|
||
self.assertIn(
|
||
"reconciled:verified-complete-archive",
|
||
text,
|
||
)
|
||
self.assertEqual(
|
||
dispatch.unfinished_work_log_attempts(destination),
|
||
[],
|
||
)
|
||
|
||
def test_normalizes_legacy_work_log_already_moved_by_review(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
archive = self.complete_archive(workspace, "single")
|
||
legacy = archive / dispatch.WORK_LOG_NAME
|
||
legacy.write_text("legacy timeline\n", encoding="utf-8")
|
||
|
||
archived, errors = dispatch.archive_completed_group_work_logs(
|
||
workspace,
|
||
{"single"},
|
||
{"single": str(archive)},
|
||
set(),
|
||
)
|
||
|
||
destination = archive / "work_log_0.log"
|
||
self.assertEqual(errors, {})
|
||
self.assertEqual(
|
||
archived,
|
||
{"single": str(destination.resolve())},
|
||
)
|
||
self.assertFalse(legacy.exists())
|
||
self.assertEqual(
|
||
destination.read_text(encoding="utf-8"),
|
||
"legacy timeline\n",
|
||
)
|
||
|
||
def test_archive_failure_preserves_source_and_reports_retryable_error(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
active_group = workspace / "agent-task" / "group"
|
||
active_group.mkdir(parents=True)
|
||
source = active_group / dispatch.WORK_LOG_NAME
|
||
source.write_text("keep me\n", encoding="utf-8")
|
||
archive = self.complete_archive(
|
||
workspace,
|
||
"group/01_done",
|
||
)
|
||
|
||
with mock.patch.object(
|
||
Path,
|
||
"replace",
|
||
side_effect=OSError("disk full"),
|
||
):
|
||
archived, errors = dispatch.archive_completed_group_work_logs(
|
||
workspace,
|
||
{"group/01_done"},
|
||
{"group/01_done": str(archive)},
|
||
set(),
|
||
)
|
||
|
||
self.assertEqual(archived, {})
|
||
self.assertIn("group", errors)
|
||
self.assertIn("disk full", errors["group"])
|
||
self.assertEqual(
|
||
source.read_text(encoding="utf-8"),
|
||
"keep me\n",
|
||
)
|
||
|
||
def test_existing_destination_is_never_overwritten(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
active_group = workspace / "agent-task" / "group"
|
||
active_group.mkdir(parents=True)
|
||
source = active_group / dispatch.WORK_LOG_NAME
|
||
source.write_text("new timeline\n", encoding="utf-8")
|
||
archive = self.complete_archive(
|
||
workspace,
|
||
"group/01_done",
|
||
)
|
||
destination = archive.parent / "work_log_0.log"
|
||
destination.write_text("existing timeline\n", encoding="utf-8")
|
||
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"next_work_log_archive_number",
|
||
return_value=0,
|
||
):
|
||
archived, errors = dispatch.archive_completed_group_work_logs(
|
||
workspace,
|
||
{"group/01_done"},
|
||
{"group/01_done": str(archive)},
|
||
set(),
|
||
)
|
||
|
||
self.assertEqual(archived, {})
|
||
self.assertIn("이미 존재한다", errors["group"])
|
||
self.assertEqual(
|
||
destination.read_text(encoding="utf-8"),
|
||
"existing timeline\n",
|
||
)
|
||
self.assertEqual(
|
||
source.read_text(encoding="utf-8"),
|
||
"new timeline\n",
|
||
)
|
||
|
||
def test_process_marker_recovers_liveness_when_pid_write_was_lost(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
locator = Path(temporary) / "locator.json"
|
||
locator.write_text(
|
||
json.dumps(
|
||
{
|
||
"status": "running",
|
||
"agent_process_marker": "attempt-marker",
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"marked_agent_process_pids",
|
||
return_value=[123, 456],
|
||
):
|
||
live, detail = dispatch.external_active_is_live(
|
||
{"active_locator": str(locator)}
|
||
)
|
||
self.assertTrue(live)
|
||
self.assertIn("123,456", detail)
|
||
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"marked_agent_process_pids",
|
||
return_value=[],
|
||
):
|
||
live, detail = dispatch.external_active_is_live(
|
||
{"active_locator": str(locator)}
|
||
)
|
||
self.assertFalse(live)
|
||
self.assertIn("absent from the process table", detail)
|
||
|
||
def test_process_marker_finds_spawned_agent_process(self):
|
||
marker = f"test-marker-{uuid.uuid4()}"
|
||
environment = {
|
||
**os.environ,
|
||
dispatch.AGENT_PROCESS_MARKER_ENV: marker,
|
||
}
|
||
process = subprocess.Popen(
|
||
[
|
||
sys.executable,
|
||
"-c",
|
||
"import time; time.sleep(5)",
|
||
],
|
||
env=environment,
|
||
)
|
||
try:
|
||
found: list[int] = []
|
||
for _ in range(50):
|
||
found = dispatch.marked_agent_process_pids(marker)
|
||
if process.pid in found:
|
||
break
|
||
time.sleep(0.01)
|
||
self.assertIn(process.pid, found)
|
||
finally:
|
||
process.terminate()
|
||
process.wait(timeout=5)
|
||
|
||
def test_orchestration_keeps_pidless_stream_evidence_active(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
stream = workspace / "stream.log"
|
||
stream.write_text("reasoning\n", encoding="utf-8")
|
||
locator = workspace / "locator.json"
|
||
locator.write_text(
|
||
json.dumps(
|
||
{
|
||
"status": "running",
|
||
"cli": "codex",
|
||
"stream_log": str(stream),
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
store.data["orchestrations"] = {
|
||
"group": {
|
||
"status": "running",
|
||
"tasks": {
|
||
"group/01_done": {
|
||
"status": "active",
|
||
"archive": None,
|
||
}
|
||
},
|
||
}
|
||
}
|
||
store.data["tasks"] = {
|
||
"group/01_done": {
|
||
"active_locator": str(locator),
|
||
}
|
||
}
|
||
|
||
live = dispatch.orchestration_live_agent_processes(
|
||
store,
|
||
"group",
|
||
)
|
||
|
||
self.assertIn("group/01_done", live)
|
||
self.assertIn(
|
||
"time-based duplicate recovery is disabled",
|
||
live["group/01_done"],
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
def test_restart_waits_for_live_writer_then_reconciles_and_archives(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task_directory = workspace / "agent-task" / "group" / "01_done"
|
||
task_directory.mkdir(parents=True)
|
||
plan = task_directory / "PLAN-local-G05.md"
|
||
review = task_directory / "CODE_REVIEW-local-G05.md"
|
||
plan.write_text(
|
||
"<!-- task=group/01_done plan=0 tag=TEST -->\n",
|
||
encoding="utf-8",
|
||
)
|
||
review.write_text(
|
||
"<!-- task=group/01_done plan=0 tag=TEST -->\n",
|
||
encoding="utf-8",
|
||
)
|
||
task = dispatch.Task(
|
||
name="group/01_done",
|
||
directory=task_directory,
|
||
plan=plan,
|
||
review=review,
|
||
user_review=None,
|
||
recovery=False,
|
||
plan_hash=dispatch.sha256_file(plan),
|
||
lane="local",
|
||
grade=5,
|
||
)
|
||
source = dispatch.append_milestone_event(
|
||
task,
|
||
event="START",
|
||
execution_id="group__01_done__p0__review__a00",
|
||
role="review",
|
||
attempt=0,
|
||
model="codex/gpt-5.6-sol xhigh",
|
||
result="running",
|
||
locator=workspace / "runs" / "attempt" / "locator.json",
|
||
)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
store.prepare_orchestration("group", [task], workspace)
|
||
archive = (
|
||
workspace
|
||
/ "agent-task"
|
||
/ "archive"
|
||
/ "2026"
|
||
/ "07"
|
||
/ "group"
|
||
/ "01_done"
|
||
)
|
||
archive.parent.mkdir(parents=True)
|
||
(task_directory / "complete.log").write_text(
|
||
"complete\n",
|
||
encoding="utf-8",
|
||
)
|
||
task_directory.rename(archive)
|
||
destination = archive.parent / "work_log_0.log"
|
||
wait_observations: list[bool] = []
|
||
|
||
async def observe_wait(seconds):
|
||
self.assertEqual(
|
||
seconds,
|
||
dispatch.STREAM_HEARTBEAT_SECONDS,
|
||
)
|
||
wait_observations.append(
|
||
source.is_file() and not destination.exists()
|
||
)
|
||
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"orchestration_live_agent_processes",
|
||
side_effect=[
|
||
{"group/01_done": "agent_pid=123 alive"},
|
||
{},
|
||
],
|
||
),
|
||
mock.patch.object(
|
||
dispatch.asyncio,
|
||
"sleep",
|
||
new=observe_wait,
|
||
),
|
||
):
|
||
result = asyncio.run(
|
||
dispatch.dispatch_with_store(
|
||
SimpleNamespace(
|
||
task_group="group",
|
||
retry_blocked=False,
|
||
dry_run=False,
|
||
),
|
||
workspace,
|
||
store,
|
||
)
|
||
)
|
||
|
||
self.assertEqual(result, 0)
|
||
self.assertEqual(wait_observations, [True])
|
||
self.assertFalse(source.exists())
|
||
self.assertTrue(destination.is_file())
|
||
text = destination.read_text(encoding="utf-8")
|
||
self.assertIn("| FINISH |", text)
|
||
self.assertIn(
|
||
"reconciled:verified-complete-archive",
|
||
text,
|
||
)
|
||
self.assertEqual(
|
||
store.data["orchestrations"]["group"]["status"],
|
||
"complete",
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
def test_dispatcher_returns_three_when_completed_log_archive_needs_retry(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
(workspace / "agent-task").mkdir()
|
||
archive = self.complete_archive(
|
||
workspace,
|
||
"group/01_done",
|
||
)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
store.mark_orchestration_task_complete(
|
||
"group",
|
||
"group/01_done",
|
||
archive,
|
||
)
|
||
args = SimpleNamespace(
|
||
task_group="group",
|
||
retry_blocked=False,
|
||
dry_run=False,
|
||
)
|
||
with (
|
||
mock.patch.object(dispatch, "scan_tasks", return_value=[]),
|
||
mock.patch.object(
|
||
dispatch,
|
||
"archive_completed_group_work_logs",
|
||
return_value=({}, {"group": "disk full"}),
|
||
),
|
||
):
|
||
result = asyncio.run(
|
||
dispatch.dispatch_with_store(
|
||
args,
|
||
workspace,
|
||
store,
|
||
)
|
||
)
|
||
|
||
self.assertEqual(result, 3)
|
||
self.assertEqual(
|
||
store.data["orchestrations"]["group"]["status"],
|
||
"running",
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
class OrchestrationPersistenceTest(unittest.TestCase):
|
||
def make_task(self, workspace: Path, name: str = "task"):
|
||
directory = workspace / "agent-task" / name
|
||
directory.mkdir(parents=True)
|
||
plan = directory / "PLAN-local-G05.md"
|
||
review = directory / "CODE_REVIEW-local-G05.md"
|
||
plan.write_text(
|
||
f"<!-- task={name} plan=0 tag=TEST -->\n"
|
||
"## 수정 파일 요약\n\n| 파일 | 항목 |\n|---|---|\n"
|
||
"| `src/task.go` | TEST-1 |\n",
|
||
encoding="utf-8",
|
||
)
|
||
review.write_text(
|
||
f"<!-- task={name} plan=0 tag=TEST -->\n",
|
||
encoding="utf-8",
|
||
)
|
||
return dispatch.scan_tasks(workspace, None)[0]
|
||
|
||
def test_complete_archive_removes_only_its_task_attempt_logs(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
runs = Path(temporary) / "runs"
|
||
completed = runs / "completed-attempt"
|
||
other = runs / "other-attempt"
|
||
for attempt, task_name in ((completed, "group/01_done"), (other, "group/02_open")):
|
||
attempt.mkdir(parents=True)
|
||
(attempt / "locator.json").write_text(
|
||
json.dumps({"task": task_name}), encoding="utf-8"
|
||
)
|
||
for name in ("stream.log", "heartbeat.log", "session.jsonl"):
|
||
(attempt / name).write_text("evidence\n", encoding="utf-8")
|
||
|
||
removed = dispatch.cleanup_completed_task_attempt_logs(
|
||
runs, "group/01_done"
|
||
)
|
||
|
||
self.assertEqual(removed, 1)
|
||
self.assertFalse(completed.exists())
|
||
self.assertTrue(other.is_dir())
|
||
self.assertTrue((other / "stream.log").is_file())
|
||
|
||
def test_mark_complete_removes_task_attempt_logs(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
attempt = store.runs / "attempt"
|
||
attempt.mkdir()
|
||
(attempt / "locator.json").write_text(
|
||
json.dumps({"task": "group/01_done"}), encoding="utf-8"
|
||
)
|
||
(attempt / "stream.log").write_text("stream\n", encoding="utf-8")
|
||
archive = workspace / "archive"
|
||
archive.mkdir()
|
||
(archive / "complete.log").write_text("complete\n", encoding="utf-8")
|
||
|
||
store.mark_orchestration_task_complete(
|
||
"group", "group/01_done", archive
|
||
)
|
||
|
||
self.assertFalse(attempt.exists())
|
||
finally:
|
||
store.close()
|
||
|
||
def test_reconcile_keeps_attempt_logs_until_active_writer_exits(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
archive = workspace / "archive"
|
||
archive.mkdir()
|
||
(archive / "complete.log").write_text(
|
||
"complete\n",
|
||
encoding="utf-8",
|
||
)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
store.mark_orchestration_task_complete(
|
||
"group",
|
||
"group/01_done",
|
||
archive,
|
||
)
|
||
attempt = store.runs / "live-attempt"
|
||
attempt.mkdir()
|
||
(attempt / "locator.json").write_text(
|
||
json.dumps({"task": "group/01_done"}),
|
||
encoding="utf-8",
|
||
)
|
||
(attempt / "stream.log").write_text(
|
||
"still running\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
completed, errors = store.reconcile_orchestration(
|
||
"group",
|
||
workspace,
|
||
{"group/01_done"},
|
||
)
|
||
|
||
self.assertEqual(errors, {})
|
||
self.assertIn("group/01_done", completed)
|
||
self.assertTrue(attempt.is_dir())
|
||
|
||
store.reconcile_orchestration("group", workspace, set())
|
||
self.assertFalse(attempt.exists())
|
||
finally:
|
||
store.close()
|
||
|
||
def test_attempt_log_cleanup_failure_does_not_revoke_completion(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
attempt = store.runs / "attempt"
|
||
attempt.mkdir()
|
||
(attempt / "locator.json").write_text(
|
||
json.dumps({"task": "group/01_done"}), encoding="utf-8"
|
||
)
|
||
archive = workspace / "archive"
|
||
archive.mkdir()
|
||
(archive / "complete.log").write_text(
|
||
"complete\n", encoding="utf-8"
|
||
)
|
||
|
||
with mock.patch.object(
|
||
dispatch.shutil,
|
||
"rmtree",
|
||
side_effect=OSError("transient cleanup failure"),
|
||
):
|
||
store.mark_orchestration_task_complete(
|
||
"group", "group/01_done", archive
|
||
)
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"scan_tasks",
|
||
return_value=[],
|
||
):
|
||
result = asyncio.run(
|
||
dispatch.dispatch_with_store(
|
||
SimpleNamespace(
|
||
task_group="group",
|
||
retry_blocked=False,
|
||
dry_run=False,
|
||
),
|
||
workspace,
|
||
store,
|
||
)
|
||
)
|
||
|
||
record = store.data["orchestrations"]["group"]["tasks"][
|
||
"group/01_done"
|
||
]
|
||
self.assertEqual(result, 3)
|
||
self.assertEqual(record["status"], "complete")
|
||
self.assertTrue(attempt.is_dir())
|
||
self.assertEqual(
|
||
dispatch.cleanup_completed_task_attempt_logs(
|
||
store.runs, "group/01_done"
|
||
),
|
||
1,
|
||
)
|
||
self.assertFalse(attempt.exists())
|
||
with mock.patch.object(dispatch, "scan_tasks", return_value=[]):
|
||
result = asyncio.run(
|
||
dispatch.dispatch_with_store(
|
||
SimpleNamespace(
|
||
task_group="group",
|
||
retry_blocked=False,
|
||
dry_run=False,
|
||
),
|
||
workspace,
|
||
store,
|
||
)
|
||
)
|
||
self.assertEqual(result, 0)
|
||
finally:
|
||
store.close()
|
||
|
||
def test_attempt_log_cleanup_pending_precedes_other_terminal_blocker(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
pending_dir = workspace / "agent-task" / "group" / "02_pending"
|
||
pending_dir.mkdir(parents=True)
|
||
pending = TaskStageTest().make_task(pending_dir)
|
||
pending.name = "group/02_pending"
|
||
pending.index = 2
|
||
pending.plan_hash = "pending-hash"
|
||
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
store.prepare_orchestration("group", [pending], workspace)
|
||
attempt = store.runs / "attempt"
|
||
attempt.mkdir()
|
||
(attempt / "locator.json").write_text(
|
||
json.dumps({"task": "group/01_done"}), encoding="utf-8"
|
||
)
|
||
archive = workspace / "archive"
|
||
archive.mkdir()
|
||
(archive / "complete.log").write_text(
|
||
"complete\n", encoding="utf-8"
|
||
)
|
||
|
||
with (
|
||
mock.patch.object(
|
||
dispatch.shutil,
|
||
"rmtree",
|
||
side_effect=OSError("transient cleanup failure"),
|
||
),
|
||
mock.patch.object(dispatch, "scan_tasks", return_value=[]),
|
||
):
|
||
store.mark_orchestration_task_complete(
|
||
"group", "group/01_done", archive
|
||
)
|
||
result = asyncio.run(
|
||
dispatch.dispatch_with_store(
|
||
SimpleNamespace(
|
||
task_group="group",
|
||
retry_blocked=False,
|
||
dry_run=False,
|
||
),
|
||
workspace,
|
||
store,
|
||
)
|
||
)
|
||
|
||
self.assertEqual(result, 3)
|
||
self.assertEqual(
|
||
store.data["orchestrations"]["group"]["status"],
|
||
"running",
|
||
)
|
||
self.assertTrue(attempt.is_dir())
|
||
finally:
|
||
store.close()
|
||
|
||
def test_external_liveness_ignores_heartbeat_mtime(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
attempt = Path(temporary)
|
||
stream = attempt / "stream.log"
|
||
heartbeat = attempt / "heartbeat.log"
|
||
locator = attempt / "locator.json"
|
||
stream.write_text("old model output\n", encoding="utf-8")
|
||
heartbeat.write_text("fresh heartbeat\n", encoding="utf-8")
|
||
stale_at = time.time() - dispatch.CODEX_STREAM_STALL_SECONDS - 1
|
||
os.utime(stream, (stale_at, stale_at))
|
||
locator.write_text(
|
||
json.dumps(
|
||
{
|
||
"status": "running",
|
||
"cli": "agy",
|
||
"stream_log": str(stream),
|
||
"heartbeat_log": str(heartbeat),
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
live, detail = dispatch.external_active_is_live(
|
||
{"active_locator": str(locator)}
|
||
)
|
||
self.assertTrue(live)
|
||
self.assertIn("stream inactive=", detail)
|
||
self.assertIn("time-based duplicate recovery is disabled", detail)
|
||
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
record["agent_pid"] = 999_999_999
|
||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||
live, detail = dispatch.external_active_is_live(
|
||
{"active_locator": str(locator)}
|
||
)
|
||
self.assertFalse(live)
|
||
self.assertIn("recorded agent process identity", detail)
|
||
|
||
record.pop("agent_pid")
|
||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||
stream.touch()
|
||
live, _ = dispatch.external_active_is_live(
|
||
{"active_locator": str(locator)}
|
||
)
|
||
self.assertTrue(live)
|
||
|
||
def test_external_liveness_keeps_silent_live_agent_process(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
attempt = Path(temporary)
|
||
stream = attempt / "stream.log"
|
||
locator = attempt / "locator.json"
|
||
stream.write_text("old model output\n", encoding="utf-8")
|
||
stale_at = time.time() - (60 * 60)
|
||
os.utime(stream, (stale_at, stale_at))
|
||
locator.write_text(
|
||
json.dumps(
|
||
{
|
||
"status": "running",
|
||
"cli": "pi",
|
||
"agent_pid": os.getpid(),
|
||
"stream_log": str(stream),
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
live, detail = dispatch.external_active_is_live(
|
||
{"active_locator": str(locator)}
|
||
)
|
||
|
||
self.assertTrue(live)
|
||
self.assertIn("agent_pid=", detail)
|
||
|
||
def test_external_liveness_never_times_out_pidless_exact_tool_execution(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
attempt = Path(temporary)
|
||
stream = attempt / "stream.log"
|
||
native = attempt / "session.jsonl"
|
||
locator = attempt / "locator.json"
|
||
stream.write_text("old model output\n", encoding="utf-8")
|
||
native.write_text(
|
||
pi_session_jsonl(
|
||
[
|
||
{
|
||
"type": "message",
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": [
|
||
{
|
||
"type": "toolCall",
|
||
"id": "slow-tool",
|
||
"name": "bash",
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
stale_at = time.time() - (24 * 60 * 60)
|
||
os.utime(stream, (stale_at, stale_at))
|
||
os.utime(native, (stale_at, stale_at))
|
||
locator.write_text(
|
||
json.dumps(
|
||
{
|
||
"status": "running",
|
||
"cli": "pi",
|
||
"stream_log": str(stream),
|
||
"native_session_path": str(native),
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
live, detail = dispatch.external_active_is_live(
|
||
{"active_locator": str(locator)}
|
||
)
|
||
|
||
self.assertTrue(live)
|
||
self.assertIn("time-based duplicate recovery is disabled", detail)
|
||
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
record["agent_pid"] = 999_999_999
|
||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||
live, detail = dispatch.external_active_is_live(
|
||
{"active_locator": str(locator)}
|
||
)
|
||
self.assertFalse(live)
|
||
self.assertIn("recorded agent process identity", detail)
|
||
|
||
def test_external_liveness_rejects_reused_pid_identity(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
attempt = Path(temporary)
|
||
stream = attempt / "stream.log"
|
||
locator = attempt / "locator.json"
|
||
stream.write_text("old model output\n", encoding="utf-8")
|
||
stale_at = time.time() - dispatch.CODEX_STREAM_STALL_SECONDS - 1
|
||
os.utime(stream, (stale_at, stale_at))
|
||
locator.write_text(
|
||
json.dumps(
|
||
{
|
||
"status": "running",
|
||
"cli": "agy",
|
||
"agent_pid": os.getpid(),
|
||
"agent_process_start_token": "not-the-current-process",
|
||
"stream_log": str(stream),
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
live, detail = dispatch.external_active_is_live(
|
||
{"active_locator": str(locator)}
|
||
)
|
||
|
||
self.assertFalse(live)
|
||
self.assertIn("recorded agent process identity", detail)
|
||
|
||
def test_retry_blocked_only_clears_selected_task_group(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
self.make_task(workspace, "alpha/01_task")
|
||
self.make_task(workspace, "beta/01_task")
|
||
tasks = {
|
||
task.name: task for task in dispatch.scan_tasks(workspace, None)
|
||
}
|
||
alpha = tasks["alpha/01_task"]
|
||
beta = tasks["beta/01_task"]
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
for task in (alpha, beta):
|
||
store.update_task(
|
||
task,
|
||
blocked="recovery failure limit exhausted: 10/10",
|
||
review_no_progress=10,
|
||
selfcheck_incomplete=10,
|
||
recovery_failures={"worker": 10},
|
||
)
|
||
|
||
store.clear_blocked("alpha")
|
||
|
||
alpha_state = store.task_state(alpha)
|
||
self.assertIsNone(alpha_state["blocked"])
|
||
self.assertEqual(alpha_state["review_no_progress"], 0)
|
||
self.assertEqual(alpha_state["selfcheck_incomplete"], 0)
|
||
self.assertEqual(alpha_state["recovery_failures"], {})
|
||
|
||
beta_state = store.task_state(beta)
|
||
self.assertIsNotNone(beta_state["blocked"])
|
||
self.assertEqual(beta_state["review_no_progress"], 10)
|
||
self.assertEqual(beta_state["selfcheck_incomplete"], 10)
|
||
self.assertEqual(beta_state["recovery_failures"], {"worker": 10})
|
||
finally:
|
||
store.close()
|
||
|
||
def test_legacy_promotion_recovery_requires_older_source_and_typed_event(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
self.make_task(workspace, "alpha/01_task")
|
||
task = dispatch.scan_tasks(workspace, None)[0]
|
||
runs = workspace / ".git" / "agent-task-dispatcher" / "runs"
|
||
locators = write_legacy_quota_attempts(runs, task)
|
||
locator = locators[-1]
|
||
state = {
|
||
"blocked": (
|
||
"worker recovery failure limit exhausted: 10/10 "
|
||
f"locator={locator}"
|
||
),
|
||
"recovery_failures": {"worker": 10},
|
||
}
|
||
|
||
recovery = dispatch.legacy_promotion_recovery(
|
||
runs,
|
||
task,
|
||
state,
|
||
)
|
||
self.assertIsNotNone(recovery)
|
||
assert recovery is not None
|
||
self.assertEqual(recovery.failure_class, "provider-quota")
|
||
self.assertEqual(recovery.evidence_source, "claude:stdout")
|
||
|
||
record = json.loads(locator.read_text(encoding="utf-8"))
|
||
record["dispatcher_source_sha256"] = (
|
||
dispatch.DISPATCHER_SOURCE_SHA256
|
||
)
|
||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||
self.assertIsNone(
|
||
dispatch.legacy_promotion_recovery(runs, task, state)
|
||
)
|
||
|
||
def test_legacy_promotion_recovery_rejects_mixed_attempt_history(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
self.make_task(workspace, "alpha/01_task")
|
||
task = dispatch.scan_tasks(workspace, None)[0]
|
||
runs = workspace / ".git" / "agent-task-dispatcher" / "runs"
|
||
locators = write_legacy_quota_attempts(runs, task)
|
||
mixed_stream = locators[4].parent / "stream.log"
|
||
mixed_stream.write_text(
|
||
"[stdout] "
|
||
+ json.dumps(
|
||
{
|
||
"type": "assistant",
|
||
"message": {
|
||
"content": "You've hit your session limit"
|
||
},
|
||
}
|
||
)
|
||
+ "\n",
|
||
encoding="utf-8",
|
||
)
|
||
state = {
|
||
"blocked": (
|
||
"worker recovery failure limit exhausted: 10/10 "
|
||
f"locator={locators[-1]}"
|
||
),
|
||
"recovery_failures": {"worker": 10},
|
||
}
|
||
|
||
self.assertIsNone(
|
||
dispatch.legacy_promotion_recovery(runs, task, state)
|
||
)
|
||
|
||
def test_persisted_legacy_promotion_survives_dispatcher_restart(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
self.make_task(workspace, "alpha/01_task")
|
||
task = dispatch.scan_tasks(workspace, None)[0]
|
||
runs = workspace / ".git" / "agent-task-dispatcher" / "runs"
|
||
locator = write_legacy_quota_attempts(runs, task)[-1]
|
||
blocked_state = {
|
||
"blocked": (
|
||
"worker recovery failure limit exhausted: 10/10 "
|
||
f"locator={locator}"
|
||
),
|
||
"recovery_failures": {"worker": 10},
|
||
}
|
||
recovery = dispatch.legacy_promotion_recovery(
|
||
runs,
|
||
task,
|
||
blocked_state,
|
||
)
|
||
assert recovery is not None
|
||
restarted_state = {
|
||
"blocked": None,
|
||
"recovery_failures": {"worker": 1},
|
||
"legacy_terminal_reclassification": {
|
||
"role": recovery.role,
|
||
"failure_class": recovery.failure_class,
|
||
"evidence_source": recovery.evidence_source,
|
||
"prior_dispatcher_sha256":
|
||
recovery.prior_dispatcher_sha256,
|
||
"current_dispatcher_sha256":
|
||
dispatch.DISPATCHER_SOURCE_SHA256,
|
||
"locator": str(recovery.locator),
|
||
"failed_cli": recovery.failed_cli,
|
||
"failed_model": recovery.failed_model,
|
||
"failed_reasoning_effort":
|
||
recovery.failed_reasoning_effort,
|
||
},
|
||
}
|
||
|
||
restored = (
|
||
dispatch.pending_persisted_legacy_promotion_recovery(
|
||
task,
|
||
restarted_state,
|
||
)
|
||
)
|
||
|
||
self.assertIsNotNone(restored)
|
||
assert restored is not None
|
||
self.assertEqual(restored.failed_cli, "claude")
|
||
self.assertEqual(
|
||
dispatch.promoted_spec(
|
||
dispatch.failed_spec_from_recovery(restored),
|
||
0,
|
||
).model,
|
||
"gpt-5.6-terra",
|
||
)
|
||
|
||
def test_persisted_legacy_agy_promotion_survives_dispatcher_restart(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
self.make_task(workspace, "alpha/01_task")
|
||
task = dispatch.scan_tasks(workspace, None)[0]
|
||
runs = workspace / ".git" / "agent-task-dispatcher" / "runs"
|
||
locator = write_legacy_quota_attempts(
|
||
runs,
|
||
task,
|
||
cli="agy",
|
||
model="Gemini 3.6 Flash (High)",
|
||
reasoning_effort=None,
|
||
)[-1]
|
||
blocked_state = {
|
||
"blocked": (
|
||
"worker recovery failure limit exhausted: 10/10 "
|
||
f"locator={locator}"
|
||
),
|
||
"recovery_failures": {"worker": 10},
|
||
}
|
||
recovery = dispatch.legacy_promotion_recovery(
|
||
runs,
|
||
task,
|
||
blocked_state,
|
||
)
|
||
assert recovery is not None
|
||
restarted_state = {
|
||
"blocked": None,
|
||
"recovery_failures": {"worker": 1},
|
||
"legacy_terminal_reclassification": {
|
||
"role": recovery.role,
|
||
"failure_class": recovery.failure_class,
|
||
"evidence_source": recovery.evidence_source,
|
||
"prior_dispatcher_sha256":
|
||
recovery.prior_dispatcher_sha256,
|
||
"current_dispatcher_sha256":
|
||
dispatch.DISPATCHER_SOURCE_SHA256,
|
||
"locator": str(recovery.locator),
|
||
"failed_cli": recovery.failed_cli,
|
||
"failed_model": recovery.failed_model,
|
||
"failed_reasoning_effort":
|
||
recovery.failed_reasoning_effort,
|
||
},
|
||
}
|
||
|
||
restored = (
|
||
dispatch.pending_persisted_legacy_promotion_recovery(
|
||
task,
|
||
restarted_state,
|
||
)
|
||
)
|
||
|
||
self.assertIsNotNone(restored)
|
||
assert restored is not None
|
||
self.assertEqual(restored.failed_cli, "agy")
|
||
self.assertEqual(restored.evidence_source, "agy:cli-log")
|
||
self.assertEqual(
|
||
dispatch.promoted_spec(
|
||
dispatch.failed_spec_from_recovery(restored),
|
||
0,
|
||
).cli,
|
||
"claude",
|
||
)
|
||
|
||
def test_corrupt_persistent_state_fails_closed_and_releases_lock(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
state_root = workspace / ".git" / "agent-task-dispatcher"
|
||
state_root.mkdir(parents=True)
|
||
state_path = state_root / "state.json"
|
||
state_path.write_text("{broken", encoding="utf-8")
|
||
|
||
with self.assertRaisesRegex(
|
||
RuntimeError, "dispatcher state를 읽을 수 없다"
|
||
):
|
||
dispatch.StateStore(workspace)
|
||
|
||
state_path.write_text("{}\n", encoding="utf-8")
|
||
reopened = dispatch.StateStore(workspace)
|
||
reopened.close()
|
||
|
||
def test_live_workspace_lock_is_non_terminal_exit_three(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
(workspace / "agent-task").mkdir()
|
||
owner = dispatch.StateStore(workspace)
|
||
args = SimpleNamespace(
|
||
workspace=str(workspace),
|
||
task_group=None,
|
||
dry_run=False,
|
||
retry_blocked=False,
|
||
)
|
||
try:
|
||
with mock.patch.object(dispatch, "parse_args", return_value=args):
|
||
result = dispatch.main()
|
||
self.assertEqual(result, 3)
|
||
finally:
|
||
owner.close()
|
||
|
||
def test_unexpected_dispatcher_exception_is_non_terminal_exit_three(self):
|
||
args = SimpleNamespace(
|
||
workspace=".",
|
||
task_group=None,
|
||
dry_run=False,
|
||
retry_blocked=False,
|
||
)
|
||
with (
|
||
mock.patch.object(dispatch, "parse_args", return_value=args),
|
||
mock.patch.object(
|
||
dispatch,
|
||
"dispatch",
|
||
new=mock.AsyncMock(side_effect=RuntimeError("transient failure")),
|
||
),
|
||
):
|
||
result = dispatch.main()
|
||
self.assertEqual(result, 3)
|
||
|
||
def test_scheduler_exception_waits_for_running_agent_tasks(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
(workspace / "agent-task").mkdir()
|
||
args = SimpleNamespace(
|
||
workspace=str(workspace),
|
||
task_group=None,
|
||
dry_run=False,
|
||
retry_blocked=False,
|
||
)
|
||
events: list[str] = []
|
||
|
||
async def failing_scheduler(args, workspace, store):
|
||
async def running_agent():
|
||
events.append("started")
|
||
await asyncio.sleep(0.02)
|
||
events.append("finished")
|
||
|
||
asyncio.create_task(running_agent())
|
||
await asyncio.sleep(0)
|
||
raise dispatch.DispatcherTerminalStateError("scheduler failed")
|
||
|
||
with mock.patch.object(
|
||
dispatch,
|
||
"dispatch_with_store",
|
||
new=failing_scheduler,
|
||
):
|
||
with self.assertRaisesRegex(
|
||
dispatch.DispatcherInterruptedWithActiveWork,
|
||
"scheduler failed",
|
||
):
|
||
asyncio.run(dispatch.dispatch(args))
|
||
|
||
self.assertEqual(events, ["started", "finished"])
|
||
|
||
def test_dry_run_retry_blocked_does_not_clear_persistent_limits(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
store.update_task(
|
||
task,
|
||
blocked="recovery failure limit exhausted: 10/10",
|
||
review_no_progress=10,
|
||
selfcheck_incomplete=10,
|
||
recovery_failures={"review": 10},
|
||
)
|
||
store.close()
|
||
|
||
args = SimpleNamespace(
|
||
workspace=str(workspace),
|
||
task_group=None,
|
||
dry_run=True,
|
||
retry_blocked=True,
|
||
)
|
||
result = asyncio.run(dispatch.dispatch(args))
|
||
self.assertEqual(result, 2)
|
||
|
||
reopened = dispatch.StateStore(workspace)
|
||
try:
|
||
state = reopened.task_state(task)
|
||
self.assertEqual(
|
||
state["blocked"],
|
||
"recovery failure limit exhausted: 10/10",
|
||
)
|
||
self.assertEqual(state["review_no_progress"], 10)
|
||
self.assertEqual(state["selfcheck_incomplete"], 10)
|
||
self.assertEqual(
|
||
state["recovery_failures"], {"review": 10}
|
||
)
|
||
finally:
|
||
reopened.close()
|
||
|
||
def test_child_restart_detects_task_that_disappeared_without_complete_log(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
first = dispatch.StateStore(workspace)
|
||
first.prepare_orchestration("__all__", [task], workspace)
|
||
first.close()
|
||
task.plan.unlink()
|
||
task.review.unlink()
|
||
task.directory.rmdir()
|
||
|
||
restarted = dispatch.StateStore(workspace)
|
||
restarted.prepare_orchestration("__all__", [], workspace)
|
||
completed, errors = restarted.reconcile_orchestration(
|
||
"__all__", workspace, set()
|
||
)
|
||
self.assertEqual(completed, {})
|
||
self.assertIn(task.name, errors)
|
||
self.assertIn("새 complete.log archive 모두에서 사라졌다", errors[task.name])
|
||
restarted.close()
|
||
|
||
def test_child_restart_recovers_new_complete_archive_not_baseline_archive(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
old_archive = workspace / "agent-task" / "archive" / "2026" / "06" / "task"
|
||
old_archive.mkdir(parents=True)
|
||
(old_archive / "complete.log").write_text("old\n", encoding="utf-8")
|
||
task = self.make_task(workspace)
|
||
first = dispatch.StateStore(workspace)
|
||
first.prepare_orchestration("__all__", [task], workspace)
|
||
first.close()
|
||
|
||
new_archive = workspace / "agent-task" / "archive" / "2026" / "07" / "task_1"
|
||
new_archive.parent.mkdir(parents=True)
|
||
(task.directory / "complete.log").write_text("new\n", encoding="utf-8")
|
||
task.directory.rename(new_archive)
|
||
|
||
restarted = dispatch.StateStore(workspace)
|
||
restarted.prepare_orchestration("__all__", [], workspace)
|
||
completed, errors = restarted.reconcile_orchestration(
|
||
"__all__", workspace, set()
|
||
)
|
||
self.assertEqual(errors, {})
|
||
self.assertEqual(completed, {"task": str(new_archive.resolve())})
|
||
restarted.close()
|
||
|
||
def test_preexisting_incomplete_archive_cannot_become_false_new_completion(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
incomplete_archive = (
|
||
workspace / "agent-task" / "archive" / "2026" / "06" / "task"
|
||
)
|
||
incomplete_archive.mkdir(parents=True)
|
||
task = self.make_task(workspace)
|
||
first = dispatch.StateStore(workspace)
|
||
first.prepare_orchestration("__all__", [task], workspace)
|
||
first.close()
|
||
|
||
task.plan.unlink()
|
||
task.review.unlink()
|
||
task.directory.rmdir()
|
||
(incomplete_archive / "complete.log").write_text(
|
||
"late unrelated completion\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
restarted = dispatch.StateStore(workspace)
|
||
restarted.prepare_orchestration("__all__", [], workspace)
|
||
completed, errors = restarted.reconcile_orchestration(
|
||
"__all__", workspace, set()
|
||
)
|
||
self.assertEqual(completed, {})
|
||
self.assertIn(task.name, errors)
|
||
restarted.close()
|
||
|
||
def test_dry_run_does_not_create_persistent_orchestration_state(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
self.make_task(workspace)
|
||
args = SimpleNamespace(
|
||
workspace=str(workspace),
|
||
task_group=None,
|
||
dry_run=True,
|
||
retry_blocked=False,
|
||
)
|
||
result = asyncio.run(dispatch.dispatch(args))
|
||
self.assertEqual(result, 0)
|
||
state_path = workspace / ".git" / "agent-task-dispatcher" / "state.json"
|
||
self.assertFalse(state_path.exists())
|
||
|
||
def test_explicit_unobserved_task_group_cannot_report_success(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
(workspace / "agent-task").mkdir()
|
||
args = SimpleNamespace(
|
||
workspace=str(workspace),
|
||
task_group="missing-group",
|
||
dry_run=False,
|
||
retry_blocked=False,
|
||
)
|
||
|
||
result = asyncio.run(dispatch.dispatch(args))
|
||
|
||
self.assertEqual(result, 2)
|
||
state = json.loads(
|
||
(
|
||
workspace
|
||
/ ".git"
|
||
/ "agent-task-dispatcher"
|
||
/ "state.json"
|
||
).read_text(encoding="utf-8")
|
||
)
|
||
self.assertEqual(
|
||
state["orchestrations"]["missing-group"]["status"],
|
||
"blocked",
|
||
)
|
||
|
||
|
||
class RouteDecisionPersistenceTest(unittest.TestCase):
|
||
def make_task(
|
||
self,
|
||
workspace: Path,
|
||
name: str = "route/01_unit",
|
||
*,
|
||
lane: str = "local",
|
||
grade: int = 5,
|
||
):
|
||
directory = workspace / "agent-task" / name
|
||
directory.mkdir(parents=True)
|
||
header = f"<!-- task={name} plan=0 tag=ROUTE -->\n"
|
||
(directory / f"PLAN-{lane}-G{grade:02d}.md").write_text(
|
||
header
|
||
+ "## 수정 파일 요약\n\n| 파일 | 항목 |\n|---|---|\n"
|
||
+ "| src/route.py | ROUTE-1 |\n",
|
||
encoding="utf-8",
|
||
)
|
||
(directory / f"CODE_REVIEW-{lane}-G{grade:02d}.md").write_text(
|
||
header,
|
||
encoding="utf-8",
|
||
)
|
||
return next(task for task in dispatch.scan_tasks(workspace, None) if task.name == name)
|
||
|
||
def test_reopen_body_edit_and_generation_reset_preserve_or_reset_pin(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
first = dispatch.StateStore(workspace)
|
||
try:
|
||
initial, worker_spec = dispatch.persisted_execution_decision(
|
||
first, task, stage="worker"
|
||
)
|
||
review, review_spec = dispatch.persisted_execution_decision(
|
||
first, task, stage="review"
|
||
)
|
||
self.assertEqual(initial["transition"]["trigger"], "initial")
|
||
self.assertEqual(worker_spec.cli, "pi")
|
||
self.assertEqual(review_spec.cli, "codex")
|
||
self.assertEqual(review["transition"]["trigger"], "initial")
|
||
self.assertEqual(
|
||
[entry["stage"] for entry in first.task_state(task)["route_transition_history"]],
|
||
["worker", "review"],
|
||
)
|
||
finally:
|
||
first.close()
|
||
|
||
reopened = dispatch.StateStore(workspace)
|
||
try:
|
||
reopened_task = dispatch.scan_tasks(workspace, None)[0]
|
||
resumed, resumed_spec = dispatch.persisted_execution_decision(
|
||
reopened, reopened_task, stage="worker"
|
||
)
|
||
self.assertEqual(resumed["transition"]["trigger"], "resume")
|
||
self.assertEqual(resumed_spec.display, "pi/iop/ornith:35b")
|
||
|
||
assert reopened_task.plan is not None
|
||
reopened_task.plan.write_text(
|
||
reopened_task.plan.read_text(encoding="utf-8") + "\n본문만 변경\n",
|
||
encoding="utf-8",
|
||
)
|
||
body_edited = dispatch.scan_tasks(workspace, None)[0]
|
||
self.assertEqual(body_edited.plan_hash, reopened_task.plan_hash)
|
||
body_resume, _ = dispatch.persisted_execution_decision(
|
||
reopened, body_edited, stage="worker"
|
||
)
|
||
self.assertEqual(body_resume["transition"]["trigger"], "resume")
|
||
|
||
assert body_edited.plan is not None and body_edited.review is not None
|
||
for path in (body_edited.plan, body_edited.review):
|
||
path.write_text(
|
||
path.read_text(encoding="utf-8").replace("plan=0", "plan=1"),
|
||
encoding="utf-8",
|
||
)
|
||
next_generation = dispatch.scan_tasks(workspace, None)[0]
|
||
reset, _ = dispatch.persisted_execution_decision(
|
||
reopened, next_generation, stage="worker"
|
||
)
|
||
self.assertEqual(reset["transition"]["trigger"], "initial")
|
||
self.assertEqual(
|
||
reopened.task_state(next_generation)["route_transition_history"],
|
||
[{"stage": "worker", "transition": "initial",
|
||
"work_unit_id": reset["work_unit_id"], "selected": reset["selected"]}],
|
||
)
|
||
finally:
|
||
reopened.close()
|
||
|
||
def test_resume_keeps_pin_across_kst_boundary(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
task = self.make_task(workspace)
|
||
initial = dispatch.select_execution_decision(
|
||
task, stage="worker",
|
||
evaluated_at=datetime(2026, 7, 25, 6, 59, tzinfo=dispatch.KST),
|
||
)
|
||
resumed = dispatch.select_execution_decision(
|
||
task, stage="worker", prior_decision=initial,
|
||
evaluated_at=datetime(2026, 7, 25, 7, 0, tzinfo=dispatch.KST),
|
||
)
|
||
self.assertEqual(resumed["transition"]["trigger"], "resume")
|
||
self.assertEqual(resumed["selected"], initial["selected"])
|
||
self.assertTrue(resumed["decision"]["pinned"])
|
||
|
||
def test_rejects_tampered_canonical_target_and_selector_load_error(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
task = self.make_task(Path(temporary))
|
||
decision = dispatch.select_execution_decision(task, stage="worker")
|
||
tampered = json.loads(json.dumps(decision))
|
||
tampered["selected"] = {
|
||
"adapter": "agy",
|
||
"target": "untrusted-target",
|
||
"execution_class": "local_model",
|
||
"selfcheck_required": False,
|
||
}
|
||
with self.assertRaises(dispatch.ExecutionDecisionError):
|
||
dispatch.agent_spec_from_decision(tampered)
|
||
for failure in (OSError("load failed"), SyntaxError("broken selector"), RuntimeError("loader crashed")):
|
||
with self.subTest(failure=type(failure).__name__):
|
||
with mock.patch.object(dispatch, "_selector_module", side_effect=failure):
|
||
with self.assertRaises(dispatch.ExecutionDecisionError):
|
||
dispatch.select_execution_decision(task, stage="worker")
|
||
|
||
def test_malformed_or_exhausted_state_blocks_only_its_task(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
exhausted = self.make_task(
|
||
workspace, "route/01_exhausted", lane="cloud", grade=7
|
||
)
|
||
healthy = self.make_task(workspace, "route/02_healthy")
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
store.update_task(
|
||
exhausted,
|
||
quota_snapshot={
|
||
"source": "test",
|
||
"targets": [{
|
||
"adapter": "claude",
|
||
"target": "claude-opus-4-8",
|
||
"status": "exhausted",
|
||
}],
|
||
},
|
||
)
|
||
asyncio.run(dispatch.run_worker(workspace, store, exhausted, {}))
|
||
self.assertIn("no_eligible_target", store.task_state(exhausted)["blocked"])
|
||
_, healthy_spec = dispatch.persisted_execution_decision(
|
||
store, healthy, stage="worker"
|
||
)
|
||
self.assertEqual(healthy_spec.cli, "pi")
|
||
|
||
store.update_task(
|
||
healthy, execution_decisions={"worker": {"malformed": True}}
|
||
)
|
||
with self.assertRaises(dispatch.ExecutionDecisionError):
|
||
dispatch.persisted_execution_decision(store, healthy, stage="worker")
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
class DispatcherConvergenceSimulationTest(unittest.IsolatedAsyncioTestCase):
|
||
def write_task(
|
||
self,
|
||
workspace: Path,
|
||
task_name: str,
|
||
source_path: str,
|
||
) -> None:
|
||
directory = workspace / "agent-task" / task_name
|
||
directory.mkdir(parents=True)
|
||
header = f"<!-- task={task_name} plan=0 tag=SIM -->\n"
|
||
(directory / "PLAN-local-G05.md").write_text(
|
||
header
|
||
+ "## 수정 파일 요약\n\n| 파일 | 항목 |\n|---|---|\n"
|
||
+ f"| `{source_path}` | SIM-1 |\n",
|
||
encoding="utf-8",
|
||
)
|
||
(directory / "CODE_REVIEW-local-G05.md").write_text(
|
||
header,
|
||
encoding="utf-8",
|
||
)
|
||
|
||
async def test_parallel_multi_task_followup_dependency_and_terminal_completion(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
(workspace / "agent-task").mkdir()
|
||
self.write_task(workspace, "sim/01_alpha", "src/alpha.go")
|
||
self.write_task(workspace, "sim/02_beta", "src/beta.go")
|
||
self.write_task(workspace, "sim/03+01,02_join", "src/join.go")
|
||
self.write_task(workspace, "sim/04_conflict", "./src/alpha.go")
|
||
work_log = workspace / "agent-task" / "sim" / dispatch.WORK_LOG_NAME
|
||
work_log.write_text("final timeline\n", encoding="utf-8")
|
||
|
||
active = {"worker": set(), "selfcheck": set(), "review": set()}
|
||
maximum = {"worker": 0, "selfcheck": 0, "review": 0}
|
||
overlap_violations: list[tuple[str, set[str]]] = []
|
||
review_attempts: dict[str, int] = {}
|
||
|
||
def enter(stage: str, task_name: str) -> None:
|
||
active[stage].add(task_name)
|
||
maximum[stage] = max(maximum[stage], len(active[stage]))
|
||
if {"sim/01_alpha", "sim/04_conflict"} <= active[stage]:
|
||
overlap_violations.append((stage, set(active[stage])))
|
||
|
||
def leave(stage: str, task_name: str) -> None:
|
||
active[stage].remove(task_name)
|
||
|
||
async def fake_worker(workspace_path, store, task, semaphores):
|
||
enter("worker", task.name)
|
||
try:
|
||
await asyncio.sleep(0.005)
|
||
store.update_task(
|
||
task,
|
||
worker_done=True,
|
||
worker_cli="pi",
|
||
worker_model="ornith:35b",
|
||
selfcheck_done=False,
|
||
blocked=None,
|
||
)
|
||
finally:
|
||
leave("worker", task.name)
|
||
|
||
async def fake_selfcheck(workspace_path, store, task, semaphores):
|
||
enter("selfcheck", task.name)
|
||
try:
|
||
await asyncio.sleep(0.005)
|
||
store.update_task(task, selfcheck_done=True, blocked=None)
|
||
finally:
|
||
leave("selfcheck", task.name)
|
||
|
||
async def fake_review(workspace_path, store, task):
|
||
enter("review", task.name)
|
||
try:
|
||
await asyncio.sleep(
|
||
0.002 if task.name == "sim/02_beta" else 0.015
|
||
)
|
||
attempt = review_attempts.get(task.name, 0) + 1
|
||
review_attempts[task.name] = attempt
|
||
if task.name == "sim/01_alpha" and attempt == 1:
|
||
for path in (task.plan, task.review):
|
||
assert path is not None
|
||
path.write_text(
|
||
path.read_text(encoding="utf-8").replace(
|
||
"plan=0", "plan=1"
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
return None
|
||
archive = (
|
||
workspace_path
|
||
/ "agent-task"
|
||
/ "archive"
|
||
/ "2026"
|
||
/ "07"
|
||
/ task.name
|
||
)
|
||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||
(task.directory / "complete.log").write_text(
|
||
"simulation complete\n", encoding="utf-8"
|
||
)
|
||
task.directory.rename(archive)
|
||
return str(archive)
|
||
finally:
|
||
leave("review", task.name)
|
||
|
||
args = SimpleNamespace(
|
||
workspace=str(workspace),
|
||
task_group="sim",
|
||
dry_run=False,
|
||
retry_blocked=False,
|
||
)
|
||
with (
|
||
mock.patch.object(dispatch, "run_worker", new=fake_worker),
|
||
mock.patch.object(dispatch, "run_selfcheck", new=fake_selfcheck),
|
||
mock.patch.object(dispatch, "run_review", new=fake_review),
|
||
mock.patch.object(dispatch, "ensure_review_shared_state"),
|
||
mock.patch.object(
|
||
dispatch, "scan_tasks", wraps=dispatch.scan_tasks
|
||
) as scan_tasks,
|
||
):
|
||
result = await asyncio.wait_for(dispatch.dispatch(args), timeout=2)
|
||
|
||
self.assertEqual(result, 0)
|
||
self.assertGreaterEqual(maximum["worker"], 2)
|
||
self.assertGreaterEqual(maximum["selfcheck"], 2)
|
||
self.assertGreaterEqual(maximum["review"], 2)
|
||
self.assertEqual(
|
||
{stage for stage, _ in overlap_violations},
|
||
{"worker", "selfcheck", "review"},
|
||
)
|
||
self.assertGreater(scan_tasks.call_count, 1)
|
||
self.assertLessEqual(scan_tasks.call_count, 5)
|
||
self.assertTrue(
|
||
any(
|
||
call.kwargs.get("exclude_names")
|
||
for call in scan_tasks.call_args_list
|
||
),
|
||
"completion-triggered scans must exclude still-running tasks",
|
||
)
|
||
self.assertEqual(review_attempts["sim/01_alpha"], 2)
|
||
self.assertEqual(review_attempts["sim/02_beta"], 1)
|
||
self.assertEqual(review_attempts["sim/03+01,02_join"], 1)
|
||
self.assertEqual(review_attempts["sim/04_conflict"], 1)
|
||
archive_root = workspace / "agent-task" / "archive" / "2026" / "07" / "sim"
|
||
for subtask in ("01_alpha", "02_beta", "03+01,02_join", "04_conflict"):
|
||
self.assertTrue((archive_root / subtask / "complete.log").is_file())
|
||
self.assertFalse(work_log.exists())
|
||
self.assertEqual(
|
||
(archive_root / "work_log_0.log").read_text(encoding="utf-8"),
|
||
"final timeline\n",
|
||
)
|
||
|
||
|
||
|
||
class DynamicFailoverBudgetTest(unittest.TestCase):
|
||
def make_task(self, workspace: Path):
|
||
directory = workspace / "agent-task" / "budget/01_unit"
|
||
directory.mkdir(parents=True)
|
||
header = "<!-- task=budget/01_unit plan=0 tag=API -->\n"
|
||
(directory / "PLAN-local-G07.md").write_text(header, encoding="utf-8")
|
||
(directory / "CODE_REVIEW-local-G07.md").write_text(header, encoding="utf-8")
|
||
return dispatch.scan_tasks(workspace, None)[0]
|
||
|
||
def test_context_package_keeps_artifacts_and_blocks_cross_adapter_native_session(self):
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
attempt = workspace / "attempt"
|
||
attempt.mkdir()
|
||
raw, normalized, native = attempt / "stream.log", attempt / "normalized-output.log", attempt / "session.jsonl"
|
||
raw.write_text("raw\n", encoding="utf-8")
|
||
normalized.write_text("normalized\n", encoding="utf-8")
|
||
native.write_text("{}\n", encoding="utf-8")
|
||
locator = attempt / "locator.json"
|
||
record = {"task": task.name, "workspace": str(workspace), "plan_path": str(task.plan), "stream_log": str(raw), "normalized_output_log": str(normalized), "native_session_path": str(native)}
|
||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||
pi = dispatch.AgentSpec("pi", "ornith:35b", "pi/iop/ornith:35b", local_pi=True)
|
||
codex = dispatch.AgentSpec("codex", "gpt-5.6-sol", "codex/gpt-5.6-sol xhigh")
|
||
logical = dispatch.build_context_package(workspace, task, locator, previous_spec=pi, next_spec=codex)
|
||
self.assertEqual(logical["resume_mode"], "logical")
|
||
self.assertNotIn("native_session_path", logical)
|
||
native_package = dispatch.build_context_package(workspace, task, locator, previous_spec=pi, next_spec=pi)
|
||
self.assertEqual(native_package["native_session_path"], str(native.resolve()))
|
||
external = workspace / "external.log"
|
||
external.write_text("outside\n", encoding="utf-8")
|
||
record["stream_log"] = str(external)
|
||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||
with self.assertRaises(dispatch.ExecutionDecisionError):
|
||
dispatch.build_context_package(workspace, task, locator, previous_spec=pi, next_spec=codex)
|
||
record["stream_log"] = str(raw)
|
||
record["workspace"] = ""
|
||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||
with self.assertRaises(dispatch.ExecutionDecisionError):
|
||
dispatch.build_context_package(workspace, task, locator, previous_spec=pi, next_spec=codex)
|
||
record["workspace"] = str(workspace)
|
||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||
normalized.unlink()
|
||
with self.assertRaises(dispatch.ExecutionDecisionError):
|
||
dispatch.build_context_package(workspace, task, locator, previous_spec=pi, next_spec=codex)
|
||
|
||
def test_primary_and_alternate_share_budget_across_reopen(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)")
|
||
locator_gemini = self.make_attempt_locator(workspace, task, gemini_spec)
|
||
laguna_spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True)
|
||
locator_laguna = self.make_attempt_locator(workspace, task, laguna_spec)
|
||
|
||
initial_store = dispatch.StateStore(workspace)
|
||
try:
|
||
dispatch.persisted_execution_decision(initial_store, task, stage="worker", evaluated_at=daytime)
|
||
finally:
|
||
initial_store.close()
|
||
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
invoked_specs = []
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
if spec.cli == "agy":
|
||
return (1, "provider-quota", locator_gemini)
|
||
if len(invoked_specs) == 2:
|
||
return (1, "generic-error", locator_laguna)
|
||
raise asyncio.CancelledError()
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
try:
|
||
asyncio.run(dispatch.run_escalating(workspace, store, task, "worker", gemini_spec))
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
state1 = store.task_state(task)
|
||
decisions1 = state1["execution_decisions"]
|
||
history1 = state1["route_transition_history"]
|
||
worker_budget1 = dispatch.StageFailureBudget.from_decision(store, task, decisions1["worker"])
|
||
self.assertEqual([s.cli for s in invoked_specs[:2]], ["agy", "pi"])
|
||
self.assertEqual([h["transition"] for h in history1], ["initial", "provider-quota"])
|
||
self.assertEqual(worker_budget1.count(), 2)
|
||
finally:
|
||
store.close()
|
||
|
||
reopened = dispatch.StateStore(workspace)
|
||
try:
|
||
worker_budget_reopened = dispatch.StageFailureBudget.from_decision(reopened, task, decisions1["worker"])
|
||
current_count = worker_budget_reopened.count()
|
||
needed_failures = 10 - current_count
|
||
locators = [workspace / f"failure-{i}.json" for i in range(needed_failures)]
|
||
|
||
with (
|
||
mock.patch.object(
|
||
dispatch,
|
||
"invoke",
|
||
new=mock.AsyncMock(
|
||
side_effect=[(1, "generic-error", loc) for loc in locators]
|
||
),
|
||
) as invoke,
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
success, final_loc = asyncio.run(
|
||
dispatch.run_escalating(workspace, reopened, task, "worker", laguna_spec)
|
||
)
|
||
self.assertFalse(success)
|
||
self.assertEqual(invoke.await_count, 8)
|
||
self.assertTrue(all(call.args[4] == laguna_spec for call in invoke.await_args_list))
|
||
self.assertEqual(final_loc, locators[-1])
|
||
self.assertIn("recovery failure limit exhausted", reopened.task_state(task)["blocked"])
|
||
|
||
state2 = reopened.task_state(task)
|
||
worker_budget2 = dispatch.StageFailureBudget.from_decision(reopened, task, decisions1["worker"])
|
||
review_decision = dispatch.select_execution_decision(task, stage="review", evaluated_at=daytime)
|
||
review_budget2 = dispatch.StageFailureBudget.from_decision(reopened, task, review_decision)
|
||
self.assertEqual(worker_budget2.count(), 10)
|
||
self.assertEqual(review_budget2.count(), 0)
|
||
raw_entry = state2.get("stage_failure_budgets", {}).get(worker_budget2.key, {})
|
||
self.assertEqual(raw_entry.get("last_target"), {"adapter": "pi", "target": "iop/laguna-s:2.1"})
|
||
self.assertEqual(raw_entry.get("last_transition"), "provider-quota")
|
||
self.assertEqual(state2["execution_decisions"], decisions1)
|
||
self.assertEqual([h["transition"] for h in state2["route_transition_history"]], ["initial", "provider-quota"])
|
||
finally:
|
||
reopened.close()
|
||
|
||
def test_success_resets_only_current_stage_budget(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)")
|
||
locator = self.make_attempt_locator(workspace, task, gemini_spec)
|
||
|
||
worker_decision = dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime)[0]
|
||
review_decision = dispatch.select_execution_decision(task, stage="review", evaluated_at=daytime)
|
||
|
||
worker_budget = dispatch.StageFailureBudget.from_decision(store, task, worker_decision)
|
||
review_budget = dispatch.StageFailureBudget.from_decision(store, task, review_decision)
|
||
|
||
worker_budget.record_failure(target={"adapter": "agy", "target": "Gemini 3.6 Flash (Medium)"}, transition="generic-error")
|
||
review_budget.record_failure(target={"adapter": "codex", "target": "gpt-5.6-sol"}, transition="generic-error")
|
||
|
||
self.assertEqual(worker_budget.count(), 1)
|
||
self.assertEqual(review_budget.count(), 1)
|
||
|
||
async def mock_invoke(*args, **kwargs):
|
||
return (0, None, locator)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
success, final_loc = asyncio.run(
|
||
dispatch.run_escalating(workspace, store, task, "worker", gemini_spec)
|
||
)
|
||
|
||
self.assertTrue(success)
|
||
self.assertEqual(final_loc, locator)
|
||
self.assertEqual(worker_budget.count(), 0)
|
||
self.assertEqual(review_budget.count(), 1)
|
||
finally:
|
||
store.close()
|
||
|
||
def make_attempt_locator(self, workspace: Path, task: dispatch.Task, spec: dispatch.AgentSpec) -> Path:
|
||
attempt = workspace / f"attempt-{spec.cli}"
|
||
attempt.mkdir(parents=True, exist_ok=True)
|
||
raw, normalized = attempt / "stream.log", attempt / "normalized-output.log"
|
||
raw.write_text("raw log\n", encoding="utf-8")
|
||
normalized.write_text("normalized output\n", encoding="utf-8")
|
||
locator = attempt / "locator.json"
|
||
record = {
|
||
"task": task.name,
|
||
"workspace": str(workspace),
|
||
"plan_path": str(task.plan),
|
||
"stream_log": str(raw),
|
||
"normalized_output_log": str(normalized),
|
||
"cli": spec.cli,
|
||
"model": spec.model,
|
||
"spec": {"adapter": spec.cli, "target": spec.model},
|
||
}
|
||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||
return locator
|
||
|
||
|
||
class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
||
def make_task(self, workspace: Path, lane: str = "local", grade: int = 8) -> dispatch.Task:
|
||
directory = workspace / "agent-task" / "failover/01_unit"
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
header = "<!-- task=failover/01_unit plan=0 tag=API -->\n"
|
||
(directory / f"PLAN-{lane}-G{grade:02d}.md").write_text(header, encoding="utf-8")
|
||
(directory / f"CODE_REVIEW-{lane}-G{grade:02d}.md").write_text(header, encoding="utf-8")
|
||
tasks = dispatch.scan_tasks(workspace, None)
|
||
return tasks[0]
|
||
|
||
def make_attempt_locator(self, workspace: Path, task: dispatch.Task, spec: dispatch.AgentSpec) -> Path:
|
||
attempt = workspace / f"attempt-{spec.cli}"
|
||
attempt.mkdir(parents=True, exist_ok=True)
|
||
raw, normalized = attempt / "stream.log", attempt / "normalized-output.log"
|
||
raw.write_text("raw log\n", encoding="utf-8")
|
||
normalized.write_text("normalized output\n", encoding="utf-8")
|
||
locator = attempt / "locator.json"
|
||
record = {
|
||
"task": task.name,
|
||
"workspace": str(workspace),
|
||
"plan_path": str(task.plan),
|
||
"stream_log": str(raw),
|
||
"normalized_output_log": str(normalized),
|
||
"cli": spec.cli,
|
||
"model": spec.model,
|
||
"spec": {"adapter": spec.cli, "target": spec.model},
|
||
}
|
||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||
return locator
|
||
|
||
async def test_invalid_logical_context_does_not_commit_or_promote(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
|
||
cases = [
|
||
("no_locator", lambda loc, ws, tk: ws / "nonexistent.json"),
|
||
("invalid_json", lambda loc, ws, tk: (loc.write_text("invalid json", encoding="utf-8"), loc)[1]),
|
||
("workspace_mismatch", lambda loc, ws, tk: (
|
||
loc.write_text(json.dumps({
|
||
"task": tk.name, "workspace": str(ws / "other"), "plan_path": str(tk.plan),
|
||
"stream_log": str(loc.parent / "stream.log"),
|
||
"normalized_output_log": str(loc.parent / "normalized-output.log"),
|
||
}), encoding="utf-8"), loc
|
||
)[1]),
|
||
("task_mismatch", lambda loc, ws, tk: (
|
||
loc.write_text(json.dumps({
|
||
"task": "other/task", "workspace": str(ws), "plan_path": str(tk.plan),
|
||
"stream_log": str(loc.parent / "stream.log"),
|
||
"normalized_output_log": str(loc.parent / "normalized-output.log"),
|
||
}), encoding="utf-8"), loc
|
||
)[1]),
|
||
("plan_mismatch", lambda loc, ws, tk: (
|
||
loc.write_text(json.dumps({
|
||
"task": tk.name, "workspace": str(ws), "plan_path": str(ws / "other.md"),
|
||
"stream_log": str(loc.parent / "stream.log"),
|
||
"normalized_output_log": str(loc.parent / "normalized-output.log"),
|
||
}), encoding="utf-8"), loc
|
||
)[1]),
|
||
("missing_raw_artifact", lambda loc, ws, tk: (
|
||
(loc.parent / "stream.log").unlink(), loc
|
||
)[1]),
|
||
("missing_normalized_artifact", lambda loc, ws, tk: (
|
||
(loc.parent / "normalized-output.log").unlink(), loc
|
||
)[1]),
|
||
]
|
||
|
||
for name, modifier in cases:
|
||
with self.subTest(variant=name), tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)")
|
||
base_locator = self.make_attempt_locator(workspace, task, gemini_spec)
|
||
target_locator = modifier(base_locator, workspace, task)
|
||
|
||
invoked_specs = []
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
return (1, "provider-quota", target_locator)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime)
|
||
initial_decisions = store.task_state(task)["execution_decisions"]["worker"]
|
||
initial_history = list(store.task_state(task)["route_transition_history"])
|
||
|
||
success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", gemini_spec)
|
||
|
||
self.assertFalse(success)
|
||
self.assertEqual(len(invoked_specs), 1)
|
||
self.assertEqual(invoked_specs[0].cli, "agy")
|
||
|
||
state = store.task_state(task)
|
||
self.assertIn("worker selector decision 실패", state.get("blocked", ""))
|
||
self.assertEqual(state["execution_decisions"]["worker"]["selected"], initial_decisions["selected"])
|
||
self.assertEqual(state["route_transition_history"], initial_history)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_day_gemini_failure_continues_on_laguna_with_logical_context(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)")
|
||
locator = self.make_attempt_locator(workspace, task, gemini_spec)
|
||
invoked_specs = []
|
||
invoked_prompts = []
|
||
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
prompt = args[5]
|
||
invoked_specs.append(spec)
|
||
invoked_prompts.append(prompt)
|
||
if spec.cli == "agy":
|
||
return (1, "provider-quota", locator)
|
||
return (0, None, locator)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime)
|
||
success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", gemini_spec)
|
||
|
||
self.assertTrue(success)
|
||
self.assertEqual(len(invoked_specs), 2)
|
||
self.assertEqual(invoked_specs[0].cli, "agy")
|
||
self.assertEqual(invoked_specs[1].cli, "pi")
|
||
self.assertTrue(invoked_specs[1].local_pi)
|
||
laguna_prompt = invoked_prompts[1]
|
||
self.assertIn(str(task.plan.resolve()), laguna_prompt)
|
||
self.assertIn(str(locator.resolve()), laguna_prompt)
|
||
self.assertIn(str(workspace.resolve()), laguna_prompt)
|
||
self.assertIn(str((locator.parent / "stream.log").resolve()), laguna_prompt)
|
||
self.assertIn(str((locator.parent / "normalized-output.log").resolve()), laguna_prompt)
|
||
state = store.task_state(task)
|
||
decisions = state["execution_decisions"]["worker"]
|
||
self.assertEqual(decisions["selected"]["adapter"], "pi")
|
||
self.assertEqual(decisions["transition"]["trigger"], "provider-quota")
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_cloud_g07_provider_quota_promotes_claude_to_codex_without_no_failover_block(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="cloud", grade=7)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
claude_spec = dispatch.AgentSpec("claude", "claude-opus-4-8", "claude/claude-opus-4-8 xhigh")
|
||
terra_spec = dispatch.AgentSpec(
|
||
"codex",
|
||
"gpt-5.6-terra",
|
||
"codex/gpt-5.6-terra high",
|
||
reasoning_effort="high",
|
||
)
|
||
claude_locator = self.make_attempt_locator(
|
||
workspace, task, claude_spec
|
||
)
|
||
terra_locator = self.make_attempt_locator(
|
||
workspace, task, terra_spec
|
||
)
|
||
invoked_specs = []
|
||
invoked_prompts = []
|
||
transition_budget_counts = []
|
||
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
invoked_prompts.append(args[5])
|
||
if spec.cli == "claude":
|
||
return (1, "provider-quota", claude_locator)
|
||
state = store.task_state(task)
|
||
decision = state["execution_decisions"]["worker"]
|
||
budget = dispatch.StageFailureBudget.from_decision(
|
||
store, task, decision
|
||
)
|
||
transition_budget_counts.append(budget.count())
|
||
return (0, None, terra_locator)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime)
|
||
success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", claude_spec)
|
||
|
||
self.assertTrue(success)
|
||
self.assertEqual(final_loc, terra_locator)
|
||
self.assertEqual(len(invoked_specs), 2)
|
||
self.assertEqual(invoked_specs, [claude_spec, terra_spec])
|
||
self.assertEqual(transition_budget_counts, [1])
|
||
continuation = invoked_prompts[1]
|
||
self.assertIn(str(task.plan.resolve()), continuation)
|
||
self.assertIn(str(claude_locator.resolve()), continuation)
|
||
self.assertIn(str(workspace.resolve()), continuation)
|
||
self.assertIn(
|
||
str((claude_locator.parent / "stream.log").resolve()),
|
||
continuation,
|
||
)
|
||
self.assertIn(
|
||
str(
|
||
(claude_locator.parent / "normalized-output.log").resolve()
|
||
),
|
||
continuation,
|
||
)
|
||
state = store.task_state(task)
|
||
decision = state["execution_decisions"]["worker"]
|
||
self.assertEqual(decision["selected"]["adapter"], "codex")
|
||
self.assertEqual(decision["selected"]["target"], "gpt-5.6-terra")
|
||
self.assertEqual(decision["transition"]["kind"], "promotion")
|
||
self.assertEqual(
|
||
decision["transition"]["trigger"], "provider-quota"
|
||
)
|
||
self.assertEqual(
|
||
[entry["transition"] for entry in state["route_transition_history"]],
|
||
["initial", "provider-quota"],
|
||
)
|
||
budget = dispatch.StageFailureBudget.from_decision(
|
||
store, task, decision
|
||
)
|
||
self.assertEqual(budget.count(), 0)
|
||
self.assertNotIn("no_failover_candidate", state.get("blocked") or "")
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_cloud_agy_promotion_chain_commits_each_transition(self):
|
||
daytime = datetime(
|
||
2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9))
|
||
)
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="cloud", grade=5)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
agy_spec = dispatch.AgentSpec(
|
||
"agy",
|
||
"Gemini 3.6 Flash (High)",
|
||
"agy/Gemini 3.6 Flash (High)",
|
||
)
|
||
claude_spec = dispatch.AgentSpec(
|
||
"claude",
|
||
"claude-opus-4-8",
|
||
"claude/claude-opus-4-8 xhigh",
|
||
)
|
||
terra_spec = dispatch.AgentSpec(
|
||
"codex",
|
||
"gpt-5.6-terra",
|
||
"codex/gpt-5.6-terra high",
|
||
reasoning_effort="high",
|
||
)
|
||
locators = {
|
||
spec.cli: self.make_attempt_locator(workspace, task, spec)
|
||
for spec in (agy_spec, claude_spec, terra_spec)
|
||
}
|
||
invoked_specs = []
|
||
invoked_prompts = []
|
||
transition_budget_counts = []
|
||
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
invoked_prompts.append(args[5])
|
||
if spec.cli == "agy":
|
||
return (1, "provider-quota", locators["agy"])
|
||
state = store.task_state(task)
|
||
decision = state["execution_decisions"]["worker"]
|
||
budget = dispatch.StageFailureBudget.from_decision(
|
||
store, task, decision
|
||
)
|
||
transition_budget_counts.append(budget.count())
|
||
if spec.cli == "claude":
|
||
return (1, "context-limit", locators["claude"])
|
||
return (0, None, locators["codex"])
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(
|
||
dispatch.asyncio, "sleep", new=mock.AsyncMock()
|
||
),
|
||
):
|
||
dispatch.persisted_execution_decision(
|
||
store, task, stage="worker", evaluated_at=daytime
|
||
)
|
||
success, final_locator = await dispatch.run_escalating(
|
||
workspace, store, task, "worker", agy_spec
|
||
)
|
||
|
||
self.assertTrue(success)
|
||
self.assertEqual(final_locator, locators["codex"])
|
||
self.assertEqual(
|
||
invoked_specs, [agy_spec, claude_spec, terra_spec]
|
||
)
|
||
self.assertEqual(transition_budget_counts, [1, 2])
|
||
self.assertIn(
|
||
str(locators["agy"].resolve()), invoked_prompts[1]
|
||
)
|
||
self.assertIn(
|
||
str(locators["claude"].resolve()), invoked_prompts[2]
|
||
)
|
||
state = store.task_state(task)
|
||
decision = state["execution_decisions"]["worker"]
|
||
self.assertEqual(
|
||
decision["promotion_path"],
|
||
[
|
||
{
|
||
"adapter": "agy",
|
||
"target": "Gemini 3.6 Flash (High)",
|
||
},
|
||
{"adapter": "claude", "target": "claude-opus-4-8"},
|
||
{"adapter": "codex", "target": "gpt-5.6-terra"},
|
||
],
|
||
)
|
||
self.assertEqual(
|
||
[entry["transition"] for entry in state["route_transition_history"]],
|
||
["initial", "provider-quota", "context-limit"],
|
||
)
|
||
self.assertEqual(
|
||
dispatch.StageFailureBudget.from_decision(
|
||
store, task, decision
|
||
).count(),
|
||
0,
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_night_laguna_failure_continues_on_available_gemini(self):
|
||
nighttime = datetime(2026, 7, 26, 1, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
laguna_spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True)
|
||
locator = self.make_attempt_locator(workspace, task, laguna_spec)
|
||
invoked_specs = []
|
||
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
if spec.cli == "pi":
|
||
return (1, "provider-stream-disconnect", locator)
|
||
return (0, None, locator)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=nighttime)
|
||
success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", laguna_spec)
|
||
|
||
self.assertTrue(success)
|
||
self.assertEqual(len(invoked_specs), 2)
|
||
self.assertEqual(invoked_specs[0].cli, "pi")
|
||
self.assertEqual(invoked_specs[1].cli, "agy")
|
||
state = store.task_state(task)
|
||
decisions = state["execution_decisions"]["worker"]
|
||
self.assertEqual(decisions["selected"]["adapter"], "agy")
|
||
self.assertEqual(decisions["transition"]["trigger"], "provider-stream-disconnect")
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_night_gemini_quota_exhaustion_blocks_without_bounce(self):
|
||
nighttime = datetime(2026, 7, 26, 1, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
laguna_spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True)
|
||
locator = self.make_attempt_locator(workspace, task, laguna_spec)
|
||
invoked_specs = []
|
||
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
return (1, "provider-quota", locator)
|
||
|
||
quota_snap = {
|
||
"targets": [
|
||
{"adapter": "agy", "target": "Gemini 3.6 Flash (Medium)", "status": "exhausted"}
|
||
]
|
||
}
|
||
store.update_task(task, quota_snapshot=quota_snap)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=nighttime)
|
||
success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", laguna_spec)
|
||
|
||
self.assertFalse(success)
|
||
state = store.task_state(task)
|
||
self.assertIn("no_failover_candidate", state.get("blocked", ""))
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_recovered_primary_quota_does_not_reverse_failover(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
store.update_task(
|
||
task,
|
||
quota_snapshot={
|
||
"snapshot_id": "gemini-exhausted",
|
||
"source": "iop-node quota-probe",
|
||
"checked_at": "2026-07-25T03:00:00+09:00",
|
||
"targets": [
|
||
{"adapter": "agy", "target": "Gemini 3.6 Flash (Medium)", "status": "exhausted"}
|
||
],
|
||
},
|
||
)
|
||
|
||
laguna_spec = dispatch.AgentSpec("pi", "iop/laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True)
|
||
decision, spec = dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime)
|
||
self.assertEqual(spec.cli, "pi")
|
||
|
||
locator = self.make_attempt_locator(workspace, task, laguna_spec)
|
||
|
||
store.update_task(
|
||
task,
|
||
quota_snapshot={
|
||
"snapshot_id": "gemini-recovered",
|
||
"source": "iop-node quota-probe",
|
||
"checked_at": "2026-07-25T04:00:00+09:00",
|
||
"targets": [
|
||
{"adapter": "agy", "target": "Gemini 3.6 Flash (Medium)", "status": "available"}
|
||
],
|
||
},
|
||
)
|
||
|
||
invoked_specs = []
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
return (1, "provider-quota", locator)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", laguna_spec)
|
||
|
||
self.assertFalse(success)
|
||
self.assertEqual(len(invoked_specs), 1)
|
||
self.assertEqual(invoked_specs[0].cli, "pi")
|
||
|
||
state = store.task_state(task)
|
||
self.assertIn("no_failover_candidate", state.get("blocked", ""))
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_generic_failure_stays_on_same_target(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)")
|
||
locator = self.make_attempt_locator(workspace, task, gemini_spec)
|
||
invoked_specs = []
|
||
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
if len(invoked_specs) == 1:
|
||
return (1, "generic-error", locator)
|
||
return (0, None, locator)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime)
|
||
success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", gemini_spec)
|
||
|
||
self.assertTrue(success)
|
||
self.assertEqual(len(invoked_specs), 2)
|
||
self.assertEqual(invoked_specs[0].cli, "agy")
|
||
self.assertEqual(invoked_specs[1].cli, "agy")
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_no_promotion_target_keeps_same_target_and_persists_state(self):
|
||
"""local-G08 daytime: provider-connection x2 → success. Same AGY target, delay [2, 4], budget reset.
|
||
|
||
The selector-backed worker must NOT fall through to legacy promoted_spec().
|
||
Invocation target, persisted selected, history, and terminal recovery backoff
|
||
must all agree on AGY with bounded exponential backoff.
|
||
"""
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
gemini_spec = dispatch.AgentSpec(
|
||
"agy",
|
||
"Gemini 3.6 Flash (Medium)",
|
||
"agy/Gemini 3.6 Flash (Medium)",
|
||
)
|
||
locator = self.make_attempt_locator(workspace, task, gemini_spec)
|
||
invoked_specs = []
|
||
sleep_delays = []
|
||
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
if len(invoked_specs) <= 2:
|
||
return (1, "provider-connection", locator)
|
||
return (0, None, locator)
|
||
|
||
async def observe_sleep(delay):
|
||
sleep_delays.append(delay)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(
|
||
dispatch.asyncio,
|
||
"sleep",
|
||
new=mock.AsyncMock(side_effect=observe_sleep),
|
||
),
|
||
):
|
||
dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime)
|
||
initial_state = store.task_state(task)
|
||
initial_selected = dict(initial_state["execution_decisions"]["worker"]["selected"])
|
||
initial_history = list(initial_state["route_transition_history"])
|
||
|
||
success, final_loc = await dispatch.run_escalating(
|
||
workspace, store, task, "worker", gemini_spec
|
||
)
|
||
|
||
self.assertTrue(success)
|
||
# Two failures then one success = 3 invocations
|
||
self.assertEqual(len(invoked_specs), 3)
|
||
# All invocations must be on AGY — no legacy AGY→Claude fallthrough
|
||
for i, spec in enumerate(invoked_specs):
|
||
self.assertEqual(spec.cli, "agy", f"invocation {i} target mismatch")
|
||
self.assertEqual(spec.model, "Gemini 3.6 Flash (Medium)", f"invocation {i} model mismatch")
|
||
|
||
# Terminal backoff: retries go 0→1→2, delays = [2**1, 2**2] = [2, 4]
|
||
self.assertEqual(len(sleep_delays), 2, f"expected 2 sleep calls, got {len(sleep_delays)}")
|
||
self.assertEqual(sleep_delays[0], 2)
|
||
self.assertEqual(sleep_delays[1], 4)
|
||
|
||
state = store.task_state(task)
|
||
# Persisted selected must NOT change from initial AGY
|
||
self.assertEqual(
|
||
state["execution_decisions"]["worker"]["selected"],
|
||
initial_selected,
|
||
)
|
||
# History must NOT have a promotion entry
|
||
self.assertEqual(
|
||
state["route_transition_history"],
|
||
initial_history,
|
||
)
|
||
# No block should be set after success
|
||
self.assertIsNone(state.get("blocked"))
|
||
# After success, stage failure budget count must be 0
|
||
worker_decision = state["execution_decisions"]["worker"]
|
||
worker_budget = dispatch.StageFailureBudget.from_decision(store, task, worker_decision)
|
||
self.assertEqual(worker_budget.count(), 0)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_promotion_chain_exhaustion_stays_on_last_target(self):
|
||
"""Cloud promotion chain: AGY→Claude→Terra exhausted.
|
||
|
||
When the last canonical target (Terra) fails with a promotable failure
|
||
and no promotion target remains, the worker must stay on Terra for
|
||
same-target recovery rather than falling through to legacy promoted_spec().
|
||
"""
|
||
daytime = datetime(
|
||
2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9))
|
||
)
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="cloud", grade=5)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
agy_spec = dispatch.AgentSpec(
|
||
"agy", "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)"
|
||
)
|
||
claude_spec = dispatch.AgentSpec(
|
||
"claude", "claude-opus-4-8", "claude/claude-opus-4-8 xhigh"
|
||
)
|
||
terra_spec = dispatch.AgentSpec(
|
||
"codex", "gpt-5.6-terra", "codex/gpt-5.6-terra high",
|
||
reasoning_effort="high",
|
||
)
|
||
loc_agy = self.make_attempt_locator(workspace, task, agy_spec)
|
||
loc_claude = self.make_attempt_locator(workspace, task, claude_spec)
|
||
loc_terra = self.make_attempt_locator(workspace, task, terra_spec)
|
||
invoked_specs = []
|
||
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
if spec.cli == "agy":
|
||
return (1, "provider-quota", loc_agy)
|
||
if spec.cli == "claude":
|
||
return (1, "context-limit", loc_claude)
|
||
# Terra fails once, then succeeds — chain exhaustion keeps it on Terra
|
||
terra_count = sum(1 for s in invoked_specs if s.cli == "codex")
|
||
if terra_count == 1:
|
||
return (1, "provider-quota", loc_terra)
|
||
return (0, None, loc_terra)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
dispatch.persisted_execution_decision(
|
||
store, task, stage="worker", evaluated_at=daytime
|
||
)
|
||
success, final_loc = await dispatch.run_escalating(
|
||
workspace, store, task, "worker", agy_spec
|
||
)
|
||
|
||
# Chain: AGY → Claude → Terra, then Terra retries on same target (no legacy fallthrough)
|
||
self.assertEqual(
|
||
[s.cli for s in invoked_specs],
|
||
["agy", "claude", "codex", "codex"],
|
||
)
|
||
# The third and fourth invocations are both Terra (same-target recovery)
|
||
self.assertEqual(invoked_specs[2].cli, "codex")
|
||
self.assertEqual(invoked_specs[2].model, "gpt-5.6-terra")
|
||
self.assertEqual(invoked_specs[3].cli, "codex")
|
||
self.assertEqual(invoked_specs[3].model, "gpt-5.6-terra")
|
||
|
||
state = store.task_state(task)
|
||
decision = state["execution_decisions"]["worker"]
|
||
# Promotion path should include all three transitions
|
||
self.assertEqual(
|
||
len(decision["promotion_path"]), 3,
|
||
)
|
||
# History should show the promotions but no legacy recovery
|
||
transitions = [h["transition"] for h in state["route_transition_history"]]
|
||
self.assertIn("provider-quota", transitions)
|
||
self.assertIn("context-limit", transitions)
|
||
# No legacy promoted_spec() fallthrough: selected stays on Terra
|
||
self.assertEqual(
|
||
decision["selected"]["adapter"], "codex"
|
||
)
|
||
self.assertEqual(
|
||
decision["selected"]["target"], "gpt-5.6-terra"
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_legacy_promoted_spec_still_works_for_non_selector_worker(self):
|
||
"""Ensure legacy promoted_spec() path is preserved for non-selector workers."""
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
# Use a task that does NOT have a persisted selector decision
|
||
# so the selector promotion block is skipped entirely.
|
||
directory = workspace / "agent-task" / "legacy_recovery_test"
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
header = "<!-- task=legacy_recovery_test plan=0 tag=API -->\n"
|
||
(directory / "PLAN-local-G08.md").write_text(header, encoding="utf-8")
|
||
(directory / "CODE_REVIEW-local-G08.md").write_text(header, encoding="utf-8")
|
||
tasks = dispatch.scan_tasks(workspace, None)
|
||
task = tasks[0]
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
agy_spec = dispatch.AgentSpec(
|
||
"agy", "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)"
|
||
)
|
||
claude_spec = dispatch.AgentSpec(
|
||
"claude", "claude-opus-4-8", "claude/claude-opus-4-8 xhigh"
|
||
)
|
||
locator = self.make_attempt_locator(workspace, task, agy_spec)
|
||
invoked_specs = []
|
||
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
if spec.cli == "agy":
|
||
return (1, "provider-quota", locator)
|
||
return (0, None, locator)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
# Do NOT persist a selector decision — legacy path only
|
||
success, final_loc = await dispatch.run_escalating(
|
||
workspace, store, task, "worker", agy_spec
|
||
)
|
||
|
||
self.assertTrue(success)
|
||
# Legacy path: AGY → Claude (via promoted_spec)
|
||
self.assertEqual(len(invoked_specs), 2)
|
||
self.assertEqual(invoked_specs[0].cli, "agy")
|
||
self.assertEqual(invoked_specs[1].cli, "claude")
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_completing_target_controls_selfcheck_and_reuses_pin(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
nighttime = datetime(2026, 7, 26, 1, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
|
||
# Case 1: Day local G08 completion on Laguna requires selfcheck with pinned Laguna
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="local", grade=8)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)")
|
||
laguna_spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True)
|
||
loc_gemini = self.make_attempt_locator(workspace, task, gemini_spec)
|
||
loc_laguna = self.make_attempt_locator(workspace, task, laguna_spec)
|
||
|
||
invoked_specs = []
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
if spec.cli == "agy":
|
||
return (1, "provider-quota", loc_gemini)
|
||
return (0, None, loc_laguna)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime)
|
||
await dispatch.run_worker(workspace, store, task, semaphores={})
|
||
|
||
self.assertEqual([s.cli for s in invoked_specs], ["agy", "pi"])
|
||
state = store.task_state(task)
|
||
self.assertTrue(state["worker_selfcheck_required"])
|
||
self.assertFalse(state["selfcheck_done"])
|
||
self.assertEqual(dispatch.task_stage(task, state), "selfcheck")
|
||
self.assertEqual(state["execution_decisions"]["worker"]["selected"]["adapter"], "pi")
|
||
hist1 = list(state["route_transition_history"])
|
||
self.assertEqual([h["transition"] for h in hist1], ["initial", "resume", "provider-quota"])
|
||
|
||
selfcheck_specs = []
|
||
async def mock_invoke_selfcheck(*args, **kwargs):
|
||
spec = args[4]
|
||
selfcheck_specs.append(spec)
|
||
return (0, None, loc_laguna)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke_selfcheck),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
mock.patch.object(dispatch, "implementation_review_errors", return_value=[]),
|
||
):
|
||
await dispatch.run_selfcheck(workspace, store, task, semaphores={})
|
||
|
||
self.assertEqual([s.cli for s in selfcheck_specs], ["pi"])
|
||
state2 = store.task_state(task)
|
||
self.assertTrue(state2["selfcheck_done"])
|
||
self.assertEqual(dispatch.task_stage(task, state2), "review")
|
||
hist2 = state2["route_transition_history"]
|
||
self.assertEqual([h["transition"] for h in hist2], ["initial", "resume", "provider-quota", "resume"])
|
||
finally:
|
||
store.close()
|
||
|
||
# Case 2: Night local G08 completion on Gemini skips selfcheck
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="local", grade=8)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)")
|
||
laguna_spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True)
|
||
loc_gemini = self.make_attempt_locator(workspace, task, gemini_spec)
|
||
loc_laguna = self.make_attempt_locator(workspace, task, laguna_spec)
|
||
|
||
invoked_specs = []
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
if spec.cli == "pi":
|
||
return (1, "provider-stream-disconnect", loc_laguna)
|
||
return (0, None, loc_gemini)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=nighttime)
|
||
await dispatch.run_worker(workspace, store, task, semaphores={})
|
||
|
||
self.assertEqual([s.cli for s in invoked_specs], ["pi", "agy"])
|
||
state = store.task_state(task)
|
||
self.assertFalse(state["worker_selfcheck_required"])
|
||
self.assertTrue(state["selfcheck_done"])
|
||
self.assertEqual(dispatch.task_stage(task, state), "review")
|
||
self.assertEqual([h["transition"] for h in state["route_transition_history"]], ["initial", "resume", "provider-stream-disconnect"])
|
||
finally:
|
||
store.close()
|
||
|
||
# Case 3: Cloud G07 completion on Claude skips selfcheck
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="cloud", grade=7)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
claude_spec = dispatch.AgentSpec("claude", "claude-opus-4-8", "claude/claude-opus-4-8 xhigh")
|
||
loc_claude = self.make_attempt_locator(workspace, task, claude_spec)
|
||
|
||
invoked_specs = []
|
||
async def mock_invoke(*args, **kwargs):
|
||
spec = args[4]
|
||
invoked_specs.append(spec)
|
||
return (0, None, loc_claude)
|
||
|
||
with (
|
||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||
):
|
||
dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime)
|
||
await dispatch.run_worker(workspace, store, task, semaphores={})
|
||
|
||
self.assertEqual([s.cli for s in invoked_specs], ["claude"])
|
||
state = store.task_state(task)
|
||
self.assertFalse(state["worker_selfcheck_required"])
|
||
self.assertTrue(state["selfcheck_done"])
|
||
self.assertEqual(dispatch.task_stage(task, state), "review")
|
||
self.assertEqual([h["transition"] for h in state["route_transition_history"]], ["initial", "resume"])
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
||
def make_task(
|
||
self, workspace: Path, lane: str = "local", grade: int = 8, unit: str = "01_unit"
|
||
) -> dispatch.Task:
|
||
directory = workspace / "agent-task" / "selector_dispatch_integration" / unit
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
header = f"<!-- task=selector_dispatch_integration/{unit} plan=0 tag=API -->\n"
|
||
(directory / f"PLAN-{lane}-G{grade:02d}.md").write_text(header, encoding="utf-8")
|
||
(directory / f"CODE_REVIEW-{lane}-G{grade:02d}.md").write_text(header, encoding="utf-8")
|
||
tasks = dispatch.scan_tasks(workspace, None)
|
||
for task in tasks:
|
||
if task.name.endswith(unit):
|
||
return task
|
||
return tasks[0]
|
||
|
||
def make_attempt_locator(
|
||
self, workspace: Path, task: dispatch.Task, spec: dispatch.AgentSpec
|
||
) -> Path:
|
||
attempt = workspace / f"attempt-{spec.cli}"
|
||
attempt.mkdir(parents=True, exist_ok=True)
|
||
raw, normalized = attempt / "stream.log", attempt / "normalized-output.log"
|
||
raw.write_text("raw log\n", encoding="utf-8")
|
||
normalized.write_text("normalized output\n", encoding="utf-8")
|
||
locator = attempt / "locator.json"
|
||
record = {
|
||
"task": task.name,
|
||
"workspace": str(workspace),
|
||
"plan_path": str(task.plan),
|
||
"stream_log": str(raw),
|
||
"normalized_output_log": str(normalized),
|
||
"cli": spec.cli,
|
||
"model": spec.model,
|
||
"spec": {"adapter": spec.cli, "target": spec.model},
|
||
}
|
||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||
return locator
|
||
|
||
async def test_worker_and_review_initial_invocation_uses_selector(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="local", grade=8)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
with mock.patch.object(dispatch, "run_escalating") as run_escalating_mock, \
|
||
mock.patch.object(dispatch, "datetime") as datetime_mock:
|
||
datetime_mock.now.return_value = daytime
|
||
run_escalating_mock.side_effect = lambda ws, st, t, stage, spec, **kwargs: (
|
||
True, self.make_attempt_locator(ws, t, spec)
|
||
)
|
||
|
||
await dispatch.run_worker(workspace, store, task, {})
|
||
self.assertEqual(run_escalating_mock.call_count, 1)
|
||
call_args = run_escalating_mock.call_args[0]
|
||
self.assertEqual(call_args[3], "worker")
|
||
spec_worker = call_args[4]
|
||
self.assertEqual(spec_worker.cli, "agy")
|
||
self.assertEqual(spec_worker.model, "Gemini 3.6 Flash (Medium)")
|
||
|
||
state_after_worker = store.task_state(task)
|
||
self.assertTrue(state_after_worker.get("worker_done"))
|
||
self.assertIn("worker", state_after_worker.get("execution_decisions", {}))
|
||
self.assertEqual(
|
||
state_after_worker["execution_decisions"]["worker"]["selected"]["target"],
|
||
"Gemini 3.6 Flash (Medium)",
|
||
)
|
||
|
||
await dispatch.run_review(workspace, store, task)
|
||
self.assertEqual(run_escalating_mock.call_count, 2)
|
||
call_args2 = run_escalating_mock.call_args[0]
|
||
self.assertEqual(call_args2[3], "review")
|
||
spec_review = call_args2[4]
|
||
self.assertEqual(spec_review.cli, "codex")
|
||
self.assertEqual(spec_review.model, "gpt-5.6-sol")
|
||
self.assertEqual(spec_review.display, "codex/gpt-5.6-sol xhigh")
|
||
|
||
state_after_review = store.task_state(task)
|
||
self.assertIn("review", state_after_review.get("execution_decisions", {}))
|
||
self.assertNotEqual(
|
||
state_after_review["execution_decisions"]["worker"]["selected"],
|
||
state_after_review["execution_decisions"]["review"]["selected"],
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_dry_run_statelessness_initial_and_resume_previews(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="local", grade=8)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
# 1. Non-persisted dry-run (initial preview)
|
||
args = dispatch.argparse.Namespace(
|
||
workspace=str(workspace),
|
||
task_group=None,
|
||
retry_blocked=False,
|
||
dry_run=True,
|
||
)
|
||
with mock.patch.object(dispatch, "datetime") as datetime_mock:
|
||
datetime_mock.now.return_value = daytime
|
||
result = await dispatch.dispatch_with_store(args, workspace, store)
|
||
|
||
self.assertEqual(result, 0)
|
||
state_initial = store.task_state(task)
|
||
self.assertEqual(state_initial.get("execution_decisions"), {})
|
||
self.assertEqual(state_initial.get("route_transition_history"), [])
|
||
|
||
# 2. Persist decision and test dry-run (read-only resume preview)
|
||
dispatch.persisted_execution_decision(
|
||
store, task, stage="worker", evaluated_at=daytime
|
||
)
|
||
state_persisted = store.task_state(task)
|
||
history_before = list(state_persisted.get("route_transition_history", []))
|
||
self.assertEqual(len(history_before), 1)
|
||
|
||
with mock.patch.object(dispatch, "datetime") as datetime_mock:
|
||
datetime_mock.now.return_value = daytime
|
||
result2 = await dispatch.dispatch_with_store(args, workspace, store)
|
||
|
||
self.assertEqual(result2, 0)
|
||
state_after_dry_run = store.task_state(task)
|
||
history_after = list(state_after_dry_run.get("route_transition_history", []))
|
||
self.assertEqual(history_before, history_after)
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_dry_run_multiple_ready_tasks_isolation_and_statelessness(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
nighttime = datetime(2026, 7, 26, 23, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task1 = self.make_task(workspace, lane="local", grade=8, unit="01_unit1")
|
||
task2 = self.make_task(workspace, lane="local", grade=8, unit="02_unit2")
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
# Task 1 is pinned during daytime
|
||
dec1, spec1 = dispatch.persisted_execution_decision(
|
||
store, task1, stage="worker", evaluated_at=daytime
|
||
)
|
||
self.assertEqual(spec1.cli, "agy")
|
||
|
||
state1_before = dict(store.task_state(task1))
|
||
state2_before = dict(store.task_state(task2))
|
||
|
||
banners = []
|
||
def capture_banner(event, name, lines):
|
||
banners.append((event, name, lines))
|
||
|
||
args = dispatch.argparse.Namespace(
|
||
workspace=str(workspace),
|
||
task_group=None,
|
||
retry_blocked=False,
|
||
dry_run=True,
|
||
)
|
||
with mock.patch.object(dispatch, "datetime") as datetime_mock, \
|
||
mock.patch.object(dispatch, "banner", side_effect=capture_banner):
|
||
datetime_mock.now.return_value = nighttime
|
||
result = await dispatch.dispatch_with_store(args, workspace, store)
|
||
|
||
self.assertEqual(result, 0)
|
||
|
||
task1_banners = [b for b in banners if b[1] == task1.name]
|
||
task2_banners = [b for b in banners if b[1] == task2.name]
|
||
self.assertTrue(any("model=agy/" in line for b in task1_banners for line in b[2]))
|
||
self.assertTrue(any("model=pi/" in line for b in task2_banners for line in b[2]))
|
||
|
||
state1_after = store.task_state(task1)
|
||
state2_after = store.task_state(task2)
|
||
|
||
self.assertEqual(
|
||
state1_before.get("route_transition_history"),
|
||
state1_after.get("route_transition_history"),
|
||
)
|
||
self.assertEqual(
|
||
state2_before.get("route_transition_history"),
|
||
state2_after.get("route_transition_history"),
|
||
)
|
||
self.assertEqual(state2_after.get("execution_decisions"), {})
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_resume_pins_target_across_time_and_body_changes_and_resets_on_new_generation(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
nighttime = datetime(2026, 7, 26, 23, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="local", grade=8)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
# 1. Initial decision daytime (KST 14:00) -> agy Gemini Medium
|
||
dec1, spec1 = dispatch.persisted_execution_decision(
|
||
store, task, stage="worker", evaluated_at=daytime
|
||
)
|
||
self.assertEqual(spec1.cli, "agy")
|
||
self.assertEqual(spec1.model, "Gemini 3.6 Flash (Medium)")
|
||
|
||
# 2. Resuming at nighttime (KST 23:00) keeps pinned Gemini Medium
|
||
dec2, spec2 = dispatch.persisted_execution_decision(
|
||
store, task, stage="worker", evaluated_at=nighttime
|
||
)
|
||
self.assertEqual(spec2.cli, "agy")
|
||
self.assertEqual(spec2.model, "Gemini 3.6 Flash (Medium)")
|
||
|
||
# 3. Body edit (header intact) keeps pinned Gemini Medium
|
||
plan_file = task.plan
|
||
header = f"<!-- task=selector_dispatch_integration/01_unit plan=0 tag=API -->\n"
|
||
plan_file.write_text(header + "\n# Modified Body Content\n", encoding="utf-8")
|
||
dec3, spec3 = dispatch.persisted_execution_decision(
|
||
store, task, stage="worker", evaluated_at=nighttime
|
||
)
|
||
self.assertEqual(spec3.cli, "agy")
|
||
|
||
# 4. New generation header (plan=1) re-evaluates initial decision at nighttime -> pi Laguna
|
||
plan_file.write_text("<!-- task=selector_dispatch_integration/01_unit plan=1 tag=API -->\n\n# New Plan\n", encoding="utf-8")
|
||
task_new = dispatch.scan_tasks(workspace, None)[0]
|
||
dec4, spec4 = dispatch.persisted_execution_decision(
|
||
store, task_new, stage="worker", evaluated_at=nighttime
|
||
)
|
||
self.assertEqual(spec4.cli, "pi")
|
||
self.assertEqual(spec4.model, "laguna-s:2.1")
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_qualified_failover_and_blocker_scenarios(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="local", grade=8)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
# 1. Initial decision local G08 -> agy Gemini Medium (primary) & pi Laguna (fallback)
|
||
dec1, spec1 = dispatch.persisted_execution_decision(
|
||
store, task, stage="worker", evaluated_at=daytime
|
||
)
|
||
self.assertEqual(spec1.cli, "agy")
|
||
|
||
# 2. Qualified failover (provider-quota) -> transitions to pi Laguna
|
||
dec2 = dispatch.select_execution_decision(
|
||
task, stage="worker", prior_decision=dec1,
|
||
evaluated_at=daytime, transition="failover", failure_class="provider-quota"
|
||
)
|
||
self.assertEqual(dec2["transition"]["trigger"], "provider-quota")
|
||
self.assertEqual(dec2["selected"]["adapter"], "pi")
|
||
|
||
# 3. Subsequent failover when no candidate remains -> raises no_failover_candidate
|
||
with self.assertRaises(dispatch.ExecutionDecisionError) as ctx:
|
||
dispatch.select_execution_decision(
|
||
task, stage="worker", prior_decision=dec2,
|
||
evaluated_at=daytime, transition="failover", failure_class="provider-quota"
|
||
)
|
||
self.assertIn("no_failover_candidate", str(ctx.exception))
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_context_budget_and_retry_blocked_lifecycle(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="local", grade=8)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
# 1. Primary initial execution (agy/Gemini Medium)
|
||
dec1, spec1 = dispatch.persisted_execution_decision(
|
||
store, task, stage="worker", evaluated_at=daytime
|
||
)
|
||
self.assertEqual(spec1.cli, "agy")
|
||
|
||
# 2. Record primary failure (count=1) -> failover to alternate (pi/laguna)
|
||
budget = dispatch.StageFailureBudget.from_decision(store, task, dec1)
|
||
count1 = budget.record_failure(target=dec1["selected"], transition="provider-quota")
|
||
self.assertEqual(count1, 1)
|
||
|
||
dec2 = dispatch.select_execution_decision(
|
||
task, stage="worker", prior_decision=dec1,
|
||
evaluated_at=daytime, transition="failover", failure_class="provider-quota"
|
||
)
|
||
dispatch.commit_execution_decision(store, task, "worker", dec2)
|
||
self.assertEqual(dec2["selected"]["adapter"], "pi")
|
||
|
||
# 3. Alternate fails 9 times -> budget count reaches 10, task is blocked
|
||
budget2 = dispatch.StageFailureBudget.from_decision(store, task, dec2)
|
||
for _ in range(9):
|
||
c = budget2.record_failure(target=dec2["selected"], transition="generic-failure")
|
||
self.assertEqual(c, 10)
|
||
|
||
store.update_task(task, blocked="worker recovery failure limit exhausted: 10/10 locator=/tmp/loc.json")
|
||
self.assertIsNotNone(store.task_state(task).get("blocked"))
|
||
|
||
# 4. Retry blocked clears blocked & budget, preserves decision & transition history
|
||
args = dispatch.argparse.Namespace(
|
||
workspace=str(workspace),
|
||
task_group=None,
|
||
retry_blocked=True,
|
||
dry_run=False,
|
||
)
|
||
with mock.patch.object(dispatch, "datetime") as datetime_mock:
|
||
datetime_mock.now.return_value = daytime
|
||
await dispatch.dispatch_with_store(args, workspace, store)
|
||
|
||
state_after_retry = store.task_state(task)
|
||
self.assertIsNone(state_after_retry.get("blocked"))
|
||
self.assertEqual(state_after_retry.get("stage_failure_budgets"), {})
|
||
self.assertIn("worker", state_after_retry.get("execution_decisions", {}))
|
||
self.assertTrue(len(state_after_retry.get("route_transition_history", [])) >= 2)
|
||
|
||
# 5. Success resets stage failure budget
|
||
budget3 = dispatch.StageFailureBudget.from_decision(store, task, dec2)
|
||
budget3.reset_on_success()
|
||
self.assertEqual(store.task_state(task).get("stage_failure_budgets"), {})
|
||
finally:
|
||
store.close()
|
||
|
||
async def test_review_recovery_and_runtime_audit_evidence(self):
|
||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
workspace = Path(temporary)
|
||
(workspace / ".git").mkdir()
|
||
task = self.make_task(workspace, lane="local", grade=8)
|
||
store = dispatch.StateStore(workspace)
|
||
try:
|
||
# 1. Review stage decision is pinned to Codex
|
||
dec_rev, spec_rev = dispatch.persisted_execution_decision(
|
||
store, task, stage="review", evaluated_at=daytime
|
||
)
|
||
self.assertEqual(spec_rev.cli, "codex")
|
||
self.assertEqual(spec_rev.model, "gpt-5.6-sol")
|
||
self.assertEqual(dec_rev["rule_id"], "official-review-codex")
|
||
|
||
# 2. Review failure budget is independent from worker stage budget
|
||
budget_worker = dispatch.StageFailureBudget(store, task, dec_rev["work_unit_id"], "worker")
|
||
budget_worker.record_failure(target={"adapter": "agy", "target": "Gemini 3.6 Flash (Medium)"}, transition="initial")
|
||
budget_review = dispatch.StageFailureBudget.from_decision(store, task, dec_rev)
|
||
self.assertEqual(budget_review.count(), 0)
|
||
|
||
# 3. Legacy review recovery synthesizes fixed official review decision
|
||
task.recovery = True
|
||
synth_dec = dispatch.read_or_preview_stage_decision(task, {}, stage="review")
|
||
self.assertEqual(synth_dec["selected"]["adapter"], "codex")
|
||
self.assertEqual(synth_dec["selected"]["target"], "gpt-5.6-sol")
|
||
self.assertEqual(synth_dec["rule_id"], "official-review-codex")
|
||
|
||
# 4. State, locator, status lines, and dry-run banner have consistent audit fields
|
||
evidence = dispatch.selector_evidence_lines(dec_rev)
|
||
self.assertTrue(any(line.startswith("work_unit_id=") for line in evidence))
|
||
self.assertTrue(any(line.startswith("rule_id=official-review-codex") for line in evidence))
|
||
self.assertTrue(any(line.startswith("priority=") for line in evidence))
|
||
self.assertTrue(any(line.startswith("transition=") for line in evidence))
|
||
self.assertTrue(any(line.startswith("quota_status=") for line in evidence))
|
||
|
||
status = dispatch.status_lines(task, "review", "ready", decision=dec_rev)
|
||
self.assertTrue(any(line.startswith("rule_id=official-review-codex") for line in status))
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|