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 +11 -0
- keepwatch/cli.py +242 -0
- keepwatch/core.py +554 -0
- keepwatch-0.1.1.dist-info/METADATA +192 -0
- keepwatch-0.1.1.dist-info/RECORD +9 -0
- keepwatch-0.1.1.dist-info/WHEEL +5 -0
- keepwatch-0.1.1.dist-info/entry_points.txt +2 -0
- keepwatch-0.1.1.dist-info/licenses/LICENSE +21 -0
- keepwatch-0.1.1.dist-info/top_level.txt +1 -0
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
|
+
[](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,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
|