Skip to content

Commit b6ed0df

Browse files
committed
feat(cli): apply daemon write-routing policy
1 parent 06cb698 commit b6ed0df

6 files changed

Lines changed: 1182 additions & 55 deletions

File tree

docs/cli-write-routing.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# CLI write routing
2+
3+
Routine CLI writes consume the shared routing policy introduced for the Tier 3
4+
rollout tracked in #1963.
5+
6+
## Routed commands
7+
8+
The policy applies to:
9+
10+
- `mempalace mine`;
11+
- `mempalace sweep`;
12+
- `mempalace sync`;
13+
- the optional post-setup mine run by `mempalace init`.
14+
15+
## Policies
16+
17+
### `direct`
18+
19+
Use the existing direct in-process execution path.
20+
21+
### `prefer`
22+
23+
Submit through the local daemon. Interactive CLI commands are allowed to start
24+
the daemon when it is not already running.
25+
26+
### `require`
27+
28+
Submit through the local daemon. Interactive CLI commands are allowed to start
29+
the daemon when it is not already running.
30+
31+
For CLI commands, `prefer` and `require` both select the daemon because daemon
32+
startup is permitted. Their difference remains meaningful to hook callers,
33+
which cannot cold-start the daemon.
34+
35+
## Explicit flags
36+
37+
Force daemon execution:
38+
39+
mempalace mine ./project --daemon
40+
41+
Force direct execution:
42+
43+
mempalace mine ./project --direct
44+
45+
The flags are mutually exclusive and override environment/config policy.
46+
47+
Background execution:
48+
49+
mempalace mine ./project --background
50+
51+
`--background` is valid when the selected route is the daemon. A direct route
52+
with `--background` exits with a configuration error.
53+
54+
## Configuration
55+
56+
Use the daemon for routine CLI writes:
57+
58+
MEMPALACE_CLI_WRITE_ROUTING=prefer
59+
60+
Prohibit an accidental direct route:
61+
62+
MEMPALACE_CLI_WRITE_ROUTING=require
63+
64+
Retain direct behavior:
65+
66+
MEMPALACE_CLI_WRITE_ROUTING=direct
67+
68+
Config file:
69+
70+
{
71+
"write_routing": {
72+
"cli": "require"
73+
}
74+
}
75+
76+
## Defaults and rollout
77+
78+
The default remains `direct` in this PR.
79+
80+
This means existing users receive no silent execution-topology change. A
81+
supervised Tier 3 deployment enables `prefer` or `require` explicitly. The
82+
production default can be changed later after the stacked rollout is reviewed.
83+
84+
## Safety properties
85+
86+
- Daemon submission errors never trigger direct fallback.
87+
- A failed or ambiguous submission may already have created a durable job;
88+
retrying directly could duplicate content.
89+
- Post-init mining forwards the already-scanned file list into the daemon, so
90+
the project is not scanned twice.
91+
- `sweep` is now a first-class daemon job.
92+
- `--direct` remains an explicit emergency/debug escape hatch.
93+
- No low-level lock behavior is changed.
94+
95+
## Maintenance exclusions
96+
97+
These remain outside ordinary routing:
98+
99+
- repair;
100+
- migration and wing migration;
101+
- index rebuild;
102+
- closet compression;
103+
- embedder identity changes.
104+
105+
They can replace indexes, rewrite broad metadata sets, or require all cached
106+
handles to close. They need an exclusive-maintenance protocol rather than an
107+
ordinary queued-write job.

docs/write-routing-policy.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,11 @@ operations offline, with no writable service running.
147147

148148
## Follow-up PRs
149149

150-
Hook-triggered writes now consume this policy; see
150+
Hook-triggered writes consume this policy; see
151151
`docs/hook-write-routing.md`.
152152

153-
The remaining rollout PR will apply the policy to routine CLI writes.
153+
Routine CLI writes also consume this policy; see
154+
`docs/cli-write-routing.md`.
154155

155156
Maintenance operations such as repair, migration, and index rebuild are not
156157
ordinary routed writes. They require a separate exclusive-maintenance policy.

mempalace/cli.py

Lines changed: 138 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
from pathlib import Path
4141

4242
from .config import MempalaceConfig
43+
from .cli_write_routing import (
44+
add_cli_write_routing_flags,
45+
resolve_cli_write_routing,
46+
)
47+
from .write_routing import WriteRoutingError
4348
from .corpus_origin import detect_origin_heuristic, detect_origin_llm
4449
from .llm_client import LLMError, get_provider
4550
from .version import __version__
@@ -102,6 +107,21 @@ def _maintenance_requires_chroma(palace_path: str, command_name: str) -> bool:
102107
return False
103108

104109

110+
def _resolve_cli_write_routing_or_exit(args, operation: str):
111+
"""Resolve routine CLI routing and render configuration errors."""
112+
try:
113+
return resolve_cli_write_routing(
114+
args,
115+
operation=operation,
116+
)
117+
except WriteRoutingError as exc:
118+
print(
119+
f"mempalace: invalid CLI write routing: {exc}",
120+
file=sys.stderr,
121+
)
122+
raise SystemExit(2) from exc
123+
124+
105125
def _gather_origin_samples(project_dir) -> list:
106126
"""Collect Tier-1 samples for corpus-origin detection.
107127
@@ -565,6 +585,39 @@ def _maybe_run_mine_after_init(args, cfg) -> None:
565585
return
566586

567587
palace_path = cfg.palace_path
588+
routing = _resolve_cli_write_routing_or_exit(
589+
args,
590+
"init auto-mine",
591+
)
592+
593+
if routing.use_daemon:
594+
payload = {
595+
"source": project_dir,
596+
"mode": "projects",
597+
"wing": None,
598+
"agent": "mempalace",
599+
"limit": 0,
600+
"dry_run": False,
601+
"extract": "exchange",
602+
"no_gitignore": False,
603+
"include_ignored": [],
604+
"max_chunks_per_file": None,
605+
"redetect_origin": False,
606+
"files": (
607+
[str(file_path) for file_path in scanned_files]
608+
if scanned_files is not None
609+
else None
610+
),
611+
}
612+
_submit_daemon_cli_job(
613+
"mine",
614+
payload,
615+
args,
616+
background=False,
617+
auto_start=routing.decision.auto_start_daemon,
618+
)
619+
return
620+
568621
try:
569622
mine(
570623
project_dir=project_dir,
@@ -722,27 +775,43 @@ def cmd_mine(args):
722775
for raw in args.include_ignored or []:
723776
include_ignored.extend(part.strip() for part in raw.split(",") if part.strip())
724777

725-
if getattr(args, "background", False) and not getattr(args, "daemon", False):
726-
print("mempalace: --background requires --daemon", file=sys.stderr)
727-
sys.exit(2)
778+
payload = {
779+
"source": args.dir,
780+
"mode": mode,
781+
"wing": args.wing,
782+
"agent": args.agent,
783+
"limit": args.limit,
784+
"dry_run": args.dry_run,
785+
"extract": args.extract,
786+
"no_gitignore": args.no_gitignore,
787+
"include_ignored": include_ignored,
788+
"max_chunks_per_file": getattr(
789+
args,
790+
"max_chunks_per_file",
791+
None,
792+
),
793+
"redetect_origin": getattr(
794+
args,
795+
"redetect_origin",
796+
False,
797+
),
798+
}
728799

729-
if getattr(args, "daemon", False):
730-
payload = {
731-
"source": args.dir,
732-
"mode": mode,
733-
"wing": args.wing,
734-
"agent": args.agent,
735-
"limit": args.limit,
736-
"dry_run": args.dry_run,
737-
"extract": args.extract,
738-
"no_gitignore": args.no_gitignore,
739-
"include_ignored": include_ignored,
740-
"max_chunks_per_file": getattr(args, "max_chunks_per_file", None),
741-
"redetect_origin": getattr(args, "redetect_origin", False),
742-
}
743-
if source_adapter:
744-
payload["source_adapter"] = source_adapter
745-
_submit_daemon_cli_job("mine", payload, args, background=getattr(args, "background", False))
800+
if source_adapter:
801+
payload["source_adapter"] = source_adapter
802+
803+
routing = _resolve_cli_write_routing_or_exit(
804+
args,
805+
"mine",
806+
)
807+
if routing.use_daemon:
808+
_submit_daemon_cli_job(
809+
"mine",
810+
payload,
811+
args,
812+
background=bool(getattr(args, "background", False)),
813+
auto_start=routing.decision.auto_start_daemon,
814+
)
746815
return
747816

748817
from .palace import MineAlreadyRunning, MineValidationError
@@ -1026,6 +1095,19 @@ def cmd_sweep(args):
10261095

10271096
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
10281097
target = os.path.expanduser(args.target)
1098+
routing = _resolve_cli_write_routing_or_exit(
1099+
args,
1100+
"sweep",
1101+
)
1102+
if routing.use_daemon:
1103+
_submit_daemon_cli_job(
1104+
"sweep",
1105+
{"target": target},
1106+
args,
1107+
background=bool(getattr(args, "background", False)),
1108+
auto_start=routing.decision.auto_start_daemon,
1109+
)
1110+
return
10291111

10301112
if os.path.isfile(target):
10311113
result = sweep(target, palace_path)
@@ -1058,18 +1140,25 @@ def cmd_sync(args):
10581140
"""Prune drawers whose source files are gitignored, deleted, or moved (#1252)."""
10591141
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
10601142

1061-
if getattr(args, "background", False) and not getattr(args, "daemon", False):
1062-
print("mempalace: --background requires --daemon", file=sys.stderr)
1063-
sys.exit(2)
1143+
payload = {
1144+
"dir": args.dir,
1145+
"root": list(args.root or []),
1146+
"wing": args.wing,
1147+
"dry_run": args.dry_run,
1148+
}
10641149

1065-
if getattr(args, "daemon", False):
1066-
payload = {
1067-
"dir": args.dir,
1068-
"root": list(args.root or []),
1069-
"wing": args.wing,
1070-
"dry_run": args.dry_run,
1071-
}
1072-
_submit_daemon_cli_job("sync", payload, args, background=getattr(args, "background", False))
1150+
routing = _resolve_cli_write_routing_or_exit(
1151+
args,
1152+
"sync",
1153+
)
1154+
if routing.use_daemon:
1155+
_submit_daemon_cli_job(
1156+
"sync",
1157+
payload,
1158+
args,
1159+
background=bool(getattr(args, "background", False)),
1160+
auto_start=routing.decision.auto_start_daemon,
1161+
)
10731162
return
10741163

10751164
from .palace import MineAlreadyRunning
@@ -1160,7 +1249,14 @@ def cmd_sync(args):
11601249
print(f"\n{'=' * 55}\n")
11611250

11621251

1163-
def _submit_daemon_cli_job(kind: str, payload: dict, args, *, background: bool) -> None:
1252+
def _submit_daemon_cli_job(
1253+
kind: str,
1254+
payload: dict,
1255+
args,
1256+
*,
1257+
background: bool,
1258+
auto_start: bool = True,
1259+
) -> None:
11641260
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
11651261
backend = _backend_arg(args)
11661262
from .daemon import DaemonError, submit_job
@@ -1172,7 +1268,7 @@ def _submit_daemon_cli_job(kind: str, payload: dict, args, *, background: bool)
11721268
palace_path=palace_path,
11731269
backend=backend,
11741270
wait=not background,
1175-
auto_start=True,
1271+
auto_start=auto_start,
11761272
# A job refused the palace lock is deferred, not failed (#2014), so
11771273
# it never becomes terminal while the holder lives. Waiting it out
11781274
# would strand this terminal behind a peer that can outlive the
@@ -2520,6 +2616,11 @@ def main():
25202616
),
25212617
)
25222618

2619+
add_cli_write_routing_flags(
2620+
p_init,
2621+
allow_background=False,
2622+
)
2623+
25232624
# mine
25242625
p_mine = sub.add_parser("mine", help="Mine files into the palace")
25252626
p_mine.add_argument(
@@ -2582,16 +2683,7 @@ def main():
25822683
p_mine.add_argument(
25832684
"--dry-run", action="store_true", help="Show what would be filed without filing"
25842685
)
2585-
p_mine.add_argument(
2586-
"--daemon",
2587-
action="store_true",
2588-
help="Submit this mine to the opt-in local daemon queue",
2589-
)
2590-
p_mine.add_argument(
2591-
"--background",
2592-
action="store_true",
2593-
help="With --daemon, return a job id immediately instead of waiting",
2594-
)
2686+
add_cli_write_routing_flags(p_mine)
25952687
p_mine.add_argument(
25962688
"--extract",
25972689
choices=["exchange", "general"],
@@ -2634,6 +2726,8 @@ def main():
26342726
help="A .jsonl transcript file, or a directory to scan recursively",
26352727
)
26362728

2729+
add_cli_write_routing_flags(p_sweep)
2730+
26372731
# sync
26382732
p_sync = sub.add_parser(
26392733
"sync",
@@ -2665,16 +2759,7 @@ def main():
26652759
action="store_false",
26662760
help="Actually delete drawers (overrides --dry-run; requires --wing or a project root)",
26672761
)
2668-
p_sync.add_argument(
2669-
"--daemon",
2670-
action="store_true",
2671-
help="Submit this sync to the opt-in local daemon queue",
2672-
)
2673-
p_sync.add_argument(
2674-
"--background",
2675-
action="store_true",
2676-
help="With --daemon, return a job id immediately instead of waiting",
2677-
)
2762+
add_cli_write_routing_flags(p_sync)
26782763

26792764
# search
26802765
p_search = sub.add_parser("search", help="Find anything, exact words")

0 commit comments

Comments
 (0)