flowstatemcp 2.5.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 (143) hide show
  1. flowstate/__init__.py +34 -0
  2. flowstate/__main__.py +7 -0
  3. flowstate/activity.py +224 -0
  4. flowstate/agent_guide.py +251 -0
  5. flowstate/app_keys.py +141 -0
  6. flowstate/approvals.py +974 -0
  7. flowstate/approve_page.py +565 -0
  8. flowstate/ask.py +107 -0
  9. flowstate/autosession.py +480 -0
  10. flowstate/backup.py +201 -0
  11. flowstate/brief.py +420 -0
  12. flowstate/card_offer.py +208 -0
  13. flowstate/card_taps.py +165 -0
  14. flowstate/card_trust.py +127 -0
  15. flowstate/claims.py +461 -0
  16. flowstate/cleanup.py +619 -0
  17. flowstate/cli.py +728 -0
  18. flowstate/cli_data.py +434 -0
  19. flowstate/cli_more.py +302 -0
  20. flowstate/cli_review.py +459 -0
  21. flowstate/cloudflared_check.py +49 -0
  22. flowstate/conflict_card_ui.py +286 -0
  23. flowstate/conflict_cards.py +1193 -0
  24. flowstate/conflict_reopen.py +320 -0
  25. flowstate/conflicts.py +420 -0
  26. flowstate/connections.py +33 -0
  27. flowstate/database.py +2191 -0
  28. flowstate/dbconn.py +308 -0
  29. flowstate/deletes.py +860 -0
  30. flowstate/embeddings.py +354 -0
  31. flowstate/erase.py +1047 -0
  32. flowstate/extras.py +71 -0
  33. flowstate/git_capture.py +157 -0
  34. flowstate/guard.py +224 -0
  35. flowstate/handoffs.py +168 -0
  36. flowstate/history.py +755 -0
  37. flowstate/history_page.py +276 -0
  38. flowstate/http_app.py +204 -0
  39. flowstate/identity.py +387 -0
  40. flowstate/install_name.py +140 -0
  41. flowstate/ledger.py +272 -0
  42. flowstate/legacy_args.py +65 -0
  43. flowstate/links.py +609 -0
  44. flowstate/markdown_io.py +242 -0
  45. flowstate/meaning.py +398 -0
  46. flowstate/memory_import.py +92 -0
  47. flowstate/migrations/001_auto_sessions.sql +16 -0
  48. flowstate/migrations/002_review_queue.sql +33 -0
  49. flowstate/migrations/003_links.sql +47 -0
  50. flowstate/migrations/004_review_mode.sql +24 -0
  51. flowstate/migrations/005_history.sql +21 -0
  52. flowstate/migrations/006_activity.sql +26 -0
  53. flowstate/migrations/007_meaning_index.sql +16 -0
  54. flowstate/migrations/008_git_capture.sql +12 -0
  55. flowstate/migrations/009_hard_rules.sql +13 -0
  56. flowstate/migrations/010_handoffs.sql +14 -0
  57. flowstate/migrations/011_record_meta.sql +13 -0
  58. flowstate/migrations/012_accountability.sql +71 -0
  59. flowstate/migrations/013_todo_claims.sql +36 -0
  60. flowstate/migrations/014_decision_conflicts.sql +41 -0
  61. flowstate/migrations/015_session_runs.sql +17 -0
  62. flowstate/migrations/016_write_receipts.sql +35 -0
  63. flowstate/migrations/017_run_views.sql +26 -0
  64. flowstate/migrations/018_run_views_upto.sql +4 -0
  65. flowstate/migrations/019_approvals.sql +47 -0
  66. flowstate/migrations/020_source_stamps.sql +10 -0
  67. flowstate/migrations/021_review_links.sql +11 -0
  68. flowstate/migrations/022_review_start_tickets.sql +10 -0
  69. flowstate/migrations/023_conflict_cards.sql +68 -0
  70. flowstate/migrations/024_rule_confirmations.sql +7 -0
  71. flowstate/migrations/025_confirmed_hash.sql +7 -0
  72. flowstate/migrations/__init__.py +149 -0
  73. flowstate/models.py +307 -0
  74. flowstate/names.py +157 -0
  75. flowstate/offsite.py +386 -0
  76. flowstate/offsite_crypto.py +272 -0
  77. flowstate/paths.py +265 -0
  78. flowstate/popup.py +238 -0
  79. flowstate/priority.py +66 -0
  80. flowstate/privatefs.py +476 -0
  81. flowstate/project_history.py +710 -0
  82. flowstate/provenance.py +696 -0
  83. flowstate/receipts.py +377 -0
  84. flowstate/relay/__init__.py +72 -0
  85. flowstate/relay/acme.py +271 -0
  86. flowstate/relay/api.py +164 -0
  87. flowstate/relay/cli.py +344 -0
  88. flowstate/relay/crypto.py +137 -0
  89. flowstate/relay/ct.py +118 -0
  90. flowstate/relay/runner.py +177 -0
  91. flowstate/relay/store.py +191 -0
  92. flowstate/relay/tunnel.py +413 -0
  93. flowstate/release_signers +14 -0
  94. flowstate/remote_auth.py +1219 -0
  95. flowstate/review.py +320 -0
  96. flowstate/review_apply.py +140 -0
  97. flowstate/review_card_ui.py +241 -0
  98. flowstate/review_cards.py +564 -0
  99. flowstate/review_mode.py +274 -0
  100. flowstate/review_page.py +1361 -0
  101. flowstate/review_phone.py +465 -0
  102. flowstate/review_tunnel.py +338 -0
  103. flowstate/routing.py +392 -0
  104. flowstate/rules.py +418 -0
  105. flowstate/safety.py +98 -0
  106. flowstate/saves.py +83 -0
  107. flowstate/scheduler.py +160 -0
  108. flowstate/schema.sql +713 -0
  109. flowstate/search.py +355 -0
  110. flowstate/secret_store.py +271 -0
  111. flowstate/secrets_scan.py +135 -0
  112. flowstate/selfupdate.py +1020 -0
  113. flowstate/server.py +1299 -0
  114. flowstate/session_contents.py +382 -0
  115. flowstate/session_records.py +629 -0
  116. flowstate/site_style.py +46 -0
  117. flowstate/stored.py +158 -0
  118. flowstate/story.py +442 -0
  119. flowstate/surface.py +173 -0
  120. flowstate/tasks.py +231 -0
  121. flowstate/term.py +40 -0
  122. flowstate/tool_labels.py +104 -0
  123. flowstate/tooldefs.py +383 -0
  124. flowstate/tools/__init__.py +118 -0
  125. flowstate/tools/core.py +545 -0
  126. flowstate/tools/files.py +416 -0
  127. flowstate/tools/git_ops.py +1081 -0
  128. flowstate/tools/intelligence.py +376 -0
  129. flowstate/tools/learning.py +282 -0
  130. flowstate/tools/problems.py +159 -0
  131. flowstate/tools/sessions.py +266 -0
  132. flowstate/tools/story.py +48 -0
  133. flowstate/tools/todos.py +79 -0
  134. flowstate/tools/utils.py +71 -0
  135. flowstate/tools/variables.py +345 -0
  136. flowstate/updates.py +482 -0
  137. flowstate/urls.py +7 -0
  138. flowstate/waiting.py +828 -0
  139. flowstate/who.py +302 -0
  140. flowstatemcp-2.5.0.dist-info/METADATA +88 -0
  141. flowstatemcp-2.5.0.dist-info/RECORD +143 -0
  142. flowstatemcp-2.5.0.dist-info/WHEEL +4 -0
  143. flowstatemcp-2.5.0.dist-info/entry_points.txt +3 -0
flowstate/claims.py ADDED
@@ -0,0 +1,461 @@
1
+ """To-do claims with an expiry (2.3.0, migration 013).
2
+
3
+ One database serves every agent, so two agents starting at once can both pick the same
4
+ "next" todo. A claim says "I'm on this until <time>": the brief, `next` and `list` show it,
5
+ so the second agent picks something else instead of doing the same work silently.
6
+
7
+ - claim: one conditional UPSERT decides, inside one write transaction. It succeeds when the
8
+ todo is unclaimed, its claim has expired, or the same claimer holds it (then it renews).
9
+ - The same claimer = same app, machine and session (identity.py; the session is this server
10
+ process for stdio, the MCP session id over HTTP), so two sessions of one app are told apart.
11
+ - A claim ends with its session (2.4.0): when the session closes (the app quits, the agent
12
+ ends it, or it goes idle) its claims are released, and the same app on the same machine
13
+ may take over a claim whose session is no longer open (it reconnected as a new session).
14
+ Other apps stay blocked until the claim expires or is released.
15
+ - No hard lock: anyone may still update a claimed todo; they get a warning and an `override`
16
+ event. done / cancelled clears the claim.
17
+ - Every event goes into todo_claim_events, which manage_history who lists.
18
+
19
+ All times come from utcnow() (tests freeze it); stored UTC, shown relative ("expires in 9m").
20
+ """
21
+
22
+ import sqlite3
23
+ from datetime import datetime, timedelta, timezone
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ from . import dbconn
28
+ from . import handoffs, identity, links
29
+
30
+ DEFAULT_MINUTES, MIN_MINUTES, MAX_MINUTES = 15, 1, 120
31
+ TS_FMT = "%Y-%m-%d %H:%M:%S"
32
+ OPEN = (None, "pending", "in_progress", "blocked")
33
+ CLOSED = ("done", "cancelled")
34
+ _ID = ("app", "model", "machine", "identity_source", "session")
35
+
36
+
37
+ def utcnow() -> datetime:
38
+ """The one clock for claims (naive UTC)."""
39
+ return datetime.now(timezone.utc).replace(tzinfo=None)
40
+
41
+
42
+ def _ts(dt: datetime) -> str:
43
+ return dt.strftime(TS_FMT)
44
+
45
+
46
+ def _parse(ts: str) -> datetime:
47
+ return datetime.strptime(ts[:19].replace("T", " "), TS_FMT)
48
+
49
+
50
+ class ClaimError(links.LinkError):
51
+ pass
52
+
53
+
54
+ def available(conn) -> bool:
55
+ return conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' "
56
+ "AND name='todo_claims'").fetchone() is not None
57
+
58
+
59
+ def connect(db_path: Path) -> sqlite3.Connection:
60
+ conn = dbconn.connect(str(db_path), timeout=10.0, isolation_level=None)
61
+ conn.row_factory = sqlite3.Row
62
+ conn.execute("PRAGMA busy_timeout = 10000")
63
+ conn.execute("PRAGMA foreign_keys = ON")
64
+ return conn
65
+
66
+
67
+ # ---------------------------------------------------------------- who
68
+ def me(client: Optional[str] = None) -> dict:
69
+ """{app, model, machine, identity_source, session, holder} of the caller."""
70
+ ident = identity.current() or identity.local(client or "unknown")
71
+ who = {**ident.as_dict(), "session": getattr(ident, "session", None)}
72
+ who["holder"] = "\x1f".join(str(who.get(k) or "") for k in ("app", "machine", "session"))
73
+ return who
74
+
75
+
76
+ def holder_name(row) -> str:
77
+ """'cursor on mac-mini' (the app, and the machine when known); 'Grok Bot (via Cursor)'
78
+ for an app that came in on a relay / own-tunnel sign-in (identity.shown)."""
79
+ return identity.shown_row(row, on_machine=True, fallback="another agent")
80
+
81
+
82
+ def relative(expires_at: str, now: Optional[datetime] = None) -> str:
83
+ """'9m', '1h 5m', or 'now'."""
84
+ secs = (_parse(expires_at) - (now or utcnow())).total_seconds()
85
+ mins = int(-(-secs // 60)) if secs > 0 else 0 # round up: 8m30s left -> 9m
86
+ if mins <= 0:
87
+ return "now"
88
+ return f"{mins}m" if mins < 60 else f"{mins // 60}h {mins % 60}m" if mins % 60 else f"{mins // 60}h"
89
+
90
+
91
+ def _who(row, mine: str) -> str:
92
+ """'you', 'cursor on mac-mini', or 'cursor on mac-mini (another session)'."""
93
+ if row["holder"] == mine:
94
+ return "you"
95
+ me_app, me_machine = (mine.split("\x1f") + ["", ""])[:2]
96
+ same = (row["app"] or "") == me_app and (row["machine"] or "") == me_machine
97
+ return holder_name(row) + (" (another session)" if same else "")
98
+
99
+
100
+ def describe(row, mine: str, now: Optional[datetime] = None) -> str:
101
+ """'claimed by cursor on mac-mini, expires in 9m' (or 'claimed by you, ...')."""
102
+ return f"claimed by {_who(row, mine)}, expires in {relative(row['expires_at'], now)}"
103
+
104
+
105
+ def active(conn, todo_ids=None, pid: Optional[int] = None) -> dict:
106
+ """{todo_id: row} of unexpired claims (by ids, or for a whole project)."""
107
+ if not available(conn):
108
+ return {}
109
+ now = _ts(utcnow())
110
+ if todo_ids is not None:
111
+ ids = [int(i) for i in todo_ids]
112
+ if not ids:
113
+ return {}
114
+ rows = conn.execute(f"SELECT * FROM todo_claims WHERE expires_at > ? AND todo_id IN "
115
+ f"({', '.join('?' * len(ids))})", (now, *ids))
116
+ else:
117
+ rows = conn.execute("SELECT * FROM todo_claims WHERE expires_at > ? AND project_id = ?",
118
+ (now, pid))
119
+ return {r["todo_id"]: r for r in rows}
120
+
121
+
122
+ def notes(db_path: Path, todo_ids, client: Optional[str] = None) -> dict:
123
+ """{todo_id: 'claimed by ..., expires in ...'} for the active claims among todo_ids."""
124
+ conn = connect(db_path)
125
+ try:
126
+ mine, now = me(client)["holder"], utcnow()
127
+ return {tid: describe(r, mine, now) for tid, r in active(conn, todo_ids).items()}
128
+ finally:
129
+ conn.close()
130
+
131
+
132
+ def annotate(db_path: Path, rows, client: Optional[str] = None):
133
+ """`todo list`: add "claim" to each row that has an active claim."""
134
+ ids = [r["id"] for r in rows if isinstance(r, dict) and isinstance(r.get("id"), int)]
135
+ found = notes(db_path, ids, client) if ids else {}
136
+ for r in rows:
137
+ if isinstance(r, dict) and r.get("id") in found:
138
+ r["claim"] = found[r["id"]]
139
+ return rows
140
+
141
+
142
+ def split_next(conn, ready: list, blocked: list, client: Optional[str] = None) -> tuple:
143
+ """`todo next`: (ready with your claims first and others' claims removed, the removed
144
+ ones as short lines). Unchanged when nothing is claimed."""
145
+ claimed = active(conn, [i["id"] for i in ready + blocked])
146
+ if not claimed:
147
+ return ready, []
148
+ mine, now = me(client)["holder"], utcnow()
149
+ own, free, others = [], [], []
150
+ for i in ready:
151
+ c = claimed.get(i["id"])
152
+ if c is None:
153
+ free.append(i)
154
+ elif c["holder"] == mine:
155
+ own.append({**i, "claim": f"yours, expires in {relative(c['expires_at'], now)}"})
156
+ else:
157
+ others.append(f"#{i['id']} {links._one_line(i['title'], 60)} "
158
+ f"({describe(c, mine, now)})")
159
+ for i in blocked:
160
+ if i["id"] in claimed:
161
+ i["claim"] = describe(claimed[i["id"]], mine, now)
162
+ return own + free, others
163
+
164
+
165
+ # ---------------------------------------------------------------- events
166
+ def _event(conn, todo_id: int, pid, event: str, summary: str, who: dict) -> None:
167
+ row = {"todo_id": todo_id, "project_id": pid, "event": event, "summary": summary[:200],
168
+ "at": _ts(utcnow()), **{k: who.get(k) for k in _ID}}
169
+ conn.execute(f"INSERT INTO todo_claim_events ({', '.join(row)}) "
170
+ f"VALUES ({', '.join('?' * len(row))})", list(row.values()))
171
+
172
+
173
+ # ---------------------------------------------------------------- actions
174
+ def _need(conn) -> None:
175
+ if not available(conn):
176
+ raise ClaimError("claims_unavailable", "Todo claims aren't set up in this database yet "
177
+ "(migration 013 has not run). Restart FlowState.")
178
+
179
+
180
+ def _minutes(value) -> int:
181
+ if value in (None, ""):
182
+ return DEFAULT_MINUTES
183
+ m = None
184
+ if isinstance(value, int) and not isinstance(value, bool):
185
+ m = value
186
+ elif isinstance(value, float) and value.is_integer():
187
+ m = int(value)
188
+ elif isinstance(value, str) and value.strip().isdigit():
189
+ m = int(value.strip())
190
+ if m is None or not MIN_MINUTES <= m <= MAX_MINUTES:
191
+ raise ClaimError("bad_minutes", f"minutes must be a whole number from {MIN_MINUTES} to "
192
+ f"{MAX_MINUTES} (default {DEFAULT_MINUTES}).")
193
+ return m
194
+
195
+
196
+ def _todo(conn, pid: int, todo_id) -> sqlite3.Row:
197
+ if todo_id is None:
198
+ raise ClaimError("todo_id_required", "Give todo_id (#id in the brief).")
199
+ t = conn.execute("SELECT id, project_id, title, status FROM todos WHERE id = ?",
200
+ (int(todo_id),)).fetchone()
201
+ if t is None:
202
+ raise ClaimError("record_not_found", f"No todo with id {todo_id}.")
203
+ if t["project_id"] != pid:
204
+ raise ClaimError("project_mismatch", f"Todo #{todo_id} is in project "
205
+ f"'{links.project_name(conn, t['project_id'])}'.")
206
+ return t
207
+
208
+
209
+ def _ended_session_sql(conn) -> Optional[str]:
210
+ """SQL (for the claim upsert) that is true when the existing claim's session is known
211
+ and no longer open for its app. None before migration 015."""
212
+ if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='session_runs'"
213
+ ).fetchone() is None:
214
+ return None
215
+ return ("EXISTS (SELECT 1 FROM session_runs r WHERE r.conn_key = todo_claims.session) "
216
+ "AND NOT EXISTS (SELECT 1 FROM session_runs r JOIN auto_sessions a USING (session_id) "
217
+ "WHERE r.conn_key = todo_claims.session AND r.app IS todo_claims.app "
218
+ "AND a.closed_at IS NULL)")
219
+
220
+
221
+ def release_for_session(conn, session_key: Optional[str], app: Optional[str], project_id: int,
222
+ reason: str) -> int:
223
+ """Inside the caller's transaction: drop the claims a closed session (this connection
224
+ and app) held in this project, with a `release` event each. Returns how many."""
225
+ if not session_key or not available(conn):
226
+ return 0
227
+ rows = conn.execute("SELECT * FROM todo_claims WHERE session = ? AND app IS ? AND project_id = ?",
228
+ (session_key, app, project_id)).fetchall()
229
+ for r in rows:
230
+ conn.execute("DELETE FROM todo_claims WHERE todo_id = ?", (r["todo_id"],))
231
+ _event(conn, r["todo_id"], project_id, "release",
232
+ f"claim on todo #{r['todo_id']} released: its session ended ({reason})",
233
+ {k: r[k] for k in _ID})
234
+ return len(rows)
235
+
236
+
237
+ def _held_elsewhere(t, row, now: datetime, mine: str) -> ClaimError:
238
+ return ClaimError(
239
+ "claimed_by_other",
240
+ f"Todo #{t['id']} is claimed by {_who(row, mine)} until {row['expires_at']} UTC "
241
+ f"(expires in {relative(row['expires_at'], now)}). Pick another one: "
242
+ f"manage_todos(action=\"next\") lists what is free.",
243
+ holder=holder_name(row), expires_at=row["expires_at"])
244
+
245
+
246
+ def claim(db_path: Path, pid: int, todo_id, minutes=None, client: Optional[str] = None) -> dict:
247
+ mins = _minutes(minutes)
248
+ who = me(client)
249
+ now = utcnow()
250
+ expires = _ts(now + timedelta(minutes=mins))
251
+ conn = connect(db_path)
252
+ try:
253
+ _need(conn)
254
+ conn.execute("BEGIN IMMEDIATE")
255
+ try:
256
+ t = _todo(conn, pid, todo_id)
257
+ if t["status"] not in OPEN:
258
+ raise ClaimError("todo_closed", f"Todo #{t['id']} is {t['status']}; claim an open one.")
259
+ before = conn.execute("SELECT * FROM todo_claims WHERE todo_id = ?",
260
+ (t["id"],)).fetchone()
261
+ vals = {"todo_id": t["id"], "project_id": pid, "holder": who["holder"],
262
+ **{k: who.get(k) for k in _ID}, "claimed_at": _ts(now), "expires_at": expires}
263
+ # The decision is this one statement: insert, or take over only when the current
264
+ # claim is ours, has expired, or is this app's on this machine from a session
265
+ # that is no longer open (the app reconnected).
266
+ ended = _ended_session_sql(conn)
267
+ same_app = (f" OR (todo_claims.app IS excluded.app AND todo_claims.machine IS "
268
+ f"excluded.machine AND {ended})" if ended else "")
269
+ cur = conn.execute(
270
+ f"""INSERT INTO todo_claims ({', '.join(vals)}) VALUES ({', '.join('?' * len(vals))})
271
+ ON CONFLICT(todo_id) DO UPDATE SET
272
+ claimed_at = CASE WHEN todo_claims.holder = excluded.holder
273
+ AND todo_claims.expires_at > ?
274
+ THEN todo_claims.claimed_at ELSE excluded.claimed_at END,
275
+ project_id = excluded.project_id, holder = excluded.holder,
276
+ app = excluded.app, model = excluded.model, machine = excluded.machine,
277
+ identity_source = excluded.identity_source, session = excluded.session,
278
+ expires_at = excluded.expires_at
279
+ WHERE todo_claims.holder = excluded.holder OR todo_claims.expires_at <= ?"""
280
+ + same_app,
281
+ (*vals.values(), _ts(now), _ts(now)))
282
+ if cur.rowcount != 1:
283
+ row = conn.execute("SELECT * FROM todo_claims WHERE todo_id = ?",
284
+ (t["id"],)).fetchone()
285
+ raise _held_elsewhere(t, row, now, who["holder"])
286
+ live = before is not None and before["expires_at"] > _ts(now)
287
+ renewed = live and before["holder"] == who["holder"]
288
+ title = links._one_line(t["title"], 60)
289
+ if renewed:
290
+ _event(conn, t["id"], pid, "renew", f"renewed claim on todo #{t['id']} ({mins}m)", who)
291
+ else:
292
+ took = ""
293
+ if before is not None and before["holder"] != who["holder"]:
294
+ took = (f" (took over the claim of an ended {holder_name(before)} session)"
295
+ if live else f" (took over an expired claim by {holder_name(before)})")
296
+ _event(conn, t["id"], pid, "claim",
297
+ f"claimed todo #{t['id']} {title} for {mins}m{took}", who)
298
+ conn.execute("COMMIT")
299
+ except Exception:
300
+ conn.execute("ROLLBACK")
301
+ raise
302
+ return {"todo_id": t["id"], "claimed": True, "renewed": renewed, "expires_at": expires,
303
+ "expires_in": relative(expires, now),
304
+ "note": "Other agents see it as claimed. Renew before it expires if you need "
305
+ "longer; release or handoff when you stop."}
306
+ finally:
307
+ conn.close()
308
+
309
+
310
+ def renew(db_path: Path, pid: int, todo_id, minutes=None, client: Optional[str] = None) -> dict:
311
+ """Extend your own live claim. Fails if you don't hold it, and once it has expired (2.4.4,
312
+ task 72): an expired claim is gone, for its holder too; claim the todo again."""
313
+ mins = _minutes(minutes)
314
+ who = me(client)
315
+ now = utcnow()
316
+ expires = _ts(now + timedelta(minutes=mins))
317
+ conn = connect(db_path)
318
+ try:
319
+ _need(conn)
320
+ conn.execute("BEGIN IMMEDIATE")
321
+ try:
322
+ t = _todo(conn, pid, todo_id)
323
+ cur = conn.execute("UPDATE todo_claims SET expires_at = ?, model = ? "
324
+ "WHERE todo_id = ? AND holder = ? AND expires_at > ?",
325
+ (expires, who.get("model"), t["id"], who["holder"], _ts(now)))
326
+ if cur.rowcount != 1:
327
+ row = conn.execute("SELECT * FROM todo_claims WHERE todo_id = ?",
328
+ (t["id"],)).fetchone()
329
+ if row is not None and row["expires_at"] > _ts(now):
330
+ raise _held_elsewhere(t, row, now, who["holder"])
331
+ if row is not None and row["holder"] == who["holder"]:
332
+ raise ClaimError("claim_expired", f"Your claim on todo #{t['id']} expired at "
333
+ f"{row['expires_at']} UTC, so it can't be renewed. Claim it "
334
+ f"again if it is still free: manage_todos(action=\"claim\").",
335
+ expired_at=row["expires_at"])
336
+ raise ClaimError("not_claimed", f"You don't hold a claim on todo #{t['id']}. "
337
+ f"Claim it first: manage_todos(action=\"claim\").")
338
+ _event(conn, t["id"], pid, "renew", f"renewed claim on todo #{t['id']} ({mins}m)", who)
339
+ conn.execute("COMMIT")
340
+ except Exception:
341
+ conn.execute("ROLLBACK")
342
+ raise
343
+ return {"todo_id": t["id"], "renewed": True, "expires_at": expires,
344
+ "expires_in": relative(expires, now)}
345
+ finally:
346
+ conn.close()
347
+
348
+
349
+ def _drop_own(conn, t, pid: int, who: dict, event: str, summary: str) -> bool:
350
+ """Delete the caller's claim on t (inside the open transaction). True if there was one;
351
+ raises if someone else holds it."""
352
+ now = utcnow()
353
+ if conn.execute("DELETE FROM todo_claims WHERE todo_id = ? AND holder = ? AND expires_at <= ?",
354
+ (t["id"], who["holder"], _ts(now))).rowcount:
355
+ return False # it had expired: it was no longer held (the stale row goes)
356
+ cur = conn.execute("DELETE FROM todo_claims WHERE todo_id = ? AND holder = ?",
357
+ (t["id"], who["holder"]))
358
+ if cur.rowcount != 1:
359
+ row = conn.execute("SELECT * FROM todo_claims WHERE todo_id = ? AND expires_at > ?",
360
+ (t["id"], _ts(now))).fetchone()
361
+ if row is not None:
362
+ raise ClaimError("not_your_claim", f"Todo #{t['id']} is claimed by {holder_name(row)} "
363
+ f"(expires in {relative(row['expires_at'], now)}), not by you.",
364
+ holder=holder_name(row), expires_at=row["expires_at"])
365
+ return False
366
+ _event(conn, t["id"], pid, event, summary, who)
367
+ return True
368
+
369
+
370
+ def release(db_path: Path, pid: int, todo_id, client: Optional[str] = None) -> dict:
371
+ who = me(client)
372
+ conn = connect(db_path)
373
+ try:
374
+ _need(conn)
375
+ conn.execute("BEGIN IMMEDIATE")
376
+ try:
377
+ t = _todo(conn, pid, todo_id)
378
+ had = _drop_own(conn, t, pid, who, "release", f"released claim on todo #{t['id']}")
379
+ conn.execute("COMMIT")
380
+ except Exception:
381
+ conn.execute("ROLLBACK")
382
+ raise
383
+ return {"todo_id": t["id"], "released": had,
384
+ "note": "Others can claim it now." if had else
385
+ "You held no claim on it (it may have expired); nothing to release."}
386
+ finally:
387
+ conn.close()
388
+
389
+
390
+ def handoff(db_path: Path, pid: int, todo_id, where_i_was: Optional[str] = None,
391
+ next_step: Optional[str] = None, client: Optional[str] = None) -> dict:
392
+ """Leave a handoff note (handoffs.py) about this todo and drop your claim."""
393
+ who = me(client)
394
+ conn = connect(db_path)
395
+ try:
396
+ _need(conn)
397
+ t = _todo(conn, pid, todo_id)
398
+ row = active(conn, [t["id"]]).get(t["id"])
399
+ if row is not None and row["holder"] != who["holder"]:
400
+ raise ClaimError("not_your_claim", f"Todo #{t['id']} is claimed by {holder_name(row)}, "
401
+ f"not by you; leave a note with manage_handoffs instead.",
402
+ holder=holder_name(row), expires_at=row["expires_at"])
403
+ finally:
404
+ conn.close()
405
+ title = links._one_line(t["title"], 80)
406
+ where = (where_i_was or "").strip()
407
+ nxt = (next_step or "").strip() or (f"Claim todo #{t['id']} (manage_todos action=claim) "
408
+ f"and carry on.")
409
+ note = handoffs.leave(db_path, pid, f"Todo #{t['id']} {title}: "
410
+ + (where or "started, not finished."), nxt, client or who["app"])
411
+ conn = connect(db_path)
412
+ try:
413
+ conn.execute("BEGIN IMMEDIATE")
414
+ try:
415
+ had = _drop_own(conn, t, pid, who, "handoff",
416
+ f"handed off todo #{t['id']} (note H{note['handoff_id']})")
417
+ if not had: # still log the handoff in the trail
418
+ _event(conn, t["id"], pid, "handoff",
419
+ f"handed off todo #{t['id']} (note H{note['handoff_id']})", who)
420
+ conn.execute("COMMIT")
421
+ except Exception:
422
+ conn.execute("ROLLBACK")
423
+ raise
424
+ finally:
425
+ conn.close()
426
+ return {"todo_id": t["id"], "released": had, "handoff_id": note["handoff_id"],
427
+ "note": "Claim dropped; the next start_here shows your handoff note."}
428
+
429
+
430
+ def after_update(db_path: Path, pid: int, todo_id: int, action: str, status: Optional[str],
431
+ client: Optional[str] = None) -> Optional[str]:
432
+ """After someone changed a todo: an `override` event and a warning when another agent
433
+ holds it; done / cancelled clears the claim. Returns the warning text, or None."""
434
+ who = me(client)
435
+ conn = connect(db_path)
436
+ try:
437
+ if not available(conn):
438
+ return None
439
+ conn.execute("BEGIN IMMEDIATE")
440
+ try:
441
+ row = active(conn, [todo_id]).get(todo_id)
442
+ warning = None
443
+ if row is not None and row["holder"] != who["holder"]:
444
+ left = relative(row["expires_at"])
445
+ warning = (f"Heads up: todo #{todo_id} is claimed by {holder_name(row)} (expires "
446
+ f"in {left}). Your {action} was saved anyway; tell the user or that "
447
+ f"agent if this was not intended.")
448
+ _event(conn, todo_id, pid, "override",
449
+ f"{action} on todo #{todo_id} claimed by {holder_name(row)}", who)
450
+ if status in CLOSED:
451
+ cur = conn.execute("DELETE FROM todo_claims WHERE todo_id = ?", (todo_id,))
452
+ if cur.rowcount and row is not None:
453
+ _event(conn, todo_id, pid, "cleared",
454
+ f"claim on todo #{todo_id} cleared ({status})", who)
455
+ conn.execute("COMMIT")
456
+ except Exception:
457
+ conn.execute("ROLLBACK")
458
+ raise
459
+ return warning
460
+ finally:
461
+ conn.close()