diff --git a/ORCHESTRATION.md b/ORCHESTRATION.md index f460680..db5ca8f 100644 --- a/ORCHESTRATION.md +++ b/ORCHESTRATION.md @@ -1,7 +1,7 @@ # NestQuest controller OpenCode 1.18.31 loads the three named agents from `.opencode/agents/`. Their -models match the current NestQuest CTXD prompt (version 6): DeepSeek V4.1 +models match the current NestQuest CTXD prompt: DeepSeek V4.1 Flash for controller and approver, GLM 5.3 Flash for development. The controller runs one OpenCode turn at a time, then starts a fresh turn based on Maestro, CTXD, Git, and pull-request state. Its state file is @@ -16,7 +16,8 @@ The approver's shell permission permits only the installed `nq-approve-merge` broker for Gitea. That broker requires exact-head Claude approval evidence, an exact-head Gitea approval by `review_agent`, the `dev` target, a mergeable PR, and passing configured checks before it -requests a squash merge. `scripts/nq-review` launches the independent +requests a squash merge with Gitea's atomic `head_commit_id` guard. +`scripts/nq-review` launches the independent Claude review in a noninteractive read-only session in the task worktree, checks the head again afterward, saves the output under `/tmp/opencode`, and posts the Gitea approval. diff --git a/orchestration_tests/test_nq_approve_merge.py b/orchestration_tests/test_nq_approve_merge.py index daef122..f429861 100644 --- a/orchestration_tests/test_nq_approve_merge.py +++ b/orchestration_tests/test_nq_approve_merge.py @@ -52,6 +52,10 @@ class MergeGateTests(unittest.TestCase): with self.assertRaises(RuntimeError): APPROVE["main"]() self.assertEqual(merged, problem == "none") + if problem == "none": + merge_calls = [payload for path, payload in calls if path.endswith("/merge")] + self.assertEqual(merge_calls, + [{"do": "squash", "head_commit_id": HEAD}]) def test_review_evidence_must_match_exact_head_and_approval(self): root = Path("/tmp/opencode") diff --git a/orchestration_tests/test_nq_controller.py b/orchestration_tests/test_nq_controller.py index 76e7c46..4445b14 100644 --- a/orchestration_tests/test_nq_controller.py +++ b/orchestration_tests/test_nq_controller.py @@ -32,6 +32,18 @@ class ControllerProtocolTests(unittest.TestCase): record["project_id"] = "wrong" with self.assertRaisesRegex(RuntimeError, "wrong NestQuest prompt"): CONTROL["check_ctxd"]() + record["project_id"] = CONTROL["CTXD_PROJECT"] + config.write_text(json.dumps({"mcp": {"ctxd": { + "url": "http://localhost:9091/mcp/", + "headers": {"Authorization": "Bearer test"}, + }}})) + self.assertEqual(CONTROL["check_ctxd"](), 6) + config.write_text(json.dumps({"mcp": {"ctxd": { + "url": "http://localhost:9091/invalid", + "headers": {"Authorization": "Bearer test"}, + }}})) + with self.assertRaisesRegex(RuntimeError, "must end in /mcp"): + CONTROL["check_ctxd"]() def test_installed_opencode_text_event(self): event = {"type": "text", "sessionID": "ses_test", "part": {"type": "text", "text": "Next task queued.\nORCHESTRATOR_STATE: CONTINUE\n"}} @@ -73,6 +85,14 @@ class ControllerProtocolTests(unittest.TestCase): db.execute("INSERT INTO session VALUES (?, ?)", ("ses_test", str(project))) (entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(project).encode() + b"\0") self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database)) + (entry / "cmdline").write_bytes(b"opencode\0web\0--dir\0" + str(project).encode() + b"\0") + self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database)) + (entry / "cmdline").write_bytes(b"opencode\0serve\0") + (entry / "cwd").unlink() + (entry / "cwd").symlink_to(project) + self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database)) + (entry / "cwd").unlink() + (entry / "cwd").symlink_to(root) (entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0") self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database)) (entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(root).encode() + b"\0") diff --git a/scripts/nq-approve-merge b/scripts/nq-approve-merge index bb82652..e3d4058 100755 --- a/scripts/nq-approve-merge +++ b/scripts/nq-approve-merge @@ -113,10 +113,12 @@ def main() -> int: raise RuntimeError("a configured commit check has not passed") # Gitea rechecks branch protection at the merge endpoint. The caller must # independently verify Maestro and task-level evidence before invoking us. - request(f"/pulls/{args.pr}/merge", token, {"Do": "squash"}) + # Gitea checks head_commit_id atomically while processing the merge. + request(f"/pulls/{args.pr}/merge", token, + {"do": "squash", "head_commit_id": args.reviewed_head}) after = inspect(args.pr, token) - if after["merged"] is not True: - raise RuntimeError("merge request returned, but the PR is not merged") + if after["merged"] is not True or after["head"] != args.reviewed_head: + raise RuntimeError("merge result is missing or differs from the reviewed head") print(json.dumps(after, sort_keys=True)) return 0 diff --git a/scripts/nq-controller b/scripts/nq-controller index 8c0b3dc..7b2ec0b 100755 --- a/scripts/nq-controller +++ b/scripts/nq-controller @@ -31,7 +31,10 @@ def check_ctxd() -> int: """Use OpenCode's configured endpoint, never an unrelated host DB.""" config = json.loads((Path.home() / ".config/opencode/opencode.json").read_text()) mcp = config["mcp"]["ctxd"] - endpoint = mcp["url"].removesuffix("/mcp") + configured_url = mcp["url"].rstrip("/") + if not configured_url.endswith("/mcp"): + raise RuntimeError("CTXD MCP URL must end in /mcp") + endpoint = configured_url.removesuffix("/mcp") url = f"{endpoint}/projects/{CTXD_PROJECT}/files/{CTXD_FILE}" with urllib.request.urlopen( urllib.request.Request(url, headers=mcp["headers"]), timeout=8 @@ -65,7 +68,7 @@ def interactive_nestquest_session( args = [a.decode(errors="replace") for a in args if a] if not args or Path(args[0]).name != "opencode": continue - if len(args) > 1 and args[1] in ("run", "debug", "serve", "web"): + if len(args) > 1 and args[1] in ("run", "debug"): continue session = None for flag in ("-s", "--session"):