@senpi/runtime 3.0.59 → 3.0.63

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 (151) hide show
  1. package/README.md +73 -2
  2. package/dist/actions/decision-engine.d.ts +0 -2
  3. package/dist/actions/decision-engine.d.ts.map +1 -1
  4. package/dist/actions/decision-engine.js +1 -79
  5. package/dist/actions/decision-engine.js.map +1 -1
  6. package/dist/cli/deploy-render.d.ts +91 -0
  7. package/dist/cli/deploy-render.d.ts.map +1 -0
  8. package/dist/cli/deploy-render.js +216 -0
  9. package/dist/cli/deploy-render.js.map +1 -0
  10. package/dist/cli/guide-commands.d.ts.map +1 -1
  11. package/dist/cli/guide-commands.js +78 -6
  12. package/dist/cli/guide-commands.js.map +1 -1
  13. package/dist/cli/senpi-commands.d.ts +6 -1
  14. package/dist/cli/senpi-commands.d.ts.map +1 -1
  15. package/dist/cli/senpi-commands.js +244 -8
  16. package/dist/cli/senpi-commands.js.map +1 -1
  17. package/dist/cli/validate-command.d.ts.map +1 -1
  18. package/dist/cli/validate-command.js +32 -1
  19. package/dist/cli/validate-command.js.map +1 -1
  20. package/dist/deploy/deadlines.d.ts +47 -0
  21. package/dist/deploy/deadlines.d.ts.map +1 -0
  22. package/dist/deploy/deadlines.js +56 -0
  23. package/dist/deploy/deadlines.js.map +1 -0
  24. package/dist/deploy/error-text.d.ts +25 -0
  25. package/dist/deploy/error-text.d.ts.map +1 -0
  26. package/dist/deploy/error-text.js +42 -0
  27. package/dist/deploy/error-text.js.map +1 -0
  28. package/dist/deploy/funding.d.ts +87 -0
  29. package/dist/deploy/funding.d.ts.map +1 -0
  30. package/dist/deploy/funding.js +229 -0
  31. package/dist/deploy/funding.js.map +1 -0
  32. package/dist/deploy/install-gate-runner.d.ts +12 -0
  33. package/dist/deploy/install-gate-runner.d.ts.map +1 -0
  34. package/dist/deploy/install-gate-runner.js +17 -0
  35. package/dist/deploy/install-gate-runner.js.map +1 -0
  36. package/dist/deploy/job.d.ts +199 -0
  37. package/dist/deploy/job.d.ts.map +1 -0
  38. package/dist/deploy/job.js +463 -0
  39. package/dist/deploy/job.js.map +1 -0
  40. package/dist/deploy/min-budget.d.ts +68 -0
  41. package/dist/deploy/min-budget.d.ts.map +1 -0
  42. package/dist/deploy/min-budget.js +297 -0
  43. package/dist/deploy/min-budget.js.map +1 -0
  44. package/dist/deploy/orchestrator.d.ts +305 -0
  45. package/dist/deploy/orchestrator.d.ts.map +1 -0
  46. package/dist/deploy/orchestrator.js +2910 -0
  47. package/dist/deploy/orchestrator.js.map +1 -0
  48. package/dist/deploy/package.d.ts +130 -0
  49. package/dist/deploy/package.d.ts.map +1 -0
  50. package/dist/deploy/package.js +351 -0
  51. package/dist/deploy/package.js.map +1 -0
  52. package/dist/deploy/proof-gate.d.ts +82 -0
  53. package/dist/deploy/proof-gate.d.ts.map +1 -0
  54. package/dist/deploy/proof-gate.js +269 -0
  55. package/dist/deploy/proof-gate.js.map +1 -0
  56. package/dist/deploy/python-float.d.ts +34 -0
  57. package/dist/deploy/python-float.d.ts.map +1 -0
  58. package/dist/deploy/python-float.js +46 -0
  59. package/dist/deploy/python-float.js.map +1 -0
  60. package/dist/deploy/recheck-commands.d.ts +50 -0
  61. package/dist/deploy/recheck-commands.d.ts.map +1 -0
  62. package/dist/deploy/recheck-commands.js +59 -0
  63. package/dist/deploy/recheck-commands.js.map +1 -0
  64. package/dist/deploy/register.d.ts +99 -0
  65. package/dist/deploy/register.d.ts.map +1 -0
  66. package/dist/deploy/register.js +689 -0
  67. package/dist/deploy/register.js.map +1 -0
  68. package/dist/deploy/taxonomy.d.ts +24 -0
  69. package/dist/deploy/taxonomy.d.ts.map +1 -0
  70. package/dist/deploy/taxonomy.js +31 -0
  71. package/dist/deploy/taxonomy.js.map +1 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +244 -132
  74. package/dist/index.js.map +1 -1
  75. package/dist/runtime/strategy-registry.d.ts +8 -0
  76. package/dist/runtime/strategy-registry.d.ts.map +1 -1
  77. package/dist/runtime/strategy-registry.js +14 -1
  78. package/dist/runtime/strategy-registry.js.map +1 -1
  79. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  80. package/dist/runtime-api/intake/intake-mount.js +7 -4
  81. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  82. package/dist/scanners/implementations/market-regime.d.ts +1 -1
  83. package/dist/senpi/client.d.ts +58 -4
  84. package/dist/senpi/client.d.ts.map +1 -1
  85. package/dist/senpi/client.js +192 -15
  86. package/dist/senpi/client.js.map +1 -1
  87. package/dist/senpi/strategy-status.d.ts +32 -0
  88. package/dist/senpi/strategy-status.d.ts.map +1 -0
  89. package/dist/senpi/strategy-status.js +70 -0
  90. package/dist/senpi/strategy-status.js.map +1 -0
  91. package/dist/senpi/types.d.ts +47 -3
  92. package/dist/senpi/types.d.ts.map +1 -1
  93. package/dist/skills-manager/manifest.js +1 -1
  94. package/dist/skills-manager/skills-manifest.json +1 -1
  95. package/dist/telemetry/metrics.d.ts +23 -0
  96. package/dist/telemetry/metrics.d.ts.map +1 -1
  97. package/dist/telemetry/metrics.js +65 -0
  98. package/dist/telemetry/metrics.js.map +1 -1
  99. package/dist/types/strategy.d.ts +9 -1
  100. package/dist/types/strategy.d.ts.map +1 -1
  101. package/dist/universe/package-universe.d.ts +97 -0
  102. package/dist/universe/package-universe.d.ts.map +1 -0
  103. package/dist/universe/package-universe.js +275 -0
  104. package/dist/universe/package-universe.js.map +1 -0
  105. package/dist/validate/codes.d.ts +20 -0
  106. package/dist/validate/codes.d.ts.map +1 -1
  107. package/dist/validate/codes.js +36 -0
  108. package/dist/validate/codes.js.map +1 -1
  109. package/dist/validate/import-stage.js +5 -5
  110. package/dist/validate/import-stage.js.map +1 -1
  111. package/dist/validate/index.d.ts +1 -1
  112. package/dist/validate/index.d.ts.map +1 -1
  113. package/dist/validate/index.js.map +1 -1
  114. package/dist/validate/install-gate.d.ts.map +1 -1
  115. package/dist/validate/install-gate.js +10 -2
  116. package/dist/validate/install-gate.js.map +1 -1
  117. package/dist/validate/live-instruments.d.ts +44 -0
  118. package/dist/validate/live-instruments.d.ts.map +1 -0
  119. package/dist/validate/live-instruments.js +71 -0
  120. package/dist/validate/live-instruments.js.map +1 -0
  121. package/dist/validate/live-stage.js +5 -5
  122. package/dist/validate/live-stage.js.map +1 -1
  123. package/dist/validate/proof.d.ts +11 -1
  124. package/dist/validate/proof.d.ts.map +1 -1
  125. package/dist/validate/proof.js +7 -6
  126. package/dist/validate/proof.js.map +1 -1
  127. package/dist/validate/recipe-checks.d.ts.map +1 -1
  128. package/dist/validate/recipe-checks.js +6 -4
  129. package/dist/validate/recipe-checks.js.map +1 -1
  130. package/dist/validate/run.d.ts +5 -0
  131. package/dist/validate/run.d.ts.map +1 -1
  132. package/dist/validate/run.js +10 -1
  133. package/dist/validate/run.js.map +1 -1
  134. package/dist/validate/target.d.ts +41 -0
  135. package/dist/validate/target.d.ts.map +1 -1
  136. package/dist/validate/target.js +192 -16
  137. package/dist/validate/target.js.map +1 -1
  138. package/dist/validate/universe-stage.d.ts +71 -0
  139. package/dist/validate/universe-stage.d.ts.map +1 -0
  140. package/dist/validate/universe-stage.js +183 -0
  141. package/dist/validate/universe-stage.js.map +1 -0
  142. package/openclaw.plugin.json +1 -1
  143. package/package.json +1 -1
  144. package/dist/runtime/managed-wallet-guard.d.ts +0 -47
  145. package/dist/runtime/managed-wallet-guard.d.ts.map +0 -1
  146. package/dist/runtime/managed-wallet-guard.js +0 -106
  147. package/dist/runtime/managed-wallet-guard.js.map +0 -1
  148. package/dist/runtime/protection-nudge.d.ts +0 -67
  149. package/dist/runtime/protection-nudge.d.ts.map +0 -1
  150. package/dist/runtime/protection-nudge.js +0 -244
  151. package/dist/runtime/protection-nudge.js.map +0 -1
@@ -0,0 +1,2910 @@
1
+ /**
2
+ * The `senpi deploy` state machine.
3
+ *
4
+ * `runDeploy` is a pure, sequential orchestration over injected collaborators:
5
+ * it performs no I/O of its own beyond reading the package's own YAML, holds no
6
+ * timers, and never touches the job slot, the journal, or the gateway. Every
7
+ * async concern lives in `job.ts`, so this whole flow tests on fakes and a
8
+ * virtual clock.
9
+ *
10
+ * Two rules shape the design and must survive every edit:
11
+ * - **Decisions read live surfaces only** — backend strategies and the runtime
12
+ * registry. Emitted events are narration; a throwing `emit` cannot alter the
13
+ * flow, and nothing ever reads a journal back to decide anything. Exactly one
14
+ * carve-out exists: the journal may supply a *lookup key* (a strategyId this
15
+ * box journaled at create time, `params.priorStrategyIds`) that is always
16
+ * verified against the live backend before use — never a decision.
17
+ * - **Abandonment is honored at step boundaries and between poll iterations
18
+ * only** — never mid-MCP-call, so money-moving calls are never left in a
19
+ * half-known state. The deadline watchdog in `job.ts` is the only thing that
20
+ * raises it (there is no cancel verb: undeploying is closing the strategy).
21
+ * Nothing is rolled back **except the one D-6 case: a wallet this job created
22
+ * whose install then failed**; the report says what completed.
23
+ */
24
+ import { join } from "node:path";
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";
31
+ import { packageMinBudget } from "./min-budget.js";
32
+ import { errorText } from "./error-text.js";
33
+ import { gateInstance } from "./install-gate-runner.js";
34
+ import { buildInstanceDirCollisionRefusal, buildProofRefusal, checkPackageProof, collidingInstanceDirs, proofRefusalParts, } from "./proof-gate.js";
35
+ const CREATE_POLL_MS = 10_000;
36
+ const OBSERVE_POLL_MS = 5_000;
37
+ const STEP_ORDER = ["reconcile", "preflight", "create", "install", "observe"];
38
+ /**
39
+ * Backend codes that mean "the name is unusable", not "the deploy is unfundable" — see
40
+ * {@link isNameRejection}. Spelled out one per entry rather than packed into a character class
41
+ * (`SERR05[568]`) because the reader who arrives here arrives holding a code: a grep for the exact
42
+ * string they were handed has to land on this list.
43
+ *
44
+ * SERR083 belongs here despite its generic text ("Custom strategy creation failed unexpectedly.
45
+ * Retry with the same payload."), which carries neither a name-ish code nor the word "name": a
46
+ * strategy stuck in PENDING_FUNDING keeps holding its strategyName, and every create reusing that
47
+ * name 500s until the record expires ~15 minutes later. Retrying the SAME payload — precisely what
48
+ * the error itself advises — can never clear it; dropping the name clears it instantly. Observed on
49
+ * a real deploy, two calls 3.6s apart at the same $693 budget:
50
+ * {initialBudget: 693, strategyName: "vanguard-long"} → SERR083
51
+ * {initialBudget: 693} → success
52
+ * It also failed at $100 with the name, ruling out any funding cause. So do not drop this entry on
53
+ * the grounds that the message reads like a generic backend failure — that reading is the bug, and
54
+ * it already cost this guard once when the money path moved out of deploy.py.
55
+ */
56
+ const NAME_REJECTION_CODES = ["SERR055", "SERR056", "SERR058", "SERR083"];
57
+ /**
58
+ * True when a failed create is the backend refusing the wallet NAME, so retrying unnamed works.
59
+ *
60
+ * The bare-word `name` arm is deliberately kept alongside the enumerated codes: every code above
61
+ * was learned from an incident, one at a time, and this arm is what covers the next one before it
62
+ * has a number. Weigh dropping it against the failure that leaves: a name collision reported as a
63
+ * hard deploy failure, stranding the user for ~15 minutes with no action that helps.
64
+ *
65
+ * What a false positive COSTS depends on whether the create it retries actually landed, and this
66
+ * predicate cannot tell — nor can its caller, in every case:
67
+ *
68
+ * - The backend said no. The ordinary shape, and the harmless one: nothing was created, so the
69
+ * retry costs legibility (the wallet lands under a backend-assigned name) and nothing else. It
70
+ * carries the same budget and the same attribution, so it cannot change what gets funded.
71
+ * - Nobody knows. `callCreate` turns EVERY throw into `success: false`, so a transport error
72
+ * raised after the request was already written — a socket reset, a 502, a gateway-injected
73
+ * transport failing mid-call (senpi/client.ts `callToolInner`) — arrives here shaped exactly
74
+ * like a refusal. If its text matches, the retry runs against a create that may already have
75
+ * funded a wallet, and funds a second one beside it. A call abandoned at its deadline is one
76
+ * member of this class, not the whole of it — merely the one `callCreate` flags (`aborted`).
77
+ *
78
+ * Nothing excludes that second class today, and no arm of this predicate can. The deadline text is
79
+ * fixed and carries neither a code nor "name" (`abortedCallError`), but a transport error's text is
80
+ * authored outside this repo and the transport is called with the create's own arguments — the
81
+ * literal key `strategyName` among them — so any transport that echoes the failed request matches.
82
+ * This exposure is unchanged from the regex this replaced, byte for byte; it is written down here
83
+ * because the fix is not the obvious one. The exclusion worth building is "the failure came from a
84
+ * THROW", not `!aborted` — and even that needs care, because a throw raised BEFORE the request went
85
+ * out (connection refused) is a case where retrying is perfectly safe. Telling pre-send from
86
+ * post-send is the actual work, and the reason it belongs in its own change rather than here.
87
+ *
88
+ * Matching stays case-INSENSITIVE on the codes as well as the word, which the regex this replaced
89
+ * was: the backend spells them upper-case today, and nothing here is worth losing the unblock over
90
+ * if some relay downcases a message. (deploy.py compared the codes case-sensitively; that is the
91
+ * one deliberate difference, and it is in the direction of firing more often, never less.)
92
+ */
93
+ function isNameRejection(message) {
94
+ const upper = message.toUpperCase();
95
+ return NAME_REJECTION_CODES.some((code) => upper.includes(code)) || upper.includes("NAME");
96
+ }
97
+ const ABANDONED_DETAIL = "abandoned at the deploy deadline — the job exceeded its wall-clock cap";
98
+ /** Ticks that prove the scanner executed; `ok` literally means "found a signal", so heartbeat counts. */
99
+ const OBSERVED_RUN_STATUSES = ["ok", "heartbeat"];
100
+ function isFreshTick(row, sinceMs) {
101
+ if (row.scheduleMode === "external") {
102
+ // C1: intake short-circuits empty POSTs before run telemetry, so a watch-only external
103
+ // scanner never records a lastRunStatus. lastAliveAt is id-keyed and advances every POST.
104
+ return row.lastAliveAt !== null && row.lastAliveAt >= sinceMs;
105
+ }
106
+ return (row.lastRunStatus !== null &&
107
+ OBSERVED_RUN_STATUSES.includes(row.lastRunStatus) &&
108
+ row.lastRunFinishedAt !== null &&
109
+ row.lastRunFinishedAt >= sinceMs // H3: pre-install telemetry survives a re-mint
110
+ );
111
+ }
112
+ function isFreshError(row, sinceMs) {
113
+ return (row.lastRunStatus === "error" &&
114
+ row.lastRunFinishedAt !== null &&
115
+ row.lastRunFinishedAt >= sinceMs);
116
+ }
117
+ /** deploy.py's `_budget_verdict` tolerance: below this fraction of the ask, say so out loud. */
118
+ const BUDGET_TOLERANCE = 0.9;
119
+ /**
120
+ * Did the D-6 rollback's `strategy_close` actually run? Returns the reason it did not, or null when
121
+ * the close is PROVEN — only an explicit `success: true` proves it.
122
+ *
123
+ * The client resolves a tool-level `strategy_close` failure as `{ success: false }` and maps an
124
+ * unrecognizable payload onto that same shape (senpi/client.ts); it throws only on an RPC fault. So
125
+ * "the promise resolved" is not evidence, and reading it as one reported funds returned from a
126
+ * wallet that is still live and still funded.
127
+ */
128
+ function describeCloseFailure(result) {
129
+ const record = typeof result === "object" && result !== null
130
+ ? result
131
+ : undefined;
132
+ if (record?.success === true)
133
+ return null;
134
+ const message = typeof record?.message === "string" && record.message.length > 0
135
+ ? record.message
136
+ : "";
137
+ return message
138
+ ? `\`strategy_close\` reported failure: ${message}`
139
+ : `\`strategy_close\` did not report success (it returned ${JSON.stringify(result) ?? String(result)})`;
140
+ }
141
+ /**
142
+ * A call this job stopped waiting on at its own deadline, as opposed to one the backend failed.
143
+ * `boundAwait` (senpi/client.ts) names those `AbortError`; the self-managed MCP transport surfaces
144
+ * the same shape as a `TimeoutError`. The distinction is load-bearing on the money path: a failure
145
+ * means nothing happened, an abandonment means nobody knows — and the two must not read alike.
146
+ */
147
+ function isDeadlineAbort(err) {
148
+ return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
149
+ }
150
+ /** The provenance clause every reconcile message carries, so a report never hides how it matched. */
151
+ function matchedByClause(matchedBy) {
152
+ return matchedBy === "created-key" ? " (matched by journaled create key, verified against the backend)" : "";
153
+ }
154
+ /**
155
+ * The other live strategies that answered to the matched name and were dropped as another
156
+ * package's. A bind that ignored one is still a bind on a CONTESTED name — the collision outlives
157
+ * this deploy and surfaces again at the next `close.py`, rename, or status read — so the report
158
+ * says so rather than rendering a clean "matched by name". It is a note, not a warning: nothing
159
+ * here is this package's to act on, which is the same reason those rows were dropped.
160
+ */
161
+ function foreignCollisionClause(ignoredForeign) {
162
+ if (ignoredForeign.length === 0)
163
+ return "";
164
+ const stamps = [...new Set(ignoredForeign.map((s) => s.skillName?.trim()).filter((v) => !!v))];
165
+ const whose = stamps.length > 0 ? stamps.map((s) => `"${s}"`).join(", ") : "another package";
166
+ const plural = ignoredForeign.length === 1 ? "y" : "ies";
167
+ return (` — note: ${ignoredForeign.length} other live strateg${plural} of the same name ` +
168
+ `${ignoredForeign.length === 1 ? "is" : "are"} stamped for ${whose} and ${ignoredForeign.length === 1 ? "was" : "were"} ` +
169
+ `left untouched`);
170
+ }
171
+ function buildAmbiguousRefusal(count, matchedAs, instance) {
172
+ return (`[E_STATE_AMBIGUOUS_WALLETS] ${count} live strategies ${matchedAs} — cannot resolve ` +
173
+ `which wallet belongs to instance "${instance}". Read-only triage first (openclaw senpi status / ` +
174
+ `strategy_list), clear the ambiguity with the user. NEVER close or recreate to "start clean".`);
175
+ }
176
+ /**
177
+ * A single live name-match whose wallet address cannot be read. deploy.py refused this case rather
178
+ * than guessing, and the taxonomy's `E_STATE_AMBIGUOUS_WALLETS` row names it explicitly: the strategy
179
+ * may be funded and live, so neither adopting a blank address nor creating a second wallet is safe.
180
+ */
181
+ function buildUnreadableWalletRefusal(strategyName, instance, strategyId) {
182
+ // An id the backend did not report is stated as unreadable, never rendered as the blank in
183
+ // "(id )" — the same rule the wallet address in this very refusal is subject to. A reader who
184
+ // sees an empty parenthetical cannot tell a missing id from a rendering bug.
185
+ const id = strategyId?.trim() ? `id ${strategyId.trim()}` : "id unreadable";
186
+ return (`[E_STATE_AMBIGUOUS_WALLETS] Live strategy "${strategyName}" (${id}) matches instance ` +
187
+ `"${instance}" but its wallet address is unreadable — cannot resolve which wallet this instance ` +
188
+ `owns, and it may be funded and live. Nothing was created or changed. Read-only triage first ` +
189
+ `(openclaw senpi status / strategy_list), clear the ambiguity with the user. NEVER close or recreate to ` +
190
+ `"start clean".`);
191
+ }
192
+ /**
193
+ * The `create: skipped` detail for an adopted wallet.
194
+ *
195
+ * When a budget was requested, this is the only place the report can say that it was **not**
196
+ * applied — deploy never adds funds to a wallet that already exists, and a silent skip would let an
197
+ * agent summarise "deployed spider with $500" over a wallet holding $100.
198
+ */
199
+ /**
200
+ * How a backend `totalFunded` read renders in prose.
201
+ *
202
+ * An unreadable figure is stated as unreadable, never as `$0.00`. Every sentence that quotes this
203
+ * number sits beside an instruction about money, and the two cases call for opposite next steps: a
204
+ * genuine zero says "there is nothing on this wallet", an unread one says "nobody knows yet".
205
+ */
206
+ function fundedClause(amount) {
207
+ return amount === undefined ? "with no readable funded amount reported" : `funded ${usd(amount)}`;
208
+ }
209
+ /** `its $60.00` / `its balance` — the possessive form of the same rule. */
210
+ function itsFunded(amount) {
211
+ return amount === undefined ? "its balance" : `its ${usd(amount)}`;
212
+ }
213
+ /**
214
+ * The rollback voice of {@link fundedClause}: `funded $250.00` / the unread form.
215
+ *
216
+ * Both sentences it feeds order money moves on the wallet they name — "closing it and returning the
217
+ * funds", "Reclaim the funds from THIS wallet" — so a `?? 0` here told the reader to reclaim from a
218
+ * wallet the same sentence called empty, which reads as "nothing to reclaim, skip it" over money
219
+ * that is genuinely out there. UNKNOWN, never $0.00.
220
+ */
221
+ function rollbackFundedClause(amount) {
222
+ return amount === undefined
223
+ ? `funded an amount that was unreadable at deploy time (UNKNOWN, never zero — verify the ` +
224
+ `wallet's balance)`
225
+ : `funded ${usd(amount)}`;
226
+ }
227
+ function buildAdoptedWalletDetail(pkg, inst, matched, wallet, budget, matchedBy = "name") {
228
+ const how = matchedBy === "created-key" ? "matched by journaled create key, verified against the backend" : "strategyName match";
229
+ const base = `reusing ${matched.status} wallet ${wallet} (${how}), ${fundedClause(matched.totalFunded)}`;
230
+ if (budget === undefined)
231
+ return base;
232
+ const share = inst.fundingShare ?? 1 / pkg.instances.length;
233
+ const intended = Math.max(MIN_WALLET, Math.round(budget * share * 100) / 100);
234
+ // An unread figure is not a comparison input. Collapsing it to 0 made this line say "the
235
+ // requested $500 was NOT added — top it up, or close this strategy first" about a wallet the
236
+ // clause above calls unreadable, and it put a second money authority on a report whose
237
+ // [W_BUDGET_FUNDED_UNREADABLE] already says "Move no money on this". Nothing is steered from a
238
+ // number nobody read: state the gap as UNKNOWN and defer to the balance being established.
239
+ if (matched.totalFunded === undefined) {
240
+ return (`${base} — deploy never funds an existing wallet, and whether the ${usd(intended)} requested ` +
241
+ `for this instance is already on it is UNKNOWN here (unreadable is never zero). No money move ` +
242
+ `follows from this read: establish the wallet's actual balance with the USER first, and only ` +
243
+ `then decide whether anything needs topping up or resizing.`);
244
+ }
245
+ if (matched.totalFunded >= intended * BUDGET_TOLERANCE)
246
+ return base;
247
+ return (`${base} — the requested ${usd(intended)} for this instance was NOT added; deploy never funds an ` +
248
+ `existing wallet. To add funds, top it up separately; to deploy at the requested size, close this ` +
249
+ `strategy first and re-run.`);
250
+ }
251
+ /**
252
+ * A live name-match in a status that is neither ACTIVE nor on its way there — PAUSED, mid-teardown,
253
+ * or something this verb does not recognise.
254
+ *
255
+ * Polling these is the bug this builder exists to prevent: a PAUSED strategy never becomes ACTIVE on
256
+ * its own, so waiting on it burns the whole `--max-wait` and then reports "wallet still funding",
257
+ * which is both untrue and unactionable. The next step is computed from the status actually read,
258
+ * and the non-destructive option always leads.
259
+ */
260
+ function buildNotDeployableRefusal(matched, instance, pkg, budget) {
261
+ const status = matched.status.toUpperCase();
262
+ const head = `[INVALID_REQUEST] Live strategy "${matched.strategyName}" (id ${matched.id}) matches instance ` +
263
+ `"${instance}" but its status is ${matched.status} — `;
264
+ const rerun = `re-run \`${resumeCommand(pkg.dir, budget)}\``;
265
+ if (status === "PAUSED") {
266
+ return (`${head}a paused strategy cannot be deployed onto, and it will not become ACTIVE on its own. ` +
267
+ `Nothing was created. Resume it first, then ${rerun}. If you meant to start over instead, tear ` +
268
+ `this one down with \`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\` (that returns its ` +
269
+ `funds) before re-running — never leave it paused and fund a second wallet beside it.`);
270
+ }
271
+ if (CLOSING_STATUSES.includes(status)) {
272
+ return (`${head}it is being torn down. Nothing was created. Wait for it to reach CLOSED (re-check with ` +
273
+ `\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`), then ${rerun} — the re-run will ` +
274
+ `create a fresh wallet once this one is gone.`);
275
+ }
276
+ return (`${head}deploy cannot proceed from that state and will not guess. Nothing was created. Read-only ` +
277
+ `triage first (openclaw senpi status / strategy_list) to find out why it is ${matched.status}, then ${rerun} ` +
278
+ `once it is ACTIVE. NEVER close or recreate to "start clean".`);
279
+ }
280
+ function buildBudgetRequiredRefusal(wallets, pkg) {
281
+ return (`[INVALID_REQUEST] --budget is required to create ${wallets} wallet(s) ` +
282
+ `(min ${usd(MIN_WALLET)}/wallet). Nothing was created. Re-run: ${resumeCommand(pkg.dir)}`);
283
+ }
284
+ /**
285
+ * `--budget 0` is not "no budget": it parses, and without this gate `planFunding` floors every
286
+ * wallet to MIN_WALLET — funding money the user never asked to spend. `--budget ≤ $0` is also the
287
+ * taxonomy's rule-4 field case: its only known source in the field is a buggy computed hint, so the
288
+ * refusal teaches where the real fix lives instead of letting that loop run. The same gate exists
289
+ * at gateway intake (register.ts), keeping every caller of `runDeploy` covered.
290
+ */
291
+ function buildBudgetNotPositiveRefusal(budget, wallets, pkg) {
292
+ return (`[INVALID_REQUEST] --budget must be a positive USD amount: ${usd(budget)} cannot fund ` +
293
+ `${wallets} wallet(s) (min ${usd(MIN_WALLET)}/wallet). Nothing was created. If a report ` +
294
+ `suggested \`--budget ${budgetArg(budget)}\`, that hint was a bug, not an instruction — a ` +
295
+ `sleeve declaring \`funding_share: 0\` is fixed by giving it a share in strategy.yaml, never ` +
296
+ `by re-deploying. Re-run: ${resumeCommand(pkg.dir)}`);
297
+ }
298
+ /** `scalp $12.00 (needs $13.50)` — both numbers, so the claim is checkable. */
299
+ function renderUnderfunded(under) {
300
+ return under.map((row) => `${row.wallet} ${usd(row.funded)} (needs ${usd(row.needs)})`).join(", ");
301
+ }
302
+ /**
303
+ * The budget a re-run scoped to exactly `under` would need, given how `planFunding` will split it:
304
+ * each sleeve gets its declared share, or an even slice of however many are being redeployed.
305
+ * COMPUTED, never templated — the taxonomy's rule 4 exists because a hint that can be nonsense for
306
+ * some state is a bug, and "deploy at the package minimum" is nonsense when only one sleeve is
307
+ * being replaced.
308
+ */
309
+ function budgetToCover(rerunFunds, pkg, min) {
310
+ // A declared share of 0 is kept verbatim by `sharesOf`, so that wallet is floored at MIN_WALLET
311
+ // at EVERY budget — no number satisfies it, and including one in the max would send this to
312
+ // Infinity. Such a sleeve never shapes the TARGET (the fix is the manifest, not the budget — see
313
+ // `zeroShareSentence`), but when the re-run will fund it anyway (it has no wallet) it stays in
314
+ // `rerunFunds` and so in the even-split DENOMINATOR: `planFunding` divides evenly over every
315
+ // wallet being funded, zero-share ones included, and a hint sized against a smaller split
316
+ // under-hints the agent straight back into this warn. (An ABSENT share is different — it becomes
317
+ // an even slice, which does scale.)
318
+ const scalable = rerunFunds.filter((name) => pkg.instances.find((i) => i.name === name)?.fundingShare !== 0);
319
+ if (scalable.length === 0)
320
+ return 0;
321
+ const evenShare = 1 / rerunFunds.length;
322
+ const needsOf = new Map(min.breakdown.map((row) => [row.wallet, row.perWalletMin]));
323
+ const worst = scalable.reduce((most, name) => {
324
+ const declared = pkg.instances.find((inst) => inst.name === name)?.fundingShare;
325
+ const share = declared !== null && declared !== undefined && declared > 0 ? declared : evenShare;
326
+ return Math.max(most, (needsOf.get(name) ?? 0) / share);
327
+ }, 0);
328
+ // Ceil to whole cents so `budget × share` clears the requirement BEFORE `planFunding`'s round2.
329
+ return Math.ceil(worst * 100) / 100;
330
+ }
331
+ /**
332
+ * The fix-the-manifest sentence for `funding_share: 0` sleeves — the one shortfall this escape can
333
+ * NEVER remediate. `planFunding` keeps a declared 0 verbatim, so the wallet is floored at
334
+ * MIN_WALLET at every budget: closing it and re-deploying bigger funds it straight back at the
335
+ * floor, which is the destructive no-op loop the close command must not offer. When such a sleeve
336
+ * is the ONLY short one, this sentence IS the whole escape — deliberately with nothing to run.
337
+ */
338
+ function zeroShareSentence(names) {
339
+ const one = names.length === 1;
340
+ return (`Sleeve${one ? "" : "s"} ${names.join(", ")} declare${one ? "s" : ""} \`funding_share: 0\`, so ` +
341
+ `NO budget can lift ${one ? "it" : "them"} above the ${usd(MIN_WALLET)} floor and closing/` +
342
+ `re-deploying cannot change that — the fix is a share in strategy.yaml, not more money.`);
343
+ }
344
+ /**
345
+ * How `[E_ROLLBACK_INCOMPLETE]` should finish its sentence, given what else is live on this package.
346
+ *
347
+ * The stranded wallet has no runtime, so `close.py --instance` cannot resolve it — the only
348
+ * per-wallet route is a direct `strategy_close` on the address, which the caller states first. What
349
+ * this adds is whether the package-wide `close.py <id>` is a safe convenience or a trap: it is fine
350
+ * when this stranded wallet is all that exists, and destructive the moment another sleeve is live,
351
+ * because it would take that one down too while "reclaiming" this one.
352
+ */
353
+ function buildStrandedCloseCaveat(pkg, runs, stranded) {
354
+ const otherLive = runs.filter((run) => run !== stranded && run.report.wallet && !run.report.rolledBack);
355
+ // `pkg.id` is quoted the way `--instance` is: these strings are copied into a shell verbatim.
356
+ if (otherLive.length === 0) {
357
+ return (`. Nothing else in this package is live, so \`python3 senpi-strategy-ops/scripts/close.py ` +
358
+ `'${pkg.id}'\` is equivalent and safe here.`);
359
+ }
360
+ return (`. Do NOT reach for \`close.py '${pkg.id}'\` to do it: ${otherLive.length} other sleeve(s) ` +
361
+ `(${otherLive.map((run) => run.inst.name).join(", ")}) hold live funded wallets and that ` +
362
+ `command would close them too. Read them first if you need context: ` +
363
+ `\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`.`);
364
+ }
365
+ /** Instance names a re-run of this package would have to fund: everything without a live wallet. */
366
+ function walletlessAfter(pkg, runs) {
367
+ const held = new Set(runs.filter((run) => run.report.wallet && !run.report.rolledBack).map((run) => run.inst.name));
368
+ return pkg.instances.map((inst) => inst.name).filter((name) => !held.has(name));
369
+ }
370
+ /**
371
+ * The close-then-redeploy escape — the ONLY sequence that actually raises a deployed wallet's size.
372
+ *
373
+ * A bare re-run at a bigger `--budget` is a guaranteed no-op: reconcile matches the strategy by
374
+ * name, ADOPTS it, skips create, and answers with `buildAdoptedWalletDetail`'s "the requested … was
375
+ * NOT added". The agent then has to reverse-engineer the real step from a message it did not
376
+ * expect, and the two likeliest readings are "the deploy failed" and "close it to start clean".
377
+ *
378
+ * SCOPED to the underfunded sleeves. `close.py <id>` tears down the WHOLE package, so on a
379
+ * partially-adopted deploy it would close the adopted, live, funded sleeve that this warn is not
380
+ * even about — the "close-to-replace teardown" class the taxonomy's rule 5 exists to prevent.
381
+ * `--instance` narrows it, and is only omitted when every sleeve in the package really is affected.
382
+ *
383
+ * `existing` is the subset of `under` whose wallets ACTUALLY EXIST on the finished report. An empty
384
+ * one yields no escape at all: on a deploy that failed before create there is no "it" to close, and
385
+ * offering a teardown for a wallet that was never made is a false premise plus a pointless step.
386
+ */
387
+ function buildBudgetEscape(pkg, min, state) {
388
+ // E_ROLLBACK_INCOMPLETE owns the remediation wherever it appears. It names a stranded funded
389
+ // wallet and the command to reclaim it; a second close instruction beside it — scoped differently,
390
+ // and unrunnable besides — is a report contradicting itself, and an agent reconciling the two
391
+ // picks whichever one executes. Defer, and say who to follow.
392
+ if (state.rollbackIncomplete) {
393
+ return (` Deal with the [E_ROLLBACK_INCOMPLETE] line on this report FIRST — it owns the cleanup here, ` +
394
+ `and its instruction is the one to follow. Re-size only once that wallet is settled.`);
395
+ }
396
+ // A wallet with no runtime cannot be closed by instance at all: `close.py --instance` resolves the
397
+ // sleeve THROUGH its runtime and hard-exits without one, telling the reader to "omit --instance to
398
+ // close all of <id>" — the whole-package teardown of any adopted sleeve beside it. Never emit a
399
+ // command whose precondition this report does not meet; point at read-only triage instead.
400
+ if (state.strandedNames.length > 0) {
401
+ // Deliberately names NO teardown command. Per-sleeve teardown resolves the sleeve through its
402
+ // runtime, which these wallets do not have; the package-wide form would take down every sleeve
403
+ // including any adopted live one. The only command here is read-only, so there is nothing to
404
+ // copy that could destroy anything.
405
+ return (` Re-sizing is NOT safe to automate on this report: wallet(s) ${state.strandedNames.join(", ")} ` +
406
+ `were funded but no runtime was installed on them, which is the one state a per-sleeve teardown ` +
407
+ `cannot resolve — and a package-wide one would take down every other sleeve with it, adopted ` +
408
+ `live ones included. Triage read-only first: ` +
409
+ `\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`, then decide with the user.`);
410
+ }
411
+ // A zero-share short sleeve is not remediable by this escape AT ALL — no budget moves it off the
412
+ // floor (see `zeroShareSentence`) — so it never reaches `closable` and never shapes the hint's
413
+ // target. When it is the only short sleeve, the manifest sentence is the entire escape.
414
+ const zeroShareNote = state.zeroShare.length > 0 ? ` ${zeroShareSentence(state.zeroShare)}` : "";
415
+ if (state.closable.length === 0)
416
+ return zeroShareNote;
417
+ // The id and the package dir are quoted the way `--instance` is: shell input, copied verbatim.
418
+ const wholePackage = state.closable.length === pkg.instances.length;
419
+ const closeCmd = wholePackage
420
+ ? `python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'`
421
+ : state.closable
422
+ .map((name) => `python3 senpi-strategy-ops/scripts/close.py '${pkg.id}' --instance '${name}'`)
423
+ .join(" && ");
424
+ // The scoped clause NAMES the sleeves it closes rather than saying "the underfunded sleeve(s)":
425
+ // a zero-share sleeve can be underfunded yet deliberately absent from the command, and prose that
426
+ // describes a wider set than the command executes is the self-contradiction this note exists to
427
+ // avoid.
428
+ const closeClause = wholePackage
429
+ ? `Close it first (\`${closeCmd}\`, which returns the funds)`
430
+ : `Close ONLY ${state.closable.join(", ")} first — the rest of the package stays up ` +
431
+ `(\`${closeCmd}\`, which returns the funds)`;
432
+ // The re-run funds every walletless instance, not just the ones being closed — a sleeve whose own
433
+ // create failed is in that set too, and sizing the hint against the closed set alone under-hints
434
+ // it straight back into this same warn.
435
+ const rerunFunds = [...new Set([...state.closable, ...state.walletless])];
436
+ const redeployBudget = wholePackage ? min.minBudget : budgetToCover(rerunFunds, pkg, min);
437
+ // Mirror of `underfundedNote`'s guard, on the module's other money-hint producer: below
438
+ // MIN_WALLET no rendered `--budget` is valid, and suggesting one produces nonsense the agent
439
+ // then follows ("--budget ≤ $0", the M381223 churn). Reachable through a mis-authored share > 1;
440
+ // better no command at all than a command with a sub-floor figure in it.
441
+ if (redeployBudget < MIN_WALLET)
442
+ return zeroShareNote;
443
+ return (` Running the authored design means starting over at the larger size, NOT re-running at a bigger ` +
444
+ `budget: deploy never adds funds to a wallet that already exists, so a re-run would just adopt ` +
445
+ `what is already there. ${closeClause}, then ` +
446
+ `\`openclaw senpi deploy -p '${pkg.dir}' --budget ${budgetArg(redeployBudget)}\`.${zeroShareNote}`);
447
+ }
448
+ /** Forward-looking tense for the preflight narration: everything is about to be funded and run. */
449
+ function forwardTense(under) {
450
+ const names = under.map((row) => row.wallet);
451
+ return { funded: new Set(names), running: new Set(names) };
452
+ }
453
+ /**
454
+ * The SOFT tier's warn: one or more wallets this deploy funds get less than their own sizing needs.
455
+ *
456
+ * It must not read like the `[E_FUNDS_*]` refusals that share its `[E_…]` shape, because it did not
457
+ * stop anything — users size their own budgets, and a strategy that runs with fewer slots than
458
+ * authored is a legitimate choice, not a fault. Hence the explicit "nothing was blocked": without
459
+ * it, an agent that pattern-matches bracketed codes to "refused" would report a live deploy as a
460
+ * failure and start closing wallets to "fix" it. The phrasing is state-independent ("the budget
461
+ * check did not stop this deploy") because this note also rides `failed` reports, where the old
462
+ * "this deploy PROCEEDS" was simply untrue.
463
+ *
464
+ * The whole-package minimum is quoted as CONTEXT, in its own parenthesis — never as the claim.
465
+ */
466
+ function buildBudgetBelowMinNote(pkg, min, under, escape, tense) {
467
+ // "Will run" is a claim about something INSTALLED, judged per row: a mixed report used to say
468
+ // "those sleeve(s) WILL RUN DEGRADED" about wallets it named as stranded two clauses later. The
469
+ // sizing observation itself stays in every branch — it was true about the plan.
470
+ const running = under.map((row) => row.wallet).filter((name) => tense.running.has(name));
471
+ const degraded = running.length === under.length
472
+ ? `those sleeve(s) will run DEGRADED, with`
473
+ : running.length === 0
474
+ ? `at that size those sleeve(s) would run DEGRADED, with`
475
+ : `of these, ${running.join(", ")} will run DEGRADED — the rest never got a runtime ` +
476
+ `installed on this run — with`;
477
+ // "This deploy funds" is a claim about the WALLET, judged per row too: on a deploy that never
478
+ // created one, present tense states an allocation that did not happen — while "would have funded"
479
+ // about a wallet that IS live and funded (stranded, rollback-incomplete) contradicts the very
480
+ // line that names it. Each row's own fact decides.
481
+ const fundedCount = under.filter((row) => tense.funded.has(row.wallet)).length;
482
+ const lead = fundedCount === under.length
483
+ ? `This deploy funds ${under.length} wallet(s) below what their own sizing needs`
484
+ : fundedCount === 0
485
+ ? `This deploy would have funded ${under.length} wallet(s) below what their own sizing needs`
486
+ : `This deploy funds ${fundedCount} of ${under.length} wallet(s) below what their own ` +
487
+ `sizing needs (the rest never got a wallet)`;
488
+ return (`[W_BUDGET_BELOW_STRATEGY_MIN] ${lead}: ${renderUnderfunded(under)}. Nothing was blocked — the ` +
489
+ `budget check did not stop this deploy; ${degraded} fewer slots than designed and each ` +
490
+ `position a larger share of its wallet. (For reference, deploying the whole ${pkg.id} package ` +
491
+ `fresh needs ${usd(min.minBudget)} across ${min.walletCount} wallet(s).)${escape}`);
492
+ }
493
+ /**
494
+ * The SOFT tier's other half: a sleeve whose slot size could not be read out of its runtime.yaml
495
+ * (the vol-parity sleeves expose risk weights, not margin percentages). Its per-wallet minimum fell
496
+ * back to the bare floor, so the figure is a LOWER bound — saying it plainly is the whole point,
497
+ * because the alternative is advertising a $10 minimum for a design that no-trades below $250.
498
+ *
499
+ * A concrete shortfall is APPENDED rather than replacing this note. Both facts matter and the
500
+ * understatement is the more important caveat, so it leads — but "these wallets are already short"
501
+ * is the one hard, checkable thing available, and suppressing it (the shape this had when the two
502
+ * branches were exclusive) left the agent with the softer half of the truth while `belowMin`
503
+ * reported the opposite of it. Once it asserts that shortfall it must also name the way out, or an
504
+ * agent reading a concrete number with no escape will improvise one — and the improvisations this
505
+ * repo has field cases for are a bare re-run and a close.
506
+ */
507
+ function buildBudgetUnresolvedNote(pkg, min, under, escape, tense) {
508
+ // Same per-row funding tense as the below-min note (see there for why).
509
+ const fundedCount = under.filter((row) => tense.funded.has(row.wallet)).length;
510
+ const funding = fundedCount === under.length
511
+ ? `is ALREADY funding ${under.length} wallet(s)`
512
+ : fundedCount === 0
513
+ ? `would have funded ${under.length} wallet(s)`
514
+ : `is ALREADY funding ${fundedCount} of ${under.length} wallet(s) (the rest never got a wallet)`;
515
+ const shortfall = under.length > 0
516
+ ? ` This deploy ${funding} below their own sizing: ${renderUnderfunded(under)}.`
517
+ : "";
518
+ return (`[W_BUDGET_UNRESOLVED] could not compute a reliable minimum for ${pkg.id} — sleeve(s) ` +
519
+ `${min.unresolvedWallets.join(", ")} expose no resolvable marginPct, so the ${usd(min.minBudget)} ` +
520
+ `figure may be UNDERSTATED. Size conservatively.${shortfall} Nothing was blocked — the budget ` +
521
+ `check did not stop this deploy.${escape}`);
522
+ }
523
+ /** Which code leads the soft-tier note, or null when there is nothing to say. */
524
+ function softLead(min, under) {
525
+ if (min.unresolvedWallets.length > 0)
526
+ return "unresolved";
527
+ return under.length > 0 ? "below" : null;
528
+ }
529
+ /**
530
+ * The soft-tier note, assembled from the facts plus whatever escape the caller's state supports.
531
+ * ONE producer for both codes' strings — the preflight narration and the final report differ only
532
+ * in `escape`, so they can never drift into two different accounts of the same finding.
533
+ */
534
+ function buildSoftBudgetNote(pkg, min, under, escape, tense) {
535
+ switch (softLead(min, under)) {
536
+ case "unresolved":
537
+ return buildBudgetUnresolvedNote(pkg, min, under, escape, tense);
538
+ case "below":
539
+ return buildBudgetBelowMinNote(pkg, min, under, escape, tense);
540
+ default:
541
+ return null;
542
+ }
543
+ }
544
+ /**
545
+ * The fraction landed, FLOORED to a tenth of a percent.
546
+ *
547
+ * Floored rather than rounded on purpose: this warn fires strictly below 90%, and a rounded 89.97%
548
+ * would print "(90%)" beside a sentence explaining that the wallet came up short of 90% — the kind
549
+ * of self-contradiction that makes a reader distrust the whole line. Trailing `.0` is dropped so
550
+ * the ordinary case reads `12%`, not `12.0%`.
551
+ *
552
+ * Computed in integer CENTS. Flooring a raw double reintroduced the misprint the flooring exists to
553
+ * prevent, away from the boundary: `(6.80 / 10.00) * 1000` is 679.999… in binary floating point, so
554
+ * an exact 68% printed `67.9%`. Cents are what the figures actually are.
555
+ */
556
+ function fundedPercent(row) {
557
+ const fundedCents = Math.round(row.funded * 100);
558
+ const requestedCents = Math.round(row.requested * 100);
559
+ return `${Math.floor((fundedCents * 1000) / requestedCents) / 10}%`;
560
+ }
561
+ /**
562
+ * `main (0x…), created by this deploy, funded $60.00 of requested $500.00 (12%, short $440.00)` —
563
+ * both numbers, so the claim is checkable, and the provenance and the shortfall folded into the same
564
+ * clause so each wallet is named exactly once.
565
+ *
566
+ * The ask is attributed to whoever made it. On the adoption path the amount comes from a PRIOR run's
567
+ * journal, so "of requested $500.00" under a lead saying "what this deploy requested" described a
568
+ * request this run never made — against a wallet it never funded. The adopted clause names that run
569
+ * instead, and a reader who wants the current number is sent to `status.py` by the remediation.
570
+ */
571
+ function renderPartialFunded(rows) {
572
+ return rows
573
+ .map((row) => {
574
+ const asked = row.created
575
+ ? `${row.instance} (${row.wallet}), created by this deploy, funded ${usd(row.funded)} of ` +
576
+ `requested ${usd(row.requested)}`
577
+ : `${row.instance} (${row.wallet}), adopted by this deploy from an earlier run, funded ` +
578
+ `${usd(row.funded)} of the ${usd(row.requested)} that run requested`;
579
+ return `${asked} (${fundedPercent(row)}, short ${usd(row.requested - row.funded)})`;
580
+ })
581
+ .join(", ");
582
+ }
583
+ /**
584
+ * The OUTCOME tier's warn: the backend funded a wallet for materially less than the create call
585
+ * asked for (a partial bridge, a rejected top-up leg — the deploy sees only the result). The
586
+ * successor to the skills-side verify gate's `_budget_verdict`, which reported the whole strategy
587
+ * "not live" over the same finding.
588
+ *
589
+ * It is a `W_`, and the difference from that predecessor is the point: the strategy IS live, its
590
+ * wallet holds real money and its runtime is ticking. Reporting `not-live` (or letting this change
591
+ * `overall`) would send an agent to close a funded, running strategy over a shortfall a top-up
592
+ * fixes. So it warns, the exit code stays whatever the deploy earned, and the remediation leads
593
+ * with the NON-destructive route.
594
+ *
595
+ * It deliberately emits NO teardown command. `close.py`'s correct scope depends on state this note
596
+ * does not own — adopted siblings, wallets stranded with no runtime, an `[E_ROLLBACK_INCOMPLETE]`
597
+ * line already claiming the cleanup — and the report already has exactly one producer of close
598
+ * commands ({@link buildBudgetEscape}). A second, differently scoped teardown beside it is the
599
+ * self-contradiction the taxonomy's rule 5 exists to prevent, and an agent reconciling two of them
600
+ * runs whichever executes. The destructive option is named as an option and routed through
601
+ * read-only triage, which is the only command here and cannot destroy anything.
602
+ *
603
+ * Two properties of the note are load-bearing and easy to lose:
604
+ * - `reassure` — the "nothing is broken, the strategy is LIVE" sentence is a CLAIM about the rest
605
+ * of the report, so it is only made when the report PROVES it: `overall === "live"` and nothing
606
+ * less (see the gate at the call site). Asserted unconditionally it rode `failed` and `pending`
607
+ * reports saying the opposite two lines above; gated only loosely it rode
608
+ * `installed-unobserved` ones, where no tick was ever seen.
609
+ * - the figures are DATED and the top-up is routed through a fresh read. This note persists on the
610
+ * job snapshot and re-renders on every later `deploy status`, and unlike every other budget note
611
+ * it asserts a MUTABLE backend quantity whose fix is exactly the action that invalidates it — so
612
+ * an undated "short $440.00" invites a second $440 top-up after the first one landed.
613
+ * - PROVENANCE, exactly as its unreadable sibling carries it. This note is reachable on the
614
+ * ADOPTION path too — the ask is looked up from a prior run's journal for the strategy this run
615
+ * adopted — where "SHORT of what this deploy requested … funded $60.00 of requested $500.00"
616
+ * named a request this run never made about a wallet it never funded ($500 was the earlier run's
617
+ * ask; this one asked for nothing and created nothing). The lead attributes the ask to no one and
618
+ * each wallet states its own origin, so a reader can correct the figure from the note itself.
619
+ */
620
+ function buildPartialFundNote(pkg, rows, reassure) {
621
+ // The size claim is about the WALLETS THIS NOTE NAMES, never about the strategy. `rows` is the
622
+ // filtered SHORT set, so "the strategy is LIVE, but it is trading a $20.00 book" read a
623
+ // whole-package figure out of one sleeve of it: on a deploy whose other sleeve landed its full
624
+ // $300, the strategy was trading $320 across two wallets. Same rule the unreadable note's
625
+ // standing sentence follows — a filtered set speaks only for the wallets in it.
626
+ const book = rows.length === 1
627
+ ? `that wallet is trading a ${usd(rows[0].funded)} book`
628
+ : `those wallets are trading only what landed on them`;
629
+ const standing = reassure
630
+ ? `Nothing was blocked and nothing is broken — the strategy is LIVE, but ${book}, `
631
+ : `Read the step lines above first — they, not this note, say what state this ` +
632
+ `deploy left behind. Either way ${book}, `;
633
+ return (`[W_BUDGET_PARTIAL_FUND] The backend funded ${rows.length} wallet(s) SHORT of what was ` +
634
+ `requested for them, as the backend reported them at deploy time: ${renderPartialFunded(rows)}. ` +
635
+ `${standing}so every position opened there is sized off the money that actually landed, not ` +
636
+ `off the amount requested. Report the funded figure, never the requested one. Fix it the ` +
637
+ `non-destructive way FIRST — and re-read the CURRENT funding before moving any money, because ` +
638
+ `those figures are what the backend reported AT DEPLOY TIME and a top-up (or a slow bridge leg ` +
639
+ `settling late) makes them stale: run ` +
640
+ `\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`, then add whatever difference it ` +
641
+ `still shows through the \`senpi-deposit-withdraw-transfer\` skill. Deploy itself never adds ` +
642
+ `funds to a wallet that already exists, so re-running at the same budget would only adopt what ` +
643
+ `is there. Closing and redeploying at the full size is the alternative and it is DESTRUCTIVE: ` +
644
+ `agree the scope with the user against that same read, because a teardown here also takes down ` +
645
+ `whatever else this package has live.`);
646
+ }
647
+ /**
648
+ * The unreadable-read sibling of {@link buildPartialFundNote}: the ask is known, the landed amount
649
+ * is not, so this note makes NO percentage claim, NO dollar shortfall, and asks for no money move.
650
+ *
651
+ * The failure it replaces: the client coerced a missing/unparseable `totalFunded` to `0`, which
652
+ * turned one bad read into `funded $0.00 of requested $500.00 (0%)` plus an instruction to add
653
+ * $500 — against a wallet that may already hold the full amount, so following it doubled the
654
+ * deployment. An unread figure is not a small figure; the only honest next step is for the USER to
655
+ * establish what is actually on the wallet.
656
+ *
657
+ * `reassure` is the same earned-claim gate its sibling carries, and for the same reason: "the
658
+ * create succeeded and the strategy is running" is a claim about the REST of the report, and this
659
+ * note is reachable on `failed` and `pending` reports through the adoption path — asserted
660
+ * unconditionally it rode a verdict saying the opposite two lines above. Without it the note keeps
661
+ * every fact it owns (the amount is unreadable, verify before moving money) and makes no claim
662
+ * about what the deploy left running.
663
+ *
664
+ * PROVENANCE is the other claim it may not invent, and it is claimed PER WALLET only. The note is
665
+ * reachable on the ADOPTION path — a journaled ask is looked up for exactly the strategy a prior
666
+ * run created — so "wallet(s) this deploy created" described a create that never happened on a
667
+ * crash-resume that adopted, created nothing and funded nothing, handing the reader a second wallet
668
+ * that does not exist. Each wallet therefore states its own origin from
669
+ * {@link UnreadableFundedWallet.created}.
670
+ *
671
+ * No sentence here generalises that over the deploy. `rows` is a FILTERED set — the wallets whose
672
+ * funded figure came back unreadable — so nothing about the OTHER wallets can be read out of it,
673
+ * and a `rows.every(...)` looks exactly like a deploy-level fact while being one about the subset:
674
+ * a deploy that created `main` (readable $300) and adopted `scalp` (unreadable) has `rows =
675
+ * [scalp]`, and "this deploy created no wallet" is then flatly false about the $300 it just moved.
676
+ * The only deploy-level claim allowed is the one `reassure` proves — the report says `live`, so the
677
+ * strategy is running — and it names no create and no adoption.
678
+ */
679
+ function buildFundedUnreadableNote(pkg, rows, reassure) {
680
+ const wallets = rows
681
+ .map((row) => row.created
682
+ ? `${row.instance} (${row.wallet}), created by this deploy, requested ${usd(row.requested)}`
683
+ : `${row.instance} (${row.wallet}), adopted by this deploy from an earlier run that ` +
684
+ `requested ${usd(row.requested)}`)
685
+ .join(", ");
686
+ const standing = reassure
687
+ ? `Nothing was blocked and the strategy is running; what is UNKNOWN is `
688
+ : `Read the step lines above first — they, not this note, say what state this deploy left ` +
689
+ `behind. What is UNKNOWN here is `;
690
+ return (`[W_BUDGET_FUNDED_UNREADABLE] The backend did not report a readable funded amount for ` +
691
+ `${rows.length} wallet(s): ${wallets}. ${standing}` +
692
+ `how much money landed on it — do not state it as ` +
693
+ `zero, and do not state it as the requested amount. Move no money on this: ask the USER to ` +
694
+ `verify the actually-funded amount first ` +
695
+ `(\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`, or inspecting the wallet), and ` +
696
+ `only then decide whether anything needs topping up or resizing.`);
697
+ }
698
+ function buildDecisionModelRefusal(instance, pkg, budget) {
699
+ return (`[INVALID_REQUEST] Instance "${instance}" runs actions with decision_mode: llm, but no decision ` +
700
+ `model was given. Nothing was created. Re-run naming the model: ` +
701
+ `${resumeCommand(pkg.dir, budget)} --decision-model <model>`);
702
+ }
703
+ function buildDecisionModelEnvRefusal(instance, pkg) {
704
+ return (`[INVALID_REQUEST] Instance "${instance}" runs actions with decision_mode: llm, but ` +
705
+ `${pkg.dir}/strategy.yaml declares no defaults.decision_model_env — there is no token to ` +
706
+ `substitute the model into. Nothing was created. Add ` +
707
+ `defaults.decision_model_env to strategy.yaml, bind it in the runtime.yaml, then re-run.`);
708
+ }
709
+ /**
710
+ * An install that came up with NO live entry scanners (`payload.unwired`). The runtime came UP and
711
+ * is BLIND, and every one of the three phases is deterministic on this box — so this is an install
712
+ * failure, not a tick that has not landed yet.
713
+ *
714
+ * Says what is true (it will never tick), what was NOT done (no scanner ever mounted), and — the
715
+ * part that has to track reality — what is LEFT on the box. The two teardown outcomes leave
716
+ * different states, so they never share a sentence: naming `senpi runtime delete` after a
717
+ * successful delete points at a runtime that no longer exists, and omitting it after a failed one
718
+ * hides a runtime still running against a wallet this job is about to close.
719
+ *
720
+ * The NEXT STEP is per-phase for the same reason. One ladder ("delete, fix, re-run") cannot serve
721
+ * all three: only `install_wire` is fixable in the package, and on `no_intake` re-running is a loop
722
+ * that cannot terminate — create → fund → install → unwired → close, moving real money out and
723
+ * back on every iteration — because the cause is box-level and the intake context is built once at
724
+ * plugin register. No emitted command may require a precondition the state lacks.
725
+ */
726
+ function buildUnwiredInstallDetail(params) {
727
+ const { phase, runtimeId, wallet, createdByThisJob, removal } = params;
728
+ const { cause, next } = describeUnwiredPhase(phase, createdByThisJob);
729
+ // Deleting a runtime tears down DSL exit management for its wallet ADDRESS — it cancels the
730
+ // exchange stop-loss and drops the backend ratchet stop for every ACTIVE DSL there. On a wallet
731
+ // this job created, the rollback closes it moments later, so there is nothing left to be
732
+ // unmanaged; on an ADOPTED wallet nothing closes, so the consequence has to be said out loud.
733
+ const dslNote = createdByThisJob || !removal.ok
734
+ ? ""
735
+ : ` Deleting it also tore down DSL exit management for ${wallet}, which this deploy did not ` +
736
+ `create: any open position there has lost its ratchet stop and its exchange stop-loss, and ` +
737
+ `nothing is watching it — check with MCP \`strategy_get_clearinghouse_state\` on ${wallet} ` +
738
+ `before you leave this.`;
739
+ const left = removal.ok
740
+ ? `The runtime was deleted, so nothing is left running against this wallet; the cause is in ` +
741
+ `the gateway log (event \`runtime.scanners_unwired\`, phase ${phase}).${dslNote}`
742
+ : `The runtime could NOT be deleted (${removal.error}) and is STILL RUNNING against this ` +
743
+ `wallet — read it with \`openclaw senpi status -r ${runtimeId}\`, then remove it yourself: ` +
744
+ `\`openclaw senpi runtime delete ${runtimeId}\`.`;
745
+ return (`install produced a BLIND runtime "${runtimeId}": ${cause} (phase ${phase}), so it has no entry ` +
746
+ `scanners and would never tick. Counted as an install failure. ${left} ${next}`);
747
+ }
748
+ /**
749
+ * The cause and the next step for one unwired phase — computed from the phase, never templated.
750
+ *
751
+ * `no_intake`'s stake depends on what this job actually did: only a run that CREATES a wallet moves
752
+ * money on every doomed iteration. Asserting that on an adopted wallet (where a re-run installs and
753
+ * closes nothing) would overstate a warning that is already strong enough on its own facts.
754
+ */
755
+ function describeUnwiredPhase(phase, createdByThisJob) {
756
+ if (phase === "no_intake") {
757
+ const loop = createdByThisJob
758
+ ? `every re-run repeats create → fund → install → close and moves real money out and back each time`
759
+ : `every re-run repeats install → delete on this adopted wallet — no funds move, but nothing ` +
760
+ `is ever left watching it`;
761
+ return {
762
+ cause: `supervised scanner intake is disabled for this whole box, so no external scanner could ` +
763
+ `be mounted — nothing about this package or this runtime caused it`,
764
+ next: `Do NOT re-run the deploy yet: the cause is box-level and is decided once when the plugin ` +
765
+ `starts, so ${loop}. Repair or clear the plugin's \`acceptance-record.json\` state file ` +
766
+ `and RESTART the gateway first (the boot log names the file with its full path); re-run ` +
767
+ `only once a fresh install wires its scanners.`,
768
+ };
769
+ }
770
+ if (phase === "install_launch") {
771
+ return {
772
+ cause: `the supervised scanner launcher failed to start`,
773
+ next: `Read the launcher error in the gateway log before re-running — a re-run repeats it ` +
774
+ `verbatim while the cause is still there.`,
775
+ };
776
+ }
777
+ return {
778
+ cause: `the supervised scanner wiring failed (a packaging or entrypoint error in this package)`,
779
+ next: `Fix the scanner entrypoint this package declares (the \`path\`/module of each external ` +
780
+ `scanner in the instance runtime.yaml), then re-run the deploy.`,
781
+ };
782
+ }
783
+ /**
784
+ * A strategy the ACTIVE poll watched go DEAD. Terminal, so the poll stops on it — and the claim it
785
+ * replaces is the point: `buildCreatePendingDetail` would have said "wallet still funding … never
786
+ * creating a second wallet", and on this input neither half holds. It is not funding, and reconcile
787
+ * filters dead statuses on BOTH match routes, so a re-run creates and funds a fresh wallet beside
788
+ * it. The status only ever survived in the step evidence; here it leads the claim.
789
+ */
790
+ function buildCreateDeadDetail(pkg, budget, live, createdByThisJob) {
791
+ const origin = polledWalletOrigin(createdByThisJob);
792
+ const target = polledWalletTarget(live);
793
+ // A teardown instruction is earned HERE and only here: a dead status proves the strategy is not
794
+ // coming back, so money left on it really is stranded.
795
+ const funds = live.totalFunded === undefined
796
+ ? `The backend reported no readable funded amount for it, so whether anything is stranded is ` +
797
+ `UNKNOWN — read it with MCP \`strategy_list\` on ${target} before deciding.`
798
+ : live.totalFunded > 0
799
+ ? `It still reports ${usd(live.totalFunded)} funded: reclaim that FIRST with MCP ` +
800
+ `\`strategy_close\` on ${target}, or the money stays in a dead strategy.`
801
+ : `It reports ${usd(live.totalFunded)} funded, so there is likely nothing to reclaim — ` +
802
+ `confirm with MCP \`strategy_list\` on ${target} before re-running.`;
803
+ return (`strategy ${live.id} went ${live.status} while waiting for ACTIVE — a dead status is terminal, ` +
804
+ `so the wait stopped here rather than running out the clock. Nothing was installed. ${origin}. ` +
805
+ `${funds} A re-run does NOT adopt a dead strategy (reconcile filters dead statuses on both ` +
806
+ `match routes), so \`${resumeCommand(pkg.dir, budget)}\` WILL create and fund a NEW wallet beside ` +
807
+ `this one.`);
808
+ }
809
+ /**
810
+ * A strategy the ACTIVE poll watched settle on a status that is neither ACTIVE nor on the way
811
+ * there — PAUSED, mid-teardown, or one this build does not recognise.
812
+ *
813
+ * Only {@link INITIALIZING_STATUSES} resolve on their own, so only they are worth waiting on;
814
+ * everything else burned the full `--max-wait` and was then reported as "wallet still funding",
815
+ * which is false for every member of this set.
816
+ *
817
+ * The REMEDIATION branches, and must: the create step gets no D-6 rollback (only an install failure
818
+ * rolls a wallet back), so a wallet this job created and funded seconds ago is still live when this
819
+ * is read. One shared "reclaim it FIRST with `strategy_close`" told an agent to tear down a wallet
820
+ * on a status this build cannot interpret — the exact opposite of what
821
+ * {@link buildNotDeployableRefusal} says for the same input at the reconcile seam ("will not guess
822
+ * … NEVER close or recreate to 'start clean'"). The two seams now agree arm for arm: a teardown is
823
+ * offered only where the status PROVES the strategy is not coming back.
824
+ */
825
+ function buildCreateNotDeployableDetail(pkg, budget, live, createdByThisJob,
826
+ /**
827
+ * The scoped start-over clause — see {@link buildPackageCloseOffer}. Used by the PAUSED arm
828
+ * only. Completes the sentence "If you meant to start over instead, …" and carries its own
829
+ * terminal period; the arm appends nothing to it.
830
+ */
831
+ closeOffer) {
832
+ const status = live.status.toUpperCase();
833
+ const origin = polledWalletOrigin(createdByThisJob);
834
+ const rerun = `re-run \`${resumeCommand(pkg.dir, budget)}\``;
835
+ // `pkg.id` is single-quoted everywhere it reaches a shell, exactly as buildStrandedCloseCaveat
836
+ // quotes it: these strings get copied verbatim into a terminal.
837
+ const statusPy = `\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\``;
838
+ const head = `strategy ${live.id} is ${live.status} while this deploy waited for ACTIVE, so the wait stopped ` +
839
+ `here — this is not a funding delay. Nothing was installed. ${origin}. `;
840
+ if (status === "PAUSED") {
841
+ return (`${head}A paused strategy will not become ACTIVE on its own. Resume it, then ${rerun}. If you ` +
842
+ `meant to start over instead, ${closeOffer} Never leave it paused and fund a second wallet ` +
843
+ `beside it.`);
844
+ }
845
+ if (CLOSING_STATUSES.includes(status)) {
846
+ return (`${head}It is being torn down, and ${itsFunded(live.totalFunded)} returns to the owner wallet as ` +
847
+ `that finishes — nothing to reclaim by hand. Wait for it to reach CLOSED (re-check with ` +
848
+ `${statusPy}), then ${rerun} — the re-run creates a fresh wallet once this one is gone.`);
849
+ }
850
+ // Unrecognized. "Waiting cannot help" is a claim this build has no basis for: the status is in
851
+ // none of the sets it knows, so it may still be on its way to ACTIVE. Read-only triage only —
852
+ // and no teardown command, because nothing here proves the money is stranded.
853
+ return (`${head}This build does not recognize the status ${live.status} — it is in none of the ` +
854
+ `initializing, closing or dead sets this version knows, so deploy will not guess what it means ` +
855
+ `or whether waiting would have helped. Read-only triage FIRST: ${statusPy}, ` +
856
+ `\`openclaw senpi deploy status\`, or MCP \`strategy_get\` with strategy_id "${live.id}" (that tool takes ` +
857
+ `the id, never a wallet address). Then ${rerun} once it is ACTIVE. Do NOT close or recreate this ` +
858
+ `strategy to "start clean": ${itsFunded(live.totalFunded)} is not proven stranded, and closing a ` +
859
+ `strategy that was seconds from ACTIVE throws the wallet away.`);
860
+ }
861
+ /**
862
+ * The start-over clause for a PAUSED poll, scoped to this job's whole INTENT, never to a mid-loop
863
+ * snapshot.
864
+ *
865
+ * The string is frozen the moment the poll settles, but creates run one instance at a time and a
866
+ * `create: failed` does not halt the loop — a sibling still queued behind this one can be funded
867
+ * and installed AFTER the text is written. So "live" here means live-or-intended: a sibling that
868
+ * already holds a wallet, one this job is still going to create (`needsWallet`) or wait on
869
+ * (`awaitingActive`) — or one whose reconcile FAILED, whose live state is therefore unknown
870
+ * (the fail-closed `strategy_list` throw means "unknown, not empty", and unknown must withdraw
871
+ * like live does, never read as "not live"). Judging `report.wallet` alone let this same job
872
+ * falsify the "nothing else in this package is live" claim on the very report that carries it,
873
+ * two lines below the offer.
874
+ * ({@link buildStrandedCloseCaveat} may judge present state — it runs at rollback time, after the
875
+ * whole create phase.)
876
+ *
877
+ * The remedy split mirrors that caveat with opposite emphasis: there the command being scoped is a
878
+ * per-wallet `strategy_close` and the package-wide one is merely warned off; here the package-wide
879
+ * teardown IS the offer, so when any sibling is live or intended the command is WITHDRAWN and
880
+ * replaced by the per-strategy route. Reconcile can hand out `close.py <pkg>` unscoped because it
881
+ * finishes for every instance before any wallet exists; this seam cannot.
882
+ *
883
+ * Rendered mid-sentence after "If you meant to start over instead, " and ends with its own period
884
+ * — the caller appends nothing, so the seam cannot splice ungrammatically again.
885
+ */
886
+ function buildPackageCloseOffer(pkg, runs, self, live) {
887
+ const otherLive = runs.filter((run) => run !== self &&
888
+ (run.report.wallet ||
889
+ run.needsWallet ||
890
+ run.awaitingActive ||
891
+ run.report.steps.reconcile.status === "failed") &&
892
+ !run.report.rolledBack);
893
+ const funds = `(that returns ${itsFunded(live.totalFunded)})`;
894
+ const closePy = `\`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\``;
895
+ if (otherLive.length === 0) {
896
+ return (`tear this one down first with ${closePy} ${funds}, then re-run — nothing else in this ` +
897
+ `package is live or queued for funding by this deploy, so that command closes exactly this ` +
898
+ `strategy.`);
899
+ }
900
+ const perStrategy = live.strategyWalletAddress
901
+ ? `MCP \`strategy_close\` on wallet ${live.strategyWalletAddress}`
902
+ : `MCP \`strategy_close\` on its wallet (read the address with \`strategy_get\`, strategy_id ` +
903
+ `"${live.id}" — its address is unreadable here)`;
904
+ return (`tear THIS one down first with ${perStrategy} ${funds}, then re-run. Do NOT reach for ` +
905
+ `${closePy} to do it: it closes every live strategy in this package, and ${otherLive.length} ` +
906
+ `other sleeve(s) (${otherLive.map((run) => run.inst.name).join(", ")}) belong to this same ` +
907
+ `deploy — any of them live by the time you read this would be closed too.`);
908
+ }
909
+ /** Who funded the polled wallet — shared by both break-outs so they can never disagree. */
910
+ function polledWalletOrigin(createdByThisJob) {
911
+ return createdByThisJob
912
+ ? `This deploy created it`
913
+ : `This deploy adopted it and funded nothing — it predates this run`;
914
+ }
915
+ /** How to name the polled wallet in a command, address-first and never `undefined`. */
916
+ function polledWalletTarget(live) {
917
+ return live.strategyWalletAddress
918
+ ? `wallet ${live.strategyWalletAddress}`
919
+ : `strategy id ${live.id} (its wallet address is unreadable)`;
920
+ }
921
+ function buildCreatePendingDetail(pkg, budget, waitedSec) {
922
+ return (`wallet still funding after ${waitedSec}s — re-run \`${resumeCommand(pkg.dir, budget)}\` to resume ` +
923
+ `(nothing is lost; reconciliation matches this strategy again and waits for it to reach ACTIVE, ` +
924
+ `never creating a second wallet)`);
925
+ }
926
+ /**
927
+ * Live wallets carry this package's stamp, and none of them answered for this instance.
928
+ *
929
+ * Rendered from the reconcile read alone — no additional network calls, because a gate that can
930
+ * fail to render is a gate that sometimes does not fire. Every column is quoted from that read and
931
+ * says so when it is unreadable: an unread funded amount is the one number a reader would otherwise
932
+ * take for an empty wallet. The name column is there because a missing/differing name is WHY the
933
+ * match failed, and it is the fact that tells a reader whether this is old-tooling residue or a
934
+ * backend that renamed what deploy created.
935
+ *
936
+ * The destructive route is named LAST and behind the read-only one, with its real blast radius on
937
+ * the same line: a refusal that names only a teardown gets followed.
938
+ *
939
+ * The consequence sentence is hedged for the same reason the columns are: this read is metadata,
940
+ * so "left funded, in position and supervised by nothing" would assert two things nothing here
941
+ * looked at (no position read, no `registryFindById`/`runtimeIsLive` call) — and the second can be
942
+ * plainly false, since install rebinds only the registry row for `<id>-<instance>` and a wallet
943
+ * carrying a different runtime id keeps its runtime. What is unread is named as unread; that is
944
+ * the argument for stopping, not a weaker version of it.
945
+ */
946
+ function buildBindingUnknownRefusal(pkg, instance, wantedName, candidates, budget) {
947
+ const rows = candidates
948
+ .map((s) => {
949
+ const wallet = s.strategyWalletAddress?.trim() || "(wallet address unreadable)";
950
+ const funded = s.totalFunded === undefined ? "funded amount unreadable" : usd(s.totalFunded);
951
+ const created = s.createdAt ? `created ${s.createdAt.slice(0, 10)}` : "creation date unreadable";
952
+ const named = s.strategyName?.trim() ? `named "${s.strategyName.trim()}"` : "no name reported";
953
+ return ` ${wallet} ${funded} ${s.status} ${created} ${named}`;
954
+ })
955
+ .join("\n");
956
+ return (`[E_INSTANCE_BINDING_UNKNOWN] ${pkg.id}: ${candidates.length} live wallet(s) carry this package's ` +
957
+ `stamp, and none of them answers for instance "${instance}".\n\n${rows}\n\n` +
958
+ `Nothing was created and no money moved. Deploying now would fund a NEW wallet beside the ` +
959
+ `one(s) above and rebind instance "${instance}"'s runtime id onto it. This read is metadata ` +
960
+ `only: whether the wallet(s) above hold open positions, and whether any runtime is still ` +
961
+ `watching them, was NOT read here — which is exactly why deploying past it is a bet rather ` +
962
+ `than a decision.\n\n` +
963
+ `Read what each wallet is doing before deciding anything:\n` +
964
+ ` \`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`\n\n` +
965
+ `Deploy binds an existing strategy two ways — by the name instance "${instance}" answers to ` +
966
+ `("${wantedName}"), and by a create key journaled on this box — and neither reached the ` +
967
+ `wallet(s) above. If one of them IS this instance under a name deploy does not know, this verb ` +
968
+ `has no way to bind to it, so re-running changes nothing. What each wallet is and whether it is ` +
969
+ `still wanted is the USER's call, made from the read above — deploy will not pick one. Only if ` +
970
+ `the USER confirms these wallets are unwanted, close them: ` +
971
+ `\`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\` returns their funds and tears down ` +
972
+ `the WHOLE ${pkg.id} package, every sleeve and runtime in it. Then re-run ` +
973
+ `\`${resumeCommand(pkg.dir, budget)}\`.`);
974
+ }
975
+ /**
976
+ * Every live strategy under the name this instance answers to is stamped for a DIFFERENT package.
977
+ *
978
+ * The counterpart to {@link buildBindingUnknownRefusal} on the other match route, and the harder of
979
+ * the two to word: nothing here is the operator's to close. The wallets named belong to another
980
+ * package — its positions, its balance, possibly its running runtime — so the only routes offered
981
+ * are edits to THIS package and reads. Re-running is explicitly not one of them: the name is
982
+ * derived, so it comes out identical every time, which is exactly why the collision is structural
983
+ * rather than transient.
984
+ *
985
+ * Neither is "set `id:` back to the stamp and re-run", which this refusal used to offer. The name
986
+ * is derived FROM the id ({@link walletName}: `sanitizeStrategyName(id)`, or `<id>-<instance>` for
987
+ * a multi-sleeve package), and a loadable id is sanitize-stable by construction
988
+ * (`assertIdentityRoundTrips`), so changing the id to the stamp changes the derived name by exactly
989
+ * as much — the run after the edit looks for a name the matched wallet does not carry, finds
990
+ * nothing, and refuses again from the gate instead, whose remedy is the whole-package `close.py`
991
+ * this refusal's last line forbids. Only the id AND the sleeve layout restored together re-derive
992
+ * the matched name, which is a restructure of this package, not a field edit — so the route ends at
993
+ * the read and hands the decision to the user.
994
+ */
995
+ function buildForeignPackageRefusal(pkg, instance, wantedName, candidates, budget) {
996
+ const rows = candidates
997
+ .map((s) => {
998
+ // Same absence rules as every other rendered candidate list: an unread field says so rather
999
+ // than rendering as a blank or a `$0.00` the reader would act on.
1000
+ const wallet = s.strategyWalletAddress?.trim() || "(wallet address unreadable)";
1001
+ const funded = s.totalFunded === undefined ? "funded amount unreadable" : usd(s.totalFunded);
1002
+ const claimant = s.skillName?.trim() || "(stamp unreadable)";
1003
+ return ` ${wallet} ${funded} ${s.status} stamped "${claimant}"`;
1004
+ })
1005
+ .join("\n");
1006
+ // Deduped, because the sentence names WHOSE they are and repeating one id per row reads as more
1007
+ // packages than were found.
1008
+ const stamps = [...new Set(candidates.map((s) => s.skillName?.trim()).filter((v) => !!v))];
1009
+ const whose = stamps.length > 0 ? stamps.map((s) => `"${s}"`).join(", ") : "another package";
1010
+ // Computed from the package as loaded, not templated: a flat package's name IS its id, and
1011
+ // telling its author to rename an instance it does not have is an edit they cannot make.
1012
+ const multi = pkg.instances.length > 1;
1013
+ const derivedFrom = multi
1014
+ ? `package id "${pkg.id}" + instance name "${instance}"`
1015
+ : `package id "${pkg.id}"`;
1016
+ const renameWhat = multi ? "the package `id:` or this instance's `name:`" : "the package `id:`";
1017
+ // A stamp is BACKEND text, not a package id this box loaded, so it is not shell-safe by
1018
+ // construction the way `pkg.id` is (`assertIdentityRoundTrips` guarantees that one). A stamp that
1019
+ // is not a legal package id is named in prose instead of pasted into a command — an emitted
1020
+ // command has to be runnable, and one carrying a quote is worse than none.
1021
+ const reads = stamps
1022
+ .map((s) => sanitizeStrategyName(s) === s
1023
+ ? ` \`python3 senpi-strategy-ops/scripts/status.py '${s}'\``
1024
+ : ` (no command for the stamp ${JSON.stringify(s)} — it is not a usable package id; look ` +
1025
+ `the wallet up by address with \`openclaw senpi status\` / \`strategy_list\`)`)
1026
+ .join("\n");
1027
+ return (`[E_WALLET_OWNED_BY_OTHER_PACKAGE] ${pkg.id}: instance "${instance}" answers to the strategy ` +
1028
+ `name "${wantedName}", and every live strategy under that name carries another package's ` +
1029
+ `attribution stamp (${whose}).\n\n${rows}\n\n` +
1030
+ `Nothing was created and no money moved. Deploy binds an existing strategy BY NAME, so ` +
1031
+ `adopting one of the above would have pointed this package's runtime at another package's ` +
1032
+ `funded wallet — its balance and its open positions — and, if something is already watching ` +
1033
+ `that wallet, left two exit engines on one margin account. This read is metadata only: what ` +
1034
+ `those wallets hold, and whether anything is watching them, was NOT read here.\n\n` +
1035
+ `Re-running changes nothing — "${wantedName}" is derived from ${derivedFrom}, so it comes out ` +
1036
+ `the same every time. Two ways forward, neither of them a money move:\n\n` +
1037
+ ` • The wallet(s) above are not this package's. Two packages collide on one derived name: ` +
1038
+ `give this one a name of its own by editing ${renameWhat} in ` +
1039
+ `${join(pkg.dir, "strategy.yaml")}, then re-run \`${resumeCommand(pkg.dir, budget)}\` — deploy ` +
1040
+ `will create this package's own wallet.\n` +
1041
+ ` • One of them IS this package's, deployed under a package id it no longer has. Editing ` +
1042
+ `\`id:\` back to the stamp does NOT reach it: "${wantedName}" is derived from ${derivedFrom}, ` +
1043
+ `so that edit moves the name deploy looks for along WITH the id — the next run stops looking ` +
1044
+ `for "${wantedName}" at all and refuses again, having bound nothing. What produced ` +
1045
+ `"${wantedName}" is the id and the sleeve layout together, so restoring both as they were is ` +
1046
+ `what would bind it by name; whether this package should go back to that shape is the USER's ` +
1047
+ `call, and a metadata read cannot settle it. Read what each wallet is doing first:\n` +
1048
+ `${reads}\n\n` +
1049
+ `Do NOT close a wallet stamped for another package to clear this — that is a different ` +
1050
+ `strategy's funds and a different runtime's positions. Whose these are is the USER's call.`);
1051
+ }
1052
+ /**
1053
+ * Refusal for a package whose instances ship no DSL exit block. Rendered BEFORE any wallet exists:
1054
+ * funding a strategy that cannot stop itself out is the "funded-but-no-DSL" hole, and the skills-side
1055
+ * validator has always refused it.
1056
+ */
1057
+ function buildNoDslExitRefusal(instances, pkg) {
1058
+ return (`[INVALID_REQUEST] Instance(s) ${instances.map((inst) => inst.name).join(", ")} declare no DSL ` +
1059
+ `exit block (\`exit.dsl_preset\`, or \`exit.engine: dsl\`) — every position they open would run ` +
1060
+ `with no stop loss and no trailing floor. Nothing was created. Add an exit block to the instance ` +
1061
+ `runtime.yaml, then re-check read-only with either route:\n` +
1062
+ // One `senpi validate` per OFFENDING instance directory, computed from the instance rather than
1063
+ // templated: the offence is per-recipe, and so is the proof that clears it.
1064
+ `${recheckBlock(instances.map((inst) => inst.runtimeYamlDir), deployPyValidateCommand(pkg.dir))}\n` +
1065
+ `${PROOF_ROUTE_NOTE} Then re-run.`);
1066
+ }
1067
+ /**
1068
+ * A package id carrying capitals, refused before any wallet exists to carry the wrong stamp.
1069
+ *
1070
+ * Deploy's copy of `register.ts`'s gate — same rule, same fix (built from the one producer in
1071
+ * `package.ts`, so the edit an author reads is word-for-word identical), different standing
1072
+ * sentence: nothing was CREATED here, where the gateway's copy could only say nothing was started.
1073
+ *
1074
+ * The re-run command names the directory the fix LEAVES BEHIND, not the one that failed — this is
1075
+ * the only refusal in the verb whose own instruction can move the package.
1076
+ */
1077
+ function buildNonLowercaseIdRefusal(pkg, budget) {
1078
+ const fix = lowercasePackageIdFix(pkg.id, pkg.dir);
1079
+ return (`[INVALID_REQUEST] strategy.yaml id "${pkg.id}" must be lowercase. Nothing was created. ` +
1080
+ `Fix — ${fix.instruction}. Why: this verb writes the id into each wallet's \`skillName\` ` +
1081
+ `attribution stamp VERBATIM at create, and the backend stores what it is given ` +
1082
+ `case-normalized, so a mixed-case id is stamped under one spelling and looked up under ` +
1083
+ `another — that is how \`close.py '${pkg.id}'\` came to report "no OPEN strategies to close." ` +
1084
+ `over a live, funded, trading wallet. Every reader now folds case, so wallets already stamped ` +
1085
+ `in any case are still found and this package will ADOPT the ones it already owns rather than ` +
1086
+ `fund a second set beside them. Then re-run \`${resumeCommand(fix.dirAfterFix, budget)}\`.`);
1087
+ }
1088
+ /**
1089
+ * The install gate's refusal, rendered for deploy rather than for the gateway.
1090
+ *
1091
+ * Same findings, different standing sentence: the gateway's copy says a recipe cannot be installed,
1092
+ * which is all that is true there. Here the run had money to move and did not move it, and that is
1093
+ * the fact a reader needs first — so it is stated, and the re-check names `senpi validate` on the
1094
+ * instance directory rather than "run validate on the package" in the abstract.
1095
+ *
1096
+ * Deploy's refusal shape: bracketed code first (taxonomy.ts:16 reads it), then what/why/fix.
1097
+ */
1098
+ function buildInstallGateRefusal(gate, inst, pkg, budget) {
1099
+ const lines = gate.blocking.map((f) => ` • ${f.what}\n why: ${f.why}\n fix: ${f.fix}`);
1100
+ return (`[${gate.blocking[0].code}] "${inst.name}" cannot be installed — it would not trade.\n\n` +
1101
+ `${lines.join("\n\n")}\n\n` +
1102
+ `Nothing was created by this run — the check reads the package only, and it stopped before ` +
1103
+ `touching the backend.\n\n` +
1104
+ `Fix the recipe, then re-check with: ${validateCommand(inst.runtimeYamlDir)}\n` +
1105
+ // The same command `next` ends the report with, budget and all: a re-run line that differs
1106
+ // from it — a `<usd>` placeholder where the report has the real number — is a second, worse
1107
+ // command competing with the authoritative one.
1108
+ `Then re-run: ${resumeCommand(pkg.dir, budget)}`);
1109
+ }
1110
+ /**
1111
+ * The gate itself could not run. NOT a verdict on the recipe — so it carries no taxonomy code and
1112
+ * names no offending field, exactly like the universe check's `unavailable` branch.
1113
+ */
1114
+ function buildInstallGateUnavailableDetail(inst, reason) {
1115
+ return (`The install gate could not run for "${inst.name}" (${reason}), so nothing is known about ` +
1116
+ `whether this recipe can trade. Nothing was created by this run. Re-run the deploy; if it ` +
1117
+ // The real directory, never a `<package dir>` placeholder: this line is the one command the
1118
+ // reader is given, and a command that has to be edited before it runs is a command an agent
1119
+ // pastes literally. The INSTANCE's directory, not the package root: the gate ran for one
1120
+ // sleeve, and on a multi-instance package the root is not a validate target at all — it
1121
+ // answers "this directory has no recipe of its own, pick an instance".
1122
+ `persists, run: ${validateCommand(inst.runtimeYamlDir)}`);
1123
+ }
1124
+ /**
1125
+ * D-2 (revised 2026-08-05): a scanner-level `enabled` key is refused outright — the engine never
1126
+ * reads it (see {@link scannersDeclaringEnabled}), so accepting `enabled: false` would ship a
1127
+ * strategy that ticks and trades while its author believes it is switched off. Package-derivable,
1128
+ * so it is decided synchronously here rather than discovered later; observe keeps its own
1129
+ * all-disabled branch for the live-state case, which the runtime API can genuinely produce.
1130
+ *
1131
+ * Every offense is enumerated in ONE refusal, each as a delete-this-line instruction naming the
1132
+ * key, the scanner, the instance and the file: a reader (agent or human) fixes the whole package in
1133
+ * one pass instead of re-running to discover the next one.
1134
+ */
1135
+ function buildScannerEnabledUnsupportedRefusal(offenders, pkg) {
1136
+ return (`[INVALID_REQUEST] Unsupported \`enabled\` key on ${offenders.length} scanner entr` +
1137
+ `${offenders.length === 1 ? "y" : "ies"}. Nothing was created. Fix — delete these lines:\n` +
1138
+ offenders.map((entry) => ` - ${describeScannerEnabledOffense(entry)}`).join("\n") +
1139
+ `\nWhy: the runtime engine never reads a scanner-level \`enabled\` (the scanner schema passes ` +
1140
+ `it through untyped and registration comes from the strategy, not the scanner entry), so an ` +
1141
+ `\`enabled: false\` scanner registers ENABLED and ticks — the package would trade while you ` +
1142
+ `believe it is off. A scanner runs because the package declares it: to stop one, remove the ` +
1143
+ `scanner entry itself, or disable it after install through the runtime API (\`disableScanner\`). ` +
1144
+ `Then re-run: ${resumeCommand(pkg.dir)}`);
1145
+ }
1146
+ /**
1147
+ * A create this job stopped waiting on at its per-call deadline. Deliberately NOT phrased as
1148
+ * "creation failed": the deadline bounds the WAIT, not the backend's work, so the wallet may come
1149
+ * into existence after this line is written. Naming both reconcile routes — and the one case
1150
+ * neither can see — is what lets the operator check rather than guess.
1151
+ */
1152
+ function buildCreateAbandonedDetail(args) {
1153
+ const { instance, strategyName, nameFallbackFired, fundAmount, message, pkg, budget } = args;
1154
+ const head = `wallet creation for instance "${instance}" was abandoned at its deadline, so its outcome is ` +
1155
+ `UNKNOWN — this is not "nothing happened": ${message ?? "no message"}. The backend may still ` +
1156
+ `complete the create and fund ${usd(fundAmount)} after this report. Nothing else was done for ` +
1157
+ `this instance, and nothing was rolled back.`;
1158
+ // Which create was abandoned is KNOWN, so neither branch hedges. A named create that lands is
1159
+ // findable by name; the unnamed retry is findable by nothing — it lands under a backend-assigned
1160
+ // name and this box never received the strategyId to journal. In that case re-running FIRST is
1161
+ // the money-losing move, so the check leads.
1162
+ const tail = nameFallbackFired
1163
+ ? ` The name "${strategyName}" had already been rejected, so this second create asked for NO ` +
1164
+ `name: if it landed, the wallet carries a backend-assigned name and no create key ever ` +
1165
+ `reached this box — a re-run can find it by neither route and would fund a SECOND wallet ` +
1166
+ `beside it. Check before you re-run: list your strategies (MCP \`strategy_list\`) and look ` +
1167
+ `for a CUSTOM strategy created just now, funded about ${usd(fundAmount)}. If it is there, ` +
1168
+ `that is this instance's wallet — keep it, or close it with ` +
1169
+ `\`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\` before re-running. If it is not ` +
1170
+ `there, the create did not land: re-run \`${resumeCommand(pkg.dir, budget)}\`.`
1171
+ : ` The create asked for the name "${strategyName}", so if it landed the wallet carries that ` +
1172
+ `name and reconcile adopts it instead of funding a second one: re-run ` +
1173
+ `\`${resumeCommand(pkg.dir, budget)}\`. If that re-run creates a fresh wallet and your ` +
1174
+ `accessible balance looks short afterwards, check the wallet list (MCP \`strategy_list\`) ` +
1175
+ `and close the orphan: \`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\`.`;
1176
+ return head + tail;
1177
+ }
1178
+ /**
1179
+ * The name-rejection retry that was NOT issued because the deadline stop landed between the two
1180
+ * creates.
1181
+ *
1182
+ * Not a variant of {@link buildCreateAbandonedDetail}: nothing was abandoned mid-call here. One
1183
+ * create was issued and the backend answered it; the second one — the money-moving call this run
1184
+ * would otherwise have made with no step boundary in front of it — never went out. So the line says
1185
+ * what was attempted, what was not, and leaves the rejected create's own residue to the same check
1186
+ * every rejection carries: reconcile re-reads the live list before it creates anything.
1187
+ */
1188
+ function buildCreateNameRetryNotIssuedDetail(args) {
1189
+ const { instance, strategyName, fundAmount, message, pkg, budget } = args;
1190
+ return (`wallet creation for instance "${instance}" stopped at the deploy deadline. The backend rejected ` +
1191
+ `the name "${strategyName}" (${message ?? "no message"}), and the unnamed retry that normally ` +
1192
+ `follows was NOT issued: a create started now would take a fresh 90s deadline of its own, ` +
1193
+ `outliving the grace period that hands the deploy slot to the next run — this job could still ` +
1194
+ `be waiting on a create funding ${usd(fundAmount)} while a new deploy runs its own preflight ` +
1195
+ `against the same balance. One create was issued for this instance and the backend rejected it; ` +
1196
+ `no second wallet was asked for, and nothing was rolled back. Re-run ` +
1197
+ `\`${resumeCommand(pkg.dir, budget)}\` — reconcile re-reads your live strategies before it ` +
1198
+ `creates anything, and a wallet that landed under the name "${strategyName}" despite the ` +
1199
+ `rejection is exactly what it looks for.`);
1200
+ }
1201
+ function buildUnobservedDetail(waitSec, runtimeId) {
1202
+ return (`no tick observed within ${waitSec}s — scanners may tick on a longer interval. ` +
1203
+ `Check in a few minutes: \`openclaw senpi scanner -r ${runtimeId}\``);
1204
+ }
1205
+ function buildObserveFailedDetail(row, runtimeId) {
1206
+ return (`scanner "${row.scannerId}" ran and failed: lastRunStatus=error errorCount=${row.errorCount} ` +
1207
+ `lastError=${JSON.stringify(row.lastError)}. Read \`openclaw senpi scanner -r ${runtimeId}\`, fix the ` +
1208
+ `scanner code — do NOT close/recreate the strategy.`);
1209
+ }
1210
+ function buildNext(overall, pkg, budget, runs) {
1211
+ switch (overall) {
1212
+ case "live":
1213
+ return undefined;
1214
+ case "refused":
1215
+ return `Nothing was created past the refusal above. Resolve it, then re-run: \`${resumeCommand(pkg.dir, budget)}\``;
1216
+ case "pending":
1217
+ return `re-run \`${resumeCommand(pkg.dir, budget)}\` to resume — reconciliation adopts what exists; nothing is lost`;
1218
+ case "failed":
1219
+ return `Read the failed step above, fix the cause, then re-run: \`${resumeCommand(pkg.dir, budget)}\``;
1220
+ case "installed-unobserved": {
1221
+ const runtimeId = runs.find((run) => run.report.runtimeId)?.report.runtimeId;
1222
+ // A `<runtimeId>` placeholder inside a backticked command is a command an agent pastes
1223
+ // literally — and `<` is a shell redirect, so it fails in a way that says nothing about the
1224
+ // deploy. With no id to name, name the command that needs none.
1225
+ return runtimeId
1226
+ ? `no verified tick yet — check in a few minutes: \`openclaw senpi scanner -r ${runtimeId}\``
1227
+ : `no verified tick yet — this report carries no runtime id: list them with ` +
1228
+ `\`openclaw senpi runtime list\` and check this package's runtime in a few minutes`;
1229
+ }
1230
+ }
1231
+ }
1232
+ function notReached() {
1233
+ return { status: "pending", detail: "not reached" };
1234
+ }
1235
+ /**
1236
+ * Record that an instance is bound to `strategyId` — the WRITE half of the reconcile claim ledger
1237
+ * (see `claimed` in {@link runDeploy}).
1238
+ *
1239
+ * An unreadable id is not claimable. `listStrategies` coerces a missing or unparseable id to `""`
1240
+ * (senpi/client.ts), and a `""` in the ledger answers "already accounted for" for every OTHER
1241
+ * unreadable-id wallet in the same read — which is exactly how a second live funded wallet became
1242
+ * invisible to the gate. Not recording it is the fail-closed direction: the gate keeps presenting
1243
+ * it and the run refuses rather than funding beside it.
1244
+ *
1245
+ * **Either this guard or {@link isAccountedFor}'s alone closes that hole; both are kept
1246
+ * deliberately.** Delete this one and the suite still passes on the deploy paths — the read guard
1247
+ * covers it. That is defence in depth, not dead code, and `orchestrator.test.ts` pins each half
1248
+ * separately so neither can be removed as redundant.
1249
+ */
1250
+ export function claimStrategyId(claimed, strategyId) {
1251
+ if (strategyId)
1252
+ claimed.add(strategyId);
1253
+ }
1254
+ /**
1255
+ * Is this live strategy PROVEN to be accounted for by an instance of this run? — the READ half of
1256
+ * the same ledger.
1257
+ *
1258
+ * Fail-closed on an unreadable id: it can never have been claimed and can never be matched, so it
1259
+ * can never be proven accounted for. Resting that on `claimed.has("")` happening to answer false is
1260
+ * not a property to give a money gate — it is only true while the write half holds. Same rule this
1261
+ * file already applies to an unreadable `totalFunded` and an unreadable `skillName`: unknown is
1262
+ * never zero, and unknown is never "not ours".
1263
+ *
1264
+ * **Either this guard or {@link claimStrategyId}'s alone closes the hole; both are kept
1265
+ * deliberately** — see there.
1266
+ */
1267
+ export function isAccountedFor(claimed, strategyId) {
1268
+ return Boolean(strategyId) && claimed.has(strategyId);
1269
+ }
1270
+ /**
1271
+ * Run one phase inside an injected span, surviving a tracer that throws.
1272
+ *
1273
+ * Same rule as narration's `emit` and `log` — telemetry may not decide what a deploy does — but
1274
+ * with the largest blast radius of the three: the span wraps EVERY phase, including create and
1275
+ * install, so a throwing tracer escaped `runDeploy` after a wallet had been funded and left the job
1276
+ * layer synthesizing an empty `instances: []` report over a live funded wallet.
1277
+ *
1278
+ * It is deliberately NOT "on failure, run the body": **the body is only safe to run once.** A
1279
+ * tracer that fails while ENDING a span has already run it, and re-running `deploy.create` is a
1280
+ * second funded wallet — telemetry causing the exact loss this file is built to prevent. So the
1281
+ * body is invoked through a one-shot handle and the two cases are told apart by whether it fired:
1282
+ *
1283
+ * - the tracer failed BEFORE reaching the body — run it here, un-spanned, and return its value;
1284
+ * - the tracer failed AFTER the body ran — the body's own outcome is the truth, including its
1285
+ * rejection if it had one. A tracer that masks a failing body with an error of its own therefore
1286
+ * cannot hide the real cause: what surfaces is the body's error, not the tracer's.
1287
+ *
1288
+ * Module-scope and exported for ONE reason: the synchronous-throw edge below is unreachable through
1289
+ * `runDeploy` (every call site passes an async arrow), so it can only be pinned here.
1290
+ *
1291
+ * @param spanFn the injected tracer — assumed to call `handle` at most once; calling it more often
1292
+ * is harmless, since the handle collapses to the first invocation either way
1293
+ * @param log narration for the degraded path; must not throw (callers pass the guarded wrapper)
1294
+ * @returns whatever the body resolves with — never the tracer's own value on a failure
1295
+ */
1296
+ export async function spanOnce(spanFn, log, name, attrs, body) {
1297
+ let started;
1298
+ /**
1299
+ * `async () => body()` rather than `body()`, and it is load-bearing: a body that throws
1300
+ * SYNCHRONOUSLY never completes the assignment, so `started` stays undefined, the catch reads
1301
+ * that as "the tracer never reached it", and the body runs a SECOND time. On `deploy.create` that
1302
+ * is the double-fund this helper exists to prevent, delivered by the helper itself. Wrapping
1303
+ * turns every failure into a rejection, so "did it start" is decided by the call and never by how
1304
+ * the body failed.
1305
+ */
1306
+ const once = () => (started ??= (async () => body())());
1307
+ try {
1308
+ return await spanFn(name, attrs, once);
1309
+ }
1310
+ catch (err) {
1311
+ if (started !== undefined)
1312
+ return started;
1313
+ log(`deploy: span "${name}" failed before the phase ran (${errorText(err)}) — running it un-spanned`);
1314
+ return once();
1315
+ }
1316
+ }
1317
+ /** Worst-first, so a phase's one-word summary is always its most serious outcome. */
1318
+ const STATUS_SEVERITY = ["failed", "refused", "pending", "unobserved", "skipped", "ok"];
1319
+ /** The phase's worst recorded step status — the `status` dimension of its span and metric. */
1320
+ function phaseStatus(runs, step) {
1321
+ const seen = runs.filter((run) => run.recorded.has(step)).map((run) => run.report.steps[step].status);
1322
+ if (seen.length === 0)
1323
+ return "not-reached";
1324
+ return STATUS_SEVERITY.find((status) => seen.includes(status)) ?? "ok";
1325
+ }
1326
+ function newInstanceRun(pkg, inst) {
1327
+ return {
1328
+ inst,
1329
+ strategyName: walletName(pkg, inst),
1330
+ needsWallet: false,
1331
+ recorded: new Set(),
1332
+ report: {
1333
+ instance: inst.name,
1334
+ steps: {
1335
+ reconcile: notReached(),
1336
+ preflight: notReached(),
1337
+ create: notReached(),
1338
+ install: notReached(),
1339
+ observe: notReached(),
1340
+ },
1341
+ },
1342
+ };
1343
+ }
1344
+ /**
1345
+ * Sequential orchestration of one deploy. Resolves with a report in every case —
1346
+ * refusals and failures are reported, never thrown, so the job layer always has
1347
+ * a truthful terminal state to journal.
1348
+ */
1349
+ export async function runDeploy(params, deps) {
1350
+ const { pkg, budget, decisionModel } = params;
1351
+ const maxWaitSec = params.maxWaitSec ?? DEFAULT_MAX_WAIT_SEC;
1352
+ const tickWaitSec = params.tickWaitSec ?? DEFAULT_TICK_WAIT_SEC;
1353
+ const runs = pkg.instances.map((inst) => newInstanceRun(pkg, inst));
1354
+ let refused = false;
1355
+ let abandoned = false;
1356
+ let summariesOnce = null;
1357
+ /**
1358
+ * Strategy ids an instance of this package is BOUND to — adopted, or waited on for ACTIVE.
1359
+ *
1360
+ * The gate's question is "which stamped wallets did nothing account for" — a wallet a sibling
1361
+ * sleeve just matched is accounted for, and presenting it to the next sleeve as unexplained
1362
+ * refuses a legitimate partial deploy and points the reader at a teardown of the live sleeve.
1363
+ *
1364
+ * Membership is written by the binding branches of `applyCandidates` themselves, at the same
1365
+ * statement that binds. That placement is the invariant, not a convention layered over one: a
1366
+ * claim that does not bind is unrepresentable, so no editor has to remember the rule and no
1367
+ * checker has to enforce it. The set was previously filled ahead of every branch, which made
1368
+ * "answered for" and "bound to" different things and left the gate one forgetful branch away from
1369
+ * hiding a live funded wallet and letting the deploy fund a second one beside it.
1370
+ *
1371
+ * What is left rests on two properties, in this order of importance:
1372
+ *
1373
+ * - **Forgetting to claim is SAFE.** A future branch that binds but does not claim under-claims,
1374
+ * so the gate sees an accounted-for wallet as unaccounted and REFUSES. That is a false refusal,
1375
+ * which is annoying; the opposite error funds a second wallet beside a live one. The residual
1376
+ * failure mode points at the fail-closed side, which is the whole reason the claim moved.
1377
+ * - It is COMPLETE before it is read. `boundaryAbandoned()` returns true on `halted()` before it
1378
+ * consults `deps.shouldAbandon()`, and pass 2 opens with it — so pass 2 runs only when pass 1
1379
+ * ran to the end over every instance. That, not iteration order, is why the gate's answer does
1380
+ * not depend on the order the sleeves are declared in.
1381
+ *
1382
+ * The branches that answer for a candidate and REFUSE claim nothing, and do not need to: each
1383
+ * sets `refused`, `halted()` makes pass 2 a no-op, and a refused sibling can never coexist with a
1384
+ * gated peer in one job. Their claims were always moot — only unsafe to reason about.
1385
+ */
1386
+ const claimed = new Set();
1387
+ /**
1388
+ * The SOFT tier's findings, filled in by the preflight and carried into the final report.
1389
+ *
1390
+ * Only FACTS are stored, never the finished note: the escape depends on what the deploy ends up
1391
+ * having created, which is not known until the report is assembled.
1392
+ */
1393
+ const soft = { min: null, under: [] };
1394
+ /** Narration is fire-and-forget: a broken journal must never change what a deploy does. */
1395
+ const emit = (event) => {
1396
+ try {
1397
+ deps.emit(event);
1398
+ }
1399
+ catch {
1400
+ /* narration only */
1401
+ }
1402
+ };
1403
+ /**
1404
+ * The same rule as {@link emit}, for the same reason: `deps.log` is an injected collaborator, and
1405
+ * it is called from the very catch blocks that exist to keep the report alive. A throwing logger
1406
+ * took the original error with it, escaped `runDeploy`, and left the job layer synthesizing an
1407
+ * empty `instances: []` report — narration deciding the outcome of a deploy that had already
1408
+ * created and funded a wallet.
1409
+ */
1410
+ const log = (line) => {
1411
+ try {
1412
+ deps.log(line);
1413
+ }
1414
+ catch {
1415
+ /* narration only */
1416
+ }
1417
+ };
1418
+ const emitPhase = (phase, detail) => emit({ type: "job", phase, detail });
1419
+ const record = (run, step, outcome) => {
1420
+ run.report.steps[step] = outcome;
1421
+ run.recorded.add(step);
1422
+ emit({
1423
+ type: "step",
1424
+ instance: run.inst.name,
1425
+ step,
1426
+ status: outcome.status,
1427
+ detail: outcome.detail,
1428
+ ...(outcome.evidence ? { evidence: outcome.evidence } : {}),
1429
+ });
1430
+ };
1431
+ const halted = () => refused || abandoned;
1432
+ /** Marks every step not yet recorded as abandoned — for every instance, not just this one. */
1433
+ const honorDeadline = () => {
1434
+ abandoned = true;
1435
+ for (const run of runs) {
1436
+ for (const step of STEP_ORDER) {
1437
+ if (!run.recorded.has(step)) {
1438
+ record(run, step, { status: "skipped", detail: ABANDONED_DETAIL });
1439
+ }
1440
+ }
1441
+ }
1442
+ };
1443
+ /**
1444
+ * Run one step's body, converting a thrown dependency into a recorded `failed` outcome.
1445
+ *
1446
+ * `runDeploy` promises a report in EVERY case: a throw from any injected collaborator (an MCP
1447
+ * timeout on the create poll, a registry read error) must not escape and leave the job layer
1448
+ * synthesizing an empty `instances: []` report — that erases the wallet, the strategyId and every
1449
+ * completed step from the only surface that reports them.
1450
+ */
1451
+ const guarded = async (run, step, body) => {
1452
+ try {
1453
+ await body();
1454
+ }
1455
+ catch (err) {
1456
+ const message = errorText(err);
1457
+ // Record FIRST, narrate second. `log` cannot throw and `record` narrates through `emit`,
1458
+ // which cannot either — but the ordering is what the promise rests on, so it is written the
1459
+ // way the promise reads: the report is made whole before anything is told about it.
1460
+ if (!run.recorded.has(step)) {
1461
+ record(run, step, {
1462
+ status: "failed",
1463
+ detail: `${step} step failed: ${message}`,
1464
+ evidence: { error: message },
1465
+ });
1466
+ }
1467
+ log(`deploy: ${step} step threw for instance "${run.inst.name}": ${message}`);
1468
+ }
1469
+ };
1470
+ const span = (name, attrs, body) => spanOnce(deps.span, log, name, attrs, body);
1471
+ /**
1472
+ * The step boundary: the only place a pending deadline stop is honored — plus one deliberate
1473
+ * mid-step boundary, ahead of the create phase's unnamed retry, which is the single money-moving
1474
+ * call with no step boundary in front of it (see `createWallet`). Both go through here.
1475
+ */
1476
+ const boundaryAbandoned = () => {
1477
+ if (halted())
1478
+ return true;
1479
+ if (!deps.shouldAbandon())
1480
+ return false;
1481
+ honorDeadline();
1482
+ return true;
1483
+ };
1484
+ /**
1485
+ * L4: `strategy_list` runs unfiltered ONCE per job and is matched in memory (O(n) per instance).
1486
+ *
1487
+ * Backend behaviour verified 2026-08-05 against `senpi-hyperliquid-mcp`: the `strategy_list` tool
1488
+ * exposes no pagination at all — its input schema accepts only filters plus `orderBy`
1489
+ * (`src/tools/strategy.tools.ts`), the `FetchStrategies` GraphQL query selects no `pageInfo`/
1490
+ * cursor fields (`src/clients/senpi.client.ts`), and its own resource doc says "No pagination;
1491
+ * use filters to reduce response size". The full set returns in one call. (The `FetchStrategies`
1492
+ * resolver itself lives in strategy-manager, outside that repo, so a SERVER-side cap cannot be
1493
+ * ruled out from the client — but the tool offers no way to request a second page and no way to
1494
+ * detect truncation, which is exactly why this must be re-verified if it ever changes.)
1495
+ *
1496
+ * Re-verify if that changes: a paginated backend would silently hide name matches and cause
1497
+ * double-creates.
1498
+ */
1499
+ const loadSummaries = async () => {
1500
+ if (summariesOnce === null)
1501
+ summariesOnce = await deps.listStrategies();
1502
+ return summariesOnce;
1503
+ };
1504
+ /**
1505
+ * The gate's own legibility: say so when the read carried strategies but NOT ONE readable
1506
+ * attribution stamp.
1507
+ *
1508
+ * Either way it makes every candidate invisible, so the gate cannot fire and the deploy proceeds
1509
+ * exactly as if the user owned nothing. Silent is the one thing that must not be: a stampless
1510
+ * list of 12 strategies and a genuine "none of these are mine" are indistinguishable without it.
1511
+ *
1512
+ * It reports the observation and BOTH known causes rather than diagnosing one. A producer that
1513
+ * stopped returning `strategyMetadata` is the same failure class as the missing `strategyName`
1514
+ * this gate was built beside — but the spec's own risk list names a second: wallets created
1515
+ * outside the sanctioned paths carry no stamp at all, and a user whose strategies are all of that
1516
+ * kind would be told an MCP fault they do not have.
1517
+ */
1518
+ const noteStamplessList = (summaries) => {
1519
+ if (summaries.length === 0)
1520
+ return;
1521
+ if (summaries.some((s) => s.skillName !== undefined))
1522
+ return;
1523
+ log(`deploy: reconcile read ${summaries.length} live strategy(ies) and NONE carried a readable ` +
1524
+ `attribution stamp (strategyMetadata.skillName) — the unaccounted-wallet gate can see no ` +
1525
+ `candidate on this read and will not fire. Two things produce that, and this read cannot ` +
1526
+ `tell them apart: a producer that stopped returning strategyMetadata, or strategies ` +
1527
+ `created outside the sanctioned paths, which carry no stamp at all. Neither is proof that ` +
1528
+ `none of these wallets belong to package "${pkg.id}".`);
1529
+ };
1530
+ // ── reconcile ─────────────────────────────────────────────────────────────
1531
+ // Protection gate — before ANY backend read or write. A package that cannot stop its own
1532
+ // positions out is never funded, on this path or the skills path.
1533
+ const unprotected = instancesWithoutDslExit(pkg);
1534
+ if (unprotected.length > 0) {
1535
+ refused = true;
1536
+ const detail = buildNoDslExitRefusal(unprotected, pkg);
1537
+ const offenders = new Set(unprotected.map((inst) => inst.name));
1538
+ for (const run of runs) {
1539
+ if (offenders.has(run.inst.name))
1540
+ record(run, "reconcile", { status: "refused", detail });
1541
+ }
1542
+ }
1543
+ // D-6 premise repair: a refusal must mean "nothing was created". Both decision-model gates are
1544
+ // package+params-derivable, so they belong HERE, before any money moves — recorded at the install
1545
+ // step (their old home) they fired AFTER create+fund and stranded a funded wallet with no
1546
+ // rollback and no [E_ROLLBACK_INCOMPLETE]. register.ts's `localStartRefusal` carries the same
1547
+ // pair so the gateway never even starts a job; this copy keeps every other caller gated.
1548
+ for (const run of runs) {
1549
+ if (halted() || run.recorded.has("reconcile"))
1550
+ continue;
1551
+ if (!runtimeUsesLlmDecisions(readRuntimeYaml(run.inst)))
1552
+ continue;
1553
+ if (!decisionModel) {
1554
+ refused = true;
1555
+ record(run, "reconcile", {
1556
+ status: "refused",
1557
+ detail: buildDecisionModelRefusal(run.inst.name, pkg, budget),
1558
+ });
1559
+ }
1560
+ else if (!pkg.decisionModelEnv) {
1561
+ refused = true;
1562
+ record(run, "reconcile", {
1563
+ status: "refused",
1564
+ detail: buildDecisionModelEnvRefusal(run.inst.name, pkg),
1565
+ });
1566
+ }
1567
+ }
1568
+ // D-2 (revised): an unsupported scanner-level `enabled` key. Package-derivable, so it is decided
1569
+ // here rather than discovered after install — and the whole package's offenses go into the one
1570
+ // refusal every affected instance carries, so one read fixes them all. Instances already refused
1571
+ // above keep that refusal — an unprotected package is the more severe finding and leads.
1572
+ const enabledKeys = scannersDeclaringEnabled(pkg);
1573
+ if (enabledKeys.length > 0) {
1574
+ refused = true;
1575
+ const detail = buildScannerEnabledUnsupportedRefusal(enabledKeys, pkg);
1576
+ const affected = new Set(enabledKeys.map((entry) => entry.instance));
1577
+ for (const run of runs) {
1578
+ if (affected.has(run.inst.name) && !run.recorded.has("reconcile")) {
1579
+ record(run, "reconcile", { status: "refused", detail });
1580
+ }
1581
+ }
1582
+ }
1583
+ // The attribution stamp is minted from `pkg.id` VERBATIM (`createWallet`) while the backend
1584
+ // stores what it is given case-normalized, so a mixed-case id is stamped under one spelling and
1585
+ // read back under another — the false "no OPEN strategies to close." over a live funded wallet.
1586
+ // Package-derivable, so it is decided here, before a wallet exists to carry the wrong stamp;
1587
+ // register.ts's `localStartRefusal` carries the same gate so the gateway never even starts a job,
1588
+ // and this copy keeps every other caller gated. Last of the local gates: the ones above are
1589
+ // money-safety findings and lead when a package trips several.
1590
+ if (nonLowercasePackageId(pkg) !== null) {
1591
+ refused = true;
1592
+ const detail = buildNonLowercaseIdRefusal(pkg, budget);
1593
+ for (const run of runs) {
1594
+ if (!run.recorded.has("reconcile"))
1595
+ record(run, "reconcile", { status: "refused", detail });
1596
+ }
1597
+ }
1598
+ // D-6 premise: a refusal must mean "nothing was created". These four conditions are
1599
+ // package-derivable, so they belong here rather than at the install step — the same move the
1600
+ // decision-model gates already made above (see the comment at the top of this block).
1601
+ if (!halted()) {
1602
+ await span("deploy.install_gate", { "senpi.deploy.step": "install_gate" }, async () => {
1603
+ if (boundaryAbandoned())
1604
+ return "skipped";
1605
+ for (const run of runs) {
1606
+ if (run.recorded.has("reconcile"))
1607
+ continue;
1608
+ let gate;
1609
+ try {
1610
+ gate = await gateInstance(run.inst);
1611
+ }
1612
+ catch (err) {
1613
+ // Fail CLOSED, matching the universe check's `unavailable` branch: pre-money, a refusal
1614
+ // costs nothing and a gate that cannot run has proven nothing. `installRuntimeCore` fails
1615
+ // OPEN on the same fault, deliberately — refusing a good recipe AT the gateway blocks a
1616
+ // working install, while refusing a deploy costs only a re-run. Neither side may be
1617
+ // changed alone.
1618
+ refused = true;
1619
+ record(run, "reconcile", {
1620
+ status: "failed",
1621
+ detail: buildInstallGateUnavailableDetail(run.inst, errorText(err)),
1622
+ evidence: { error: errorText(err) },
1623
+ });
1624
+ continue;
1625
+ }
1626
+ if (gate.ok)
1627
+ continue;
1628
+ refused = true;
1629
+ record(run, "reconcile", {
1630
+ status: "refused",
1631
+ detail: buildInstallGateRefusal(gate, run.inst, pkg, budget),
1632
+ evidence: { codes: gate.blocking.map((f) => f.code) },
1633
+ });
1634
+ }
1635
+ return refused ? "refused" : "ok";
1636
+ });
1637
+ }
1638
+ // The proof gate: deploy refuses a package no one has ever observed run. Pre-money like every
1639
+ // gate above it, and BEFORE the universe gate deliberately — this is a local hash comparison over
1640
+ // files already on disk, so it costs nothing and should not sit behind a network read.
1641
+ //
1642
+ // It re-validates nothing and runs no tick. The observe step already waits for the deployed
1643
+ // runtime's tick; spending the deploy deadline on proving code here would report a slow
1644
+ // validation as an unobserved deploy. Producing (or repairing) a proof is the author's loop, run
1645
+ // through `senpi validate` — which is what every refusal below tells the reader to do.
1646
+ if (!halted()) {
1647
+ await span("deploy.proof", { "senpi.deploy.step": "proof" }, async () => {
1648
+ if (boundaryAbandoned())
1649
+ return "skipped";
1650
+ // Before verifying: a package whose instances share a directory cannot BE verified, because
1651
+ // one proof file would answer for two recipes and the unproven one would ride the other's
1652
+ // pass into funding. Refused, not waved through — see `collidingInstanceDirs`.
1653
+ const collisions = collidingInstanceDirs(pkg);
1654
+ if (collisions.length > 0) {
1655
+ refused = true;
1656
+ const detail = buildInstanceDirCollisionRefusal(collisions, pkg);
1657
+ const offenders = new Set(collisions.flatMap((c) => c.instances.map((inst) => inst.name)));
1658
+ for (const run of runs) {
1659
+ if (run.recorded.has("reconcile") || !offenders.has(run.inst.name))
1660
+ continue;
1661
+ record(run, "reconcile", {
1662
+ status: "refused",
1663
+ detail,
1664
+ evidence: { shared_dirs: collisions.map((c) => c.dir) },
1665
+ });
1666
+ }
1667
+ return "refused";
1668
+ }
1669
+ const verdict = checkPackageProof(pkg);
1670
+ if (verdict.ok)
1671
+ return "ok";
1672
+ refused = true;
1673
+ const detail = buildProofRefusal(verdict, pkg);
1674
+ // The refusal is about the PACKAGE — `verifyProof` answers once for every instance it was
1675
+ // given — so every instance not already refused carries it.
1676
+ for (const run of runs) {
1677
+ if (run.recorded.has("reconcile"))
1678
+ continue;
1679
+ record(run, "reconcile", {
1680
+ status: "refused",
1681
+ detail,
1682
+ // A small machine-readable bag, like every sibling gate: the prose lives in `detail`.
1683
+ // `code` comes from the same producer that rendered it, so the two cannot disagree.
1684
+ evidence: {
1685
+ reason: verdict.reason,
1686
+ // `instance_dir`, NOT `instance`: the emitted event already carries a top-level
1687
+ // `instance` holding the MANIFEST name (see `record`), and two different identifiers
1688
+ // under one key name on one event is how a consumer joins the wrong two rows.
1689
+ ...(verdict.instance ? { instance_dir: verdict.instance } : {}),
1690
+ ...(verdict.changedFiles ? { changed_files: verdict.changedFiles } : {}),
1691
+ code: proofRefusalParts(verdict, pkg).code,
1692
+ },
1693
+ });
1694
+ }
1695
+ return "refused";
1696
+ });
1697
+ }
1698
+ // S-1 (user rulings 2026-08-07): the live-universe gate — the deploy-side consumer of the
1699
+ // reusable `checkPackageUniverse` (universe.ts owns the decision; this block only records it).
1700
+ // A dead hardcoded name does not error — the scan skips it and the strategy silently trades
1701
+ // nothing — so it refuses PRE-MONEY, before reconcile even reads the backend. It needs one MCP
1702
+ // read, which is why it lives here as a job-time gate (the E_FUNDS precedent) rather than in
1703
+ // register.ts's synchronous localStartRefusal: every caller passes through runDeploy, so one
1704
+ // site gates them all. It runs BEFORE reconcile deliberately (U-2): installing a blind runtime
1705
+ // on an adopted wallet is the same zombie as funding a fresh one. A package hardcoding nothing
1706
+ // never reads the list at all.
1707
+ if (!halted()) {
1708
+ await span("deploy.universe", { "senpi.deploy.step": "universe" }, async () => {
1709
+ // The same step boundary every other MCP-touching phase honors: once the watchdog has asked
1710
+ // for a stop, no new deadline-bounded read may be opened ahead of it.
1711
+ if (boundaryAbandoned())
1712
+ return "skipped";
1713
+ const verdict = await checkPackageUniverse(pkg, deps);
1714
+ // Narration is computed from the verdict, never a forward claim a tickerless package would
1715
+ // falsify — so nothing is announced for a package with nothing to check. It is rendered in
1716
+ // plain words: the union's tag names are internal, and a journal line is read by people.
1717
+ const narration = describeUniverseVerdict(verdict);
1718
+ if (narration)
1719
+ emitPhase("reconcile", narration);
1720
+ if (verdict.status === "no-hardcoded" || verdict.status === "clean")
1721
+ return "ok";
1722
+ if (verdict.status === "unavailable") {
1723
+ // Fail-closed, exactly the thrown funds-preflight shape: the halt flag stops the run, the
1724
+ // STEP status carries the truth, and no instrument is named dead on a read that failed.
1725
+ refused = true;
1726
+ log(`deploy: universe check failed: ${verdict.reason}`);
1727
+ const detail = buildUniverseUnavailableDetail(verdict.reason, pkg);
1728
+ for (const run of runs) {
1729
+ if (!run.recorded.has("reconcile")) {
1730
+ record(run, "reconcile", { status: "failed", detail, evidence: { error: verdict.reason } });
1731
+ }
1732
+ }
1733
+ return "failed";
1734
+ }
1735
+ refused = true;
1736
+ const detail = buildUniverseNotLiveRefusal(verdict.dead, pkg, verdict.liveCount, budget);
1737
+ // The refusal lands on every instance whose own file carries a dead name; a strategy.yaml
1738
+ // (catalog.assets) offense belongs to the whole package, so it lands on every instance.
1739
+ const deadFiles = new Set([...verdict.dead.values()].flat().map((occ) => occ.file));
1740
+ const packageWide = deadFiles.has(join(pkg.dir, "strategy.yaml"));
1741
+ for (const run of runs) {
1742
+ if (run.recorded.has("reconcile"))
1743
+ continue;
1744
+ if (packageWide || deadFiles.has(run.inst.runtimeYamlPath)) {
1745
+ record(run, "reconcile", {
1746
+ status: "refused",
1747
+ detail,
1748
+ evidence: { dead: [...verdict.dead.keys()], liveCount: verdict.liveCount },
1749
+ });
1750
+ }
1751
+ }
1752
+ return "refused";
1753
+ });
1754
+ }
1755
+ if (!halted())
1756
+ emitPhase("reconcile", `matching ${pkg.instances.length} instance(s) against live strategies`);
1757
+ await span("deploy.reconcile", { "senpi.deploy.step": "reconcile" }, async () => {
1758
+ /**
1759
+ * TWO passes, because the gate below asks a whole-package question and cannot be answered from
1760
+ * a half-reconciled package.
1761
+ *
1762
+ * Pass 1 resolves every instance's binding and records which strategies answered. Pass 2 then
1763
+ * gates the instances nobody accounted for against the wallets nobody accounted for. Deciding
1764
+ * both inside one loop made the answer depend on iteration order: a two-sleeve package with
1765
+ * only sleeve 1 live (a crashed deploy, a sleeve added to a live package, a sleeve closed by
1766
+ * hand) refused sleeve 2 over the wallet sleeve 1 had just adopted — and the only actionable
1767
+ * route the refusal names tears down the whole package, i.e. the live funded sleeve beside it.
1768
+ */
1769
+ const unresolved = [];
1770
+ for (const run of runs) {
1771
+ if (boundaryAbandoned())
1772
+ break;
1773
+ await guarded(run, "reconcile", () => resolveInstanceBinding(run, unresolved));
1774
+ if (halted())
1775
+ break;
1776
+ }
1777
+ if (unresolved.length > 0 && summariesOnce)
1778
+ noteStamplessList(summariesOnce);
1779
+ for (const run of unresolved) {
1780
+ if (boundaryAbandoned())
1781
+ break;
1782
+ await guarded(run, "reconcile", () => gateOrCreate(run));
1783
+ if (halted())
1784
+ break;
1785
+ }
1786
+ return phaseStatus(runs, "reconcile");
1787
+ });
1788
+ /**
1789
+ * Pass 1: bind this instance to a live strategy by name or by journaled create key, or defer it
1790
+ * to the gate. A strategy an instance BINDS to is claimed (see {@link claimed}) — adopted or
1791
+ * waited on for ACTIVE — so a sibling sleeve's gate does not read it as unexplained.
1792
+ */
1793
+ async function resolveInstanceBinding(run, unresolved) {
1794
+ const summaries = await loadSummaries();
1795
+ // The backend LOWERCASES a name on create (observed: "WARPATH" in, "warpath" back), so a
1796
+ // case-sensitive compare would miss exactly the strategies it stored for us and fund a second
1797
+ // wallet beside them. An empty name on either side is not a match — two absences are not an
1798
+ // identity, and an unnamed strategy would otherwise bind to the first instance that asked.
1799
+ const wanted = run.strategyName.trim().toLowerCase();
1800
+ const byName = wanted
1801
+ ? summaries.filter((s) => (s.strategyName ?? "").trim().toLowerCase() === wanted &&
1802
+ !DEAD_STATUSES.includes(s.status.toUpperCase()))
1803
+ : [];
1804
+ if (byName.length > 0) {
1805
+ /**
1806
+ * A name is not an identity. `walletName` derives it from `<id>-<instance>` (or the bare id
1807
+ * for a flat package), so two DIFFERENT packages can produce the same one — a multi-sleeve
1808
+ * `alpha`/`one` and a flat package whose id IS `alpha-one` both land on "alpha-one", and both
1809
+ * pass `assertIdentityRoundTrips`. Matched on the name alone, this package's deploy ADOPTS
1810
+ * the other's funded wallet: install then puts a second runtime on it, and the positions and
1811
+ * the money belong to a package nobody asked about.
1812
+ *
1813
+ * The stamp is the only thing in this read that can say whose wallet it is, so a candidate is
1814
+ * FOREIGN only when it carries one and it names someone else. Absent is not foreign — and
1815
+ * that half is load-bearing, not leniency. Pass 2's doc records that its own stamp gate "does
1816
+ * NOT survive STAMP starvation: an MCP that stops returning `strategyMetadata`…". A strict
1817
+ * match here would inherit exactly that failure with the money pointing the other way: on a
1818
+ * starved read every re-run of a working package would stop recognising its own wallet and
1819
+ * fund a second one beside it, converting a benign producer fault into a duplicate-funding
1820
+ * bug. Wallets predating attribution, and wallets created by hand through the MCP, are
1821
+ * stampless for the same reason and are equally not evidence of foreign ownership.
1822
+ *
1823
+ * Case-folded on both sides, for the reason pass 2's filter states: the backend
1824
+ * case-normalizes what it stores and `sanitizeStrategyName` preserves capitals, so a
1825
+ * case-sensitive compare would read a package's OWN stamp as someone else's.
1826
+ */
1827
+ const stamp = pkg.id.trim().toLowerCase();
1828
+ const claimantOf = (s) => (s.skillName ?? "").trim().toLowerCase();
1829
+ const ours = byName.filter((s) => claimantOf(s) === "" || claimantOf(s) === stamp);
1830
+ if (ours.length > 0) {
1831
+ /**
1832
+ * Dropping the foreign rows narrows the list `applyCandidates` COUNTS, and >1 there is the
1833
+ * `E_STATE_AMBIGUOUS_WALLETS` refusal. So the drop is allowed only where the stamp really
1834
+ * does say which is which: at least one survivor has to carry THIS package's stamp.
1835
+ *
1836
+ * A survivor that carries no stamp proves nothing about ownership — that is the whole point
1837
+ * of treating absent as not-foreign above — so a stampless row standing beside a proven
1838
+ * foreign one under the same name is not "ours, obviously": the foreign row is positive
1839
+ * evidence the name is contested, and a raw `strategy_create_custom_strategy` or a
1840
+ * pre-attribution create is exactly how a stampless wallet gets there. Collapsing that pair
1841
+ * to one adoption would install this package's runtime onto a funded wallet nothing proved
1842
+ * was its own. Keep the full list instead, so the count still reaches the ambiguity refusal
1843
+ * and the operator triages it read-only, pre-money.
1844
+ *
1845
+ * When nothing foreign matched at all, `ours` IS `byName` and this decides nothing — the
1846
+ * stamp-starvation path (every row stampless) is unaffected either way.
1847
+ */
1848
+ const proven = ours.some((s) => claimantOf(s) === stamp);
1849
+ applyCandidates(run, proven ? ours : byName, "name",
1850
+ // Only the rows this call actually dropped: when the list is not narrowed, none were.
1851
+ proven ? byName.filter((s) => !ours.includes(s)) : []);
1852
+ return;
1853
+ }
1854
+ refused = true;
1855
+ record(run, "reconcile", {
1856
+ status: "refused",
1857
+ detail: buildForeignPackageRefusal(pkg, run.inst.name, run.strategyName, byName, budget),
1858
+ evidence: {
1859
+ instance: run.inst.name,
1860
+ strategyName: run.strategyName,
1861
+ // Same absence rules as pass 2's evidence: an id or address the backend did not report is
1862
+ // OMITTED, never handed to a `--json` reader as the empty string.
1863
+ candidates: byName.map((s) => ({
1864
+ ...(s.id?.trim() ? { strategyId: s.id.trim() } : {}),
1865
+ ...(s.strategyWalletAddress?.trim() ? { wallet: s.strategyWalletAddress.trim() } : {}),
1866
+ ...(s.skillName?.trim() ? { skillName: s.skillName.trim() } : {}),
1867
+ status: s.status,
1868
+ totalFunded: s.totalFunded,
1869
+ })),
1870
+ },
1871
+ });
1872
+ return;
1873
+ }
1874
+ // C2: a create whose custom name the backend rejected lands under a backend-assigned name, so
1875
+ // the name match above can never find it again and a re-run would fund a SECOND wallet beside
1876
+ // it. The journal supplies the strategyId it recorded at create time; the BACKEND decides
1877
+ // whether that id still means anything — a dead key falls straight through to a fresh create.
1878
+ const priorIds = params.priorStrategyIds?.get(run.inst.name) ?? [];
1879
+ if (priorIds.length > 0) {
1880
+ const verified = (await deps.listStrategies({ strategyIds: priorIds })).filter((s) => !DEAD_STATUSES.includes(s.status.toUpperCase()));
1881
+ if (verified.length > 0) {
1882
+ applyCandidates(run, verified, "created-key");
1883
+ return;
1884
+ }
1885
+ }
1886
+ unresolved.push(run);
1887
+ }
1888
+ /**
1889
+ * Pass 2: no match route accounted for this instance. If wallets stamped with this package are
1890
+ * live and unclaimed, "I could not account for these" is NOT "there are none": creating here
1891
+ * funds a second wallet beside a live one, and the install step then moves the runtime onto the
1892
+ * new wallet, leaving the old one funded, in position and supervised by nothing. Refuse while no
1893
+ * money has moved.
1894
+ *
1895
+ * This holds when the NAME route is starved (an older MCP build, a backend that renames) —
1896
+ * that is the failure it was built for. It does NOT survive STAMP starvation: an MCP that stops
1897
+ * returning `strategyMetadata` empties this filter and the gate silently stops existing, which is
1898
+ * why a stampless non-empty list is logged rather than passed over in silence.
1899
+ */
1900
+ async function gateOrCreate(run) {
1901
+ const summaries = await loadSummaries();
1902
+ // Case-folded on BOTH sides, like the name match: the backend case-normalizes what it stores,
1903
+ // and a case-sensitive compare here fails OPEN — a package id with a capital in it would find
1904
+ // no stamped wallet, and the gate that exists to stop a second funded wallet would wave it
1905
+ // through. `sanitizeStrategyName` deliberately preserves capitals, so this is reachable.
1906
+ const stamp = pkg.id.trim().toLowerCase();
1907
+ const stamped = summaries.filter((s) => (s.skillName ?? "").trim().toLowerCase() === stamp &&
1908
+ !DEAD_STATUSES.includes(s.status.toUpperCase()) &&
1909
+ !isAccountedFor(claimed, s.id));
1910
+ if (stamped.length > 0) {
1911
+ refused = true;
1912
+ record(run, "reconcile", {
1913
+ status: "refused",
1914
+ detail: buildBindingUnknownRefusal(pkg, run.inst.name, run.strategyName, stamped, budget),
1915
+ evidence: {
1916
+ instance: run.inst.name,
1917
+ candidates: stamped.map((s) => ({
1918
+ // The id gets the same treatment as the wallet below, and for a sharper reason: an
1919
+ // unreadable id is exactly why this candidate could not be proven accounted for, so
1920
+ // reporting it as `""` would hand a `--json` reader the empty string as an identifier.
1921
+ ...(s.id?.trim() ? { strategyId: s.id.trim() } : {}),
1922
+ // Same rule as `totalFunded`: an address the backend did not report is ABSENT here, not
1923
+ // an empty string. The prose says "unreadable"; a consumer of the evidence must not read
1924
+ // a falsy-but-present `""` as an address, and the key is omitted rather than set to
1925
+ // undefined so `in`/`hasOwnProperty` agree with the JSON a `--json` reader sees.
1926
+ ...(s.strategyWalletAddress?.trim() ? { wallet: s.strategyWalletAddress.trim() } : {}),
1927
+ status: s.status,
1928
+ totalFunded: s.totalFunded,
1929
+ createdAt: s.createdAt,
1930
+ })),
1931
+ },
1932
+ });
1933
+ return;
1934
+ }
1935
+ run.needsWallet = true;
1936
+ record(run, "reconcile", {
1937
+ status: "ok",
1938
+ detail: `no live strategy named "${run.strategyName}" — a wallet will be created`,
1939
+ });
1940
+ }
1941
+ /**
1942
+ * The single reconcile decision, shared by both ways of finding a candidate: >1 is ambiguous and
1943
+ * refuses; a single one branches on its live status (ACTIVE adopt / initializing wait / anything
1944
+ * else refuse). Keeping one copy is what stops the two match routes from drifting apart.
1945
+ */
1946
+ function applyCandidates(run, candidates, matchedBy,
1947
+ /**
1948
+ * Live strategies that shared the matched NAME and were dropped as another package's. Reported
1949
+ * rather than silently discarded: the collision is a durable fact about the backend, and the
1950
+ * operator meets it again at the next `close.py`, rename or status read. It never changes what
1951
+ * this function decides — the decision is made from `candidates` alone.
1952
+ */
1953
+ ignoredForeign = []) {
1954
+ const provenance = matchedByClause(matchedBy) + foreignCollisionClause(ignoredForeign);
1955
+ /**
1956
+ * Record that THIS instance is bound to `strategyId`, so a sibling sleeve's gate does not read
1957
+ * that wallet as one nothing accounted for.
1958
+ *
1959
+ * Called from the binding branches below ONLY, at the statement that binds — never ahead of the
1960
+ * branch that decides, which is what made a claim that binds nothing representable at all.
1961
+ * {@link claimStrategyId} owns what may be recorded.
1962
+ */
1963
+ const claim = (strategyId) => claimStrategyId(claimed, strategyId);
1964
+ if (candidates.length > 1) {
1965
+ refused = true;
1966
+ record(run, "reconcile", {
1967
+ status: "refused",
1968
+ detail: buildAmbiguousRefusal(candidates.length, matchedBy === "name"
1969
+ ? `named "${run.strategyName}"`
1970
+ : `journaled as created for instance "${run.inst.name}"`, run.inst.name),
1971
+ evidence: {
1972
+ strategyName: run.strategyName,
1973
+ matchedBy,
1974
+ // `null`, not `""`, for an id the backend did not report. The omit-the-key rule the other
1975
+ // evidence sites use cannot apply inside an array without changing its length — and the
1976
+ // length IS the ambiguity being reported, so it has to survive. `null` is the array's
1977
+ // form of absent, and unlike `""` a consumer cannot mistake it for an identifier.
1978
+ candidates: candidates.map((c) => c.id?.trim() || null),
1979
+ },
1980
+ });
1981
+ return;
1982
+ }
1983
+ const matched = candidates[0];
1984
+ const status = matched.status.toUpperCase();
1985
+ const wallet = matched.strategyWalletAddress?.trim() ?? "";
1986
+ const evidence = {
1987
+ // The rows the stamp partition dropped, on every record this bind produces. Same absence
1988
+ // rules as the candidate lists above: a field the backend did not report is OMITTED.
1989
+ ...(ignoredForeign.length > 0
1990
+ ? {
1991
+ alsoNamed: ignoredForeign.map((s) => ({
1992
+ ...(s.id?.trim() ? { strategyId: s.id.trim() } : {}),
1993
+ ...(s.strategyWalletAddress?.trim() ? { wallet: s.strategyWalletAddress.trim() } : {}),
1994
+ ...(s.skillName?.trim() ? { skillName: s.skillName.trim() } : {}),
1995
+ status: s.status,
1996
+ })),
1997
+ }
1998
+ : {}),
1999
+ // Same rule as the gate's candidate rows: an unreadable id is ABSENT from the evidence rather
2000
+ // than reported as the empty string, so a `--json` reader never reads `""` as an identifier.
2001
+ ...(matched.id?.trim() ? { strategyId: matched.id.trim() } : {}),
2002
+ strategyName: matched.strategyName,
2003
+ status: matched.status,
2004
+ strategyWalletAddress: matched.strategyWalletAddress,
2005
+ totalFunded: matched.totalFunded,
2006
+ };
2007
+ if (status === "ACTIVE" && !wallet) {
2008
+ // deploy.py's "unreadable" refusal (taxonomy: E_STATE_AMBIGUOUS_WALLETS covers a candidate
2009
+ // whose address cannot be read). Adopting a blank address would install onto `undefined`;
2010
+ // creating a second wallet would double-fund what may be a live strategy. Refuse both.
2011
+ refused = true;
2012
+ record(run, "reconcile", {
2013
+ status: "refused",
2014
+ detail: buildUnreadableWalletRefusal(matched.strategyName, run.inst.name, matched.id),
2015
+ evidence: { ...evidence, matchedBy },
2016
+ });
2017
+ return;
2018
+ }
2019
+ run.report.strategyId = matched.id;
2020
+ if (status !== "ACTIVE" && !INITIALIZING_STATUSES.includes(status)) {
2021
+ // PAUSED, mid-teardown, or unrecognised: waiting cannot help, so refuse with the real status
2022
+ // and a next step computed from it rather than burning --max-wait on "still funding".
2023
+ refused = true;
2024
+ record(run, "reconcile", {
2025
+ status: "refused",
2026
+ detail: buildNotDeployableRefusal(matched, run.inst.name, pkg, budget),
2027
+ evidence: { ...evidence, matchedBy },
2028
+ });
2029
+ return;
2030
+ }
2031
+ if (status !== "ACTIVE") {
2032
+ // Still initializing (CREATE_WALLET → FUND_WALLET → … → ACTIVE). Do NOT adopt the wallet and
2033
+ // do NOT skip to install: the create phase runs the same bounded ACTIVE poll a fresh wallet
2034
+ // gets, so a transient state reports `pending` (re-run to resume), never `failed`.
2035
+ run.awaitingActive = matched.id;
2036
+ claim(matched.id);
2037
+ record(run, "reconcile", {
2038
+ status: "ok",
2039
+ detail: `matched live strategy "${matched.strategyName}" (status ${matched.status})${provenance} — ` +
2040
+ `not ACTIVE yet, waiting for it rather than creating a second wallet`,
2041
+ evidence: { ...evidence, matchedBy },
2042
+ });
2043
+ record(run, "preflight", {
2044
+ status: "skipped",
2045
+ detail: "instance already has a strategy — no new wallet to fund",
2046
+ });
2047
+ return;
2048
+ }
2049
+ run.report.wallet = wallet;
2050
+ run.report.funded = matched.totalFunded;
2051
+ claim(matched.id);
2052
+ record(run, "reconcile", {
2053
+ status: "ok",
2054
+ detail: `matched live strategy "${matched.strategyName}" (status ${matched.status})${provenance}`,
2055
+ evidence: { ...evidence, matchedBy },
2056
+ });
2057
+ record(run, "preflight", { status: "skipped", detail: "instance already has a wallet" });
2058
+ record(run, "create", {
2059
+ status: "skipped",
2060
+ detail: buildAdoptedWalletDetail(pkg, run.inst, matched, wallet, budget, matchedBy),
2061
+ evidence: { ...evidence, matchedBy },
2062
+ });
2063
+ }
2064
+ // ── preflight ─────────────────────────────────────────────────────────────
2065
+ const needing = runs.filter((run) => run.needsWallet);
2066
+ if (!halted() && needing.length > 0) {
2067
+ emitPhase("preflight", `checking accessible balance for ${needing.length} new wallet(s)`);
2068
+ await span("deploy.preflight", { "senpi.deploy.step": "preflight" }, async () => {
2069
+ if (!boundaryAbandoned()) {
2070
+ try {
2071
+ await runPreflight();
2072
+ }
2073
+ catch (err) {
2074
+ // A thrown preflight is fail-closed: no balance was proven, so nothing is created.
2075
+ const message = errorText(err);
2076
+ refused = true;
2077
+ log(`deploy: preflight threw: ${message}`);
2078
+ for (const run of needing) {
2079
+ if (!run.recorded.has("preflight")) {
2080
+ record(run, "preflight", {
2081
+ status: "failed",
2082
+ detail: `preflight step failed: ${message}. Nothing was created.`,
2083
+ evidence: { error: message },
2084
+ });
2085
+ }
2086
+ }
2087
+ }
2088
+ }
2089
+ return phaseStatus(runs, "preflight");
2090
+ });
2091
+ }
2092
+ async function runPreflight() {
2093
+ if (budget === undefined) {
2094
+ refused = true;
2095
+ const detail = buildBudgetRequiredRefusal(needing.length, pkg);
2096
+ for (const run of needing)
2097
+ record(run, "preflight", { status: "refused", detail });
2098
+ return;
2099
+ }
2100
+ if (budget <= 0) {
2101
+ refused = true;
2102
+ const detail = buildBudgetNotPositiveRefusal(budget, needing.length, pkg);
2103
+ for (const run of needing)
2104
+ record(run, "preflight", { status: "refused", detail });
2105
+ return;
2106
+ }
2107
+ let available = null;
2108
+ try {
2109
+ available = accessibleUsd(await deps.getPortfolio());
2110
+ }
2111
+ catch (err) {
2112
+ log(`deploy: portfolio read failed (${errorText(err)}) — ` +
2113
+ `proceeding without a local balance check; the backend is the funding authority`);
2114
+ available = null;
2115
+ }
2116
+ const plan = planFunding(needing.map((run) => ({ name: run.inst.name, fundingShare: run.inst.fundingShare })), budget, available);
2117
+ if (plan.shortfall) {
2118
+ refused = true;
2119
+ const detail = underfundedNote(plan.shortfall, needing.map((run) => ({ fundingShare: run.inst.fundingShare })), pkg.dir);
2120
+ for (const run of needing) {
2121
+ record(run, "preflight", { status: "refused", detail, evidence: { ...plan.shortfall } });
2122
+ }
2123
+ return;
2124
+ }
2125
+ // The HARD floor has now passed. Everything above is the money gate — `planFunding` floors each
2126
+ // wallet at MIN_WALLET and `underfundedNote` halts. What follows is the SOFT tier, and the two
2127
+ // must never be confused: it is a local estimate over the package's own YAML, the backend does
2128
+ // not enforce it, and a user is entitled to size their own budget. It warns and proceeds.
2129
+ //
2130
+ // It is handed the PLAN, not the budget: the claim is about what each wallet actually receives.
2131
+ applyCalculatedMinimum(plan.amounts);
2132
+ for (const run of needing) {
2133
+ run.fundAmount = plan.amounts.get(run.inst.name);
2134
+ record(run, "preflight", {
2135
+ status: "ok",
2136
+ detail: available === null
2137
+ ? `will fund ${usd(run.fundAmount ?? 0)} — accessible balance unreadable, proceeding (the backend is the funding authority)`
2138
+ : `will fund ${usd(run.fundAmount ?? 0)} of ${usd(available)} accessible`,
2139
+ evidence: { initialBudget: run.fundAmount, accessibleUsd: available },
2140
+ });
2141
+ }
2142
+ }
2143
+ /**
2144
+ * The SOFT calculated-minimum tier. The package-level figure is computed over the WHOLE package
2145
+ * (every sleeve's share of a hypothetical fresh deploy) and rides the report as CONTEXT — but the
2146
+ * shortfall CLAIM is per-wallet, over the allocation `planFunding` just decided.
2147
+ *
2148
+ * That split is the whole point. Those two questions have different denominators on a
2149
+ * partially-adopted deploy: `planFunding` divides among the instances still NEEDING a wallet,
2150
+ * while the minimum divides among all of them. Comparing budget-to-package-minimum therefore
2151
+ * announced a shortfall on a re-run that was handing one $13.50 sleeve the entire $20 — a claim
2152
+ * about money that is not moving, which is the same rule that makes an adopted-ONLY deploy skip
2153
+ * this check entirely.
2154
+ *
2155
+ * Advisory by construction: it records facts, never a step status, and a throw is swallowed. A
2156
+ * warning that cannot be computed is a missing warning, never a failed deploy.
2157
+ */
2158
+ function applyCalculatedMinimum(amounts) {
2159
+ // The guard covers the WHOLE body, not just `packageMinBudget`. It used to end before the note
2160
+ // was rendered, so a throw from `buildSoftBudgetNote`/`forwardTense` escaped into the preflight
2161
+ // catch and REFUSED the deploy — the one outcome this tier's own doc rules out. Fail-closed, so
2162
+ // never a money bug; a flat contradiction of the contract two lines above it, which is how the
2163
+ // next editor learns the wrong rule.
2164
+ try {
2165
+ const result = packageMinBudget(pkg);
2166
+ soft.min = result;
2167
+ const needsOf = new Map(result.breakdown.map((row) => [row.wallet, row.perWalletMin]));
2168
+ for (const [wallet, funded] of amounts) {
2169
+ const needs = needsOf.get(wallet);
2170
+ if (needs !== undefined && funded < needs)
2171
+ soft.under.push({ wallet, funded, needs });
2172
+ }
2173
+ // No escape in the narration: at preflight nothing has been created yet, so there is nothing
2174
+ // to close. The final report re-renders this note with the escape its own state supports. The
2175
+ // tense here is forward-looking — every short wallet is about to be funded and run — so both
2176
+ // per-row sets are full and the narration reads "funds …; will run DEGRADED".
2177
+ const note = buildSoftBudgetNote(pkg, result, soft.under, "", forwardTense(soft.under));
2178
+ if (note === null)
2179
+ return;
2180
+ log(`deploy: ${note}`);
2181
+ emit({
2182
+ type: "job",
2183
+ phase: "preflight",
2184
+ detail: note,
2185
+ evidence: {
2186
+ minBudget: result.minBudget,
2187
+ walletCount: result.walletCount,
2188
+ bindingWallet: result.bindingWallet,
2189
+ unresolvedWallets: result.unresolvedWallets,
2190
+ underfunded: soft.under,
2191
+ },
2192
+ });
2193
+ }
2194
+ catch (err) {
2195
+ log(`deploy: calculated-minimum check skipped (${errorText(err)}) — advisory only`);
2196
+ }
2197
+ }
2198
+ // ── create ────────────────────────────────────────────────────────────────
2199
+ const createTargets = runs.filter((run) => run.needsWallet || run.awaitingActive);
2200
+ if (!halted() && createTargets.length > 0) {
2201
+ const fresh = createTargets.filter((run) => run.needsWallet).length;
2202
+ const waiting = createTargets.length - fresh;
2203
+ emitPhase("create", waiting === 0
2204
+ ? `creating and funding ${fresh} wallet(s)`
2205
+ : `creating and funding ${fresh} wallet(s); waiting for ${waiting} existing strategy(ies) to become ACTIVE`);
2206
+ await span("deploy.create", { "senpi.deploy.step": "create" }, async () => {
2207
+ for (const run of createTargets) {
2208
+ if (boundaryAbandoned())
2209
+ break;
2210
+ await guarded(run, "create", () => createInstance(run));
2211
+ }
2212
+ return phaseStatus(runs, "create");
2213
+ });
2214
+ }
2215
+ /**
2216
+ * A create call that never throws: the backend surfaces a name rejection as a `success:false`
2217
+ * message in some paths and as a thrown MCP error in others, and only one of those shapes must
2218
+ * not decide whether the best-effort naming fallback fires (deploy.py caught the exception).
2219
+ */
2220
+ async function callCreate(params) {
2221
+ try {
2222
+ return await deps.createCustomStrategy(params);
2223
+ }
2224
+ catch (err) {
2225
+ // Residual window, stated precisely because it is not closed here: a create abandoned at its
2226
+ // per-call deadline is recorded WITHOUT a journaled create key — the key only exists in the
2227
+ // response that never arrived. The deadline bounds this job's wait, not the backend's work,
2228
+ // so the create may still succeed afterwards. A re-run then finds that wallet by NAME, which
2229
+ // is enough in every case but one: the custom name was also rejected, so the wallet landed
2230
+ // under a backend-assigned name AND has no journaled key. That wallet is invisible to both
2231
+ // reconcile paths and a re-run funds a second one beside it. Narrow (needs a >90s create and
2232
+ // a rejected name) and unchanged by the C2 carve-out, which could only ever journal a key it
2233
+ // received; closing it needs an idempotency key on the create tool, not a client-side fix.
2234
+ // Until then the failure detail below tells the operator exactly what to check.
2235
+ return { success: false, message: errorText(err), aborted: isDeadlineAbort(err) };
2236
+ }
2237
+ }
2238
+ async function createInstance(run) {
2239
+ const strategyId = run.awaitingActive ?? (await createWallet(run));
2240
+ if (!strategyId)
2241
+ return;
2242
+ run.report.strategyId = strategyId;
2243
+ await pollUntilActive(run, strategyId);
2244
+ }
2245
+ /** Create + fund a fresh wallet. Returns its strategyId, or undefined when the step already failed. */
2246
+ async function createWallet(run) {
2247
+ const initialBudget = run.fundAmount ?? 0;
2248
+ const attribution = { skillName: pkg.id, skillVersion: pkg.version };
2249
+ let result = await callCreate({ initialBudget, strategyName: run.strategyName, ...attribution });
2250
+ // Whether the abandoned create (if it comes to that) is the UNNAMED second attempt. Known here
2251
+ // and nowhere else — and it is the whole difference between "a re-run adopts it by name" and
2252
+ // "no route can ever find it", so the report must not have to hedge about which happened.
2253
+ let nameFallbackFired = false;
2254
+ if (!result.success && isNameRejection(result.message ?? "")) {
2255
+ // The ONE money-moving call in this file with no step boundary ahead of it — every other one
2256
+ // is preceded by `boundaryAbandoned()`. It needs its own, because `callCreate` mints a FRESH
2257
+ // `MCP_CALL_TIMEOUT_MS` deadline: a retry issued after the soft stop can still be awaiting a
2258
+ // funded create 90s later, while the grace (`GRACE_MS`, sized to outlast exactly one in-flight
2259
+ // call) has already force-terminated the job and handed the single-flight slot on. The next
2260
+ // deploy then runs its preflight against a balance this one is still spending — the overlap
2261
+ // the grace arithmetic exists to make impossible.
2262
+ //
2263
+ // Recorded BEFORE the sweep, so this instance's create keeps the detail that says which call
2264
+ // was issued and which was not; `honorDeadline` only fills steps nothing has recorded.
2265
+ if (deps.shouldAbandon()) {
2266
+ record(run, "create", {
2267
+ status: "failed",
2268
+ detail: buildCreateNameRetryNotIssuedDetail({
2269
+ instance: run.inst.name,
2270
+ strategyName: run.strategyName,
2271
+ fundAmount: initialBudget,
2272
+ message: result.message,
2273
+ pkg,
2274
+ budget,
2275
+ }),
2276
+ evidence: { success: false, message: result.message, retryIssued: false },
2277
+ });
2278
+ boundaryAbandoned();
2279
+ return undefined;
2280
+ }
2281
+ // deploy.py:447 parity — naming is best-effort legibility and must never block a deploy.
2282
+ // Narrated as a job event: it is not the create step's recorded outcome.
2283
+ emit({
2284
+ type: "job",
2285
+ phase: "create",
2286
+ detail: `name "${run.strategyName}" rejected (${result.message}); creating without a custom name`,
2287
+ });
2288
+ nameFallbackFired = true;
2289
+ result = await callCreate({ initialBudget, ...attribution });
2290
+ }
2291
+ if (!result.success || !result.strategyId) {
2292
+ record(run, "create", {
2293
+ status: "failed",
2294
+ detail: result.aborted
2295
+ ? buildCreateAbandonedDetail({
2296
+ instance: run.inst.name,
2297
+ strategyName: run.strategyName,
2298
+ nameFallbackFired,
2299
+ fundAmount: run.fundAmount ?? 0,
2300
+ message: result.message,
2301
+ pkg,
2302
+ budget,
2303
+ })
2304
+ : `wallet creation failed: ${result.message ?? "backend returned no strategyId"}`,
2305
+ evidence: {
2306
+ success: result.success,
2307
+ message: result.message,
2308
+ strategyId: result.strategyId,
2309
+ ...(result.aborted ? { outcome: "unknown" } : {}),
2310
+ },
2311
+ });
2312
+ return undefined;
2313
+ }
2314
+ // C2: journal the create key BEFORE the ACTIVE poll — a crash during that poll is exactly the
2315
+ // window in which the create step's own outcome never gets recorded, and (when the custom name
2316
+ // was rejected) the strategy is unfindable by name afterwards. This event is the only thing
2317
+ // that lets a re-run adopt it instead of funding a second wallet beside it.
2318
+ //
2319
+ // `requested` rides the same line for the same reason, one narration tier down: the ask exists
2320
+ // nowhere else once this process dies, and the run that later ADOPTS this strategy needs it to
2321
+ // notice that the backend landed less than was asked for. It is a lookup input to a WARN only
2322
+ // (see DeployParams.priorRequested) — never an orchestration decision, and never the funded
2323
+ // figure, which is always re-read live.
2324
+ emit({
2325
+ type: "job",
2326
+ phase: "create",
2327
+ detail: `wallet created for instance "${run.inst.name}" (strategyId ${result.strategyId})`,
2328
+ evidence: { instance: run.inst.name, strategyId: result.strategyId, requested: initialBudget },
2329
+ });
2330
+ return result.strategyId;
2331
+ }
2332
+ /** The bounded ACTIVE poll — shared by a freshly created wallet and an adopted initializing one. */
2333
+ async function pollUntilActive(run, strategyId) {
2334
+ const deadline = deps.now() + maxWaitSec * 1000;
2335
+ for (;;) {
2336
+ const [live] = await deps.listStrategies({ strategyIds: [strategyId] });
2337
+ if (live && live.status.toUpperCase() === "ACTIVE" && live.strategyWalletAddress) {
2338
+ run.report.wallet = live.strategyWalletAddress;
2339
+ run.report.funded = live.totalFunded;
2340
+ record(run, "create", {
2341
+ status: "ok",
2342
+ // An adopted instance was never funded by THIS run, so it carries the same
2343
+ // budget-not-applied note the ACTIVE-adopt path renders; a freshly created wallet was
2344
+ // funded to its planned share and needs none.
2345
+ detail: run.awaitingActive
2346
+ ? buildAdoptedWalletDetail(pkg, run.inst, live, live.strategyWalletAddress, budget)
2347
+ : `wallet ${live.strategyWalletAddress} is ACTIVE, ${fundedClause(live.totalFunded)}`,
2348
+ evidence: {
2349
+ strategyId,
2350
+ strategyName: live.strategyName,
2351
+ status: live.status,
2352
+ strategyWalletAddress: live.strategyWalletAddress,
2353
+ totalFunded: live.totalFunded,
2354
+ },
2355
+ });
2356
+ return;
2357
+ }
2358
+ // Only INITIALIZING statuses resolve on their own, so only they are worth waiting on (the
2359
+ // same rule reconcile applies two screens up). Everything else that is not ACTIVE burns the
2360
+ // whole --max-wait and is then reported `pending` with "wallet still funding" — false for
2361
+ // every one of them. Dead first: it is a subset with its own, opposite re-run consequence.
2362
+ if (live && isDead(live.status)) {
2363
+ record(run, "create", {
2364
+ status: "failed",
2365
+ detail: buildCreateDeadDetail(pkg, budget, live, run.awaitingActive === undefined),
2366
+ evidence: {
2367
+ strategyId,
2368
+ status: live.status,
2369
+ strategyWalletAddress: live.strategyWalletAddress,
2370
+ totalFunded: live.totalFunded,
2371
+ },
2372
+ });
2373
+ return;
2374
+ }
2375
+ if (live && live.status.toUpperCase() !== "ACTIVE" && !isInitializing(live.status)) {
2376
+ record(run, "create", {
2377
+ status: "failed",
2378
+ detail: buildCreateNotDeployableDetail(pkg, budget, live, run.awaitingActive === undefined,
2379
+ // Scoped HERE, where the sibling runs are in scope — the detail builder is pure.
2380
+ buildPackageCloseOffer(pkg, runs, run, live)),
2381
+ evidence: {
2382
+ strategyId,
2383
+ status: live.status,
2384
+ strategyWalletAddress: live.strategyWalletAddress,
2385
+ totalFunded: live.totalFunded,
2386
+ },
2387
+ });
2388
+ return;
2389
+ }
2390
+ if (deps.now() >= deadline) {
2391
+ record(run, "create", {
2392
+ status: "pending",
2393
+ // `budget` verbatim, never a per-instance fallback: an adopted instance has no
2394
+ // fundAmount, and `--budget 0` is a command no one can usefully run.
2395
+ detail: buildCreatePendingDetail(pkg, budget, maxWaitSec),
2396
+ evidence: { strategyId, status: live?.status ?? null },
2397
+ });
2398
+ return;
2399
+ }
2400
+ await deps.sleep(CREATE_POLL_MS);
2401
+ if (deps.shouldAbandon()) {
2402
+ record(run, "create", {
2403
+ status: "pending",
2404
+ detail: `deploy deadline hit while waiting for wallet ${strategyId} to become ACTIVE — the wallet ` +
2405
+ `may still be funding. Nothing was rolled back; re-run \`${resumeCommand(pkg.dir, budget)}\` to resume`,
2406
+ evidence: { strategyId },
2407
+ });
2408
+ honorDeadline();
2409
+ return;
2410
+ }
2411
+ }
2412
+ }
2413
+ // ── install ───────────────────────────────────────────────────────────────
2414
+ const installable = runs.filter((run) => run.report.wallet);
2415
+ if (!halted() && installable.length > 0) {
2416
+ emitPhase("install", `installing ${installable.length} runtime(s)`);
2417
+ await span("deploy.install", { "senpi.deploy.step": "install" }, async () => {
2418
+ for (const run of installable) {
2419
+ if (boundaryAbandoned())
2420
+ break;
2421
+ await guarded(run, "install", () => installInstance(run));
2422
+ }
2423
+ return phaseStatus(runs, "install");
2424
+ });
2425
+ }
2426
+ async function installInstance(run) {
2427
+ const wallet = run.report.wallet;
2428
+ const runtimeId = sanitizeStrategyName(`${pkg.id}-${run.inst.name}`);
2429
+ /** Prefixed onto the install detail when a dead registry row was cleared out of the way. */
2430
+ let replacedDeadRow = "";
2431
+ const onWallet = await deps.registryFindByWallet(wallet);
2432
+ if (onWallet) {
2433
+ if (await deps.runtimeIsLive(onWallet.id)) {
2434
+ run.installedAtMs = deps.now();
2435
+ run.report.runtimeId = onWallet.id;
2436
+ record(run, "install", {
2437
+ status: "skipped",
2438
+ detail: `runtime already registered AND running on this wallet (id "${onWallet.id}")`,
2439
+ evidence: { id: onWallet.id, wallet, live: true },
2440
+ });
2441
+ return;
2442
+ }
2443
+ // H4: a registry row with no live handle is a previous install that died before start —
2444
+ // reporting it `skipped` would be a clean line over a dead runtime, the DOA shape again.
2445
+ await deps.deleteRuntime(onWallet.id);
2446
+ emit({
2447
+ type: "job",
2448
+ phase: "install",
2449
+ detail: `registry entry "${onWallet.id}" had no live runtime — deleted; reinstalling on this wallet`,
2450
+ });
2451
+ replacedDeadRow = `registry entry "${onWallet.id}" had no live runtime — deleted and reinstalled. `;
2452
+ // fall through to the normal install path below
2453
+ }
2454
+ // The decision-model gates ran before reconcile — by here they are settled, so no refusal
2455
+ // (and therefore no un-rolled-back funded wallet) can originate at the install step.
2456
+ // The verb never reuses an old wallet: a same-id runtime bound elsewhere is deleted and
2457
+ // recreated on the fresh wallet rather than left pointing at stale money.
2458
+ let replaced = replacedDeadRow;
2459
+ const byId = await deps.registryFindById(runtimeId);
2460
+ if (byId && byId.wallet?.toLowerCase() !== wallet.toLowerCase()) {
2461
+ // Includes an entry whose wallet is unreadable: leaving it in place makes installRuntime
2462
+ // refuse with a bare "already exists" plus a `runtime delete` hint, instead of this verb's
2463
+ // own never-reuse-an-old-wallet outcome.
2464
+ await deps.deleteRuntime(runtimeId);
2465
+ replaced +=
2466
+ `existing runtime was on an old wallet (${byId.wallet ?? "address unreadable"}) — deleting ` +
2467
+ `and recreating on the fresh wallet; never reusing the old one. `;
2468
+ }
2469
+ let runtimeYamlContent;
2470
+ try {
2471
+ runtimeYamlContent = renderRuntimeYaml(run.inst, wallet, decisionModel && pkg.decisionModelEnv
2472
+ ? { env: pkg.decisionModelEnv, model: decisionModel }
2473
+ : undefined);
2474
+ }
2475
+ catch (err) {
2476
+ record(run, "install", {
2477
+ status: "failed",
2478
+ detail: `${replaced}render failed: ${errorText(err)}`,
2479
+ });
2480
+ if (run.needsWallet)
2481
+ await rollbackWallet(run);
2482
+ return;
2483
+ }
2484
+ const result = await deps.installRuntime({
2485
+ runtimeYamlContent,
2486
+ runtimeYamlDir: run.inst.runtimeYamlDir,
2487
+ id: runtimeId,
2488
+ });
2489
+ if (!result.ok || !result.payload) {
2490
+ record(run, "install", {
2491
+ status: "failed",
2492
+ detail: `${replaced}install refused: ${result.error?.code ?? "UNKNOWN"} — ${result.error?.message ?? "no message"}`,
2493
+ evidence: { code: result.error?.code, message: result.error?.message },
2494
+ });
2495
+ if (run.needsWallet)
2496
+ await rollbackWallet(run);
2497
+ return;
2498
+ }
2499
+ const payload = result.payload;
2500
+ if (payload.unwired) {
2501
+ // The install succeeded and the runtime is UP — but with no live entry scanners, so it can
2502
+ // never produce a signal. Every one of the three phases is deterministic on this box, so
2503
+ // waiting on the observe step would only turn a certain failure into `installed-unobserved`
2504
+ // over a wallet this job just funded. It is an install failure, and it rolls back like one.
2505
+ run.report.runtimeId = payload.id;
2506
+ // The runtime is UP: `installRuntimeCore` set a live handle and wrote a registry row before
2507
+ // it reported the unwired phase. Closing the wallet under it (D-6, just below) would leave a
2508
+ // RUNNING runtime — DSL monitor, position reconcile, MCP polls — bound to a strategy this
2509
+ // same job closed. A re-run would clean it up, but the interim is exactly the zombie shape,
2510
+ // deliberately created on the money path. Delete it here, and delete it BEFORE the close so
2511
+ // nothing is ever live against a closing wallet. Unconditional: an install that failed must
2512
+ // leave nothing installed, adopted wallet or not.
2513
+ const removal = await removeBlindRuntime(payload.id);
2514
+ record(run, "install", {
2515
+ status: "failed",
2516
+ detail: `${replaced}${buildUnwiredInstallDetail({
2517
+ phase: payload.unwired,
2518
+ runtimeId: payload.id,
2519
+ wallet,
2520
+ createdByThisJob: run.needsWallet,
2521
+ removal,
2522
+ })}`,
2523
+ evidence: {
2524
+ ...payload,
2525
+ runtimeDeleted: removal.ok,
2526
+ ...(removal.ok ? {} : { deleteError: removal.error }),
2527
+ },
2528
+ });
2529
+ if (run.needsWallet)
2530
+ await rollbackWallet(run);
2531
+ return;
2532
+ }
2533
+ run.installedAtMs = deps.now();
2534
+ run.report.runtimeId = payload.id;
2535
+ record(run, "install", {
2536
+ status: "ok",
2537
+ detail: `${replaced}installed runtime "${payload.id}" on wallet ${payload.wallet}`,
2538
+ evidence: { ...payload },
2539
+ });
2540
+ }
2541
+ /**
2542
+ * Tear down a runtime whose install is being recorded `failed`. Never throws: a cleanup that
2543
+ * cannot run must not swallow the D-6 rollback that follows it — a stranded FUNDED wallet is the
2544
+ * worse outcome by far. The result is what the detail and the evidence are computed from, so the
2545
+ * report says what is actually left on the box rather than what was attempted.
2546
+ */
2547
+ async function removeBlindRuntime(id) {
2548
+ try {
2549
+ await deps.deleteRuntime(id);
2550
+ return { ok: true };
2551
+ }
2552
+ catch (err) {
2553
+ return { ok: false, error: errorText(err) };
2554
+ }
2555
+ }
2556
+ /**
2557
+ * D-6: unwind ONLY a wallet this job created and funded, ONLY when its install failed.
2558
+ * Never an adopted wallet (it predates this deploy), never on observe failure (the runtime is
2559
+ * installed and may open a position any moment), never on a refusal (nothing was created).
2560
+ * A close that cannot run or fails is E_ROLLBACK_INCOMPLETE — a stranded wallet is never silent.
2561
+ *
2562
+ * D-7: `strategy_close` returns the funds to the owner embedded wallet on its own, so the unwind
2563
+ * is one call — there is no separate withdraw step to get half-done.
2564
+ */
2565
+ async function rollbackWallet(run) {
2566
+ await span("deploy.rollback", { "senpi.deploy.step": "rollback" }, () => rollbackWalletBody(run));
2567
+ }
2568
+ /** Resolves with the rollback's result — the wiring turns it into `senpi.deploy.rollback.count`. */
2569
+ async function rollbackWalletBody(run) {
2570
+ const wallet = run.report.wallet;
2571
+ const funded = rollbackFundedClause(run.report.funded);
2572
+ emit({
2573
+ type: "job",
2574
+ phase: "install",
2575
+ detail: `install failed on wallet ${wallet}, which this job created and ${funded} — ` +
2576
+ `closing it and returning the funds`,
2577
+ });
2578
+ const incomplete = (reason) => record(run, "create", {
2579
+ status: "failed",
2580
+ detail: `[E_ROLLBACK_INCOMPLETE] wallet ${wallet} was created by this deploy and ${funded}, ` +
2581
+ `its install failed, and the automatic close did not complete: ${reason}. The wallet is ` +
2582
+ `live and unwatched. Reclaim the funds from THIS wallet: MCP \`strategy_close\` on ` +
2583
+ `${wallet}${buildStrandedCloseCaveat(pkg, runs, run)}`,
2584
+ evidence: { wallet, funded: run.report.funded, reason },
2585
+ });
2586
+ try {
2587
+ if (!(await deps.walletIsFlat(wallet))) {
2588
+ incomplete("the wallet holds open positions — refusing to auto-close");
2589
+ return "incomplete";
2590
+ }
2591
+ const result = await deps.closeStrategy(wallet);
2592
+ // A resolved close proves nothing on its own: `strategy_close` reports a tool-level failure
2593
+ // by RESOLVING `{ success: false }`, never by throwing. Treating that as done zeroed `funded`
2594
+ // and told the operator the funds were back while the wallet was still live and funded.
2595
+ const closeFailure = describeCloseFailure(result);
2596
+ if (closeFailure) {
2597
+ incomplete(closeFailure);
2598
+ return "incomplete";
2599
+ }
2600
+ // The structured facts the report leads with must agree with the step detail: this wallet is
2601
+ // closed and the funds are back with the owner, so it is no longer a funded wallet. The
2602
+ // amount survives in the step evidence, where it reads as history rather than as a holding.
2603
+ const fundedBeforeRollback = run.report.funded;
2604
+ run.report.funded = 0;
2605
+ run.report.rolledBack = true;
2606
+ record(run, "create", {
2607
+ status: "ok",
2608
+ detail: `wallet ${wallet} was created by this deploy and its install failed — rolled back: strategy ` +
2609
+ `closed, funds returned to the owner wallet (close result: ${JSON.stringify(result)})`,
2610
+ evidence: { wallet, funded: fundedBeforeRollback, rolledBack: true, closeResult: result },
2611
+ });
2612
+ return "ok";
2613
+ }
2614
+ catch (err) {
2615
+ incomplete(errorText(err));
2616
+ return "incomplete";
2617
+ }
2618
+ }
2619
+ // ── observe ───────────────────────────────────────────────────────────────
2620
+ const observable = runs.filter((run) => run.report.runtimeId && ["ok", "skipped"].includes(run.report.steps.install.status));
2621
+ if (!halted() && observable.length > 0) {
2622
+ emitPhase("observe", tickWaitSec > 0
2623
+ ? `waiting up to ${tickWaitSec}s for one verified scanner tick`
2624
+ : "tick observation skipped by --tick-wait 0");
2625
+ await span("deploy.observe", { "senpi.deploy.step": "observe" }, async () => {
2626
+ for (const run of observable) {
2627
+ if (boundaryAbandoned())
2628
+ break;
2629
+ await guarded(run, "observe", () => observeInstance(run));
2630
+ }
2631
+ return phaseStatus(runs, "observe");
2632
+ });
2633
+ }
2634
+ async function observeInstance(run) {
2635
+ const runtimeId = run.report.runtimeId;
2636
+ if (tickWaitSec <= 0) {
2637
+ // D-3 / M3: skipping observation must never read as verified — one argument away in a wrapper.
2638
+ record(run, "observe", {
2639
+ status: "unobserved",
2640
+ detail: `tick observation skipped (--tick-wait 0) — liveness NOT verified. ` +
2641
+ `Check it yourself: \`openclaw senpi scanner -r ${runtimeId}\``,
2642
+ });
2643
+ return;
2644
+ }
2645
+ const sinceMs = run.installedAtMs ?? deps.now();
2646
+ const deadline = deps.now() + tickWaitSec * 1000;
2647
+ let rowsLastSeen = [];
2648
+ /** Every distinct lastRunStatus seen across polls — the production data for tuning --tick-wait. */
2649
+ const statusesSeen = [];
2650
+ for (;;) {
2651
+ const rows = (await deps.getScannerRows(runtimeId)) ?? [];
2652
+ rowsLastSeen = rows;
2653
+ for (const row of rows) {
2654
+ if (row.lastRunStatus !== null && !statusesSeen.includes(row.lastRunStatus)) {
2655
+ statusesSeen.push(row.lastRunStatus);
2656
+ }
2657
+ }
2658
+ const active = rows.filter((row) => row.enabled);
2659
+ if (rows.length > 0 && active.length === 0) {
2660
+ record(run, "observe", {
2661
+ status: "unobserved",
2662
+ detail: `all ${rows.length} scanner(s) are disabled in the live engine — the runtime will ` +
2663
+ `never tick. Only the runtime API disables a scanner (a runtime.yaml \`enabled\` key is ` +
2664
+ `refused at deploy and never reaches the engine), so something disabled these after ` +
2665
+ `install. Check: \`openclaw senpi scanner -r ${runtimeId}\``,
2666
+ evidence: {
2667
+ scanners: rows.map((row) => ({ scannerId: row.scannerId, enabled: row.enabled })),
2668
+ statusesSeen,
2669
+ },
2670
+ });
2671
+ return;
2672
+ }
2673
+ const ticked = active.find((row) => isFreshTick(row, sinceMs));
2674
+ if (ticked) {
2675
+ const via = ticked.scheduleMode === "external"
2676
+ ? `alive (external, lastAliveAt fresh)`
2677
+ : `lastRunStatus=${ticked.lastRunStatus}`;
2678
+ record(run, "observe", {
2679
+ status: "ok",
2680
+ detail: `verified tick: ${ticked.scannerId} ${via} runCount=${ticked.runCount} ` +
2681
+ `signalsProduced=${ticked.signalsProduced} health=${ticked.health}`,
2682
+ evidence: {
2683
+ scannerId: ticked.scannerId,
2684
+ scheduleMode: ticked.scheduleMode,
2685
+ lastRunStatus: ticked.lastRunStatus,
2686
+ lastRunFinishedAt: ticked.lastRunFinishedAt,
2687
+ lastAliveAt: ticked.lastAliveAt,
2688
+ runCount: ticked.runCount,
2689
+ signalsProduced: ticked.signalsProduced,
2690
+ health: ticked.health,
2691
+ statusesSeen,
2692
+ },
2693
+ });
2694
+ return;
2695
+ }
2696
+ const failedRow = active.find((row) => isFreshError(row, sinceMs));
2697
+ if (failedRow) {
2698
+ // D-1: an error tick is a terminal answer — polling the rest of the budget adds nothing.
2699
+ record(run, "observe", {
2700
+ status: "failed",
2701
+ detail: buildObserveFailedDetail(failedRow, runtimeId),
2702
+ evidence: {
2703
+ scannerId: failedRow.scannerId,
2704
+ lastRunStatus: failedRow.lastRunStatus,
2705
+ errorCount: failedRow.errorCount,
2706
+ lastError: failedRow.lastError,
2707
+ health: failedRow.health,
2708
+ statusesSeen,
2709
+ },
2710
+ });
2711
+ return;
2712
+ }
2713
+ if (deps.now() >= deadline)
2714
+ break;
2715
+ await deps.sleep(OBSERVE_POLL_MS);
2716
+ if (deps.shouldAbandon()) {
2717
+ record(run, "observe", {
2718
+ status: "unobserved",
2719
+ detail: `deploy deadline hit before a tick was observed — the runtime is installed and may be ` +
2720
+ `ticking. Check it yourself: \`openclaw senpi scanner -r ${runtimeId}\``,
2721
+ });
2722
+ honorDeadline();
2723
+ return;
2724
+ }
2725
+ }
2726
+ // H2: a scanner on a long interval is not broken, it is early. When the registry says the first
2727
+ // tick is still ahead of the budget we just burned, say when — a bare "no tick observed" reads
2728
+ // as a fault and invites a close/recreate.
2729
+ const scheduled = rowsLastSeen.find((row) => row.enabled && row.nextRunAt !== null && row.nextRunAt > deadline);
2730
+ record(run, "observe", {
2731
+ status: "unobserved",
2732
+ detail: scheduled
2733
+ ? `no tick observed within ${tickWaitSec}s — scanner "${scheduled.scannerId}" is scheduled, ` +
2734
+ `first tick due ${new Date(scheduled.nextRunAt).toISOString()}. ` +
2735
+ `Check then: \`openclaw senpi scanner -r ${runtimeId}\``
2736
+ : buildUnobservedDetail(tickWaitSec, runtimeId),
2737
+ evidence: { statusesSeen },
2738
+ });
2739
+ }
2740
+ // ── overall ───────────────────────────────────────────────────────────────
2741
+ const overall = decideOverall(runs, refused, abandoned);
2742
+ // The soft-tier fields ride the FINAL report, not a step detail, so the warn is still on the page
2743
+ // for a `live` / `installed-unobserved` deploy — where the capped event tail has long since
2744
+ // scrolled past the preflight narration that first raised it.
2745
+ // The escape's precondition is the RUNTIME, not the wallet: `close.py --instance` resolves a
2746
+ // sleeve through its runtime. The note's tense reads the same terminal facts, but PER ROW —
2747
+ // "funds" follows each wallet, "will run" follows each runtime — so a mixed report cannot claim
2748
+ // one state about sleeves its own escape describes differently.
2749
+ const byName = new Map(runs.map((run) => [run.inst.name, run]));
2750
+ const hasRuntime = (name) => {
2751
+ const run = byName.get(name);
2752
+ return Boolean(run?.report.runtimeId) && ["ok", "skipped"].includes(run?.report.steps.install.status ?? "");
2753
+ };
2754
+ const hasLiveWallet = (name) => {
2755
+ const run = byName.get(name);
2756
+ return Boolean(run?.report.wallet) && !run?.report.rolledBack;
2757
+ };
2758
+ const isStranded = (name) => hasLiveWallet(name) && !hasRuntime(name);
2759
+ const isZeroShare = (name) => pkg.instances.find((inst) => inst.name === name)?.fundingShare === 0;
2760
+ const escapeState = {
2761
+ closable: soft.under.map((row) => row.wallet).filter((name) => hasRuntime(name) && !isZeroShare(name)),
2762
+ strandedNames: soft.under.map((row) => row.wallet).filter(isStranded),
2763
+ walletless: walletlessAfter(pkg, runs),
2764
+ zeroShare: soft.under.map((row) => row.wallet).filter(isZeroShare),
2765
+ rollbackIncomplete: runs.some((run) => run.report.steps.create.status === "failed" &&
2766
+ run.report.steps.create.detail.startsWith("[E_ROLLBACK_INCOMPLETE]")),
2767
+ };
2768
+ const underNames = soft.under.map((row) => row.wallet);
2769
+ const softNote = soft.min
2770
+ ? buildSoftBudgetNote(pkg, soft.min, soft.under, buildBudgetEscape(pkg, soft.min, escapeState), {
2771
+ funded: new Set(underNames.filter(hasLiveWallet)),
2772
+ running: new Set(underNames.filter(hasRuntime)),
2773
+ })
2774
+ : null;
2775
+ const softTier = {
2776
+ ...(soft.min
2777
+ ? {
2778
+ minBudget: soft.min.minBudget,
2779
+ minWalletCount: soft.min.walletCount,
2780
+ ...(soft.min.unresolvedWallets.length > 0
2781
+ ? { minBudgetUnresolved: soft.min.unresolvedWallets }
2782
+ : {}),
2783
+ }
2784
+ : {}),
2785
+ ...(soft.under.length > 0 ? { belowMin: true } : {}),
2786
+ ...(softNote ? { minBudgetNote: softNote } : {}),
2787
+ };
2788
+ /**
2789
+ * The OUTCOME check the plan-side W_BUDGET_* tiers cannot make: what the backend actually landed,
2790
+ * against what this job asked it for. Computed HERE, in the single place that has both halves —
2791
+ * the ask (the amount `planFunding` decided and the create call carried) and `report.funded`
2792
+ * (quoted from the post-create `strategy_list` read, the same figure the create step prints).
2793
+ *
2794
+ * The ask comes from this run's own `fundAmount` when this run funded, and otherwise from
2795
+ * {@link DeployParams.priorRequested} — the amount a prior run of this package journaled for the
2796
+ * very strategyId this run just ADOPTED. Without that fallback a crash between a partial fund and
2797
+ * report assembly resumed as an adoption with no ask, and the shortfall vanished from the report.
2798
+ * The journal supplies the ask ONLY; `funded` is always the live read from this run.
2799
+ *
2800
+ * Every guard below exists so the pair can only ever be a real, comparable one:
2801
+ * - the ask — a wallet nobody ever planned to fund has none, and inventing one would announce a
2802
+ * shortfall against money that was never requested; the create step's own "the requested …
2803
+ * was NOT added" line already owns that case. A non-positive ask is not a comparison either.
2804
+ * - `report.wallet` — set on, and only on, the ACTIVE strategy read. Absent means the read never
2805
+ * happened (create pending, failed, abandoned), so there is nothing to say about a wallet
2806
+ * nobody looked at. Silence is the fail-closed answer.
2807
+ * - a rollback that was ATTEMPTED, proven (`rolledBack`) or not. A proven close zeroes `funded`,
2808
+ * so the pair would compare $0 against the ask on a wallet that no longer exists. An
2809
+ * INCOMPLETE one is the sharper case: `funded` keeps its partial value, and the create step is
2810
+ * already carrying `[E_ROLLBACK_INCOMPLETE]` — "reclaim the funds from THIS wallet". A
2811
+ * top-the-wallet-up warn beside it makes one report the author of two opposite money moves on
2812
+ * one wallet, and an agent reconciling them runs whichever executes. When the rollback text is
2813
+ * present it is the single remediation authority, exactly as the funding-round doctrine says.
2814
+ *
2815
+ * `report.funded` absent WITH a wallet present is the third outcome, not silence: the read
2816
+ * happened and the backend reported no readable figure. That is
2817
+ * {@link buildFundedUnreadableNote}, never a `$0.00` claim.
2818
+ */
2819
+ const rollbackAttempted = (run) => Boolean(run.report.rolledBack) ||
2820
+ (run.report.steps.create.status === "failed" &&
2821
+ run.report.steps.create.detail.startsWith("[E_ROLLBACK_INCOMPLETE]"));
2822
+ const askFor = (run) => run.fundAmount ??
2823
+ (run.report.strategyId !== undefined ? params.priorRequested?.get(run.report.strategyId) : undefined);
2824
+ const partialFunded = [];
2825
+ const unreadableFunded = [];
2826
+ for (const run of runs) {
2827
+ const requested = askFor(run);
2828
+ const funded = run.report.funded;
2829
+ const wallet = run.report.wallet;
2830
+ if (requested === undefined || requested <= 0)
2831
+ continue;
2832
+ if (wallet === undefined || rollbackAttempted(run))
2833
+ continue;
2834
+ if (funded === undefined) {
2835
+ unreadableFunded.push({
2836
+ instance: run.inst.name,
2837
+ wallet,
2838
+ requested,
2839
+ created: run.needsWallet,
2840
+ });
2841
+ continue;
2842
+ }
2843
+ if (funded >= requested * BUDGET_TOLERANCE)
2844
+ continue;
2845
+ partialFunded.push({
2846
+ instance: run.inst.name,
2847
+ wallet,
2848
+ requested,
2849
+ funded,
2850
+ created: run.needsWallet,
2851
+ });
2852
+ }
2853
+ /**
2854
+ * Whether a budget note may claim "nothing is broken — the strategy is LIVE".
2855
+ *
2856
+ * One condition, because one is all there is: the report as a WHOLE must be `live`. That verdict
2857
+ * is the sentence's whole subject, and {@link decideOverall} only reaches it when EVERY step of
2858
+ * every instance is `ok` or `skipped` — no refusal, no abandonment, nothing failed, pending or
2859
+ * unobserved anywhere in the package. The gate used to admit `installed-unobserved` too, and its
2860
+ * every() only ever looked at the rows the note names, so a fully-funded sibling sleeve that
2861
+ * never ticked left the note asserting "the strategy is LIVE" two lines under a verdict saying
2862
+ * the opposite.
2863
+ *
2864
+ * A per-row "installed and observed ticking" check rode along after that fix, justified by "live
2865
+ * tolerates a skipped observe". It does not, and the check was therefore dead: `observeInstance`
2866
+ * records only `ok`, `unobserved` or `failed`, the sole writer of a `skipped` observe is the
2867
+ * deadline honour — which sets `abandoned`, and an abandoned deploy is `failed` — and an instance
2868
+ * that was never observed at all keeps `pending`. Under `live`, every named row's observe is
2869
+ * already `ok`. A dead check kept alive by a comment that asserts the opposite is worse than no
2870
+ * check: the next maintainer adds a real skipped-observe path believing this gate covers it. If
2871
+ * one is ever added, `live` stops proving "observed ticking" and this is the site to revisit.
2872
+ */
2873
+ const reassures = () => overall === "live";
2874
+ return {
2875
+ packageId: pkg.id,
2876
+ version: pkg.version,
2877
+ overall,
2878
+ instances: runs.map((run) => run.report),
2879
+ next: buildNext(overall, pkg, budget, runs),
2880
+ ...softTier,
2881
+ ...(partialFunded.length > 0
2882
+ ? { partialFundNote: buildPartialFundNote(pkg, partialFunded, reassures()) }
2883
+ : {}),
2884
+ ...(unreadableFunded.length > 0
2885
+ ? { fundedUnreadableNote: buildFundedUnreadableNote(pkg, unreadableFunded, reassures()) }
2886
+ : {}),
2887
+ };
2888
+ }
2889
+ /**
2890
+ * Refusals and abandonment outrank outcomes, and an unobserved tick can never
2891
+ * read as success — the precedence order is the fail-closed one.
2892
+ *
2893
+ * An abandoned deploy is `failed`, not a status of its own: it ran out of wall clock without
2894
+ * finishing, and the resume path is the same one every failure gets.
2895
+ */
2896
+ function decideOverall(runs, refused, abandoned) {
2897
+ if (refused)
2898
+ return "refused";
2899
+ if (abandoned)
2900
+ return "failed";
2901
+ const statuses = runs.flatMap((run) => STEP_ORDER.map((step) => run.report.steps[step].status));
2902
+ if (statuses.includes("failed"))
2903
+ return "failed";
2904
+ if (statuses.includes("pending"))
2905
+ return "pending";
2906
+ if (statuses.includes("unobserved"))
2907
+ return "installed-unobserved";
2908
+ return "live";
2909
+ }
2910
+ //# sourceMappingURL=orchestrator.js.map