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.
- {swarph_cli-0.4.0/src/swarph_cli.egg-info → swarph_cli-0.5.0}/PKG-INFO +32 -8
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/README.md +30 -6
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/pyproject.toml +2 -2
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/__init__.py +1 -1
- swarph_cli-0.5.0/src/swarph_cli/commands/daemon.py +438 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/main.py +6 -3
- {swarph_cli-0.4.0 → swarph_cli-0.5.0/src/swarph_cli.egg-info}/PKG-INFO +32 -8
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli.egg-info/SOURCES.txt +2 -0
- swarph_cli-0.5.0/tests/test_daemon_command.py +356 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/LICENSE +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/setup.cfg +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli.egg-info/requires.txt +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_import_command.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_main.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.4.0 → swarph_cli-0.5.0}/tests/test_smoke_one_shot.py +0 -0
- {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
|
-
Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.
|
|
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.
|
|
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>` —
|
|
58
|
-
5. `swarph ratify <peer-name>` —
|
|
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>`,
|
|
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
|
|
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.
|
|
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>` —
|
|
26
|
-
5. `swarph ratify <peer-name>` —
|
|
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>`,
|
|
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
|
|
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.
|
|
8
|
-
description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.
|
|
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"
|
|
@@ -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
|
|
54
|
-
(Phase
|
|
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
|
-
|
|
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
|
-
Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.
|
|
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.
|
|
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>` —
|
|
58
|
-
5. `swarph ratify <peer-name>` —
|
|
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>`,
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|