@senpi/runtime 3.0.71 → 3.0.73

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