@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716233548
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/composer-commands.d.ts +37 -0
- package/dist/cli/composer-commands.d.ts.map +1 -0
- package/dist/cli/composer-commands.js +396 -0
- package/dist/cli/composer-commands.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +2 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/validate-runtime-config.mjs +190 -0
- package/dist/composer/AUTHORING.md +435 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +59 -0
- package/dist/composer/authoring/answers.py +297 -0
- package/dist/composer/authoring/archetypes/__init__.py +26 -0
- package/dist/composer/authoring/archetypes/_common.py +410 -0
- package/dist/composer/authoring/archetypes/breakout.py +134 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +76 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
- package/dist/composer/authoring/builder.py +191 -0
- package/dist/composer/authoring/emit.py +264 -0
- package/dist/composer/authoring/gaps.py +115 -0
- package/dist/composer/authoring/generate.py +131 -0
- package/dist/composer/authoring/presets.py +168 -0
- package/dist/composer/authoring/runtime_emit.py +266 -0
- package/dist/composer/authoring/strategy_manifest.py +94 -0
- package/dist/composer/cli.py +487 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +739 -0
- package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
- package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
- package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
- package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
- package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
- package/dist/composer/generated/README.md +22 -0
- package/dist/composer/generated/catalog.md +118 -0
- package/dist/composer/generated/describe.md +1168 -0
- package/dist/composer/generated/graph.schema.json +1946 -0
- package/dist/composer/primitives/__init__.py +320 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +363 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +326 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +979 -0
- package/dist/composer/primitives/stateful.py +254 -0
- package/dist/composer/pure_fn/__init__.py +34 -0
- package/dist/composer/pure_fn/checks.py +39 -0
- package/dist/composer/pure_fn/models.py +177 -0
- package/dist/composer/pure_fn/purity.py +311 -0
- package/dist/composer/pure_fn/runtests.py +155 -0
- package/dist/composer/registry/__init__.py +40 -0
- package/dist/composer/registry/generate.py +425 -0
- package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
- package/dist/composer/registry/mcp_closure.py +311 -0
- package/dist/composer/registry/models.py +417 -0
- package/dist/composer/registry/nodes.py +1297 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/scaffold_path.py +118 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +34 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +227 -0
- package/dist/composer/validator/graph_checks.py +443 -0
- package/dist/composer/validator/loader.py +110 -0
- package/dist/composer/validator/schema_checks.py +147 -0
- package/dist/composer/validator/validate.py +86 -0
- package/dist/composer/vendoring.py +103 -0
- package/dist/composer/verbs/__init__.py +49 -0
- package/dist/composer/verbs/_box.py +308 -0
- package/dist/composer/verbs/check.py +254 -0
- package/dist/composer/verbs/close.py +494 -0
- package/dist/composer/verbs/deploy.py +279 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/fund.py +498 -0
- package/dist/composer/verbs/install.py +586 -0
- package/dist/composer/verbs/runtime_validate.py +422 -0
- package/dist/composer/verbs/smoke.py +134 -0
- package/dist/composer/verbs/status.py +383 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -9
- package/dist/index.js.map +1 -1
- package/dist/runtime/load-runtime-config.d.ts +17 -0
- package/dist/runtime/load-runtime-config.d.ts.map +1 -1
- package/dist/runtime/load-runtime-config.js +2 -2
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime-api/config.d.ts +2 -2
- package/dist/runtime-api/config.js +4 -4
- package/dist/runtime-api/config.js.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
- package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.js +5 -14
- package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +2 -8
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/skills-manager/manifest.d.ts.map +1 -1
- package/dist/skills-manager/manifest.js +1 -3
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -3
- package/package.json +4 -2
- package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
- package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
- package/dist/runtime-api/intake/ingest-mutex.js +0 -18
- package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""close — safe teardown of a running strategy (slice B1; absorbs senpi-strategy-ops close.py).
|
|
2
|
+
|
|
3
|
+
THE INVARIANT (non-negotiable): STOP the runtime FIRST, confirm it is gone, and only THEN
|
|
4
|
+
submit the MCP ``strategy_close``. A raw ``strategy_close`` while a runtime is still live
|
|
5
|
+
STRANDS the runtime (it keeps trading a wallet the server is tearing down). So per target:
|
|
6
|
+
|
|
7
|
+
1. STOP — ``openclaw senpi runtime delete <id> --address <wallet>`` (if a runtime is
|
|
8
|
+
live for the wallet), then CONFIRM gone by re-listing. If the delete fails
|
|
9
|
+
(CMP220) or the runtime is still present after it (CMP221), strategy_close is
|
|
10
|
+
WITHHELD — the verb fails loud rather than strand or double-fire.
|
|
11
|
+
2. TRIGGER — submit MCP ``strategy_close(<strategyId>)`` (flattens ALL positions, closes
|
|
12
|
+
the strategy, returns funds). SUBMIT-ONLY, no poll: strategy_close is async.
|
|
13
|
+
|
|
14
|
+
The verb is IDEMPOTENT and re-running it IS the poll:
|
|
15
|
+
- runtime already gone → skip the delete (step 1 confirmed on entry).
|
|
16
|
+
- strategy already ACTIVE→closing → skip re-submit (only an ACTIVE/unknown status submits).
|
|
17
|
+
- strategy left the active set → report ``closed``.
|
|
18
|
+
|
|
19
|
+
Discovery precedence (report which one resolved the target):
|
|
20
|
+
(artifact) the composer's own on-box artifacts FIRST — a canonical hash dir's
|
|
21
|
+
``install.json`` (wallet + runtime_id + strategy), or a strategy name / runtime id
|
|
22
|
+
resolved under ``<state>/strategies/``. The strategyId + status come from MCP
|
|
23
|
+
``strategy_list`` matched by the bound wallet.
|
|
24
|
+
(legacy) strategies the composer did NOT install (ops-deployed) — match MCP
|
|
25
|
+
``strategy_list`` by ``strategyId == target`` or attribution ``skillName == target``;
|
|
26
|
+
sid + wallet are read from the STRATEGY RECORD (not the runtime), so orphans close too.
|
|
27
|
+
(wallet) a bare ``0x…`` target (or ``--wallet``) is used as the wallet directly.
|
|
28
|
+
|
|
29
|
+
Both the ``openclaw`` shell-out and the MCP call are behind injectable seams (see ``_box``),
|
|
30
|
+
so the whole verb is tested with no box and no network.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Optional
|
|
40
|
+
|
|
41
|
+
from composer.validator.errors import Location, TeachingError
|
|
42
|
+
|
|
43
|
+
from . import _box
|
|
44
|
+
from ._box import (
|
|
45
|
+
CLOSED_STATUSES,
|
|
46
|
+
LIVE_STATUSES,
|
|
47
|
+
DeleteFn,
|
|
48
|
+
ListFn,
|
|
49
|
+
McpCredsMissing,
|
|
50
|
+
McpError,
|
|
51
|
+
McpFn,
|
|
52
|
+
wallet_match,
|
|
53
|
+
)
|
|
54
|
+
from .install import INSTALL_JSON
|
|
55
|
+
|
|
56
|
+
CLOSING = "CLOSING"
|
|
57
|
+
CLOSED = "CLOSED"
|
|
58
|
+
REFUSED = "REFUSED"
|
|
59
|
+
FAILED = "FAILED"
|
|
60
|
+
|
|
61
|
+
_WALLET_ADDR = re.compile(r"^0x[0-9a-fA-F]{40}$")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------- strategy extractors
|
|
65
|
+
# The MCP strategy_list shape is not strictly pinned; every extractor tries a few key
|
|
66
|
+
# spellings and degrades to None rather than throwing (ported from senpi-strategy-ops _cli).
|
|
67
|
+
|
|
68
|
+
def _dig(obj, *keys, default=None):
|
|
69
|
+
if not isinstance(obj, dict):
|
|
70
|
+
return default
|
|
71
|
+
lower = {k.lower(): v for k, v in obj.items()}
|
|
72
|
+
for k in keys:
|
|
73
|
+
if k in obj:
|
|
74
|
+
return obj[k]
|
|
75
|
+
if k.lower() in lower:
|
|
76
|
+
return lower[k.lower()]
|
|
77
|
+
return default
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _find_list(obj, *wrappers) -> list:
|
|
81
|
+
if isinstance(obj, list):
|
|
82
|
+
return obj
|
|
83
|
+
if isinstance(obj, dict):
|
|
84
|
+
for k in wrappers + ("data", "result", "items"):
|
|
85
|
+
v = _dig(obj, k)
|
|
86
|
+
if isinstance(v, list):
|
|
87
|
+
return v
|
|
88
|
+
d = obj.get("data") if isinstance(obj.get("data"), dict) else None
|
|
89
|
+
if d:
|
|
90
|
+
return _find_list(d, *wrappers)
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _strategy_obj(x) -> dict:
|
|
95
|
+
if not isinstance(x, dict):
|
|
96
|
+
return {}
|
|
97
|
+
for path in (("data", "strategy"), ("strategy",), ("data",)):
|
|
98
|
+
cur = x
|
|
99
|
+
for k in path:
|
|
100
|
+
cur = cur.get(k) if isinstance(cur, dict) else None
|
|
101
|
+
if isinstance(cur, dict) and (_dig(cur, "strategyId", "id", "strategy_id")
|
|
102
|
+
or _dig(cur, "status", "state")):
|
|
103
|
+
return cur
|
|
104
|
+
return x
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _strat_id(s):
|
|
108
|
+
return _dig(_strategy_obj(s), "strategyId", "id", "strategy_id")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _strat_status(s):
|
|
112
|
+
return _dig(_strategy_obj(s), "status", "state")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _strat_wallet(s):
|
|
116
|
+
return _dig(_strategy_obj(s), "strategyWalletAddress", "walletAddress", "wallet", "address")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _strat_skill(s):
|
|
120
|
+
o = _strategy_obj(s)
|
|
121
|
+
meta = _dig(o, "strategyMetadata", "metadata")
|
|
122
|
+
if isinstance(meta, dict):
|
|
123
|
+
sk = _dig(meta, "skillName", "skill_name")
|
|
124
|
+
if sk:
|
|
125
|
+
return sk
|
|
126
|
+
return _dig(o, "skillName", "skill_name", "skill") or _dig(o, "tradingStrategyName", "name")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------- resolved targets
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class CloseTarget:
|
|
133
|
+
"""One strategy to close, resolved from the CLI target."""
|
|
134
|
+
|
|
135
|
+
wallet: str
|
|
136
|
+
source: str # "artifact" | "legacy" | "wallet"
|
|
137
|
+
strategy_id: Optional[str] = None
|
|
138
|
+
status: Optional[str] = None
|
|
139
|
+
runtime_id: Optional[str] = None # the id recorded in install.json (artifact source)
|
|
140
|
+
label: str = ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class InstanceResult:
|
|
145
|
+
label: str
|
|
146
|
+
wallet: str
|
|
147
|
+
verdict: str
|
|
148
|
+
source: str
|
|
149
|
+
strategy_id: Optional[str] = None
|
|
150
|
+
runtime_id: Optional[str] = None
|
|
151
|
+
runtime: str = "" # "stopped" | "not_found" | "delete_failed" | "still_present"
|
|
152
|
+
strategy_status: Optional[str] = None
|
|
153
|
+
error: Optional[TeachingError] = None
|
|
154
|
+
|
|
155
|
+
def to_json(self) -> dict:
|
|
156
|
+
return {
|
|
157
|
+
"label": self.label,
|
|
158
|
+
"wallet": self.wallet,
|
|
159
|
+
"verdict": self.verdict,
|
|
160
|
+
"source": self.source,
|
|
161
|
+
"strategy_id": self.strategy_id,
|
|
162
|
+
"runtime_id": self.runtime_id,
|
|
163
|
+
"runtime": self.runtime,
|
|
164
|
+
"strategy_status": self.strategy_status,
|
|
165
|
+
"error": self.error.to_dict() if self.error else None,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class CloseResult:
|
|
171
|
+
target: str
|
|
172
|
+
verdict: str
|
|
173
|
+
instances: list[InstanceResult] = field(default_factory=list)
|
|
174
|
+
errors: list[TeachingError] = field(default_factory=list)
|
|
175
|
+
message: str = ""
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def ok(self) -> bool:
|
|
179
|
+
return self.verdict in (CLOSING, CLOSED)
|
|
180
|
+
|
|
181
|
+
def to_json(self) -> dict:
|
|
182
|
+
return {
|
|
183
|
+
"verb": "close",
|
|
184
|
+
"target": self.target,
|
|
185
|
+
"verdict": self.verdict,
|
|
186
|
+
"instances": [i.to_json() for i in self.instances],
|
|
187
|
+
"error_count": len(self.errors),
|
|
188
|
+
"errors": [e.to_dict() for e in self.errors],
|
|
189
|
+
"message": self.message,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
def render(self) -> str:
|
|
193
|
+
lines = [f"{self.verdict}: {self.target}"]
|
|
194
|
+
if self.message:
|
|
195
|
+
lines.append(f" {self.message}")
|
|
196
|
+
for inst in self.instances:
|
|
197
|
+
rt = inst.runtime or "-"
|
|
198
|
+
sid = (inst.strategy_id or "")[:12]
|
|
199
|
+
lines.append(
|
|
200
|
+
f" - {inst.label}: {inst.verdict} (runtime: {rt}; strategy: {sid or '?'}"
|
|
201
|
+
f"{'; ' + inst.source if inst.source else ''})"
|
|
202
|
+
)
|
|
203
|
+
if inst.error:
|
|
204
|
+
lines.append(" " + inst.error.render().replace("\n", "\n "))
|
|
205
|
+
for e in self.errors:
|
|
206
|
+
lines.append(e.render())
|
|
207
|
+
if self.verdict == CLOSING:
|
|
208
|
+
lines.append(
|
|
209
|
+
" strategy_close is async — positions flatten on-chain. Re-run "
|
|
210
|
+
"`composer close` to poll until CLOSED (re-running is idempotent)."
|
|
211
|
+
)
|
|
212
|
+
return "\n".join(lines) + "\n"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _refused(target: str, error: TeachingError) -> CloseResult:
|
|
216
|
+
return CloseResult(target=target, verdict=REFUSED, errors=[error], message=error.message)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --------------------------------------------------------------------------- target resolution
|
|
220
|
+
|
|
221
|
+
def _install_jsons_under(strategy_dir: Path) -> list[dict]:
|
|
222
|
+
"""Every ``install.json`` under a ``<state>/strategies/<strategy>/`` dir (one per hash8)."""
|
|
223
|
+
out: list[dict] = []
|
|
224
|
+
if not strategy_dir.is_dir():
|
|
225
|
+
return out
|
|
226
|
+
for child in sorted(strategy_dir.iterdir()):
|
|
227
|
+
p = child / INSTALL_JSON
|
|
228
|
+
if p.is_file():
|
|
229
|
+
try:
|
|
230
|
+
out.append(json.loads(p.read_text(encoding="utf-8")))
|
|
231
|
+
except (OSError, json.JSONDecodeError):
|
|
232
|
+
continue
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _artifacts_for_target(target: str, base_state_dir: str) -> list[dict]:
|
|
237
|
+
"""Resolve the CLI target to on-box ``install.json`` records (artifact source), if any.
|
|
238
|
+
|
|
239
|
+
Accepts: a canonical hash dir path (has install.json), a strategy dir path (has hash
|
|
240
|
+
subdirs), a strategy NAME (resolved under ``<state>/strategies/<name>/``), or a runtime id
|
|
241
|
+
(matched against every install.json's ``runtime_id``)."""
|
|
242
|
+
p = Path(target)
|
|
243
|
+
# a canonical hash dir passed directly.
|
|
244
|
+
if p.is_dir() and (p / INSTALL_JSON).is_file():
|
|
245
|
+
try:
|
|
246
|
+
return [json.loads((p / INSTALL_JSON).read_text(encoding="utf-8"))]
|
|
247
|
+
except (OSError, json.JSONDecodeError):
|
|
248
|
+
return []
|
|
249
|
+
# a strategy dir passed directly (contains hash subdirs).
|
|
250
|
+
if p.is_dir():
|
|
251
|
+
found = _install_jsons_under(p)
|
|
252
|
+
if found:
|
|
253
|
+
return found
|
|
254
|
+
strategies_root = Path(base_state_dir) / "strategies"
|
|
255
|
+
# a strategy NAME under the state dir.
|
|
256
|
+
named = _install_jsons_under(strategies_root / target)
|
|
257
|
+
if named:
|
|
258
|
+
return named
|
|
259
|
+
# a runtime id — scan every strategy's hash dirs for a matching install.json.
|
|
260
|
+
if strategies_root.is_dir():
|
|
261
|
+
for strat_dir in sorted(strategies_root.iterdir()):
|
|
262
|
+
for rec in _install_jsons_under(strat_dir):
|
|
263
|
+
if rec.get("runtime_id") == target:
|
|
264
|
+
return [rec]
|
|
265
|
+
return []
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _resolve_targets(target: str, base_state_dir: str, strategies: list,
|
|
269
|
+
wallet_override: Optional[str],
|
|
270
|
+
strategy_id_override: Optional[str]) -> list[CloseTarget]:
|
|
271
|
+
"""Turn the CLI target (+ overrides) into the list of strategies to close.
|
|
272
|
+
|
|
273
|
+
Precedence: explicit override → on-box artifact → legacy MCP attribution → bare wallet."""
|
|
274
|
+
# explicit --wallet override: use it directly, resolve sid/status from strategy_list.
|
|
275
|
+
if wallet_override:
|
|
276
|
+
s = next((s for s in strategies if wallet_match(_strat_wallet(s), wallet_override)), None)
|
|
277
|
+
return [CloseTarget(
|
|
278
|
+
wallet=wallet_override, source="wallet", label=wallet_override[:14] + "…",
|
|
279
|
+
strategy_id=strategy_id_override or (_strat_id(s) if s else None),
|
|
280
|
+
status=str(_strat_status(s) or "").upper() if s else None,
|
|
281
|
+
)]
|
|
282
|
+
|
|
283
|
+
# explicit --strategy-id override: pull wallet from its strategy record.
|
|
284
|
+
if strategy_id_override:
|
|
285
|
+
s = next((s for s in strategies if _strat_id(s) == strategy_id_override), None)
|
|
286
|
+
if s is not None:
|
|
287
|
+
return [CloseTarget(
|
|
288
|
+
wallet=str(_strat_wallet(s) or ""), source="legacy",
|
|
289
|
+
strategy_id=strategy_id_override,
|
|
290
|
+
status=str(_strat_status(s) or "").upper(),
|
|
291
|
+
label=f"{_strat_skill(s) or 'strategy'}:{strategy_id_override[:8]}",
|
|
292
|
+
)]
|
|
293
|
+
|
|
294
|
+
# (artifact) the composer's own on-box artifacts FIRST.
|
|
295
|
+
artifacts = _artifacts_for_target(target, base_state_dir)
|
|
296
|
+
if artifacts:
|
|
297
|
+
out: list[CloseTarget] = []
|
|
298
|
+
for rec in artifacts:
|
|
299
|
+
wallet = str(rec.get("wallet") or "")
|
|
300
|
+
s = next((s for s in strategies if wallet_match(_strat_wallet(s), wallet)), None)
|
|
301
|
+
out.append(CloseTarget(
|
|
302
|
+
wallet=wallet, source="artifact",
|
|
303
|
+
runtime_id=rec.get("runtime_id"),
|
|
304
|
+
strategy_id=_strat_id(s) if s else None,
|
|
305
|
+
status=str(_strat_status(s) or "").upper() if s else None,
|
|
306
|
+
label=f"{rec.get('strategy') or target}:{(rec.get('hash8') or '')[:8]}",
|
|
307
|
+
))
|
|
308
|
+
return out
|
|
309
|
+
|
|
310
|
+
# (legacy) match the strategy record by strategyId or skillName attribution.
|
|
311
|
+
legacy = [
|
|
312
|
+
s for s in strategies
|
|
313
|
+
if _strat_id(s) == target or _strat_skill(s) == target
|
|
314
|
+
]
|
|
315
|
+
if legacy:
|
|
316
|
+
return [CloseTarget(
|
|
317
|
+
wallet=str(_strat_wallet(s) or ""), source="legacy",
|
|
318
|
+
strategy_id=_strat_id(s),
|
|
319
|
+
status=str(_strat_status(s) or "").upper(),
|
|
320
|
+
label=f"{_strat_skill(s) or 'strategy'}:{str(_strat_id(s) or '')[:8]}",
|
|
321
|
+
) for s in legacy]
|
|
322
|
+
|
|
323
|
+
# (wallet) a bare 0x… target.
|
|
324
|
+
if _WALLET_ADDR.match(target):
|
|
325
|
+
s = next((s for s in strategies if wallet_match(_strat_wallet(s), target)), None)
|
|
326
|
+
return [CloseTarget(
|
|
327
|
+
wallet=target, source="wallet", label=target[:14] + "…",
|
|
328
|
+
strategy_id=_strat_id(s) if s else None,
|
|
329
|
+
status=str(_strat_status(s) or "").upper() if s else None,
|
|
330
|
+
)]
|
|
331
|
+
|
|
332
|
+
return []
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# --------------------------------------------------------------------------- the invariant
|
|
336
|
+
|
|
337
|
+
def _live_runtime_for(runtimes: list, wallet: str, runtime_id: Optional[str]):
|
|
338
|
+
"""The runtime bound to this wallet (or matching the recorded id), or None."""
|
|
339
|
+
for r in runtimes:
|
|
340
|
+
if wallet and wallet_match(r.get("wallet"), wallet):
|
|
341
|
+
return r
|
|
342
|
+
if runtime_id and r.get("name") == runtime_id:
|
|
343
|
+
return r
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _close_one(t: CloseTarget, list_fn: ListFn, delete_fn: DeleteFn,
|
|
348
|
+
mcp_fn: McpFn, mcp_ok: bool) -> InstanceResult:
|
|
349
|
+
"""Enforce the invariant for ONE strategy: STOP runtime → CONFIRM gone → TRIGGER close."""
|
|
350
|
+
rec = InstanceResult(label=t.label or (t.wallet[:14] + "…"), wallet=t.wallet,
|
|
351
|
+
verdict=CLOSING, source=t.source, strategy_id=t.strategy_id,
|
|
352
|
+
runtime_id=t.runtime_id, strategy_status=t.status)
|
|
353
|
+
|
|
354
|
+
# 1. STOP the runtime FIRST (if one is live for this wallet), then CONFIRM it is gone.
|
|
355
|
+
runtimes = list_fn()
|
|
356
|
+
rt = _live_runtime_for(runtimes, t.wallet, t.runtime_id)
|
|
357
|
+
if rt is not None:
|
|
358
|
+
rname = rt.get("name") or t.runtime_id or ""
|
|
359
|
+
res = delete_fn(rname, t.wallet)
|
|
360
|
+
if not res.ok:
|
|
361
|
+
rec.verdict = FAILED
|
|
362
|
+
rec.runtime = "delete_failed"
|
|
363
|
+
rec.error = TeachingError(
|
|
364
|
+
"CMP220", f"`openclaw senpi runtime delete` failed for runtime {rname!r} "
|
|
365
|
+
f"(exit {res.returncode}).",
|
|
366
|
+
"The runtime could NOT be stopped, so strategy_close was WITHHELD (a raw close "
|
|
367
|
+
"with a live runtime strands it). Check the box is reachable "
|
|
368
|
+
"(`openclaw senpi runtime list`) and re-run `composer close`; the delete + close "
|
|
369
|
+
"are idempotent.",
|
|
370
|
+
Location(),
|
|
371
|
+
)
|
|
372
|
+
return rec
|
|
373
|
+
# CONFIRM gone — re-list; the invariant forbids strategy_close while the delete is unconfirmed.
|
|
374
|
+
if _live_runtime_for(list_fn(), t.wallet, t.runtime_id) is not None:
|
|
375
|
+
rec.verdict = FAILED
|
|
376
|
+
rec.runtime = "still_present"
|
|
377
|
+
rec.error = TeachingError(
|
|
378
|
+
"CMP221", f"runtime {rname!r} is STILL present after delete — teardown unconfirmed.",
|
|
379
|
+
"strategy_close was WITHHELD to protect the invariant (never close a strategy "
|
|
380
|
+
"while its runtime is live). Re-run `composer close` to retry the stop; if it "
|
|
381
|
+
"persists, delete it by hand: `openclaw senpi runtime delete --id "
|
|
382
|
+
f"{rname} --address {t.wallet}`.",
|
|
383
|
+
Location(),
|
|
384
|
+
)
|
|
385
|
+
return rec
|
|
386
|
+
rec.runtime = "stopped"
|
|
387
|
+
else:
|
|
388
|
+
rec.runtime = "not_found" # already gone (idempotent re-run) or an orphan strategy
|
|
389
|
+
|
|
390
|
+
# No strategyId → nothing (live) to close server-side.
|
|
391
|
+
if not t.strategy_id:
|
|
392
|
+
# An orphan runtime we just stopped IS the whole job; and when MCP is reachable, a
|
|
393
|
+
# wallet with no LIVE strategy means it has already left the active set → CLOSED. This is
|
|
394
|
+
# exactly the terminal state the idempotent re-run polls to.
|
|
395
|
+
if rec.runtime == "stopped":
|
|
396
|
+
rec.verdict = CLOSED
|
|
397
|
+
rec.strategy_status = "ORPHAN_RUNTIME_STOPPED"
|
|
398
|
+
return rec
|
|
399
|
+
if mcp_ok:
|
|
400
|
+
rec.verdict = CLOSED
|
|
401
|
+
rec.strategy_status = "CLOSED"
|
|
402
|
+
return rec
|
|
403
|
+
# MCP was unreachable, so we could NOT confirm the strategy is gone — fail loud, don't
|
|
404
|
+
# pretend it closed.
|
|
405
|
+
rec.verdict = FAILED
|
|
406
|
+
rec.error = TeachingError(
|
|
407
|
+
"CMP224", "resolved a wallet but could not resolve a strategyId (MCP unreachable).",
|
|
408
|
+
"strategy_list did not return, so the strategy could not be confirmed closed. Check "
|
|
409
|
+
"MCP creds/connectivity and re-run `composer close` (idempotent), or pass the "
|
|
410
|
+
"strategy explicitly with `--strategy-id <id>`.",
|
|
411
|
+
Location(),
|
|
412
|
+
)
|
|
413
|
+
return rec
|
|
414
|
+
|
|
415
|
+
status = str(t.status or "").upper()
|
|
416
|
+
# 2. TRIGGER close — but only when the strategy is still ACTIVE (or status unknown). Once
|
|
417
|
+
# triggered it leaves ACTIVE, so a re-run never re-submits (re-running IS the poll).
|
|
418
|
+
if status in CLOSED_STATUSES:
|
|
419
|
+
rec.verdict = CLOSED
|
|
420
|
+
return rec
|
|
421
|
+
if status in ("ACTIVE", ""):
|
|
422
|
+
try:
|
|
423
|
+
mcp_fn("strategy_close", strategyId=t.strategy_id, timeout=_box._SUBMIT_TIMEOUT)
|
|
424
|
+
except McpError as e:
|
|
425
|
+
rec.verdict = FAILED
|
|
426
|
+
rec.error = TeachingError(
|
|
427
|
+
"CMP222", f"MCP `strategy_close` submit failed: {str(e)[:200]}.",
|
|
428
|
+
"The runtime is already stopped, so re-running `composer close` will retry the "
|
|
429
|
+
"close only (idempotent). If it keeps failing, verify the strategy is still open "
|
|
430
|
+
"in `strategy_list` and that MCP creds are valid.",
|
|
431
|
+
Location(),
|
|
432
|
+
)
|
|
433
|
+
return rec
|
|
434
|
+
# ACTIVE→submitted, or an in-flight status (e.g. CLOSING_POSITIONS) we don't re-submit.
|
|
435
|
+
rec.verdict = CLOSING
|
|
436
|
+
return rec
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# --------------------------------------------------------------------------- the verb
|
|
440
|
+
|
|
441
|
+
def run_close(
|
|
442
|
+
target: str,
|
|
443
|
+
base_state_dir: str,
|
|
444
|
+
*,
|
|
445
|
+
wallet: Optional[str] = None,
|
|
446
|
+
strategy_id: Optional[str] = None,
|
|
447
|
+
list_fn: ListFn = _box.default_list_runtimes,
|
|
448
|
+
delete_fn: DeleteFn = _box.default_delete_runtime,
|
|
449
|
+
mcp_fn: McpFn = _box.default_mcp_call,
|
|
450
|
+
) -> CloseResult:
|
|
451
|
+
"""Close a strategy safely (see module docstring)."""
|
|
452
|
+
if wallet and not _WALLET_ADDR.match(wallet):
|
|
453
|
+
return _refused(target, TeachingError(
|
|
454
|
+
"CMP205", "`--wallet` is not a 0x… address.",
|
|
455
|
+
"Pass a 40-hex `0x…` wallet, or omit `--wallet` and pass the strategy name / "
|
|
456
|
+
"runtime id / strategyId as the target.",
|
|
457
|
+
Location(),
|
|
458
|
+
))
|
|
459
|
+
|
|
460
|
+
# Fetch live strategies ONCE (shared across resolution + every close_one). Missing creds is
|
|
461
|
+
# a loud CMP223 (never a silent skip that would fail to submit strategy_close).
|
|
462
|
+
mcp_ok = True
|
|
463
|
+
try:
|
|
464
|
+
raw = mcp_fn("strategy_list", status=LIVE_STATUSES)
|
|
465
|
+
strategies = _find_list(raw, "strategies")
|
|
466
|
+
except McpCredsMissing as e:
|
|
467
|
+
return _refused(target, TeachingError(
|
|
468
|
+
"CMP223", f"MCP credentials missing: {e}",
|
|
469
|
+
"Set SENPI_AUTH_TOKEN or SENPI_API_KEY (and optionally SENPI_MCP_URL) in the environment — close needs "
|
|
470
|
+
"the MCP server to resolve the strategyId and submit strategy_close.",
|
|
471
|
+
Location(),
|
|
472
|
+
))
|
|
473
|
+
except McpError:
|
|
474
|
+
strategies, mcp_ok = [], False # transport hiccup — degrade to artifact-only
|
|
475
|
+
|
|
476
|
+
targets = _resolve_targets(target, base_state_dir, strategies, wallet, strategy_id)
|
|
477
|
+
if not targets:
|
|
478
|
+
return _refused(target, TeachingError(
|
|
479
|
+
"CMP225", f"could not resolve {target!r} to any strategy to close.",
|
|
480
|
+
"Pass a strategy the composer installed (a name / runtime id under the state dir, or "
|
|
481
|
+
"a canonical hash dir), a bare `0x…` wallet, or — for an ops-deployed strategy — its "
|
|
482
|
+
"strategyId or attribution skillName. `strategy_list` shows open strategies.",
|
|
483
|
+
Location(),
|
|
484
|
+
))
|
|
485
|
+
|
|
486
|
+
instances = [_close_one(t, list_fn, delete_fn, mcp_fn, mcp_ok) for t in targets]
|
|
487
|
+
verdicts = {i.verdict for i in instances}
|
|
488
|
+
overall = (FAILED if FAILED in verdicts else
|
|
489
|
+
CLOSING if CLOSING in verdicts else CLOSED)
|
|
490
|
+
n = len(instances)
|
|
491
|
+
msg = (f"closed {n} strateg{'y' if n == 1 else 'ies'}." if overall == CLOSED else
|
|
492
|
+
f"closing {n} strateg{'y' if n == 1 else 'ies'} (runtime(s) stopped)." if overall == CLOSING
|
|
493
|
+
else "one or more instances failed to close cleanly (see below).")
|
|
494
|
+
return CloseResult(target=target, verdict=overall, instances=instances, message=msg)
|