From f9f09a5440585f833d56b37fcae7a960c1ecb928 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 23 Sep 2026 13:50:18 +0800 Subject: [PATCH] fix(ops): remove duplicate agentctl queue subparser registration The second queue parser shadowed handler-compatible flags (--head, cancel --task, schedule --drain), so enqueue/cancel/schedule failed at runtime and queue_items stayed empty. Keep the first registration and add regression coverage for parser flags, roundtrip, and schedule. --- scripts/agentctl.py | 5 -- scripts/tests/test_agentctl.py | 93 ++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/scripts/agentctl.py b/scripts/agentctl.py index ecda1cb2..29984226 100755 --- a/scripts/agentctl.py +++ b/scripts/agentctl.py @@ -1626,11 +1626,6 @@ def parser(): hooks.add_parser('install');hc=hooks.add_parser('check');hc.add_argument('--merge',action='store_true');hr=hooks.add_parser('ref');hr.add_argument('state') v=sub.add_parser('validate');v.add_argument('--task',required=True);v.add_argument('command',nargs=argparse.REMAINDER) i=sub.add_parser('integrate');i.add_argument('--task',required=True);i.add_argument('--expected-dev',required=True);i.add_argument('--request-id') - q=sub.add_parser('queue').add_subparsers(dest='queue_action',required=True) - qe=q.add_parser('enqueue');qe.add_argument('--task',required=True);qe.add_argument('--expected-dev',default='dev');qe.add_argument('--head-sha');qe.add_argument('--priority',type=int,default=0);qe.add_argument('--request-id') - ql=q.add_parser('list');ql.add_argument('--all',action='store_true') - qc=q.add_parser('cancel');qc.add_argument('--id',required=True);qc.add_argument('--reason',required=True) - q.add_parser('schedule') dep=sub.add_parser('deploy').add_subparsers(dest='deploy_action',required=True) dr=dep.add_parser('run-loop') dr.add_argument('--task',required=True);dr.add_argument('--id',required=True) diff --git a/scripts/tests/test_agentctl.py b/scripts/tests/test_agentctl.py index 0550c815..b0a18b97 100644 --- a/scripts/tests/test_agentctl.py +++ b/scripts/tests/test_agentctl.py @@ -654,6 +654,99 @@ class AgentctlTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(self.git("status", "--porcelain", cwd=task), "") + def test_queue_subparser_registered_once(self): + """Duplicate queue registration must not shadow handler-compatible flags.""" + import importlib.util + spec = importlib.util.spec_from_file_location("agentctl_queue_parser", SOURCE) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + parser = mod.parser() + enqueue = parser.parse_args( + ["queue", "enqueue", "--task", "alpha", "--expected-dev", "dev", "--head", "deadbeef"]) + self.assertEqual(enqueue.head, "deadbeef") + self.assertFalse(hasattr(enqueue, "head_sha")) + cancel = parser.parse_args( + ["queue", "cancel", "--task", "alpha", "--id", "item-1", "--reason", "test"]) + self.assertEqual(cancel.task, "alpha") + schedule = parser.parse_args(["queue", "schedule", "--drain"]) + self.assertTrue(schedule.drain) + # A second registration that only exposes --head-sha must not win. + with self.assertRaises(SystemExit): + parser.parse_args( + ["queue", "enqueue", "--task", "alpha", "--expected-dev", "dev", "--head-sha", "deadbeef"]) + + def test_queue_enqueue_list_cancel_roundtrip(self): + task = self.initialized_task(scopes=(".",), capabilities=("git:dev",)) + baseline = self.git("rev-parse", "dev") + (task / "src/a/feature.py").parent.mkdir(parents=True, exist_ok=True) + (task / "src/a/feature.py").write_text("feature = True\n", encoding="utf-8") + self.git("add", "src/a/feature.py", cwd=task) + change = self.run_lease( + "alpha", ["git:index", "path:."], + "import subprocess,sys;sys.exit(subprocess.run(['git','commit','-m','feature']).returncode)", + cwd=task) + self.assertEqual(change.returncode, 0, change.stderr) + candidate = self.initialized_task("candidate", scopes=(".",), capabilities=("git:dev",)) + merged = self.run_lease( + "candidate", ["git:index", "git:dev", "path:."], + "import subprocess,sys;sys.exit(subprocess.run(['git','merge','--no-ff','-m','candidate','feature/alpha']).returncode)", + cwd=candidate) + self.assertEqual(merged.returncode, 0, merged.stderr) + head = self.git("rev-parse", "HEAD", cwd=candidate) + self.cli("validate", "--task", "candidate", "--", "git", "diff", "--check", baseline, cwd=candidate) + enqueued = json.loads(self.cli( + "queue", "enqueue", "--task", "candidate", "--expected-dev", baseline, + "--head", head, "--request-id", "q-alpha-1", cwd=candidate).stdout) + self.assertEqual(enqueued["status"], "enqueued") + self.assertEqual(enqueued["item"]["state"], "pending") + listed = json.loads(self.cli("queue", "list").stdout) + self.assertEqual(len(listed["items"]), 1) + self.assertEqual(listed["items"][0]["id"], "q-alpha-1") + foreign = self.cli( + "queue", "cancel", "--task", "alpha", "--id", "q-alpha-1", "--reason", "wrong owner", + cwd=task, check=False) + self.assertNotEqual(foreign.returncode, 0) + self.assertIn("PATH_OUT_OF_SCOPE", foreign.stderr) + cancelled = json.loads(self.cli( + "queue", "cancel", "--task", "candidate", "--id", "q-alpha-1", "--reason", "abort", + cwd=candidate).stdout) + self.assertEqual(cancelled["status"], "cancelled") + self.assertEqual(cancelled["item"]["state"], "cancelled") + + def test_queue_schedule_promotes_validated_candidate(self): + task = self.initialized_task(scopes=(".",), capabilities=("git:dev",)) + (task / "src/a/feature.py").parent.mkdir(parents=True, exist_ok=True) + (task / "src/a/feature.py").write_text("feature = True\n", encoding="utf-8") + self.git("add", "src/a/feature.py", cwd=task) + change = self.run_lease( + "alpha", ["git:index", "path:."], + "import subprocess,sys;sys.exit(subprocess.run(['git','commit','-m','feature']).returncode)", + cwd=task) + self.assertEqual(change.returncode, 0, change.stderr) + baseline = self.git("rev-parse", "dev") + candidate = self.initialized_task("candidate", scopes=(".",), capabilities=("git:dev",)) + merged = self.run_lease( + "candidate", ["git:index", "git:dev", "path:."], + "import subprocess,sys;sys.exit(subprocess.run(['git','merge','--no-ff','-m','candidate','feature/alpha']).returncode)", + cwd=candidate) + self.assertEqual(merged.returncode, 0, merged.stderr) + head = self.git("rev-parse", "HEAD", cwd=candidate) + self.cli("validate", "--task", "candidate", "--", "git", "diff", "--check", baseline, cwd=candidate) + self.cli("queue", "enqueue", "--task", "candidate", "--expected-dev", baseline, + "--head", head, "--request-id", "q-sched-1", cwd=candidate) + denied = self.cli("queue", "schedule", cwd=candidate, check=False) + self.assertNotEqual(denied.returncode, 0) + self.assertIn("MAIN_WORKTREE_REQUIRED", denied.stderr) + scheduled_raw = self.cli("queue", "schedule", cwd=self.root) + scheduled = json.loads([line for line in scheduled_raw.stdout.splitlines() if line.startswith("{")][-1]) + self.assertEqual(scheduled["status"], "ok") + self.assertEqual(scheduled["processed"], 1) + self.assertEqual(scheduled["results"][0]["status"], "completed") + self.assertEqual(self.git("rev-parse", "dev"), head) + listed = json.loads(self.cli("queue", "list", "--all").stdout) + item = next(i for i in listed["items"] if i["id"] == "q-sched-1") + self.assertEqual(item["state"], "completed") + class ProcessPlatformTests(unittest.TestCase): @classmethod