@senpi/runtime 3.0.9-dev.feat-strategy-compos.20260721003344 → 3.0.9-dev.feat-strategy-compos.20260722213012
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.map +1 -1
- package/dist/cli/composer-commands.js +193 -41
- package/dist/cli/composer-commands.js.map +1 -1
- package/dist/cli/composer-live-read.d.ts +68 -0
- package/dist/cli/composer-live-read.d.ts.map +1 -0
- package/dist/cli/composer-live-read.js +126 -0
- package/dist/cli/composer-live-read.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +8 -10
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/senpi-strategy-tool.d.ts +56 -0
- package/dist/cli/senpi-strategy-tool.d.ts.map +1 -0
- package/dist/cli/senpi-strategy-tool.js +158 -0
- package/dist/cli/senpi-strategy-tool.js.map +1 -0
- package/dist/composer/authoring/__init__.py +2 -0
- package/dist/composer/authoring/answers.py +52 -0
- package/dist/composer/authoring/archetypes/__init__.py +2 -0
- package/dist/composer/authoring/archetypes/contrarian_fade.py +493 -0
- package/dist/composer/authoring/archetypes/relative_value.py +280 -23
- package/dist/composer/cli.py +57 -6
- package/dist/composer/examples/contrarian_fade_crowd.graph.yaml +356 -0
- package/dist/composer/examples/rotation_cross_sectional.graph.yaml +389 -0
- package/dist/composer/generated/catalog.md +2 -1
- package/dist/composer/registry/generate.py +2 -1
- package/dist/composer/validator/errors.py +2 -0
- package/dist/composer/verbs/__init__.py +6 -0
- package/dist/composer/verbs/_box.py +96 -5
- package/dist/composer/verbs/_layout.py +37 -0
- package/dist/composer/verbs/_live.py +50 -6
- package/dist/composer/verbs/close.py +51 -6
- package/dist/composer/verbs/fund.py +144 -27
- package/dist/composer/verbs/install.py +25 -6
- package/dist/composer/verbs/portfolio.py +650 -0
- package/dist/composer/verbs/review.py +724 -0
- package/dist/composer/verbs/status.py +129 -2
- package/dist/composer/verbs/update.py +9 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/composer-managed-write.d.ts +133 -0
- package/dist/runtime/composer-managed-write.d.ts.map +1 -0
- package/dist/runtime/composer-managed-write.js +369 -0
- package/dist/runtime/composer-managed-write.js.map +1 -0
- package/openclaw.plugin.json +5 -0
- package/package.json +2 -1
|
@@ -96,10 +96,15 @@ class LiveState:
|
|
|
96
96
|
# --------------------------------------------------------------------------- default seam impl
|
|
97
97
|
|
|
98
98
|
def default_system_state(runtime_id: Optional[str]) -> LiveState:
|
|
99
|
-
"""Production :data:`SystemStateFn
|
|
100
|
-
``
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
"""Production :data:`SystemStateFn`. FIRST branch (J9): when the rim handed down a live document
|
|
100
|
+
(``SENPI_LIVE_FILE``), serve the system state from its pre-fetched ``system_state`` section — NO
|
|
101
|
+
openclaw boot. The ``senpi state --json`` shell-out below remains ONLY as the FALLBACK for a
|
|
102
|
+
direct/offline invocation with no live-file. NEVER throws — every failure path degrades to
|
|
103
|
+
``LiveState(available=False, reason=...)`` so status stays read-only and prints an honest
|
|
104
|
+
unavailable section (CMP259)."""
|
|
105
|
+
doc = _box.load_live_document()
|
|
106
|
+
if doc is not None and isinstance(doc.get("system_state"), dict):
|
|
107
|
+
return _live_system_state(doc["system_state"], runtime_id)
|
|
103
108
|
args = ["openclaw", "senpi", "state", "--json"]
|
|
104
109
|
if runtime_id:
|
|
105
110
|
args += ["-r", runtime_id]
|
|
@@ -112,6 +117,25 @@ def default_system_state(runtime_id: Optional[str]) -> LiveState:
|
|
|
112
117
|
return parse_system_state(res.stdout, runtime_id)
|
|
113
118
|
|
|
114
119
|
|
|
120
|
+
def _live_system_state(section: dict, runtime_id: Optional[str]) -> LiveState:
|
|
121
|
+
"""Serve a :class:`LiveState` from the rim-served document's ``system_state`` section (J9). The
|
|
122
|
+
rim fetches ``senpi.getSystemState`` UNSCOPED (all runtimes) into one document; this scopes it to
|
|
123
|
+
``runtime_id`` EXACTLY as the gateway's ``-r`` would have — filtering the states to the matching
|
|
124
|
+
runtime and reusing :func:`parse_system_state` UNCHANGED. A ``-r`` miss yields an empty envelope,
|
|
125
|
+
so parse_system_state returns the identical "runtime is not running" degrade the scoped shell
|
|
126
|
+
would. An ``ok:false`` section (the rim could not reach the gateway) stays exactly as LOUD as the
|
|
127
|
+
shell path's failure (CMP259) — never a silent partial."""
|
|
128
|
+
if not section.get("ok"):
|
|
129
|
+
reason = section.get("reason") or "gateway unreachable or errored"
|
|
130
|
+
return LiveState(False, reason=reason, runtime_id=runtime_id)
|
|
131
|
+
states = section.get("states")
|
|
132
|
+
states = states if isinstance(states, list) else []
|
|
133
|
+
if runtime_id:
|
|
134
|
+
states = [s for s in states if isinstance(s, dict)
|
|
135
|
+
and (s.get("runtimeId") == runtime_id or s.get("runtimeName") == runtime_id)]
|
|
136
|
+
return parse_system_state(json.dumps({"ok": True, "states": states}), runtime_id)
|
|
137
|
+
|
|
138
|
+
|
|
115
139
|
def parse_system_state(stdout: str, runtime_id: Optional[str]) -> LiveState:
|
|
116
140
|
"""Parse the ``senpi state --json`` envelope (``{ok, states:[RuntimeSystemState...]}``) into a
|
|
117
141
|
:class:`LiveState`. Tolerant: a non-JSON body, an ok:false error, or zero matching states all
|
|
@@ -244,6 +268,23 @@ def human_tier(tier_index, tier_count) -> str:
|
|
|
244
268
|
return f"tier {tier_index + 1} of {n}"
|
|
245
269
|
|
|
246
270
|
|
|
271
|
+
def is_tier_armed(p: dict) -> bool:
|
|
272
|
+
"""Whether a live position has an ARMED profit-ratchet tier — derived DIRECTLY from the engine's
|
|
273
|
+
``currentTierIndex`` (0-based, in range against ``tiers``), never from a single fail-open
|
|
274
|
+
``phase`` field or a separate ratchet-list lookup.
|
|
275
|
+
|
|
276
|
+
This is the field-log incident's fix. A sibling engine (senpi-portfolio's portfolio.py) decided
|
|
277
|
+
"armed" from the presence of a matched ``ratchet_stop_list`` row keyed by asset, whose container
|
|
278
|
+
it parsed under ``configs/ratchetStops/data/items`` — but the live API returns the rows under
|
|
279
|
+
``positions``, so the lookup silently missed and EVERY armed position (a real ``currentTierIndex``
|
|
280
|
+
of 1 with an active SL order id) was downgraded to "not yet armed / hard_stop null". The box
|
|
281
|
+
engine's ``getSystemState`` already carries ``currentTierIndex`` on each position, so we read the
|
|
282
|
+
armed state off it and never reconstruct it from a second, mis-parsed source."""
|
|
283
|
+
ti = p.get("currentTierIndex")
|
|
284
|
+
tiers = p.get("tiers") or []
|
|
285
|
+
return isinstance(ti, int) and 0 <= ti < len(tiers)
|
|
286
|
+
|
|
287
|
+
|
|
247
288
|
# --------------------------------------------------------------------------- scanner rendering
|
|
248
289
|
|
|
249
290
|
def _scanner_role(scanner_id: str) -> str:
|
|
@@ -427,9 +468,12 @@ def render_protection_lines(p: dict) -> list[str]:
|
|
|
427
468
|
f"high-water {_price(hw)} = peak {_pct(hw_roe, signed=True)})"
|
|
428
469
|
]
|
|
429
470
|
|
|
430
|
-
# Phase + active tier, in 1-based human terms.
|
|
471
|
+
# Phase + active tier, in 1-based human terms. ARMED is derived from currentTierIndex DIRECTLY
|
|
472
|
+
# (is_tier_armed) — an armed tier IS phase 2 by engine definition, so we never let a stale/absent
|
|
473
|
+
# ``phase`` field downgrade a genuinely-armed position to the phase-1 "no tier armed" rendering
|
|
474
|
+
# (the field-log incident: currentTierIndex=1 + an active SL order rendered "not yet armed").
|
|
431
475
|
tier_human = human_tier(tier_idx, len(tiers))
|
|
432
|
-
if
|
|
476
|
+
if is_tier_armed(p):
|
|
433
477
|
tier = tiers[tier_idx] if isinstance(tiers[tier_idx], dict) else {}
|
|
434
478
|
lock_hw = tier.get("lockHwPct")
|
|
435
479
|
locked = p.get("lockedProfitPct")
|
|
@@ -56,7 +56,14 @@ from ._box import (
|
|
|
56
56
|
McpFn,
|
|
57
57
|
wallet_match,
|
|
58
58
|
)
|
|
59
|
-
from ._layout import
|
|
59
|
+
from ._layout import (
|
|
60
|
+
COMPOSER_DIR,
|
|
61
|
+
KIND_MANUAL,
|
|
62
|
+
LEGACY_STRATEGIES_DIR,
|
|
63
|
+
iter_release_dirs,
|
|
64
|
+
read_strategy_kind,
|
|
65
|
+
resolve_wallet_json,
|
|
66
|
+
)
|
|
60
67
|
from .install import INSTALL_JSON
|
|
61
68
|
|
|
62
69
|
CLOSING = "CLOSING"
|
|
@@ -366,6 +373,32 @@ def _resolve_targets(target: str, base_state_dir: str, strategies: list,
|
|
|
366
373
|
))
|
|
367
374
|
return out
|
|
368
375
|
|
|
376
|
+
# (manual) J3: a MANUAL strategy owns a canonical wallet.json marked ``kind: manual`` but NO
|
|
377
|
+
# install.json artifact (it has no runtime and was never installed). Resolve it FROM that record
|
|
378
|
+
# — wallet + backend strategyId — so close discovers it directly (not reliant on MCP name-match)
|
|
379
|
+
# and reports source="manual". The teardown is naturally REDUCED: _close_one finds no runtime to
|
|
380
|
+
# stop (there is none) and submits strategy_close, which flattens any positions + returns funds —
|
|
381
|
+
# exactly the right and sanctioned teardown for a manual strategy (no runtime-delete step).
|
|
382
|
+
if read_strategy_kind(base_state_dir, target) == KIND_MANUAL:
|
|
383
|
+
wj_path, _layout = resolve_wallet_json(base_state_dir, target)
|
|
384
|
+
rec: dict = {}
|
|
385
|
+
if wj_path is not None:
|
|
386
|
+
try:
|
|
387
|
+
rec = json.loads(wj_path.read_text(encoding="utf-8"))
|
|
388
|
+
except (OSError, json.JSONDecodeError):
|
|
389
|
+
rec = {}
|
|
390
|
+
wallet = str(rec.get("wallet") or "")
|
|
391
|
+
rec_sid = rec.get("strategyId")
|
|
392
|
+
s = next((s for s in strategies
|
|
393
|
+
if (rec_sid and _strat_id(s) == rec_sid) or wallet_match(_strat_wallet(s), wallet)),
|
|
394
|
+
None)
|
|
395
|
+
return [CloseTarget(
|
|
396
|
+
wallet=wallet, source="manual",
|
|
397
|
+
strategy_id=rec_sid or (_strat_id(s) if s else None),
|
|
398
|
+
status=str(_strat_status(s) or "").upper() if s else None,
|
|
399
|
+
label=f"{target}:manual",
|
|
400
|
+
)]
|
|
401
|
+
|
|
369
402
|
# (legacy) match the strategy record by strategyId, by OLD-shape skillName attribution, OR by
|
|
370
403
|
# NEW-shape (H4) strategyName. The three legs together discover BOTH strategies already live in
|
|
371
404
|
# the field (skillName == the strategy name) and composer-minted ones (skillName ==
|
|
@@ -407,8 +440,12 @@ def _live_runtime_for(runtimes: list, wallet: str, runtime_id: Optional[str]):
|
|
|
407
440
|
|
|
408
441
|
|
|
409
442
|
def _close_one(t: CloseTarget, list_fn: ListFn, delete_fn: DeleteFn,
|
|
410
|
-
mcp_fn: McpFn, mcp_ok: bool) -> InstanceResult:
|
|
411
|
-
"""Enforce the invariant for ONE strategy: STOP runtime → CONFIRM gone → TRIGGER close.
|
|
443
|
+
mcp_fn: McpFn, mcp_ok: bool, confirm_list_fn: ListFn) -> InstanceResult:
|
|
444
|
+
"""Enforce the invariant for ONE strategy: STOP runtime → CONFIRM gone → TRIGGER close.
|
|
445
|
+
|
|
446
|
+
``list_fn`` supplies the DISCOVERY read (may come from a rim-served snapshot, J9); ``confirm_list_fn``
|
|
447
|
+
is the post-delete CONFIRM re-read, which MUST be a FRESH read — never a pre-run snapshot that
|
|
448
|
+
would falsely see the just-deleted runtime as still live (CMP221)."""
|
|
412
449
|
rec = InstanceResult(label=t.label or (t.wallet[:14] + "…"), wallet=t.wallet,
|
|
413
450
|
verdict=CLOSING, source=t.source, strategy_id=t.strategy_id,
|
|
414
451
|
runtime_id=t.runtime_id, strategy_status=t.status)
|
|
@@ -432,8 +469,9 @@ def _close_one(t: CloseTarget, list_fn: ListFn, delete_fn: DeleteFn,
|
|
|
432
469
|
Location(),
|
|
433
470
|
)
|
|
434
471
|
return rec
|
|
435
|
-
# CONFIRM gone — re-list; the invariant forbids strategy_close while the delete is
|
|
436
|
-
|
|
472
|
+
# CONFIRM gone — re-list FRESH; the invariant forbids strategy_close while the delete is
|
|
473
|
+
# unconfirmed (a rim-served snapshot predates the delete, so the confirm never reads it — J9).
|
|
474
|
+
if _live_runtime_for(confirm_list_fn(), t.wallet, t.runtime_id) is not None:
|
|
437
475
|
rec.verdict = FAILED
|
|
438
476
|
rec.runtime = "still_present"
|
|
439
477
|
rec.error = TeachingError(
|
|
@@ -545,7 +583,14 @@ def run_close(
|
|
|
545
583
|
Location(),
|
|
546
584
|
))
|
|
547
585
|
|
|
548
|
-
|
|
586
|
+
# The post-delete CONFIRM must be a FRESH read. When the PRODUCTION default seam is in play, the
|
|
587
|
+
# discovery ``list_fn`` may serve from a rim-served snapshot (J9), so the confirm shells fresh via
|
|
588
|
+
# ``list_runtimes_fresh``; an INJECTED seam (tests) serves both — its identity is not the default,
|
|
589
|
+
# so the confirm uses it unchanged (a stateful fake reflects the delete exactly as before).
|
|
590
|
+
confirm_list_fn: ListFn = (
|
|
591
|
+
_box.list_runtimes_fresh if list_fn is _box.default_list_runtimes else list_fn
|
|
592
|
+
)
|
|
593
|
+
instances = [_close_one(t, list_fn, delete_fn, mcp_fn, mcp_ok, confirm_list_fn) for t in targets]
|
|
549
594
|
verdicts = {i.verdict for i in instances}
|
|
550
595
|
overall = (FAILED if FAILED in verdicts else
|
|
551
596
|
CLOSING if CLOSING in verdicts else CLOSED)
|
|
@@ -67,7 +67,16 @@ from ._box import (
|
|
|
67
67
|
McpFn,
|
|
68
68
|
wallet_match,
|
|
69
69
|
)
|
|
70
|
-
from ._layout import
|
|
70
|
+
from ._layout import (
|
|
71
|
+
KIND_MANAGED,
|
|
72
|
+
KIND_MANUAL,
|
|
73
|
+
canonical_graph_path,
|
|
74
|
+
canonical_releases_dir,
|
|
75
|
+
canonical_staged_dir,
|
|
76
|
+
canonical_wallet_json,
|
|
77
|
+
resolve_wallet_json,
|
|
78
|
+
wallet_kind,
|
|
79
|
+
)
|
|
71
80
|
from .close import (
|
|
72
81
|
_dig,
|
|
73
82
|
_find_list,
|
|
@@ -197,10 +206,15 @@ def _read_wallet_json(path: Path) -> Optional[dict]:
|
|
|
197
206
|
|
|
198
207
|
|
|
199
208
|
def _write_wallet_json(path: Path, *, strategy: str, wallet: str, strategy_id: str,
|
|
200
|
-
budget: float, created_at: Optional[str]
|
|
209
|
+
budget: float, created_at: Optional[str],
|
|
210
|
+
kind: Optional[str] = None) -> None:
|
|
201
211
|
"""Record the minted wallet BESIDE the strategy (the resumable-by-artifact marker). Written
|
|
202
212
|
BEFORE polling so an interrupted run resumes at step (a). ``created_at`` is an INJECTED value
|
|
203
|
-
(no wall-clock in the Python core) — None when the caller omits it.
|
|
213
|
+
(no wall-clock in the Python core) — None when the caller omits it.
|
|
214
|
+
|
|
215
|
+
``kind`` (J3) stamps the strategy KIND marker. It is OMITTED entirely for a MANAGED wallet
|
|
216
|
+
(``kind`` None or ``"managed"``) so the managed payload is byte-for-byte what it was pre-J3;
|
|
217
|
+
only a MANUAL wallet carries ``"kind": "manual"`` (absence = managed, back-compat)."""
|
|
204
218
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
205
219
|
payload = {
|
|
206
220
|
"strategy": strategy,
|
|
@@ -209,6 +223,8 @@ def _write_wallet_json(path: Path, *, strategy: str, wallet: str, strategy_id: s
|
|
|
209
223
|
"budget": budget,
|
|
210
224
|
"created_at": created_at,
|
|
211
225
|
}
|
|
226
|
+
if kind == KIND_MANUAL:
|
|
227
|
+
payload["kind"] = KIND_MANUAL
|
|
212
228
|
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
213
229
|
|
|
214
230
|
|
|
@@ -237,6 +253,7 @@ class FundResult:
|
|
|
237
253
|
strategy_id: Optional[str] = None
|
|
238
254
|
budget: Optional[float] = None
|
|
239
255
|
available: Optional[float] = None
|
|
256
|
+
kind: str = KIND_MANAGED # J3: "managed" (a graph strategy) | "manual" (wallet, no runtime)
|
|
240
257
|
pre_action: str = "" # the unmistakable money-mutation line (create path only)
|
|
241
258
|
errors: list[TeachingError] = field(default_factory=list)
|
|
242
259
|
message: str = ""
|
|
@@ -255,6 +272,7 @@ class FundResult:
|
|
|
255
272
|
"strategy_id": self.strategy_id,
|
|
256
273
|
"budget": self.budget,
|
|
257
274
|
"available": self.available,
|
|
275
|
+
"kind": self.kind,
|
|
258
276
|
"pre_action": self.pre_action,
|
|
259
277
|
"error_count": len(self.errors),
|
|
260
278
|
"errors": [e.to_dict() for e in self.errors],
|
|
@@ -267,6 +285,8 @@ class FundResult:
|
|
|
267
285
|
lines.append(f" {self.pre_action}")
|
|
268
286
|
if self.strategy:
|
|
269
287
|
lines.append(f" strategy : {self.strategy}")
|
|
288
|
+
if self.kind == KIND_MANUAL:
|
|
289
|
+
lines.append(" kind : manual (wallet only — NO graph/runtime)")
|
|
270
290
|
if self.wallet:
|
|
271
291
|
lines.append(f" wallet : {self.wallet}")
|
|
272
292
|
if self.strategy_id:
|
|
@@ -275,7 +295,11 @@ class FundResult:
|
|
|
275
295
|
lines.append(f" budget : ${self.budget:,.2f}")
|
|
276
296
|
if self.message:
|
|
277
297
|
lines.append(f" {self.message}")
|
|
278
|
-
if self.verdict == FUNDED and self.wallet:
|
|
298
|
+
if self.verdict == FUNDED and self.wallet and self.kind == KIND_MANUAL:
|
|
299
|
+
lines.append(" next: manual strategy is live — place/manage positions directly via "
|
|
300
|
+
"MCP on this wallet (the sanctioned path for this kind). `composer status "
|
|
301
|
+
f"{self.strategy or '<name>'}` shows it; `composer close` tears it down.")
|
|
302
|
+
elif self.verdict == FUNDED and self.wallet:
|
|
279
303
|
lines.append(f" next: `composer install <target>` (auto-binds this wallet), or "
|
|
280
304
|
f"`--wallet {self.wallet}`.")
|
|
281
305
|
if self.verdict == PENDING:
|
|
@@ -300,12 +324,14 @@ def run_fund(
|
|
|
300
324
|
*,
|
|
301
325
|
out_dir: Optional[str] = None,
|
|
302
326
|
created_at: Optional[str] = None,
|
|
327
|
+
manual: bool = False,
|
|
303
328
|
mcp_fn: McpFn = _box.default_mcp_call,
|
|
304
329
|
sleep_fn: Callable[[float], None] = time.sleep,
|
|
305
330
|
poll_attempts: int = 3,
|
|
306
331
|
poll_interval: float = 5.0,
|
|
307
332
|
) -> FundResult:
|
|
308
|
-
"""Create + fund a strategy's wallet (see module docstring).
|
|
333
|
+
"""Create + fund a strategy's wallet (see module docstring). ``manual=True`` (J3) funds a MANUAL
|
|
334
|
+
strategy — a wallet with NO graph/staged unit — resolving the target as a bare strategy name."""
|
|
309
335
|
# (0) --budget gate — REQUIRED, EXPLICIT, ≥ the platform floor. Never derived or split.
|
|
310
336
|
if budget is None or _num(budget) is None or float(budget) < MIN_BUDGET:
|
|
311
337
|
shown = "(missing)" if budget is None else f"${float(budget):,.2f}" if _num(budget) is not None else repr(budget)
|
|
@@ -319,6 +345,14 @@ def run_fund(
|
|
|
319
345
|
), budget=_num(budget))
|
|
320
346
|
budget = float(budget)
|
|
321
347
|
|
|
348
|
+
# J3: a MANUAL strategy has NO graph / no staged unit — it is a funded wallet under composer's
|
|
349
|
+
# record system, nothing more. It resolves by NAME (the target IS the strategy) and refuses to
|
|
350
|
+
# collide with a managed strategy. The managed path below is byte-for-byte unchanged.
|
|
351
|
+
if manual:
|
|
352
|
+
return _run_fund_manual(target, budget, base_state_dir, mcp_fn=mcp_fn,
|
|
353
|
+
created_at=created_at, sleep_fn=sleep_fn,
|
|
354
|
+
poll_attempts=poll_attempts, poll_interval=poll_interval)
|
|
355
|
+
|
|
322
356
|
# (1) resolve the staged unit → strategy name (+ version for attribution). Bare strategy names
|
|
323
357
|
# and canonical graph paths resolve through the layout (G8b).
|
|
324
358
|
staged = _resolve_staged(target, out_dir, base_state_dir)
|
|
@@ -326,7 +360,8 @@ def run_fund(
|
|
|
326
360
|
return _refused(target, "", TeachingError(
|
|
327
361
|
"CMP200", "the fund target is not a staged unit (no manifest.json found).",
|
|
328
362
|
"Point `composer fund` at a `composer deploy` staging dir, or pass the graph file plus "
|
|
329
|
-
"`-o <staging-dir>`. Run `composer deploy <graph> -o <dir>` first."
|
|
363
|
+
"`-o <staging-dir>`. Run `composer deploy <graph> -o <dir>` first. (To fund a MANUAL "
|
|
364
|
+
"strategy — a wallet with no graph — use `composer fund <name> --manual --budget N`.)",
|
|
330
365
|
Location(),
|
|
331
366
|
), budget=budget)
|
|
332
367
|
try:
|
|
@@ -344,11 +379,82 @@ def run_fund(
|
|
|
344
379
|
return _verify_existing(target, strategy, budget, existing, existing_path, mcp_fn, created_at)
|
|
345
380
|
wj_path = wallet_json_path(base_state_dir, strategy) # canonical write path for a fresh mint
|
|
346
381
|
|
|
382
|
+
# (b)+(c) preflight balance → create → record → poll (shared with the manual path; kind=None so
|
|
383
|
+
# the managed wallet.json payload is byte-unchanged).
|
|
384
|
+
return _mint_wallet(target, strategy, budget, wj_path, mcp_fn=mcp_fn, created_at=created_at,
|
|
385
|
+
sleep_fn=sleep_fn, poll_attempts=poll_attempts, poll_interval=poll_interval,
|
|
386
|
+
kind=None)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# --------------------------------------------------------------------------- manual strategy (J3)
|
|
390
|
+
|
|
391
|
+
def _manual_artifacts_present(base_state_dir: str, strategy: str) -> bool:
|
|
392
|
+
"""True when a MANAGED strategy already owns ``strategy``'s canonical dir — a graph.yaml record,
|
|
393
|
+
a staged/ deploy output, or a releases/ install tree. These are the graph-lifecycle artifacts a
|
|
394
|
+
manual strategy NEVER has, so their presence is the collision signal (CMP262)."""
|
|
395
|
+
return (
|
|
396
|
+
canonical_graph_path(base_state_dir, strategy).is_file()
|
|
397
|
+
or canonical_staged_dir(base_state_dir, strategy).is_dir()
|
|
398
|
+
or canonical_releases_dir(base_state_dir, strategy).is_dir()
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _run_fund_manual(target: str, budget: float, base_state_dir: str, *,
|
|
403
|
+
mcp_fn: McpFn, created_at: Optional[str],
|
|
404
|
+
sleep_fn: Callable[[float], None], poll_attempts: int,
|
|
405
|
+
poll_interval: float) -> FundResult:
|
|
406
|
+
"""Fund a MANUAL strategy (J3): the target IS the strategy name; there is no graph to resolve.
|
|
407
|
+
Refuses (CMP262) if the name already belongs to a MANAGED strategy; otherwise mints + funds a
|
|
408
|
+
wallet exactly like the managed path, stamping the ``kind: manual`` marker into wallet.json."""
|
|
409
|
+
strategy = str(target)
|
|
410
|
+
|
|
411
|
+
existing_path, _layout = resolve_wallet_json(base_state_dir, strategy)
|
|
412
|
+
existing = _read_wallet_json(existing_path) if existing_path is not None else None
|
|
413
|
+
managed_wallet = existing is not None and wallet_kind(existing) != KIND_MANUAL
|
|
414
|
+
|
|
415
|
+
# COLLISION: never overwrite a managed strategy (graph/staged/releases, or a managed wallet.json).
|
|
416
|
+
if _manual_artifacts_present(base_state_dir, strategy) or managed_wallet:
|
|
417
|
+
return _refused(target, strategy, TeachingError(
|
|
418
|
+
"CMP262", f"{strategy!r} already exists as a MANAGED strategy (it has a graph / staged "
|
|
419
|
+
f"/ installed artifacts or a managed wallet.json) — it cannot be re-created as "
|
|
420
|
+
f"a manual wallet.",
|
|
421
|
+
f"Pick a DIFFERENT name for the manual wallet, or manage {strategy!r} as the graph "
|
|
422
|
+
f"strategy it already is (`composer status {strategy}` shows its lifecycle; "
|
|
423
|
+
f"`composer fund {strategy} --budget N` funds its graph-bound wallet). `--manual` only "
|
|
424
|
+
f"creates a brand-new wallet-only strategy.",
|
|
425
|
+
Location(),
|
|
426
|
+
), budget=budget, kind=KIND_MANUAL)
|
|
427
|
+
|
|
428
|
+
# A pre-existing MANUAL wallet.json → the idempotent verify path (never re-fund).
|
|
429
|
+
if existing is not None:
|
|
430
|
+
res = _verify_existing(target, strategy, budget, existing, existing_path, mcp_fn, created_at,
|
|
431
|
+
kind=KIND_MANUAL)
|
|
432
|
+
res.kind = KIND_MANUAL
|
|
433
|
+
return res
|
|
434
|
+
|
|
435
|
+
wj_path = wallet_json_path(base_state_dir, strategy) # canonical write path for a fresh mint
|
|
436
|
+
return _mint_wallet(target, strategy, budget, wj_path, mcp_fn=mcp_fn, created_at=created_at,
|
|
437
|
+
sleep_fn=sleep_fn, poll_attempts=poll_attempts, poll_interval=poll_interval,
|
|
438
|
+
kind=KIND_MANUAL)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# --------------------------------------------------------------------------- mint (shared b+c)
|
|
442
|
+
|
|
443
|
+
def _mint_wallet(target: str, strategy: str, budget: float, wj_path: Path, *,
|
|
444
|
+
mcp_fn: McpFn, created_at: Optional[str],
|
|
445
|
+
sleep_fn: Callable[[float], None], poll_attempts: int, poll_interval: float,
|
|
446
|
+
kind: Optional[str]) -> FundResult:
|
|
447
|
+
"""Steps (b)+(c) shared by the managed + manual fund paths: preflight the visible balance,
|
|
448
|
+
create the wallet via MCP (a money mutation), record wallet.json BEFORE polling, then poll
|
|
449
|
+
strategy_list briefly for ACTIVE. ``kind`` stamps the wallet.json marker (manual only) and is
|
|
450
|
+
carried onto every FundResult so the caller/render/JSON knows the strategy kind."""
|
|
451
|
+
result_kind = kind or KIND_MANAGED
|
|
452
|
+
|
|
347
453
|
# (b) PREFLIGHT the visible balance (no wallet yet).
|
|
348
454
|
try:
|
|
349
455
|
portfolio = mcp_fn("account_get_portfolio", timeout=_box._SUBMIT_TIMEOUT)
|
|
350
456
|
except McpCredsMissing as e:
|
|
351
|
-
return _refused(target, strategy, _creds_error(e), budget=budget)
|
|
457
|
+
return _refused(target, strategy, _creds_error(e), budget=budget, kind=result_kind)
|
|
352
458
|
except McpError:
|
|
353
459
|
portfolio = None # transport hiccup — proceed unverified (create fails loud if truly short)
|
|
354
460
|
available = _available_usd(portfolio)
|
|
@@ -363,16 +469,19 @@ def run_fund(
|
|
|
363
469
|
"topping up more. To add funds: deposit USDC to your embedded wallet, or bridge from "
|
|
364
470
|
"Hyperliquid. The composer NEVER bridges or splits the budget for you.",
|
|
365
471
|
Location(),
|
|
366
|
-
), budget=budget, available=available)
|
|
472
|
+
), budget=budget, available=available, kind=result_kind)
|
|
367
473
|
|
|
368
474
|
# (c) CREATE — a money mutation. State exactly what is being done (no --yes; conversational).
|
|
369
475
|
sname = sanitize_strategy_name(strategy)
|
|
476
|
+
manual_note = (" MANUAL strategy (a funded wallet with NO graph/runtime — direct MCP position "
|
|
477
|
+
"edits are the sanctioned path)." if kind == KIND_MANUAL else "")
|
|
370
478
|
pre_action = (f"CREATING WALLET — strategy={strategy!r}, budget=${budget:,.2f}, "
|
|
371
479
|
f"funding source=your embedded balance (Hyperliquid + EVM), "
|
|
372
480
|
f"attribution skillName={COMPOSER_SKILL_NAME!r} (v{COMPOSER_SKILL_VERSION}), "
|
|
373
481
|
f"strategyName={sname!r}."
|
|
374
482
|
+ ("" if available is not None
|
|
375
|
-
else " (balance could not be pre-verified; the backend will reject if short.)")
|
|
483
|
+
else " (balance could not be pre-verified; the backend will reject if short.)")
|
|
484
|
+
+ manual_note)
|
|
376
485
|
|
|
377
486
|
def _create(with_name: bool):
|
|
378
487
|
# skillName = the AUTHORING skill (never the strategy); skillVersion = the composer version
|
|
@@ -397,26 +506,28 @@ def run_fund(
|
|
|
397
506
|
res = _create(with_name=True)
|
|
398
507
|
except McpCredsMissing as e:
|
|
399
508
|
return _refused(target, strategy, _creds_error(e), budget=budget, available=available,
|
|
400
|
-
pre_action=pre_action)
|
|
509
|
+
pre_action=pre_action, kind=result_kind)
|
|
401
510
|
except McpError as e:
|
|
402
511
|
msg = str(e)
|
|
403
512
|
if any(c in msg for c in _NAME_ERR_CODES) or "name" in msg.lower():
|
|
404
513
|
try:
|
|
405
514
|
res = _create(with_name=False) # naming is best-effort — retry unnamed
|
|
406
515
|
except McpError as e2:
|
|
407
|
-
return _create_failed(target, strategy, budget, available, pre_action, e2
|
|
516
|
+
return _create_failed(target, strategy, budget, available, pre_action, e2,
|
|
517
|
+
kind=result_kind)
|
|
408
518
|
else:
|
|
409
|
-
return _create_failed(target, strategy, budget, available, pre_action, e
|
|
519
|
+
return _create_failed(target, strategy, budget, available, pre_action, e,
|
|
520
|
+
kind=result_kind)
|
|
410
521
|
|
|
411
522
|
sid = _strat_id(res)
|
|
412
523
|
if not sid:
|
|
413
524
|
return _create_failed(target, strategy, budget, available, pre_action,
|
|
414
|
-
f"create returned no strategyId: {res!r}")
|
|
525
|
+
f"create returned no strategyId: {res!r}", kind=result_kind)
|
|
415
526
|
addr = str(_strat_wallet(res) or "")
|
|
416
527
|
|
|
417
528
|
# Record BEFORE polling — an interrupted run resumes at step (a), never double-funds.
|
|
418
529
|
_write_wallet_json(wj_path, strategy=strategy, wallet=addr, strategy_id=sid,
|
|
419
|
-
budget=budget, created_at=created_at)
|
|
530
|
+
budget=budget, created_at=created_at, kind=kind)
|
|
420
531
|
|
|
421
532
|
# Poll strategy_list BRIEFLY for ACTIVE (bounded; still pending → PENDING, not a failure).
|
|
422
533
|
for attempt in range(max(1, poll_attempts)):
|
|
@@ -429,10 +540,11 @@ def run_fund(
|
|
|
429
540
|
cur_addr = str(_strat_wallet(s) or "") if s else ""
|
|
430
541
|
if status == "ACTIVE" and cur_addr:
|
|
431
542
|
_write_wallet_json(wj_path, strategy=strategy, wallet=cur_addr, strategy_id=sid,
|
|
432
|
-
budget=budget, created_at=created_at)
|
|
543
|
+
budget=budget, created_at=created_at, kind=kind)
|
|
433
544
|
return FundResult(
|
|
434
545
|
target=target, verdict=FUNDED, strategy=strategy, wallet=cur_addr,
|
|
435
|
-
strategy_id=sid, budget=budget, available=available,
|
|
546
|
+
strategy_id=sid, budget=budget, available=available, kind=result_kind,
|
|
547
|
+
pre_action=pre_action,
|
|
436
548
|
message=f"wallet ACTIVE and funded with ${budget:,.2f}.",
|
|
437
549
|
)
|
|
438
550
|
if attempt < poll_attempts - 1:
|
|
@@ -440,7 +552,7 @@ def run_fund(
|
|
|
440
552
|
|
|
441
553
|
return FundResult(
|
|
442
554
|
target=target, verdict=PENDING, strategy=strategy, wallet=addr, strategy_id=sid,
|
|
443
|
-
budget=budget, available=available, pre_action=pre_action,
|
|
555
|
+
budget=budget, available=available, kind=result_kind, pre_action=pre_action,
|
|
444
556
|
message=f"wallet created (strategyId {sid}) and funding; not ACTIVE yet.",
|
|
445
557
|
)
|
|
446
558
|
|
|
@@ -448,8 +560,13 @@ def run_fund(
|
|
|
448
560
|
# --------------------------------------------------------------------------- sub-flows
|
|
449
561
|
|
|
450
562
|
def _verify_existing(target: str, strategy: str, budget: float, rec: dict, wj_path: Path,
|
|
451
|
-
mcp_fn: McpFn, created_at: Optional[str]
|
|
452
|
-
|
|
563
|
+
mcp_fn: McpFn, created_at: Optional[str],
|
|
564
|
+
kind: Optional[str] = None) -> FundResult:
|
|
565
|
+
"""Step (a): a wallet.json already exists — verify it via strategy_list, never re-fund.
|
|
566
|
+
``kind`` (the recorded marker) is PRESERVED across any in-place backfill so a manual wallet
|
|
567
|
+
never loses its marker on a re-run."""
|
|
568
|
+
rec_kind = kind if kind is not None else wallet_kind(rec)
|
|
569
|
+
result_kind = rec_kind or KIND_MANAGED
|
|
453
570
|
rec_wallet = str(rec.get("wallet") or "")
|
|
454
571
|
rec_sid = rec.get("strategyId")
|
|
455
572
|
try:
|
|
@@ -457,7 +574,7 @@ def _verify_existing(target: str, strategy: str, budget: float, rec: dict, wj_pa
|
|
|
457
574
|
mcp_ok = True
|
|
458
575
|
except McpCredsMissing as e:
|
|
459
576
|
return _refused(target, strategy, _creds_error(e), wallet=rec_wallet,
|
|
460
|
-
strategy_id=rec_sid, budget=budget)
|
|
577
|
+
strategy_id=rec_sid, budget=budget, kind=result_kind)
|
|
461
578
|
except McpError:
|
|
462
579
|
strategies, mcp_ok = [], False
|
|
463
580
|
|
|
@@ -470,23 +587,23 @@ def _verify_existing(target: str, strategy: str, budget: float, rec: dict, wj_pa
|
|
|
470
587
|
if addr and addr != rec_wallet:
|
|
471
588
|
_write_wallet_json(wj_path, strategy=strategy, wallet=addr,
|
|
472
589
|
strategy_id=rec_sid or _strat_id(s), budget=budget,
|
|
473
|
-
created_at=rec.get("created_at") or created_at)
|
|
590
|
+
created_at=rec.get("created_at") or created_at, kind=rec_kind)
|
|
474
591
|
return FundResult(
|
|
475
592
|
target=target, verdict=FUNDED, strategy=strategy, wallet=addr or rec_wallet,
|
|
476
593
|
strategy_id=rec_sid or _strat_id(s), budget=rec.get("budget", budget),
|
|
477
|
-
message="wallet already funded and ACTIVE (no-op).",
|
|
594
|
+
kind=result_kind, message="wallet already funded and ACTIVE (no-op).",
|
|
478
595
|
)
|
|
479
596
|
if s is not None and status and status not in CLOSED_STATUSES:
|
|
480
597
|
return FundResult(
|
|
481
598
|
target=target, verdict=PENDING, strategy=strategy, wallet=addr or rec_wallet,
|
|
482
599
|
strategy_id=rec_sid or _strat_id(s), budget=rec.get("budget", budget),
|
|
483
|
-
message=f"wallet still initializing (status {status}).",
|
|
600
|
+
kind=result_kind, message=f"wallet still initializing (status {status}).",
|
|
484
601
|
)
|
|
485
602
|
if not mcp_ok:
|
|
486
603
|
# Could not reach MCP to confirm — do NOT declare stale; re-run once reachable.
|
|
487
604
|
return FundResult(
|
|
488
605
|
target=target, verdict=PENDING, strategy=strategy, wallet=rec_wallet,
|
|
489
|
-
strategy_id=rec_sid, budget=rec.get("budget", budget),
|
|
606
|
+
strategy_id=rec_sid, budget=rec.get("budget", budget), kind=result_kind,
|
|
490
607
|
message="could not reach MCP to verify the recorded wallet — re-run `composer fund`.",
|
|
491
608
|
)
|
|
492
609
|
# Found closed, or not present in the live set at all → the recorded wallet is stale.
|
|
@@ -497,7 +614,7 @@ def _verify_existing(target: str, strategy: str, budget: float, rec: dict, wj_pa
|
|
|
497
614
|
f"for {strategy!r}, delete the stale marker `{wj_path}` and re-run `composer fund`. If you "
|
|
498
615
|
f"believe it is still open, check MCP `strategy_list` (it may be filtered out as terminal).",
|
|
499
616
|
Location(),
|
|
500
|
-
), wallet=rec_wallet, strategy_id=rec_sid, budget=budget)
|
|
617
|
+
), wallet=rec_wallet, strategy_id=rec_sid, budget=budget, kind=result_kind)
|
|
501
618
|
|
|
502
619
|
|
|
503
620
|
def _creds_error(exc: Exception) -> TeachingError:
|
|
@@ -510,10 +627,10 @@ def _creds_error(exc: Exception) -> TeachingError:
|
|
|
510
627
|
|
|
511
628
|
|
|
512
629
|
def _create_failed(target: str, strategy: str, budget: float, available: Optional[float],
|
|
513
|
-
pre_action: str, err) -> FundResult:
|
|
630
|
+
pre_action: str, err, kind: str = KIND_MANAGED) -> FundResult:
|
|
514
631
|
return FundResult(
|
|
515
632
|
target=target, verdict=FAILED, strategy=strategy, budget=budget, available=available,
|
|
516
|
-
pre_action=pre_action,
|
|
633
|
+
kind=kind, pre_action=pre_action,
|
|
517
634
|
errors=[TeachingError(
|
|
518
635
|
"CMP233", f"MCP `strategy_create_custom_strategy` failed: {str(err)[:200]}.",
|
|
519
636
|
"No wallet was created (nothing to clean up). Re-run `composer fund` — it is safe "
|
|
@@ -104,16 +104,35 @@ class CreateResult:
|
|
|
104
104
|
CreateFn = Callable[[str, str], CreateResult]
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
# The one production shell-out that had NO timeout: a hung `runtime create` is the field-reported
|
|
108
|
+
# "deploy verify hang" class. Bound it generously (create legitimately runs 30-60s: MCP connect,
|
|
109
|
+
# strategy + DSL init, supervisor + scaffold spawn — senpi-commands.ts uses a 60s gateway timeout),
|
|
110
|
+
# but never UNBOUNDED. 120s leaves ample headroom over the gateway's own 60s while still failing loud.
|
|
111
|
+
_CREATE_TIMEOUT = 120
|
|
112
|
+
|
|
113
|
+
|
|
107
114
|
def _default_create(runtime_yaml_path: str, runtime_id: str) -> CreateResult:
|
|
108
115
|
"""Production seam: shell out to the FROZEN consumer in PATH mode (never --content — the
|
|
109
116
|
external scanner needs the recipe dir the -p path carries; index.ts:1088, senpi-commands.ts:94).
|
|
110
|
-
Captures stdout/stderr so the verdict is parsed truthfully rather than inherited to the tty.
|
|
117
|
+
Captures stdout/stderr so the verdict is parsed truthfully rather than inherited to the tty. A
|
|
118
|
+
hang is bounded at ``_CREATE_TIMEOUT`` (J9): a timeout returns an honest non-zero CreateResult
|
|
119
|
+
(mirroring ``_box._run_cli``'s timeout shape) so the mapper teaches it rather than hanging."""
|
|
111
120
|
openclaw = os.environ.get("OPENCLAW_BIN", "openclaw")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
argv = [openclaw, "senpi", "runtime", "create", "-p", runtime_yaml_path, "--runtime-id", runtime_id]
|
|
122
|
+
try:
|
|
123
|
+
proc = subprocess.run( # noqa: S603 — fixed argv, no shell
|
|
124
|
+
argv,
|
|
125
|
+
capture_output=True,
|
|
126
|
+
text=True,
|
|
127
|
+
timeout=_CREATE_TIMEOUT,
|
|
128
|
+
)
|
|
129
|
+
except subprocess.TimeoutExpired:
|
|
130
|
+
return CreateResult(
|
|
131
|
+
-1, "",
|
|
132
|
+
f"`openclaw senpi runtime create` timed out after {_CREATE_TIMEOUT}s "
|
|
133
|
+
f"(runtime {runtime_id!r}). The box may be wedged or the gateway unreachable — re-run "
|
|
134
|
+
f"`composer install` (idempotent); if it persists, check `openclaw senpi runtime list`.",
|
|
135
|
+
)
|
|
117
136
|
return CreateResult(proc.returncode, proc.stdout or "", proc.stderr or "")
|
|
118
137
|
|
|
119
138
|
|