@senpi/runtime 3.0.72 → 3.0.74
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/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +6 -5
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/deploy/deadlines.d.ts +61 -1
- package/dist/deploy/deadlines.d.ts.map +1 -1
- package/dist/deploy/deadlines.js +85 -2
- package/dist/deploy/deadlines.js.map +1 -1
- package/dist/deploy/funding.d.ts +38 -0
- package/dist/deploy/funding.d.ts.map +1 -1
- package/dist/deploy/funding.js +39 -0
- package/dist/deploy/funding.js.map +1 -1
- package/dist/deploy/job.d.ts.map +1 -1
- package/dist/deploy/job.js +7 -1
- package/dist/deploy/job.js.map +1 -1
- package/dist/deploy/messages.d.ts +603 -0
- package/dist/deploy/messages.d.ts.map +1 -0
- package/dist/deploy/messages.js +1388 -0
- package/dist/deploy/messages.js.map +1 -0
- package/dist/deploy/orchestrator.d.ts +8 -0
- package/dist/deploy/orchestrator.d.ts.map +1 -1
- package/dist/deploy/orchestrator.js +146 -1211
- package/dist/deploy/orchestrator.js.map +1 -1
- package/dist/deploy/package.d.ts +89 -0
- package/dist/deploy/package.d.ts.map +1 -1
- package/dist/deploy/package.js +213 -25
- package/dist/deploy/package.js.map +1 -1
- package/dist/deploy/proof-gate.d.ts +4 -0
- package/dist/deploy/proof-gate.d.ts.map +1 -1
- package/dist/deploy/proof-gate.js +4 -0
- package/dist/deploy/proof-gate.js.map +1 -1
- package/dist/deploy/recheck-commands.d.ts.map +1 -1
- package/dist/deploy/recheck-commands.js +21 -3
- package/dist/deploy/recheck-commands.js.map +1 -1
- package/dist/deploy/register.d.ts +2 -0
- package/dist/deploy/register.d.ts.map +1 -1
- package/dist/deploy/register.js +23 -6
- package/dist/deploy/register.js.map +1 -1
- package/dist/deploy/taxonomy.d.ts +19 -0
- package/dist/deploy/taxonomy.d.ts.map +1 -1
- package/dist/deploy/taxonomy.js +28 -0
- package/dist/deploy/taxonomy.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +230 -176
- package/dist/index.js.map +1 -1
- package/dist/runtime/env-resolve.d.ts +13 -0
- package/dist/runtime/env-resolve.d.ts.map +1 -1
- package/dist/runtime/env-resolve.js +14 -1
- package/dist/runtime/env-resolve.js.map +1 -1
- package/dist/runtime/runtime-schema.d.ts +100 -100
- package/dist/scanners/external/scaffold/preflight.py +39 -0
- package/dist/telemetry/metrics.d.ts +7 -0
- package/dist/telemetry/metrics.d.ts.map +1 -1
- package/dist/telemetry/metrics.js +7 -0
- package/dist/telemetry/metrics.js.map +1 -1
- package/dist/validate/codes.d.ts +10 -0
- package/dist/validate/codes.d.ts.map +1 -1
- package/dist/validate/codes.js +38 -1
- package/dist/validate/codes.js.map +1 -1
- package/dist/validate/live-stage.d.ts +8 -0
- package/dist/validate/live-stage.d.ts.map +1 -1
- package/dist/validate/live-stage.js +87 -18
- package/dist/validate/live-stage.js.map +1 -1
- package/dist/validate/package-gates-stage.d.ts +61 -0
- package/dist/validate/package-gates-stage.d.ts.map +1 -0
- package/dist/validate/package-gates-stage.js +215 -0
- package/dist/validate/package-gates-stage.js.map +1 -0
- package/dist/validate/run.d.ts +24 -1
- package/dist/validate/run.d.ts.map +1 -1
- package/dist/validate/run.js +51 -4
- package/dist/validate/run.js.map +1 -1
- package/dist/validate/taxonomy-finding.d.ts +22 -0
- package/dist/validate/taxonomy-finding.d.ts.map +1 -0
- package/dist/validate/taxonomy-finding.js +25 -0
- package/dist/validate/taxonomy-finding.js.map +1 -0
- package/dist/validate/universe-stage.d.ts.map +1 -1
- package/dist/validate/universe-stage.js +2 -20
- package/dist/validate/universe-stage.js.map +1 -1
- package/dist/validate/wallet-binding-stage.d.ts +44 -0
- package/dist/validate/wallet-binding-stage.d.ts.map +1 -0
- package/dist/validate/wallet-binding-stage.js +92 -0
- package/dist/validate/wallet-binding-stage.js.map +1 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every refusal, warning and detail string the deploy verb emits — one producer per taxonomy code.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `orchestrator.ts` verbatim: this module holds the rendering, `orchestrator.ts` holds
|
|
5
|
+
* the control flow that decides which of these to call. Nothing here reads the job's run state, which
|
|
6
|
+
* is why it could move at all — the handful of builders that DO take an `InstanceRun` stay beside it.
|
|
7
|
+
*/
|
|
8
|
+
import { join, } from "node:path";
|
|
9
|
+
import { describeScannerEnabledOffense, lowercasePackageIdFix, sanitizeStrategyName, } from "./package.js";
|
|
10
|
+
import { CLOSING_STATUSES, isInitializing, } from "../senpi/strategy-status.js";
|
|
11
|
+
import { MIN_WALLET, budgetArg, resumeCommand, usd, } from "./funding.js";
|
|
12
|
+
import { deployPyValidateCommand, PROOF_ROUTE_NOTE, recheckBlock, validateCommand, } from "./recheck-commands.js";
|
|
13
|
+
import { INSTALL_DEADLINE_MS, INSTALL_INDETERMINATE_CODE, WALLET_INSTALL_IN_FLIGHT_CODE, } from "./deadlines.js";
|
|
14
|
+
/** The provenance clause every reconcile message carries, so a report never hides how it matched. */
|
|
15
|
+
export function matchedByClause(matchedBy) {
|
|
16
|
+
return matchedBy === "created-key" ? " (matched by journaled create key, verified against the backend)" : "";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The other live strategies that answered to the matched name and were dropped as another
|
|
20
|
+
* package's. A bind that ignored one is still a bind on a CONTESTED name — the collision outlives
|
|
21
|
+
* this deploy and surfaces again at the next `close.py`, rename, or status read — so the report
|
|
22
|
+
* says so rather than rendering a clean "matched by name". It is a note, not a warning: nothing
|
|
23
|
+
* here is this package's to act on, which is the same reason those rows were dropped.
|
|
24
|
+
*/
|
|
25
|
+
export function foreignCollisionClause(ignoredForeign) {
|
|
26
|
+
if (ignoredForeign.length === 0)
|
|
27
|
+
return "";
|
|
28
|
+
const stamps = [...new Set(ignoredForeign.map((s) => s.skillName?.trim()).filter((v) => !!v))];
|
|
29
|
+
const whose = stamps.length > 0 ? stamps.map((s) => `"${s}"`).join(", ") : "another package";
|
|
30
|
+
const plural = ignoredForeign.length === 1 ? "y" : "ies";
|
|
31
|
+
return (` — note: ${ignoredForeign.length} other live strateg${plural} of the same name ` +
|
|
32
|
+
`${ignoredForeign.length === 1 ? "is" : "are"} stamped for ${whose} and ${ignoredForeign.length === 1 ? "was" : "were"} ` +
|
|
33
|
+
`left untouched`);
|
|
34
|
+
}
|
|
35
|
+
export function buildAmbiguousRefusal(count, matchedAs, instance) {
|
|
36
|
+
return (`[E_STATE_AMBIGUOUS_WALLETS] ${count} live strategies ${matchedAs} — cannot resolve ` +
|
|
37
|
+
`which wallet belongs to instance "${instance}". Read-only triage first (openclaw senpi status / ` +
|
|
38
|
+
`strategy_list), clear the ambiguity with the user. NEVER close or recreate to "start clean".`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A single live name-match whose wallet address cannot be read. deploy.py refused this case rather
|
|
42
|
+
* than guessing, and the taxonomy's `E_STATE_AMBIGUOUS_WALLETS` row names it explicitly: the strategy
|
|
43
|
+
* may be funded and live, so neither adopting a blank address nor creating a second wallet is safe.
|
|
44
|
+
*/
|
|
45
|
+
export function buildUnreadableWalletRefusal(strategyName, instance, strategyId) {
|
|
46
|
+
// An id the backend did not report is stated as unreadable, never rendered as the blank in
|
|
47
|
+
// "(id )" — the same rule the wallet address in this very refusal is subject to. A reader who
|
|
48
|
+
// sees an empty parenthetical cannot tell a missing id from a rendering bug.
|
|
49
|
+
const id = strategyId?.trim() ? `id ${strategyId.trim()}` : "id unreadable";
|
|
50
|
+
return (`[E_STATE_AMBIGUOUS_WALLETS] Live strategy "${strategyName}" (${id}) matches instance ` +
|
|
51
|
+
`"${instance}" but its wallet address is unreadable — cannot resolve which wallet this instance ` +
|
|
52
|
+
`owns, and it may be funded and live. Nothing was created or changed. Read-only triage first ` +
|
|
53
|
+
`(openclaw senpi status / strategy_list), clear the ambiguity with the user. NEVER close or recreate to ` +
|
|
54
|
+
`"start clean".`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The `create: skipped` detail for an adopted wallet.
|
|
58
|
+
*
|
|
59
|
+
* When a budget was requested, this is the only place the report can say that it was **not**
|
|
60
|
+
* applied — deploy never adds funds to a wallet that already exists, and a silent skip would let an
|
|
61
|
+
* agent summarise "deployed spider with $500" over a wallet holding $100.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* How a backend `totalFunded` read renders in prose.
|
|
65
|
+
*
|
|
66
|
+
* An unreadable figure is stated as unreadable, never as `$0.00`. Every sentence that quotes this
|
|
67
|
+
* number sits beside an instruction about money, and the two cases call for opposite next steps: a
|
|
68
|
+
* genuine zero says "there is nothing on this wallet", an unread one says "nobody knows yet".
|
|
69
|
+
*/
|
|
70
|
+
export function fundedClause(amount) {
|
|
71
|
+
return amount === undefined ? "with no readable funded amount reported" : `funded ${usd(amount)}`;
|
|
72
|
+
}
|
|
73
|
+
/** `its $60.00` / `its balance` — the possessive form of the same rule. */
|
|
74
|
+
export function itsFunded(amount) {
|
|
75
|
+
return amount === undefined ? "its balance" : `its ${usd(amount)}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The rollback voice of {@link fundedClause}: `funded $250.00` / the unread form.
|
|
79
|
+
*
|
|
80
|
+
* Both sentences it feeds order money moves on the wallet they name — "closing it and returning the
|
|
81
|
+
* funds", "Reclaim the funds from THIS wallet" — so a `?? 0` here told the reader to reclaim from a
|
|
82
|
+
* wallet the same sentence called empty, which reads as "nothing to reclaim, skip it" over money
|
|
83
|
+
* that is genuinely out there. UNKNOWN, never $0.00.
|
|
84
|
+
*/
|
|
85
|
+
export function rollbackFundedClause(amount) {
|
|
86
|
+
return amount === undefined
|
|
87
|
+
? `funded an amount that was unreadable at deploy time (UNKNOWN, never zero — verify the ` +
|
|
88
|
+
`wallet's balance)`
|
|
89
|
+
: `funded ${usd(amount)}`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A live name-match in a status that is neither ACTIVE nor on its way there — PAUSED, mid-teardown,
|
|
93
|
+
* or something this verb does not recognise.
|
|
94
|
+
*
|
|
95
|
+
* Polling these is the bug this builder exists to prevent: a PAUSED strategy never becomes ACTIVE on
|
|
96
|
+
* its own, so waiting on it burns the whole `--max-wait` and then reports the bounded-wait outcome
|
|
97
|
+
* — "re-run to resume, reconciliation waits for it to reach ACTIVE" — which is unactionable on a
|
|
98
|
+
* status that will never get there. The next step is computed from the status actually read,
|
|
99
|
+
* and the non-destructive option always leads.
|
|
100
|
+
*/
|
|
101
|
+
export function buildNotDeployableRefusal(matched, instance, pkg, budget) {
|
|
102
|
+
const status = matched.status.toUpperCase();
|
|
103
|
+
const head = `[INVALID_REQUEST] Live strategy "${matched.strategyName}" (id ${matched.id}) matches instance ` +
|
|
104
|
+
`"${instance}" but its status is ${matched.status} — `;
|
|
105
|
+
const rerun = `re-run \`${resumeCommand(pkg.dir, budget)}\``;
|
|
106
|
+
if (status === "PAUSED") {
|
|
107
|
+
return (`${head}a paused strategy cannot be deployed onto, and it will not become ACTIVE on its own. ` +
|
|
108
|
+
`Nothing was created. Resume it first, then ${rerun}. If you meant to start over instead, tear ` +
|
|
109
|
+
`this one down with \`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\` (that returns its ` +
|
|
110
|
+
`funds) before re-running — never leave it paused and fund a second wallet beside it.`);
|
|
111
|
+
}
|
|
112
|
+
if (CLOSING_STATUSES.includes(status)) {
|
|
113
|
+
return (`${head}it is being torn down. Nothing was created. Wait for it to reach CLOSED (re-check with ` +
|
|
114
|
+
`\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`), then ${rerun} — the re-run will ` +
|
|
115
|
+
`create a fresh wallet once this one is gone.`);
|
|
116
|
+
}
|
|
117
|
+
return (`${head}deploy cannot proceed from that state and will not guess. Nothing was created. Read-only ` +
|
|
118
|
+
`triage first (openclaw senpi status / strategy_list) to find out why it is ${matched.status}, then ${rerun} ` +
|
|
119
|
+
`once it is ACTIVE. NEVER close or recreate to "start clean".`);
|
|
120
|
+
}
|
|
121
|
+
export function buildBudgetRequiredRefusal(wallets, pkg) {
|
|
122
|
+
return (`[INVALID_REQUEST] --budget is required to create ${wallets} wallet(s) ` +
|
|
123
|
+
`(min ${usd(MIN_WALLET)}/wallet). Nothing was created. Re-run: ${resumeCommand(pkg.dir)}`);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* `--budget 0` is not "no budget": it parses, and without this gate `planFunding` floors every
|
|
127
|
+
* wallet to MIN_WALLET — funding money the user never asked to spend. `--budget ≤ $0` is also the
|
|
128
|
+
* taxonomy's rule-4 field case: its only known source in the field is a buggy computed hint, so the
|
|
129
|
+
* refusal teaches where the real fix lives instead of letting that loop run. The same gate exists
|
|
130
|
+
* at gateway intake (register.ts), keeping every caller of `runDeploy` covered.
|
|
131
|
+
*/
|
|
132
|
+
export function buildBudgetNotPositiveRefusal(budget, wallets, pkg) {
|
|
133
|
+
return (`[INVALID_REQUEST] --budget must be a positive USD amount: ${usd(budget)} cannot fund ` +
|
|
134
|
+
`${wallets} wallet(s) (min ${usd(MIN_WALLET)}/wallet). Nothing was created. If a report ` +
|
|
135
|
+
`suggested \`--budget ${budgetArg(budget)}\`, that hint was a bug, not an instruction — a ` +
|
|
136
|
+
`sleeve declaring \`funding_share: 0\` is fixed by giving it a share in strategy.yaml, never ` +
|
|
137
|
+
`by re-deploying. Re-run: ${resumeCommand(pkg.dir)}`);
|
|
138
|
+
}
|
|
139
|
+
/** `scalp $12.00 (needs $13.50)` — both numbers, so the claim is checkable. */
|
|
140
|
+
function renderUnderfunded(under) {
|
|
141
|
+
return under.map((row) => `${row.wallet} ${usd(row.funded)} (needs ${usd(row.needs)})`).join(", ");
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The budget a re-run scoped to exactly `under` would need, given how `planFunding` will split it:
|
|
145
|
+
* each sleeve gets its declared share, or an even slice of however many are being redeployed.
|
|
146
|
+
* COMPUTED, never templated — the taxonomy's rule 4 exists because a hint that can be nonsense for
|
|
147
|
+
* some state is a bug, and "deploy at the package minimum" is nonsense when only one sleeve is
|
|
148
|
+
* being replaced.
|
|
149
|
+
*/
|
|
150
|
+
function budgetToCover(rerunFunds, pkg, min) {
|
|
151
|
+
// A declared share of 0 is kept verbatim by `sharesOf`, so that wallet is floored at MIN_WALLET
|
|
152
|
+
// at EVERY budget — no number satisfies it, and including one in the max would send this to
|
|
153
|
+
// Infinity. Such a sleeve never shapes the TARGET (the fix is the manifest, not the budget — see
|
|
154
|
+
// `zeroShareSentence`), but when the re-run will fund it anyway (it has no wallet) it stays in
|
|
155
|
+
// `rerunFunds` and so in the even-split DENOMINATOR: `planFunding` divides evenly over every
|
|
156
|
+
// wallet being funded, zero-share ones included, and a hint sized against a smaller split
|
|
157
|
+
// under-hints the agent straight back into this warn. (An ABSENT share is different — it becomes
|
|
158
|
+
// an even slice, which does scale.)
|
|
159
|
+
const scalable = rerunFunds.filter((name) => pkg.instances.find((i) => i.name === name)?.fundingShare !== 0);
|
|
160
|
+
if (scalable.length === 0)
|
|
161
|
+
return 0;
|
|
162
|
+
const evenShare = 1 / rerunFunds.length;
|
|
163
|
+
const needsOf = new Map(min.breakdown.map((row) => [row.wallet, row.perWalletMin]));
|
|
164
|
+
const worst = scalable.reduce((most, name) => {
|
|
165
|
+
const declared = pkg.instances.find((inst) => inst.name === name)?.fundingShare;
|
|
166
|
+
const share = declared !== null && declared !== undefined && declared > 0 ? declared : evenShare;
|
|
167
|
+
return Math.max(most, (needsOf.get(name) ?? 0) / share);
|
|
168
|
+
}, 0);
|
|
169
|
+
// Ceil to whole cents so `budget × share` clears the requirement BEFORE `planFunding`'s round2.
|
|
170
|
+
return Math.ceil(worst * 100) / 100;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The fix-the-manifest sentence for `funding_share: 0` sleeves — the one shortfall this escape can
|
|
174
|
+
* NEVER remediate. `planFunding` keeps a declared 0 verbatim, so the wallet is floored at
|
|
175
|
+
* MIN_WALLET at every budget: closing it and re-deploying bigger funds it straight back at the
|
|
176
|
+
* floor, which is the destructive no-op loop the close command must not offer. When such a sleeve
|
|
177
|
+
* is the ONLY short one, this sentence IS the whole escape — deliberately with nothing to run.
|
|
178
|
+
*/
|
|
179
|
+
function zeroShareSentence(names) {
|
|
180
|
+
const one = names.length === 1;
|
|
181
|
+
return (`Sleeve${one ? "" : "s"} ${names.join(", ")} declare${one ? "s" : ""} \`funding_share: 0\`, so ` +
|
|
182
|
+
`NO budget can lift ${one ? "it" : "them"} above the ${usd(MIN_WALLET)} floor and closing/` +
|
|
183
|
+
`re-deploying cannot change that — the fix is a share in strategy.yaml, not more money.`);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The close-then-redeploy escape — the ONLY sequence that actually raises a deployed wallet's size.
|
|
187
|
+
*
|
|
188
|
+
* A bare re-run at a bigger `--budget` is a guaranteed no-op: reconcile matches the strategy by
|
|
189
|
+
* name, ADOPTS it, skips create, and answers with `buildAdoptedWalletDetail`'s "the requested … was
|
|
190
|
+
* NOT added". The agent then has to reverse-engineer the real step from a message it did not
|
|
191
|
+
* expect, and the two likeliest readings are "the deploy failed" and "close it to start clean".
|
|
192
|
+
*
|
|
193
|
+
* SCOPED to the underfunded sleeves. `close.py <id>` tears down the WHOLE package, so on a
|
|
194
|
+
* partially-adopted deploy it would close the adopted, live, funded sleeve that this warn is not
|
|
195
|
+
* even about — the "close-to-replace teardown" class the taxonomy's rule 5 exists to prevent.
|
|
196
|
+
* `--instance` narrows it, and is only omitted when every sleeve in the package really is affected.
|
|
197
|
+
*
|
|
198
|
+
* `existing` is the subset of `under` whose wallets ACTUALLY EXIST on the finished report. An empty
|
|
199
|
+
* one yields no escape at all: on a deploy that failed before create there is no "it" to close, and
|
|
200
|
+
* offering a teardown for a wallet that was never made is a false premise plus a pointless step.
|
|
201
|
+
*/
|
|
202
|
+
export function buildBudgetEscape(pkg, min, state) {
|
|
203
|
+
// E_ROLLBACK_INCOMPLETE owns the remediation wherever it appears. It names a stranded funded
|
|
204
|
+
// wallet and the command to reclaim it; a second close instruction beside it — scoped differently,
|
|
205
|
+
// and unrunnable besides — is a report contradicting itself, and an agent reconciling the two
|
|
206
|
+
// picks whichever one executes. Defer, and say who to follow.
|
|
207
|
+
if (state.rollbackIncomplete) {
|
|
208
|
+
return (` Deal with the [E_ROLLBACK_INCOMPLETE] line on this report FIRST — it owns the cleanup here, ` +
|
|
209
|
+
`and its instruction is the one to follow. Re-size only once that wallet is settled.`);
|
|
210
|
+
}
|
|
211
|
+
// A wallet with no runtime cannot be closed by instance at all: `close.py --instance` resolves the
|
|
212
|
+
// sleeve THROUGH its runtime and hard-exits without one, telling the reader to "omit --instance to
|
|
213
|
+
// close all of <id>" — the whole-package teardown of any adopted sleeve beside it. Never emit a
|
|
214
|
+
// command whose precondition this report does not meet; point at read-only triage instead.
|
|
215
|
+
if (state.strandedNames.length > 0) {
|
|
216
|
+
// Deliberately names NO teardown command. Per-sleeve teardown resolves the sleeve through its
|
|
217
|
+
// runtime, which these wallets do not have; the package-wide form would take down every sleeve
|
|
218
|
+
// including any adopted live one. The only command here is read-only, so there is nothing to
|
|
219
|
+
// copy that could destroy anything.
|
|
220
|
+
return (` Re-sizing is NOT safe to automate on this report: wallet(s) ${state.strandedNames.join(", ")} ` +
|
|
221
|
+
`were funded but no runtime was installed on them, which is the one state a per-sleeve teardown ` +
|
|
222
|
+
`cannot resolve — and a package-wide one would take down every other sleeve with it, adopted ` +
|
|
223
|
+
`live ones included. Triage read-only first: ` +
|
|
224
|
+
`\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`, then decide with the user.`);
|
|
225
|
+
}
|
|
226
|
+
// A zero-share short sleeve is not remediable by this escape AT ALL — no budget moves it off the
|
|
227
|
+
// floor (see `zeroShareSentence`) — so it never reaches `closable` and never shapes the hint's
|
|
228
|
+
// target. When it is the only short sleeve, the manifest sentence is the entire escape.
|
|
229
|
+
const zeroShareNote = state.zeroShare.length > 0 ? ` ${zeroShareSentence(state.zeroShare)}` : "";
|
|
230
|
+
if (state.closable.length === 0)
|
|
231
|
+
return zeroShareNote;
|
|
232
|
+
// The id and the package dir are quoted the way `--instance` is: shell input, copied verbatim.
|
|
233
|
+
const wholePackage = state.closable.length === pkg.instances.length;
|
|
234
|
+
const closeCmd = wholePackage
|
|
235
|
+
? `python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'`
|
|
236
|
+
: state.closable
|
|
237
|
+
.map((name) => `python3 senpi-strategy-ops/scripts/close.py '${pkg.id}' --instance '${name}'`)
|
|
238
|
+
.join(" && ");
|
|
239
|
+
// The scoped clause NAMES the sleeves it closes rather than saying "the underfunded sleeve(s)":
|
|
240
|
+
// a zero-share sleeve can be underfunded yet deliberately absent from the command, and prose that
|
|
241
|
+
// describes a wider set than the command executes is the self-contradiction this note exists to
|
|
242
|
+
// avoid.
|
|
243
|
+
const closeClause = wholePackage
|
|
244
|
+
? `Close it first (\`${closeCmd}\`, which returns the funds)`
|
|
245
|
+
: `Close ONLY ${state.closable.join(", ")} first — the rest of the package stays up ` +
|
|
246
|
+
`(\`${closeCmd}\`, which returns the funds)`;
|
|
247
|
+
// The re-run funds every walletless instance, not just the ones being closed — a sleeve whose own
|
|
248
|
+
// create failed is in that set too, and sizing the hint against the closed set alone under-hints
|
|
249
|
+
// it straight back into this same warn.
|
|
250
|
+
const rerunFunds = [...new Set([...state.closable, ...state.walletless])];
|
|
251
|
+
const redeployBudget = wholePackage ? min.minBudget : budgetToCover(rerunFunds, pkg, min);
|
|
252
|
+
// Mirror of `underfundedNote`'s guard, on the module's other money-hint producer: below
|
|
253
|
+
// MIN_WALLET no rendered `--budget` is valid, and suggesting one produces nonsense the agent
|
|
254
|
+
// then follows ("--budget ≤ $0", the M381223 churn).
|
|
255
|
+
//
|
|
256
|
+
// No longer reachable through `runDeploy`: its only route in was a mis-authored share > 1, and
|
|
257
|
+
// `sharesOverAuthorization` now refuses that at the preflight, before a wallet is funded. With
|
|
258
|
+
// every share at or below 1 the implied figure is `perWalletMin / share ≥ perWalletMin`, and
|
|
259
|
+
// `min_budget` floors perWalletMin at MIN_WALLET, so it cannot fall under it. Kept as defence in
|
|
260
|
+
// depth — it costs one comparison, and it is the second half of a rule the other producer also
|
|
261
|
+
// enforces locally.
|
|
262
|
+
if (redeployBudget < MIN_WALLET)
|
|
263
|
+
return zeroShareNote;
|
|
264
|
+
return (` Running the authored design means starting over at the larger size, NOT re-running at a bigger ` +
|
|
265
|
+
`budget: deploy never adds funds to a wallet that already exists, so a re-run would just adopt ` +
|
|
266
|
+
`what is already there. ${closeClause}, then ` +
|
|
267
|
+
`\`openclaw senpi deploy -p '${pkg.dir}' --budget ${budgetArg(redeployBudget)}\`.${zeroShareNote}`);
|
|
268
|
+
}
|
|
269
|
+
/** Forward-looking tense for the preflight narration: everything is about to be funded and run. */
|
|
270
|
+
export function forwardTense(under) {
|
|
271
|
+
const names = under.map((row) => row.wallet);
|
|
272
|
+
return { funded: new Set(names), running: new Set(names) };
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The SOFT tier's warn: one or more wallets this deploy funds get less than their own sizing needs.
|
|
276
|
+
*
|
|
277
|
+
* It must not read like the `[E_FUNDS_*]` refusals that share its `[E_…]` shape, because it did not
|
|
278
|
+
* stop anything — users size their own budgets, and a strategy that runs with fewer slots than
|
|
279
|
+
* authored is a legitimate choice, not a fault. Hence the explicit "nothing was blocked": without
|
|
280
|
+
* it, an agent that pattern-matches bracketed codes to "refused" would report a live deploy as a
|
|
281
|
+
* failure and start closing wallets to "fix" it. The phrasing is state-independent ("the budget
|
|
282
|
+
* check did not stop this deploy") because this note also rides `failed` reports, where the old
|
|
283
|
+
* "this deploy PROCEEDS" was simply untrue.
|
|
284
|
+
*
|
|
285
|
+
* The whole-package minimum is quoted as CONTEXT, in its own parenthesis — never as the claim.
|
|
286
|
+
*/
|
|
287
|
+
function buildBudgetBelowMinNote(pkg, min, under, escape, tense) {
|
|
288
|
+
// "Will run" is a claim about something INSTALLED, judged per row: a mixed report used to say
|
|
289
|
+
// "those sleeve(s) WILL RUN DEGRADED" about wallets it named as stranded two clauses later. The
|
|
290
|
+
// sizing observation itself stays in every branch — it was true about the plan.
|
|
291
|
+
const running = under.map((row) => row.wallet).filter((name) => tense.running.has(name));
|
|
292
|
+
const degraded = running.length === under.length
|
|
293
|
+
? `those sleeve(s) will run DEGRADED, with`
|
|
294
|
+
: running.length === 0
|
|
295
|
+
? `at that size those sleeve(s) would run DEGRADED, with`
|
|
296
|
+
: `of these, ${running.join(", ")} will run DEGRADED — the rest never got a runtime ` +
|
|
297
|
+
`installed on this run — with`;
|
|
298
|
+
// "This deploy funds" is a claim about the WALLET, judged per row too: on a deploy that never
|
|
299
|
+
// created one, present tense states an allocation that did not happen — while "would have funded"
|
|
300
|
+
// about a wallet that IS live and funded (stranded, rollback-incomplete) contradicts the very
|
|
301
|
+
// line that names it. Each row's own fact decides.
|
|
302
|
+
const fundedCount = under.filter((row) => tense.funded.has(row.wallet)).length;
|
|
303
|
+
const lead = fundedCount === under.length
|
|
304
|
+
? `This deploy funds ${under.length} wallet(s) below what their own sizing needs`
|
|
305
|
+
: fundedCount === 0
|
|
306
|
+
? `This deploy would have funded ${under.length} wallet(s) below what their own sizing needs`
|
|
307
|
+
: `This deploy funds ${fundedCount} of ${under.length} wallet(s) below what their own ` +
|
|
308
|
+
`sizing needs (the rest never got a wallet)`;
|
|
309
|
+
return (`[W_BUDGET_BELOW_STRATEGY_MIN] ${lead}: ${renderUnderfunded(under)}. Nothing was blocked — the ` +
|
|
310
|
+
`budget check did not stop this deploy; ${degraded} fewer slots than designed and each ` +
|
|
311
|
+
`position a larger share of its wallet. (For reference, deploying the whole ${pkg.id} package ` +
|
|
312
|
+
`fresh needs ${usd(min.minBudget)} across ${min.walletCount} wallet(s).)${escape}`);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The SOFT tier's other half: a sleeve whose slot size could not be read out of its runtime.yaml
|
|
316
|
+
* (the vol-parity sleeves expose risk weights, not margin percentages). Its per-wallet minimum fell
|
|
317
|
+
* back to the bare floor, so the figure is a LOWER bound — saying it plainly is the whole point,
|
|
318
|
+
* because the alternative is advertising a $10 minimum for a design that no-trades below $250.
|
|
319
|
+
*
|
|
320
|
+
* A concrete shortfall is APPENDED rather than replacing this note. Both facts matter and the
|
|
321
|
+
* understatement is the more important caveat, so it leads — but "these wallets are already short"
|
|
322
|
+
* is the one hard, checkable thing available, and suppressing it (the shape this had when the two
|
|
323
|
+
* branches were exclusive) left the agent with the softer half of the truth while `belowMin`
|
|
324
|
+
* reported the opposite of it. Once it asserts that shortfall it must also name the way out, or an
|
|
325
|
+
* agent reading a concrete number with no escape will improvise one — and the improvisations this
|
|
326
|
+
* repo has field cases for are a bare re-run and a close.
|
|
327
|
+
*/
|
|
328
|
+
function buildBudgetUnresolvedNote(pkg, min, under, escape, tense) {
|
|
329
|
+
// Same per-row funding tense as the below-min note (see there for why).
|
|
330
|
+
const fundedCount = under.filter((row) => tense.funded.has(row.wallet)).length;
|
|
331
|
+
const funding = fundedCount === under.length
|
|
332
|
+
? `is ALREADY funding ${under.length} wallet(s)`
|
|
333
|
+
: fundedCount === 0
|
|
334
|
+
? `would have funded ${under.length} wallet(s)`
|
|
335
|
+
: `is ALREADY funding ${fundedCount} of ${under.length} wallet(s) (the rest never got a wallet)`;
|
|
336
|
+
const shortfall = under.length > 0
|
|
337
|
+
? ` This deploy ${funding} below their own sizing: ${renderUnderfunded(under)}.`
|
|
338
|
+
: "";
|
|
339
|
+
return (`[W_BUDGET_UNRESOLVED] could not compute a reliable minimum for ${pkg.id} — sleeve(s) ` +
|
|
340
|
+
`${min.unresolvedWallets.join(", ")} expose no resolvable marginPct, so the ${usd(min.minBudget)} ` +
|
|
341
|
+
`figure may be UNDERSTATED. Size conservatively.${shortfall} Nothing was blocked — the budget ` +
|
|
342
|
+
`check did not stop this deploy.${escape}`);
|
|
343
|
+
}
|
|
344
|
+
/** Which code leads the soft-tier note, or null when there is nothing to say. */
|
|
345
|
+
function softLead(min, under) {
|
|
346
|
+
if (min.unresolvedWallets.length > 0)
|
|
347
|
+
return "unresolved";
|
|
348
|
+
return under.length > 0 ? "below" : null;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* The soft-tier note, assembled from the facts plus whatever escape the caller's state supports.
|
|
352
|
+
* ONE producer for both codes' strings — the preflight narration and the final report differ only
|
|
353
|
+
* in `escape`, so they can never drift into two different accounts of the same finding.
|
|
354
|
+
*/
|
|
355
|
+
export function buildSoftBudgetNote(pkg, min, under, escape, tense) {
|
|
356
|
+
switch (softLead(min, under)) {
|
|
357
|
+
case "unresolved":
|
|
358
|
+
return buildBudgetUnresolvedNote(pkg, min, under, escape, tense);
|
|
359
|
+
case "below":
|
|
360
|
+
return buildBudgetBelowMinNote(pkg, min, under, escape, tense);
|
|
361
|
+
default:
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The fraction landed, FLOORED to a tenth of a percent.
|
|
367
|
+
*
|
|
368
|
+
* Floored rather than rounded on purpose: this warn fires strictly below 90%, and a rounded 89.97%
|
|
369
|
+
* would print "(90%)" beside a sentence explaining that the wallet came up short of 90% — the kind
|
|
370
|
+
* of self-contradiction that makes a reader distrust the whole line. Trailing `.0` is dropped so
|
|
371
|
+
* the ordinary case reads `12%`, not `12.0%`.
|
|
372
|
+
*
|
|
373
|
+
* Computed in integer CENTS. Flooring a raw double reintroduced the misprint the flooring exists to
|
|
374
|
+
* prevent, away from the boundary: `(6.80 / 10.00) * 1000` is 679.999… in binary floating point, so
|
|
375
|
+
* an exact 68% printed `67.9%`. Cents are what the figures actually are.
|
|
376
|
+
*/
|
|
377
|
+
function fundedPercent(row) {
|
|
378
|
+
const fundedCents = Math.round(row.funded * 100);
|
|
379
|
+
const requestedCents = Math.round(row.requested * 100);
|
|
380
|
+
return `${Math.floor((fundedCents * 1000) / requestedCents) / 10}%`;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* `main (0x…), created by this deploy, funded $60.00 of requested $500.00 (12%, short $440.00)` —
|
|
384
|
+
* both numbers, so the claim is checkable, and the provenance and the shortfall folded into the same
|
|
385
|
+
* clause so each wallet is named exactly once.
|
|
386
|
+
*
|
|
387
|
+
* The ask is attributed to whoever made it. On the adoption path the amount comes from a PRIOR run's
|
|
388
|
+
* journal, so "of requested $500.00" under a lead saying "what this deploy requested" described a
|
|
389
|
+
* request this run never made — against a wallet it never funded. The adopted clause names that run
|
|
390
|
+
* instead, and a reader who wants the current number is sent to `status.py` by the remediation.
|
|
391
|
+
*/
|
|
392
|
+
function renderPartialFunded(rows) {
|
|
393
|
+
return rows
|
|
394
|
+
.map((row) => {
|
|
395
|
+
const asked = row.created
|
|
396
|
+
? `${row.instance} (${row.wallet}), created by this deploy, funded ${usd(row.funded)} of ` +
|
|
397
|
+
`requested ${usd(row.requested)}`
|
|
398
|
+
: `${row.instance} (${row.wallet}), adopted by this deploy from an earlier run, funded ` +
|
|
399
|
+
`${usd(row.funded)} of the ${usd(row.requested)} that run requested`;
|
|
400
|
+
return `${asked} (${fundedPercent(row)}, short ${usd(row.requested - row.funded)})`;
|
|
401
|
+
})
|
|
402
|
+
.join(", ");
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The OUTCOME tier's warn: the backend funded a wallet for materially less than the create call
|
|
406
|
+
* asked for (a partial bridge, a rejected top-up leg — the deploy sees only the result). The
|
|
407
|
+
* successor to the skills-side verify gate's `_budget_verdict`, which reported the whole strategy
|
|
408
|
+
* "not live" over the same finding.
|
|
409
|
+
*
|
|
410
|
+
* It is a `W_`, and the difference from that predecessor is the point: the strategy IS live, its
|
|
411
|
+
* wallet holds real money and its runtime is ticking. Reporting `not-live` (or letting this change
|
|
412
|
+
* `overall`) would send an agent to close a funded, running strategy over a shortfall a top-up
|
|
413
|
+
* fixes. So it warns, the exit code stays whatever the deploy earned, and the remediation leads
|
|
414
|
+
* with the NON-destructive route.
|
|
415
|
+
*
|
|
416
|
+
* It deliberately emits NO teardown command. `close.py`'s correct scope depends on state this note
|
|
417
|
+
* does not own — adopted siblings, wallets stranded with no runtime, an `[E_ROLLBACK_INCOMPLETE]`
|
|
418
|
+
* line already claiming the cleanup — and the report already has exactly one producer of close
|
|
419
|
+
* commands ({@link buildBudgetEscape}). A second, differently scoped teardown beside it is the
|
|
420
|
+
* self-contradiction the taxonomy's rule 5 exists to prevent, and an agent reconciling two of them
|
|
421
|
+
* runs whichever executes. The destructive option is named as an option and routed through
|
|
422
|
+
* read-only triage, which is the only command here and cannot destroy anything.
|
|
423
|
+
*
|
|
424
|
+
* Two properties of the note are load-bearing and easy to lose:
|
|
425
|
+
* - `reassure` — the "nothing is broken, the strategy is LIVE" sentence is a CLAIM about the rest
|
|
426
|
+
* of the report, so it is only made when the report PROVES it: `overall === "live"` and nothing
|
|
427
|
+
* less (see the gate at the call site). Asserted unconditionally it rode `failed` and `pending`
|
|
428
|
+
* reports saying the opposite two lines above; gated only loosely it rode
|
|
429
|
+
* `installed-unobserved` ones, where no tick was ever seen.
|
|
430
|
+
* - the figures are DATED and the top-up is routed through a fresh read. This note persists on the
|
|
431
|
+
* job snapshot and re-renders on every later `deploy status`, and unlike every other budget note
|
|
432
|
+
* it asserts a MUTABLE backend quantity whose fix is exactly the action that invalidates it — so
|
|
433
|
+
* an undated "short $440.00" invites a second $440 top-up after the first one landed.
|
|
434
|
+
* - PROVENANCE, exactly as its unreadable sibling carries it. This note is reachable on the
|
|
435
|
+
* ADOPTION path too — the ask is looked up from a prior run's journal for the strategy this run
|
|
436
|
+
* adopted — where "SHORT of what this deploy requested … funded $60.00 of requested $500.00"
|
|
437
|
+
* named a request this run never made about a wallet it never funded ($500 was the earlier run's
|
|
438
|
+
* ask; this one asked for nothing and created nothing). The lead attributes the ask to no one and
|
|
439
|
+
* each wallet states its own origin, so a reader can correct the figure from the note itself.
|
|
440
|
+
*/
|
|
441
|
+
export function buildPartialFundNote(pkg, rows, reassure) {
|
|
442
|
+
// The size claim is about the WALLETS THIS NOTE NAMES, never about the strategy. `rows` is the
|
|
443
|
+
// filtered SHORT set, so "the strategy is LIVE, but it is trading a $20.00 book" read a
|
|
444
|
+
// whole-package figure out of one sleeve of it: on a deploy whose other sleeve landed its full
|
|
445
|
+
// $300, the strategy was trading $320 across two wallets. Same rule the unreadable note's
|
|
446
|
+
// standing sentence follows — a filtered set speaks only for the wallets in it.
|
|
447
|
+
const book = rows.length === 1
|
|
448
|
+
? `that wallet is trading a ${usd(rows[0].funded)} book`
|
|
449
|
+
: `those wallets are trading only what landed on them`;
|
|
450
|
+
const standing = reassure
|
|
451
|
+
? `Nothing was blocked and nothing is broken — the strategy is LIVE, but ${book}, `
|
|
452
|
+
: `Read the step lines above first — they, not this note, say what state this ` +
|
|
453
|
+
`deploy left behind. Either way ${book}, `;
|
|
454
|
+
return (`[W_BUDGET_PARTIAL_FUND] The backend funded ${rows.length} wallet(s) SHORT of what was ` +
|
|
455
|
+
`requested for them, as the backend reported them at deploy time: ${renderPartialFunded(rows)}. ` +
|
|
456
|
+
`${standing}so every position opened there is sized off the money that actually landed, not ` +
|
|
457
|
+
`off the amount requested. Report the funded figure, never the requested one. Fix it the ` +
|
|
458
|
+
`non-destructive way FIRST — and re-read the CURRENT funding before moving any money, because ` +
|
|
459
|
+
`those figures are what the backend reported AT DEPLOY TIME and a top-up (or a slow bridge leg ` +
|
|
460
|
+
`settling late) makes them stale: run ` +
|
|
461
|
+
`\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`, then add whatever difference it ` +
|
|
462
|
+
`still shows through the \`senpi-deposit-withdraw-transfer\` skill. Deploy itself never adds ` +
|
|
463
|
+
`funds to a wallet that already exists, so re-running at the same budget would only adopt what ` +
|
|
464
|
+
`is there. Closing and redeploying at the full size is the alternative and it is DESTRUCTIVE: ` +
|
|
465
|
+
`agree the scope with the user against that same read, because a teardown here also takes down ` +
|
|
466
|
+
`whatever else this package has live.`);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The unreadable-read sibling of {@link buildPartialFundNote}: the ask is known, the landed amount
|
|
470
|
+
* is not, so this note makes NO percentage claim, NO dollar shortfall, and asks for no money move.
|
|
471
|
+
*
|
|
472
|
+
* The failure it replaces: the client coerced a missing/unparseable `totalFunded` to `0`, which
|
|
473
|
+
* turned one bad read into `funded $0.00 of requested $500.00 (0%)` plus an instruction to add
|
|
474
|
+
* $500 — against a wallet that may already hold the full amount, so following it doubled the
|
|
475
|
+
* deployment. An unread figure is not a small figure; the only honest next step is for the USER to
|
|
476
|
+
* establish what is actually on the wallet.
|
|
477
|
+
*
|
|
478
|
+
* `reassure` is the same earned-claim gate its sibling carries, and for the same reason: "the
|
|
479
|
+
* create succeeded and the strategy is running" is a claim about the REST of the report, and this
|
|
480
|
+
* note is reachable on `failed` and `pending` reports through the adoption path — asserted
|
|
481
|
+
* unconditionally it rode a verdict saying the opposite two lines above. Without it the note keeps
|
|
482
|
+
* every fact it owns (the amount is unreadable, verify before moving money) and makes no claim
|
|
483
|
+
* about what the deploy left running.
|
|
484
|
+
*
|
|
485
|
+
* PROVENANCE is the other claim it may not invent, and it is claimed PER WALLET only. The note is
|
|
486
|
+
* reachable on the ADOPTION path — a journaled ask is looked up for exactly the strategy a prior
|
|
487
|
+
* run created — so "wallet(s) this deploy created" described a create that never happened on a
|
|
488
|
+
* crash-resume that adopted, created nothing and funded nothing, handing the reader a second wallet
|
|
489
|
+
* that does not exist. Each wallet therefore states its own origin from
|
|
490
|
+
* {@link UnreadableFundedWallet.created}.
|
|
491
|
+
*
|
|
492
|
+
* No sentence here generalises that over the deploy. `rows` is a FILTERED set — the wallets whose
|
|
493
|
+
* funded figure came back unreadable — so nothing about the OTHER wallets can be read out of it,
|
|
494
|
+
* and a `rows.every(...)` looks exactly like a deploy-level fact while being one about the subset:
|
|
495
|
+
* a deploy that created `main` (readable $300) and adopted `scalp` (unreadable) has `rows =
|
|
496
|
+
* [scalp]`, and "this deploy created no wallet" is then flatly false about the $300 it just moved.
|
|
497
|
+
* The only deploy-level claim allowed is the one `reassure` proves — the report says `live`, so the
|
|
498
|
+
* strategy is running — and it names no create and no adoption.
|
|
499
|
+
*/
|
|
500
|
+
export function buildFundedUnreadableNote(pkg, rows, reassure) {
|
|
501
|
+
const wallets = rows
|
|
502
|
+
.map((row) => row.created
|
|
503
|
+
? `${row.instance} (${row.wallet}), created by this deploy, requested ${usd(row.requested)}`
|
|
504
|
+
: `${row.instance} (${row.wallet}), adopted by this deploy from an earlier run that ` +
|
|
505
|
+
`requested ${usd(row.requested)}`)
|
|
506
|
+
.join(", ");
|
|
507
|
+
const standing = reassure
|
|
508
|
+
? `Nothing was blocked and the strategy is running; what is UNKNOWN is `
|
|
509
|
+
: `Read the step lines above first — they, not this note, say what state this deploy left ` +
|
|
510
|
+
`behind. What is UNKNOWN here is `;
|
|
511
|
+
return (`[W_BUDGET_FUNDED_UNREADABLE] The backend did not report a readable funded amount for ` +
|
|
512
|
+
`${rows.length} wallet(s): ${wallets}. ${standing}` +
|
|
513
|
+
`how much money landed on it — do not state it as ` +
|
|
514
|
+
`zero, and do not state it as the requested amount. Move no money on this: ask the USER to ` +
|
|
515
|
+
`verify the actually-funded amount first ` +
|
|
516
|
+
`(\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`, or inspecting the wallet), and ` +
|
|
517
|
+
`only then decide whether anything needs topping up or resizing.`);
|
|
518
|
+
}
|
|
519
|
+
export function buildDecisionModelRefusal(instance, pkg, budget) {
|
|
520
|
+
return (`[INVALID_REQUEST] Instance "${instance}" runs actions with decision_mode: llm, but no decision ` +
|
|
521
|
+
`model was given. Nothing was created. Re-run naming the model: ` +
|
|
522
|
+
`${resumeCommand(pkg.dir, budget)} --decision-model <model>`);
|
|
523
|
+
}
|
|
524
|
+
export function buildDecisionModelEnvRefusal(instance, pkg) {
|
|
525
|
+
return (`[INVALID_REQUEST] Instance "${instance}" runs actions with decision_mode: llm, but ` +
|
|
526
|
+
`${pkg.dir}/strategy.yaml declares no defaults.decision_model_env — there is no token to ` +
|
|
527
|
+
`substitute the model into. Nothing was created. Add ` +
|
|
528
|
+
`defaults.decision_model_env to strategy.yaml, bind it in the runtime.yaml, then re-run.`);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* An install that came up with NO live entry scanners (`payload.unwired`). The runtime came UP and
|
|
532
|
+
* is BLIND, and every one of the three phases is deterministic on this box — so this is an install
|
|
533
|
+
* failure, not a tick that has not landed yet.
|
|
534
|
+
*
|
|
535
|
+
* Says what is true (it will never tick), what was NOT done (no scanner ever mounted), and — the
|
|
536
|
+
* part that has to track reality — what is LEFT on the box. The two teardown outcomes leave
|
|
537
|
+
* different states, so they never share a sentence: naming `senpi runtime delete` after a
|
|
538
|
+
* successful delete points at a runtime that no longer exists, and omitting it after a failed one
|
|
539
|
+
* hides a runtime still running against a wallet this job is about to close.
|
|
540
|
+
*
|
|
541
|
+
* The NEXT STEP is per-phase for the same reason. One ladder ("delete, fix, re-run") cannot serve
|
|
542
|
+
* all three: only `install_wire` is fixable in the package, and on `no_intake` re-running is a loop
|
|
543
|
+
* that cannot terminate — create → fund → install → unwired → close, moving real money out and
|
|
544
|
+
* back on every iteration — because the cause is box-level and the intake context is built once at
|
|
545
|
+
* plugin register. No emitted command may require a precondition the state lacks.
|
|
546
|
+
*/
|
|
547
|
+
export function buildUnwiredInstallDetail(params) {
|
|
548
|
+
const { phase, runtimeId, wallet, createdByThisJob, removal } = params;
|
|
549
|
+
const { cause, next } = describeUnwiredPhase(phase, createdByThisJob);
|
|
550
|
+
// Deleting a runtime tears down DSL exit management for its wallet ADDRESS — it cancels the
|
|
551
|
+
// exchange stop-loss and drops the backend ratchet stop for every ACTIVE DSL there. On a wallet
|
|
552
|
+
// this job created, the rollback closes it moments later, so there is nothing left to be
|
|
553
|
+
// unmanaged; on an ADOPTED wallet nothing closes, so the consequence has to be said out loud.
|
|
554
|
+
const dslNote = createdByThisJob || !removal.ok
|
|
555
|
+
? ""
|
|
556
|
+
: ` Deleting it also tore down DSL exit management for ${wallet}, which this deploy did not ` +
|
|
557
|
+
`create: any open position there has lost its ratchet stop and its exchange stop-loss, and ` +
|
|
558
|
+
`nothing is watching it — check with MCP \`strategy_get_clearinghouse_state\` on ${wallet} ` +
|
|
559
|
+
`before you leave this.`;
|
|
560
|
+
const left = removal.ok
|
|
561
|
+
? `The runtime was deleted, so nothing is left running against this wallet; the cause is in ` +
|
|
562
|
+
`the gateway log (event \`runtime.scanners_unwired\`, phase ${phase}).${dslNote}`
|
|
563
|
+
: `The runtime could NOT be deleted (${removal.error}) and is STILL RUNNING against this ` +
|
|
564
|
+
`wallet — read it with \`openclaw senpi status -r ${runtimeId}\`, then remove it yourself: ` +
|
|
565
|
+
`\`openclaw senpi runtime delete ${runtimeId}\`.`;
|
|
566
|
+
return (`install produced a BLIND runtime "${runtimeId}": ${cause} (phase ${phase}), so it has no entry ` +
|
|
567
|
+
`scanners and would never tick. Counted as an install failure. ${left} ${next}`);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* The install step's detail when this deploy stopped waiting on an install it could not cancel.
|
|
571
|
+
*
|
|
572
|
+
* Every clause here is load-bearing. The install is IN-PROCESS work that takes no signal, so this
|
|
573
|
+
* deploy giving up says nothing about what the install did — and a detail that guessed either way
|
|
574
|
+
* would send a reader to close a wallet a runtime may have just come up on. So the claim is the
|
|
575
|
+
* only one both reads support: the outcome is unknown. The read that resolves it is free and
|
|
576
|
+
* non-destructive, so it leads; the money-moving step is conditional on its answer, and carries the
|
|
577
|
+
* same live-sleeve caveat the rollback path uses, because the same package may hold other wallets.
|
|
578
|
+
*
|
|
579
|
+
* The detail forks on TWO facts, because one was not enough to make either instruction true.
|
|
580
|
+
*
|
|
581
|
+
* `createdByThisJob` (the same flag {@link buildUnwiredInstallDetail} branches on) decides whether a
|
|
582
|
+
* close is even on the table. A wallet this job created was never closed BECAUSE the install might
|
|
583
|
+
* still bind it — the causal claim, and the reclaim instruction, both hold, and the money is this
|
|
584
|
+
* job's own to reclaim. A wallet this job ADOPTED predates the deploy: D-6 never closes an adopted
|
|
585
|
+
* wallet regardless of how the install lands, so "would have closed it but for the race" is false on
|
|
586
|
+
* that wallet, and `funded` is the caller's pre-existing balance — not a close target this deploy
|
|
587
|
+
* has any standing to name.
|
|
588
|
+
*
|
|
589
|
+
* `stillInFlight` is the wallet's install FENCE, read as this detail is written, and it decides
|
|
590
|
+
* whether ANY instruction may be given yet. `installRuntimeCore` writes its registry row and takes
|
|
591
|
+
* its MCP wallet-ownership check BEFORE `run()` — the modal wedge — so at this instant the install
|
|
592
|
+
* can be sitting anywhere in that sequence with the fence still held. Two things follow, and both
|
|
593
|
+
* used to be wrong here:
|
|
594
|
+
* - a registry ROW is not proof the install landed. The row is written first; `senpi.listRuntimes`
|
|
595
|
+
* renders exactly that state `status: "stopped"`. The landed claim is therefore keyed on the
|
|
596
|
+
* runtime showing RUNNING, never on a row existing.
|
|
597
|
+
* - while the fence is held, no close may be named at all. An install wedged at the ownership
|
|
598
|
+
* check has written NO row, so "if NO row names that wallet, reclaim the funds" pointed straight
|
|
599
|
+
* at a `strategy_close` that races the install into the one state this whole carve-out exists to
|
|
600
|
+
* prevent — a live runtime trading a closed strategy.
|
|
601
|
+
* An UNREADABLE fence (`undefined`) takes the in-flight arm: it cannot be proven clear, and the
|
|
602
|
+
* only unsafe mistake here is naming a close over an install that is still running.
|
|
603
|
+
*/
|
|
604
|
+
export function buildInstallIndeterminateDetail(args) {
|
|
605
|
+
const { wallet, funded, runtimeId, deadlineMs, createdByThisJob, stillInFlight, resume, caveat } = args;
|
|
606
|
+
// The landed claim, keyed on RUNNING. A "stopped" row is called out by name because it is the
|
|
607
|
+
// likeliest thing this reader actually sees: the row is written before `run()`, so the wedge this
|
|
608
|
+
// detail reports for produces exactly that row.
|
|
609
|
+
const triage = `Read what exists before changing anything: \`openclaw senpi runtime list\` — the install ` +
|
|
610
|
+
`LANDED only if that list shows "${runtimeId}" on ${wallet} RUNNING. A row on its own does not ` +
|
|
611
|
+
`prove it: the registry row is written BEFORE the runtime starts, so a row rendered "stopped" ` +
|
|
612
|
+
`is an install that registered and never came up. If it is running, re-run \`${resume}\` to ` +
|
|
613
|
+
`observe a tick.`;
|
|
614
|
+
// Whose wallet this is — the one fact both settled arms and the in-flight arm state, because a
|
|
615
|
+
// reader deciding anything about money has to know whether this deploy created it.
|
|
616
|
+
const inFlightProvenance = createdByThisJob
|
|
617
|
+
? `This wallet — ${fundedClause(funded)} — was created by this deploy and was NOT closed`
|
|
618
|
+
: `This wallet predates this deploy — ${fundedClause(funded)} — and this deploy never closes an ` +
|
|
619
|
+
`adopted wallet`;
|
|
620
|
+
const outcome = stillInFlight !== false
|
|
621
|
+
? `${inFlightProvenance}. ` +
|
|
622
|
+
(stillInFlight === true
|
|
623
|
+
? `That install is STILL RUNNING inside this gateway as this report is written`
|
|
624
|
+
: `Whether that install is still running could not be read here, so this report assumes ` +
|
|
625
|
+
`it is`) +
|
|
626
|
+
` — the wallet is fenced against a second install until it settles, so its outcome is not ` +
|
|
627
|
+
`decided yet and nothing you can read right now is final. Change NOTHING on ${wallet} and ` +
|
|
628
|
+
`close NOTHING: a close now would land on a strategy that install is still binding, ` +
|
|
629
|
+
`leaving a live runtime trading a closed strategy. Re-read shortly — \`openclaw senpi ` +
|
|
630
|
+
`deploy status\`, then \`openclaw senpi runtime list\` — and decide from the settled ` +
|
|
631
|
+
// Scoped to THIS wallet on purpose: a re-run that reaches it is fenced, but "a re-run
|
|
632
|
+
// changes nothing" is a claim about the whole deploy that this detail cannot make — a
|
|
633
|
+
// sleeve whose strategy reconcile fails to match by name is created and funded again.
|
|
634
|
+
`state. A re-run that reaches this wallet is refused rather than installing over it, so it ` +
|
|
635
|
+
`cannot settle this for you.`
|
|
636
|
+
: createdByThisJob
|
|
637
|
+
? `This wallet — ${fundedClause(funded)} — was NOT closed, because closing a wallet an ` +
|
|
638
|
+
`install is still binding would leave a runtime trading a closed strategy. That install ` +
|
|
639
|
+
`is no longer running in this gateway — the wallet's install fence has cleared — so what ` +
|
|
640
|
+
`exists now is what it left. ${triage} If that list shows "${runtimeId}" on ${wallet} ` +
|
|
641
|
+
`but NOT running, do not close the wallet: the next gateway restart will try to start ` +
|
|
642
|
+
`that runtime, so read it first (\`openclaw senpi status -r ${runtimeId}\`) and decide ` +
|
|
643
|
+
`with the user. If NO runtime names that wallet at all, nothing is watching the funds: ` +
|
|
644
|
+
`reclaim them with MCP \`strategy_close\` on ${wallet}${caveat}`
|
|
645
|
+
: `This wallet predates this deploy — ${fundedClause(funded)} — and this deploy never ` +
|
|
646
|
+
`closes an adopted wallet, whichever way the install landed. That install is no longer ` +
|
|
647
|
+
`running in this gateway. ${triage} Its funds are unaffected by this deploy either way; ` +
|
|
648
|
+
`use the read above to decide what, if anything, needs doing yourself.`;
|
|
649
|
+
return (`[${INSTALL_INDETERMINATE_CODE}] the install on wallet ${wallet} did not finish within ` +
|
|
650
|
+
`${Math.round(deadlineMs / 1000)}s, so this deploy stopped waiting for it. The install runs ` +
|
|
651
|
+
`inside the gateway and was NOT cancelled: it may still have completed, or it may not have. ` +
|
|
652
|
+
`${outcome}`);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The install step's detail when this deploy found an install already running on the wallet.
|
|
656
|
+
*
|
|
657
|
+
* The refusal `installRuntimeCore` renders for the same condition is the enforcement copy; this one
|
|
658
|
+
* exists because deploy reaches the condition one step earlier — before the dead-row branch that
|
|
659
|
+
* would delete the in-flight install's registry row — and so can say what deploy did (nothing) as
|
|
660
|
+
* well as what it found. Both name the same reads and neither names a delete, and this one now
|
|
661
|
+
* carries the same install-bound and wedge-vs-restart facts the enforcement copy does — deploy is
|
|
662
|
+
* the primary path, so it is the one most readers see, and a weaker message here would invert which
|
|
663
|
+
* of the two is the source of truth. The one fact it deliberately does NOT reach parity on is elapsed
|
|
664
|
+
* time: `installPendingOnWallet` reports a boolean, not when the install started, and the job's own
|
|
665
|
+
* timing is already what `openclaw senpi deploy status` (named below) shows.
|
|
666
|
+
*/
|
|
667
|
+
export function buildInstallInFlightDetail(args) {
|
|
668
|
+
const boundSec = Math.round(INSTALL_DEADLINE_MS / 1000);
|
|
669
|
+
return (`[${WALLET_INSTALL_IN_FLIGHT_CODE}] an install is already running on wallet ${args.wallet} ` +
|
|
670
|
+
`(runtime "${args.runtimeId}") inside this gateway, so nothing was installed or deleted here — ` +
|
|
671
|
+
`a second runtime on one wallet would put two exit engines on one margin account. This normally ` +
|
|
672
|
+
`clears on its own once that install finishes. A deploy's own install bound is ${boundSec}s — ` +
|
|
673
|
+
`well past that, this install is more likely wedged than merely slow, and if so the fence on ` +
|
|
674
|
+
`this wallet will not clear until this gateway process restarts. Read what that install produced ` +
|
|
675
|
+
`before acting: openclaw senpi deploy status, then openclaw senpi runtime list.`);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* The cause and the next step for one unwired phase — computed from the phase, never templated.
|
|
679
|
+
*
|
|
680
|
+
* `no_intake`'s stake depends on what this job actually did: only a run that CREATES a wallet moves
|
|
681
|
+
* money on every doomed iteration. Asserting that on an adopted wallet (where a re-run installs and
|
|
682
|
+
* closes nothing) would overstate a warning that is already strong enough on its own facts.
|
|
683
|
+
*/
|
|
684
|
+
function describeUnwiredPhase(phase, createdByThisJob) {
|
|
685
|
+
if (phase === "no_intake") {
|
|
686
|
+
const loop = createdByThisJob
|
|
687
|
+
? `every re-run repeats create → fund → install → close and moves real money out and back each time`
|
|
688
|
+
: `every re-run repeats install → delete on this adopted wallet — no funds move, but nothing ` +
|
|
689
|
+
`is ever left watching it`;
|
|
690
|
+
return {
|
|
691
|
+
cause: `supervised scanner intake is disabled for this whole box, so no external scanner could ` +
|
|
692
|
+
`be mounted — nothing about this package or this runtime caused it`,
|
|
693
|
+
next: `Do NOT re-run the deploy yet: the cause is box-level and is decided once when the plugin ` +
|
|
694
|
+
`starts, so ${loop}. Repair or clear the plugin's \`acceptance-record.json\` state file ` +
|
|
695
|
+
`and RESTART the gateway first (the boot log names the file with its full path); re-run ` +
|
|
696
|
+
`only once a fresh install wires its scanners.`,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
if (phase === "install_launch") {
|
|
700
|
+
return {
|
|
701
|
+
cause: `the supervised scanner launcher failed to start`,
|
|
702
|
+
next: `Read the launcher error in the gateway log before re-running — a re-run repeats it ` +
|
|
703
|
+
`verbatim while the cause is still there.`,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
cause: `the supervised scanner wiring failed (a packaging or entrypoint error in this package)`,
|
|
708
|
+
next: `Fix the scanner entrypoint this package declares (the \`path\`/module of each external ` +
|
|
709
|
+
`scanner in the instance runtime.yaml), then re-run the deploy.`,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* A strategy the ACTIVE poll watched go DEAD. Terminal, so the poll stops on it — and the claim it
|
|
714
|
+
* replaces is the point: `buildCreatePendingDetail` would have said "waits for it to reach ACTIVE,
|
|
715
|
+
* never creating a second wallet", and on this input neither half holds. It never reaches ACTIVE,
|
|
716
|
+
* and reconcile filters dead statuses on BOTH match routes, so a re-run creates and funds a fresh
|
|
717
|
+
* wallet beside it. The status only ever survived in the step evidence; here it leads the claim.
|
|
718
|
+
*/
|
|
719
|
+
export function buildCreateDeadDetail(pkg, budget, live, createdByThisJob) {
|
|
720
|
+
const origin = polledWalletOrigin(createdByThisJob);
|
|
721
|
+
const target = polledWalletTarget(live);
|
|
722
|
+
// A teardown instruction is earned HERE and only here: a dead status proves the strategy is not
|
|
723
|
+
// coming back, so money left on it really is stranded.
|
|
724
|
+
const funds = live.totalFunded === undefined
|
|
725
|
+
? `The backend reported no readable funded amount for it, so whether anything is stranded is ` +
|
|
726
|
+
`UNKNOWN — read it with MCP \`strategy_list\` on ${target} before deciding.`
|
|
727
|
+
: live.totalFunded > 0
|
|
728
|
+
? `It still reports ${usd(live.totalFunded)} funded: reclaim that FIRST with MCP ` +
|
|
729
|
+
`\`strategy_close\` on ${target}, or the money stays in a dead strategy.`
|
|
730
|
+
: `It reports ${usd(live.totalFunded)} funded, so there is likely nothing to reclaim — ` +
|
|
731
|
+
`confirm with MCP \`strategy_list\` on ${target} before re-running.`;
|
|
732
|
+
return (`strategy ${live.id} went ${live.status} while waiting for ACTIVE — a dead status is terminal, ` +
|
|
733
|
+
`so the wait stopped here rather than running out the clock. Nothing was installed. ${origin}. ` +
|
|
734
|
+
`${funds} A re-run does NOT adopt a dead strategy (reconcile filters dead statuses on both ` +
|
|
735
|
+
`match routes), so \`${resumeCommand(pkg.dir, budget)}\` WILL create and fund a NEW wallet beside ` +
|
|
736
|
+
`this one.`);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* A strategy the ACTIVE poll watched settle on a status that is neither ACTIVE nor on the way
|
|
740
|
+
* there — PAUSED, mid-teardown, or one this build does not recognise.
|
|
741
|
+
*
|
|
742
|
+
* Only {@link INITIALIZING_STATUSES} resolve on their own, so only they are worth waiting on;
|
|
743
|
+
* everything else burned the full `--max-wait` and was then reported with the bounded-wait detail's
|
|
744
|
+
* "re-run; reconciliation waits for it to reach ACTIVE" promise, which none of them will honour.
|
|
745
|
+
*
|
|
746
|
+
* The REMEDIATION branches, and must: the create step gets no D-6 rollback (only an install failure
|
|
747
|
+
* rolls a wallet back), so a wallet this job created and funded seconds ago is still live when this
|
|
748
|
+
* is read. One shared "reclaim it FIRST with `strategy_close`" told an agent to tear down a wallet
|
|
749
|
+
* on a status this build cannot interpret — the exact opposite of what
|
|
750
|
+
* {@link buildNotDeployableRefusal} says for the same input at the reconcile seam ("will not guess
|
|
751
|
+
* … NEVER close or recreate to 'start clean'"). The two seams now agree arm for arm: a teardown is
|
|
752
|
+
* offered only where the status PROVES the strategy is not coming back.
|
|
753
|
+
*/
|
|
754
|
+
export function buildCreateNotDeployableDetail(pkg, budget, live, createdByThisJob,
|
|
755
|
+
/**
|
|
756
|
+
* The scoped start-over clause — see {@link buildPackageCloseOffer}. Used by the PAUSED arm
|
|
757
|
+
* only. Completes the sentence "If you meant to start over instead, …" and carries its own
|
|
758
|
+
* terminal period; the arm appends nothing to it.
|
|
759
|
+
*/
|
|
760
|
+
closeOffer) {
|
|
761
|
+
const status = live.status.toUpperCase();
|
|
762
|
+
const origin = polledWalletOrigin(createdByThisJob);
|
|
763
|
+
const rerun = `re-run \`${resumeCommand(pkg.dir, budget)}\``;
|
|
764
|
+
// `pkg.id` is single-quoted everywhere it reaches a shell, exactly as buildStrandedCloseCaveat
|
|
765
|
+
// quotes it: these strings get copied verbatim into a terminal.
|
|
766
|
+
const statusPy = `\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\``;
|
|
767
|
+
const head = `strategy ${live.id} is ${live.status} while this deploy waited for ACTIVE, so the wait stopped ` +
|
|
768
|
+
`here — this is not a funding delay. Nothing was installed. ${origin}. `;
|
|
769
|
+
if (status === "PAUSED") {
|
|
770
|
+
return (`${head}A paused strategy will not become ACTIVE on its own. Resume it, then ${rerun}. If you ` +
|
|
771
|
+
`meant to start over instead, ${closeOffer} Never leave it paused and fund a second wallet ` +
|
|
772
|
+
`beside it.`);
|
|
773
|
+
}
|
|
774
|
+
if (CLOSING_STATUSES.includes(status)) {
|
|
775
|
+
return (`${head}It is being torn down, and ${itsFunded(live.totalFunded)} returns to the owner wallet as ` +
|
|
776
|
+
`that finishes — nothing to reclaim by hand. Wait for it to reach CLOSED (re-check with ` +
|
|
777
|
+
`${statusPy}), then ${rerun} — the re-run creates a fresh wallet once this one is gone.`);
|
|
778
|
+
}
|
|
779
|
+
// Unrecognized. "Waiting cannot help" is a claim this build has no basis for: the status is in
|
|
780
|
+
// none of the sets it knows, so it may still be on its way to ACTIVE. Read-only triage only —
|
|
781
|
+
// and no teardown command, because nothing here proves the money is stranded.
|
|
782
|
+
return (`${head}This build does not recognize the status ${live.status} — it is in none of the ` +
|
|
783
|
+
`initializing, closing or dead sets this version knows, so deploy will not guess what it means ` +
|
|
784
|
+
`or whether waiting would have helped. Read-only triage FIRST: ${statusPy}, ` +
|
|
785
|
+
`\`openclaw senpi deploy status\`, or MCP \`strategy_get\` with strategy_id "${live.id}" (that tool takes ` +
|
|
786
|
+
`the id, never a wallet address). Then ${rerun} once it is ACTIVE. Do NOT close or recreate this ` +
|
|
787
|
+
// "seconds from ACTIVE" stood here — an unprovable duration on a rendered surface, the same
|
|
788
|
+
// class deleted from the bounded-wait outcome. Nothing here read how far along this strategy
|
|
789
|
+
// is; what this payload DOES establish is that the status is unclassifiable, which is an
|
|
790
|
+
// argument for not closing on its own.
|
|
791
|
+
`strategy to "start clean": ${itsFunded(live.totalFunded)} is not proven stranded, and a status ` +
|
|
792
|
+
`this build cannot place is not evidence the strategy is finished — closing it would throw away ` +
|
|
793
|
+
`a wallet nothing here showed to be done.`);
|
|
794
|
+
}
|
|
795
|
+
/** Who funded the polled wallet — shared by both break-outs so they can never disagree. */
|
|
796
|
+
function polledWalletOrigin(createdByThisJob) {
|
|
797
|
+
return createdByThisJob
|
|
798
|
+
? `This deploy created it`
|
|
799
|
+
: `This deploy adopted it and funded nothing — it predates this run`;
|
|
800
|
+
}
|
|
801
|
+
/** How to name the polled wallet in a command, address-first and never `undefined`. */
|
|
802
|
+
function polledWalletTarget(live) {
|
|
803
|
+
return live.strategyWalletAddress
|
|
804
|
+
? `wallet ${live.strategyWalletAddress}`
|
|
805
|
+
: `strategy id ${live.id} (its wallet address is unreadable)`;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* What the last poll read said about the record, and nothing else.
|
|
809
|
+
*
|
|
810
|
+
* Both facts are quoted from the `strategy_list` row the poll already holds — no extra call, no
|
|
811
|
+
* interpretation. An absent row and an unreadable field are named as such rather than filled in:
|
|
812
|
+
* this whole surface exists because "wallet still funding" was asserted over states nobody read.
|
|
813
|
+
*
|
|
814
|
+
* The status carries one qualifier, and it is a classification this repo owns rather than a
|
|
815
|
+
* measurement: membership of `INITIALIZING_STATUSES`, the list whose entire definition is "statuses
|
|
816
|
+
* that resolve on their own, so a bounded poll is the right response" (`senpi/strategy-status.ts`).
|
|
817
|
+
* It is provable by a lookup against a list in this repo, it needs no hedge, and it is the reason
|
|
818
|
+
* waiting — and re-running to keep waiting — is the correct next step on that payload.
|
|
819
|
+
*
|
|
820
|
+
* Nothing here says how long initialization takes. The backend owns that; a figure measured once
|
|
821
|
+
* somewhere else is not something this deploy read, cannot be checked from here, and cannot be
|
|
822
|
+
* acted on — so the only durations on this surface are the wait this job bounded and the record's
|
|
823
|
+
* age off `createdAt`.
|
|
824
|
+
*/
|
|
825
|
+
function polledRecordRead(live, nowMs) {
|
|
826
|
+
if (!live) {
|
|
827
|
+
return "the last `strategy_list` read returned no row for it, so its status and age are unread here";
|
|
828
|
+
}
|
|
829
|
+
// The empty-status arm is unreachable through `runDeploy`: an empty status is neither ACTIVE nor
|
|
830
|
+
// initializing, so `pollUntilActive`'s non-initializing guard reports the step `failed` through
|
|
831
|
+
// `buildCreateNotDeployableDetail` before the deadline branch can run, and reconcile refuses the
|
|
832
|
+
// same value at the match seam (`buildNotDeployableRefusal`). Kept anyway: `status` is a string
|
|
833
|
+
// this process read off the backend, this builder is one guard-reorder away from seeing an empty
|
|
834
|
+
// one, and the alternative rendering is the blank in "it reads status and was created 630s ago".
|
|
835
|
+
const status = live.status
|
|
836
|
+
? `status ${live.status.toUpperCase()}${isInitializing(live.status) ? " (an initializing status this wait is for)" : ""}`
|
|
837
|
+
: "NO STATUS on that read";
|
|
838
|
+
const createdMs = Date.parse(live.createdAt ?? "");
|
|
839
|
+
if (Number.isNaN(createdMs)) {
|
|
840
|
+
return `it reads ${status}, age UNREADABLE (the row carried no parseable createdAt)`;
|
|
841
|
+
}
|
|
842
|
+
const ageSec = Math.max(0, Math.round((nowMs - createdMs) / 1000));
|
|
843
|
+
return `it reads ${status} and was created ${ageSec}s ago`;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Is the NAME route even open for this row — i.e. does it carry the name a re-run will look up?
|
|
847
|
+
*
|
|
848
|
+
* Reconcile has two match routes: `strategyName` (case-folded, non-empty on both sides) and a
|
|
849
|
+
* create key journaled by THIS box. This is the name comparison out of `resolveInstanceBinding`,
|
|
850
|
+
* and it is a NECESSARY condition for that route, never a sufficient one. Reconcile applies three
|
|
851
|
+
* further filters this function cannot see, all of them about rows other than the one held here:
|
|
852
|
+
* it drops `DEAD_STATUSES` before comparing, it refuses when the surviving list carries a foreign
|
|
853
|
+
* `skillName` stamp (`E_WALLET_OWNED_BY_OTHER_PACKAGE`), and it refuses when more than one
|
|
854
|
+
* candidate answers to the name (`E_STATE_AMBIGUOUS_WALLETS`). All three are executed and all three
|
|
855
|
+
* create nothing — so a `false` here means "the name route is closed", while a `true` means only
|
|
856
|
+
* "it is not closed by the name", which is why the sentence it feeds states its outcome as
|
|
857
|
+
* conditional rather than as a fact.
|
|
858
|
+
*
|
|
859
|
+
* A `false` is not hypothetical: the unnamed create retry sends no `strategyName` at all (it is
|
|
860
|
+
* `.optional()` in the MCP schema), so the backend names that wallet itself and the row comes back
|
|
861
|
+
* under a name this package will never derive.
|
|
862
|
+
*/
|
|
863
|
+
export function reconcileCanMatchByName(wantedName, live) {
|
|
864
|
+
const wanted = wantedName.trim().toLowerCase();
|
|
865
|
+
return wanted !== "" && (live.strategyName ?? "").trim().toLowerCase() === wanted;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* The money invariant, and the executed boundary it holds at.
|
|
869
|
+
*
|
|
870
|
+
* `resolveInstanceBinding` matches by NAME first, and a non-empty match ends in `applyCandidates` —
|
|
871
|
+
* which adopts, waits or refuses and has no create in it. Only `gateOrCreate` sets `needsWallet`,
|
|
872
|
+
* and it is reached only when NO live row answers to the name this deploy derives. So this is not a
|
|
873
|
+
* prediction about the row in hand: it is a property of the name route, and it was executed rather
|
|
874
|
+
* than reasoned — 180 second `runDeploy`s over a live row under that name (status × stamp × sibling
|
|
875
|
+
* count × address readable/blank × journal present/absent) created nothing, split across `ok` binds
|
|
876
|
+
* and three different refusal codes, and so did every payload where this row was dead or under
|
|
877
|
+
* another name while a live row still answered to the derived one.
|
|
878
|
+
*
|
|
879
|
+
* WHICH of those answers a re-run gives is exactly what this builder cannot know, so it is not
|
|
880
|
+
* stated. The create count is the part that spends money, and it is the part that holds.
|
|
881
|
+
*
|
|
882
|
+
* `carriesTheName` is the read: this row's own name is the one a re-run looks up. Without a row
|
|
883
|
+
* (the poll's last read returned none) the invariant still holds — it is about the name, not about
|
|
884
|
+
* this row — so the clause that asserts something about the row is simply dropped.
|
|
885
|
+
*
|
|
886
|
+
* Both halves are scoped to THIS strategy, and that scope is the whole of what the boundary
|
|
887
|
+
* covers. The command is package-wide: `deploy` runs every sleeve, and a sleeve with no live row
|
|
888
|
+
* under its own derived name gets a wallet — correct behaviour, and it means a sentence whose
|
|
889
|
+
* subject is the command is false on any multi-sleeve package. Executed on a two-sleeve package
|
|
890
|
+
* (`main` 0.6 / `scalp` 0.4, budget 250) whose `main` sleeve renders this very sentence: re-running
|
|
891
|
+
* exactly the command it names creates `spider-scalp` at `initialBudget: 100` beside a live sibling
|
|
892
|
+
* row, and again when that row is dead or absent (only an ours-stamped sibling refuses) — while
|
|
893
|
+
* `spider-main` is bound, not created, on all four. So the promise is stated per strategy: this
|
|
894
|
+
* strategy is bound or stopped, and no second wallet appears for it.
|
|
895
|
+
*/
|
|
896
|
+
function noSecondWalletWhileNamedLive(resume, carriesTheName) {
|
|
897
|
+
const lead = carriesTheName
|
|
898
|
+
? `This strategy carries the name a re-run looks up, and while a live strategy still answers to ` +
|
|
899
|
+
`that name, `
|
|
900
|
+
: `While a live strategy answers to the name a re-run looks up, `;
|
|
901
|
+
return (`${lead}re-running ${resume} binds this strategy to what it finds there or stops with a code — ` +
|
|
902
|
+
`never creating a second wallet for it.`);
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* The same invariant read from the other side, for a row the name route cannot reach.
|
|
906
|
+
*
|
|
907
|
+
* The unnamed create retry sends no `strategyName`, so the backend names that wallet and the name
|
|
908
|
+
* this package derives will never hit it. The protection above is about that name, and this row is
|
|
909
|
+
* not under it — executed: names differ, journal absent, and the stamp missing from the read
|
|
910
|
+
* (starvation; both create calls send `skillName: pkg.id`, `:3252` and `:3294`, so a stampless read
|
|
911
|
+
* is a producer fault rather than a coin flip) answers `reconcile: ok` with ONE create, a second
|
|
912
|
+
* funded wallet standing beside this live row. Six such rows executed, across the statuses and
|
|
913
|
+
* addresses this builder can render.
|
|
914
|
+
*
|
|
915
|
+
* Stated as what it CAN do, because whether it does turns on the journal — a file on disk, read by
|
|
916
|
+
* the next process, which this render path cannot consult. That is what the triage step is for.
|
|
917
|
+
*
|
|
918
|
+
* The protection it quotes is scoped per strategy for the same reason the sentence above is: the
|
|
919
|
+
* command covers every sleeve in the package, so "a re-run funds no second wallet" as a statement
|
|
920
|
+
* about the COMMAND is false on any multi-sleeve package (executed — see
|
|
921
|
+
* {@link noSecondWalletWhileNamedLive}).
|
|
922
|
+
*/
|
|
923
|
+
function secondWalletRiskWhenNameRouteClosed(resume) {
|
|
924
|
+
return (`A re-run funds no second wallet for a strategy while a live strategy answers to the name it ` +
|
|
925
|
+
`looks up — this strategy is not under that name, so that does not cover it: re-running ` +
|
|
926
|
+
`${resume} can fund a SECOND wallet beside this live one.`);
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Facts that change what a re-run does — named, with no outcome attached to any of them, and with
|
|
930
|
+
* no claim that naming them closes the question.
|
|
931
|
+
*
|
|
932
|
+
* Each is a fact about rows this process did not read, or about a field it read as absent. Four
|
|
933
|
+
* rounds attached outcomes to them and each round's enumeration was falsified by the payload beside
|
|
934
|
+
* the one it was written from, including along these last two axes, which the stated conditions
|
|
935
|
+
* never named at all. Naming the facts is what this builder can do; naming their outcomes is not.
|
|
936
|
+
*
|
|
937
|
+
* A fifth round then attached a COUNT to them ("Four facts decide the answer"), which is a closure
|
|
938
|
+
* claim and is false on the names-differ arms: the create key journaled on this box decides those,
|
|
939
|
+
* and this builder takes no journal argument, so it renders the same bytes either way. Executed —
|
|
940
|
+
* identical row, byte-identical detail: journal absent → one create, journal present → none. The
|
|
941
|
+
* journal is not listed here for exactly that reason (a fact this render cannot read is not
|
|
942
|
+
* context, it is a guess); what is dropped instead is the pretence that the list is complete.
|
|
943
|
+
*/
|
|
944
|
+
const FACTS_THAT_DECIDE = `Facts that change the answer, none of them settled by this read: whether another live strategy ` +
|
|
945
|
+
`answers to that name, whether one under it carries a different package's stamp, whether this ` +
|
|
946
|
+
`strategy has died, and whether its wallet address has become readable.`;
|
|
947
|
+
/**
|
|
948
|
+
* The system invariant behind every wallet sentence on this surface, and who resolves a breach.
|
|
949
|
+
*
|
|
950
|
+
* One wallet per strategy; two is the failure mode. That makes a duplicate recoverable rather than
|
|
951
|
+
* catastrophic — both wallets are ordinary rows in `strategy_list` — but only for a reader who is
|
|
952
|
+
* told what the duplicate looks like and that choosing between the two is not this deploy's call.
|
|
953
|
+
* Closing is the destructive act, so an agent picking the wrong row closes a funded wallet; that is
|
|
954
|
+
* why this stands immediately before the never-close line and states no outcome of its own.
|
|
955
|
+
*
|
|
956
|
+
* The invariant and whose call a breach is hold on every payload. Where to LOOK does not: on the
|
|
957
|
+
* degraded read that returned no row, pointing at `strategy_list` for the duplicate contradicts the
|
|
958
|
+
* lead this same detail opens with, and an agent that triages, sees one row, and reads that as "no
|
|
959
|
+
* duplicate" re-runs — funding a wallet at the full budget. So that clause renders only where a row
|
|
960
|
+
* was actually read.
|
|
961
|
+
*/
|
|
962
|
+
function oneWalletPerStrategy(rowWasRead) {
|
|
963
|
+
// The locator is dropped where the read this detail was built from returned no row. Saying "two
|
|
964
|
+
// wallets show as two rows in `strategy_list`" beside a lead that just reported no row there
|
|
965
|
+
// reads as "the list is authoritative and it shows one" — an agent that triages, sees nothing,
|
|
966
|
+
// and concludes there is no duplicate re-runs, and that re-run funds a wallet at the full budget.
|
|
967
|
+
// The invariant and whose call it is hold on every payload; only the where-to-look does not.
|
|
968
|
+
const whoDecides = rowWasRead
|
|
969
|
+
? `Two wallets show as two rows in \`strategy_list\`, and which one to keep is the user's call.`
|
|
970
|
+
: `Which one to keep is the user's call.`;
|
|
971
|
+
return `One wallet per strategy is the invariant, and two is the failure mode. ${whoDecides}`;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* The one certain next step, on every arm, and the one act that must never follow.
|
|
975
|
+
*
|
|
976
|
+
* Read-only triage is the only instruction here that is true regardless of what the next read
|
|
977
|
+
* finds, and close/recreate is the only genuinely destructive act available on this surface — it
|
|
978
|
+
* throws away a wallet that may already be funded and live.
|
|
979
|
+
*
|
|
980
|
+
* `strategy_list` is named alone. `openclaw senpi status` used to stand beside it as an
|
|
981
|
+
* alternative "to establish those", and it establishes none of them: it renders
|
|
982
|
+
* `RuntimeHealthStatus` for runtimes installed HERE — runtime id, health, log level, scanners, DSL,
|
|
983
|
+
* actions — and reads no backend strategy row, so it carries no name, no status, no package stamp
|
|
984
|
+
* and no wallet address. Executed through the CLI action: on this payload the install step never
|
|
985
|
+
* ran, so the gateway collects no handles and the whole output is `No running runtime health
|
|
986
|
+
* status.`; on the richest payload it CAN render, every line is about a local runtime. An agent
|
|
987
|
+
* that took the cheaper option would have learned nothing and could count the mandatory triage step
|
|
988
|
+
* as discharged.
|
|
989
|
+
*/
|
|
990
|
+
function triageThenNeverClose(rowWasRead) {
|
|
991
|
+
return (`Read-only triage first — \`strategy_list\` — to establish those before re-running anywhere. ` +
|
|
992
|
+
`${oneWalletPerStrategy(rowWasRead)} NEVER close or recreate to "start clean".`);
|
|
993
|
+
}
|
|
994
|
+
/** The name this row actually carries versus the one this deploy asked for, when they differ. */
|
|
995
|
+
function polledNameMismatchClause(wantedName, live) {
|
|
996
|
+
const wanted = wantedName.trim();
|
|
997
|
+
const has = (live.strategyName ?? "").trim();
|
|
998
|
+
const asked = wanted ? `the "${wanted}" this deploy asked for` : "any name this deploy can look it up by";
|
|
999
|
+
return has ? `, under the name "${has}", not ${asked}` : `, under no readable name of its own, not ${asked}`;
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* The bounded wait ran out. What this renders is what it READ, plus the one thing it can promise.
|
|
1003
|
+
*
|
|
1004
|
+
* The poll's success test is ACTIVE **and** a readable wallet address; everything else that is not
|
|
1005
|
+
* ACTIVE is intercepted before the deadline (dead → `failed`, neither-ACTIVE-nor-initializing →
|
|
1006
|
+
* `failed`), and reconcile refuses those statuses one screen earlier. So what lands here is either
|
|
1007
|
+
* a row still on its way to ACTIVE, or a row that already READS ACTIVE whose address could not be
|
|
1008
|
+
* read — plus the degenerate reads (no row at all, unparseable `createdAt`), which keep the
|
|
1009
|
+
* initializing branch's wording because they establish nothing to say instead. The two payloads
|
|
1010
|
+
* differ in what is MISSING, and that is the one place they diverge here: ACTIVE for the first, the
|
|
1011
|
+
* wallet address for the second.
|
|
1012
|
+
*
|
|
1013
|
+
* It states no outcome for a re-run beyond the money invariant, and the reason is structural. This
|
|
1014
|
+
* builder holds ONE row, read by id. How many live rows answer to the name a re-run looks up, what
|
|
1015
|
+
* stamps THOSE rows carry, whether a create key journaled on this box still resolves, and whether
|
|
1016
|
+
* the address becomes readable are facts about things it did not read and must not read here — a
|
|
1017
|
+
* detail that can throw is a detail that sometimes does not render. Four rounds enumerated those
|
|
1018
|
+
* outcomes anyway: the enumeration grew to 16 predicted branches across the four arms and was still
|
|
1019
|
+
* false on six executed payloads, twice along axes its stated conditions never named. Predicting
|
|
1020
|
+
* from a read that cannot see the deciding facts does not converge, so this renders none of it.
|
|
1021
|
+
*
|
|
1022
|
+
* What survives is {@link noSecondWalletWhileNamedLive} / {@link secondWalletRiskWhenNameRouteClosed}
|
|
1023
|
+
* — one class statement about money per arm, scoped to this strategy and executed at its boundary
|
|
1024
|
+
* rather than reasoned — then {@link FACTS_THAT_DECIDE}, which names facts WITHOUT attaching
|
|
1025
|
+
* outcomes to them or claiming to have listed them all, and {@link triageThenNeverClose}, which
|
|
1026
|
+
* carries the only next step that is correct whatever the next read finds, the shape of the failure
|
|
1027
|
+
* this is all about ({@link oneWalletPerStrategy}), and the one act that must never follow. Every
|
|
1028
|
+
* arm ends on those two, because the tail is what a reader in a hurry acts on.
|
|
1029
|
+
*/
|
|
1030
|
+
export function buildCreatePendingDetail(pkg, budget, waitedSec, live, nowMs, wantedName) {
|
|
1031
|
+
const resume = `\`${resumeCommand(pkg.dir, budget)}\``;
|
|
1032
|
+
const byName = !live || reconcileCanMatchByName(wantedName, live);
|
|
1033
|
+
const nameClause = live && !byName ? polledNameMismatchClause(wantedName, live) : "";
|
|
1034
|
+
const money = byName
|
|
1035
|
+
? noSecondWalletWhileNamedLive(resume, !!live)
|
|
1036
|
+
: secondWalletRiskWhenNameRouteClosed(resume);
|
|
1037
|
+
const tail = `${money} ${FACTS_THAT_DECIDE} ${triageThenNeverClose(!!live)}`;
|
|
1038
|
+
if (live && live.status.toUpperCase() === "ACTIVE") {
|
|
1039
|
+
return (`after ${waitedSec}s this strategy still has no readable wallet address — ` +
|
|
1040
|
+
`${polledRecordRead(live, nowMs)}${nameClause}: the status this wait was for is already ` +
|
|
1041
|
+
`reached, the wallet address is the field still missing, and an ACTIVE strategy whose ` +
|
|
1042
|
+
`address cannot be read may be funded and live. ${tail}`);
|
|
1043
|
+
}
|
|
1044
|
+
return (`still waiting after ${waitedSec}s for this strategy to read ACTIVE with a wallet address — ` +
|
|
1045
|
+
`${polledRecordRead(live, nowMs)}${nameClause}. ACTIVE is the state this wait is still ` +
|
|
1046
|
+
`missing. ${tail}`);
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Live wallets carry this package's stamp, and none of them answered for this instance.
|
|
1050
|
+
*
|
|
1051
|
+
* Rendered from the reconcile read alone — no additional network calls, because a gate that can
|
|
1052
|
+
* fail to render is a gate that sometimes does not fire. Every column is quoted from that read and
|
|
1053
|
+
* says so when it is unreadable: an unread funded amount is the one number a reader would otherwise
|
|
1054
|
+
* take for an empty wallet. The name column is there because a missing/differing name is WHY the
|
|
1055
|
+
* match failed, and it is the fact that tells a reader whether this is old-tooling residue or a
|
|
1056
|
+
* backend that renamed what deploy created.
|
|
1057
|
+
*
|
|
1058
|
+
* The destructive route is named LAST and behind the read-only one, with its real blast radius on
|
|
1059
|
+
* the same line: a refusal that names only a teardown gets followed.
|
|
1060
|
+
*
|
|
1061
|
+
* The consequence sentence is hedged for the same reason the columns are: this read is metadata,
|
|
1062
|
+
* so "left funded, in position and supervised by nothing" would assert two things nothing here
|
|
1063
|
+
* looked at (no position read, no `registryFindById`/`runtimeIsLive` call) — and the second can be
|
|
1064
|
+
* plainly false, since install rebinds only the registry row for `<id>-<instance>` and a wallet
|
|
1065
|
+
* carrying a different runtime id keeps its runtime. What is unread is named as unread; that is
|
|
1066
|
+
* the argument for stopping, not a weaker version of it.
|
|
1067
|
+
*/
|
|
1068
|
+
export function buildBindingUnknownRefusal(pkg, instance, wantedName, candidates, budget) {
|
|
1069
|
+
const rows = candidates
|
|
1070
|
+
.map((s) => {
|
|
1071
|
+
const wallet = s.strategyWalletAddress?.trim() || "(wallet address unreadable)";
|
|
1072
|
+
const funded = s.totalFunded === undefined ? "funded amount unreadable" : usd(s.totalFunded);
|
|
1073
|
+
const created = s.createdAt ? `created ${s.createdAt.slice(0, 10)}` : "creation date unreadable";
|
|
1074
|
+
const named = s.strategyName?.trim() ? `named "${s.strategyName.trim()}"` : "no name reported";
|
|
1075
|
+
return ` ${wallet} ${funded} ${s.status} ${created} ${named}`;
|
|
1076
|
+
})
|
|
1077
|
+
.join("\n");
|
|
1078
|
+
return (`[E_INSTANCE_BINDING_UNKNOWN] ${pkg.id}: ${candidates.length} live wallet(s) carry this package's ` +
|
|
1079
|
+
`stamp, and none of them answers for instance "${instance}".\n\n${rows}\n\n` +
|
|
1080
|
+
`Nothing was created and no money moved. Deploying now would fund a NEW wallet beside the ` +
|
|
1081
|
+
`one(s) above and rebind instance "${instance}"'s runtime id onto it. This read is metadata ` +
|
|
1082
|
+
`only: whether the wallet(s) above hold open positions, and whether any runtime is still ` +
|
|
1083
|
+
`watching them, was NOT read here — which is exactly why deploying past it is a bet rather ` +
|
|
1084
|
+
`than a decision.\n\n` +
|
|
1085
|
+
`Read what each wallet is doing before deciding anything:\n` +
|
|
1086
|
+
` \`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`\n\n` +
|
|
1087
|
+
`Deploy binds an existing strategy two ways — by the name instance "${instance}" answers to ` +
|
|
1088
|
+
`("${wantedName}"), and by a create key journaled on this box — and neither reached the ` +
|
|
1089
|
+
`wallet(s) above. If one of them IS this instance under a name deploy does not know, this verb ` +
|
|
1090
|
+
`has no way to bind to it, so re-running changes nothing. What each wallet is and whether it is ` +
|
|
1091
|
+
`still wanted is the USER's call, made from the read above — deploy will not pick one. Only if ` +
|
|
1092
|
+
`the USER confirms these wallets are unwanted, close them: ` +
|
|
1093
|
+
`\`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\` returns their funds and tears down ` +
|
|
1094
|
+
`the WHOLE ${pkg.id} package, every sleeve and runtime in it. Then re-run ` +
|
|
1095
|
+
`\`${resumeCommand(pkg.dir, budget)}\`.`);
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Every live strategy under the name this instance answers to is stamped for a DIFFERENT package.
|
|
1099
|
+
*
|
|
1100
|
+
* The counterpart to {@link buildBindingUnknownRefusal} on the other match route, and the harder of
|
|
1101
|
+
* the two to word: nothing here is the operator's to close. The wallets named belong to another
|
|
1102
|
+
* package — its positions, its balance, possibly its running runtime — so the only routes offered
|
|
1103
|
+
* are edits to THIS package and reads. Re-running is explicitly not one of them: the name is
|
|
1104
|
+
* derived, so it comes out identical every time, which is exactly why the collision is structural
|
|
1105
|
+
* rather than transient.
|
|
1106
|
+
*
|
|
1107
|
+
* Neither is "set `id:` back to the stamp and re-run", which this refusal used to offer. The name
|
|
1108
|
+
* is derived FROM the id ({@link walletName}: `sanitizeStrategyName(id)`, or `<id>-<instance>` for
|
|
1109
|
+
* a multi-sleeve package), and a loadable id is sanitize-stable by construction
|
|
1110
|
+
* (`assertIdentityRoundTrips`), so changing the id to the stamp changes the derived name by exactly
|
|
1111
|
+
* as much — the run after the edit looks for a name the matched wallet does not carry, finds
|
|
1112
|
+
* nothing, and refuses again from the gate instead, whose remedy is the whole-package `close.py`
|
|
1113
|
+
* this refusal's last line forbids. Only the id AND the sleeve layout restored together re-derive
|
|
1114
|
+
* the matched name, which is a restructure of this package, not a field edit — so the route ends at
|
|
1115
|
+
* the read and hands the decision to the user.
|
|
1116
|
+
*/
|
|
1117
|
+
export function buildForeignPackageRefusal(pkg, instance, wantedName, candidates, budget) {
|
|
1118
|
+
const rows = candidates
|
|
1119
|
+
.map((s) => {
|
|
1120
|
+
// Same absence rules as every other rendered candidate list: an unread field says so rather
|
|
1121
|
+
// than rendering as a blank or a `$0.00` the reader would act on.
|
|
1122
|
+
const wallet = s.strategyWalletAddress?.trim() || "(wallet address unreadable)";
|
|
1123
|
+
const funded = s.totalFunded === undefined ? "funded amount unreadable" : usd(s.totalFunded);
|
|
1124
|
+
const claimant = s.skillName?.trim() || "(stamp unreadable)";
|
|
1125
|
+
return ` ${wallet} ${funded} ${s.status} stamped "${claimant}"`;
|
|
1126
|
+
})
|
|
1127
|
+
.join("\n");
|
|
1128
|
+
// Deduped, because the sentence names WHOSE they are and repeating one id per row reads as more
|
|
1129
|
+
// packages than were found.
|
|
1130
|
+
const stamps = [...new Set(candidates.map((s) => s.skillName?.trim()).filter((v) => !!v))];
|
|
1131
|
+
const whose = stamps.length > 0 ? stamps.map((s) => `"${s}"`).join(", ") : "another package";
|
|
1132
|
+
// Computed from the package as loaded, not templated: a flat package's name IS its id, and
|
|
1133
|
+
// telling its author to rename an instance it does not have is an edit they cannot make.
|
|
1134
|
+
const multi = pkg.instances.length > 1;
|
|
1135
|
+
const derivedFrom = multi
|
|
1136
|
+
? `package id "${pkg.id}" + instance name "${instance}"`
|
|
1137
|
+
: `package id "${pkg.id}"`;
|
|
1138
|
+
const renameWhat = multi ? "the package `id:` or this instance's `name:`" : "the package `id:`";
|
|
1139
|
+
// A stamp is BACKEND text, not a package id this box loaded, so it is not shell-safe by
|
|
1140
|
+
// construction the way `pkg.id` is (`assertIdentityRoundTrips` guarantees that one). A stamp that
|
|
1141
|
+
// is not a legal package id is named in prose instead of pasted into a command — an emitted
|
|
1142
|
+
// command has to be runnable, and one carrying a quote is worse than none.
|
|
1143
|
+
const reads = stamps
|
|
1144
|
+
.map((s) => sanitizeStrategyName(s) === s
|
|
1145
|
+
? ` \`python3 senpi-strategy-ops/scripts/status.py '${s}'\``
|
|
1146
|
+
: ` (no command for the stamp ${JSON.stringify(s)} — it is not a usable package id; look ` +
|
|
1147
|
+
`the wallet up by address with \`openclaw senpi status\` / \`strategy_list\`)`)
|
|
1148
|
+
.join("\n");
|
|
1149
|
+
return (`[E_WALLET_OWNED_BY_OTHER_PACKAGE] ${pkg.id}: instance "${instance}" answers to the strategy ` +
|
|
1150
|
+
`name "${wantedName}", and every live strategy under that name carries another package's ` +
|
|
1151
|
+
`attribution stamp (${whose}).\n\n${rows}\n\n` +
|
|
1152
|
+
`Nothing was created and no money moved. Deploy binds an existing strategy BY NAME, so ` +
|
|
1153
|
+
`adopting one of the above would have pointed this package's runtime at another package's ` +
|
|
1154
|
+
`funded wallet — its balance and its open positions — and, if something is already watching ` +
|
|
1155
|
+
`that wallet, left two exit engines on one margin account. This read is metadata only: what ` +
|
|
1156
|
+
`those wallets hold, and whether anything is watching them, was NOT read here.\n\n` +
|
|
1157
|
+
`Re-running changes nothing — "${wantedName}" is derived from ${derivedFrom}, so it comes out ` +
|
|
1158
|
+
`the same every time. Two ways forward, neither of them a money move:\n\n` +
|
|
1159
|
+
` • The wallet(s) above are not this package's. Two packages collide on one derived name: ` +
|
|
1160
|
+
`give this one a name of its own by editing ${renameWhat} in ` +
|
|
1161
|
+
`${join(pkg.dir, "strategy.yaml")}, then re-run \`${resumeCommand(pkg.dir, budget)}\` — deploy ` +
|
|
1162
|
+
`will create this package's own wallet.\n` +
|
|
1163
|
+
` • One of them IS this package's, deployed under a package id it no longer has. Editing ` +
|
|
1164
|
+
`\`id:\` back to the stamp does NOT reach it: "${wantedName}" is derived from ${derivedFrom}, ` +
|
|
1165
|
+
`so that edit moves the name deploy looks for along WITH the id — the next run stops looking ` +
|
|
1166
|
+
`for "${wantedName}" at all and refuses again, having bound nothing. What produced ` +
|
|
1167
|
+
`"${wantedName}" is the id and the sleeve layout together, so restoring both as they were is ` +
|
|
1168
|
+
`what would bind it by name; whether this package should go back to that shape is the USER's ` +
|
|
1169
|
+
`call, and a metadata read cannot settle it. Read what each wallet is doing first:\n` +
|
|
1170
|
+
`${reads}\n\n` +
|
|
1171
|
+
`Do NOT close a wallet stamped for another package to clear this — that is a different ` +
|
|
1172
|
+
`strategy's funds and a different runtime's positions. Whose these are is the USER's call.`);
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Refusal for a package whose instances ship no DSL exit block. Rendered BEFORE any wallet exists:
|
|
1176
|
+
* funding a strategy that cannot stop itself out is the "funded-but-no-DSL" hole, and the skills-side
|
|
1177
|
+
* validator has always refused it.
|
|
1178
|
+
*/
|
|
1179
|
+
export function buildNoDslExitRefusal(instances, pkg) {
|
|
1180
|
+
return (`[INVALID_REQUEST] Instance(s) ${instances.map((inst) => inst.name).join(", ")} declare no DSL ` +
|
|
1181
|
+
`exit block (\`exit.dsl_preset\`, or \`exit.engine: dsl\`) — every position they open would run ` +
|
|
1182
|
+
`with no stop loss and no trailing floor. Nothing was created. Add an exit block to the instance ` +
|
|
1183
|
+
`runtime.yaml, then re-check read-only with either route:\n` +
|
|
1184
|
+
// One `senpi validate` per OFFENDING instance directory, computed from the instance rather than
|
|
1185
|
+
// templated: the offence is per-recipe, and so is the proof that clears it.
|
|
1186
|
+
`${recheckBlock(instances.map((inst) => inst.runtimeYamlDir), deployPyValidateCommand(pkg.dir))}\n` +
|
|
1187
|
+
`${PROOF_ROUTE_NOTE} Then re-run.`);
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* A recipe that does not bind the wallet its manifest promises, refused before a wallet exists.
|
|
1191
|
+
*
|
|
1192
|
+
* Names the two addresses side by side because the failure is precisely that they are two: the
|
|
1193
|
+
* money this run would move, and the account the strategy would trade. It states the edit rather
|
|
1194
|
+
* than a command, in the {@link buildInstanceDirCollisionRefusal} spirit — the fix is one line in a
|
|
1195
|
+
* file the reader already has open, and no command can make it for them.
|
|
1196
|
+
*/
|
|
1197
|
+
export function buildUnboundWalletRefusal(faults, pkg) {
|
|
1198
|
+
const rows = faults.map((fault) => ` - "${fault.instance.name}" (${fault.instance.runtimeYamlPath}) binds ` +
|
|
1199
|
+
`${fault.declared === undefined ? "no strategy.wallet at all" : `\`${fault.declared}\``}, ` +
|
|
1200
|
+
`but strategy.yaml declares \`wallet_env: ${fault.instance.walletEnv}\` for it`);
|
|
1201
|
+
return (`[INVALID_REQUEST] ${faults.length} instance(s) do not bind the wallet this package says they ` +
|
|
1202
|
+
`bind:\n${rows.join("\n")}\n\n` +
|
|
1203
|
+
`Why: deploy CREATES and FUNDS a wallet for each instance and passes it in by substituting ` +
|
|
1204
|
+
`\`\${<wallet_env>}\` into the recipe. A recipe that does not carry that token gets nothing ` +
|
|
1205
|
+
`substituted — silently, because the unresolved-placeholder check only looks for \`\${...}\` ` +
|
|
1206
|
+
`left over, and a hardcoded address leaves none. The deploy would fund a new wallet from your ` +
|
|
1207
|
+
`budget and then install the strategy, actions and exit engine included, against a DIFFERENT ` +
|
|
1208
|
+
`account: the funded wallet would sit with nothing watching it while the strategy traded ` +
|
|
1209
|
+
`elsewhere, and the install step would name the recipe's address, so the report would read ` +
|
|
1210
|
+
`clean.\n\n` +
|
|
1211
|
+
`Nothing was created — this gate reads the package's own files and stopped before any live ` +
|
|
1212
|
+
`read.\n\n` +
|
|
1213
|
+
`Fix — in each recipe above set \`strategy.wallet\` to the token strategy.yaml declares for ` +
|
|
1214
|
+
`that instance (for example \`wallet: "${faults[0].expected}"\` in ` +
|
|
1215
|
+
`${faults[0].instance.runtimeYamlPath}). Deploy substitutes the wallet it creates; a literal ` +
|
|
1216
|
+
`address there is never what you want on this path. Then re-check read-only with either ` +
|
|
1217
|
+
`route:\n` +
|
|
1218
|
+
`${recheckBlock(faults.map((fault) => fault.instance.runtimeYamlDir), deployPyValidateCommand(pkg.dir))}\n` +
|
|
1219
|
+
`${PROOF_ROUTE_NOTE} Then re-run.`);
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* A package id carrying capitals, refused before any wallet exists to carry the wrong stamp.
|
|
1223
|
+
*
|
|
1224
|
+
* Deploy's copy of `register.ts`'s gate — same rule, same fix (built from the one producer in
|
|
1225
|
+
* `package.ts`, so the edit an author reads is word-for-word identical), different standing
|
|
1226
|
+
* sentence: nothing was CREATED here, where the gateway's copy could only say nothing was started.
|
|
1227
|
+
*
|
|
1228
|
+
* The re-run command names the directory the fix LEAVES BEHIND, not the one that failed — this is
|
|
1229
|
+
* the only refusal in the verb whose own instruction can move the package.
|
|
1230
|
+
*/
|
|
1231
|
+
export function buildNonLowercaseIdRefusal(pkg, budget) {
|
|
1232
|
+
const fix = lowercasePackageIdFix(pkg.id, pkg.dir);
|
|
1233
|
+
return (`[INVALID_REQUEST] strategy.yaml id "${pkg.id}" must be lowercase. Nothing was created. ` +
|
|
1234
|
+
`Fix — ${fix.instruction}. Why: this verb writes the id into each wallet's \`skillName\` ` +
|
|
1235
|
+
`attribution stamp VERBATIM at create, and the backend stores what it is given ` +
|
|
1236
|
+
`case-normalized, so a mixed-case id is stamped under one spelling and looked up under ` +
|
|
1237
|
+
`another — that is how \`close.py '${pkg.id}'\` came to report "no OPEN strategies to close." ` +
|
|
1238
|
+
`over a live, funded, trading wallet. Every reader now folds case, so wallets already stamped ` +
|
|
1239
|
+
`in any case are still found and this package will ADOPT the ones it already owns rather than ` +
|
|
1240
|
+
`fund a second set beside them. Then re-run \`${resumeCommand(fix.dirAfterFix, budget)}\`.`);
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* The refusal for a manifest whose funding shares allocate more than the whole budget.
|
|
1244
|
+
*
|
|
1245
|
+
* States the arithmetic and nothing about the outcome: the sum, every sleeve's effective share, and
|
|
1246
|
+
* the amount the plan would have moved against the amount authorized. A reader can check each
|
|
1247
|
+
* figure against the file named, which is the point — the defect is a typo in one line of
|
|
1248
|
+
* `strategy.yaml`, and the fastest fix is seeing which share is wrong.
|
|
1249
|
+
*
|
|
1250
|
+
* The fix is the author's, and only they can make it: which sleeve should be smaller is a decision
|
|
1251
|
+
* about the strategy, not one this gate can compute. So it names the constraint (the shares are a
|
|
1252
|
+
* split of ONE budget) and the file, and offers no edit of its own — a hint naming a particular
|
|
1253
|
+
* share would be a guess presented as an instruction. Deliberately no teardown route either:
|
|
1254
|
+
* nothing was created, so there is nothing to tear down.
|
|
1255
|
+
*/
|
|
1256
|
+
export function buildShareOverrunRefusal(overrun, pkg, budget) {
|
|
1257
|
+
const rows = overrun.shares
|
|
1258
|
+
.map((row) => `${row.name} ${budgetArg(row.share)} (${usd(budget * row.share)})`)
|
|
1259
|
+
.join(", ");
|
|
1260
|
+
return (`[E_FUNDING_SHARES_OVER_ONE] The funding shares in ${join(pkg.dir, "strategy.yaml")} sum to ` +
|
|
1261
|
+
`${budgetArg(overrun.sum)}, not 1 — so this deploy would fund ` +
|
|
1262
|
+
`${usd(budget * overrun.sum)} against the ${usd(budget)} you authorized. ` +
|
|
1263
|
+
`Nothing was created and no money moved. Shares: ${rows}. ` +
|
|
1264
|
+
`Fix — a \`funding_share\` is that sleeve's slice of ONE budget, so the values must sum to 1 ` +
|
|
1265
|
+
`across the package (an undeclared share is an even split of what is left). Correct the ` +
|
|
1266
|
+
`share(s) in ${join(pkg.dir, "strategy.yaml")}, then re-run ` +
|
|
1267
|
+
`\`${resumeCommand(pkg.dir, budget)}\`. ` +
|
|
1268
|
+
`Why this is refused rather than clamped: the amounts a share produces are what each wallet ` +
|
|
1269
|
+
`TRADES, so scaling them down here would size every position off a number you never chose.`);
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* The install gate's refusal, rendered for deploy rather than for the gateway.
|
|
1273
|
+
*
|
|
1274
|
+
* Same findings, different standing sentence: the gateway's copy says a recipe cannot be installed,
|
|
1275
|
+
* which is all that is true there. Here the run had money to move and did not move it, and that is
|
|
1276
|
+
* the fact a reader needs first — so it is stated, and the re-check names `senpi validate` on the
|
|
1277
|
+
* instance directory rather than "run validate on the package" in the abstract.
|
|
1278
|
+
*
|
|
1279
|
+
* Deploy's refusal shape: bracketed code first (taxonomy.ts:16 reads it), then what/why/fix.
|
|
1280
|
+
*/
|
|
1281
|
+
export function buildInstallGateRefusal(gate, inst, pkg, budget) {
|
|
1282
|
+
const lines = gate.blocking.map((f) => ` • ${f.what}\n why: ${f.why}\n fix: ${f.fix}`);
|
|
1283
|
+
return (`[${gate.blocking[0].code}] "${inst.name}" cannot be installed — it would not trade.\n\n` +
|
|
1284
|
+
`${lines.join("\n\n")}\n\n` +
|
|
1285
|
+
`Nothing was created by this run — the check reads the package only, and it stopped before ` +
|
|
1286
|
+
`touching the backend.\n\n` +
|
|
1287
|
+
`Fix the recipe, then re-check with: ${validateCommand(inst.runtimeYamlDir)}\n` +
|
|
1288
|
+
// The same command `next` ends the report with, budget and all: a re-run line that differs
|
|
1289
|
+
// from it — a `<usd>` placeholder where the report has the real number — is a second, worse
|
|
1290
|
+
// command competing with the authoritative one.
|
|
1291
|
+
`Then re-run: ${resumeCommand(pkg.dir, budget)}`);
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* The gate itself could not run. NOT a verdict on the recipe — so it carries no taxonomy code and
|
|
1295
|
+
* names no offending field, exactly like the universe check's `unavailable` branch.
|
|
1296
|
+
*/
|
|
1297
|
+
export function buildInstallGateUnavailableDetail(inst, reason) {
|
|
1298
|
+
return (`The install gate could not run for "${inst.name}" (${reason}), so nothing is known about ` +
|
|
1299
|
+
`whether this recipe can trade. Nothing was created by this run. Re-run the deploy; if it ` +
|
|
1300
|
+
// The real directory, never a `<package dir>` placeholder: this line is the one command the
|
|
1301
|
+
// reader is given, and a command that has to be edited before it runs is a command an agent
|
|
1302
|
+
// pastes literally. The INSTANCE's directory, not the package root: the gate ran for one
|
|
1303
|
+
// sleeve, and on a multi-instance package the root is not a validate target at all — it
|
|
1304
|
+
// answers "this directory has no recipe of its own, pick an instance".
|
|
1305
|
+
`persists, run: ${validateCommand(inst.runtimeYamlDir)}`);
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* D-2 (revised 2026-08-05): a scanner-level `enabled` key is refused outright — the engine never
|
|
1309
|
+
* reads it (see {@link scannersDeclaringEnabled}), so accepting `enabled: false` would ship a
|
|
1310
|
+
* strategy that ticks and trades while its author believes it is switched off. Package-derivable,
|
|
1311
|
+
* so it is decided synchronously here rather than discovered later; observe keeps its own
|
|
1312
|
+
* all-disabled branch for the live-state case, which the runtime API can genuinely produce.
|
|
1313
|
+
*
|
|
1314
|
+
* Every offense is enumerated in ONE refusal, each as a delete-this-line instruction naming the
|
|
1315
|
+
* key, the scanner, the instance and the file: a reader (agent or human) fixes the whole package in
|
|
1316
|
+
* one pass instead of re-running to discover the next one.
|
|
1317
|
+
*/
|
|
1318
|
+
export function buildScannerEnabledUnsupportedRefusal(offenders, pkg) {
|
|
1319
|
+
return (`[INVALID_REQUEST] Unsupported \`enabled\` key on ${offenders.length} scanner entr` +
|
|
1320
|
+
`${offenders.length === 1 ? "y" : "ies"}. Nothing was created. Fix — delete these lines:\n` +
|
|
1321
|
+
offenders.map((entry) => ` - ${describeScannerEnabledOffense(entry)}`).join("\n") +
|
|
1322
|
+
`\nWhy: the runtime engine never reads a scanner-level \`enabled\` (the scanner schema passes ` +
|
|
1323
|
+
`it through untyped and registration comes from the strategy, not the scanner entry), so an ` +
|
|
1324
|
+
`\`enabled: false\` scanner registers ENABLED and ticks — the package would trade while you ` +
|
|
1325
|
+
`believe it is off. A scanner runs because the package declares it: to stop one, remove the ` +
|
|
1326
|
+
`scanner entry itself, or disable it after install through the runtime API (\`disableScanner\`). ` +
|
|
1327
|
+
`Then re-run: ${resumeCommand(pkg.dir)}`);
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* A create this job stopped waiting on at its per-call deadline. Deliberately NOT phrased as
|
|
1331
|
+
* "creation failed": the deadline bounds the WAIT, not the backend's work, so the wallet may come
|
|
1332
|
+
* into existence after this line is written. Naming both reconcile routes — and the one case
|
|
1333
|
+
* neither can see — is what lets the operator check rather than guess.
|
|
1334
|
+
*/
|
|
1335
|
+
export function buildCreateAbandonedDetail(args) {
|
|
1336
|
+
const { instance, strategyName, nameFallbackFired, fundAmount, message, pkg, budget } = args;
|
|
1337
|
+
const head = `wallet creation for instance "${instance}" was abandoned at its deadline, so its outcome is ` +
|
|
1338
|
+
`UNKNOWN — this is not "nothing happened": ${message ?? "no message"}. The backend may still ` +
|
|
1339
|
+
`complete the create and fund ${usd(fundAmount)} after this report. Nothing else was done for ` +
|
|
1340
|
+
`this instance, and nothing was rolled back.`;
|
|
1341
|
+
// Which create was abandoned is KNOWN, so neither branch hedges. A named create that lands is
|
|
1342
|
+
// findable by name; the unnamed retry is findable by nothing — it lands under a backend-assigned
|
|
1343
|
+
// name and this box never received the strategyId to journal. In that case re-running FIRST is
|
|
1344
|
+
// the money-losing move, so the check leads.
|
|
1345
|
+
const tail = nameFallbackFired
|
|
1346
|
+
? ` The name "${strategyName}" had already been rejected, so this second create asked for NO ` +
|
|
1347
|
+
`name: if it landed, the wallet carries a backend-assigned name and no create key ever ` +
|
|
1348
|
+
`reached this box — a re-run can find it by neither route and would fund a SECOND wallet ` +
|
|
1349
|
+
`beside it. Check before you re-run: list your strategies (MCP \`strategy_list\`) and look ` +
|
|
1350
|
+
`for a CUSTOM strategy created just now, funded about ${usd(fundAmount)}. If it is there, ` +
|
|
1351
|
+
`that is this instance's wallet — keep it, or close it with ` +
|
|
1352
|
+
`\`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\` before re-running. If it is not ` +
|
|
1353
|
+
`there, the create did not land: re-run \`${resumeCommand(pkg.dir, budget)}\`.`
|
|
1354
|
+
: ` The create asked for the name "${strategyName}", so if it landed the wallet carries that ` +
|
|
1355
|
+
`name and reconcile adopts it instead of funding a second one: re-run ` +
|
|
1356
|
+
`\`${resumeCommand(pkg.dir, budget)}\`. If that re-run creates a fresh wallet and your ` +
|
|
1357
|
+
`accessible balance looks short afterwards, check the wallet list (MCP \`strategy_list\`) ` +
|
|
1358
|
+
`and close the orphan: \`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\`.`;
|
|
1359
|
+
return head + tail;
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* The name-rejection retry that was NOT issued because the deadline stop landed between the two
|
|
1363
|
+
* creates.
|
|
1364
|
+
*
|
|
1365
|
+
* Not a variant of {@link buildCreateAbandonedDetail}: nothing was abandoned mid-call here. One
|
|
1366
|
+
* create was issued and the backend answered it; the second one — the money-moving call this run
|
|
1367
|
+
* would otherwise have made with no step boundary in front of it — never went out. So the line says
|
|
1368
|
+
* what was attempted, what was not, and leaves the rejected create's own residue to the same check
|
|
1369
|
+
* every rejection carries: reconcile re-reads the live list before it creates anything.
|
|
1370
|
+
*/
|
|
1371
|
+
export function buildCreateNameRetryNotIssuedDetail(args) {
|
|
1372
|
+
const { instance, strategyName, fundAmount, message, pkg, budget } = args;
|
|
1373
|
+
return (`wallet creation for instance "${instance}" stopped at the deploy deadline. The backend rejected ` +
|
|
1374
|
+
`the name "${strategyName}" (${message ?? "no message"}), and the unnamed retry that normally ` +
|
|
1375
|
+
`follows was NOT issued: a create started now would take a fresh 90s deadline of its own, ` +
|
|
1376
|
+
`outliving the grace period that hands the deploy slot to the next run — this job could still ` +
|
|
1377
|
+
`be waiting on a create funding ${usd(fundAmount)} while a new deploy runs its own preflight ` +
|
|
1378
|
+
`against the same balance. One create was issued for this instance and the backend rejected it; ` +
|
|
1379
|
+
`no second wallet was asked for, and nothing was rolled back. Re-run ` +
|
|
1380
|
+
`\`${resumeCommand(pkg.dir, budget)}\` — reconcile re-reads your live strategies before it ` +
|
|
1381
|
+
`creates anything, and a wallet that landed under the name "${strategyName}" despite the ` +
|
|
1382
|
+
`rejection is exactly what it looks for.`);
|
|
1383
|
+
}
|
|
1384
|
+
export function buildUnobservedDetail(waitSec, runtimeId) {
|
|
1385
|
+
return (`no tick observed within ${waitSec}s — scanners may tick on a longer interval. ` +
|
|
1386
|
+
`Check in a few minutes: \`openclaw senpi scanner -r ${runtimeId}\``);
|
|
1387
|
+
}
|
|
1388
|
+
//# sourceMappingURL=messages.js.map
|