annoscript 0.1.0__py3-none-any.whl

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.
Files changed (128) hide show
  1. annoscript/__init__.py +1 -0
  2. annoscript/_backend_profiles.py +17 -0
  3. annoscript/_client_common.py +287 -0
  4. annoscript/_data.py +37 -0
  5. annoscript/_dispatch_pool.py +47 -0
  6. annoscript/_env.py +19 -0
  7. annoscript/_examples.py +34 -0
  8. annoscript/_expressions.py +93 -0
  9. annoscript/_plan_viewer.html +953 -0
  10. annoscript/_remote_jobs.py +211 -0
  11. annoscript/_request_adapters.py +71 -0
  12. annoscript/_speculation.py +321 -0
  13. annoscript/_test_artifacts.py +33 -0
  14. annoscript/_test_evaluation_http.py +117 -0
  15. annoscript/_test_ir_runtime.py +138 -0
  16. annoscript/_test_job_process.py +166 -0
  17. annoscript/_test_plan_runtime.py +331 -0
  18. annoscript/_test_remote_client.py +46 -0
  19. annoscript/_test_remote_server.py +75 -0
  20. annoscript/_test_synthetic.py +67 -0
  21. annoscript/anno.py +270 -0
  22. annoscript/backends/__init__.py +13 -0
  23. annoscript/backends/_batch_wire.py +26 -0
  24. annoscript/backends/_bounded_grammar.py +97 -0
  25. annoscript/backends/_example_layout.py +23 -0
  26. annoscript/backends/_generation_policy.py +116 -0
  27. annoscript/backends/_grammar.py +83 -0
  28. annoscript/backends/_preview.py +13 -0
  29. annoscript/backends/_prompt.py +115 -0
  30. annoscript/backends/_shared_rules.py +38 -0
  31. annoscript/backends/adapters/__init__.py +17 -0
  32. annoscript/backends/adapters/chat.py +39 -0
  33. annoscript/backends/adapters/decision.py +119 -0
  34. annoscript/backends/base.py +427 -0
  35. annoscript/backends/config.py +67 -0
  36. annoscript/backends/decision.py +150 -0
  37. annoscript/backends/llm.py +338 -0
  38. annoscript/client.py +247 -0
  39. annoscript/client_tests.py +17 -0
  40. annoscript/compiler.py +708 -0
  41. annoscript/cross_version_tests.py +68 -0
  42. annoscript/fixtures/request-adapters-v1.json +4741 -0
  43. annoscript/formatter.py +171 -0
  44. annoscript/grammar_tests.py +176 -0
  45. annoscript/inspect.py +77 -0
  46. annoscript/ir.py +458 -0
  47. annoscript/job_cross_version_tests.py +118 -0
  48. annoscript/job_protocol.py +180 -0
  49. annoscript/jobs.py +255 -0
  50. annoscript/metrics.py +153 -0
  51. annoscript/p0_grammar_tests.py +147 -0
  52. annoscript/passes/__init__.py +40 -0
  53. annoscript/passes/_analysis.py +178 -0
  54. annoscript/passes/control_flow.py +68 -0
  55. annoscript/passes/dead_assignments.py +44 -0
  56. annoscript/passes/folding.py +104 -0
  57. annoscript/passes/propagation.py +53 -0
  58. annoscript/passes/temporaries.py +45 -0
  59. annoscript/plan.py +542 -0
  60. annoscript/plan_formatter.py +452 -0
  61. annoscript/plan_lowering.py +571 -0
  62. annoscript/preview.py +115 -0
  63. annoscript/remote.py +304 -0
  64. annoscript/runtime.py +861 -0
  65. annoscript/server/__init__.py +1 -0
  66. annoscript/server/__main__.py +49 -0
  67. annoscript/server/_plan_cache.py +79 -0
  68. annoscript/server/api.py +146 -0
  69. annoscript/server/metrics.py +140 -0
  70. annoscript/server/migrate.py +61 -0
  71. annoscript/server/releases.py +143 -0
  72. annoscript/server/store.py +429 -0
  73. annoscript/server/worker.py +290 -0
  74. annoscript/substring_tests.py +79 -0
  75. annoscript/test_advisory_lengths.py +115 -0
  76. annoscript/test_backends.py +181 -0
  77. annoscript/test_client.py +349 -0
  78. annoscript/test_compact_batch.py +95 -0
  79. annoscript/test_compiler.py +444 -0
  80. annoscript/test_decision_adapters.py +410 -0
  81. annoscript/test_decision_wire.py +190 -0
  82. annoscript/test_diagnostics.py +97 -0
  83. annoscript/test_engineering_p0.py +191 -0
  84. annoscript/test_environment.py +65 -0
  85. annoscript/test_example_backends.py +251 -0
  86. annoscript/test_examples.py +149 -0
  87. annoscript/test_field_names.py +221 -0
  88. annoscript/test_formatter.py +153 -0
  89. annoscript/test_fusion_scheduling.py +109 -0
  90. annoscript/test_generation_policy.py +180 -0
  91. annoscript/test_ir.py +148 -0
  92. annoscript/test_jobs.py +371 -0
  93. annoscript/test_jobs_server.py +511 -0
  94. annoscript/test_llm_batch.py +428 -0
  95. annoscript/test_metrics.py +59 -0
  96. annoscript/test_metrics_server.py +261 -0
  97. annoscript/test_minimal_fusion.py +59 -0
  98. annoscript/test_nullable_generate.py +169 -0
  99. annoscript/test_passes.py +408 -0
  100. annoscript/test_plan.py +522 -0
  101. annoscript/test_plan_constraints.py +305 -0
  102. annoscript/test_plan_formatter.py +272 -0
  103. annoscript/test_prompt_layout.py +140 -0
  104. annoscript/test_qwen_backend.py +181 -0
  105. annoscript/test_qwen_pilot_regression.py +180 -0
  106. annoscript/test_release_client.py +20 -0
  107. annoscript/test_remote.py +308 -0
  108. annoscript/test_request_adapters.py +295 -0
  109. annoscript/test_requirements.py +228 -0
  110. annoscript/test_rule_dedup.py +67 -0
  111. annoscript/test_runtime.py +314 -0
  112. annoscript/test_runtime_client.py +133 -0
  113. annoscript/test_source_fields.py +111 -0
  114. annoscript/test_speculation.py +458 -0
  115. annoscript/test_substring_backend.py +81 -0
  116. annoscript/test_synthetic_example.py +61 -0
  117. annoscript-0.1.0.dist-info/METADATA +145 -0
  118. annoscript-0.1.0.dist-info/RECORD +128 -0
  119. annoscript-0.1.0.dist-info/WHEEL +4 -0
  120. annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
  121. annoscript_examples/__init__.py +1 -0
  122. annoscript_examples/fixtures.py +3 -0
  123. annoscript_examples/request.execution_plan.json +3017 -0
  124. annoscript_examples/request.json +858 -0
  125. annoscript_examples/request.py +37 -0
  126. annoscript_examples/request_preview.decision.json +23 -0
  127. annoscript_examples/request_preview.generation.json +20 -0
  128. annoscript_examples/run.py +24 -0
@@ -0,0 +1,511 @@
1
+ """Offline SQLite, API and real Runtime integration; no external services."""
2
+
3
+ import asyncio
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from contextlib import closing
6
+ from copy import deepcopy
7
+ import json
8
+ from pathlib import Path
9
+ import sqlite3
10
+ import tempfile
11
+ import unittest
12
+ from unittest.mock import patch
13
+
14
+ import httpx
15
+ from fastapi.testclient import TestClient
16
+
17
+ from annoscript._client_common import Result, ResultError
18
+ from annoscript.backends import BackendFatalError
19
+ from annoscript.backends.base import InferenceError
20
+ from annoscript.compiler import compile_program
21
+ from annoscript.job_protocol import cursor_for, validate_page
22
+ from annoscript.jobs import AnnoJobClient, RemoteSubmissionError
23
+ from annoscript.remote import ir_sha256
24
+ from annoscript.server.api import create_app
25
+ from annoscript.server.releases import Releases
26
+ from annoscript.server.store import ServiceError, Store
27
+ from annoscript.server.worker import Worker
28
+ from annoscript.test_jobs import Clock, generate
29
+ from annoscript.test_runtime import FakeBackend
30
+
31
+
32
+ def configurations(**policy):
33
+ return {"test": {"decision": {"base_url": "http://offline.invalid", "model": "fixture", "revision": "fixed-1"},
34
+ "generation": {"base_url": "http://offline.invalid", "model": "fixture", "revision": "fixed-1"},
35
+ "policy": policy}}
36
+
37
+
38
+ def payload(states=("a", "b"), *, mode="eager", program=generate, **options):
39
+ ir = compile_program(program).to_dict()
40
+ return {"protocol_version": 1, "ir": ir, "ir_sha256": ir_sha256(ir), "mode": mode,
41
+ "inputs": [{"index": i * 2, "state": value} for i, value in enumerate(states)], **options}
42
+
43
+
44
+ def input_keys(anno):
45
+ result = []
46
+ for key in anno.state:
47
+ result = result + [key]
48
+ return result
49
+
50
+
51
+ class StoreTests(unittest.TestCase):
52
+ def setUp(self):
53
+ self.folder = tempfile.TemporaryDirectory()
54
+ self.addCleanup(self.folder.cleanup)
55
+ self.path = Path(self.folder.name, "jobs.sqlite")
56
+ self.clock = Clock()
57
+ self.store = Store(self.path, clock=self.clock)
58
+ self.releases = Releases(configurations(), "test")
59
+
60
+ def submit(self, states=("a", "b"), key="job", **options):
61
+ return self.store.submit("alice", key, payload(states, **options), self.releases)
62
+
63
+ def claim(self):
64
+ return self.store.claim("worker", self.releases)
65
+
66
+ def test_repeated_and_concurrent_idempotency_and_principal_isolation(self):
67
+ request = payload()
68
+ with ThreadPoolExecutor(max_workers=6) as pool:
69
+ jobs = list(pool.map(lambda _: self.store.submit("alice", "same", request, self.releases), range(12)))
70
+ self.assertEqual(len({j["job_id"] for j in jobs}), 1)
71
+ bob = self.store.submit("bob", "same", request, self.releases)
72
+ self.assertNotEqual(bob["job_id"], jobs[0]["job_id"])
73
+ with self.assertRaises(ServiceError) as caught:
74
+ self.store.status("bob", jobs[0]["job_id"])
75
+ self.assertEqual(caught.exception.status, 404)
76
+ with self.assertRaises(ServiceError) as caught:
77
+ self.store.submit("alice", "same", payload(("changed",)), self.releases)
78
+ self.assertEqual(caught.exception.code, "idempotency_conflict")
79
+
80
+ def test_new_default_does_not_rebind_repeated_submission(self):
81
+ original = self.submit()
82
+ config = configurations()
83
+ config["test"]["generation"]["revision"] = "fixed-2"
84
+ changed = Releases(config, "test")
85
+ old = self.store.submit("alice", "job", payload(), changed)
86
+ self.assertEqual(old["release_id"], original["release_id"])
87
+ self.clock.now += 1
88
+ fresh = self.store.submit("alice", "new", payload(), changed)
89
+ self.assertNotEqual(fresh["release_id"], original["release_id"])
90
+ self.store.claim("worker", changed)
91
+ self.assertEqual(self.store.status("alice", original["job_id"])["pause_reason"]["code"], "release_unavailable")
92
+
93
+ def test_profile_normalization_version_binding_and_unavailable_release(self):
94
+ from annoscript.server.releases import build_sha256
95
+ original = self.submit()
96
+ config = configurations()
97
+ config["test"]["generation"]["profile"] = "legacy"
98
+ self.assertEqual(Releases(config, "test").default, self.releases.default)
99
+ config["test"]["generation"]["profile"] = "qwen_vllm"
100
+ changed = Releases(config, "test")
101
+ self.assertNotEqual(changed.default, self.releases.default)
102
+ replay = self.store.submit("alice", "job", payload(), changed)
103
+ self.assertEqual(replay["release_id"], original["release_id"])
104
+ self.assertIsNone(self.store.claim("worker", changed))
105
+ self.assertEqual(self.store.status("alice", original["job_id"])["pause_reason"]["code"], "release_unavailable")
106
+ config["test"]["disable_thinking"] = False
107
+ self.assertNotEqual(Releases(config, "test").default, changed.default)
108
+ # Grammar lives in source included by the existing build digest.
109
+ before = build_sha256()
110
+ read_bytes = Path.read_bytes
111
+ def changed_grammar(path):
112
+ content = read_bytes(path)
113
+ return content + b"\n# changed grammar\n" if path.name == "_grammar.py" else content
114
+ with patch.object(Path, "read_bytes", changed_grammar):
115
+ self.assertNotEqual(before, build_sha256())
116
+
117
+ def test_invalid_release_profile_rejected(self):
118
+ for profile in (None, "", "unknown", 1, False, []):
119
+ config = configurations()
120
+ config["test"]["generation"]["profile"] = profile
121
+ with self.subTest(profile=profile), self.assertRaises(ValueError):
122
+ Releases(config, "test")
123
+ config = configurations()
124
+ config["test"]["decision"]["profile"] = "qwen_vllm"
125
+ with self.assertRaises(ValueError):
126
+ Releases(config, "test")
127
+
128
+ def test_batch_configuration_digest_validation_and_existing_job_binding(self):
129
+ from annoscript.backends.base import LLM_BATCH_DEFAULTS
130
+ original = self.submit()
131
+ for name, default in LLM_BATCH_DEFAULTS.items():
132
+ config = configurations()
133
+ config["test"]["runtime"] = {name: default}
134
+ self.assertEqual(Releases(config, "test").default, self.releases.default)
135
+ config["test"]["runtime"][name] = 0 if name == "llm_batch_repair_attempts" else default + 1
136
+ changed = Releases(config, "test")
137
+ self.assertNotEqual(changed.default, self.releases.default)
138
+ self.assertEqual(self.store.submit("alice", "job", payload(), changed)["release_id"], original["release_id"])
139
+ for invalid in (True, -1, 1.5, None, 2 if name == "llm_batch_repair_attempts" else 0):
140
+ config["test"]["runtime"][name] = invalid
141
+ with self.subTest(name=name, invalid=invalid), self.assertRaises(ValueError):
142
+ Releases(config, "test")
143
+
144
+ def test_restart_reclaim_and_stale_result_fencing(self):
145
+ job = self.submit(("a",))
146
+ first = self.claim()
147
+ self.clock.now += 61
148
+ restarted = Store(self.path, clock=self.clock)
149
+ second = restarted.claim("replacement", self.releases)
150
+ self.assertEqual(second["attempt"], 2)
151
+ self.assertNotEqual(first["token"], second["token"])
152
+ self.assertFalse(self.store.finish(first, Result(0, "success", "stale")))
153
+ self.assertTrue(restarted.finish(second, Result(0, "success", None)))
154
+ page = restarted.results("alice", job["job_id"])
155
+ self.assertTrue(page["done"])
156
+ self.assertIsNone(page["results"][0]["value"])
157
+ self.assertEqual(page["results"][0]["attempts"], 2)
158
+
159
+ def test_crash_after_result_commit_never_reexecutes(self):
160
+ job = self.submit(("a",))
161
+ claim = self.claim()
162
+ self.store.finish(claim, Result(0, "success", {"saved": True}))
163
+ self.clock.now += 61
164
+ self.assertIsNone(Store(self.path, clock=self.clock).claim("replacement", self.releases))
165
+ self.assertEqual(self.store.lookup("alice", "job")["state"], "completed")
166
+
167
+ def test_result_pages_completion_order_and_cursor_validation(self):
168
+ job = self.submit()
169
+ first, second = self.claim(), self.claim()
170
+ empty = self.store.results("alice", job["job_id"])
171
+ self.assertFalse(empty["done"])
172
+ self.store.finish(second, Result(2, "success", [None, "中文"]))
173
+ page = self.store.results("alice", job["job_id"], limit=1)
174
+ validate_page(page, job["job_id"])
175
+ self.assertEqual(page["results"][0]["index"], 2)
176
+ self.assertFalse(page["done"])
177
+ self.store.finish(first, Result(0, "error", error=ResultError("invalid_response", "invalid")))
178
+ final = self.store.results("alice", job["job_id"], page["next_cursor"])
179
+ validate_page(final, job["job_id"], page["next_cursor"])
180
+ self.assertTrue(final["done"])
181
+ self.assertEqual(final["results"][0]["index"], 0)
182
+ self.assertEqual(final, self.store.results("alice", job["job_id"], page["next_cursor"]))
183
+ for cursor in ("bad", cursor_for("another", 0), cursor_for(job["job_id"], 100)):
184
+ with self.assertRaises(ServiceError):
185
+ self.store.results("alice", job["job_id"], cursor)
186
+
187
+ def test_retry_exhaustion_pause_and_idempotent_resume(self):
188
+ job = self.submit()
189
+ first, second = self.claim(), self.claim()
190
+ self.store.finish(second, Result(2, "success", "kept"))
191
+ error = {"code": "model_timeout", "message": "temporary"}
192
+ for n in range(3):
193
+ self.store.attempt_failed(first, error)
194
+ if n < 2:
195
+ first = self.claim()
196
+ paused = self.store.status("alice", job["job_id"])
197
+ self.assertEqual(paused["state"], "paused")
198
+ self.assertEqual(paused["counts"]["completed"], 1)
199
+ self.assertIsNone(self.claim())
200
+ self.store.resume("alice", job["job_id"], "resume", paused["pause_generation"], self.releases)
201
+ final = self.claim()
202
+ self.assertEqual((final["index"], final["attempt"], final["round_attempt"]), (0, 4, 1))
203
+ # Repeating a lost resume response cannot grant another retry budget.
204
+ self.store.resume("alice", job["job_id"], "resume", paused["pause_generation"], self.releases)
205
+ self.store.finish(final, Result(0, "success", "fixed"))
206
+ self.assertEqual(self.store.status("alice", job["job_id"])["state"], "completed")
207
+ with self.assertRaises(ServiceError):
208
+ self.store.resume("alice", job["job_id"], "new-resume", paused["pause_generation"], self.releases)
209
+
210
+ def test_abandoned_attempts_consume_budget(self):
211
+ job = self.submit(("a",))
212
+ for n in range(3):
213
+ self.assertIsNotNone(self.claim())
214
+ self.clock.now += 61
215
+ self.store.maintain()
216
+ self.assertIsNone(self.claim())
217
+ self.assertEqual(self.store.status("alice", job["job_id"])["state"], "paused")
218
+
219
+ def test_cancellation_preserves_results_and_rejects_late_worker(self):
220
+ job = self.submit()
221
+ first, second = self.claim(), self.claim()
222
+ self.store.finish(first, Result(0, "success", "done"))
223
+ cancelled = self.store.cancel("alice", job["job_id"])
224
+ self.assertEqual(cancelled["state"], "cancelled")
225
+ self.assertFalse(self.store.finish(second, Result(2, "success", "late")))
226
+ rows = self.store.results("alice", job["job_id"])["results"]
227
+ self.assertEqual(rows[0]["value"], "done")
228
+ self.assertEqual(rows[1]["error"]["code"], "cancelled")
229
+ self.assertEqual(cancelled, self.store.cancel("alice", job["job_id"]))
230
+
231
+ def test_session_close_catches_unknown_job_and_prevents_late_submit(self):
232
+ session = self.store.create_session("alice", "session")
233
+ self.assertEqual(session, self.store.create_session("alice", "session"))
234
+ job = self.submit(session_id=session["session_id"])
235
+ claim = self.claim()
236
+ self.store.close_session("alice", session["session_id"])
237
+ self.assertEqual(self.store.lookup("alice", "job")["state"], "cancelled")
238
+ self.assertFalse(self.store.renew(claim))
239
+ with self.assertRaises(ServiceError):
240
+ self.submit(key="late", session_id=session["session_id"])
241
+ with self.assertRaises(ServiceError):
242
+ self.store.heartbeat_session("alice", session["session_id"])
243
+ self.assertEqual(self.store.create_session("alice", "session")["state"], "closed")
244
+
245
+ def test_session_expiry_cancels_even_after_process_restart(self):
246
+ session = self.store.create_session("alice", "session")
247
+ job = self.submit(session_id=session["session_id"])
248
+ self.clock.now += 200
249
+ self.store.heartbeat_session("alice", session["session_id"])
250
+ self.clock.now += 200
251
+ self.assertEqual(self.store.status("alice", job["job_id"])["state"], "queued")
252
+ self.clock.now += 101
253
+ restarted = Store(self.path, clock=self.clock)
254
+ self.assertIsNone(restarted.claim("worker", self.releases))
255
+ rows = restarted.results("alice", job["job_id"])["results"]
256
+ self.assertEqual({row["error"]["code"] for row in rows}, {"session_expired"})
257
+
258
+ def test_close_submit_race_leaves_no_runnable_orphan(self):
259
+ session = self.store.create_session("alice", "session")
260
+ def submit():
261
+ try:
262
+ return self.submit(session_id=session["session_id"])
263
+ except ServiceError as error:
264
+ self.assertEqual(error.code, "session_closed")
265
+ with ThreadPoolExecutor(max_workers=2) as pool:
266
+ a = pool.submit(submit)
267
+ b = pool.submit(self.store.close_session, "alice", session["session_id"])
268
+ a.result(), b.result()
269
+ self.assertIsNone(self.claim())
270
+
271
+ def test_retention_tombstone_and_paused_job_retention(self):
272
+ job = self.submit()
273
+ self.store.cancel("alice", job["job_id"])
274
+ other = self.submit(("c",), key="paused")
275
+ self.store.attempt_failed(self.claim(), {"code": "authentication_failed", "message": "bad config"}, pause=True)
276
+ self.clock.now += 8 * 86400
277
+ for operation in (lambda: self.store.status("alice", job["job_id"]),
278
+ lambda: self.submit(), lambda: self.store.lookup("alice", "job")):
279
+ with self.assertRaises(ServiceError) as error:
280
+ operation()
281
+ self.assertEqual(error.exception.status, 410)
282
+ self.assertEqual(self.store.status("alice", other["job_id"])["state"], "paused")
283
+
284
+ def test_schema_and_input_structure_rejected(self):
285
+ with closing(sqlite3.connect(self.path)) as db:
286
+ db.execute("PRAGMA user_version=99")
287
+ with self.assertRaises(RuntimeError):
288
+ Store(self.path)
289
+ with closing(sqlite3.connect(self.path)) as db:
290
+ db.execute("PRAGMA user_version=2")
291
+ bad = payload()
292
+ bad["inputs"][1]["index"] = 0
293
+ with self.assertRaises(ValueError):
294
+ self.store.submit("alice", "bad", bad, self.releases)
295
+
296
+ def test_concurrent_database_initialization_is_atomic(self):
297
+ path = Path(self.folder.name, "new.sqlite")
298
+ with ThreadPoolExecutor(max_workers=6) as pool:
299
+ stores = list(pool.map(lambda _: Store(path, clock=self.clock), range(6)))
300
+ self.assertEqual(len(stores), 6)
301
+ self.assertEqual(stores[0].create_session("alice", "key"), stores[-1].create_session("alice", "key"))
302
+
303
+ def test_initialization_recovers_from_transient_journal_busy(self):
304
+ connect = sqlite3.connect
305
+
306
+ class BusyOnce(sqlite3.Connection):
307
+ def execute(self, statement, *args, **kwargs):
308
+ if statement == "PRAGMA journal_mode=WAL" and not getattr(self, "injected", False):
309
+ self.injected = True
310
+ error = sqlite3.OperationalError("database is locked")
311
+ error.sqlite_errorcode = sqlite3.SQLITE_BUSY | (1 << 8)
312
+ raise error
313
+ return super().execute(statement, *args, **kwargs)
314
+
315
+ with patch("annoscript.server.store.sqlite3.connect",
316
+ side_effect=lambda *a, **kw: connect(*a, factory=BusyOnce, **kw)):
317
+ store = Store(Path(self.folder.name, "busy.sqlite"), clock=self.clock)
318
+ first = store.create_session("alice", "stable-key")
319
+ self.assertEqual(first, store.create_session("alice", "stable-key"))
320
+
321
+ def test_initialization_preserves_non_busy_database_error(self):
322
+ connect = sqlite3.connect
323
+ error = sqlite3.OperationalError("injected storage error")
324
+ error.sqlite_errorcode = sqlite3.SQLITE_IOERR
325
+ attempts = []
326
+
327
+ class Broken(sqlite3.Connection):
328
+ def execute(self, statement, *args, **kwargs):
329
+ if statement == "PRAGMA journal_mode=WAL":
330
+ attempts.append(statement)
331
+ raise error
332
+ return super().execute(statement, *args, **kwargs)
333
+
334
+ with patch("annoscript.server.store.sqlite3.connect",
335
+ side_effect=lambda *a, **kw: connect(*a, factory=Broken, **kw)):
336
+ with self.assertRaises(sqlite3.OperationalError) as caught:
337
+ Store(Path(self.folder.name, "broken.sqlite"), clock=self.clock)
338
+ self.assertIs(caught.exception, error)
339
+ self.assertEqual(len(attempts), 1)
340
+
341
+ def test_input_key_order_conflicts_even_when_values_match(self):
342
+ self.submit(({"a": 1, "b": 2},))
343
+ with self.assertRaises(ServiceError) as error:
344
+ self.submit(({"b": 2, "a": 1},))
345
+ self.assertEqual(error.exception.code, "idempotency_conflict")
346
+
347
+ def test_foreign_session_and_foreign_resume_are_rejected(self):
348
+ session = self.store.create_session("bob", "session")
349
+ with self.assertRaises(ServiceError) as error:
350
+ self.submit(session_id=session["session_id"])
351
+ self.assertEqual(error.exception.status, 404)
352
+ job = self.submit(("a",))
353
+ self.store.attempt_failed(self.claim(), {"code": "model_timeout", "message": "timeout"}, pause=True)
354
+ with self.assertRaises(ServiceError) as error:
355
+ self.store.resume("bob", job["job_id"], "resume", 1, self.releases)
356
+ self.assertEqual(error.exception.status, 404)
357
+
358
+
359
+ class ExecutionTests(unittest.TestCase):
360
+ def setUp(self):
361
+ self.folder = tempfile.TemporaryDirectory()
362
+ self.addCleanup(self.folder.cleanup)
363
+ self.clock = Clock()
364
+ self.store = Store(Path(self.folder.name, "jobs.sqlite"), clock=self.clock)
365
+ self.releases = Releases(configurations(), "test")
366
+ self.http = TestClient(create_app(self.store, self.releases, {"test-token": "alice"}))
367
+ self.addCleanup(self.http.close)
368
+
369
+ def client(self):
370
+ def handle(request):
371
+ return self.http.request(request.method, str(request.url), content=request.content, headers=request.headers)
372
+ client = AnnoJobClient("http://testserver", "test-token", transport=httpx.MockTransport(handle))
373
+ self.addCleanup(client.close)
374
+ return client
375
+
376
+ def run_claim(self, factory=None):
377
+ if factory is None:
378
+ def factory(manifest):
379
+ backend = FakeBackend()
380
+ return backend, backend
381
+ worker = Worker(self.store, self.releases, backend_factory=factory, random_source=lambda: 0)
382
+ claim = self.store.claim("worker", self.releases)
383
+ self.assertIsNotNone(claim)
384
+ asyncio.run(worker.execute(claim))
385
+ return claim
386
+
387
+ def test_http_ir_deserialization_runtime_and_indexed_results(self):
388
+ client = self.client()
389
+ ir = compile_program(generate)
390
+ for mode in ("eager", "strict"):
391
+ job = client.submit_ir(ir, [{"index": 7, "state": "hello"}], idempotency_key=mode, mode=mode)
392
+ self.run_claim()
393
+ page = client.fetch_results(job)
394
+ self.assertTrue(page["done"])
395
+ self.assertEqual((page["results"][0]["index"], page["results"][0]["value"]), (7, "hello"))
396
+ self.assertEqual(client.lookup(mode)["job_id"], job["job_id"])
397
+
398
+ def test_input_object_iteration_order_survives_http_journal_and_runtime(self):
399
+ client = self.client()
400
+ job = client.submit_ir(compile_program(input_keys), [
401
+ {"index": 0, "state": {"a": 1, "b": 2}},
402
+ {"index": 1, "state": {"b": 2, "a": 1}},
403
+ ], idempotency_key="ordered-keys")
404
+ self.run_claim()
405
+ self.run_claim()
406
+ self.assertEqual([r["value"] for r in client.fetch_results(job)["results"]], [["a", "b"], ["b", "a"]])
407
+
408
+ def test_http_auth_bad_json_version_digest_and_limits(self):
409
+ response = self.http.get("/v1/jobs/unknown")
410
+ self.assertEqual(response.status_code, 401)
411
+ headers = {"Authorization": "Bearer test-token", "Content-Type": "application/json", "Idempotency-Key": "bad"}
412
+ for content in (b'{"duplicate":1,"duplicate":2}', b'{"x":NaN}', b'invalid'):
413
+ self.assertEqual(self.http.post("/v1/jobs", content=content, headers=headers).status_code, 400)
414
+ for field, value in (("protocol_version", 2), ("ir_sha256", "wrong")):
415
+ request = payload()
416
+ request[field] = value
417
+ self.assertEqual(self.http.post("/v1/jobs", json=request, headers=headers).status_code, 400)
418
+ oversized = payload(["a"] * 501)
419
+ self.assertEqual(self.http.post("/v1/jobs", json=oversized, headers=headers).status_code, 413)
420
+
421
+ def test_transient_failures_pause_and_can_be_resumed(self):
422
+ client = self.client()
423
+ job = client.submit_ir(compile_program(generate), [{"index": 0, "state": "hello"}], idempotency_key="retry")
424
+ class Failing(FakeBackend):
425
+ async def infer(self, *args):
426
+ raise InferenceError("limited", code="model_http_error", metadata={"http_status": 429, "retry_after": "2"})
427
+ for n in range(3):
428
+ self.run_claim(lambda _: (Failing(), Failing()))
429
+ self.clock.now += 2
430
+ paused = client.status(job)
431
+ self.assertEqual(paused["state"], "paused")
432
+ self.assertEqual(client.fetch_results(job)["results"], [])
433
+ client.resume(job, pause_generation=paused["pause_generation"], idempotency_key="resume")
434
+ self.run_claim()
435
+ self.assertEqual(client.fetch_results(job)["results"][0]["attempts"], 4)
436
+
437
+ def test_real_http_backend_retry_metadata_reaches_journal(self):
438
+ from annoscript.backends.llm import LLMAPIBackend
439
+ client = self.client()
440
+ job = client.submit_ir(compile_program(generate), [{"index": 0, "state": "hello"}], idempotency_key="headers")
441
+ def factory(manifest):
442
+ llm = LLMAPIBackend("http://offline.invalid", "fixture-token", "fixture-model", profile="qwen_vllm",
443
+ transport=httpx.MockTransport(lambda request: httpx.Response(429, json={}, headers={"Retry-After": "7"})))
444
+ return FakeBackend(), llm
445
+ self.run_claim(factory)
446
+ self.assertIsNone(self.store.claim("worker", self.releases))
447
+ with closing(sqlite3.connect(self.store.path)) as db:
448
+ data = json.loads(db.execute("SELECT metadata FROM attempts WHERE job_id=?", (job["job_id"],)).fetchone()[0])
449
+ self.assertEqual(data, {"http_status": 429, "retry_after": "7"})
450
+ self.clock.now += 7
451
+ self.run_claim()
452
+ self.assertTrue(client.fetch_results(job)["done"])
453
+
454
+ def test_worker_uses_bound_profile_despite_new_default_and_environment(self):
455
+ from annoscript.backends.llm import LLMAPIBackend
456
+ from annoscript.server.worker import backends
457
+ config = configurations()
458
+ config["test"]["generation"]["profile"] = "qwen_vllm"
459
+ qwen = Releases(config, "test")
460
+ job = self.store.submit("alice", "qwen", payload(("hello",)), qwen)
461
+ # Both releases remain available; the server's new default is legacy.
462
+ self.releases.manifests.update(qwen.manifests)
463
+ bodies = []
464
+ def handle(request):
465
+ body = json.loads(request.content)
466
+ bodies.append(body)
467
+ return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": "hello"}}]})
468
+ def create(*args, **kwargs):
469
+ return LLMAPIBackend(*args, **kwargs, transport=httpx.MockTransport(handle))
470
+ with patch.dict("os.environ", {"ANNO_DECISION_AUTH_TOKEN": "fixture", "ANNO_GENERATE_AUTH_TOKEN": "fixture",
471
+ "ANNO_GENERATE_PROFILE": "legacy"}),\
472
+ patch("annoscript.backends.llm.LLMAPIBackend", side_effect=create):
473
+ claim = self.run_claim(backends)
474
+ self.assertEqual(claim["release_id"], job["release_id"])
475
+ self.assertEqual(bodies[0]["chat_template_kwargs"], {"enable_thinking": False})
476
+ self.assertNotIn("thinking", bodies[0])
477
+ self.assertEqual(len(bodies), 1)
478
+ self.assertTrue(self.store.results("alice", job["job_id"])["done"])
479
+
480
+ def test_cancelled_attempt_cannot_start_another_model_call(self):
481
+ from annoscript.server.worker import ObservedBackend
482
+ backend = FakeBackend()
483
+ observed = ObservedBackend(backend, [], lambda: False)
484
+ async def run():
485
+ with self.assertRaises(asyncio.CancelledError):
486
+ await observed.infer(None, None)
487
+ with self.assertRaises(asyncio.CancelledError):
488
+ await observed.infer_batch(None, None)
489
+ asyncio.run(run())
490
+ self.assertEqual(backend.calls, [])
491
+
492
+ def test_auth_failure_pauses_immediately_and_invalid_response_is_terminal(self):
493
+ client = self.client()
494
+ class Auth(FakeBackend):
495
+ async def infer(self, *args):
496
+ raise BackendFatalError("bad token", code="authentication_failed")
497
+ job = client.submit_ir(compile_program(generate), [{"index": 0, "state": "a"}], idempotency_key="auth")
498
+ self.run_claim(lambda _: (Auth(), Auth()))
499
+ self.assertEqual(client.status(job)["state"], "paused")
500
+ client.cancel(job)
501
+ class Invalid(FakeBackend):
502
+ async def infer(self, *args):
503
+ raise InferenceError("invalid output", code="invalid_response")
504
+ job = client.submit_ir(compile_program(generate), [{"index": 0, "state": "a"}], idempotency_key="invalid")
505
+ self.run_claim(lambda _: (Invalid(), Invalid()))
506
+ result = client.fetch_results(job)["results"][0]
507
+ self.assertEqual((result["error"]["code"], result["attempts"]), ("invalid_response", 1))
508
+
509
+
510
+ if __name__ == "__main__":
511
+ unittest.main()