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,429 @@
1
+ """Transactional job journal. All model work happens outside these transactions."""
2
+
3
+ from contextlib import contextmanager
4
+ from pathlib import Path
5
+ import sqlite3
6
+ import time
7
+ import uuid
8
+
9
+ from annoscript.job_protocol import (TERMINAL, cursor_for, cursor_seq, decode, encode,
10
+ request_sha256, validate_request)
11
+ from annoscript.server.metrics import MetricStore, UPGRADE
12
+
13
+
14
+ class ServiceError(Exception):
15
+ def __init__(self, code, message, status=400):
16
+ super().__init__(message)
17
+ self.code, self.status = code, status
18
+
19
+
20
+ SCHEMA_V1 = """
21
+ CREATE TABLE releases (id TEXT PRIMARY KEY, manifest BLOB NOT NULL);
22
+ CREATE TABLE sessions (
23
+ id TEXT PRIMARY KEY, principal TEXT NOT NULL, idem_key TEXT NOT NULL,
24
+ state TEXT NOT NULL, expires REAL NOT NULL, created REAL NOT NULL,
25
+ UNIQUE(principal, idem_key));
26
+ CREATE TABLE jobs (
27
+ id TEXT PRIMARY KEY, principal TEXT NOT NULL, idem_key TEXT NOT NULL,
28
+ request_hash TEXT NOT NULL, request BLOB, ir BLOB, digest TEXT NOT NULL,
29
+ mode TEXT NOT NULL, release_id TEXT NOT NULL REFERENCES releases(id),
30
+ session_id TEXT REFERENCES sessions(id), state TEXT NOT NULL, indices BLOB,
31
+ pause_generation INTEGER NOT NULL DEFAULT 0, pause_reason BLOB,
32
+ seq INTEGER NOT NULL DEFAULT 0, created REAL NOT NULL, updated REAL NOT NULL,
33
+ terminal_at REAL, expired INTEGER NOT NULL DEFAULT 0,
34
+ UNIQUE(principal, idem_key));
35
+ CREATE TABLE inputs (
36
+ job_id TEXT NOT NULL REFERENCES jobs(id), idx INTEGER NOT NULL, state BLOB NOT NULL,
37
+ status TEXT NOT NULL DEFAULT 'pending', round_attempts INTEGER NOT NULL DEFAULT 0,
38
+ total_attempts INTEGER NOT NULL DEFAULT 0, token INTEGER NOT NULL DEFAULT 0,
39
+ lease_until REAL, next_attempt REAL NOT NULL DEFAULT 0,
40
+ PRIMARY KEY(job_id, idx));
41
+ CREATE TABLE attempts (
42
+ job_id TEXT NOT NULL, idx INTEGER NOT NULL, number INTEGER NOT NULL,
43
+ token INTEGER NOT NULL, worker TEXT NOT NULL, status TEXT NOT NULL,
44
+ started REAL NOT NULL, finished REAL, error BLOB, metadata BLOB,
45
+ PRIMARY KEY(job_id, idx, number),
46
+ FOREIGN KEY(job_id, idx) REFERENCES inputs(job_id, idx));
47
+ CREATE TABLE results (
48
+ job_id TEXT NOT NULL, idx INTEGER NOT NULL, seq INTEGER NOT NULL,
49
+ status TEXT NOT NULL, value BLOB NOT NULL, error BLOB NOT NULL, attempts INTEGER NOT NULL,
50
+ PRIMARY KEY(job_id, idx), UNIQUE(job_id, seq),
51
+ FOREIGN KEY(job_id, idx) REFERENCES inputs(job_id, idx));
52
+ CREATE TABLE resume_ops (
53
+ job_id TEXT NOT NULL REFERENCES jobs(id), idem_key TEXT NOT NULL,
54
+ generation INTEGER NOT NULL, PRIMARY KEY(job_id, idem_key));
55
+ CREATE INDEX inputs_ready ON inputs(status, next_attempt);
56
+ CREATE INDEX jobs_sessions ON jobs(session_id, state);
57
+ PRAGMA user_version=1;
58
+ """
59
+
60
+
61
+ SCHEMA = SCHEMA_V1 + UPGRADE
62
+
63
+
64
+ class Store(MetricStore):
65
+ def __init__(self, path, *, clock=time.time, session_ttl=300.0, lease_seconds=60.0,
66
+ retention_seconds=7 * 86400, monotonic=time.monotonic):
67
+ self.path, self.clock, self.monotonic = str(path), clock, monotonic
68
+ try:
69
+ self.clock_epoch = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
70
+ except OSError:
71
+ self.clock_epoch = None
72
+ self.session_ttl, self.lease_seconds, self.retention_seconds = session_ttl, lease_seconds, retention_seconds
73
+ with self._connection() as db:
74
+ # Switching journal mode can return BUSY immediately when several
75
+ # processes initialize the same file. Retry only this startup step.
76
+ deadline = time.monotonic() + 5.0
77
+ while True:
78
+ try:
79
+ db.execute("PRAGMA journal_mode=WAL")
80
+ break
81
+ except sqlite3.OperationalError as error:
82
+ code = getattr(error, "sqlite_errorcode", 0)
83
+ if code & 0xff != sqlite3.SQLITE_BUSY or time.monotonic() >= deadline:
84
+ raise
85
+ time.sleep(0.01)
86
+ db.execute("BEGIN IMMEDIATE")
87
+ version = db.execute("PRAGMA user_version").fetchone()[0]
88
+ if version == 0:
89
+ # Never treat an unversioned existing database as a fresh one.
90
+ if db.execute("SELECT 1 FROM sqlite_master WHERE type='table'").fetchone():
91
+ raise RuntimeError("unversioned nonempty job database")
92
+ for statement in SCHEMA.split(";"):
93
+ if statement.strip():
94
+ db.execute(statement)
95
+ elif version != 2:
96
+ raise RuntimeError(f"unsupported job database schema {version}")
97
+ db.commit()
98
+
99
+ @contextmanager
100
+ def _connection(self):
101
+ db = sqlite3.connect(self.path, timeout=5, isolation_level=None)
102
+ db.row_factory = sqlite3.Row
103
+ db.execute("PRAGMA foreign_keys=ON")
104
+ db.execute("PRAGMA synchronous=FULL")
105
+ try:
106
+ yield db
107
+ finally:
108
+ db.close()
109
+
110
+ @contextmanager
111
+ def _tx(self):
112
+ with self._connection() as db:
113
+ db.execute("BEGIN IMMEDIATE")
114
+ try:
115
+ self._maintenance(db)
116
+ yield db
117
+ except ServiceError:
118
+ # Maintenance remains durable even if the requested operation
119
+ # is rejected. Operations validate before changing their data.
120
+ db.commit()
121
+ raise
122
+ except BaseException:
123
+ db.rollback()
124
+ raise
125
+ else:
126
+ db.commit()
127
+
128
+ def maintain(self):
129
+ with self._tx():
130
+ pass
131
+
132
+ def _job(self, db, job_id, principal=None):
133
+ job = db.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
134
+ if job is None or (principal is not None and job["principal"] != principal):
135
+ raise ServiceError("not_found", "job not found", 404)
136
+ if job["expired"]:
137
+ raise ServiceError("job_expired", "job data has expired", 410)
138
+ return job
139
+
140
+ def _session(self, db, session_id, principal):
141
+ row = db.execute("SELECT * FROM sessions WHERE id=? AND principal=?", (session_id, principal)).fetchone()
142
+ if row is None:
143
+ raise ServiceError("not_found", "session not found", 404)
144
+ return row
145
+
146
+ def _session_value(self, row):
147
+ return {"session_id": row["id"], "state": row["state"], "expires_at": row["expires"],
148
+ "heartbeat_interval": min(30.0, self.session_ttl / 3)}
149
+
150
+ def create_session(self, principal, idem_key):
151
+ with self._tx() as db:
152
+ row = db.execute("SELECT * FROM sessions WHERE principal=? AND idem_key=?", (principal, idem_key)).fetchone()
153
+ if row is None:
154
+ sid, now = uuid.uuid4().hex, self.clock()
155
+ db.execute("INSERT INTO sessions VALUES (?,?,?,?,?,?)", (sid, principal, idem_key, "active", now + self.session_ttl, now))
156
+ row = self._session(db, sid, principal)
157
+ return self._session_value(row)
158
+
159
+ def heartbeat_session(self, principal, session_id):
160
+ with self._tx() as db:
161
+ row = self._session(db, session_id, principal)
162
+ if row["state"] != "active":
163
+ raise ServiceError("session_closed", "session is closed or expired", 409)
164
+ db.execute("UPDATE sessions SET expires=? WHERE id=?", (self.clock() + self.session_ttl, session_id))
165
+ return self._session_value(self._session(db, session_id, principal))
166
+
167
+ def close_session(self, principal, session_id):
168
+ with self._tx() as db:
169
+ row = self._session(db, session_id, principal)
170
+ if row["state"] == "active":
171
+ db.execute("UPDATE sessions SET state='closed' WHERE id=?", (session_id,))
172
+ self._cancel_session_jobs(db, session_id, "client_closed")
173
+ return self._session_value(self._session(db, session_id, principal))
174
+
175
+ def _cancel_session_jobs(self, db, session_id, code):
176
+ for row in db.execute("SELECT * FROM jobs WHERE session_id=? AND state NOT IN ('completed','cancelled','failed')", (session_id,)).fetchall():
177
+ self._terminate(db, row, "cancelled", {"code": code, "message": "client session ended"})
178
+
179
+ def submit(self, principal, idem_key, request, releases):
180
+ request = validate_request(request)
181
+ from annoscript.ir import Program
182
+ try:
183
+ Program.from_json(encode(request["ir"]).decode())
184
+ except (ValueError, TypeError, KeyError, RecursionError) as exc:
185
+ raise ServiceError("invalid_ir", "invalid AnnoScript IR") from exc
186
+ fingerprint = request_sha256(request)
187
+ with self._tx() as db:
188
+ existing = db.execute("SELECT * FROM jobs WHERE principal=? AND idem_key=?", (principal, idem_key)).fetchone()
189
+ if existing:
190
+ if existing["request_hash"] != fingerprint:
191
+ raise ServiceError("idempotency_conflict", "idempotency key is bound to another request", 409)
192
+ return self._status(db, self._job(db, existing["id"], principal))
193
+ session_id = request.get("session_id")
194
+ if session_id is not None and self._session(db, session_id, principal)["state"] != "active":
195
+ raise ServiceError("session_closed", "cannot submit to a closed or expired session", 409)
196
+ try:
197
+ release_id, manifest = releases.resolve(request.get("release_id"))
198
+ except KeyError:
199
+ raise ServiceError("release_unavailable", "requested execution release is unavailable", 409) from None
200
+ policy = manifest["policy"]
201
+ if len(request["inputs"]) > policy["max_inputs"] or len(encode(request)) > policy["max_request_bytes"]:
202
+ raise ServiceError("request_too_large", "job exceeds release resource limits", 413)
203
+ db.execute("INSERT OR IGNORE INTO releases VALUES (?,?)", (release_id, encode(manifest, canonical=True)))
204
+ job_id, now = uuid.uuid4().hex, self.clock()
205
+ db.execute("""INSERT INTO jobs
206
+ (id,principal,idem_key,request_hash,request,ir,digest,mode,release_id,session_id,state,indices,created,updated,metrics_available)
207
+ VALUES (?,?,?,?,?,?,?,?,?,?,'queued',?,?,?,1)""",
208
+ (job_id, principal, idem_key, fingerprint, encode(request), encode(request["ir"]), request["ir_sha256"],
209
+ request["mode"], release_id, session_id, encode([v["index"] for v in request["inputs"]]), now, now))
210
+ db.execute("UPDATE jobs SET queued_monotonic=?,clock_epoch=? WHERE id=?",
211
+ (self.monotonic(), self.clock_epoch, job_id))
212
+ db.executemany("INSERT INTO inputs(job_id,idx,state) VALUES (?,?,?)",
213
+ [(job_id, item["index"], encode(item["state"])) for item in request["inputs"]])
214
+ return self._status(db, self._job(db, job_id))
215
+
216
+ def lookup(self, principal, idem_key):
217
+ with self._tx() as db:
218
+ row = db.execute("SELECT id FROM jobs WHERE principal=? AND idem_key=?", (principal, idem_key)).fetchone()
219
+ if row is None:
220
+ raise ServiceError("not_found", "job not found", 404)
221
+ return self._status(db, self._job(db, row["id"], principal))
222
+
223
+ def _status(self, db, job):
224
+ counts = {"success": 0, "error": 0}
225
+ for row in db.execute("SELECT status,COUNT(*) AS n FROM results WHERE job_id=? GROUP BY status", (job["id"],)):
226
+ counts[row["status"]] = row["n"]
227
+ indices = decode(job["indices"])
228
+ return {"job_id": job["id"], "ir_sha256": job["digest"], "release_id": job["release_id"],
229
+ "mode": job["mode"], "input_indices": indices, "state": job["state"],
230
+ "counts": {"total": len(indices), "completed": counts["success"] + counts["error"], **counts},
231
+ "pause_generation": job["pause_generation"],
232
+ "pause_reason": decode(job["pause_reason"]) if job["pause_reason"] is not None else None,
233
+ "created_at": job["created"], "updated_at": job["updated"]}
234
+
235
+ def status(self, principal, job_id):
236
+ with self._tx() as db:
237
+ return self._status(db, self._job(db, job_id, principal))
238
+
239
+ def results(self, principal, job_id, cursor=None, limit=100):
240
+ if type(limit) is not int or not 1 <= limit <= 1000:
241
+ raise ServiceError("invalid_limit", "limit must be between 1 and 1000")
242
+ try:
243
+ after = cursor_seq(cursor, job_id)
244
+ except ValueError as exc:
245
+ raise ServiceError("invalid_cursor", str(exc)) from None
246
+ with self._tx() as db:
247
+ job = self._job(db, job_id, principal)
248
+ if after > job["seq"]:
249
+ raise ServiceError("invalid_cursor", "cursor is beyond the result log")
250
+ rows = db.execute("SELECT * FROM results WHERE job_id=? AND seq>? ORDER BY seq LIMIT ?", (job_id, after, limit)).fetchall()
251
+ items = [{"seq": r["seq"], "index": r["idx"], "status": r["status"], "value": decode(r["value"]),
252
+ "error": decode(r["error"]), "attempts": r["attempts"]} for r in rows]
253
+ last = items[-1]["seq"] if items else after
254
+ more = last < job["seq"]
255
+ return {"protocol_version": 1, "job": self._status(db, job), "results": items,
256
+ "next_cursor": cursor_for(job_id, last), "has_more": more,
257
+ "done": job["state"] in TERMINAL and not more}
258
+
259
+ def release(self, release_id, releases):
260
+ if release_id in releases.manifests:
261
+ return {"release_id": release_id, "manifest": releases.manifests[release_id], "available": True}
262
+ with self._tx() as db:
263
+ row = db.execute("SELECT manifest FROM releases WHERE id=?", (release_id,)).fetchone()
264
+ if row is None:
265
+ raise ServiceError("not_found", "release not found", 404)
266
+ return {"release_id": release_id, "manifest": decode(row["manifest"]), "available": False}
267
+
268
+ def _policy(self, db, job):
269
+ return decode(db.execute("SELECT manifest FROM releases WHERE id=?", (job["release_id"],)).fetchone()[0])["policy"]
270
+
271
+ def _pause(self, db, job_id, reason):
272
+ db.execute("""UPDATE jobs SET state='paused',pause_generation=pause_generation+1,pause_reason=?,updated=?
273
+ WHERE id=? AND state IN ('queued','running')""", (encode(reason), self.clock(), job_id))
274
+
275
+ def _write_result(self, db, job_id, item, status, value, error):
276
+ now = self.clock()
277
+ db.execute("UPDATE jobs SET seq=seq+1,updated=? WHERE id=?", (now, job_id))
278
+ seq = db.execute("SELECT seq FROM jobs WHERE id=?", (job_id,)).fetchone()[0]
279
+ db.execute("INSERT INTO results VALUES (?,?,?,?,?,?,?)", (job_id, item["idx"], seq, status, encode(value), encode(error), item["total_attempts"]))
280
+ db.execute("UPDATE inputs SET status='final',token=token+1,lease_until=NULL WHERE job_id=? AND idx=?", (job_id, item["idx"]))
281
+
282
+ def _terminate(self, db, job, state, error):
283
+ if job["state"] in TERMINAL:
284
+ return
285
+ now = self.clock()
286
+ for item in db.execute("SELECT * FROM inputs WHERE job_id=? AND status!='final' ORDER BY idx", (job["id"],)).fetchall():
287
+ self._write_result(db, job["id"], item, "error", None, error)
288
+ db.execute("UPDATE attempts SET status='cancelled',finished=?,error=? WHERE job_id=? AND status='running'", (now, encode(error), job["id"]))
289
+ db.execute("UPDATE jobs SET state=?,pause_reason=NULL,terminal_at=?,updated=? WHERE id=?", (state, now, now, job["id"]))
290
+
291
+ def cancel(self, principal, job_id):
292
+ with self._tx() as db:
293
+ self._terminate(db, self._job(db, job_id, principal), "cancelled", {"code": "cancelled", "message": "job cancelled"})
294
+ return self._status(db, self._job(db, job_id))
295
+
296
+ def resume(self, principal, job_id, idem_key, generation, releases):
297
+ with self._tx() as db:
298
+ job = self._job(db, job_id, principal)
299
+ old = db.execute("SELECT generation FROM resume_ops WHERE job_id=? AND idem_key=?", (job_id, idem_key)).fetchone()
300
+ if old:
301
+ if old[0] != generation:
302
+ raise ServiceError("idempotency_conflict", "resume key belongs to another pause generation", 409)
303
+ return self._status(db, job)
304
+ if job["state"] != "paused" or job["pause_generation"] != generation:
305
+ raise ServiceError("pause_conflict", "job is not at the requested pause generation", 409)
306
+ if job["release_id"] not in releases.manifests:
307
+ raise ServiceError("release_unavailable", "original execution release is unavailable", 409)
308
+ if db.execute("SELECT 1 FROM inputs WHERE job_id=? AND status='running'", (job_id,)).fetchone():
309
+ raise ServiceError("attempts_active", "wait for existing attempts before resuming", 409)
310
+ db.execute("INSERT INTO resume_ops VALUES (?,?,?)", (job_id, idem_key, generation))
311
+ db.execute("UPDATE inputs SET round_attempts=0,next_attempt=0 WHERE job_id=? AND status!='final'", (job_id,))
312
+ db.execute("UPDATE jobs SET state='queued',pause_reason=NULL,updated=? WHERE id=?", (self.clock(), job_id))
313
+ return self._status(db, self._job(db, job_id))
314
+
315
+ def _maintenance(self, db):
316
+ now = self.clock()
317
+ for session in db.execute("SELECT id FROM sessions WHERE state='active' AND expires<=?", (now,)).fetchall():
318
+ db.execute("UPDATE sessions SET state='expired' WHERE id=?", (session["id"],))
319
+ self._cancel_session_jobs(db, session["id"], "session_expired")
320
+ for item in db.execute("SELECT * FROM inputs WHERE status='running' AND lease_until<=?", (now,)).fetchall():
321
+ error = {"code": "lease_expired", "message": "worker attempt lease expired"}
322
+ db.execute("UPDATE attempts SET status='abandoned',finished=?,error=? WHERE job_id=? AND idx=? AND number=?",
323
+ (now, encode(error), item["job_id"], item["idx"], item["total_attempts"]))
324
+ db.execute("UPDATE inputs SET status='pending',token=token+1,lease_until=NULL,next_attempt=? WHERE job_id=? AND idx=?",
325
+ (now, item["job_id"], item["idx"]))
326
+ job = self._job(db, item["job_id"])
327
+ policy = self._policy(db, job)
328
+ if (not policy.get("retry_unknown_outcomes", True)
329
+ or item["round_attempts"] >= policy["max_attempts"]):
330
+ self._pause(db, job["id"], error)
331
+ for row in db.execute("SELECT id FROM jobs WHERE expired=0 AND terminal_at<=?", (now - self.retention_seconds,)).fetchall():
332
+ job_id = row["id"]
333
+ for table in ("http_requests", "results", "attempts", "inputs", "resume_ops"):
334
+ db.execute(f"DELETE FROM {table} WHERE job_id=?", (job_id,))
335
+ db.execute("UPDATE jobs SET expired=1,request=NULL,ir=NULL,indices=NULL,pause_reason=NULL WHERE id=?", (job_id,))
336
+
337
+ def claim(self, worker_id, releases):
338
+ with self._tx() as db:
339
+ for job in db.execute("SELECT * FROM jobs WHERE state IN ('queued','running') ORDER BY created,id").fetchall():
340
+ if job["release_id"] not in releases.manifests:
341
+ self._pause(db, job["id"], {"code": "release_unavailable", "message": "original execution release is unavailable"})
342
+ continue
343
+ item = db.execute("SELECT * FROM inputs WHERE job_id=? AND status='pending' AND next_attempt<=? ORDER BY idx LIMIT 1", (job["id"], self.clock())).fetchone()
344
+ if item is None:
345
+ continue
346
+ now, token, number = self.clock(), item["token"] + 1, item["total_attempts"] + 1
347
+ db.execute("""UPDATE inputs SET status='running',round_attempts=round_attempts+1,total_attempts=?,
348
+ token=?,lease_until=? WHERE job_id=? AND idx=?""", (number, token, now + self.lease_seconds, job["id"], item["idx"]))
349
+ db.execute("INSERT INTO attempts(job_id,idx,number,token,worker,status,started) VALUES (?,?,?,?,?,'running',?)",
350
+ (job["id"], item["idx"], number, token, worker_id, now))
351
+ if number == 1 and self.clock_epoch is not None and self.clock_epoch == job["clock_epoch"]:
352
+ wait = self.monotonic() - job["queued_monotonic"]
353
+ db.execute("UPDATE attempts SET queue_wait_seconds=? WHERE job_id=? AND idx=? AND number=?",
354
+ (wait if wait >= 0 else None, job["id"], item["idx"], number))
355
+ db.execute("UPDATE jobs SET state='running',updated=? WHERE id=?", (now, job["id"]))
356
+ return {"job_id": job["id"], "index": item["idx"], "state": decode(item["state"]),
357
+ "token": token, "attempt": number, "round_attempt": item["round_attempts"] + 1,
358
+ "ir": decode(job["ir"]), "mode": job["mode"], "release_id": job["release_id"]}
359
+ return None
360
+
361
+ def _owned(self, db, claim):
362
+ job = db.execute("SELECT * FROM jobs WHERE id=?", (claim["job_id"],)).fetchone()
363
+ item = db.execute("SELECT * FROM inputs WHERE job_id=? AND idx=?", (claim["job_id"], claim["index"])).fetchone()
364
+ if (job is None or job["state"] in TERMINAL or item is None or item["status"] != "running"
365
+ or item["token"] != claim["token"] or item["lease_until"] <= self.clock()):
366
+ return None
367
+ return job, item
368
+
369
+ def renew(self, claim):
370
+ with self._tx() as db:
371
+ if self._owned(db, claim) is None:
372
+ return False
373
+ db.execute("UPDATE inputs SET lease_until=? WHERE job_id=? AND idx=?", (self.clock() + self.lease_seconds, claim["job_id"], claim["index"]))
374
+ return True
375
+
376
+ def can_dispatch(self, claim):
377
+ """Pausing blocks new HTTP work, but preserves in-flight ownership."""
378
+ with self._tx() as db:
379
+ owned = self._owned(db, claim)
380
+ return owned is not None and owned[0]["state"] in ("queued", "running")
381
+
382
+ def pause_dispatch(self, claim, error):
383
+ """Stop admission immediately, retaining leases for in-flight accounting."""
384
+ with self._tx() as db:
385
+ if self._owned(db, claim) is None:
386
+ return False
387
+ self._pause(db, claim["job_id"], error)
388
+ return True
389
+
390
+ def finish(self, claim, result, *, elapsed_seconds=None):
391
+ if result.index != claim["index"]:
392
+ raise ValueError("attempt result index mismatch")
393
+ with self._tx() as db:
394
+ owned = self._owned(db, claim)
395
+ if owned is None:
396
+ return False
397
+ job, item = owned
398
+ self._attempt_duration(db, claim, elapsed_seconds)
399
+ error = None if result.error is None else {"code": result.error.code, "message": result.error.message}
400
+ self._write_result(db, job["id"], item, result.status, result.value, error)
401
+ db.execute("UPDATE attempts SET status='finished',finished=?,error=? WHERE job_id=? AND idx=? AND number=?",
402
+ (self.clock(), encode(error), job["id"], item["idx"], item["total_attempts"]))
403
+ if not db.execute("SELECT 1 FROM inputs WHERE job_id=? AND status!='final'", (job["id"],)).fetchone():
404
+ db.execute("UPDATE jobs SET state='completed',pause_reason=NULL,terminal_at=?,updated=? WHERE id=?", (self.clock(), self.clock(), job["id"]))
405
+ return True
406
+
407
+ def attempt_failed(self, claim, error, *, pause=False, delay=0.0, metadata=None, elapsed_seconds=None):
408
+ with self._tx() as db:
409
+ owned = self._owned(db, claim)
410
+ if owned is None:
411
+ return False
412
+ job, item = owned
413
+ self._attempt_duration(db, claim, elapsed_seconds)
414
+ db.execute("UPDATE attempts SET status='failed',finished=?,error=?,metadata=? WHERE job_id=? AND idx=? AND number=?",
415
+ (self.clock(), encode(error), encode(metadata or {}), job["id"], item["idx"], item["total_attempts"]))
416
+ db.execute("UPDATE inputs SET status='pending',lease_until=NULL,next_attempt=? WHERE job_id=? AND idx=?",
417
+ (self.clock() + delay, job["id"], item["idx"]))
418
+ if pause or item["round_attempts"] >= self._policy(db, job)["max_attempts"]:
419
+ self._pause(db, job["id"], error)
420
+ return True
421
+
422
+ def fail_job(self, claim, error, *, elapsed_seconds=None):
423
+ with self._tx() as db:
424
+ owned = self._owned(db, claim)
425
+ if owned is None:
426
+ return False
427
+ self._attempt_duration(db, claim, elapsed_seconds)
428
+ self._terminate(db, owned[0], "failed", error)
429
+ return True