@miller-tech/uap 1.210.5 → 1.210.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ """A tool that BLOCKS on a long job must not be loop-broken for repeating.
2
+
3
+ Incident (2026-08-17): an agent was following a healthy `deliver` run — heartbeat
4
+ 8s old, run checkpoint advancing, genuinely compiling a Rust crate. `deliver` in
5
+ follow mode blocks up to its wait budget and returns "still running", so the
6
+ correct behaviour is to call it again; every poll therefore carries identical
7
+ arguments and an identical fingerprint.
8
+
9
+ Three separate guards read those repeats as a spin:
10
+
11
+ 1. should_force_stuck_break — fires FIRST, at 4 identical calls, judged on
12
+ the fingerprint alone, and injects "STOP ... do NOT repeat it".
13
+ 2. the cycle path — enters review, increments review cycles, and
14
+ at the review-cycle limit forces a "wrap up" finalize turn.
15
+ 3. cycle-break narrowing — excluded the deliver tool outright, after which
16
+ the agent could no longer observe the work it was waiting on and fell
17
+ through to Bash, cycling on THAT instead. Median turn spacing collapsed
18
+ from the 45s poll interval to 5s.
19
+
20
+ Unlike a cycling Bash ("vary the command"), there is no different argument to
21
+ vary toward: the job is simply not finished yet. The wait is bounded by the job.
22
+ """
23
+ import importlib.util
24
+ import os
25
+ import unittest
26
+ from pathlib import Path
27
+
28
+ proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
29
+
30
+
31
+ def load_proxy(wait_tools=None):
32
+ # Explicit, so a project .uap/proxy.env cannot inject PROXY_WAIT_POLL_TOOLS
33
+ # and silently invalidate the default-list assertions when this module is
34
+ # run directly rather than through the npm script.
35
+ os.environ["UAP_PROXY_ENV_AUTOLOAD"] = "0"
36
+ if wait_tools is None:
37
+ os.environ.pop("PROXY_WAIT_POLL_TOOLS", None)
38
+ else:
39
+ os.environ["PROXY_WAIT_POLL_TOOLS"] = wait_tools
40
+ spec = importlib.util.spec_from_file_location("anthropic_proxy_waitpoll", proxy_path)
41
+ mod = importlib.util.module_from_spec(spec)
42
+ spec.loader.exec_module(mod)
43
+ return mod
44
+
45
+
46
+ def tool(name):
47
+ return {"type": "function", "function": {"name": name, "parameters": {}}}
48
+
49
+
50
+ # The toolset from the live incident, trimmed to the relevant members.
51
+ TOOLS = [tool(n) for n in ("uap-router_deliver", "bash", "read", "edit", "glob")]
52
+
53
+
54
+ _PRIOR_AUTOLOAD = os.environ.get("UAP_PROXY_ENV_AUTOLOAD")
55
+
56
+
57
+ def tearDownModule():
58
+ # Restore rather than leak into any module loaded later in this process —
59
+ # cross-test env leakage from files like this one has caused
60
+ # order-dependent failures before.
61
+ if _PRIOR_AUTOLOAD is None:
62
+ os.environ.pop("UAP_PROXY_ENV_AUTOLOAD", None)
63
+ else:
64
+ os.environ["UAP_PROXY_ENV_AUTOLOAD"] = _PRIOR_AUTOLOAD
65
+
66
+
67
+ class _Base(unittest.TestCase):
68
+ def tearDown(self):
69
+ os.environ.pop("PROXY_WAIT_POLL_TOOLS", None)
70
+
71
+ @staticmethod
72
+ def _names(tools):
73
+ return [t["function"]["name"] for t in tools]
74
+
75
+
76
+ class WaitPollNotNarrowedTest(_Base):
77
+ def test_the_polled_tool_survives_the_cycle_break(self):
78
+ ap = load_proxy()
79
+ narrowed, _ = ap._narrow_tools_for_cycle_break(TOOLS, ["uap-router_deliver"], [])
80
+ self.assertIn("uap-router_deliver", self._names(narrowed))
81
+
82
+ def test_a_genuinely_spinning_tool_is_still_narrowed(self):
83
+ ap = load_proxy()
84
+ narrowed, _ = ap._narrow_tools_for_cycle_break(TOOLS, ["edit"], [])
85
+ self.assertNotIn("edit", self._names(narrowed))
86
+
87
+ def test_matching_is_exact_not_substring(self):
88
+ # A tool whose name merely CONTAINS an exempt name must still be
89
+ # narrowed — otherwise the exemption silently widens to anything
90
+ # someone names "deliver_report".
91
+ ap = load_proxy()
92
+ tools = [tool("deliver_report"), tool("bash"), tool("edit")]
93
+ narrowed, _ = ap._narrow_tools_for_cycle_break(tools, ["deliver_report"], [])
94
+ self.assertNotIn("deliver_report", self._names(narrowed))
95
+
96
+ def test_the_polled_tool_is_never_auto_banned(self):
97
+ ap = load_proxy()
98
+ self.assertFalse(ap._should_auto_ban("uap-router_deliver", cycle_count=99, ban_at=3))
99
+ self.assertTrue(ap._should_auto_ban("edit", cycle_count=99, ban_at=3))
100
+
101
+ def test_an_explicit_session_ban_is_still_honoured(self):
102
+ ap = load_proxy()
103
+ narrowed, _ = ap._narrow_tools_for_cycle_break(TOOLS, [], ["uap-router_deliver"])
104
+ self.assertNotIn("uap-router_deliver", self._names(narrowed))
105
+
106
+ def test_the_set_is_configurable(self):
107
+ ap = load_proxy(wait_tools="my_wait_tool")
108
+ self.assertIn("my_wait_tool", ap._WAIT_POLL_TOOLS)
109
+ narrowed, _ = ap._narrow_tools_for_cycle_break(
110
+ [tool("my_wait_tool"), tool("edit")], ["my_wait_tool"], []
111
+ )
112
+ self.assertIn("my_wait_tool", self._names(narrowed))
113
+
114
+ def test_matching_is_case_insensitive(self):
115
+ ap = load_proxy()
116
+ narrowed, _ = ap._narrow_tools_for_cycle_break(
117
+ [tool("UAP-Router_Deliver"), tool("edit")], ["UAP-Router_Deliver"], []
118
+ )
119
+ self.assertIn("UAP-Router_Deliver", self._names(narrowed))
120
+
121
+ def test_exploration_hatch_still_exempt(self):
122
+ ap = load_proxy()
123
+ narrowed, _ = ap._narrow_tools_for_cycle_break(TOOLS, ["bash"], [])
124
+ self.assertIn("bash", self._names(narrowed))
125
+
126
+
127
+ class DeliverWireNamesTest(_Base):
128
+ def test_both_mcp_config_keys_are_covered(self):
129
+ # Names arrive prefixed with the MCP server's CONFIG KEY, and this repo
130
+ # ships two: `uap-router` (.mcp.json / opencode.json) and `router`
131
+ # (setup-mcp-router.ts, used by `uap setup`). Covering one exempts only
132
+ # half the fleet.
133
+ ap = load_proxy()
134
+ for name in ("uap-router_deliver", "mcp__uap-router__deliver",
135
+ "router_deliver", "mcp__router__deliver", "deliver"):
136
+ self.assertIn(name, ap._WAIT_POLL_TOOLS, name)
137
+
138
+ def test_deliver_counts_as_a_write_under_every_wire_name(self):
139
+ # _WRITE_TOOL_CLASS with only the bare name let the no-write streak
140
+ # climb through a healthy delivery, escalating recon-convergence
141
+ # mid-wait ("write your deliverable now") while the deliverable was
142
+ # being written by the run being waited on.
143
+ ap = load_proxy()
144
+ for name in ap._DELIVER_TOOL_NAMES:
145
+ self.assertIn(name, ap._WRITE_TOOL_CLASS, name)
146
+
147
+ def test_wait_poll_and_write_classes_deliberately_overlap(self):
148
+ # Other tool classes in this module are pairwise disjoint; this pair is
149
+ # not, and the intent is pinned here rather than left implicit.
150
+ ap = load_proxy()
151
+ self.assertTrue(ap._WAIT_POLL_TOOLS & ap._WRITE_TOOL_CLASS)
152
+
153
+
154
+ class StuckBreakTest(_Base):
155
+ """The guard that fires FIRST — 4 identical calls, outcome-blind."""
156
+
157
+ @staticmethod
158
+ def _monitor(ap, history):
159
+ m = ap.SessionMonitor(context_window=131072)
160
+ m.tool_call_history = list(history)
161
+ return m
162
+
163
+ def test_a_healthy_poll_does_not_force_a_stuck_break(self):
164
+ ap = load_proxy()
165
+ m = self._monitor(ap, ["uap-router_deliver:abc"] * 8)
166
+ forced, reason = m.should_force_stuck_break()
167
+ self.assertFalse(forced, reason)
168
+
169
+ def test_a_genuinely_repeated_call_still_forces_a_break(self):
170
+ # The 44-turn `git diff --stat` loop this guard exists for.
171
+ ap = load_proxy()
172
+ m = self._monitor(ap, ["bash:diffstat"] * 8)
173
+ forced, _ = m.should_force_stuck_break()
174
+ self.assertTrue(forced)
175
+
176
+ def test_a_wait_mixed_with_a_spin_still_breaks(self):
177
+ # all-not-any: a turn calling deliver alongside a spinning tool is
178
+ # still a spin and must stay breakable.
179
+ ap = load_proxy()
180
+ m = self._monitor(ap, ["uap-router_deliver:abc|bash:x"] * 8)
181
+ forced, _ = m.should_force_stuck_break()
182
+ self.assertTrue(forced)
183
+
184
+
185
+ class StateMachineEndToEndTest(_Base):
186
+ """Drive real polls through the state machine — the test that catches the
187
+ guard the helper-level tests miss.
188
+
189
+ The first cut of this fix exempted narrowing, the stuck-break and the cycle
190
+ trip, and STILL force-finalized a healthy wait: the stagnation signal is
191
+ keyed on repeats (`latest_fingerprint == last_fingerprint`) and its only
192
+ reset needs a turn WITHOUT a tool_result, which a poll always has. It
193
+ climbed one per poll, entered review at 9, and hit the review-cycle limit at
194
+ 16 with "wrap up ... what is blocking further progress" — mid-build.
195
+ """
196
+
197
+ FP = "uap-router_deliver:abc"
198
+
199
+ @staticmethod
200
+ def _poll(ap, monitor, fingerprint, msgs):
201
+ """One agent turn: record the call, then run the state machine.
202
+
203
+ `msgs` GROWS across turns. A constant-length message list reads as a
204
+ compaction boundary ("message count collapsed"), which resets all
205
+ anti-spin state every turn and would make this harness silently prove
206
+ nothing.
207
+ """
208
+ monitor.record_tool_calls(["deliver"], fingerprint=fingerprint)
209
+ ap._update_tool_state_stagnation(
210
+ monitor, latest_tool_fingerprint=fingerprint, last_user_has_tool_result=True
211
+ )
212
+ msgs.append({"role": "assistant", "content": "calling"})
213
+ msgs.append({"role": "user", "content": "STILL RUNNING"})
214
+ body = {"messages": list(msgs), "tools": [tool("uap-router_deliver")]}
215
+ # A poll always carries the previous poll's "still running" tool_result,
216
+ # which is precisely why the stagnation reset never fires for it.
217
+ return ap._resolve_state_machine_tool_choice(
218
+ body, monitor, has_tool_results=True, last_user_has_tool_result=True
219
+ )
220
+
221
+ def test_a_healthy_wait_is_never_force_finalized(self):
222
+ ap = load_proxy()
223
+ m = ap.SessionMonitor(context_window=131072)
224
+ msgs = [{"role": "user", "content": "start"}]
225
+ for i in range(30):
226
+ self._poll(ap, m, self.FP, msgs)
227
+ self.assertNotEqual(
228
+ m.tool_turn_phase, "finalize",
229
+ f"forced a finalize on poll {i + 1} of a healthy wait",
230
+ )
231
+ self.assertEqual(m.tool_state_stagnation_streak, 0)
232
+ self.assertEqual(m.tool_state_review_cycles, 0)
233
+
234
+ def test_a_wedged_job_is_still_bounded(self):
235
+ # The exemption is call-side and outcome-blind, so it MUST be capped:
236
+ # a dead run polled forever would otherwise be bounded only by the
237
+ # client's own timeout.
238
+ ap = load_proxy()
239
+ ap.PROXY_WAIT_POLL_MAX_STREAK = 5
240
+ m = ap.SessionMonitor(context_window=131072)
241
+ msgs = [{"role": "user", "content": "start"}]
242
+ for _ in range(40):
243
+ self._poll(ap, m, self.FP, msgs)
244
+ self.assertGreater(
245
+ m.tool_state_stagnation_streak, 0,
246
+ "past the cap the normal guards must resume",
247
+ )
248
+
249
+ def test_a_real_spin_still_stagnates(self):
250
+ ap = load_proxy()
251
+ m = ap.SessionMonitor(context_window=131072)
252
+ msgs = [{"role": "user", "content": "start"}]
253
+ for _ in range(10):
254
+ self._poll(ap, m, "bash:samecmd", msgs)
255
+ self.assertGreater(m.tool_state_stagnation_streak, 0)
256
+
257
+ def test_the_streak_resets_when_the_agent_does_something_else(self):
258
+ ap = load_proxy()
259
+ m = ap.SessionMonitor(context_window=131072)
260
+ msgs = [{"role": "user", "content": "start"}]
261
+ for _ in range(5):
262
+ self._poll(ap, m, self.FP, msgs)
263
+ self.assertEqual(m.wait_poll_streak, 5)
264
+ self._poll(ap, m, "edit:file", msgs)
265
+ self.assertEqual(m.wait_poll_streak, 0)
266
+
267
+
268
+ class HelperTest(_Base):
269
+ def test_fingerprint_names_are_unpacked(self):
270
+ ap = load_proxy()
271
+ self.assertEqual(
272
+ ap._fingerprint_tool_names("uap-router_deliver:abc123|bash:def"),
273
+ {"uap-router_deliver", "bash"},
274
+ )
275
+
276
+ def test_is_wait_poll_only_requires_all(self):
277
+ ap = load_proxy()
278
+ self.assertTrue(ap._is_wait_poll_only({"uap-router_deliver"}))
279
+ self.assertFalse(ap._is_wait_poll_only({"uap-router_deliver", "bash"}))
280
+ self.assertFalse(ap._is_wait_poll_only(set()))
281
+
282
+ def test_hint_names_drop_wait_tools_but_keep_spinners(self):
283
+ # The load-bearing mixed case: the hint must still fire, naming only
284
+ # the tool that is actually spinning.
285
+ ap = load_proxy()
286
+ self.assertEqual(
287
+ ap._spinning_cycling_names(["uap-router_deliver", "read"]), ["read"]
288
+ )
289
+ self.assertEqual(ap._spinning_cycling_names(["uap-router_deliver"]), [])
290
+
291
+
292
+ if __name__ == "__main__":
293
+ unittest.main()
@@ -113,3 +113,57 @@ class SchemaDiffGateTest(unittest.TestCase):
113
113
 
114
114
  if __name__ == "__main__":
115
115
  unittest.main()
116
+
117
+
118
+ class MergeVerbatimTest(unittest.TestCase):
119
+ """2026-08-16 pay2u #3153 incident: a conflict-resolution merge staged
120
+ migration files that arrived VERBATIM from the already-merged base branch,
121
+ and the gate demanded a schema-diff re-pass for content this branch never
122
+ authored. merge_verbatim() must exempt staged files whose blob equals the
123
+ MERGE_HEAD version — and must NOT exempt files the merge actually edited."""
124
+
125
+ def setUp(self):
126
+ self._tmp = tempfile.TemporaryDirectory(prefix="schema-gate-merge-")
127
+ self.root = Path(self._tmp.name)
128
+ g = lambda *a: subprocess.run(
129
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t", *a],
130
+ cwd=self.root, check=True, capture_output=True,
131
+ )
132
+ g("init", "-q", "-b", "main")
133
+ (self.root / "migrations").mkdir()
134
+ (self.root / "base.txt").write_text("base")
135
+ g("add", "-A")
136
+ g("commit", "-q", "-m", "init")
137
+ # Feature branch diverges without touching migrations.
138
+ g("checkout", "-q", "-b", "feature")
139
+ (self.root / "feature.txt").write_text("feature work")
140
+ g("add", "-A")
141
+ g("commit", "-q", "-m", "feature")
142
+ # Main gains a watched migration (reviewed there).
143
+ g("checkout", "-q", "main")
144
+ (self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id int);")
145
+ g("add", "-A")
146
+ g("commit", "-q", "-m", "migration on main")
147
+ # Merge main INTO feature: migration arrives verbatim, merge left open
148
+ # (no commit) so MERGE_HEAD exists and the file is staged.
149
+ g("checkout", "-q", "feature")
150
+ subprocess.run(
151
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t", "merge", "--no-commit", "--no-ff", "main"],
152
+ cwd=self.root, check=True, capture_output=True,
153
+ )
154
+
155
+ def tearDown(self):
156
+ self._tmp.cleanup()
157
+
158
+ def test_verbatim_incoming_migration_is_exempt(self):
159
+ _, allowed, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
160
+ self.assertTrue(allowed, f"verbatim merge-incoming migration should not gate: {reason}")
161
+
162
+ def test_merge_edited_migration_still_gates(self):
163
+ # Editing the migration during the merge makes it THIS branch's change.
164
+ (self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id bigint);")
165
+ subprocess.run(["git", "add", "migrations/001_add_table.sql"], cwd=self.root, check=True)
166
+ _, allowed, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
167
+ self.assertFalse(allowed, "a migration edited during the merge must still gate")
168
+ self.assertIn("schema-diff", reason)
169
+