keepwatch 0.1.1__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.
keepwatch/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """keepwatch — keep work alive when the process that started it is not.
2
+
3
+ A task is a JSON file that moves between three folders. Whoever holds it holds a
4
+ lease; a dead holder's lease expires and the task comes back with a trail. A task
5
+ is only ``done`` when the ledger holds a ``verify`` record with ``rc == 0``.
6
+
7
+ Protocol: docs/PROTOCOL.md. Story: examples/two_workers.py.
8
+ """
9
+
10
+ __version__ = "0.1.1"
11
+ __all__ = ["__version__"]
keepwatch/cli.py ADDED
@@ -0,0 +1,242 @@
1
+ """Command line interface. Every command maps to one function in ``core``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from . import __version__, core
14
+
15
+
16
+ def _default_root() -> str:
17
+ return os.environ.get("KEEPWATCH_ROOT", ".")
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="keepwatch",
23
+ description="Keep agent work alive when the process that started it is not.",
24
+ )
25
+ parser.add_argument("--root", default=_default_root(), help="work root (default: $KEEPWATCH_ROOT or .)")
26
+ parser.add_argument("--json", action="store_true", help="machine readable output")
27
+ parser.add_argument("--version", action="version", version=f"keepwatch {__version__}")
28
+
29
+ sub = parser.add_subparsers(dest="command", required=True)
30
+
31
+ p_init = sub.add_parser("init", help="create the layout for some agents")
32
+ p_init.add_argument("agents", nargs="+", metavar="AGENT")
33
+
34
+ p_send = sub.add_parser("send", help="hand a task to an agent")
35
+ p_send.add_argument("--from", dest="sender", required=True)
36
+ p_send.add_argument("--to", required=True)
37
+ p_send.add_argument("--subject", required=True)
38
+ p_send.add_argument("--body", default="")
39
+ p_send.add_argument("--body-file")
40
+
41
+ p_check = sub.add_parser("check", help="list tasks you can pick up")
42
+ p_check.add_argument("--who", required=True)
43
+
44
+ p_claim = sub.add_parser("claim", help="take a task (and hold a lease on it)")
45
+ p_claim.add_argument("--who", required=True)
46
+ p_claim.add_argument("--id", required=True)
47
+ p_claim.add_argument("--ttl", type=int, default=core.DEFAULT_TTL_SECONDS)
48
+
49
+ p_renew = sub.add_parser("renew", help="extend your own lease on a task")
50
+ p_renew.add_argument("--who", required=True)
51
+ p_renew.add_argument("--id", required=True)
52
+ p_renew.add_argument("--ttl", type=int, default=core.DEFAULT_TTL_SECONDS)
53
+
54
+ p_done = sub.add_parser("done", help="close a task (requires a passing verify)")
55
+ p_done.add_argument("--who", required=True)
56
+ p_done.add_argument("--id", required=True)
57
+ p_done.add_argument("--note", default="")
58
+ p_done.add_argument("--force", action="store_true", help="close without a verify record")
59
+
60
+ p_verify = sub.add_parser("verify", help="record a check result against a task")
61
+ p_verify.add_argument("--id", required=True)
62
+ p_verify.add_argument("--name", required=True)
63
+ p_verify.add_argument("--rc", type=int, required=True)
64
+
65
+ p_run = sub.add_parser(
66
+ "run",
67
+ help="claim a task, run a command, record the exit code and close or release it",
68
+ usage="keepwatch run --who AGENT [--id ID] [--ttl SECONDS] [--max-tries N] -- COMMAND ...",
69
+ )
70
+ p_run.add_argument("--who", required=True)
71
+ p_run.add_argument("--id", help="task id (default: the oldest pending task)")
72
+ p_run.add_argument("--ttl", type=int, default=core.DEFAULT_TTL_SECONDS)
73
+ p_run.add_argument("--max-tries", type=int, default=3)
74
+ p_run.add_argument("cmd", nargs=argparse.REMAINDER)
75
+
76
+ sub.add_parser("reap", help="return expired leases to pending")
77
+
78
+ p_status = sub.add_parser("status", help="what is stuck, who is dead, what is next")
79
+ p_status.add_argument("--stale", type=int, default=core.DEFAULT_STALE_SECONDS)
80
+
81
+ return parser
82
+
83
+
84
+ def _emit(args: argparse.Namespace, payload: Any, human: list[str]) -> None:
85
+ if args.json:
86
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
87
+ else:
88
+ for line in human:
89
+ print(line)
90
+
91
+
92
+ def _short(text: str, width: int = 40) -> str:
93
+ text = (text or "").replace("\n", " ")
94
+ return text if len(text) <= width else text[: width - 1] + "…"
95
+
96
+
97
+ def run(args: argparse.Namespace) -> int:
98
+ store = core.Store(args.root)
99
+
100
+ if args.command == "init":
101
+ store.init(args.agents)
102
+ _emit(args, {"root": str(store.root), "agents": args.agents},
103
+ [f"initialised {store.root}", "agents: " + ", ".join(args.agents)])
104
+ return core.EXIT_OK
105
+
106
+ if args.command == "send":
107
+ body = args.body
108
+ if args.body_file:
109
+ body = Path(args.body_file).read_text(encoding="utf-8")
110
+ task = core.send(store, args.sender, args.to, args.subject, body)
111
+ _emit(args, task, [f"sent {task['id']} -> {args.to}: {args.subject}"])
112
+ return core.EXIT_OK
113
+
114
+ if args.command == "check":
115
+ core.heartbeat(store, args.who)
116
+ tasks = core.check(store, args.who)
117
+ human = [f"{len(tasks)} task(s) for {args.who}"]
118
+ for t in tasks:
119
+ age = int(core.age_seconds(t["created_at"]))
120
+ human.append(f" {t['id']} {age:>6}s from {t['from']:<12} {_short(t['subject'])}")
121
+ _emit(args, {"who": args.who, "tasks": tasks}, human)
122
+ return core.EXIT_OK
123
+
124
+ if args.command == "claim":
125
+ core.heartbeat(store, args.who)
126
+ task = core.claim(store, args.who, args.id, args.ttl)
127
+ lease = task["_lease"]
128
+ _emit(args, task, [
129
+ f"claimed {task['id']} (try {task['tries']}) lease until {lease['expires_at']}",
130
+ ])
131
+ return core.EXIT_OK
132
+
133
+ if args.command == "renew":
134
+ core.heartbeat(store, args.who)
135
+ task = core.renew(store, args.who, args.id, args.ttl)
136
+ _emit(args, task, [f"renewed {task['id']} until {task['_lease']['expires_at']}"])
137
+ return core.EXIT_OK
138
+
139
+ if args.command == "done":
140
+ core.heartbeat(store, args.who)
141
+ task = core.done(store, args.who, args.id, args.note, args.force)
142
+ _emit(args, task, [f"closed {task['id']}"])
143
+ return core.EXIT_OK
144
+
145
+ if args.command == "verify":
146
+ record = core.verify(store, args.id, args.name, args.rc)
147
+ _emit(args, record, [f"recorded {args.name} rc={args.rc} for {args.id}"])
148
+ return core.EXIT_OK
149
+
150
+ if args.command == "run":
151
+ return run_task(store, args)
152
+
153
+ if args.command == "reap":
154
+ tasks = core.reap(store)
155
+ human = [f"reaped {len(tasks)} task(s)"]
156
+ for t in tasks:
157
+ human.append(f" {t['id']} back to pending (tries={t['tries']})")
158
+ _emit(args, {"reaped": tasks}, human)
159
+ return core.EXIT_OK
160
+
161
+ if args.command == "status":
162
+ state = core.status(store, args.stale)
163
+ human = [f"root: {state['root']}"]
164
+ for name, row in state["agents"].items():
165
+ if row["heartbeat_age_s"] is None:
166
+ hb, flag = "never seen", "unknown"
167
+ elif row["alive"]:
168
+ hb, flag = f"hb {row['heartbeat_age_s']}s", "alive"
169
+ else:
170
+ hb, flag = f"hb {row['heartbeat_age_s']}s", "STALE"
171
+ human.append(
172
+ f" {name:<14} pending {row['pending']} processing {row['processing']}"
173
+ f" done {row['done']} {hb} ({flag})"
174
+ )
175
+ if state["next_steps"]:
176
+ human.append("next:")
177
+ human.extend(f" - {line}" for line in state["next_steps"])
178
+ else:
179
+ human.append("next: nothing to do")
180
+ _emit(args, state, human)
181
+ return core.EXIT_OK if state["ok"] else core.EXIT_BLOCKED
182
+
183
+ raise core.keepwatchError(f"unknown command: {args.command}")
184
+
185
+
186
+ def run_task(store: core.Store, args: argparse.Namespace) -> int:
187
+ """Claim -> run -> verify -> close or release. The one command an agent needs."""
188
+ cmd = list(args.cmd)
189
+ if cmd and cmd[0] == "--":
190
+ cmd = cmd[1:]
191
+ if not cmd:
192
+ raise core.keepwatchError(
193
+ "no command given; use: keepwatch run --who AGENT -- COMMAND ..."
194
+ )
195
+
196
+ core.heartbeat(store, args.who)
197
+ task_id = args.id
198
+ if not task_id:
199
+ task = core.oldest_pending(store, args.who)
200
+ if task is None:
201
+ raise core.NotFound(f"no pending task for {args.who}")
202
+ task_id = task["id"]
203
+
204
+ claimed = core.claim(store, args.who, task_id, args.ttl)
205
+ label = " ".join(cmd)
206
+ print(f"running {task_id} (try {claimed['tries']}): {label}", flush=True)
207
+
208
+ # stdio is inherited on purpose: the agent's output must reach the human.
209
+ rc = subprocess.run(cmd, check=False).returncode
210
+ core.verify(store, task_id, label, rc)
211
+
212
+ if rc == 0:
213
+ core.done(store, args.who, task_id, note=label)
214
+ print(f"closed {task_id} (rc=0)", flush=True)
215
+ return core.EXIT_OK
216
+
217
+ if claimed["tries"] >= args.max_tries:
218
+ core.ledger_append(store, "suspect", id=task_id, tries=claimed["tries"], rc=rc)
219
+ print(
220
+ f"left {task_id} in processing: rc={rc}, tries={claimed['tries']} >= {args.max_tries}. "
221
+ f"Check `status`, then `reap` if it should be retried anyway.",
222
+ flush=True,
223
+ )
224
+ return rc if 0 < rc < 256 else core.EXIT_ERROR
225
+
226
+ core.release(store, args.who, task_id, reason=f"rc={rc}")
227
+ print(f"released {task_id} back to pending (rc={rc})", flush=True)
228
+ return rc if 0 < rc < 256 else core.EXIT_ERROR
229
+
230
+
231
+ def main(argv: list[str] | None = None) -> int:
232
+ parser = build_parser()
233
+ args = parser.parse_args(argv)
234
+ try:
235
+ return run(args)
236
+ except core.keepwatchError as exc:
237
+ print(f"keepwatch: {exc}", file=sys.stderr)
238
+ return exc.code
239
+
240
+
241
+ if __name__ == "__main__": # pragma: no cover
242
+ sys.exit(main())
keepwatch/core.py ADDED
@@ -0,0 +1,554 @@
1
+ """keepwatch core.
2
+
3
+ Three ideas, nothing more:
4
+
5
+ 1. A task is a JSON file that lives in exactly one of three folders:
6
+ ``pending`` -> ``processing`` -> ``done``.
7
+ 2. Whoever is working on a task holds a *lease*. If that worker dies, the lease
8
+ expires and ``reap`` puts the task back. The task keeps a hand-over trail.
9
+ 3. A task is only ``done`` when a ``verify`` record with ``rc == 0`` exists.
10
+ The model can say "it passed"; the ledger is what proves it.
11
+
12
+ Everything is files. No database, no daemon, no third-party dependency.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import socket
21
+ import time
22
+ import uuid
23
+ from contextlib import contextmanager
24
+ from datetime import datetime, timedelta, timezone
25
+ from pathlib import Path
26
+ from typing import Any, Iterable
27
+
28
+ SCHEMA_VERSION = 1
29
+ PENDING = "pending"
30
+ PROCESSING = "processing"
31
+ DONE = "done"
32
+ BOXES = (PENDING, PROCESSING, DONE)
33
+
34
+ AGENT_RE = re.compile(r"^[a-z0-9_-]{1,32}$")
35
+ DEFAULT_TTL_SECONDS = 300
36
+ DEFAULT_STALE_SECONDS = 1200
37
+
38
+ EXIT_OK = 0
39
+ EXIT_ERROR = 1
40
+ EXIT_NOT_FOUND = 2
41
+ EXIT_HELD = 3
42
+ EXIT_BLOCKED = 13
43
+
44
+
45
+ class keepwatchError(Exception):
46
+ """Base error. ``code`` is part of the CLI contract."""
47
+
48
+ code = EXIT_ERROR
49
+
50
+
51
+ class NotFound(keepwatchError):
52
+ code = EXIT_NOT_FOUND
53
+
54
+
55
+ class Held(keepwatchError):
56
+ code = EXIT_HELD
57
+
58
+
59
+ class Blocked(keepwatchError):
60
+ code = EXIT_BLOCKED
61
+
62
+
63
+ # --------------------------------------------------------------------------
64
+ # time helpers (always UTC, always aware)
65
+ # --------------------------------------------------------------------------
66
+ def utcnow() -> datetime:
67
+ return datetime.now(timezone.utc)
68
+
69
+
70
+ def stamp(dt: datetime | None = None) -> str:
71
+ # Milliseconds, not seconds: two tasks created in the same second must still
72
+ # have a deterministic order when we pick "the oldest pending one".
73
+ return (dt or utcnow()).isoformat(timespec="milliseconds").replace("+00:00", "Z")
74
+
75
+
76
+ def parse_stamp(text: str) -> datetime:
77
+ return datetime.fromisoformat(text.replace("Z", "+00:00"))
78
+
79
+
80
+ def age_seconds(text: str) -> float:
81
+ return (utcnow() - parse_stamp(text)).total_seconds()
82
+
83
+
84
+ # --------------------------------------------------------------------------
85
+ # layout
86
+ # --------------------------------------------------------------------------
87
+ class Store:
88
+ """The directory layout. Construct once, pass around."""
89
+
90
+ def __init__(self, root: str | Path):
91
+ self.root = Path(root).expanduser().resolve()
92
+
93
+ # -- paths ----------------------------------------------------------
94
+ def agent_dir(self, agent: str) -> Path:
95
+ if not AGENT_RE.match(agent):
96
+ raise keepwatchError(f"bad agent id: {agent!r} (want [a-z0-9_-]{{1,32}})")
97
+ return self.root / "agents" / agent
98
+
99
+ def box(self, agent: str, box: str) -> Path:
100
+ return self.agent_dir(agent) / box
101
+
102
+ def task_path(self, agent: str, box: str, task_id: str) -> Path:
103
+ return self.box(agent, box) / f"{task_id}.json"
104
+
105
+ def heartbeat_path(self, agent: str) -> Path:
106
+ return self.root / "state" / f"{agent}.heartbeat.json"
107
+
108
+ def audit_path(self) -> Path:
109
+ return self.root / "audit" / f"{utcnow():%Y-%m}.jsonl"
110
+
111
+ # -- lifecycle ------------------------------------------------------
112
+ def init(self, agents: Iterable[str]) -> Store:
113
+ for agent in agents:
114
+ self.agent_dir(agent)
115
+ for box in BOXES:
116
+ self.box(agent, box).mkdir(parents=True, exist_ok=True)
117
+ (self.root / "state").mkdir(parents=True, exist_ok=True)
118
+ (self.root / "audit").mkdir(parents=True, exist_ok=True)
119
+ return self
120
+
121
+ def require(self) -> None:
122
+ if not (self.root / "agents").is_dir():
123
+ raise keepwatchError(f"{self.root} is not a keepwatch root; run `init` first")
124
+
125
+ def agents(self) -> list[str]:
126
+ base = self.root / "agents"
127
+ if not base.is_dir():
128
+ return []
129
+ return sorted(p.name for p in base.iterdir() if p.is_dir())
130
+
131
+
132
+ # --------------------------------------------------------------------------
133
+ # files
134
+ # --------------------------------------------------------------------------
135
+ def _read(path: Path) -> dict[str, Any]:
136
+ for attempt in range(6):
137
+ try:
138
+ return json.loads(path.read_text(encoding="utf-8"))
139
+ except FileNotFoundError:
140
+ raise NotFound(f"task not found: {path.name}") from None
141
+ except PermissionError:
142
+ # Windows may refuse a read while another process renames the file.
143
+ if attempt == 5:
144
+ raise Held(f"task is busy (locked by another process): {path.name}") from None
145
+ time.sleep(0.005)
146
+ except json.JSONDecodeError as exc:
147
+ raise keepwatchError(f"corrupt task file {path.name}: {exc}") from exc
148
+ raise Held(f"task is busy: {path.name}")
149
+
150
+
151
+ def _replace(src: Path, dst: Path, retries: int = 10, delay: float = 0.005) -> None:
152
+ """os.replace with a short retry loop.
153
+
154
+ On Windows a rename fails with PermissionError (WinError 32) while another
155
+ process has the file open, even for a moment. Retrying turns a spurious
156
+ failure into a correct outcome: whoever wins the retry race owns the file.
157
+ """
158
+ last: Exception | None = None
159
+ for _ in range(retries):
160
+ try:
161
+ os.replace(src, dst)
162
+ return
163
+ except FileNotFoundError:
164
+ raise
165
+ except PermissionError as exc:
166
+ last = exc
167
+ time.sleep(delay)
168
+ assert last is not None
169
+ raise last
170
+
171
+
172
+ def _write_atomic(path: Path, payload: dict[str, Any]) -> None:
173
+ """Write to a uniquely named temp file, then rename. No half files, no shared temps."""
174
+ path.parent.mkdir(parents=True, exist_ok=True)
175
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp")
176
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
177
+ try:
178
+ _replace(tmp, path)
179
+ finally:
180
+ if tmp.exists():
181
+ tmp.unlink(missing_ok=True)
182
+
183
+
184
+ def _move(src: Path, dst: Path) -> None:
185
+ """Atomic move. If src disappeared, someone else won the race."""
186
+ dst.parent.mkdir(parents=True, exist_ok=True)
187
+ try:
188
+ _replace(src, dst)
189
+ except FileNotFoundError:
190
+ raise NotFound(f"task already taken: {dst.name}") from None
191
+ except PermissionError:
192
+ raise Held(f"task is busy (locked by another process): {dst.name}") from None
193
+
194
+
195
+ @contextmanager
196
+ def _exclusive(path: Path, stale_after: float = 30.0):
197
+ """A mutex built on ``O_CREAT | O_EXCL``.
198
+
199
+ Do **not** use rename as a lock. On at least one Windows filesystem two
200
+ concurrent ``os.replace()`` calls on the same source both report success,
201
+ which silently lets two workers claim one task. Exclusive create was the one
202
+ primitive that stayed atomic under contention, so it is the one we use.
203
+ """
204
+ acquired = False
205
+ for _ in range(4):
206
+ try:
207
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
208
+ os.write(fd, str(os.getpid()).encode())
209
+ os.close(fd)
210
+ acquired = True
211
+ break
212
+ except FileExistsError:
213
+ try:
214
+ if time.time() - path.stat().st_mtime > stale_after:
215
+ path.unlink(missing_ok=True)
216
+ continue
217
+ except FileNotFoundError:
218
+ continue
219
+ raise Held(f"task is busy (someone is working on it): {path.name}") from None
220
+ if not acquired:
221
+ raise Held(f"task is busy: {path.name}")
222
+ try:
223
+ yield
224
+ finally:
225
+ path.unlink(missing_ok=True)
226
+
227
+
228
+ def heartbeat(store: Store, agent: str) -> dict[str, Any]:
229
+ payload = {"agent": agent, "at": stamp(), "pid": os.getpid(), "host": socket.gethostname()}
230
+ _write_atomic(store.heartbeat_path(agent), payload)
231
+ return payload
232
+
233
+
234
+ def ledger_append(store: Store, event: str, **fields: Any) -> dict[str, Any]:
235
+ record = {"at": stamp(), "event": event, "schema": SCHEMA_VERSION, **fields}
236
+ path = store.audit_path()
237
+ path.parent.mkdir(parents=True, exist_ok=True)
238
+ with path.open("a", encoding="utf-8") as handle:
239
+ handle.write(json.dumps(record, ensure_ascii=False) + "\n")
240
+ return record
241
+
242
+
243
+ def ledger_records(store: Store) -> list[dict[str, Any]]:
244
+ records: list[dict[str, Any]] = []
245
+ audit = store.root / "audit"
246
+ if not audit.is_dir():
247
+ return records
248
+ for path in sorted(audit.glob("*.jsonl")):
249
+ for line in path.read_text(encoding="utf-8").splitlines():
250
+ line = line.strip()
251
+ if not line:
252
+ continue
253
+ try:
254
+ records.append(json.loads(line))
255
+ except json.JSONDecodeError:
256
+ continue
257
+ return records
258
+
259
+
260
+ # --------------------------------------------------------------------------
261
+ # commands
262
+ # --------------------------------------------------------------------------
263
+ def new_task_id() -> str:
264
+ return f"{utcnow():%Y%m%dT%H%M%S}-{uuid.uuid4().hex[:6]}"
265
+
266
+
267
+ def send(store: Store, sender: str, to: str, subject: str, body: str = "") -> dict[str, Any]:
268
+ store.require()
269
+ if to not in store.agents():
270
+ raise keepwatchError(f"unknown agent: {to!r}")
271
+ task = {
272
+ "id": new_task_id(),
273
+ "from": sender,
274
+ "to": to,
275
+ "subject": subject,
276
+ "body": body,
277
+ "created_at": stamp(),
278
+ "state": PENDING,
279
+ "tries": 0,
280
+ "prev_owners": [],
281
+ "_lease": None,
282
+ }
283
+ _write_atomic(store.task_path(to, PENDING, task["id"]), task)
284
+ ledger_append(store, "send", id=task["id"], from_=sender, to=to, subject=subject)
285
+ return task
286
+
287
+
288
+ def list_tasks(store: Store, agent: str, box: str = PENDING) -> list[dict[str, Any]]:
289
+ store.require()
290
+ folder = store.box(agent, box)
291
+ if not folder.is_dir():
292
+ return []
293
+ tasks = []
294
+ for path in sorted(folder.glob("*.json")):
295
+ try:
296
+ tasks.append(_read(path))
297
+ except keepwatchError:
298
+ continue
299
+ return tasks
300
+
301
+
302
+ def check(store: Store, agent: str) -> list[dict[str, Any]]:
303
+ """What can I pick up right now?"""
304
+ return list_tasks(store, agent, PENDING)
305
+
306
+
307
+ def claim(store: Store, agent: str, task_id: str, ttl: int = DEFAULT_TTL_SECONDS) -> dict[str, Any]:
308
+ store.require()
309
+ token = store.box(agent, PROCESSING) / f".{task_id}.claim"
310
+ with _exclusive(token):
311
+ return _claim_locked(store, agent, task_id, ttl)
312
+
313
+
314
+ def _claim_locked(store: Store, agent: str, task_id: str, ttl: int) -> dict[str, Any]:
315
+ now = utcnow()
316
+ pending = store.task_path(agent, PENDING, task_id)
317
+ processing = store.task_path(agent, PROCESSING, task_id)
318
+
319
+ if pending.exists():
320
+ task = _read(pending)
321
+ _move(pending, processing)
322
+ task["state"] = PROCESSING
323
+ task["tries"] = int(task.get("tries", 0)) + 1
324
+ task["_lease"] = {
325
+ "owner": agent,
326
+ "acquired_at": stamp(now),
327
+ "expires_at": stamp(now + timedelta(seconds=ttl)),
328
+ }
329
+ _write_atomic(processing, task)
330
+ ledger_append(store, "claim", id=task_id, by=agent, tries=task["tries"], ttl=ttl)
331
+ return task
332
+
333
+ if processing.exists():
334
+ task = _read(processing)
335
+ lease = task.get("_lease") or {}
336
+ owner = lease.get("owner")
337
+ expires = lease.get("expires_at")
338
+ if not expires:
339
+ # The file is in processing but has no lease yet: another process is
340
+ # between "moved the file" and "wrote the lease". Never steal that.
341
+ raise Held(f"task {task_id} is being claimed right now (no lease yet)")
342
+ if parse_stamp(expires) > now:
343
+ # Strict on purpose: a second claim means two workers would run the
344
+ # same task. Use `renew` to extend your own lease instead.
345
+ raise Held(f"task {task_id} is already claimed (owner={owner}, until {expires})")
346
+ # Lease expired: take it over, and leave a trail.
347
+ task.setdefault("prev_owners", []).append(owner)
348
+ task["tries"] = int(task.get("tries", 0)) + 1
349
+ task["_lease"] = {
350
+ "owner": agent,
351
+ "acquired_at": stamp(now),
352
+ "expires_at": stamp(now + timedelta(seconds=ttl)),
353
+ }
354
+ _write_atomic(processing, task)
355
+ ledger_append(store, "steal", id=task_id, by=agent, from_=owner, tries=task["tries"])
356
+ return task
357
+
358
+ raise NotFound(f"no such task in {agent}: {task_id}")
359
+
360
+
361
+ def renew(store: Store, agent: str, task_id: str, ttl: int = DEFAULT_TTL_SECONDS) -> dict[str, Any]:
362
+ """Extend your own lease. Only the current owner may call this."""
363
+ store.require()
364
+ path = store.task_path(agent, PROCESSING, task_id)
365
+ with _exclusive(store.box(agent, PROCESSING) / f".{task_id}.claim"):
366
+ task = _read(path)
367
+ lease = task.get("_lease") or {}
368
+ if lease.get("owner") != agent:
369
+ raise Held(f"task {task_id} is not yours (owner={lease.get('owner')})")
370
+ lease["expires_at"] = stamp(utcnow() + timedelta(seconds=ttl))
371
+ task["_lease"] = lease
372
+ _write_atomic(path, task)
373
+ ledger_append(store, "renew", id=task_id, by=agent, ttl=ttl)
374
+ return task
375
+
376
+
377
+ def release(store: Store, agent: str, task_id: str, reason: str = "") -> dict[str, Any]:
378
+ """Put a task back to pending without closing it (failed run, or giving up)."""
379
+ store.require()
380
+ path = store.task_path(agent, PROCESSING, task_id)
381
+ token = store.box(agent, PROCESSING) / f".{task_id}.claim"
382
+ with _exclusive(token):
383
+ task = _read(path)
384
+ lease = task.get("_lease") or {}
385
+ if lease.get("owner") not in (None, agent):
386
+ raise Held(f"task {task_id} is held by {lease.get('owner')}")
387
+ task["state"] = PENDING
388
+ task["prev_owners"] = list(task.get("prev_owners", [])) + [lease.get("owner")]
389
+ task["_lease"] = None
390
+ task["released_at"] = stamp()
391
+ _write_atomic(path, task)
392
+ _move(path, store.task_path(agent, PENDING, task_id))
393
+ ledger_append(store, "release", id=task_id, by=agent, reason=reason)
394
+ return task
395
+
396
+
397
+ def oldest_pending(store: Store, agent: str) -> dict[str, Any] | None:
398
+ tasks = list_tasks(store, agent, PENDING)
399
+ if not tasks:
400
+ return None
401
+ return min(tasks, key=lambda t: (t.get("created_at", ""), t["id"]))
402
+
403
+
404
+ def verify(store: Store, task_id: str, name: str, rc: int) -> dict[str, Any]:
405
+ """Record a verification result. The task is not done until this returns 0."""
406
+ store.require()
407
+ return ledger_append(store, "verify", id=task_id, name=name, rc=int(rc))
408
+
409
+
410
+ def has_ok_verify(store: Store, task_id: str) -> bool:
411
+ return any(
412
+ r.get("event") == "verify" and r.get("id") == task_id and r.get("rc") == 0
413
+ for r in ledger_records(store)
414
+ )
415
+
416
+
417
+ def done(
418
+ store: Store,
419
+ agent: str,
420
+ task_id: str,
421
+ note: str = "",
422
+ force: bool = False,
423
+ ) -> dict[str, Any]:
424
+ store.require()
425
+ processing = store.task_path(agent, PROCESSING, task_id)
426
+ with _exclusive(store.box(agent, PROCESSING) / f".{task_id}.claim"):
427
+ task = _read(processing)
428
+ lease = task.get("_lease") or {}
429
+ if lease.get("owner") not in (None, agent):
430
+ raise Held(f"task {task_id} is held by {lease.get('owner')}")
431
+
432
+ if not force and not has_ok_verify(store, task_id):
433
+ raise Blocked(
434
+ f"refusing to close {task_id}: no verify record with rc=0. "
435
+ f"Run `verify --id {task_id} --name <check> --rc 0` first, or pass --force."
436
+ )
437
+
438
+ task["state"] = DONE
439
+ task["_lease"] = None
440
+ task["closed_at"] = stamp()
441
+ task["closed_by"] = agent
442
+ task["note"] = note
443
+ _write_atomic(processing, task)
444
+ _move(processing, store.task_path(agent, DONE, task_id))
445
+ ledger_append(store, "done", id=task_id, by=agent, note=note)
446
+ return task
447
+
448
+
449
+ def reap(store: Store, stale_after: int = 0) -> list[dict[str, Any]]:
450
+ """Return expired leases to pending. This is what runs after a worker dies."""
451
+ store.require()
452
+ now = utcnow()
453
+ reaped: list[dict[str, Any]] = []
454
+ for agent in store.agents():
455
+ for task in list_tasks(store, agent, PROCESSING):
456
+ lease = task.get("_lease") or {}
457
+ path = store.task_path(agent, PROCESSING, task["id"])
458
+ expires = lease.get("expires_at")
459
+ if expires:
460
+ deadline = parse_stamp(expires)
461
+ else:
462
+ # Orphan: crashed between moving the file and writing the lease.
463
+ deadline = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
464
+ if deadline + timedelta(seconds=stale_after) > now:
465
+ continue
466
+ task_id = task["id"]
467
+ token = store.box(agent, PROCESSING) / f".{task_id}.claim"
468
+ try:
469
+ with _exclusive(token):
470
+ task["state"] = PENDING
471
+ task["reaped_at"] = stamp(now)
472
+ task["prev_owners"] = list(task.get("prev_owners", [])) + [lease.get("owner")]
473
+ task["_lease"] = None
474
+ _write_atomic(store.task_path(agent, PROCESSING, task_id), task)
475
+ _move(
476
+ store.task_path(agent, PROCESSING, task_id),
477
+ store.task_path(agent, PENDING, task_id),
478
+ )
479
+ except Held:
480
+ continue # someone is mid-claim; leave it for the next sweep
481
+ ledger_append(store, "reap", id=task_id, agent=agent, owner=lease.get("owner"))
482
+ reaped.append(task)
483
+ return reaped
484
+
485
+
486
+ def status(store: Store, stale_after: int = DEFAULT_STALE_SECONDS) -> dict[str, Any]:
487
+ """Everything a human needs to answer: what is stuck, who is dead, what is next."""
488
+ store.require()
489
+ now = utcnow()
490
+ agents: dict[str, Any] = {}
491
+ expired: list[dict[str, Any]] = []
492
+ next_steps: list[str] = []
493
+
494
+ for agent in store.agents():
495
+ hb_path = store.heartbeat_path(agent)
496
+ hb_age = None
497
+ if hb_path.exists():
498
+ try:
499
+ hb_age = int(age_seconds(json.loads(hb_path.read_text(encoding="utf-8"))["at"]))
500
+ except (KeyError, ValueError, json.JSONDecodeError):
501
+ hb_age = None
502
+ alive = hb_age is not None and hb_age <= stale_after
503
+
504
+ pending = list_tasks(store, agent, PENDING)
505
+ processing = list_tasks(store, agent, PROCESSING)
506
+ finished = list_tasks(store, agent, DONE)
507
+ agents[agent] = {
508
+ "pending": len(pending),
509
+ "processing": len(processing),
510
+ "done": len(finished),
511
+ "heartbeat_age_s": hb_age,
512
+ "alive": alive,
513
+ }
514
+
515
+ for task in processing:
516
+ lease = task.get("_lease") or {}
517
+ expires = lease.get("expires_at")
518
+ if expires:
519
+ deadline = parse_stamp(expires)
520
+ else:
521
+ deadline = datetime.fromtimestamp(
522
+ store.task_path(agent, PROCESSING, task["id"]).stat().st_mtime,
523
+ tz=timezone.utc,
524
+ )
525
+ if deadline <= now:
526
+ expired.append(
527
+ {
528
+ "agent": agent,
529
+ "id": task["id"],
530
+ "held_by": lease.get("owner"),
531
+ "overdue_s": int((now - deadline).total_seconds()),
532
+ }
533
+ )
534
+
535
+ if pending and not processing and not alive:
536
+ next_steps.append(
537
+ f"{agent}: {len(pending)} task(s) waiting, and no live heartbeat - is the worker dead?"
538
+ )
539
+ elif pending and alive:
540
+ next_steps.append(f"{agent}: {len(pending)} task(s) waiting to be claimed")
541
+
542
+ for item in expired:
543
+ next_steps.append(
544
+ f"{item['agent']}: {item['id']} held by {item['held_by'] or 'nobody'}, "
545
+ f"{item['overdue_s']}s past its lease - run `reap`"
546
+ )
547
+
548
+ return {
549
+ "root": str(store.root),
550
+ "agents": agents,
551
+ "expired_leases": expired,
552
+ "next_steps": next_steps,
553
+ "ok": not expired,
554
+ }
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepwatch
3
+ Version: 0.1.1
4
+ Summary: Reliability layer for unattended agent mailboxes: leases, heartbeats, and silent-failure detection.
5
+ License: MIT
6
+ Project-URL: Documentation, https://github.com/OWNER/keepwatch#readme
7
+ Project-URL: Issues, https://github.com/OWNER/keepwatch/issues
8
+ Keywords: agents,multi-agent,reliability,mailbox,unattended
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: System :: Distributed Computing
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # keepwatch
22
+
23
+ > **Keep work alive when the process that started it is not.**
24
+ > A 300-line, zero-dependency ledger for agents that run unattended.
25
+
26
+ [![ci](https://github.com/ReZeroStarting/keepwatch/actions/workflows/ci.yml/badge.svg)](https://github.com/ReZeroStarting/keepwatch/actions/workflows/ci.yml)
27
+
28
+ ---
29
+
30
+ ## The problem
31
+
32
+ An unattended pipeline does not fail loudly. It goes quiet:
33
+
34
+ - a task sits in `pending/` and nobody claims it
35
+ - a worker dies mid-task and the task is stuck in `processing/` forever
36
+ - a worker finishes but never marks it done, so the next round re-dispatches it
37
+ - the agent says "all tests pass" and you have nothing to check that against
38
+
39
+ All four look identical from the outside: **nothing happened, and the logs are clean.**
40
+
41
+ ## What it does
42
+
43
+ Three folders, one ledger, four verbs.
44
+
45
+ ```
46
+ <root>/agents/<agent>/pending/ tasks a worker can pick up
47
+ <root>/agents/<agent>/processing/ tasks someone is holding (with a lease)
48
+ <root>/agents/<agent>/done/ closed tasks, never deleted
49
+ <root>/audit/YYYY-MM.jsonl append-only record of everything
50
+ ```
51
+
52
+ | Verb | Meaning |
53
+ |---|---|
54
+ | `check` | what can I pick up |
55
+ | `claim` | take one, and hold a lease on it |
56
+ | `done` | close it — **only if a passing `verify` record exists** |
57
+ | `verify` | record a check result (`--rc 0`) against a task |
58
+ | **`run`** | **claim → run a command → record its exit code → close or release** |
59
+
60
+ Plus `send`, `renew`, `release`, `reap`, `status`.
61
+
62
+ ## The one command you actually need
63
+
64
+ ```bash
65
+ keepwatch run --who builder -- python -m pytest
66
+ ```
67
+
68
+ That does the whole loop in one go:
69
+
70
+ 1. claims the oldest pending task (or `--id <id>`)
71
+ 2. runs the command with inherited stdio, so you still see the output
72
+ 3. writes `verify ... --rc <real exit code>` into the ledger
73
+ 4. `rc=0` → `done`; `rc!=0` → back to `pending` for another try
74
+ 5. after `--max-tries` (default 3) failures → left in `processing` with a `suspect` record
75
+
76
+ If the process is killed mid-command, nothing is lost: the lease expires and `reap` puts the
77
+ task back. **You do not have to remember to write the ledger — the wrapper does it.**
78
+
79
+ ## Why it matters
80
+
81
+ `claim` starts a lease. If the worker dies, the lease expires and `reap` puts the task
82
+ back with a trail (`tries`, `prev_owners`). No task is ever stranded, and no task is
83
+ silently done twice.
84
+
85
+ `done` refuses to close a task unless the ledger holds a `verify ... rc=0` for it.
86
+ That is the whole point: **the model can say "it passed"; the ledger is what proves it.**
87
+
88
+ ```console
89
+ $ keepwatch done --who builder --id 20261023T083729-8c4fae
90
+ keepwatch: refusing to close 20261023T083729-8c4fae: no verify record with rc=0.
91
+ Run `verify --id ... --name <check> --rc 0` first, or pass --force.
92
+ $ echo $?
93
+ 13
94
+ ```
95
+
96
+ ## Install
97
+
98
+ Python 3.9+, no dependencies.
99
+
100
+ ```bash
101
+ pip install -e .
102
+ # or just run it from a checkout:
103
+ PYTHONPATH=src python -m keepwatch.cli --help
104
+ ```
105
+
106
+ ## 60-second demo
107
+
108
+ ```bash
109
+ keepwatch --root /tmp/ns init builder reviewer
110
+
111
+ keepwatch --root /tmp/ns send \
112
+ --from reviewer --to builder --subject "batch 3: add login page"
113
+
114
+ keepwatch --root /tmp/ns check --who builder
115
+ keepwatch --root /tmp/ns claim --who builder --id <id> --ttl 60
116
+ ```
117
+
118
+ Now pretend the worker dies. Wait for the lease, then:
119
+
120
+ ```console
121
+ $ keepwatch --root /tmp/ns status
122
+ root: /tmp/ns
123
+ builder pending 0 processing 1 done 0 hb 61s (alive)
124
+ reviewer pending 0 processing 0 done 0 never seen (unknown)
125
+ next:
126
+ - builder: 20261023T083750-ef2c5d held by builder, 1s past its lease - run `reap`
127
+
128
+ $ keepwatch --root /tmp/ns reap
129
+ reaped 1 task(s)
130
+ 20261023T083750-ef2c5d back to pending (tries=1)
131
+
132
+ $ keepwatch --root /tmp/ns claim --who builder --id 20261023T083750-ef2c5d
133
+ claimed 20261023T083750-ef2c5d (try 2) lease until 2026-10-23T08:42:31Z
134
+ ```
135
+
136
+ The task came back with `tries=1` and the previous owner recorded. Close it, but only
137
+ after proof:
138
+
139
+ ```bash
140
+ keepwatch --root /tmp/ns verify --id <id> --name pytest --rc 0
141
+ keepwatch --root /tmp/ns done --who builder --id <id> --note "e2e green"
142
+ ```
143
+
144
+ ## Exit codes
145
+
146
+ Exit codes are the interface; agents read them instead of parsing prose.
147
+
148
+ | Code | Meaning |
149
+ |---|---|
150
+ | 0 | ok |
151
+ | 1 | bad arguments, IO error |
152
+ | 2 | task not found / already taken |
153
+ | 3 | task held by another worker (or busy) |
154
+ | 13 | blocked: no passing verify, or `status` found something that needs attention |
155
+
156
+ ## Design notes
157
+
158
+ **Files are the source of truth.** No database, no daemon, no network. `ls` and `cat`
159
+ are the debugging tools.
160
+
161
+ **Exclusive create is the mutex, not rename.** On at least one Windows filesystem two
162
+ concurrent `os.replace()` calls on the same source *both report success*. Claiming is
163
+ therefore guarded by `O_CREAT|O_EXCL` on a short-lived token file. This is documented in
164
+ [`docs/PROTOCOL.md`](docs/PROTOCOL.md) because it cost a real bug to find.
165
+
166
+ **One worker per agent box.** Leases are per agent directory, so `builder` running two
167
+ processes is a configuration error, not a supported mode.
168
+
169
+ **`status` exits 13 when something needs attention** — so `keepwatch status || alert`
170
+ works in cron.
171
+
172
+ ## What it is not
173
+
174
+ - not a mailbox (see [docs/COMPETITION.md](docs/COMPETITION.md) for what exists)
175
+ - not an orchestrator: it never decides *what* to do next
176
+ - not an agent: it makes no model calls
177
+ - not durable execution: it will not replay your side effects
178
+
179
+ ## Docs
180
+
181
+ | File | Contents |
182
+ |---|---|
183
+ | [docs/PROTOCOL.md](docs/PROTOCOL.md) | layout, task format, lease rules, exit codes |
184
+ | [docs/COMPETITION.md](docs/COMPETITION.md) | why this exists next to agent-mailbox / A2A / Temporal |
185
+ | [docs/ROADMAP.md](docs/ROADMAP.md) | v0.1 → v1.0 and the release checklist |
186
+ | [docs/zh/](docs/zh/) | 中文原文 (Chinese originals) |
187
+
188
+ Tests: `PYTHONPATH=src python -m unittest discover -s tests -v`
189
+
190
+ ## License
191
+
192
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,9 @@
1
+ keepwatch/__init__.py,sha256=BSckz3Ju9p8d17DnoE5TMLqLhdYuRu6Iu4litEjq-BQ,429
2
+ keepwatch/cli.py,sha256=puT2EKicKcbe0mMIRJrVBbA4_5XAtSIyucGByqKQGb8,9290
3
+ keepwatch/core.py,sha256=_nNQOZYNwahMuzAaaCtUEK-exuHFPzbKqWvkO01fVrQ,20162
4
+ keepwatch-0.1.1.dist-info/licenses/LICENSE,sha256=9IP-h92D0ka3PdvhJtELf0-B4aSEGPGLfTx-m6oA9L8,1080
5
+ keepwatch-0.1.1.dist-info/METADATA,sha256=MpOLzgMA5uBu-IyoiAqL4DBepv2NiSZqwRy6kBLgSac,6624
6
+ keepwatch-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ keepwatch-0.1.1.dist-info/entry_points.txt,sha256=JEzd6ch5sCcBtTy24E3tCAZAXStW6sSTQdwt3nPNtPI,49
8
+ keepwatch-0.1.1.dist-info/top_level.txt,sha256=M0z5U1DUcrRuGXqzBow4QXHGgNIIXpps_gAD1wPOYec,10
9
+ keepwatch-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ keepwatch = keepwatch.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nightshift contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ keepwatch