keepwatch 0.1.1__tar.gz

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.
@@ -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,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,172 @@
1
+ # keepwatch
2
+
3
+ > **Keep work alive when the process that started it is not.**
4
+ > A 300-line, zero-dependency ledger for agents that run unattended.
5
+
6
+ [![ci](https://github.com/ReZeroStarting/keepwatch/actions/workflows/ci.yml/badge.svg)](https://github.com/ReZeroStarting/keepwatch/actions/workflows/ci.yml)
7
+
8
+ ---
9
+
10
+ ## The problem
11
+
12
+ An unattended pipeline does not fail loudly. It goes quiet:
13
+
14
+ - a task sits in `pending/` and nobody claims it
15
+ - a worker dies mid-task and the task is stuck in `processing/` forever
16
+ - a worker finishes but never marks it done, so the next round re-dispatches it
17
+ - the agent says "all tests pass" and you have nothing to check that against
18
+
19
+ All four look identical from the outside: **nothing happened, and the logs are clean.**
20
+
21
+ ## What it does
22
+
23
+ Three folders, one ledger, four verbs.
24
+
25
+ ```
26
+ <root>/agents/<agent>/pending/ tasks a worker can pick up
27
+ <root>/agents/<agent>/processing/ tasks someone is holding (with a lease)
28
+ <root>/agents/<agent>/done/ closed tasks, never deleted
29
+ <root>/audit/YYYY-MM.jsonl append-only record of everything
30
+ ```
31
+
32
+ | Verb | Meaning |
33
+ |---|---|
34
+ | `check` | what can I pick up |
35
+ | `claim` | take one, and hold a lease on it |
36
+ | `done` | close it — **only if a passing `verify` record exists** |
37
+ | `verify` | record a check result (`--rc 0`) against a task |
38
+ | **`run`** | **claim → run a command → record its exit code → close or release** |
39
+
40
+ Plus `send`, `renew`, `release`, `reap`, `status`.
41
+
42
+ ## The one command you actually need
43
+
44
+ ```bash
45
+ keepwatch run --who builder -- python -m pytest
46
+ ```
47
+
48
+ That does the whole loop in one go:
49
+
50
+ 1. claims the oldest pending task (or `--id <id>`)
51
+ 2. runs the command with inherited stdio, so you still see the output
52
+ 3. writes `verify ... --rc <real exit code>` into the ledger
53
+ 4. `rc=0` → `done`; `rc!=0` → back to `pending` for another try
54
+ 5. after `--max-tries` (default 3) failures → left in `processing` with a `suspect` record
55
+
56
+ If the process is killed mid-command, nothing is lost: the lease expires and `reap` puts the
57
+ task back. **You do not have to remember to write the ledger — the wrapper does it.**
58
+
59
+ ## Why it matters
60
+
61
+ `claim` starts a lease. If the worker dies, the lease expires and `reap` puts the task
62
+ back with a trail (`tries`, `prev_owners`). No task is ever stranded, and no task is
63
+ silently done twice.
64
+
65
+ `done` refuses to close a task unless the ledger holds a `verify ... rc=0` for it.
66
+ That is the whole point: **the model can say "it passed"; the ledger is what proves it.**
67
+
68
+ ```console
69
+ $ keepwatch done --who builder --id 20261023T083729-8c4fae
70
+ keepwatch: refusing to close 20261023T083729-8c4fae: no verify record with rc=0.
71
+ Run `verify --id ... --name <check> --rc 0` first, or pass --force.
72
+ $ echo $?
73
+ 13
74
+ ```
75
+
76
+ ## Install
77
+
78
+ Python 3.9+, no dependencies.
79
+
80
+ ```bash
81
+ pip install -e .
82
+ # or just run it from a checkout:
83
+ PYTHONPATH=src python -m keepwatch.cli --help
84
+ ```
85
+
86
+ ## 60-second demo
87
+
88
+ ```bash
89
+ keepwatch --root /tmp/ns init builder reviewer
90
+
91
+ keepwatch --root /tmp/ns send \
92
+ --from reviewer --to builder --subject "batch 3: add login page"
93
+
94
+ keepwatch --root /tmp/ns check --who builder
95
+ keepwatch --root /tmp/ns claim --who builder --id <id> --ttl 60
96
+ ```
97
+
98
+ Now pretend the worker dies. Wait for the lease, then:
99
+
100
+ ```console
101
+ $ keepwatch --root /tmp/ns status
102
+ root: /tmp/ns
103
+ builder pending 0 processing 1 done 0 hb 61s (alive)
104
+ reviewer pending 0 processing 0 done 0 never seen (unknown)
105
+ next:
106
+ - builder: 20261023T083750-ef2c5d held by builder, 1s past its lease - run `reap`
107
+
108
+ $ keepwatch --root /tmp/ns reap
109
+ reaped 1 task(s)
110
+ 20261023T083750-ef2c5d back to pending (tries=1)
111
+
112
+ $ keepwatch --root /tmp/ns claim --who builder --id 20261023T083750-ef2c5d
113
+ claimed 20261023T083750-ef2c5d (try 2) lease until 2026-10-23T08:42:31Z
114
+ ```
115
+
116
+ The task came back with `tries=1` and the previous owner recorded. Close it, but only
117
+ after proof:
118
+
119
+ ```bash
120
+ keepwatch --root /tmp/ns verify --id <id> --name pytest --rc 0
121
+ keepwatch --root /tmp/ns done --who builder --id <id> --note "e2e green"
122
+ ```
123
+
124
+ ## Exit codes
125
+
126
+ Exit codes are the interface; agents read them instead of parsing prose.
127
+
128
+ | Code | Meaning |
129
+ |---|---|
130
+ | 0 | ok |
131
+ | 1 | bad arguments, IO error |
132
+ | 2 | task not found / already taken |
133
+ | 3 | task held by another worker (or busy) |
134
+ | 13 | blocked: no passing verify, or `status` found something that needs attention |
135
+
136
+ ## Design notes
137
+
138
+ **Files are the source of truth.** No database, no daemon, no network. `ls` and `cat`
139
+ are the debugging tools.
140
+
141
+ **Exclusive create is the mutex, not rename.** On at least one Windows filesystem two
142
+ concurrent `os.replace()` calls on the same source *both report success*. Claiming is
143
+ therefore guarded by `O_CREAT|O_EXCL` on a short-lived token file. This is documented in
144
+ [`docs/PROTOCOL.md`](docs/PROTOCOL.md) because it cost a real bug to find.
145
+
146
+ **One worker per agent box.** Leases are per agent directory, so `builder` running two
147
+ processes is a configuration error, not a supported mode.
148
+
149
+ **`status` exits 13 when something needs attention** — so `keepwatch status || alert`
150
+ works in cron.
151
+
152
+ ## What it is not
153
+
154
+ - not a mailbox (see [docs/COMPETITION.md](docs/COMPETITION.md) for what exists)
155
+ - not an orchestrator: it never decides *what* to do next
156
+ - not an agent: it makes no model calls
157
+ - not durable execution: it will not replay your side effects
158
+
159
+ ## Docs
160
+
161
+ | File | Contents |
162
+ |---|---|
163
+ | [docs/PROTOCOL.md](docs/PROTOCOL.md) | layout, task format, lease rules, exit codes |
164
+ | [docs/COMPETITION.md](docs/COMPETITION.md) | why this exists next to agent-mailbox / A2A / Temporal |
165
+ | [docs/ROADMAP.md](docs/ROADMAP.md) | v0.1 → v1.0 and the release checklist |
166
+ | [docs/zh/](docs/zh/) | 中文原文 (Chinese originals) |
167
+
168
+ Tests: `PYTHONPATH=src python -m unittest discover -s tests -v`
169
+
170
+ ## License
171
+
172
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ # Name checked on PyPI 2026-10-23. "nightshift", "agentbus", "agent-mailbox",
7
+ # "agent-watchdog", "deadman", "backstop", "watchpost", "handover" are all taken.
8
+ # See docs/COMPETITION.md.
9
+ name = "keepwatch"
10
+ version = "0.1.1"
11
+ description = "Reliability layer for unattended agent mailboxes: leases, heartbeats, and silent-failure detection."
12
+ readme = "README.md"
13
+ requires-python = ">=3.9"
14
+ license = { text = "MIT" }
15
+ keywords = ["agents", "multi-agent", "reliability", "mailbox", "unattended"]
16
+ classifiers = [
17
+ "Development Status :: 2 - Pre-Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: System :: Distributed Computing",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=8"]
27
+
28
+ [project.scripts]
29
+ keepwatch = "keepwatch.cli:main"
30
+
31
+ [project.urls]
32
+ Documentation = "https://github.com/OWNER/keepwatch#readme"
33
+ Issues = "https://github.com/OWNER/keepwatch/issues"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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__"]
@@ -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())