@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.
Files changed (108) hide show
  1. package/dist/cli/composer-commands.d.ts +37 -0
  2. package/dist/cli/composer-commands.d.ts.map +1 -0
  3. package/dist/cli/composer-commands.js +396 -0
  4. package/dist/cli/composer-commands.js.map +1 -0
  5. package/dist/cli/senpi-commands.d.ts.map +1 -1
  6. package/dist/cli/senpi-commands.js +2 -0
  7. package/dist/cli/senpi-commands.js.map +1 -1
  8. package/dist/cli/validate-runtime-config.mjs +190 -0
  9. package/dist/composer/AUTHORING.md +435 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +59 -0
  12. package/dist/composer/authoring/answers.py +297 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +26 -0
  14. package/dist/composer/authoring/archetypes/_common.py +410 -0
  15. package/dist/composer/authoring/archetypes/breakout.py +134 -0
  16. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  17. package/dist/composer/authoring/archetypes/skeleton.py +76 -0
  18. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  19. package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
  20. package/dist/composer/authoring/builder.py +191 -0
  21. package/dist/composer/authoring/emit.py +264 -0
  22. package/dist/composer/authoring/gaps.py +115 -0
  23. package/dist/composer/authoring/generate.py +131 -0
  24. package/dist/composer/authoring/presets.py +168 -0
  25. package/dist/composer/authoring/runtime_emit.py +266 -0
  26. package/dist/composer/authoring/strategy_manifest.py +94 -0
  27. package/dist/composer/cli.py +487 -0
  28. package/dist/composer/compiler/__init__.py +22 -0
  29. package/dist/composer/compiler/compile.py +739 -0
  30. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  31. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  32. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  33. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  34. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  35. package/dist/composer/generated/README.md +22 -0
  36. package/dist/composer/generated/catalog.md +118 -0
  37. package/dist/composer/generated/describe.md +1168 -0
  38. package/dist/composer/generated/graph.schema.json +1946 -0
  39. package/dist/composer/primitives/__init__.py +320 -0
  40. package/dist/composer/primitives/_candles.py +76 -0
  41. package/dist/composer/primitives/adapter.py +363 -0
  42. package/dist/composer/primitives/emitters.py +142 -0
  43. package/dist/composer/primitives/filters.py +71 -0
  44. package/dist/composer/primitives/indicators.py +326 -0
  45. package/dist/composer/primitives/rankers.py +40 -0
  46. package/dist/composer/primitives/scorers.py +100 -0
  47. package/dist/composer/primitives/sources.py +979 -0
  48. package/dist/composer/primitives/stateful.py +254 -0
  49. package/dist/composer/pure_fn/__init__.py +34 -0
  50. package/dist/composer/pure_fn/checks.py +39 -0
  51. package/dist/composer/pure_fn/models.py +177 -0
  52. package/dist/composer/pure_fn/purity.py +311 -0
  53. package/dist/composer/pure_fn/runtests.py +155 -0
  54. package/dist/composer/registry/__init__.py +40 -0
  55. package/dist/composer/registry/generate.py +425 -0
  56. package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
  57. package/dist/composer/registry/mcp_closure.py +311 -0
  58. package/dist/composer/registry/models.py +417 -0
  59. package/dist/composer/registry/nodes.py +1297 -0
  60. package/dist/composer/requirements.txt +21 -0
  61. package/dist/composer/scaffold_path.py +118 -0
  62. package/dist/composer/templates/__init__.py +13 -0
  63. package/dist/composer/templates/scan.py.j2 +34 -0
  64. package/dist/composer/validator/__init__.py +29 -0
  65. package/dist/composer/validator/desugar.py +133 -0
  66. package/dist/composer/validator/errors.py +227 -0
  67. package/dist/composer/validator/graph_checks.py +443 -0
  68. package/dist/composer/validator/loader.py +110 -0
  69. package/dist/composer/validator/schema_checks.py +147 -0
  70. package/dist/composer/validator/validate.py +86 -0
  71. package/dist/composer/vendoring.py +103 -0
  72. package/dist/composer/verbs/__init__.py +49 -0
  73. package/dist/composer/verbs/_box.py +308 -0
  74. package/dist/composer/verbs/check.py +254 -0
  75. package/dist/composer/verbs/close.py +494 -0
  76. package/dist/composer/verbs/deploy.py +279 -0
  77. package/dist/composer/verbs/docs.py +30 -0
  78. package/dist/composer/verbs/fund.py +498 -0
  79. package/dist/composer/verbs/install.py +586 -0
  80. package/dist/composer/verbs/runtime_validate.py +422 -0
  81. package/dist/composer/verbs/smoke.py +134 -0
  82. package/dist/composer/verbs/status.py +383 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -9
  85. package/dist/index.js.map +1 -1
  86. package/dist/runtime/load-runtime-config.d.ts +17 -0
  87. package/dist/runtime/load-runtime-config.d.ts.map +1 -1
  88. package/dist/runtime/load-runtime-config.js +2 -2
  89. package/dist/runtime/load-runtime-config.js.map +1 -1
  90. package/dist/runtime-api/config.d.ts +2 -2
  91. package/dist/runtime-api/config.js +4 -4
  92. package/dist/runtime-api/config.js.map +1 -1
  93. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  94. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  95. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  96. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  97. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  98. package/dist/runtime-api/intake/intake-mount.js +2 -8
  99. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  100. package/dist/skills-manager/manifest.d.ts.map +1 -1
  101. package/dist/skills-manager/manifest.js +1 -3
  102. package/dist/skills-manager/manifest.js.map +1 -1
  103. package/dist/skills-manager/skills-manifest.json +1 -3
  104. package/package.json +4 -2
  105. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  106. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  107. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  108. 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)