swarph-cli 0.4.0__tar.gz → 0.5.0__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.
Files changed (32) hide show
  1. {swarph_cli-0.4.0/src/swarph_cli.egg-info → swarph_cli-0.5.0}/PKG-INFO +32 -8
  2. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/README.md +30 -6
  3. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/pyproject.toml +2 -2
  4. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/__init__.py +1 -1
  5. swarph_cli-0.5.0/src/swarph_cli/commands/daemon.py +438 -0
  6. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/main.py +6 -3
  7. {swarph_cli-0.4.0 → swarph_cli-0.5.0/src/swarph_cli.egg-info}/PKG-INFO +32 -8
  8. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli.egg-info/SOURCES.txt +2 -0
  9. swarph_cli-0.5.0/tests/test_daemon_command.py +356 -0
  10. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/LICENSE +0 -0
  11. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/setup.cfg +0 -0
  12. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/caller.py +0 -0
  13. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/commands/__init__.py +0 -0
  14. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/commands/chat.py +0 -0
  15. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/commands/import_session.py +0 -0
  16. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/commands/onboard.py +0 -0
  17. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/commands/ratify.py +0 -0
  18. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/parsers/__init__.py +0 -0
  19. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/parsers/claude.py +0 -0
  20. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  21. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  22. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli.egg-info/requires.txt +0 -0
  23. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
  24. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_chat_command.py +0 -0
  25. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_claude_parser.py +0 -0
  26. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_import_command.py +0 -0
  27. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_main.py +0 -0
  28. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_onboard_command.py +0 -0
  29. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_ratify_command.py +0 -0
  30. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_smoke_chat.py +0 -0
  31. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_smoke_one_shot.py +0 -0
  32. {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_smoke_phase_5_5.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.4.0
4
- Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.4.0 ships Phase 5.5 `swarph onboard` + `swarph ratify` on top of Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL (PLAN.md §13 / §15).
3
+ Version: 0.5.0
4
+ Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.5.0 ships Phase 5.6 `swarph daemon` (foreground inbox drain — retires the orphaned-tail-F class) on top of Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify (PLAN.md §13 / §16).
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -49,15 +49,38 @@ This is one of three repos in the v0.3.x architecture:
49
49
 
50
50
  ## Status
51
51
 
52
- **v0.4.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify.** Five verbs ship:
52
+ **v0.5.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify + Phase 5.6 daemon.** Six verbs ship:
53
53
 
54
54
  1. `swarph "prompt"` — Phase 2 one-shot mode (any of five providers)
55
55
  2. `swarph chat` — Phase 5 interactive REPL with multi-turn history + slash commands
56
56
  3. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
57
- 4. `swarph onboard <peer-name>` — **NEW** Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
58
- 5. `swarph ratify <peer-name>` — **NEW** Phase 5.5 witness ratification (PLAN.md §15.4a)
57
+ 4. `swarph onboard <peer-name>` — Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
58
+ 5. `swarph ratify <peer-name>` — Phase 5.5 witness ratification (PLAN.md §15.4a)
59
+ 6. `swarph daemon` — **NEW** Phase 5.6 foreground inbox drain loop (PLAN.md §16); structurally retires the orphaned-tail-F class
59
60
 
60
- Subsequent phases extend the CLI surface (`--ask <peer>`, daemon).
61
+ Subsequent phases extend the CLI surface (`--ask <peer>`, REPL drain coroutine + `/inbox` + `/reply` slash commands in 5.6b).
62
+
63
+ ### `swarph daemon` (Phase 5.6)
64
+
65
+ Replaces the 4-layer `tail -F | grep | Monitor | systemd | cron poll` stack with one foreground process. Liveness check collapses to:
66
+
67
+ ```bash
68
+ ps aux | grep '[s]warph daemon' # zero output = monitoring is down
69
+ ```
70
+
71
+ ```bash
72
+ $ swarph daemon --state-dir ~/swarph_state/lab-ovh --self lab-ovh
73
+ [swarph-daemon] starting: self=lab-ovh gateway=http://localhost:8788 poll=30s ...
74
+ [2026-05-08T21:00:30Z] id=728 from=droplet kind=answer → 'Drop review on Phase 5.5 PRs A+B...'
75
+ [2026-05-08T21:01:10Z] id=729 from=droplet kind=fyi → 'Both Phase 5.5 PRs merged...'
76
+ ^C
77
+ [swarph-daemon] signal 2 received — draining + flushing cursor
78
+ [swarph-daemon] shutdown: iterations=12 dms_seen=2 cursor.last_msg_id=729
79
+ ```
80
+
81
+ Loud-on-down (PLAN §16.5): never silently exits. Cursor writes are atomic (write-and-rename — corrupted mid-flush leaves the previous cursor intact). Backoff: 60s after 5 consecutive empty polls; 300s after 5 min of consecutive 5xx. SIGINT/SIGTERM trigger clean drain + flush.
82
+
83
+ `--auto-act` flag is documented for v0.5.1+ when handler registration via `@swarph.on_dm(...)` lands; v0.5.0 ships surface-only mode (DMs printed + JSONL-logged to `inbox.log`, no automatic replies).
61
84
 
62
85
  ### `swarph onboard` + `swarph ratify` (Phase 5.5)
63
86
 
@@ -191,9 +214,10 @@ Pong!
191
214
  | **2** (v0.1.0) | One-shot mode: `swarph "hello" --provider gemini` |
192
215
  | **2.5** (v0.2.0) | `swarph import` — Claude JSONL → swarph-native session format |
193
216
  | **5** (v0.3.0) | `swarph chat` interactive REPL — multi-turn against any of five adapters + slash commands |
194
- | **5.5** (v0.4.0 — this release) | **`swarph onboard <peer-name>` + `swarph ratify <peer-name>`** — six mechanics steps + handshake template + witness flip (PLAN.md §15) |
217
+ | **5.5** (v0.4.0) | `swarph onboard` + `swarph ratify` — six mechanics steps + handshake template + witness flip (PLAN.md §15) |
218
+ | **5.6** (v0.5.0 — this release) | **`swarph daemon`** — foreground inbox drain loop with atomic cursor writes; retires the orphaned-tail-F class (PLAN.md §16) |
219
+ | **5.6b** | REPL drain coroutine + `/inbox`/`/reply` slash commands + `@swarph.on_dm()` handler registration (mesh + cli) |
195
220
  | **3** | `--ask <peer>` mesh-aware one-shot via MeshClient |
196
- | **5.6** | `swarph daemon` foreground drain loop + REPL drain coroutine + `/inbox`, `/reply` (PLAN.md §16) |
197
221
  | **6** | (already done) PyPI publish |
198
222
 
199
223
  ## Why split CLI from substrate
@@ -17,15 +17,38 @@ This is one of three repos in the v0.3.x architecture:
17
17
 
18
18
  ## Status
19
19
 
20
- **v0.4.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify.** Five verbs ship:
20
+ **v0.5.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify + Phase 5.6 daemon.** Six verbs ship:
21
21
 
22
22
  1. `swarph "prompt"` — Phase 2 one-shot mode (any of five providers)
23
23
  2. `swarph chat` — Phase 5 interactive REPL with multi-turn history + slash commands
24
24
  3. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
25
- 4. `swarph onboard <peer-name>` — **NEW** Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
26
- 5. `swarph ratify <peer-name>` — **NEW** Phase 5.5 witness ratification (PLAN.md §15.4a)
25
+ 4. `swarph onboard <peer-name>` — Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
26
+ 5. `swarph ratify <peer-name>` — Phase 5.5 witness ratification (PLAN.md §15.4a)
27
+ 6. `swarph daemon` — **NEW** Phase 5.6 foreground inbox drain loop (PLAN.md §16); structurally retires the orphaned-tail-F class
27
28
 
28
- Subsequent phases extend the CLI surface (`--ask <peer>`, daemon).
29
+ Subsequent phases extend the CLI surface (`--ask <peer>`, REPL drain coroutine + `/inbox` + `/reply` slash commands in 5.6b).
30
+
31
+ ### `swarph daemon` (Phase 5.6)
32
+
33
+ Replaces the 4-layer `tail -F | grep | Monitor | systemd | cron poll` stack with one foreground process. Liveness check collapses to:
34
+
35
+ ```bash
36
+ ps aux | grep '[s]warph daemon' # zero output = monitoring is down
37
+ ```
38
+
39
+ ```bash
40
+ $ swarph daemon --state-dir ~/swarph_state/lab-ovh --self lab-ovh
41
+ [swarph-daemon] starting: self=lab-ovh gateway=http://localhost:8788 poll=30s ...
42
+ [2026-05-08T21:00:30Z] id=728 from=droplet kind=answer → 'Drop review on Phase 5.5 PRs A+B...'
43
+ [2026-05-08T21:01:10Z] id=729 from=droplet kind=fyi → 'Both Phase 5.5 PRs merged...'
44
+ ^C
45
+ [swarph-daemon] signal 2 received — draining + flushing cursor
46
+ [swarph-daemon] shutdown: iterations=12 dms_seen=2 cursor.last_msg_id=729
47
+ ```
48
+
49
+ Loud-on-down (PLAN §16.5): never silently exits. Cursor writes are atomic (write-and-rename — corrupted mid-flush leaves the previous cursor intact). Backoff: 60s after 5 consecutive empty polls; 300s after 5 min of consecutive 5xx. SIGINT/SIGTERM trigger clean drain + flush.
50
+
51
+ `--auto-act` flag is documented for v0.5.1+ when handler registration via `@swarph.on_dm(...)` lands; v0.5.0 ships surface-only mode (DMs printed + JSONL-logged to `inbox.log`, no automatic replies).
29
52
 
30
53
  ### `swarph onboard` + `swarph ratify` (Phase 5.5)
31
54
 
@@ -159,9 +182,10 @@ Pong!
159
182
  | **2** (v0.1.0) | One-shot mode: `swarph "hello" --provider gemini` |
160
183
  | **2.5** (v0.2.0) | `swarph import` — Claude JSONL → swarph-native session format |
161
184
  | **5** (v0.3.0) | `swarph chat` interactive REPL — multi-turn against any of five adapters + slash commands |
162
- | **5.5** (v0.4.0 — this release) | **`swarph onboard <peer-name>` + `swarph ratify <peer-name>`** — six mechanics steps + handshake template + witness flip (PLAN.md §15) |
185
+ | **5.5** (v0.4.0) | `swarph onboard` + `swarph ratify` — six mechanics steps + handshake template + witness flip (PLAN.md §15) |
186
+ | **5.6** (v0.5.0 — this release) | **`swarph daemon`** — foreground inbox drain loop with atomic cursor writes; retires the orphaned-tail-F class (PLAN.md §16) |
187
+ | **5.6b** | REPL drain coroutine + `/inbox`/`/reply` slash commands + `@swarph.on_dm()` handler registration (mesh + cli) |
163
188
  | **3** | `--ask <peer>` mesh-aware one-shot via MeshClient |
164
- | **5.6** | `swarph daemon` foreground drain loop + REPL drain coroutine + `/inbox`, `/reply` (PLAN.md §16) |
165
189
  | **6** | (already done) PyPI publish |
166
190
 
167
191
  ## Why split CLI from substrate
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.4.0"
8
- description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.4.0 ships Phase 5.5 `swarph onboard` + `swarph ratify` on top of Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL (PLAN.md §13 / §15)."
7
+ version = "0.5.0"
8
+ description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.5.0 ships Phase 5.6 `swarph daemon` (foreground inbox drain — retires the orphaned-tail-F class) on top of Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify (PLAN.md §13 / §16)."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.10"
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.4.0"
19
+ __version__ = "0.5.0"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -0,0 +1,438 @@
1
+ """``swarph daemon`` — Phase 5.6 foreground drain loop per PLAN.md §16.
2
+
3
+ The structural retirement of the orphaned-tail-F class. Replaces the
4
+ 4-layer ``tail -F | grep | Monitor | systemd | cron poll`` stack with
5
+ one foreground process that polls the gateway directly and writes
6
+ the cursor transactionally (write-and-rename, no half-flushed state).
7
+
8
+ Liveness check collapses to::
9
+
10
+ ps aux | grep '[s]warph daemon'
11
+
12
+ — zero output = monitoring is down.
13
+
14
+ Default mode is **surface-only** (DMs printed + logged, never auto-replied).
15
+ ``--auto-act`` flips on the AI-to-AI default per CLAUDE.md DM SEMANTICS,
16
+ routing incoming DMs to handlers registered via ``@swarph.on_dm(...)``.
17
+ v0.5.0 ships the daemon + cursor + signals + backoff; handler registration
18
+ + ``MeshClient.watch()`` event stream + REPL drain coroutine + capability
19
+ advert + heartbeat self-reporting land in v0.5.1+ per PLAN §16.4 / §16.4a /
20
+ §16.4b.
21
+
22
+ Loud-on-down discipline (PLAN §16.5): the daemon never silently exits.
23
+ SIGINT / SIGTERM trigger a clean drain + cursor flush + non-zero shell
24
+ liveness signal; uncaught exceptions land on stderr loudly. ``ps aux``
25
+ is the only thing that needs to be checked.
26
+
27
+ Open question §16.7 #2 resolution: ``--auto-act`` default OFF (lab read +
28
+ drop's standing-auth lane discretion). Daemon-launchers in §15.4 step 6
29
+ include ``--auto-act`` explicitly so AI peers opt in at provisioning time.
30
+
31
+ Open question §16.7 #3 resolution: cursor format stays single-row JSON
32
+ with write-and-rename atomic semantics. If flush fails mid-write the
33
+ rename never happens and the previous cursor stands — no append-only
34
+ log needed.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import asyncio
41
+ import json
42
+ import os
43
+ import signal
44
+ import sys
45
+ import time
46
+ import urllib.error
47
+ import urllib.request
48
+ from contextlib import suppress
49
+ from pathlib import Path
50
+ from typing import Optional
51
+
52
+
53
+ _DEFAULT_POLL_S = 30
54
+ _BACKOFF_EMPTY_THRESHOLD = 5 # consecutive empty polls before backing off
55
+ _BACKOFF_EMPTY_SECONDS = 60
56
+ _BACKOFF_5XX_THRESHOLD_SECONDS = 300 # 5 min of consecutive 5xx
57
+ _BACKOFF_5XX_SECONDS = 300
58
+ _LOUD_DISCONNECT_SECONDS = 600 # emit loud line every minute past this
59
+
60
+
61
+ def _build_parser() -> argparse.ArgumentParser:
62
+ p = argparse.ArgumentParser(
63
+ prog="swarph daemon",
64
+ description=(
65
+ "Phase 5.6 mesh inbox drain daemon per PLAN.md §16. "
66
+ "Foreground process; loud-on-down; transactional cursor."
67
+ ),
68
+ )
69
+ p.add_argument(
70
+ "--state-dir",
71
+ default=None,
72
+ help="state directory containing cursor.json + inbox.log "
73
+ "(default: ~/swarph_state/<self>/).",
74
+ )
75
+ p.add_argument(
76
+ "--self",
77
+ dest="self_name",
78
+ default=None,
79
+ help="canonical name of this peer (default: $SWARPH_SELF or "
80
+ "the directory name of --state-dir).",
81
+ )
82
+ p.add_argument(
83
+ "--gateway",
84
+ default=os.environ.get("MESH_GATEWAY_URL", "http://localhost:8788"),
85
+ help="mesh-gateway base URL.",
86
+ )
87
+ p.add_argument(
88
+ "--token-file",
89
+ default=None,
90
+ help="optional secrets file path (mode 0600 expected).",
91
+ )
92
+ p.add_argument(
93
+ "--poll-seconds",
94
+ type=int,
95
+ default=_DEFAULT_POLL_S,
96
+ help=f"base poll cadence in seconds (default: {_DEFAULT_POLL_S}).",
97
+ )
98
+ p.add_argument(
99
+ "--auto-act",
100
+ action="store_true",
101
+ help="route DMs to registered @swarph.on_dm handlers (v0.5.1+ — "
102
+ "in v0.5.0 this is a documentation flag; surface-only mode runs "
103
+ "regardless).",
104
+ )
105
+ p.add_argument(
106
+ "--once",
107
+ action="store_true",
108
+ help="run a single poll iteration then exit (test mode).",
109
+ )
110
+ return p
111
+
112
+
113
+ def _resolve_self_name(arg: Optional[str], state_dir: Path) -> str:
114
+ if arg:
115
+ return arg
116
+ env = os.environ.get("SWARPH_SELF")
117
+ if env:
118
+ return env
119
+ return state_dir.name
120
+
121
+
122
+ def _resolve_state_dir(arg: Optional[str], self_name_arg: Optional[str]) -> Path:
123
+ if arg:
124
+ return Path(arg).expanduser()
125
+ self_name = self_name_arg or os.environ.get("SWARPH_SELF")
126
+ if self_name:
127
+ return Path.home() / "swarph_state" / self_name
128
+ # Last resort — a self_name is needed to disambiguate; surface error.
129
+ raise SystemExit(
130
+ "swarph daemon: cannot resolve state directory. "
131
+ "Pass --state-dir <path> or set $SWARPH_SELF."
132
+ )
133
+
134
+
135
+ def _resolve_token(token_file_arg: Optional[str]) -> str:
136
+ """Mirror onboard's resolution. env → secrets.toml mode 0600 → prompt."""
137
+ from swarph_cli.commands.onboard import _resolve_token as _onboard_resolve
138
+
139
+ return _onboard_resolve(token_file_arg)
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Cursor — single-row JSON with write-and-rename atomic semantics
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ def _read_cursor(path: Path) -> dict:
148
+ if not path.exists():
149
+ return {"last_msg_id": 0, "tasks_snapshot": {}}
150
+ try:
151
+ return json.loads(path.read_text(encoding="utf-8"))
152
+ except json.JSONDecodeError as exc:
153
+ # Loud — corrupted cursor needs operator attention, not silent reset.
154
+ print(
155
+ f"[swarph-daemon] CORRUPTED cursor at {path}: {exc}. "
156
+ f"Refusing to overwrite. Inspect manually.",
157
+ file=sys.stderr,
158
+ flush=True,
159
+ )
160
+ raise
161
+
162
+
163
+ def _write_cursor_atomic(path: Path, cursor: dict) -> None:
164
+ """Write-and-rename: write to a tmp file in the same dir, then atomic
165
+ rename over the target. Failed mid-write leaves the previous cursor
166
+ intact — open question §16.7 #3 resolution."""
167
+ path.parent.mkdir(parents=True, exist_ok=True)
168
+ tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
169
+ tmp.write_text(json.dumps(cursor, indent=2, sort_keys=True), encoding="utf-8")
170
+ os.replace(tmp, path) # atomic on POSIX + Windows ≥3.3
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # HTTP — stdlib only, no httpx
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ def _http_get(url: str, *, token: str, timeout: float = 10.0) -> tuple[int, dict]:
179
+ req = urllib.request.Request(
180
+ url, headers={"Authorization": f"Bearer {token}"}
181
+ )
182
+ try:
183
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
184
+ return resp.status, json.loads(resp.read().decode("utf-8") or "{}")
185
+ except urllib.error.HTTPError as exc:
186
+ try:
187
+ body = json.loads(exc.read().decode("utf-8") or "{}")
188
+ except Exception:
189
+ body = {"detail": str(exc)}
190
+ return exc.code, body
191
+ except urllib.error.URLError as exc:
192
+ # Network-level failure — gateway down, DNS, etc.
193
+ return 0, {"detail": str(exc)}
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Daemon state + loop
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ class DaemonState:
202
+ """Mutable state held by the drain loop. Surfaced as a class so tests
203
+ can inspect post-run."""
204
+
205
+ def __init__(self, *, self_name: str, state_dir: Path, gateway: str,
206
+ token: str, poll_s: int, auto_act: bool):
207
+ self.self_name = self_name
208
+ self.state_dir = state_dir
209
+ self.gateway = gateway
210
+ self.token = token
211
+ self.poll_s = poll_s
212
+ self.auto_act = auto_act
213
+ self.cursor_path = state_dir / "cursor.json"
214
+ self.inbox_log_path = state_dir / "inbox.log"
215
+ self.cursor = _read_cursor(self.cursor_path)
216
+ self.consecutive_empty = 0
217
+ self.disconnect_since: Optional[float] = None
218
+ self.iterations = 0
219
+ self.dms_seen = 0
220
+ self.shutdown_requested = False
221
+
222
+
223
+ def _log_dm(state: DaemonState, dm: dict) -> None:
224
+ """Both stdout (visible to operator + journald) AND inbox.log (cursor
225
+ audit trail). Inbox.log is append-only structured JSONL."""
226
+ line = (
227
+ f"[{dm.get('created_at', '?')}] "
228
+ f"id={dm['id']} from={dm.get('from_node')} kind={dm.get('kind')} "
229
+ f"→ {dm['content'][:120]!r}"
230
+ )
231
+ print(line, flush=True)
232
+ state.inbox_log_path.parent.mkdir(parents=True, exist_ok=True)
233
+ with state.inbox_log_path.open("a", encoding="utf-8") as f:
234
+ f.write(json.dumps(dm) + "\n")
235
+
236
+
237
+ def _route_to_handler(state: DaemonState, dm: dict) -> None:
238
+ """v0.5.0 stub. v0.5.1+ wires this through @swarph.on_dm() registrations
239
+ on the swarph-mesh side. For now, --auto-act prints a placeholder."""
240
+ if state.auto_act:
241
+ print(
242
+ f" [auto-act] handler dispatch deferred to v0.5.1 "
243
+ f"(@swarph.on_dm + MeshClient.watch); surfacing only.",
244
+ flush=True,
245
+ )
246
+
247
+
248
+ def _select_next_poll_seconds(state: DaemonState) -> int:
249
+ """Backoff per §16.2: empty-poll backoff after 5 consecutive empties,
250
+ 5xx backoff after 5 min of consecutive failures."""
251
+ if state.disconnect_since is not None:
252
+ if (time.time() - state.disconnect_since) > _BACKOFF_5XX_THRESHOLD_SECONDS:
253
+ return _BACKOFF_5XX_SECONDS
254
+ if state.consecutive_empty >= _BACKOFF_EMPTY_THRESHOLD:
255
+ return _BACKOFF_EMPTY_SECONDS
256
+ return state.poll_s
257
+
258
+
259
+ async def _drain_iteration(state: DaemonState) -> None:
260
+ """One poll → handle → cursor-write cycle. Errors logged loud; never
261
+ raises out of the loop."""
262
+ state.iterations += 1
263
+ last_id = state.cursor.get("last_msg_id", 0)
264
+ # Note: gateway query param is `to=` (NOT `to_node=`). The latter is
265
+ # silently ignored — bit the entire session's drain code which had
266
+ # python-side filters masking the issue. Defense-in-depth: also
267
+ # filter from_node != self_name client-side in case any future
268
+ # gateway quirk re-introduces outbound bleed-through.
269
+ url = (
270
+ f"{state.gateway}/messages?to={state.self_name}"
271
+ f"&limit=50"
272
+ )
273
+ status, body = _http_get(url, token=state.token)
274
+
275
+ if status == 0:
276
+ # Network-level failure
277
+ if state.disconnect_since is None:
278
+ state.disconnect_since = time.time()
279
+ elapsed = time.time() - state.disconnect_since
280
+ if elapsed > _LOUD_DISCONNECT_SECONDS:
281
+ print(
282
+ f"[swarph-daemon] LOUD: gateway unreachable for "
283
+ f"{elapsed:.0f}s — {body.get('detail', '?')}",
284
+ file=sys.stderr,
285
+ flush=True,
286
+ )
287
+ return
288
+ if status >= 500:
289
+ if state.disconnect_since is None:
290
+ state.disconnect_since = time.time()
291
+ print(
292
+ f"[swarph-daemon] gateway 5xx {status}: {body.get('detail', '?')}",
293
+ file=sys.stderr,
294
+ flush=True,
295
+ )
296
+ return
297
+ if status >= 400:
298
+ print(
299
+ f"[swarph-daemon] gateway {status}: {body.get('detail', '?')}",
300
+ file=sys.stderr,
301
+ flush=True,
302
+ )
303
+ return
304
+
305
+ # Success — clear disconnect tracking
306
+ state.disconnect_since = None
307
+
308
+ messages = [
309
+ m
310
+ for m in body.get("messages", [])
311
+ if m["id"] > last_id and m.get("from_node") != state.self_name
312
+ ]
313
+ if not messages:
314
+ state.consecutive_empty += 1
315
+ return
316
+
317
+ # Process oldest-first so cursor monotonically advances
318
+ messages.sort(key=lambda m: m["id"])
319
+ state.consecutive_empty = 0
320
+ new_last_id = last_id
321
+ for dm in messages:
322
+ _log_dm(state, dm)
323
+ _route_to_handler(state, dm)
324
+ state.dms_seen += 1
325
+ new_last_id = max(new_last_id, dm["id"])
326
+
327
+ state.cursor["last_msg_id"] = new_last_id
328
+ _write_cursor_atomic(state.cursor_path, state.cursor)
329
+
330
+
331
+ async def _drain_loop(state: DaemonState) -> None:
332
+ """Main loop. Returns on shutdown_requested. Exceptions in
333
+ _drain_iteration are caught + logged + retried; only signal handlers
334
+ set shutdown_requested."""
335
+ print(
336
+ f"[swarph-daemon] starting: self={state.self_name} "
337
+ f"gateway={state.gateway} poll={state.poll_s}s "
338
+ f"state={state.state_dir} auto_act={state.auto_act} "
339
+ f"cursor.last_msg_id={state.cursor.get('last_msg_id', 0)}",
340
+ flush=True,
341
+ )
342
+
343
+ while not state.shutdown_requested:
344
+ try:
345
+ await _drain_iteration(state)
346
+ except Exception as exc: # noqa: BLE001 — loud-on-error per §16.4
347
+ print(
348
+ f"[swarph-daemon] iteration error (continuing): "
349
+ f"{type(exc).__name__}: {exc}",
350
+ file=sys.stderr,
351
+ flush=True,
352
+ )
353
+
354
+ delay = _select_next_poll_seconds(state)
355
+ # Sleep in 1-second chunks so SIGINT/SIGTERM can interrupt promptly
356
+ for _ in range(delay):
357
+ if state.shutdown_requested:
358
+ break
359
+ await asyncio.sleep(1)
360
+
361
+ print(
362
+ f"[swarph-daemon] shutdown: iterations={state.iterations} "
363
+ f"dms_seen={state.dms_seen} cursor.last_msg_id={state.cursor.get('last_msg_id', 0)}",
364
+ flush=True,
365
+ )
366
+
367
+
368
+ def _install_signal_handlers(loop: asyncio.AbstractEventLoop, state: DaemonState) -> None:
369
+ """SIGINT + SIGTERM → set shutdown_requested. The loop drains cleanly
370
+ on the next sleep boundary (≤1s)."""
371
+
372
+ def _handler(signum, frame): # noqa: ARG001
373
+ if not state.shutdown_requested:
374
+ print(
375
+ f"[swarph-daemon] signal {signum} received — draining + flushing cursor",
376
+ flush=True,
377
+ )
378
+ state.shutdown_requested = True
379
+
380
+ # Use the signal module directly rather than loop.add_signal_handler so
381
+ # this works inside test harnesses where the loop's default policy may
382
+ # block signal-handler installation.
383
+ signal.signal(signal.SIGINT, _handler)
384
+ signal.signal(signal.SIGTERM, _handler)
385
+
386
+
387
+ def run_daemon(argv: list[str]) -> int:
388
+ """Entry point invoked by ``swarph_cli.main`` verb dispatch."""
389
+ args = _build_parser().parse_args(argv)
390
+
391
+ # Resolve identity + state path
392
+ self_name = args.self_name or os.environ.get("SWARPH_SELF")
393
+ if args.state_dir:
394
+ state_dir = Path(args.state_dir).expanduser()
395
+ if not self_name:
396
+ self_name = state_dir.name
397
+ elif self_name:
398
+ state_dir = Path.home() / "swarph_state" / self_name
399
+ else:
400
+ print(
401
+ "swarph daemon: cannot resolve identity. Pass --self <name> or "
402
+ "--state-dir <path> or set $SWARPH_SELF.",
403
+ file=sys.stderr,
404
+ flush=True,
405
+ )
406
+ return 2
407
+
408
+ state_dir.mkdir(parents=True, exist_ok=True)
409
+ token = _resolve_token(args.token_file)
410
+ if not token:
411
+ print("swarph daemon: empty MESH_GATEWAY_TOKEN", file=sys.stderr)
412
+ return 2
413
+
414
+ state = DaemonState(
415
+ self_name=self_name,
416
+ state_dir=state_dir,
417
+ gateway=args.gateway,
418
+ token=token,
419
+ poll_s=args.poll_seconds,
420
+ auto_act=args.auto_act,
421
+ )
422
+
423
+ if args.once:
424
+ # Test mode — single iteration, no signal handlers, no loop
425
+ asyncio.run(_drain_iteration(state))
426
+ return 0
427
+
428
+ loop = asyncio.new_event_loop()
429
+ asyncio.set_event_loop(loop)
430
+ _install_signal_handlers(loop, state)
431
+ try:
432
+ loop.run_until_complete(_drain_loop(state))
433
+ finally:
434
+ # Final cursor flush in case shutdown happened mid-iteration
435
+ with suppress(Exception):
436
+ _write_cursor_atomic(state.cursor_path, state.cursor)
437
+ loop.close()
438
+ return 0
@@ -40,6 +40,7 @@ Usage:
40
40
  swarph import <path-to-source-session> [--report-only] [--target-session NAME]
41
41
  swarph onboard <peer-name> [--gateway URL]
42
42
  swarph ratify <peer-name> [--reason "<text>"] [--witness-name <self>]
43
+ swarph daemon [--state-dir DIR] [--self NAME] [--poll-seconds N]
43
44
 
44
45
  Examples:
45
46
  swarph "explain Hawkes process briefly"
@@ -50,8 +51,9 @@ Examples:
50
51
  swarph ratify new-peer-name --reason "handshake covers four invariants"
51
52
 
52
53
  Status: Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL +
53
- Phase 5.5 onboard/ratify ready. --ask <peer> (Phase 3), daemon
54
- (Phase 5.6) ship in subsequent releases.
54
+ Phase 5.5 onboard/ratify + Phase 5.6 daemon ready.
55
+ --ask <peer> (Phase 3), REPL drain coroutine + /inbox /reply
56
+ slash commands (Phase 5.6b) ship in subsequent releases.
55
57
 
56
58
  Spec: https://github.com/darw007d/hedge-fund-mcp/blob/main/research/swarph_cli/PLAN.md
57
59
  """
@@ -64,7 +66,8 @@ _VERB_HANDLERS: dict[str, str] = {
64
66
  "chat": "swarph_cli.commands.chat.run_chat",
65
67
  "onboard": "swarph_cli.commands.onboard.run_onboard",
66
68
  "ratify": "swarph_cli.commands.ratify.run_ratify",
67
- # Future: "daemon", "list-peers", "list-adapters", etc.
69
+ "daemon": "swarph_cli.commands.daemon.run_daemon",
70
+ # Future: "list-peers", "list-adapters", etc.
68
71
  }
69
72
 
70
73
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.4.0
4
- Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.4.0 ships Phase 5.5 `swarph onboard` + `swarph ratify` on top of Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL (PLAN.md §13 / §15).
3
+ Version: 0.5.0
4
+ Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.5.0 ships Phase 5.6 `swarph daemon` (foreground inbox drain — retires the orphaned-tail-F class) on top of Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify (PLAN.md §13 / §16).
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -49,15 +49,38 @@ This is one of three repos in the v0.3.x architecture:
49
49
 
50
50
  ## Status
51
51
 
52
- **v0.4.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify.** Five verbs ship:
52
+ **v0.5.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify + Phase 5.6 daemon.** Six verbs ship:
53
53
 
54
54
  1. `swarph "prompt"` — Phase 2 one-shot mode (any of five providers)
55
55
  2. `swarph chat` — Phase 5 interactive REPL with multi-turn history + slash commands
56
56
  3. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
57
- 4. `swarph onboard <peer-name>` — **NEW** Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
58
- 5. `swarph ratify <peer-name>` — **NEW** Phase 5.5 witness ratification (PLAN.md §15.4a)
57
+ 4. `swarph onboard <peer-name>` — Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
58
+ 5. `swarph ratify <peer-name>` — Phase 5.5 witness ratification (PLAN.md §15.4a)
59
+ 6. `swarph daemon` — **NEW** Phase 5.6 foreground inbox drain loop (PLAN.md §16); structurally retires the orphaned-tail-F class
59
60
 
60
- Subsequent phases extend the CLI surface (`--ask <peer>`, daemon).
61
+ Subsequent phases extend the CLI surface (`--ask <peer>`, REPL drain coroutine + `/inbox` + `/reply` slash commands in 5.6b).
62
+
63
+ ### `swarph daemon` (Phase 5.6)
64
+
65
+ Replaces the 4-layer `tail -F | grep | Monitor | systemd | cron poll` stack with one foreground process. Liveness check collapses to:
66
+
67
+ ```bash
68
+ ps aux | grep '[s]warph daemon' # zero output = monitoring is down
69
+ ```
70
+
71
+ ```bash
72
+ $ swarph daemon --state-dir ~/swarph_state/lab-ovh --self lab-ovh
73
+ [swarph-daemon] starting: self=lab-ovh gateway=http://localhost:8788 poll=30s ...
74
+ [2026-05-08T21:00:30Z] id=728 from=droplet kind=answer → 'Drop review on Phase 5.5 PRs A+B...'
75
+ [2026-05-08T21:01:10Z] id=729 from=droplet kind=fyi → 'Both Phase 5.5 PRs merged...'
76
+ ^C
77
+ [swarph-daemon] signal 2 received — draining + flushing cursor
78
+ [swarph-daemon] shutdown: iterations=12 dms_seen=2 cursor.last_msg_id=729
79
+ ```
80
+
81
+ Loud-on-down (PLAN §16.5): never silently exits. Cursor writes are atomic (write-and-rename — corrupted mid-flush leaves the previous cursor intact). Backoff: 60s after 5 consecutive empty polls; 300s after 5 min of consecutive 5xx. SIGINT/SIGTERM trigger clean drain + flush.
82
+
83
+ `--auto-act` flag is documented for v0.5.1+ when handler registration via `@swarph.on_dm(...)` lands; v0.5.0 ships surface-only mode (DMs printed + JSONL-logged to `inbox.log`, no automatic replies).
61
84
 
62
85
  ### `swarph onboard` + `swarph ratify` (Phase 5.5)
63
86
 
@@ -191,9 +214,10 @@ Pong!
191
214
  | **2** (v0.1.0) | One-shot mode: `swarph "hello" --provider gemini` |
192
215
  | **2.5** (v0.2.0) | `swarph import` — Claude JSONL → swarph-native session format |
193
216
  | **5** (v0.3.0) | `swarph chat` interactive REPL — multi-turn against any of five adapters + slash commands |
194
- | **5.5** (v0.4.0 — this release) | **`swarph onboard <peer-name>` + `swarph ratify <peer-name>`** — six mechanics steps + handshake template + witness flip (PLAN.md §15) |
217
+ | **5.5** (v0.4.0) | `swarph onboard` + `swarph ratify` — six mechanics steps + handshake template + witness flip (PLAN.md §15) |
218
+ | **5.6** (v0.5.0 — this release) | **`swarph daemon`** — foreground inbox drain loop with atomic cursor writes; retires the orphaned-tail-F class (PLAN.md §16) |
219
+ | **5.6b** | REPL drain coroutine + `/inbox`/`/reply` slash commands + `@swarph.on_dm()` handler registration (mesh + cli) |
195
220
  | **3** | `--ask <peer>` mesh-aware one-shot via MeshClient |
196
- | **5.6** | `swarph daemon` foreground drain loop + REPL drain coroutine + `/inbox`, `/reply` (PLAN.md §16) |
197
221
  | **6** | (already done) PyPI publish |
198
222
 
199
223
  ## Why split CLI from substrate
@@ -12,6 +12,7 @@ src/swarph_cli.egg-info/requires.txt
12
12
  src/swarph_cli.egg-info/top_level.txt
13
13
  src/swarph_cli/commands/__init__.py
14
14
  src/swarph_cli/commands/chat.py
15
+ src/swarph_cli/commands/daemon.py
15
16
  src/swarph_cli/commands/import_session.py
16
17
  src/swarph_cli/commands/onboard.py
17
18
  src/swarph_cli/commands/ratify.py
@@ -19,6 +20,7 @@ src/swarph_cli/parsers/__init__.py
19
20
  src/swarph_cli/parsers/claude.py
20
21
  tests/test_chat_command.py
21
22
  tests/test_claude_parser.py
23
+ tests/test_daemon_command.py
22
24
  tests/test_import_command.py
23
25
  tests/test_main.py
24
26
  tests/test_onboard_command.py
@@ -0,0 +1,356 @@
1
+ """Tests for ``swarph daemon`` — mocks HTTP + filesystem.
2
+
3
+ The daemon's structural value is replacing the orphaned-tail-F class
4
+ with one foreground process. Tests cover: cursor read/write atomicity,
5
+ backoff selection, single iteration end-to-end (via --once), gateway
6
+ failure modes (5xx, network unreachable), signal-driven shutdown,
7
+ verb dispatch.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import os
15
+ import time
16
+ from pathlib import Path
17
+ from unittest.mock import patch
18
+
19
+ import pytest
20
+
21
+ from swarph_cli.commands import daemon as daemon_cmd
22
+ from swarph_cli.commands.daemon import (
23
+ DaemonState,
24
+ _BACKOFF_5XX_SECONDS,
25
+ _BACKOFF_5XX_THRESHOLD_SECONDS,
26
+ _BACKOFF_EMPTY_SECONDS,
27
+ _BACKOFF_EMPTY_THRESHOLD,
28
+ _drain_iteration,
29
+ _read_cursor,
30
+ _select_next_poll_seconds,
31
+ _write_cursor_atomic,
32
+ )
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Cursor — read/write/atomicity
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ def test_read_cursor_returns_default_on_missing(tmp_path):
41
+ cursor = _read_cursor(tmp_path / "no-such.json")
42
+ assert cursor == {"last_msg_id": 0, "tasks_snapshot": {}}
43
+
44
+
45
+ def test_read_cursor_loads_existing(tmp_path):
46
+ p = tmp_path / "cursor.json"
47
+ p.write_text(json.dumps({"last_msg_id": 42, "tasks_snapshot": {"a": 1}}))
48
+ cursor = _read_cursor(p)
49
+ assert cursor["last_msg_id"] == 42
50
+ assert cursor["tasks_snapshot"] == {"a": 1}
51
+
52
+
53
+ def test_read_cursor_raises_on_corrupted_file(tmp_path, capsys):
54
+ p = tmp_path / "cursor.json"
55
+ p.write_text("not json {{{")
56
+ with pytest.raises(json.JSONDecodeError):
57
+ _read_cursor(p)
58
+ err = capsys.readouterr().err
59
+ assert "CORRUPTED" in err
60
+ assert "Refusing to overwrite" in err
61
+
62
+
63
+ def test_write_cursor_atomic_uses_rename(tmp_path):
64
+ p = tmp_path / "cursor.json"
65
+ _write_cursor_atomic(p, {"last_msg_id": 7})
66
+ assert p.exists()
67
+ assert json.loads(p.read_text())["last_msg_id"] == 7
68
+ # No leftover .tmp files
69
+ tmps = list(tmp_path.glob("cursor.json.tmp.*"))
70
+ assert tmps == []
71
+
72
+
73
+ def test_write_cursor_atomic_creates_parent_dir(tmp_path):
74
+ nested = tmp_path / "a" / "b" / "cursor.json"
75
+ _write_cursor_atomic(nested, {"last_msg_id": 1})
76
+ assert nested.exists()
77
+
78
+
79
+ def test_write_cursor_atomic_overwrites_cleanly(tmp_path):
80
+ p = tmp_path / "cursor.json"
81
+ _write_cursor_atomic(p, {"last_msg_id": 1})
82
+ _write_cursor_atomic(p, {"last_msg_id": 99})
83
+ assert json.loads(p.read_text())["last_msg_id"] == 99
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Backoff
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def _state(tmp_path) -> DaemonState:
92
+ return DaemonState(
93
+ self_name="lab-test",
94
+ state_dir=tmp_path,
95
+ gateway="http://x:8788",
96
+ token="tok",
97
+ poll_s=30,
98
+ auto_act=False,
99
+ )
100
+
101
+
102
+ def test_backoff_returns_base_when_healthy(tmp_path):
103
+ s = _state(tmp_path)
104
+ assert _select_next_poll_seconds(s) == 30
105
+
106
+
107
+ def test_backoff_kicks_in_after_consecutive_empty(tmp_path):
108
+ s = _state(tmp_path)
109
+ s.consecutive_empty = _BACKOFF_EMPTY_THRESHOLD
110
+ assert _select_next_poll_seconds(s) == _BACKOFF_EMPTY_SECONDS
111
+
112
+
113
+ def test_backoff_5xx_kicks_in_after_5min_disconnect(tmp_path):
114
+ s = _state(tmp_path)
115
+ s.disconnect_since = time.time() - (_BACKOFF_5XX_THRESHOLD_SECONDS + 1)
116
+ assert _select_next_poll_seconds(s) == _BACKOFF_5XX_SECONDS
117
+
118
+
119
+ def test_backoff_5xx_short_outage_uses_base(tmp_path):
120
+ s = _state(tmp_path)
121
+ s.disconnect_since = time.time() - 10 # 10s out
122
+ assert _select_next_poll_seconds(s) == 30
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # _drain_iteration — happy path + failure modes
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def _http_factory(scripted: list):
131
+ """Returns (fake_http_get, captured_calls). scripted is a list of
132
+ (status, body) pairs returned in order."""
133
+ captured = []
134
+ it = iter(scripted)
135
+
136
+ def fake(url, *, token, timeout=10.0):
137
+ captured.append({"url": url, "token": token})
138
+ return next(it)
139
+
140
+ return fake, captured
141
+
142
+
143
+ def test_drain_iteration_empty_inbox_advances_empty_counter(tmp_path, monkeypatch):
144
+ s = _state(tmp_path)
145
+ fake, _ = _http_factory([(200, {"messages": []})])
146
+ monkeypatch.setattr(daemon_cmd, "_http_get", fake)
147
+ asyncio.run(_drain_iteration(s))
148
+ assert s.consecutive_empty == 1
149
+ assert s.dms_seen == 0
150
+
151
+
152
+ def test_drain_iteration_processes_messages_and_advances_cursor(tmp_path, monkeypatch):
153
+ s = _state(tmp_path)
154
+ s.cursor["last_msg_id"] = 5
155
+ fake, _ = _http_factory([
156
+ (200, {"messages": [
157
+ {"id": 6, "from_node": "drop", "kind": "fyi",
158
+ "content": "hello", "created_at": "2026-05-08T20:00:00Z"},
159
+ {"id": 7, "from_node": "drop", "kind": "fyi",
160
+ "content": "second", "created_at": "2026-05-08T20:01:00Z"},
161
+ ]}),
162
+ ])
163
+ monkeypatch.setattr(daemon_cmd, "_http_get", fake)
164
+ asyncio.run(_drain_iteration(s))
165
+ assert s.dms_seen == 2
166
+ assert s.cursor["last_msg_id"] == 7
167
+ assert s.consecutive_empty == 0
168
+ # Cursor flushed to disk
169
+ on_disk = json.loads((tmp_path / "cursor.json").read_text())
170
+ assert on_disk["last_msg_id"] == 7
171
+ # inbox.log has both DMs as JSONL
172
+ log_lines = (tmp_path / "inbox.log").read_text().strip().splitlines()
173
+ assert len(log_lines) == 2
174
+ assert json.loads(log_lines[0])["id"] == 6
175
+
176
+
177
+ def test_drain_iteration_filters_outbound_self_messages(tmp_path, monkeypatch):
178
+ """Defense-in-depth filter: even if gateway returns messages where
179
+ from_node==self_name (latent ?to_node= vs ?to= bug, fixed mid-session),
180
+ the daemon must not log/process them as inbound. Regression-tested."""
181
+ s = _state(tmp_path)
182
+ fake, _ = _http_factory([
183
+ (200, {"messages": [
184
+ {"id": 1, "from_node": "drop", "to_node": "lab-test", "kind": "fyi",
185
+ "content": "real inbound", "created_at": "z"},
186
+ {"id": 2, "from_node": "lab-test", "to_node": "drop", "kind": "fyi",
187
+ "content": "MY outbound — should be skipped", "created_at": "z"},
188
+ {"id": 3, "from_node": "drop", "to_node": "lab-test", "kind": "fyi",
189
+ "content": "more inbound", "created_at": "z"},
190
+ ]}),
191
+ ])
192
+ monkeypatch.setattr(daemon_cmd, "_http_get", fake)
193
+ asyncio.run(_drain_iteration(s))
194
+ assert s.dms_seen == 2 # only id=1 and id=3
195
+ log_lines = (tmp_path / "inbox.log").read_text().strip().splitlines()
196
+ assert len(log_lines) == 2
197
+ contents = [json.loads(l)["content"] for l in log_lines]
198
+ assert "MY outbound — should be skipped" not in contents
199
+
200
+
201
+ def test_drain_iteration_filters_already_seen_messages(tmp_path, monkeypatch):
202
+ """Gateway returns recent N messages including some <= last_id; daemon
203
+ must filter so it doesn't re-process."""
204
+ s = _state(tmp_path)
205
+ s.cursor["last_msg_id"] = 10
206
+ fake, _ = _http_factory([
207
+ (200, {"messages": [
208
+ {"id": 8, "from_node": "x", "kind": "fyi",
209
+ "content": "old", "created_at": "z"},
210
+ {"id": 9, "from_node": "x", "kind": "fyi",
211
+ "content": "old2", "created_at": "z"},
212
+ {"id": 11, "from_node": "x", "kind": "fyi",
213
+ "content": "new", "created_at": "z"},
214
+ ]}),
215
+ ])
216
+ monkeypatch.setattr(daemon_cmd, "_http_get", fake)
217
+ asyncio.run(_drain_iteration(s))
218
+ assert s.dms_seen == 1 # only id=11
219
+ assert s.cursor["last_msg_id"] == 11
220
+
221
+
222
+ def test_drain_iteration_processes_in_id_order(tmp_path, monkeypatch):
223
+ """Gateway may return messages in any order; daemon must process
224
+ oldest-first so cursor advances monotonically."""
225
+ s = _state(tmp_path)
226
+ fake, _ = _http_factory([
227
+ (200, {"messages": [
228
+ {"id": 3, "from_node": "x", "kind": "fyi",
229
+ "content": "third", "created_at": "z"},
230
+ {"id": 1, "from_node": "x", "kind": "fyi",
231
+ "content": "first", "created_at": "z"},
232
+ {"id": 2, "from_node": "x", "kind": "fyi",
233
+ "content": "second", "created_at": "z"},
234
+ ]}),
235
+ ])
236
+ monkeypatch.setattr(daemon_cmd, "_http_get", fake)
237
+ asyncio.run(_drain_iteration(s))
238
+ log_ids = [
239
+ json.loads(line)["id"]
240
+ for line in (tmp_path / "inbox.log").read_text().strip().splitlines()
241
+ ]
242
+ assert log_ids == [1, 2, 3]
243
+ assert s.cursor["last_msg_id"] == 3
244
+
245
+
246
+ def test_drain_iteration_5xx_records_disconnect(tmp_path, monkeypatch, capsys):
247
+ s = _state(tmp_path)
248
+ fake, _ = _http_factory([(503, {"detail": "service unavailable"})])
249
+ monkeypatch.setattr(daemon_cmd, "_http_get", fake)
250
+ asyncio.run(_drain_iteration(s))
251
+ assert s.disconnect_since is not None
252
+ err = capsys.readouterr().err
253
+ assert "503" in err
254
+ assert "service unavailable" in err
255
+
256
+
257
+ def test_drain_iteration_4xx_logs_loud(tmp_path, monkeypatch, capsys):
258
+ s = _state(tmp_path)
259
+ fake, _ = _http_factory([(401, {"detail": "bad token"})])
260
+ monkeypatch.setattr(daemon_cmd, "_http_get", fake)
261
+ asyncio.run(_drain_iteration(s))
262
+ err = capsys.readouterr().err
263
+ assert "401" in err
264
+ # 4xx is not a "disconnect" — caller probably has wrong creds
265
+ assert s.disconnect_since is None
266
+
267
+
268
+ def test_drain_iteration_network_unreachable_records_disconnect(
269
+ tmp_path, monkeypatch
270
+ ):
271
+ s = _state(tmp_path)
272
+ fake, _ = _http_factory([(0, {"detail": "name resolution failure"})])
273
+ monkeypatch.setattr(daemon_cmd, "_http_get", fake)
274
+ asyncio.run(_drain_iteration(s))
275
+ assert s.disconnect_since is not None
276
+
277
+
278
+ def test_drain_iteration_clears_disconnect_on_recovery(tmp_path, monkeypatch):
279
+ s = _state(tmp_path)
280
+ s.disconnect_since = time.time()
281
+ fake, _ = _http_factory([(200, {"messages": []})])
282
+ monkeypatch.setattr(daemon_cmd, "_http_get", fake)
283
+ asyncio.run(_drain_iteration(s))
284
+ assert s.disconnect_since is None
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # run_daemon — verb entry point with --once test mode
289
+ # ---------------------------------------------------------------------------
290
+
291
+
292
+ def test_run_daemon_once_returns_zero(tmp_path, monkeypatch):
293
+ monkeypatch.setenv("MESH_GATEWAY_TOKEN", "tok")
294
+ fake, _ = _http_factory([(200, {"messages": []})])
295
+ monkeypatch.setattr(daemon_cmd, "_http_get", fake)
296
+ rc = daemon_cmd.run_daemon(
297
+ [
298
+ "--state-dir",
299
+ str(tmp_path / "state"),
300
+ "--self",
301
+ "test-peer",
302
+ "--once",
303
+ ]
304
+ )
305
+ assert rc == 0
306
+
307
+
308
+ def test_run_daemon_resolves_self_from_state_dir_basename(tmp_path, monkeypatch):
309
+ monkeypatch.setenv("MESH_GATEWAY_TOKEN", "tok")
310
+ monkeypatch.delenv("SWARPH_SELF", raising=False)
311
+ captured = []
312
+
313
+ def fake(url, *, token, timeout=10.0):
314
+ captured.append(url)
315
+ return 200, {"messages": []}
316
+
317
+ monkeypatch.setattr(daemon_cmd, "_http_get", fake)
318
+ state_dir = tmp_path / "swarph_state" / "auto-named-peer"
319
+ rc = daemon_cmd.run_daemon(
320
+ ["--state-dir", str(state_dir), "--once"]
321
+ )
322
+ assert rc == 0
323
+ # URL should embed auto-named-peer (the basename of state-dir).
324
+ # Gateway accepts ?to=, NOT ?to_node= — the latter is silently
325
+ # ignored. Bug regression-test for the session-long latent issue.
326
+ assert "to=auto-named-peer" in captured[0]
327
+ assert "to_node=" not in captured[0]
328
+
329
+
330
+ def test_run_daemon_no_identity_exits_nonzero(monkeypatch, capsys):
331
+ monkeypatch.setenv("MESH_GATEWAY_TOKEN", "tok")
332
+ monkeypatch.delenv("SWARPH_SELF", raising=False)
333
+ rc = daemon_cmd.run_daemon(["--once"])
334
+ assert rc == 2
335
+ err = capsys.readouterr().err
336
+ assert "cannot resolve identity" in err
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Verb dispatch
341
+ # ---------------------------------------------------------------------------
342
+
343
+
344
+ def test_main_dispatches_daemon_verb(monkeypatch):
345
+ from swarph_cli import main as main_mod
346
+
347
+ captured = {}
348
+
349
+ def fake_run(argv):
350
+ captured["argv"] = argv
351
+ return 0
352
+
353
+ monkeypatch.setattr("swarph_cli.commands.daemon.run_daemon", fake_run)
354
+ rc = main_mod.main(["daemon", "--state-dir", "/tmp/x", "--once"])
355
+ assert rc == 0
356
+ assert captured["argv"] == ["--state-dir", "/tmp/x", "--once"]
File without changes
File without changes