Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions docs/adr/015-chores/ADR-0015-chores.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
adr: ADR-0015
status: draft
liveness: operating (the handler, the gate, the store boundary, the ticks and the catch-up are pinned by tests with scripted backends and a fake store; no test drives a positive hand-run check; the catch-up's store-side filters are owed)
liveness: operating (the handler, the gate, the store boundary, the ticks and the catch-up are pinned by tests with scripted backends and a fake store; the hand-run check is driven both ways; the catch-up passes its creator and status filters to the store)
date: "2026-09-25"
area: chores
kind: new
Expand Down Expand Up @@ -220,12 +220,14 @@ with the expected actor asserted (C4).
### D4: Ticks are the store's reminders, and missed ones are caught up once ^d4

Serves C3. `ensure_ticks` reads the actor's own pending reminders from `schedule.agenda` and creates
each missing one with `schedule.remind`, first firing a minute out. `_catch_up` lists up to 200
scheduled rows, keeps the actor's own marked missed or failed, and runs each once, remembered under
each missing one with `schedule.remind`, first firing a minute out. `_catch_up` asks the store for
the actor's own scheduled rows marked missed and those marked failed, filtered by creator and status
before a page of 200 each. It checks both again in code and runs each row once, remembered under
`missed_seen`. The CLI creates ticks at serve start and on `--ticks`, never for a chair.

- **Landing evidence**: `test_ensure_ticks_creates_the_reminders_that_are_missing_and_only_those`,
`test_a_missed_tick_of_the_actors_own_is_run_at_the_next_check_and_only_once`;
`test_a_missed_tick_of_the_actors_own_is_run_at_the_next_check_and_only_once`,
`test_the_missed_tick_read_asks_the_store_for_the_actors_missed_and_failed_rows_before_the_page`;
`test_a_chair_wakes_on_the_owners_mail_reads_its_directory_and_answers_on_the_thread` asserts
`--ticks` creates nothing for a chair.

Expand All @@ -236,7 +238,9 @@ Serves C6. `agent_main` claims the identity, runs the named instrument with no a
bypasses the handler: no refusals, no row, no send.

- **Landing evidence**: `test_once_refuses_served_identity` in `tests/test_agent.py` pins its
refusal while a live process holds the identity; no test drives a positive run.
refusal while a live process holds the identity;
`test_check_with_the_identity_free_prints_and_lands_the_measurement_and_sends_nothing` drives a
positive run: the printed text is the landed text, and no send, row or tick follows.

## Alternatives

Expand All @@ -260,15 +264,17 @@ bypasses the handler: no refusals, no row, no send.
directory is not bounded, and no store-side refusal is relied on. A refusal in the store of any
write by the agent's own actor is the kernel's to give, with the identity's grants
([[ADR-0013-the-agent|ADR-0013]] S9).
- **S3**: The missed-tick read takes one page of 200 scheduled rows and filters to the actor in
code; a busy namespace can push a missed row past the page. The store's `list` filters scheduled
rows by creator and by status before the page, and the code passes neither; passing them is owed.
- **S3**: The missed-tick read passes the store's `list` the actor as creator and each of missed and
failed as status, so another actor's rows and fired ones never fill the page. The creator is
display metadata, not attribution, so the code checks both again. It reads a row's status at the
item's top level, beside `properties`, as the store returns it. More than 200 of the actor's own
missed rows still leave the rest past a page no later run advances.
- **S4**: A standing state is told once until it changes, a standing failure included: a chore that
recovers quietly and fails again the same way is not resent. The digest carries the counts, and an
owner who wants a state again asks.
- **S5**: The hand-run check skips the handler's refusals, so it shows the raw measurement, and no
test drives it past the identity claim. The `verdict-freshness` enumerator runs as the config
gives it, outside anything that checks it only reads.
- **S5**: The hand-run check skips the handler's refusals, so it shows the raw measurement; a test
drives it past the identity claim. The `verdict-freshness` enumerator runs as the config gives it,
outside anything that checks it only reads.
- **S6**: The chore ledger is read whole on every check and never rotated, so a check's cost grows
with the agent's age. Its `at` stamps are local time without a zone; a row also carries `ts`, the
instant in epoch seconds, which the aged pass reads, and a row without one is read by its `at`.
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
| [ADR-0012](012-bench/ADR-0012-the-bench.md) | 012-bench | draft | operating (the set runner, the in-box grader, the closure, the run identity and both arms are under tests; that compared bench runs share instances, head and budgets is the reader's check; no CLI-arm bench run on the hard set has the package indexes closed) | ADR-0002, ADR-0003, ADR-0006, ADR-0009 |
| [ADR-0013](013-agent/ADR-0013-the-agent.md) | 013-agent | draft | operating (the wake, the gate, the caps, the hop count, the cursor, the lock and the continuous run with its checkpoint are pinned by tests; supervision and the lease are not in the process) | ADR-0001, ADR-0002, ADR-0003, ADR-0007, ADR-0010 |
| [ADR-0014](014-instrument/ADR-0014-the-instrument.md) | 014-instrument | draft | operating (eleven instruments build from one config and run under tests against scripted commands and stores; the command-line check prints a measurement unvetted and has no test; a lock-holder and a scheduled-job instrument are owed) | ADR-0005, ADR-0007 |
| [ADR-0015](015-chores/ADR-0015-chores.md) | 015-chores | draft | operating (the handler, the gate, the store boundary, the ticks and the catch-up are pinned by tests with scripted backends and a fake store; no test drives a positive hand-run check; the catch-up's store-side filters are owed) | ADR-0001, ADR-0005, ADR-0007, ADR-0010 |
| [ADR-0015](015-chores/ADR-0015-chores.md) | 015-chores | draft | operating (the handler, the gate, the store boundary, the ticks and the catch-up are pinned by tests with scripted backends and a fake store; the hand-run check is driven both ways; the catch-up passes its creator and status filters to the store) | ADR-0001, ADR-0005, ADR-0007, ADR-0010 |
| [ADR-0016](016-command/ADR-0016-the-lion-command.md) | 016-command | draft | partial (the command tree, the shared lock and the spend row are under tests; the text `--stats` prints has no test; a mark for an unknown or partial cost is owed) | ADR-0007, ADR-0009, ADR-0010 |
| [ADR-0017](017-desk/ADR-0017-the-desk.md) | 017-desk | draft | operating (every claim is pinned by tests with scripted backends and a stubbed store; no bench exercises the desk; the gate for a clarify's answer mailed to the desk holds under `[desk] admit_replies`, S8) | ADR-0001, ADR-0002, ADR-0003, ADR-0010 |
| [ADR-0018](018-chat/ADR-0018-chat-in-a-box.md) | 018-chat | draft | operating (the chat, its resume and the boxed chat are pinned by tests against scripted backends and a stubbed box; the one real-box test runs only when enabled; no test interrupts a boxed chat) | ADR-0002, ADR-0003, ADR-0005, ADR-0007 |
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ sandboxed executor are the published side of that boundary; the rest is named by
| ADR-0013 | The long-running agent: the wake, the send gate and the hop count, the caps, the cursor and posture, the lock, the continuous run and its checkpoint | draft (2026-09-25) | the process: one identity per process, lease by turn activity, one supervisor |
| ADR-0012 | The bench as the instrument: the grade in the box, the row, the run id, unaided by default, what compares with what, the cited figures | draft (2026-09-25); one carried figure not reproduced | the instrument contract |
| ADR-0014 | The instrument contract: the measurement and its proof, the refusals, the dead instrument, first sight then transitions, the floors | draft (2026-09-25); the command-line check unvetted and untested; a lock-holder and a scheduled-job instrument, a dead mark on delivery owed | none |
| ADR-0015 | Chores: three endings chosen by code, eleven chores from one file, ticks as store reminders, the bounded client, print and save | draft (2026-09-25); no test drives a positive hand-run check; the catch-up's store-side filters owed | the scheduled run of a process |
| ADR-0015 | Chores: three endings chosen by code, eleven chores from one file, ticks as store reminders, the bounded client, print and save | draft (2026-09-25) | the scheduled run of a process |
| ADR-0016 | The lion command and the spend row: one entry, the shared lock, the row from the envelopes, the readers | draft (2026-09-25); the unknown-cost mark owed | none |
| ADR-0017 | The desk: the read-only reader, the cursor that never marks, the gate, code escalations, every model-facing command with its gate, pending until its row, keyed replay, record answers, the brief, areas and chairs | draft (2026-09-25); no bench exercises the desk | mail settlement by keyed replay |
| ADR-0018 | Chat in a box: one run that waits for the person, the flags, the person's tree or a copy, the patch home, the resume | draft (2026-09-25); no test interrupts a boxed chat | none |
Expand Down
34 changes: 20 additions & 14 deletions hub/agent/chores.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,36 +650,42 @@ async def _catch_up(self, run: Run, *, skip: str) -> list[str]:
if self._caught_up == run.id:
return []
self._caught_up = run.id
try:
(rows,) = await self.agent.khive.exec('list(kind="scheduled_event", limit=200)')
me = self.agent.khive.actor
try: # the store filters by creator and status before the page, so the page holds only these
pages = await self.agent.khive.exec(
"["
+ ", ".join(
f'list(kind="scheduled_event", created_by_actor={lit(me)}, status={lit(s)}, limit=200)'
for s in ("missed", "failed")
)
+ "]"
)
except Exception as e:
return [f"missed-tick read failed: {type(e).__name__}: {e}"]
items = rows.get("items", []) if isinstance(rows, dict) else (rows or [])
me, seen, done = self.agent.khive.actor, list(_val(self.agent.state, "missed_seen", [])), []
items = [
it for rows in pages for it in (rows.get("items", []) if isinstance(rows, dict) else (rows or []))
]
seen, done = list(_val(self.agent.state, "missed_seen", [])), []
for it in items:
p = it.get("properties") or {}
if (
p.get("created_by_actor") != me
or p.get("status") not in ("missed", "failed")
or it["id"] in seen
):
p, status = it.get("properties") or {}, it.get("status") # status sits beside properties
if p.get("created_by_actor") != me or status not in ("missed", "failed") or it["id"] in seen:
continue
seen.append(it["id"])
content = str(it.get("content") or "")
m = re.match(r"tick: ([\w-]+)", content)
chore = m.group(1) if m else None
if chore == "digest":
await self.digest(None, run)
done.append(f"{content}: {p['status']}, digest sent now")
done.append(f"{content}: {status}, digest sent now")
elif chore == "aged":
done.append(f"{content}: {p['status']}, " + await self.aged(None, run))
done.append(f"{content}: {status}, " + await self.aged(None, run))
elif chore in self.instruments and chore != skip:
done.append(
f"{content}: {p['status']}, ran now: "
f"{content}: {status}, ran now: "
+ await self.check(Check(chore=chore, args={"missed_tick": it["id"]}), run)
)
else:
done.append(f"{content}: {p['status']}, noted")
done.append(f"{content}: {status}, noted")
if done:
self.agent.state.put("missed_seen", seen[-500:], run=run.id, seq=run.record.seq)
return done
Expand Down
47 changes: 47 additions & 0 deletions tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,53 @@ async def fake_exec(self, op):
)


def test_check_with_the_identity_free_prints_and_lands_the_measurement_and_sends_nothing(
tmp_path, kkernel_stub, monkeypatch, capsys
):
"""With no live holder, --check claims the identity, runs the named instrument alone, prints it and
lands the same text, then lets the identity go: no send, no ledger row, no tick."""
d = tmp_path / "agent"
(d / ".khive").mkdir(parents=True)
(d / ".khive" / "config.toml").write_text('[actor]\nid = "lambda:x:agent"\n')
(d / "chores.toml").write_text(
'actor = "lambda:x:agent"\nowner = "lambda:x"\nsteward = "lambda:owner"\n\n[chores.gtd-inbox]\n'
'every = "every:1h"\n'
)
ops: list[str] = []

async def fake_exec(self, op):
ops.append(op)
old = {"id": "t1", "title": "triage me", "created_at": "2026-01-01T00:00:00Z"}
return [[old], [old]]

monkeypatch.setattr(Khive, "exec", fake_exec)
a = argparse.Namespace(
dir=str(d),
stats=False,
model=None,
check="gtd-inbox",
ticks=False,
arm=False,
once=False,
rerun=None,
wait=0,
)
asyncio.run(agent_main(a))
text, landed = capsys.readouterr().out.rsplit("\nlanded: ", 1)
path = Path(landed.strip())
assert text.startswith(
"gtd-inbox: control ok, 1 finding(s), escalate=False, count=1, direction under-report"
)
assert "- 1 gtd inbox row(s) older than 24h of 1 in inbox" in text
assert path.parent == d / "landing" and re.fullmatch(r"\d{8}-check-gtd-inbox\.txt", path.name)
assert path.read_text() == text
assert ops == [
'[gtd.tasks(status="inbox", assignee="lambda:x", limit=100), gtd.tasks(assignee="lambda:x", limit=1)]'
]
files = sorted(str(p.relative_to(d)) for p in d.rglob("*") if p.is_file())
assert files == [".khive/config.toml", "chores.toml", f"landing/{path.name}"] # the identity let go


def test_serve_keeps_the_lock_its_process_already_claimed(tmp_path):
"""The CLI claims before its first act; serve takes that lock over instead of claiming it again, and
lets it go when it stops."""
Expand Down
53 changes: 47 additions & 6 deletions tests/test_chores.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import asyncio
import itertools
import json
import re
import time
from datetime import UTC, datetime, timedelta

Expand Down Expand Up @@ -64,7 +65,8 @@ def __init__(self, *inbox: dict, agenda: list[str] = (), events: list[dict] = ()
self.sent: list[dict] = []
self.ops: list[str] = []
self.agenda = list(agenda)
self.events = list(events) # scheduled_event rows `list` returns
self.events = list(events) # scheduled_event rows `list` returns, status beside properties as live
self.filtered = False # True: `list` applies creator and status before the page, as the store does
self.threads: dict[str, list[dict]] = {} # thread id -> messages `comm.thread` returns
self.refuse: set[str] = set() # recipients the transport refuses, as the hop cap would
self.keyed: dict[str, tuple] = {} # idempotency key -> (the request, what its send returned)
Expand Down Expand Up @@ -113,8 +115,18 @@ async def exec(self, ops: str):
for c in self.agenda
]
return [{"events": events}]
if ops.startswith('list(kind="scheduled_event"'):
return [{"items": list(self.events)}]
if ops.startswith('[list(kind="scheduled_event"'): # one page per call, filtered before it
pages = []
for call in re.findall(r"list\(([^)]*)\)", ops):
args = {k: json.loads(v) for k, v in re.findall(r'(\w+)=("[^"]*"|\d+)', call)}
want = {k: args[k] for k in ("created_by_actor", "status") if k in args and self.filtered}
rows = [
e
for e in self.events
if all({**e["properties"], "status": e["status"]}.get(k) == v for k, v in want.items())
]
pages.append({"items": rows[: args["limit"]]})
return pages
if ops.startswith("comm.thread"):
tid = ops.split('id="')[1].split('"')[0]
msgs = self.threads.get(tid, [])
Expand Down Expand Up @@ -745,15 +757,18 @@ def test_a_missed_tick_of_the_actors_own_is_run_at_the_next_check_and_only_once(
{
"id": "ev1",
"content": "tick: pr (fired at 2026-09-21T03:04:09Z)",
"properties": {"created_by_actor": SELF, "status": "missed"},
"status": "missed",
"properties": {"created_by_actor": SELF},
},
{
"id": "ev2",
"content": "tick: disk",
"properties": {"created_by_actor": "lambda:other", "status": "missed"},
"status": "missed",
"properties": {"created_by_actor": "lambda:other"},
},
{"id": "ev3", "content": "tick: disk", "properties": {"created_by_actor": SELF, "status": "fired"}},
{"id": "ev3", "content": "tick: disk", "status": "fired", "properties": {"created_by_actor": SELF}},
]
# a store that ignores the filters: the code's own check still keeps the actor's missed and failed rows
khive = FakeKhive(mail(SELF, "tick: disk"), mail(SELF, "tick: disk", mid="b2c3d4e5"), events=events)
chores, agent = build(
tmp_path,
Expand All @@ -776,6 +791,32 @@ def test_a_missed_tick_of_the_actors_own_is_run_at_the_next_check_and_only_once(
assert agent.state.get("missed_seen").value == ["ev1"]


def test_the_missed_tick_read_asks_the_store_for_the_actors_missed_and_failed_rows_before_the_page(tmp_path):
noise = [
{"id": f"n{i}", "content": "tick: disk", "status": status, "properties": {"created_by_actor": actor}}
for i, (actor, status) in enumerate([("lambda:other", "missed"), (SELF, "fired")] * 100)
]
late = [
{"id": "ev9", "content": "tick: pr", "status": "missed", "properties": {"created_by_actor": SELF}},
{"id": "ev8", "content": "tick: pr", "status": "failed", "properties": {"created_by_actor": SELF}},
]
khive = FakeKhive(mail(SELF, "tick: disk"), events=noise + late) # both past an unfiltered page of 200
khive.filtered = True
chores, agent = build(tmp_path, khive, [CHECK, DONE], disk=Probe("disk", full()), pr=Probe("pr", full()))
asyncio.run(agent.wake())
assert [op for op in khive.ops if "scheduled_event" in op] == [
f'[list(kind="scheduled_event", created_by_actor="{SELF}", status="missed", limit=200), '
f'list(kind="scheduled_event", created_by_actor="{SELF}", status="failed", limit=200)]'
]
rows = chores.rows()
assert [(r["chore"], r["args"]) for r in rows] == [
("pr", {"missed_tick": "ev9"}),
("pr", {"missed_tick": "ev8"}),
("disk", {}),
]
assert agent.state.get("missed_seen").value == ["ev9", "ev8"]


def test_ensure_ticks_creates_the_reminders_that_are_missing_and_only_those(tmp_path):
# another agent's "tick: digest" sits in the same namespace's agenda: not this actor's, created anyway
khive = FakeKhive(agenda=["tick: disk", "unrelated reminder", ("tick: digest", "lambda:other:agent")])
Expand Down
Loading