kijito-tools 0.2.2 → 0.2.4
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.
- package/package.json +1 -1
- package/providers/_shared/wake-core.mjs +4 -2
- package/providers/claude/scripts/heartbeat-watchdog.sh +1 -1
- package/providers/claude/scripts/session-catchup-hint.sh +3 -3
- package/providers/claude/skills/kijito-start/SKILL.md +1 -1
- package/providers/codex/release-manifest.json +2 -2
- package/providers/codex/skills/kijito-start/SKILL.md +1 -1
- package/providers/codex/wake-helper/kijito-wake-helper.test.mjs +1 -1
- package/providers/monitor/CHANGELOG.md +26 -0
- package/providers/monitor/README.md +2 -1
- package/providers/monitor/UPSTREAM +11 -11
- package/providers/monitor/com.kijito.inbox-monitor.plist.template +2 -2
- package/providers/monitor/docs/DESIGN.md +1 -0
- package/providers/monitor/kijito_inbox_monitor.py +107 -4
- package/providers/monitor/package.json +1 -1
- package/providers/monitor/pyproject.toml +1 -1
- package/providers/monitor/test_kijito_monitor.py +198 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kijito-tools",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Installer for kijito-tools: copies the context-tracking, session catch-up, and self-clear scripts plus the Kijito skills into ~/.claude.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -52,12 +52,14 @@ function requirePersona(persona) {
|
|
|
52
52
|
// silently — measured live 2026-08-15: a real corrupt-state producer emitted baseline_skipped
|
|
53
53
|
// into an armed stream and the helper ignored it, the exact "a diagnostic added to kill a
|
|
54
54
|
// silent failure is itself silent unless the consumer's filter learned its name" class the
|
|
55
|
-
// monitor documents. This is now the certified NEW_LENIENT 8-kind set
|
|
55
|
+
// monitor documents. This is now the certified NEW_LENIENT 8-kind set plus `still_unread` (row
|
|
56
|
+
// M229, monitor v0.5.5: the bounded reminder for mail announced once and still unread; a
|
|
57
|
+
// diagnostics-only batch already carries the reconcile-inbox peek below). `armed` and `heartbeat`
|
|
56
58
|
// stay EXCLUDED deliberately — liveness kinds must never wake (heartbeat fires every 900s, and
|
|
57
59
|
// armed's exclusion is why "I was not woken" does not mean "nothing arrived").
|
|
58
60
|
const MAIL_KINDS = Object.freeze(["new"]);
|
|
59
61
|
const DIAGNOSTIC_KINDS = Object.freeze([
|
|
60
|
-
"alert", "recovered", "state_corrupt", "baseline_skipped", "seed_ahead", "replay_capped", "persona_added",
|
|
62
|
+
"alert", "recovered", "state_corrupt", "baseline_skipped", "seed_ahead", "replay_capped", "persona_added", "still_unread",
|
|
61
63
|
]);
|
|
62
64
|
|
|
63
65
|
export function parseEventLine(line, persona) {
|
|
@@ -74,7 +74,7 @@ _pane_hash() { tmux capture-pane -p -t "$1" 2>/dev/null | tail -40 | cksum | awk
|
|
|
74
74
|
# SURFACED AS: an `HEARTBEAT_UNCONSUMED_STREAM` lifecycle-log line, a flag file the status line shows
|
|
75
75
|
# (`unconsumed.<pane>`), and a nudge prompt that says to re-arm the consumer FIRST. Cleared (with a
|
|
76
76
|
# `HEARTBEAT_STREAM_CONSUMED` line) the moment a consumer is attached again.
|
|
77
|
-
WAKE_EVENTS='"event": ?"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)"'
|
|
77
|
+
WAKE_EVENTS='"event": ?"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)"'
|
|
78
78
|
UNCONSUMED_FLAG="$KIJITO_LC_DIR/unconsumed.$PANE"
|
|
79
79
|
st_missing_since=""; st_offset=""; st_alerted=0; st_path=""
|
|
80
80
|
|
|
@@ -297,7 +297,7 @@ task list:
|
|
|
297
297
|
all but one.
|
|
298
298
|
• You have NO inbox Monitor (brand-new session; the tail is a sibling's or a leaked orphan that will
|
|
299
299
|
NOT wake YOU) → arm exactly one for yourself:
|
|
300
|
-
Monitor(command="tail -n 0 -F $_events | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)\"'", persistent=true)
|
|
300
|
+
Monitor(command="tail -n 0 -F $_events | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)\"'", persistent=true)
|
|
301
301
|
Dedupe with TaskStop (agent-scoped), NOT \`pkill -f …events…\` (that can kill a sibling's or your own live consumer). $_prod
|
|
302
302
|
EOF
|
|
303
303
|
elif [ -n "$_safe" ]; then
|
|
@@ -308,7 +308,7 @@ background \`tail\` is CAPTURE-ONLY (it writes to a file and never re-invokes yo
|
|
|
308
308
|
woken). Arm the wake-capable consumer with the Monitor TOOL (persistent) — it streams each event
|
|
309
309
|
as a live notification. Your persona for this project is "$_persona":
|
|
310
310
|
|
|
311
|
-
Monitor(command="tail -n 0 -F $_events | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)\"'", persistent=true)
|
|
311
|
+
Monitor(command="tail -n 0 -F $_events | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)\"'", persistent=true)
|
|
312
312
|
|
|
313
313
|
First confirm nothing is already monitoring that stream this session (avoid double-arming). $_prod
|
|
314
314
|
EOF
|
|
@@ -320,7 +320,7 @@ background \`tail\` is CAPTURE-ONLY (it writes to a file and never re-invokes yo
|
|
|
320
320
|
woken). Arm the wake-capable consumer for YOUR persona with the Monitor TOOL (persistent) — it
|
|
321
321
|
streams each event as a live notification. Substitute your persona name for <persona>:
|
|
322
322
|
|
|
323
|
-
Monitor(command="tail -n 0 -F $_events_tmpl | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)\"'", persistent=true)
|
|
323
|
+
Monitor(command="tail -n 0 -F $_events_tmpl | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)\"'", persistent=true)
|
|
324
324
|
|
|
325
325
|
(No .kijito_persona marker found in this project — add a one-line \`.kijito_persona\` file with your
|
|
326
326
|
persona name in the project root so this resolves automatically next session.) $_prod
|
|
@@ -57,7 +57,7 @@ Run `kijito_startup(persona="<P>", project="<J>")` with the persona/project your
|
|
|
57
57
|
"you already hit the trap" — and the remedy it prescribed would have killed a **working**
|
|
58
58
|
inbox. Anchoring on `^tail` excludes the shell and returns exactly one line per monitor.
|
|
59
59
|
- **prints nothing →** arm exactly ONE, wake-capable, via the Monitor tool (persistent):
|
|
60
|
-
`Monitor(command="tail -n 0 -F $STREAM | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)\"'", persistent=true)`
|
|
60
|
+
`Monitor(command="tail -n 0 -F $STREAM | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)\"'", persistent=true)`
|
|
61
61
|
- **prints one line →** already armed by a prior (pre-`/clear`) session; **STOP — do not start another.**
|
|
62
62
|
- **prints two or more lines →** genuinely stacked; keep the newest, kill the rest:
|
|
63
63
|
```bash
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
"verify": "node providers/codex/install.mjs (default action; also runs before any skills deploy)"
|
|
14
14
|
},
|
|
15
15
|
"artifacts": {
|
|
16
|
-
"wakeCoreSha256": "
|
|
16
|
+
"wakeCoreSha256": "f6ca4f88841b371409dfb4946e3b427e83150bd511f38068b364de1090c5cd35",
|
|
17
17
|
"workflowSha256": "ec6a65c095e7011c3100c2a804e23c1dee1a63cfba0fb79f1b872aee91dd3b2d",
|
|
18
18
|
"planSha256": "d9d666b86ac06414f6e6a13a3e3e2115983a1dad44fcfded7eccc51b191fb451",
|
|
19
19
|
"wakeHelperSha256": "838f6b4462ba037a23a14b6fdb8dc34ee8b8f4a7df258b428832bc092fac3ea4",
|
|
20
20
|
"wsUdsSha256": "4da13eef89b572fb735e756cb06846a021919dec7f91c6d07989377944cc776c",
|
|
21
|
-
"wakeHelperTestsSha256": "
|
|
21
|
+
"wakeHelperTestsSha256": "c5b6ec88eef8d84195e6c7eaa71cfb78dd6e2f743622caaa2b97a6d087718dd1",
|
|
22
22
|
"wakeHelperIntegrationTestsSha256": "48f565dfe7e5b719903cd24b12ddfb3f17e1a16c532af397bfffcb39e4bfbe9b",
|
|
23
23
|
"wakeHelperMockDaemonSha256": "3406bcd46d913071c0ee293e30f46b42ad5cf8c5c77f70c4000d9bb37854dd0c"
|
|
24
24
|
},
|
|
@@ -111,7 +111,7 @@ session please") works with no flags and outranks this default.
|
|
|
111
111
|
```
|
|
112
112
|
If nothing prints, arm exactly ONE persistent Monitor:
|
|
113
113
|
```
|
|
114
|
-
Monitor(command="tail -n 0 -F $STREAM | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)\"'", persistent=true)
|
|
114
|
+
Monitor(command="tail -n 0 -F $STREAM | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)\"'", persistent=true)
|
|
115
115
|
```
|
|
116
116
|
Report **armed-live (Claude Code fallback)**. This is not an ad-hoc
|
|
117
117
|
watcher — it is the standard, battle-tested Claude Code wake path.
|
|
@@ -84,7 +84,7 @@ test("R2: the armed-record byte stamps equal the sha256 of the files actually lo
|
|
|
84
84
|
// ── Gate-7 seam extension (argus 7819 conditions a/b): the NEW_LENIENT 8-kind set ──
|
|
85
85
|
|
|
86
86
|
test("seam: every diagnostic kind wakes with a lifecycle key; armed/heartbeat provably never wake", () => {
|
|
87
|
-
const DIAG = ["alert", "recovered", "state_corrupt", "baseline_skipped", "seed_ahead", "replay_capped", "persona_added"];
|
|
87
|
+
const DIAG = ["alert", "recovered", "state_corrupt", "baseline_skipped", "seed_ahead", "replay_capped", "persona_added", "still_unread"];
|
|
88
88
|
for (const kind of DIAG) {
|
|
89
89
|
const parsed = parseEventLine(JSON.stringify({ source: "kijito-inbox", persona: "codex", event: kind, ts: "2026-08-15T08:04:46.059708+00:00" }), "codex");
|
|
90
90
|
assert.equal(parsed.event?.trigger, "lifecycle", `${kind} must wake as lifecycle`);
|
|
@@ -3,6 +3,32 @@
|
|
|
3
3
|
All notable changes to kijito-inbox-monitor are documented in this file.
|
|
4
4
|
The format is based on Keep a Changelog, and this project follows Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## [0.5.6] - 2026-09-25
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **The launchd template names the state file the producer actually writes (row M289).** It passed a base
|
|
10
|
+
(`--state-file <dir>/hive.json`) that the producer turned into `hive.<persona>.json`, so anyone reading the
|
|
11
|
+
template looked for a file that never existed. It now passes `--state-file-template <dir>/hive.{persona}.json`,
|
|
12
|
+
which resolves to the SAME file as before, so upgrading moves nothing. The README's multi-persona example
|
|
13
|
+
likewise names `state.{persona}.json`. A new `_state_path_from_args()` is the one place the producer
|
|
14
|
+
decides a persona's state file, and `StateFileNameAgreementTest` derives the name from the template's own
|
|
15
|
+
argv through it.
|
|
16
|
+
- **A negative persisted `consecutive_failures` makes the state file CORRUPT.** A count below zero was
|
|
17
|
+
resumed as-is and silently postponed the dead-man alert by that many polls.
|
|
18
|
+
|
|
19
|
+
## [0.5.5] - 2026-09-24
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **`still_unread`: a bounded reminder for mail that was announced and then left unread (row M229).**
|
|
23
|
+
The watcher announces each message once (`new`), so a message an agent looked at without marking it
|
|
24
|
+
read used to sit unread forever with nothing to bring it back. Now, when already-announced mail is still
|
|
25
|
+
unread `--still-unread-after` seconds after it was sent (default 7200; `0` turns it off), ONE
|
|
26
|
+
`still_unread` event per poll lists those messages. Each message is reminded at most once per window
|
|
27
|
+
and at most `--still-unread-max` times (default 3); retired, reserved and write_only inboxes are never
|
|
28
|
+
reminded; the first poll after a restart only takes note, so restarts do not produce a burst. It never
|
|
29
|
+
moves the cursor. Its `wake_class` is `diagnostic` (it wakes; `new` remains the only `mail`). Consumers
|
|
30
|
+
that filter by event name should add `still_unread` to their list.
|
|
31
|
+
|
|
6
32
|
## [0.5.4] - 2026-09-24
|
|
7
33
|
|
|
8
34
|
### Fixed
|
|
@@ -269,7 +269,7 @@ owned, rotated event file, and each agent session consumes only its own:
|
|
|
269
269
|
```sh
|
|
270
270
|
kijito-inbox-monitor --all-personas \
|
|
271
271
|
--events-file-template ~/.local/state/kijito-inbox-monitor/events.{persona}.ndjson \
|
|
272
|
-
--state-file ~/.local/state/kijito-inbox-monitor/state.json
|
|
272
|
+
--state-file-template ~/.local/state/kijito-inbox-monitor/state.{persona}.json
|
|
273
273
|
```
|
|
274
274
|
|
|
275
275
|
Each session then wakes on its own `events.<persona>.ndjson` using the recipe in
|
|
@@ -297,6 +297,7 @@ Each line of the events file (and each `exec-per-event` invocation) is one event
|
|
|
297
297
|
| `new` | a new inbox message | `KIJITOMON_ID`, `KIJITOMON_FROM`, `KIJITOMON_CONTENT`, `KIJITOMON_CREATED`, `KIJITOMON_PERSONA` |
|
|
298
298
|
| `alert` | the source has been unreachable for `--alert-after` polls (dead-man), **or** mail is stranded in an inbox nobody watches, **or** the server holds unread mail this window did not show (all below) | `KIJITOMON_REASON`, `KIJITOMON_FAILURES`, `KIJITOMON_STRANDED` |
|
|
299
299
|
| `recovered` | the source came back after an `alert` | `KIJITOMON_CURSOR` |
|
|
300
|
+
| `still_unread` | mail already announced as `new` is still **unread** `--still-unread-after` seconds (default 2 h) after it was sent. One event per poll names every such message; each message is reminded at most once per window and at most `--still-unread-max` (default 3) times; never for retired, reserved or write_only inboxes; nothing on the first poll after a restart. Read what you have handled with `mark_read=true` and it stops. | `KIJITOMON_IDS`, `KIJITOMON_OLDEST_AGE`, `KIJITOMON_REASON` |
|
|
300
301
|
| `heartbeat` | optional liveness tick (`--heartbeat N`) | `KIJITOMON_CURSOR` |
|
|
301
302
|
|
|
302
303
|
Every event also carries `KIJITOMON_EVENT`, `KIJITOMON_SOURCE`, `KIJITOMON_TS`, `KIJITOMON_EVENT_ID`,
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
# The upstream release providers/monitor is a byte-exact copy of (row M314).
|
|
2
2
|
# Written by scripts/import-monitor.sh; verified by tests/vendored_monitor_test.sh. Never hand-edit.
|
|
3
3
|
repo=KijitoAI/kijito-inbox-monitor
|
|
4
|
-
sha=
|
|
5
|
-
tree=
|
|
6
|
-
tag=v0.5.
|
|
4
|
+
sha=04bb12ae11fc882d14c30ac36a9220591b5fab65
|
|
5
|
+
tree=101419def29ffc023991a6f5aace5933d042768a
|
|
6
|
+
tag=v0.5.6
|
|
7
7
|
file=100644 8d88ccb0fd09b9144505f985b4f882db278fac1f .github/workflows/publish-npm.yml
|
|
8
8
|
file=100644 19ce3471a2cffa689be9d261049c1e74e3e7a25e .github/workflows/publish-pypi.yml
|
|
9
9
|
file=100644 4534290a5dd36d18d07a782b784b6b0ad5ee38fc .gitignore
|
|
10
|
-
file=100644
|
|
10
|
+
file=100644 8d92a7f3d76e6802faed79328e349b6b7786b87e CHANGELOG.md
|
|
11
11
|
file=100644 d645695673349e3947e8e5ae42332d0ac3164cd7 LICENSE
|
|
12
12
|
file=100644 f6b0e5634f3fd4156c1c000efb31578bfb068cf3 NOTICE
|
|
13
|
-
file=100644
|
|
13
|
+
file=100644 73d2bf5a5d5d6cb622db014285cd9ada4c705724 README.md
|
|
14
14
|
file=100644 72f0c148f1e721ac1bb4a38e5cae690355774314 RELEASING.md
|
|
15
15
|
file=100755 fca1203267cb726736b0c8f239e2d74c40a7a895 arm-hive-monitor.sh
|
|
16
16
|
file=100755 bbe3f07de13dab8a8a7a4798481156ae4a46ab8f bin/cli.js
|
|
17
|
-
file=100644
|
|
18
|
-
file=100644
|
|
17
|
+
file=100644 fffa8bd23749a321285bd942a3f4f4a98936129c com.kijito.inbox-monitor.plist.template
|
|
18
|
+
file=100644 30519b1ad6098541083648392f344da68e688246 docs/DESIGN.md
|
|
19
19
|
file=100644 4f5e53a8633e60e6c57c451e28546f8af8783fe8 kijito-inbox-monitor@.service.template
|
|
20
|
-
file=100644
|
|
21
|
-
file=100644
|
|
22
|
-
file=100644
|
|
20
|
+
file=100644 5038ff752d4b936e5b9bfab5642f38e7622fd4a4 kijito_inbox_monitor.py
|
|
21
|
+
file=100644 5e682088a3dafbe9526761c87f9a16573c3bbdc9 package.json
|
|
22
|
+
file=100644 6f90a103962a6ee2fa985f8356ff8c95cb93a6a7 pyproject.toml
|
|
23
23
|
file=100755 b34a5c775385ecfaefd4b374c1fc1aa082a85d14 scripts/migrate-systemd-unit.sh
|
|
24
24
|
file=100755 22acef6d6875395a81c701eb535030d0a78b0df1 scripts/mutation-check.py
|
|
25
25
|
file=100755 acba6196213736922fc4f7135749386d85d172a6 scripts/prepublish-gate.sh
|
|
26
|
-
file=100644
|
|
26
|
+
file=100644 ea22077c22b25d53d6719af4b0e7d9e719aeea7d test_kijito_monitor.py
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
<string>__PROGRAM__</string>
|
|
38
38
|
<string>--token-file</string>
|
|
39
39
|
<string>__HOME__/.config/kijito-inbox-monitor/token</string>
|
|
40
|
-
<string>--state-file</string>
|
|
41
|
-
<string>__HOME__/.cache/kijito-inbox-monitor/hive.json</string>
|
|
40
|
+
<string>--state-file-template</string>
|
|
41
|
+
<string>__HOME__/.cache/kijito-inbox-monitor/hive.{persona}.json</string>
|
|
42
42
|
<string>--events-file-template</string>
|
|
43
43
|
<string>__HOME__/.cache/kijito-inbox-monitor/events.{persona}.ndjson</string>
|
|
44
44
|
<string>--heartbeat</string>
|
|
@@ -293,6 +293,7 @@ One object per line; every event carries `event`, `source`, `ts` (emit-time UTC
|
|
|
293
293
|
{"event":"armed", "source":"kijito-inbox","ts":"<iso>","cursor":250}
|
|
294
294
|
{"event":"alert", "source":"kijito-inbox","ts":"<iso>","reason":"unreachable","consecutive_failures":3,"seconds":180}
|
|
295
295
|
{"event":"recovered", "source":"kijito-inbox","ts":"<iso>","cursor":250}
|
|
296
|
+
{"event":"still_unread","source":"kijito-inbox","ts":"<iso>","ids":[240,246],"senders":["river"],"oldest_age_seconds":9000,"after_seconds":7200,"reason":"..."} # M229 backstop, bounded
|
|
296
297
|
{"event":"heartbeat", "source":"kijito-inbox","ts":"<iso>","cursor":250} # only if --heartbeat; cursor may be null
|
|
297
298
|
{"event":"seed_ahead", "source":"kijito-inbox","ts":"<iso>","seeded":600,"current_max":539} # seed > reality (§7.0)
|
|
298
299
|
{"event":"replay_capped","source":"kijito-inbox","ts":"<iso>","capped_to":539,"dropped":389} # backlog > --max-replay (§7.0)
|
|
@@ -33,7 +33,7 @@ try:
|
|
|
33
33
|
except ImportError: # pragma: no cover - Windows
|
|
34
34
|
fcntl = None
|
|
35
35
|
|
|
36
|
-
__version__ = "0.5.
|
|
36
|
+
__version__ = "0.5.6"
|
|
37
37
|
SOURCE = "kijito-inbox"
|
|
38
38
|
# A named User-Agent is REQUIRED: api.kijito.ai is fronted by a WAF that 403s the default Python-urllib UA.
|
|
39
39
|
USER_AGENT = "kijito-inbox-monitor/%s" % __version__
|
|
@@ -952,6 +952,11 @@ _LIVENESS_KINDS = frozenset({"heartbeat", "armed"})
|
|
|
952
952
|
# liveness is a diagnostic, INCLUDING kinds absent from this table (see _wake_class).
|
|
953
953
|
_WAKE_CLASS_BY_KIND = {
|
|
954
954
|
"new": WAKE_CLASS_MAIL,
|
|
955
|
+
# Row M229: a reminder ABOUT mail (already-notified messages still unread past the threshold), not a
|
|
956
|
+
# message itself - one event names several ids and carries no body, so it is NOT `mail` (`new` stays
|
|
957
|
+
# the only mail, pinned by a test). As a diagnostic it WAKES, which is its whole job. Bounded by
|
|
958
|
+
# construction; see WatchTarget._resurface_still_unread.
|
|
959
|
+
"still_unread": WAKE_CLASS_DIAGNOSTIC,
|
|
955
960
|
"alert": WAKE_CLASS_DIAGNOSTIC,
|
|
956
961
|
"recovered": WAKE_CLASS_DIAGNOSTIC,
|
|
957
962
|
"state_corrupt": WAKE_CLASS_DIAGNOSTIC,
|
|
@@ -1181,6 +1186,7 @@ class Emitter:
|
|
|
1181
1186
|
"capped_to": "KIJITOMON_CAPPED_TO", "dropped": "KIJITOMON_DROPPED",
|
|
1182
1187
|
"stranded_inboxes": "KIJITOMON_STRANDED",
|
|
1183
1188
|
"dormant_inboxes": "KIJITOMON_DORMANT",
|
|
1189
|
+
"ids": "KIJITOMON_IDS", "oldest_age_seconds": "KIJITOMON_OLDEST_AGE",
|
|
1184
1190
|
}
|
|
1185
1191
|
for k, envname in keymap.items():
|
|
1186
1192
|
if k in event and event[k] is not None:
|
|
@@ -1537,7 +1543,7 @@ class StateFile:
|
|
|
1537
1543
|
"%s\n" % self.path)
|
|
1538
1544
|
return CORRUPT_STATE
|
|
1539
1545
|
if not ((cursor is None or _is_int(cursor)) and state in ("UP", "DOWN")
|
|
1540
|
-
and _is_int(failures)):
|
|
1546
|
+
and _is_int(failures) and failures >= 0): # a failure COUNT is never negative
|
|
1541
1547
|
sys.stderr.write("kijito-inbox-monitor: WARNING state-file has a valid envelope but invalid "
|
|
1542
1548
|
"fields; refusing to baseline over it: %s\n" % self.path)
|
|
1543
1549
|
return CORRUPT_STATE
|
|
@@ -1852,6 +1858,29 @@ def _persona_path(template, persona):
|
|
|
1852
1858
|
return template.replace("{persona}", _state_safe_persona(persona))
|
|
1853
1859
|
|
|
1854
1860
|
|
|
1861
|
+
def _state_path_from_args(args, persona):
|
|
1862
|
+
"""THE state file a persona's watch target opens (row M289): the `{persona}` template when given, else the
|
|
1863
|
+
per-persona derivation of the `--state-file` base. One function, so a supervisor template, the docs and a
|
|
1864
|
+
test can ask the producer which file it will write instead of re-deriving the name and drifting."""
|
|
1865
|
+
if getattr(args, "state_file_template", None):
|
|
1866
|
+
return _persona_path(args.state_file_template, persona)
|
|
1867
|
+
return _state_path_for_persona(getattr(args, "state_file", None), persona)
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
def _created_epoch(created):
|
|
1871
|
+
"""A message's `created` as epoch seconds, or None if it cannot be read. The server writes naive UTC
|
|
1872
|
+
('2026-09-24 21:34:24.943580'); an explicit offset or 'Z' is honoured when present."""
|
|
1873
|
+
if not isinstance(created, str) or not created:
|
|
1874
|
+
return None
|
|
1875
|
+
try:
|
|
1876
|
+
dt = datetime.datetime.fromisoformat(created.strip().replace("Z", "+00:00"))
|
|
1877
|
+
except ValueError:
|
|
1878
|
+
return None
|
|
1879
|
+
if dt.tzinfo is None:
|
|
1880
|
+
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
|
1881
|
+
return dt.timestamp()
|
|
1882
|
+
|
|
1883
|
+
|
|
1855
1884
|
def _state_path_for_persona(base_path, persona):
|
|
1856
1885
|
if not base_path or not persona:
|
|
1857
1886
|
return base_path
|
|
@@ -2002,8 +2031,7 @@ class WatchTarget:
|
|
|
2002
2031
|
cp = urllib.parse.urlsplit(url)
|
|
2003
2032
|
self.unread_persona = dict(urllib.parse.parse_qsl(cp.query)).get("persona") or persona
|
|
2004
2033
|
|
|
2005
|
-
state_path = (
|
|
2006
|
-
else _state_path_for_persona(args.state_file, persona))
|
|
2034
|
+
state_path = _state_path_from_args(args, persona)
|
|
2007
2035
|
if state_path:
|
|
2008
2036
|
self.state_file = StateFile(state_path, self.identity)
|
|
2009
2037
|
if not args.self_test:
|
|
@@ -2154,6 +2182,69 @@ class WatchTarget:
|
|
|
2154
2182
|
"cursor below it so it is re-delivered rather than skipped (further reports "
|
|
2155
2183
|
"suppressed until delivery recovers)\n" % (mid, self.persona))
|
|
2156
2184
|
|
|
2185
|
+
def _resurface_still_unread(self, items, now=None):
|
|
2186
|
+
"""Row M229 BACKSTOP: re-surface mail that was notified once and then left UNREAD.
|
|
2187
|
+
|
|
2188
|
+
The producer is edge-triggered per id - it emits `new` exactly once per message - so a message the
|
|
2189
|
+
agent peeked at (mark_read=false), acted on or meant to, and never consumed sits unread and INERT:
|
|
2190
|
+
never notified again, visible only to a staleness alarm. The ROOT fix is the consumer's (read
|
|
2191
|
+
what you handled with mark_read=true, shipped as wake-workflow doctrine 2026-08-12); this is the
|
|
2192
|
+
bounded backstop for what still slips through, and it is built so it cannot become a storm:
|
|
2193
|
+
* only for ids AT OR BELOW the cursor (already notified) whose server `read` flag is False and
|
|
2194
|
+
whose `created` is at least --still-unread-after seconds old;
|
|
2195
|
+
* at most once per id per window (--still-unread-after), and at most --still-unread-max times per
|
|
2196
|
+
id in this process - deliberately deferred mail is reminded a few times, not forever;
|
|
2197
|
+
* ONE summarising `still_unread` event per poll however many ids are due (one wake, not N);
|
|
2198
|
+
* never for debris (retired/reserved, row M332) or a write_only inbox;
|
|
2199
|
+
* the FIRST full poll of a process only notes what is already aged and emits nothing, so every
|
|
2200
|
+
restart (a deploy, a reboot) does not replay a burst of reminders.
|
|
2201
|
+
The ledger is per process: a restart can remind again, at most --still-unread-max times per id.
|
|
2202
|
+
It never touches the cursor - a reminder is not a delivery, and failing to emit one loses nothing.
|
|
2203
|
+
"""
|
|
2204
|
+
after = getattr(self.args, "still_unread_after", 0) or 0
|
|
2205
|
+
if after <= 0 or _is_debris(self.persona) or _PERSONA_WRITE_ONLY.get(self.persona) is True:
|
|
2206
|
+
return []
|
|
2207
|
+
cap = max(1, getattr(self.args, "still_unread_max", 3) or 1)
|
|
2208
|
+
now = time.time() if now is None else now
|
|
2209
|
+
ledger = self.__dict__.setdefault("still_unread_seen", {}) # id -> (times reminded, last at)
|
|
2210
|
+
primed = self.__dict__.get("still_unread_primed", False)
|
|
2211
|
+
cursor = self.cursor if isinstance(self.cursor, int) else -1
|
|
2212
|
+
due, visible = [], set()
|
|
2213
|
+
for m in items:
|
|
2214
|
+
mid = m.get("id")
|
|
2215
|
+
if not isinstance(mid, int) or isinstance(mid, bool) or mid > cursor or m.get("read") is not False:
|
|
2216
|
+
continue
|
|
2217
|
+
visible.add(mid)
|
|
2218
|
+
born = _created_epoch(m.get("created"))
|
|
2219
|
+
if born is None or now - born < after:
|
|
2220
|
+
continue
|
|
2221
|
+
n, last = ledger.get(mid, (0, None))
|
|
2222
|
+
if not primed:
|
|
2223
|
+
ledger[mid] = (n, now) # restart: note it, remind one full window from now
|
|
2224
|
+
continue
|
|
2225
|
+
if n >= cap or (last is not None and now - last < after):
|
|
2226
|
+
continue
|
|
2227
|
+
due.append((mid, m, now - born, n))
|
|
2228
|
+
for mid in list(ledger): # read, or gone from the window: forget it
|
|
2229
|
+
if mid not in visible:
|
|
2230
|
+
del ledger[mid]
|
|
2231
|
+
self.still_unread_primed = True
|
|
2232
|
+
if not due:
|
|
2233
|
+
return []
|
|
2234
|
+
oldest = max(age for _, _, age, _ in due)
|
|
2235
|
+
ok = self.emitter.lifecycle(
|
|
2236
|
+
"still_unread", persona=self.persona,
|
|
2237
|
+
ids=[mid for mid, _, _, _ in due],
|
|
2238
|
+
senders=sorted({m.get("from") for _, m, _, _ in due if m.get("from")}),
|
|
2239
|
+
oldest_age_seconds=int(oldest), after_seconds=int(after),
|
|
2240
|
+
reason=("still-unread: %d message(s) in this inbox were notified and are still UNREAD after "
|
|
2241
|
+
"%ds or more (oldest %ds). Read what you have handled with mark_read=true; this "
|
|
2242
|
+
"reminder repeats at most %d time(s) per message." % (len(due), after, int(oldest), cap)))
|
|
2243
|
+
if ok is True:
|
|
2244
|
+
for mid, _, _, n in due:
|
|
2245
|
+
ledger[mid] = (n + 1, now)
|
|
2246
|
+
return [mid for mid, _, _, _ in due]
|
|
2247
|
+
|
|
2157
2248
|
def _delivery_recovered(self):
|
|
2158
2249
|
if not self.delivery_blocked:
|
|
2159
2250
|
return
|
|
@@ -2579,6 +2670,7 @@ class WatchTarget:
|
|
|
2579
2670
|
delivered = set()
|
|
2580
2671
|
if blocked_at is None:
|
|
2581
2672
|
self._delivery_recovered()
|
|
2673
|
+
self._resurface_still_unread(items)
|
|
2582
2674
|
|
|
2583
2675
|
# §5.2 UNREAD MAIL WE CANNOT SEE. Fires on the FALSE->TRUE edge and releases itself when
|
|
2584
2676
|
# the condition clears, so it needs no ack: an ack would let someone silence "there is
|
|
@@ -3655,6 +3747,13 @@ def build_parser():
|
|
|
3655
3747
|
"Mutually exclusive with --token-file.")
|
|
3656
3748
|
p.add_argument("--no-fast-path", action="store_true",
|
|
3657
3749
|
help="Disable the /api/notify/pending unread pre-check; always full-poll the inbox list.")
|
|
3750
|
+
p.add_argument("--still-unread-after", type=int, default=7200, metavar="SECONDS",
|
|
3751
|
+
help="Row M229: emit ONE `still_unread` event per poll for mail that was already notified and "
|
|
3752
|
+
"is still unread this many seconds after it was sent (default 7200; 0 disables). At most "
|
|
3753
|
+
"once per message per window, capped by --still-unread-max; never for retired/reserved or "
|
|
3754
|
+
"write_only inboxes.")
|
|
3755
|
+
p.add_argument("--still-unread-max", type=int, default=3, metavar="N",
|
|
3756
|
+
help="How many times one message may be re-surfaced as still_unread per process (default 3).")
|
|
3658
3757
|
p.add_argument("--resync-every", type=int, default=10,
|
|
3659
3758
|
help="Fast-path safety floor: force a full inbox poll after at most N consecutive cheap "
|
|
3660
3759
|
"skips, so a stale/wrong unread count can never blind the watcher (default 10, min 1).")
|
|
@@ -3688,6 +3787,10 @@ def validate_args(args):
|
|
|
3688
3787
|
raise FatalConfig("--max-replay must be >= 0")
|
|
3689
3788
|
if args.keep_logs < 1:
|
|
3690
3789
|
raise FatalConfig("--keep-logs must be >= 1")
|
|
3790
|
+
if args.still_unread_after < 0:
|
|
3791
|
+
raise FatalConfig("--still-unread-after must be >= 0 (0 disables)")
|
|
3792
|
+
if args.still_unread_max < 1:
|
|
3793
|
+
raise FatalConfig("--still-unread-max must be >= 1")
|
|
3691
3794
|
if args.events_file and args.events_file_template:
|
|
3692
3795
|
raise FatalConfig("--events-file and --events-file-template are mutually exclusive")
|
|
3693
3796
|
if args.events_file_template and "{persona}" not in args.events_file_template:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kijito-inbox-monitor",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
4
4
|
"description": "Pointer to the Python package kijito-inbox-monitor. A zero-dependency watcher that wakes a running agent when new Kijito inbox mail arrives. Install the real tool with pipx/uv/pip.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "kijito-inbox-monitor"
|
|
10
|
-
version = "0.5.
|
|
10
|
+
version = "0.5.6"
|
|
11
11
|
description = "Zero-dependency watcher that wakes a running agent when new Kijito inbox mail arrives."
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.9"
|
|
@@ -5995,3 +5995,201 @@ class SystemdUnitMigrationRewriteTest(unittest.TestCase):
|
|
|
5995
5995
|
r = self._run(fh.read())
|
|
5996
5996
|
self.assertIn("already migrated", r.stdout)
|
|
5997
5997
|
self.assertNotIn("ExecStart after:", r.stdout)
|
|
5998
|
+
|
|
5999
|
+
|
|
6000
|
+
class StillUnreadBackstopTest(unittest.TestCase):
|
|
6001
|
+
"""Row M229: mail that was notified once and then left UNREAD is re-surfaced - boundedly. The producer
|
|
6002
|
+
is edge-triggered per id, so without this such a message sits inert forever. These pin the bounds that
|
|
6003
|
+
keep a reminder from becoming a storm: priming on start, once per id per window, a per-id cap, one
|
|
6004
|
+
event per poll, and silence for debris and write_only inboxes."""
|
|
6005
|
+
|
|
6006
|
+
E2E = BoundedWindowEndToEndTest
|
|
6007
|
+
T0 = km._created_epoch("2026-09-24 00:00:00")
|
|
6008
|
+
H = 3600
|
|
6009
|
+
|
|
6010
|
+
def setUp(self):
|
|
6011
|
+
self._saved = [dict(d) for d in (km._PERSONA_RETIRED, km._PERSONA_RESERVED, km._PERSONA_WRITE_ONLY)]
|
|
6012
|
+
for d in (km._PERSONA_RETIRED, km._PERSONA_RESERVED, km._PERSONA_WRITE_ONLY):
|
|
6013
|
+
d.clear()
|
|
6014
|
+
|
|
6015
|
+
def tearDown(self):
|
|
6016
|
+
for d, keep in zip((km._PERSONA_RETIRED, km._PERSONA_RESERVED, km._PERSONA_WRITE_ONLY), self._saved):
|
|
6017
|
+
d.clear()
|
|
6018
|
+
d.update(keep)
|
|
6019
|
+
|
|
6020
|
+
def _target(self, after=7200, cap=3, cursor=10, em=None):
|
|
6021
|
+
em = em or self.E2E.RecordingEmitter()
|
|
6022
|
+
t = self.E2E()._target(cursor=cursor, emitter=em)
|
|
6023
|
+
t.args.still_unread_after, t.args.still_unread_max = after, cap
|
|
6024
|
+
return t, em
|
|
6025
|
+
|
|
6026
|
+
def _alerts(self, em):
|
|
6027
|
+
return [f for e, f in em.events if e == "still_unread"]
|
|
6028
|
+
|
|
6029
|
+
ITEMS = [{"id": 5, "from": "river", "read": False, "created": "2026-09-24 00:00:00"},
|
|
6030
|
+
{"id": 6, "from": "river", "read": True, "created": "2026-09-24 00:00:00"}, # already read
|
|
6031
|
+
{"id": 11, "from": "river", "read": False, "created": "2026-09-24 00:00:00"}] # not yet notified
|
|
6032
|
+
|
|
6033
|
+
def test_primes_on_start_then_reminds_once_per_window_up_to_the_cap(self):
|
|
6034
|
+
t, em = self._target()
|
|
6035
|
+
now = self.T0 + 3 * self.H
|
|
6036
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, now), [], "a restart must not burst")
|
|
6037
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, now + 60), [], "not before a full window")
|
|
6038
|
+
seen = [t._resurface_still_unread(self.ITEMS, now + k * 7200) for k in range(1, 6)]
|
|
6039
|
+
self.assertEqual(seen, [[5], [5], [5], [], []], "once per window, capped at 3")
|
|
6040
|
+
self.assertEqual(len(self._alerts(em)), 3)
|
|
6041
|
+
self.assertEqual(self._alerts(em)[0]["ids"], [5])
|
|
6042
|
+
self.assertEqual(self._alerts(em)[0]["senders"], ["river"])
|
|
6043
|
+
|
|
6044
|
+
def test_young_mail_is_not_reminded(self):
|
|
6045
|
+
t, em = self._target()
|
|
6046
|
+
t.still_unread_primed = True
|
|
6047
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, self.T0 + 3000), [])
|
|
6048
|
+
|
|
6049
|
+
def test_several_due_messages_are_ONE_event(self):
|
|
6050
|
+
items = [dict(self.ITEMS[0]), dict(self.ITEMS[0], id=7, **{"from": "assay"})]
|
|
6051
|
+
t, em = self._target()
|
|
6052
|
+
t.still_unread_primed = True
|
|
6053
|
+
self.assertEqual(t._resurface_still_unread(items, self.T0 + 3 * self.H), [5, 7])
|
|
6054
|
+
self.assertEqual(len(self._alerts(em)), 1)
|
|
6055
|
+
self.assertEqual(self._alerts(em)[0]["senders"], ["assay", "river"])
|
|
6056
|
+
|
|
6057
|
+
def test_read_mail_leaves_the_ledger(self):
|
|
6058
|
+
t, _ = self._target()
|
|
6059
|
+
t.still_unread_primed = True
|
|
6060
|
+
t._resurface_still_unread(self.ITEMS, self.T0 + 3 * self.H)
|
|
6061
|
+
self.assertIn(5, t.still_unread_seen)
|
|
6062
|
+
t._resurface_still_unread([dict(self.ITEMS[0], read=True)], self.T0 + 4 * self.H)
|
|
6063
|
+
self.assertNotIn(5, t.still_unread_seen)
|
|
6064
|
+
|
|
6065
|
+
def test_a_failed_emit_is_retried_not_counted(self):
|
|
6066
|
+
class Refusing(self.E2E.RecordingEmitter):
|
|
6067
|
+
def lifecycle(self, event, **f):
|
|
6068
|
+
self.events.append((event, f))
|
|
6069
|
+
return False
|
|
6070
|
+
t, em = self._target(em=Refusing())
|
|
6071
|
+
t.still_unread_primed = True
|
|
6072
|
+
now = self.T0 + 3 * self.H
|
|
6073
|
+
t._resurface_still_unread(self.ITEMS, now)
|
|
6074
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, now + 1), [5], "undelivered = not reminded yet")
|
|
6075
|
+
|
|
6076
|
+
def test_debris_and_write_only_inboxes_are_never_reminded(self):
|
|
6077
|
+
for flag in (km._PERSONA_RETIRED, km._PERSONA_RESERVED, km._PERSONA_WRITE_ONLY):
|
|
6078
|
+
with self.subTest(flag=flag):
|
|
6079
|
+
self.setUp()
|
|
6080
|
+
flag["argus"] = True
|
|
6081
|
+
t, em = self._target()
|
|
6082
|
+
t.still_unread_primed = True
|
|
6083
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, self.T0 + 9 * self.H), [])
|
|
6084
|
+
self.assertEqual(self._alerts(em), [])
|
|
6085
|
+
|
|
6086
|
+
def test_zero_disables(self):
|
|
6087
|
+
t, em = self._target(after=0)
|
|
6088
|
+
t.still_unread_primed = True
|
|
6089
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, self.T0 + 99 * self.H), [])
|
|
6090
|
+
|
|
6091
|
+
def test_it_is_wired_into_the_full_poll(self):
|
|
6092
|
+
t, em = self._target(after=1, cursor=10)
|
|
6093
|
+
t.still_unread_primed = True
|
|
6094
|
+
old = [{"id": 5, "from": "river", "read": False, "created": "2020-01-01 00:00:00"}]
|
|
6095
|
+
self.E2E()._run(t, self.E2E()._fetch(old, 0))
|
|
6096
|
+
alerts = self._alerts(em)
|
|
6097
|
+
self.assertEqual(len(alerts), 1)
|
|
6098
|
+
self.assertEqual(alerts[0]["ids"], [5])
|
|
6099
|
+
self.assertEqual(t.cursor, 10, "a reminder never moves the cursor")
|
|
6100
|
+
self.assertEqual(em.new_ids, [], "and is never re-emitted as `new`")
|
|
6101
|
+
|
|
6102
|
+
def test_created_parsing(self):
|
|
6103
|
+
self.assertEqual(km._created_epoch("2026-09-24 00:00:00"), km._created_epoch("2026-09-24T00:00:00Z"))
|
|
6104
|
+
self.assertEqual(km._created_epoch("2026-09-24T02:00:00+02:00"), self.T0)
|
|
6105
|
+
for junk in (None, "", "yesterday", 17):
|
|
6106
|
+
self.assertIsNone(km._created_epoch(junk))
|
|
6107
|
+
|
|
6108
|
+
def test_flags_are_validated(self):
|
|
6109
|
+
def err(argv):
|
|
6110
|
+
try:
|
|
6111
|
+
km.validate_args(km.build_parser().parse_args(argv))
|
|
6112
|
+
except km.FatalConfig as e:
|
|
6113
|
+
return str(e)
|
|
6114
|
+
self.assertIn(">= 0", err(["--persona", "a", "--still-unread-after", "-1"]))
|
|
6115
|
+
self.assertIn(">= 1", err(["--persona", "a", "--still-unread-max", "0"]))
|
|
6116
|
+
self.assertIsNone(err(["--persona", "a", "--still-unread-after", "0"]))
|
|
6117
|
+
|
|
6118
|
+
|
|
6119
|
+
class StateFileNameAgreementTest(unittest.TestCase):
|
|
6120
|
+
"""Row M289 (second half): the launchd template, the docs and the producer must agree on the state file's
|
|
6121
|
+
NAME. The template used to pass a base (`--state-file <dir>/hive.json`) that the producer silently turned
|
|
6122
|
+
into `hive.<persona>.json`, so a reader of the template looked for a file that never existed. Now the
|
|
6123
|
+
template names the file with a `{persona}` template and this test derives the name from the template's
|
|
6124
|
+
OWN argv, through the producer's OWN parser and `_state_path_from_args` - one constant, no second copy."""
|
|
6125
|
+
|
|
6126
|
+
NAMES = ["argus", "Loom", "name (purpose)", "Ωmega", "two words", "Claude-chat"]
|
|
6127
|
+
HOME = "/HOMEDIR"
|
|
6128
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
6129
|
+
|
|
6130
|
+
def _launchd_argv(self):
|
|
6131
|
+
with open(os.path.join(self._HERE, "com.kijito.inbox-monitor.plist.template"), encoding="utf-8") as fh:
|
|
6132
|
+
text = fh.read()
|
|
6133
|
+
block = re.search(r"<key>ProgramArguments</key>\s*<array>(.*?)</array>", text, re.S).group(1)
|
|
6134
|
+
words = [w.replace("__HOME__", self.HOME) for w in re.findall(r"<string>([^<]*)</string>", block)]
|
|
6135
|
+
return words[words.index("__PROGRAM__") + 1:]
|
|
6136
|
+
|
|
6137
|
+
def _args(self):
|
|
6138
|
+
args = km.build_parser().parse_args(self._launchd_argv())
|
|
6139
|
+
km.validate_args(args)
|
|
6140
|
+
return args
|
|
6141
|
+
|
|
6142
|
+
def test_the_launchd_template_names_exactly_the_state_file_the_producer_writes(self):
|
|
6143
|
+
args = self._args()
|
|
6144
|
+
self.assertIsNotNone(args.state_file_template, "the template must NAME the per-persona file, not a base")
|
|
6145
|
+
for name in self.NAMES:
|
|
6146
|
+
with self.subTest(persona=name):
|
|
6147
|
+
path = km._state_path_from_args(args, name)
|
|
6148
|
+
self.assertEqual(os.path.basename(path), "hive.%s.json" % km._state_safe_persona(name))
|
|
6149
|
+
|
|
6150
|
+
def test_existing_launchd_installs_keep_their_state_file(self):
|
|
6151
|
+
# The old template's base `hive.json` was derived to `hive.<safe>.json`; the new template must land on
|
|
6152
|
+
# the SAME file, or an upgrade would baseline over every persona's cursor (skipping its backlog).
|
|
6153
|
+
args = self._args()
|
|
6154
|
+
legacy = self.HOME + "/.cache/kijito-inbox-monitor/hive.json"
|
|
6155
|
+
for name in self.NAMES:
|
|
6156
|
+
with self.subTest(persona=name):
|
|
6157
|
+
self.assertEqual(km._state_path_from_args(args, name), km._state_path_for_persona(legacy, name))
|
|
6158
|
+
|
|
6159
|
+
def test_the_producer_opens_the_path_state_path_from_args_returns(self):
|
|
6160
|
+
# Pins that the watch target uses THIS function, so the test above speaks for the running producer.
|
|
6161
|
+
with open(os.path.join(self._HERE, "kijito_inbox_monitor.py"), encoding="utf-8") as fh:
|
|
6162
|
+
src = fh.read()
|
|
6163
|
+
self.assertIn("state_path = _state_path_from_args(args, persona)", src)
|
|
6164
|
+
|
|
6165
|
+
def test_the_readme_multi_persona_example_writes_the_file_it_documents(self):
|
|
6166
|
+
with open(os.path.join(self._HERE, "README.md"), encoding="utf-8") as fh:
|
|
6167
|
+
readme = fh.read()
|
|
6168
|
+
m = re.search(r"--all-personas \\\n(?:.*\\\n)*?\s*--state-file-template (\S+)", readme)
|
|
6169
|
+
self.assertIsNotNone(m, "the multi-persona example must name its state file with a {persona} template")
|
|
6170
|
+
args = km.build_parser().parse_args(["--all-personas", "--state-file-template", m.group(1)])
|
|
6171
|
+
self.assertEqual(os.path.basename(km._state_path_from_args(args, "argus")), "state.argus.json")
|
|
6172
|
+
self.assertIn("state.<persona>.json", readme)
|
|
6173
|
+
|
|
6174
|
+
|
|
6175
|
+
class NegativeFailureCountStateTest(unittest.TestCase):
|
|
6176
|
+
"""A persisted `consecutive_failures` below zero is not a count: resumed as-is it would postpone the
|
|
6177
|
+
dead-man edge by that many polls, silently. `_is_int` alone accepted it (a negative is a real integer)."""
|
|
6178
|
+
|
|
6179
|
+
def _load(self, failures):
|
|
6180
|
+
with tempfile.TemporaryDirectory() as d:
|
|
6181
|
+
p = os.path.join(d, "s.json")
|
|
6182
|
+
with open(p, "w") as f:
|
|
6183
|
+
json.dump({"identity": "idx", "cursor": 100, "state": "UP", "consecutive_failures": failures}, f)
|
|
6184
|
+
err, sys.stderr = sys.stderr, io.StringIO()
|
|
6185
|
+
try:
|
|
6186
|
+
return km.StateFile(p, "idx").load()
|
|
6187
|
+
finally:
|
|
6188
|
+
sys.stderr = err
|
|
6189
|
+
|
|
6190
|
+
def test_a_negative_count_is_CORRUPT(self):
|
|
6191
|
+
for bad in (-1, -1000):
|
|
6192
|
+
self.assertIs(self._load(bad), km.CORRUPT_STATE, "consecutive_failures=%r must not be resumed" % bad)
|
|
6193
|
+
|
|
6194
|
+
def test_zero_still_loads(self):
|
|
6195
|
+
self.assertIsNot(self._load(0), km.CORRUPT_STATE)
|