kijito-tools 0.2.3 → 0.2.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kijito-tools",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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",
@@ -85,6 +85,32 @@ PYSCAN
85
85
  return 1
86
86
  }
87
87
 
88
+ # kijito_unread_for_persona <persona> -> prints the persona's unread count, or nothing (row M309).
89
+ # The producer (kijito-inbox-monitor >= 0.5.7) writes `unread` into the persona's STATE file on every poll
90
+ # that had a count, and omits it when it had none. Nothing is printed unless a FRESH state file for this
91
+ # persona holds one: a stale or missing figure is a confident wrong number on a pane, which is worse than none.
92
+ # ⛔ THE FILE IS FOUND BY WHAT IT SAYS, NOT BY ITS NAME. Re-deriving the persona->filename rule here would be a
93
+ # third copy of it (the M290 lesson); every state file records its own watched persona in `identity`, so one
94
+ # jq pass over the known layouts asks the files instead. Newest first, and the NEWEST match decides: if it
95
+ # holds no count (unknown), an older file's figure must not stand in for it.
96
+ kijito_unread_for_persona() {
97
+ local want=${1:-} f n files=()
98
+ [ -n "$want" ] || return 1
99
+ command -v jq >/dev/null 2>&1 || return 1
100
+ # rewritten every poll, so anything older than 10 min is a producer that has stopped writing it
101
+ while IFS= read -r f; do [ -n "$f" ] && files+=("$f"); done < <(
102
+ find "$HOME/.kijito-monitor" "$HOME/.cache/kijito-inbox-monitor" "$HOME/.local/state/kijito-inbox-monitor" \
103
+ -maxdepth 1 -type f \( -name '*.state' -o -name 'hive.*.json' -o -name 'state.*.json' \) -mmin -10 \
104
+ 2>/dev/null)
105
+ [ "${#files[@]}" -gt 0 ] || return 1
106
+ # shellcheck disable=SC2012 # the paths come from find above; ls is only ordering them by mtime
107
+ n=$(ls -t "${files[@]}" 2>/dev/null | tr '\n' '\0' | xargs -0 jq -rn --arg w "$(printf '%s' "$want" | tr '[:upper:]' '[:lower:]')" '
108
+ first(inputs | select((try (.identity[4] | map(select(.[0] == "persona")) | .[0][1] | ascii_downcase)
109
+ catch null) == $w)) | .unread // empty' 2>/dev/null)
110
+ case "$n" in ''|*[!0-9]*) return 1 ;; esac
111
+ printf '%s' "$n"
112
+ }
113
+
88
114
  # kijito_stream_consumed <stream-path> -> 0 if a wake-capable consumer (`tail -n 0 -F …`) reads it.
89
115
  # ⚠️ ANCHOR ON WHAT THE PROCESS *IS*. An unanchored pgrep on the events path SELF-MATCHES the producer
90
116
  # (its own argv contains that path), and the harness's `bash -c … eval` wrappers carry the same argv —
@@ -58,6 +58,14 @@ col=$(awk -v p="$pct" 'BEGIN { if (p>=80) printf "\033[31m"; else if (p>=60) pri
58
58
  if [ -n "$persona" ]; then
59
59
  printf '\033[36m%s\033[0m · ' "$(kijito_truncate "$persona" 18)"
60
60
  fi
61
+ # ROW M309, second half: the persona's unread count, from the producer's own state file (fresh only).
62
+ # Shown only when non-zero, so a quiet pane keeps the line it had.
63
+ if [ -n "$persona" ]; then
64
+ unread=$(kijito_unread_for_persona "$persona" || true)
65
+ if [ -n "$unread" ] && [ "$unread" -gt 0 ]; then
66
+ printf '\033[33m✉ %s\033[0m · ' "$unread"
67
+ fi
68
+ fi
61
69
  # ROW M291: the heartbeat watchdog raises this flag when this pane's persona has unread wake events
62
70
  # in its stream and NO consumer reading it (typically after a usage-limit outage ended the loop). It
63
71
  # is the one place a human watching the pane will see it; the watchdog removes it on re-arm.
@@ -3,6 +3,30 @@
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.7] - 2026-09-25
7
+
8
+ ### Added
9
+ - **The state file carries the watched persona's unread count (row M309).** The producer read every persona's
10
+ unread count from `/api/notify/pending` each poll and kept it only in memory, so nothing local - a status
11
+ line, a health check - could show one. Each poll that had a count now writes it as `unread` (a
12
+ non-negative integer); a poll that had no count writes no `unread`, so its absence means unknown, never
13
+ zero. It is written with the fast path on or off. Nothing in the producer reads it back to decide what to
14
+ emit. It is read as strictly as the other persisted fields: anything but a non-negative integer makes the
15
+ file CORRUPT. Files written by older versions, which lack the field, load as before.
16
+
17
+ ## [0.5.6] - 2026-09-25
18
+
19
+ ### Fixed
20
+ - **The launchd template names the state file the producer actually writes (row M289).** It passed a base
21
+ (`--state-file <dir>/hive.json`) that the producer turned into `hive.<persona>.json`, so anyone reading the
22
+ template looked for a file that never existed. It now passes `--state-file-template <dir>/hive.{persona}.json`,
23
+ which resolves to the SAME file as before, so upgrading moves nothing. The README's multi-persona example
24
+ likewise names `state.{persona}.json`. A new `_state_path_from_args()` is the one place the producer
25
+ decides a persona's state file, and `StateFileNameAgreementTest` derives the name from the template's own
26
+ argv through it.
27
+ - **A negative persisted `consecutive_failures` makes the state file CORRUPT.** A count below zero was
28
+ resumed as-is and silently postponed the dead-man alert by that many polls.
29
+
6
30
  ## [0.5.5] - 2026-09-24
7
31
 
8
32
  ### Added
@@ -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
@@ -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=35b2fe6688b2f2f536a98952f40816c96057c69f
5
- tree=994bb014166bb65b57223a524bbb0673f57ce22d
6
- tag=v0.5.5
4
+ sha=9ac160aa98d8205a9eabe05378d9fd7ac25d8273
5
+ tree=65ee5cf22640992369c09d6d6f1707116093908b
6
+ tag=v0.5.7
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 9e7c054bccb95cbcb42ea8642eb1fab76e5863d5 CHANGELOG.md
10
+ file=100644 483f8aaa29ab3500ff421315bca2d894b3c40fc6 CHANGELOG.md
11
11
  file=100644 d645695673349e3947e8e5ae42332d0ac3164cd7 LICENSE
12
12
  file=100644 f6b0e5634f3fd4156c1c000efb31578bfb068cf3 NOTICE
13
- file=100644 fa81d2fc2645a5503117825b714200e0ae642cc4 README.md
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 23e088613c314bf86b384640639b545de60ea9d0 com.kijito.inbox-monitor.plist.template
18
- file=100644 30519b1ad6098541083648392f344da68e688246 docs/DESIGN.md
17
+ file=100644 fffa8bd23749a321285bd942a3f4f4a98936129c com.kijito.inbox-monitor.plist.template
18
+ file=100644 fc953e43bcd3069a1dc426ddefc751d8f01cf599 docs/DESIGN.md
19
19
  file=100644 4f5e53a8633e60e6c57c451e28546f8af8783fe8 kijito-inbox-monitor@.service.template
20
- file=100644 a88a59eb52d45918794b46d54de826617d977ff2 kijito_inbox_monitor.py
21
- file=100644 8c2d292152fbe752d5ebf3445141a9d7df5576e5 package.json
22
- file=100644 199474330b420fa514378ab0c1211e21db9c6c41 pyproject.toml
20
+ file=100644 db09ae46bf223a01ea0c2903cc886e6e09b21d43 kijito_inbox_monitor.py
21
+ file=100644 9cea8637ea9c61323ac2298da3d521454eabd85a package.json
22
+ file=100644 d0af8757fa4d3c3f4591f64a0e3d67d1637a9210 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 8745e5bfe9573b9e85dd4e10f8904273d1dff864 test_kijito_monitor.py
26
+ file=100644 8d881e5f8562d4ce6b1b22fc97bf9f1ade1d52fb 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>
@@ -592,7 +592,13 @@ read-state-neutral (DONE-WHEN #5 holds after self-test).
592
592
  - **Content (JSON):** `{"identity":<canonical-id>, "cursor":<int|null>, "state":"UP|DOWN",
593
593
  "consecutive_failures":<int>}`, plus the optional pin fields written only when they are in force:
594
594
  `emitted_above` (list of int), `gap_alerted` (int), `pin_forced` (true), `pin_evidence_intact` (false),
595
- `state_corrupt` (true), `pin_release_at` (int).
595
+ `state_corrupt` (true), `pin_release_at` (int). Also `unread_hidden` (true) while the unread-not-shown alarm
596
+ is announced, and `unread` (non-negative int, row M309): the watched persona's unread count as of the poll that
597
+ wrote the file. `unread` is INFORMATIONAL - it is written for local readers (a status line, a health check) and
598
+ nothing in the producer reads it back to decide what to emit. It is written only by a poll whose
599
+ `/api/notify/pending` read succeeded (a persona missing from a good response is a real 0 - the server omits
600
+ personas with nothing pending); a poll without that read writes no `unread`, so its absence means UNKNOWN,
601
+ never zero. A reader should also judge freshness from the file's mtime: the file is rewritten every poll.
596
602
  - **Every persisted field is read STRICTLY, and anything unrecognised fails CLOSED** (Loom re-audit 7, HIGH 2).
597
603
  Booleans must be JSON booleans and integers must be real integers - a JSON `1` for `pin_forced` used to
598
604
  normalise to `false` and silently UNPIN the watermark, letting the replay cap cross the very span the pin was
@@ -33,7 +33,7 @@ try:
33
33
  except ImportError: # pragma: no cover - Windows
34
34
  fcntl = None
35
35
 
36
- __version__ = "0.5.5"
36
+ __version__ = "0.5.7"
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__
@@ -1543,7 +1543,7 @@ class StateFile:
1543
1543
  "%s\n" % self.path)
1544
1544
  return CORRUPT_STATE
1545
1545
  if not ((cursor is None or _is_int(cursor)) and state in ("UP", "DOWN")
1546
- and _is_int(failures)):
1546
+ and _is_int(failures) and failures >= 0): # a failure COUNT is never negative
1547
1547
  sys.stderr.write("kijito-inbox-monitor: WARNING state-file has a valid envelope but invalid "
1548
1548
  "fields; refusing to baseline over it: %s\n" % self.path)
1549
1549
  return CORRUPT_STATE
@@ -1644,10 +1644,18 @@ class StateFile:
1644
1644
  # a re-announce after an upgrade costs one event and is honest about the current condition,
1645
1645
  # whereas defaulting to True would silence a live condition for the rest of the run.
1646
1646
  hidden = d.get("unread_hidden") is True
1647
+ # `unread` (row M309) is read as strictly as every other field: a count that is not a non-negative
1648
+ # integer is evidence the file was not written by us, and the answer to that is the same fail-closed
1649
+ # one. Absent (an older file, or a poll with no count) is fine and means unknown.
1650
+ unread = d.get("unread")
1651
+ if unread is not None and not (_is_int(unread) and unread >= 0):
1652
+ sys.stderr.write("kijito-inbox-monitor: WARNING state-file 'unread' is not a non-negative integer "
1653
+ "(%r); treating the whole file as CORRUPT (fail closed): %s\n" % (unread, self.path))
1654
+ return CORRUPT_STATE
1647
1655
  return {"cursor": cursor, "state": state, "failures": failures, "emitted_above": emitted,
1648
1656
  "gap_alerted": alerted, "pin_evidence_intact": intact,
1649
1657
  "pin_forced": pin_forced, "pin_release_at": release_at,
1650
- "state_corrupt": state_corrupt, "unread_hidden": hidden}
1658
+ "state_corrupt": state_corrupt, "unread_hidden": hidden, "unread": unread}
1651
1659
 
1652
1660
  def unlock(self):
1653
1661
  """Release the single-writer flock and close the sidecar fd.
@@ -1664,7 +1672,7 @@ class StateFile:
1664
1672
 
1665
1673
  def save(self, cursor, state, failures, emitted_above=None, gap_alerted=None,
1666
1674
  pin_forced=False, pin_evidence_intact=True, state_corrupt=False, pin_release_at=None,
1667
- unread_hidden=False):
1675
+ unread_hidden=False, unread=None):
1668
1676
  """Persist the cursor. Returns True IFF the write is DURABLE (Loom re-audit 8, HIGH 3).
1669
1677
 
1670
1678
  The directory fsync used to be called and its answer thrown away, so a failure returned success
@@ -1700,6 +1708,11 @@ class StateFile:
1700
1708
  # act on any faster for being told twice.
1701
1709
  if unread_hidden:
1702
1710
  d["unread_hidden"] = True
1711
+ # The persona's unread count as of this poll (row M309) - the one LOCAL place a status line or a
1712
+ # health check can read it. INFORMATIONAL: nothing here reads it back to decide what to emit.
1713
+ # Omitted when this poll had no count, so its absence means "unknown", never "zero".
1714
+ if unread is not None:
1715
+ d["unread"] = unread
1703
1716
  dirn = os.path.dirname(os.path.abspath(self.path)) or "."
1704
1717
  # BOTH OF THESE ARE INSIDE THE GUARD, and they did not used to be (drill, 2026-08-05).
1705
1718
  # This function builds a careful "written but not provably durable" path - _fsync_dir fails ->
@@ -1858,6 +1871,15 @@ def _persona_path(template, persona):
1858
1871
  return template.replace("{persona}", _state_safe_persona(persona))
1859
1872
 
1860
1873
 
1874
+ def _state_path_from_args(args, persona):
1875
+ """THE state file a persona's watch target opens (row M289): the `{persona}` template when given, else the
1876
+ per-persona derivation of the `--state-file` base. One function, so a supervisor template, the docs and a
1877
+ test can ask the producer which file it will write instead of re-deriving the name and drifting."""
1878
+ if getattr(args, "state_file_template", None):
1879
+ return _persona_path(args.state_file_template, persona)
1880
+ return _state_path_for_persona(getattr(args, "state_file", None), persona)
1881
+
1882
+
1861
1883
  def _created_epoch(created):
1862
1884
  """A message's `created` as epoch seconds, or None if it cannot be read. The server writes naive UTC
1863
1885
  ('2026-09-24 21:34:24.943580'); an explicit offset or 'Z' is honoured when present."""
@@ -1989,6 +2011,10 @@ class WatchTarget:
1989
2011
  self.armed = False
1990
2012
  self.fast_path = False
1991
2013
  self.last_unread = None
2014
+ # What THIS poll learned about the persona's unread count, for the state file (row M309). Kept apart
2015
+ # from `last_unread`, which is the fast-path's wake TRIGGER and only exists while the fast path is on:
2016
+ # reusing it would publish nothing under --no-fast-path, and a status line would read that as zero.
2017
+ self.observed_unread = None
1992
2018
  self.skips = 0
1993
2019
  self.first_poll = True
1994
2020
  self.last_heartbeat = _monotonic()
@@ -2022,8 +2048,7 @@ class WatchTarget:
2022
2048
  cp = urllib.parse.urlsplit(url)
2023
2049
  self.unread_persona = dict(urllib.parse.parse_qsl(cp.query)).get("persona") or persona
2024
2050
 
2025
- state_path = (_persona_path(args.state_file_template, persona) if args.state_file_template
2026
- else _state_path_for_persona(args.state_file, persona))
2051
+ state_path = _state_path_from_args(args, persona)
2027
2052
  if state_path:
2028
2053
  self.state_file = StateFile(state_path, self.identity)
2029
2054
  if not args.self_test:
@@ -2400,6 +2425,11 @@ class WatchTarget:
2400
2425
  def poll_once(self, counts_available=False, unread_counts=None):
2401
2426
  args = self.args
2402
2427
  unread_counts = unread_counts or {}
2428
+ # A count the server did NOT give us this tick is UNKNOWN, never zero: the state file then carries no
2429
+ # `unread` at all, so a reader shows nothing rather than a stale or invented figure. A persona missing
2430
+ # from a GOOD response is a real zero - /api/notify/pending omits personas with nothing pending.
2431
+ self.observed_unread = (unread_counts.get(self.unread_persona, 0)
2432
+ if counts_available and self.unread_persona else None)
2403
2433
 
2404
2434
  skip_full = False
2405
2435
  if self.armed and self.fast_path and not args.no_fast_path and self.unread_persona:
@@ -2824,7 +2854,8 @@ class WatchTarget:
2824
2854
  pin_evidence_intact=self.pin_evidence_intact,
2825
2855
  state_corrupt=self.state_corrupt,
2826
2856
  pin_release_at=self.pin_release_at,
2827
- unread_hidden=self.unread_hidden)
2857
+ unread_hidden=self.unread_hidden,
2858
+ unread=self.observed_unread)
2828
2859
  # ★ CONSUME THE ANSWER (Loom re-audit 9, MEDIUM). Round 8 taught me to RETURN a durability
2829
2860
  # status; this is the same defect one layer out - I produced an answer and then discarded it
2830
2861
  # at the call site, which is the exact thing the previous round was about. A cursor whose
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kijito-inbox-monitor",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
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.5"
10
+ version = "0.5.7"
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"
@@ -6114,3 +6114,163 @@ class StillUnreadBackstopTest(unittest.TestCase):
6114
6114
  self.assertIn(">= 0", err(["--persona", "a", "--still-unread-after", "-1"]))
6115
6115
  self.assertIn(">= 1", err(["--persona", "a", "--still-unread-max", "0"]))
6116
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)
6196
+
6197
+
6198
+ class PersistedUnreadCountTest(unittest.TestCase):
6199
+ """Row M309: the producer knew every persona's unread count and threw it away, so nothing local - a
6200
+ status line, a health check - could show one. The count now rides in the state file, written by every
6201
+ poll that HAD a count, absent from every poll that did not (absent = unknown, never zero), and read as
6202
+ strictly as every other persisted field."""
6203
+
6204
+ RecordingEmitter = BoundedWindowEndToEndTest.RecordingEmitter
6205
+ FullArgs = BoundedWindowEndToEndTest.FullArgs
6206
+ _target = BoundedWindowEndToEndTest._target
6207
+ _fetch = BoundedWindowEndToEndTest._fetch
6208
+
6209
+ def setUp(self):
6210
+ self._dir = tempfile.TemporaryDirectory()
6211
+ self.addCleanup(self._dir.cleanup)
6212
+ self.path = os.path.join(self._dir.name, "argus.state")
6213
+
6214
+ def _poll(self, counts_available, unread_counts, no_fast_path=True):
6215
+ t = self._target(100, self.RecordingEmitter())
6216
+ t.args.no_fast_path = no_fast_path
6217
+ t.state_file = km.StateFile(self.path, "idx")
6218
+ orig, km.fetch = km.fetch, self._fetch([], 0)
6219
+ try:
6220
+ t.poll_once(counts_available=counts_available, unread_counts=unread_counts)
6221
+ finally:
6222
+ km.fetch = orig
6223
+ with open(self.path) as f:
6224
+ return json.load(f)
6225
+
6226
+ def _load(self, extra):
6227
+ d = {"identity": "idx", "cursor": 100, "state": "UP", "consecutive_failures": 0}
6228
+ d.update(extra)
6229
+ with open(self.path, "w") as f:
6230
+ json.dump(d, f)
6231
+ err, sys.stderr = sys.stderr, io.StringIO()
6232
+ try:
6233
+ return km.StateFile(self.path, "idx").load()
6234
+ finally:
6235
+ sys.stderr = err
6236
+
6237
+ def test_a_poll_WITH_a_count_persists_it(self):
6238
+ self.assertEqual(self._poll(True, {"argus": 7, "river": 2})["unread"], 7)
6239
+
6240
+ def test_it_is_persisted_with_the_fast_path_OFF(self):
6241
+ # The fast path's `last_unread` only exists while the fast path is on; the fleet's systemd units
6242
+ # and every --no-fast-path user would otherwise publish nothing at all.
6243
+ self.assertEqual(self._poll(True, {"argus": 3}, no_fast_path=True)["unread"], 3)
6244
+ self.assertEqual(self._poll(True, {"argus": 4}, no_fast_path=False)["unread"], 4)
6245
+
6246
+ def test_a_persona_missing_from_a_GOOD_response_is_zero(self):
6247
+ # /api/notify/pending omits a persona with nothing pending (measured 2026-09-25: river absent).
6248
+ self.assertEqual(self._poll(True, {"river": 2})["unread"], 0)
6249
+
6250
+ def test_a_poll_WITHOUT_a_count_writes_NO_count(self):
6251
+ # Unknown must not be published as zero, and must not leave the PREVIOUS figure standing either.
6252
+ self._poll(True, {"argus": 9})
6253
+ self.assertNotIn("unread", self._poll(False, {}))
6254
+
6255
+ def test_the_count_round_trips_through_load(self):
6256
+ self._poll(True, {"argus": 5})
6257
+ err, sys.stderr = sys.stderr, io.StringIO()
6258
+ try:
6259
+ st = km.StateFile(self.path, "idx").load()
6260
+ finally:
6261
+ sys.stderr = err
6262
+ self.assertIsNot(st, km.CORRUPT_STATE)
6263
+ self.assertEqual(st["unread"], 5)
6264
+ self.assertEqual(st["cursor"], 100)
6265
+
6266
+ def test_an_OLDER_file_without_the_field_still_loads(self):
6267
+ st = self._load({})
6268
+ self.assertIsNot(st, km.CORRUPT_STATE)
6269
+ self.assertIsNone(st["unread"])
6270
+
6271
+ def test_a_malformed_count_is_CORRUPT(self):
6272
+ for bad in (-1, 1.5, "3", True, [], {}):
6273
+ self.assertIs(self._load({"unread": bad}), km.CORRUPT_STATE, "unread=%r must fail closed" % (bad,))
6274
+
6275
+ def test_zero_loads(self):
6276
+ self.assertEqual(self._load({"unread": 0})["unread"], 0)