@senpi/runtime 3.0.65 → 3.0.66

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 (38) hide show
  1. package/dist/cli/deploy-render.d.ts +3 -1
  2. package/dist/cli/deploy-render.d.ts.map +1 -1
  3. package/dist/cli/deploy-render.js +233 -11
  4. package/dist/cli/deploy-render.js.map +1 -1
  5. package/dist/cli/guide-commands.d.ts.map +1 -1
  6. package/dist/cli/guide-commands.js +9 -7
  7. package/dist/cli/guide-commands.js.map +1 -1
  8. package/dist/cli/senpi-commands.js +4 -4
  9. package/dist/cli/senpi-commands.js.map +1 -1
  10. package/dist/deploy/deadlines.d.ts +3 -3
  11. package/dist/deploy/deadlines.js +3 -3
  12. package/dist/deploy/job.d.ts +16 -0
  13. package/dist/deploy/job.d.ts.map +1 -1
  14. package/dist/deploy/job.js +7 -1
  15. package/dist/deploy/job.js.map +1 -1
  16. package/dist/deploy/orchestrator.d.ts +2 -1
  17. package/dist/deploy/orchestrator.d.ts.map +1 -1
  18. package/dist/deploy/orchestrator.js +402 -84
  19. package/dist/deploy/orchestrator.js.map +1 -1
  20. package/dist/deploy/proof-gate.d.ts.map +1 -1
  21. package/dist/deploy/proof-gate.js +8 -3
  22. package/dist/deploy/proof-gate.js.map +1 -1
  23. package/dist/deploy/register.js +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +32 -3
  26. package/dist/index.js.map +1 -1
  27. package/dist/senpi/client.d.ts.map +1 -1
  28. package/dist/senpi/client.js +72 -5
  29. package/dist/senpi/client.js.map +1 -1
  30. package/dist/senpi/types.d.ts +18 -0
  31. package/dist/senpi/types.d.ts.map +1 -1
  32. package/dist/validate/proof.js +4 -1
  33. package/dist/validate/proof.js.map +1 -1
  34. package/dist/validate/run.d.ts.map +1 -1
  35. package/dist/validate/run.js +4 -1
  36. package/dist/validate/run.js.map +1 -1
  37. package/openclaw.plugin.json +1 -1
  38. package/package.json +1 -1
@@ -36,63 +36,83 @@ const CREATE_POLL_MS = 10_000;
36
36
  const OBSERVE_POLL_MS = 5_000;
37
37
  const STEP_ORDER = ["reconcile", "preflight", "create", "install", "observe"];
38
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.
39
+ * Backend codes that mean "the name is unusable AND nothing was created", so retrying unnamed is
40
+ * free — see {@link isNameRejection}. Spelled out one per entry rather than packed into a character
41
+ * class (`SERR05[568]`) because the reader who arrives here arrives holding a code: a grep for the
42
+ * exact string they were handed has to land on this list.
43
+ *
44
+ * All three are `INVALID_ARGUMENT` in the MCP's registry (`senpi-error-registry.ts:155-167`):
45
+ * deterministic input refusals. 055 is the active-name collision; 056 and 058 are the length and
46
+ * whitespace rules, enforced by zod before the request ever reaches the backend. The backend
47
+ * rejected the input and created nothing, so a retry cannot double-fund.
48
+ *
49
+ * SERR083 was here and is now DELIBERATELY EXCLUDED (decision, 2026-08-11). Its text is generic
50
+ * ("Custom strategy creation failed unexpectedly. Retry with the same payload.") and the registry
51
+ * classes it UNAVAILABLE/`retryable` — meaning the outcome is unknown and a wallet may exist behind
52
+ * it. The case it was added for is real: a strategy still holding its name made every named create
53
+ * fail, and two calls 3.6s apart at the same $693 budget went named → SERR083, unnamed → success.
54
+ * But that unblock is bought with a retry that funds a SECOND wallet whenever the first create did
55
+ * land, and the runtime has a cheaper route to the same outcome: reconcile reads the live
56
+ * strategies and adopts an initializing record BY NAME before the create call is ever made
57
+ * (`awaitingActive`), which is exactly the state that produces SERR083. deploy.py, where this guard
58
+ * came from, had no such step — that is why the code list travelled here with 083 in it. So the
59
+ * defensive arm was chosen: no retry, and the operator gets the backend's real words (item 1) in
60
+ * place of "backend returned no strategyId".
55
61
  */
56
- const NAME_REJECTION_CODES = ["SERR055", "SERR056", "SERR058", "SERR083"];
62
+ const NAME_REJECTION_CODES = ["SERR055", "SERR056", "SERR058"];
63
+ /** The `SERRnnn` token in a code or a message, upper-cased; `undefined` when there is none. */
64
+ function readSerrCode(text) {
65
+ return text?.toUpperCase().match(/SERR\d{3}/)?.[0];
66
+ }
57
67
  /**
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.)
68
+ * True when a failed create is the backend refusing the wallet NAME **and nothing was created**, so
69
+ * retrying unnamed works and cannot cost a second funded wallet.
70
+ *
71
+ * Keyed on the CODE, not on prose. Reading the code was impossible until the client learned to
72
+ * parse the error envelope: a backend refusal has no `data` key, so the old flat `data.message` read
73
+ * returned `undefined` for every one of them and this predicate was called with `""`. That made the
74
+ * safe arm — the enumerated backend codes — dead, while the dangerous arm stayed live: `callCreate`
75
+ * shapes every THROW into `{ success: false, message: errorText(err) }`, and a transport error's
76
+ * text is authored outside this repo. The predicate the codes were written for could never fire;
77
+ * the only failures that could reach the retry were the ones whose outcome nobody knows.
78
+ *
79
+ * Three arms, in this order:
80
+ *
81
+ * 1. **A throw is never a name rejection.** `callTool` throws only on an RPC fault
82
+ * (`senpi/client.ts` `callToolInner`); every backend refusal RESOLVES as an error envelope. So
83
+ * a throw here means transport, and a transport failure raised after the request was written —
84
+ * socket reset, 502, a gateway-injected transport failing mid-call — may have already created
85
+ * and funded a wallet. Retrying funds a second one beside it. Refusing on `threw` is stricter
86
+ * than telling pre-send from post-send (a connection refused before the request left is safe to
87
+ * retry), and it is deliberately the strict version: the give-up costs one legible name, the
88
+ * false positive costs a funded wallet. A deadline abort is one member of this class, not the
89
+ * whole of it — `aborted` flags only the member `callCreate` recognises.
90
+ * 2. **A SERR code decides, wherever it is written.** The structured `errorCode` first, then the
91
+ * message — because the zod-enforced rules (056 length, 058 whitespace) arrive as
92
+ * `INVALID_ARGUMENT` with the SERR code in the TEXT (`classifyError` → `InvalidArgumentError`),
93
+ * while a backend-mapped refusal carries it structurally. If a code is found, it decides
94
+ * alone: an out-of-list code (SERR083, SERR045, SERR037) is a NO, and the text is not consulted
95
+ * afterwards, so a non-name refusal can never sneak in on a stray word.
96
+ * 3. **Text, only when there is no code at all.** So an uncoded future refusal is not silently
97
+ * unmatchable — the reason the bare-word arm existed. It matches the WORD `name`
98
+ * (`\bname\b`), not the substring: `HOSTNAME`, `USERNAME`, `UNNAMED` and an echoed
99
+ * `strategyName` key all fail the word boundary, which is the false-positive class the
100
+ * follow-up ticket named. And because arm 1 already returned, this arm can only ever see a
101
+ * RETURNED failure — the backend answered, so nothing was created — which is why widening it
102
+ * cannot re-open the outcome-unknown hole.
103
+ *
104
+ * Code matching is case-INSENSITIVE, as the regex this replaced was: the backend spells them
105
+ * upper-case today, and nothing here is worth losing the unblock over if some relay downcases a
106
+ * message. (deploy.py compared the codes case-sensitively; that is the one deliberate difference,
107
+ * and it is in the direction of firing more often, never less.)
92
108
  */
93
- function isNameRejection(message) {
94
- const upper = message.toUpperCase();
95
- return NAME_REJECTION_CODES.some((code) => upper.includes(code)) || upper.includes("NAME");
109
+ function isNameRejection(result) {
110
+ if (result.threw)
111
+ return false;
112
+ const code = readSerrCode(result.errorCode) ?? readSerrCode(result.message);
113
+ if (code)
114
+ return NAME_REJECTION_CODES.includes(code);
115
+ return /\bname\b/i.test(result.message ?? "");
96
116
  }
97
117
  const ABANDONED_DETAIL = "abandoned at the deploy deadline — the job exceeded its wall-clock cap";
98
118
  /** Ticks that prove the scanner executed; `ok` literally means "found a signal", so heartbeat counts. */
@@ -109,6 +129,25 @@ function isFreshTick(row, sinceMs) {
109
129
  row.lastRunFinishedAt >= sinceMs // H3: pre-install telemetry survives a re-mint
110
130
  );
111
131
  }
132
+ /**
133
+ * Did THIS row record a run of its own since the install — regardless of how it is scheduled?
134
+ *
135
+ * Kept separate from {@link isFreshTick}, which is the GATE and must not change: its external
136
+ * branch short-circuits on `lastAliveAt` before reading these fields, deliberately, because a
137
+ * watch-only external scanner can never record a run status and demanding one would make its
138
+ * deploy unable to ever reach `live`. This is the RENDER classifier — it decides which of the two
139
+ * claims the observe line is allowed to make, and it must answer for the row in front of it rather
140
+ * than for its schedule mode, since an external scanner that posts non-empty signals does commit
141
+ * run telemetry.
142
+ *
143
+ * Merging the two would be the bug: the gate would start demanding what this asks for.
144
+ */
145
+ function recordedFreshRun(row, sinceMs) {
146
+ return (row.lastRunStatus !== null &&
147
+ OBSERVED_RUN_STATUSES.includes(row.lastRunStatus) &&
148
+ row.lastRunFinishedAt !== null &&
149
+ row.lastRunFinishedAt >= sinceMs);
150
+ }
112
151
  function isFreshError(row, sinceMs) {
113
152
  return (row.lastRunStatus === "error" &&
114
153
  row.lastRunFinishedAt !== null &&
@@ -253,8 +292,9 @@ function buildAdoptedWalletDetail(pkg, inst, matched, wallet, budget, matchedBy
253
292
  * or something this verb does not recognise.
254
293
  *
255
294
  * 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,
295
+ * its own, so waiting on it burns the whole `--max-wait` and then reports the bounded-wait outcome
296
+ * — "re-run to resume, reconciliation waits for it to reach ACTIVE" — which is unactionable on a
297
+ * status that will never get there. The next step is computed from the status actually read,
258
298
  * and the non-destructive option always leads.
259
299
  */
260
300
  function buildNotDeployableRefusal(matched, instance, pkg, budget) {
@@ -782,10 +822,10 @@ function describeUnwiredPhase(phase, createdByThisJob) {
782
822
  }
783
823
  /**
784
824
  * 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.
825
+ * replaces is the point: `buildCreatePendingDetail` would have said "waits for it to reach ACTIVE,
826
+ * never creating a second wallet", and on this input neither half holds. It never reaches ACTIVE,
827
+ * and reconcile filters dead statuses on BOTH match routes, so a re-run creates and funds a fresh
828
+ * wallet beside it. The status only ever survived in the step evidence; here it leads the claim.
789
829
  */
790
830
  function buildCreateDeadDetail(pkg, budget, live, createdByThisJob) {
791
831
  const origin = polledWalletOrigin(createdByThisJob);
@@ -811,8 +851,8 @@ function buildCreateDeadDetail(pkg, budget, live, createdByThisJob) {
811
851
  * there — PAUSED, mid-teardown, or one this build does not recognise.
812
852
  *
813
853
  * 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.
854
+ * everything else burned the full `--max-wait` and was then reported with the bounded-wait detail's
855
+ * "re-run; reconciliation waits for it to reach ACTIVE" promise, which none of them will honour.
816
856
  *
817
857
  * The REMEDIATION branches, and must: the create step gets no D-6 rollback (only an install failure
818
858
  * rolls a wallet back), so a wallet this job created and funded seconds ago is still live when this
@@ -855,8 +895,13 @@ closeOffer) {
855
895
  `or whether waiting would have helped. Read-only triage FIRST: ${statusPy}, ` +
856
896
  `\`openclaw senpi deploy status\`, or MCP \`strategy_get\` with strategy_id "${live.id}" (that tool takes ` +
857
897
  `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.`);
898
+ // "seconds from ACTIVE" stood here — an unprovable duration on a rendered surface, the same
899
+ // class deleted from the bounded-wait outcome. Nothing here read how far along this strategy
900
+ // is; what this payload DOES establish is that the status is unclassifiable, which is an
901
+ // argument for not closing on its own.
902
+ `strategy to "start clean": ${itsFunded(live.totalFunded)} is not proven stranded, and a status ` +
903
+ `this build cannot place is not evidence the strategy is finished — closing it would throw away ` +
904
+ `a wallet nothing here showed to be done.`);
860
905
  }
861
906
  /**
862
907
  * The start-over clause for a PAUSED poll, scoped to this job's whole INTENT, never to a mid-loop
@@ -918,10 +963,246 @@ function polledWalletTarget(live) {
918
963
  ? `wallet ${live.strategyWalletAddress}`
919
964
  : `strategy id ${live.id} (its wallet address is unreadable)`;
920
965
  }
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)`);
966
+ /**
967
+ * What the last poll read said about the record, and nothing else.
968
+ *
969
+ * Both facts are quoted from the `strategy_list` row the poll already holds — no extra call, no
970
+ * interpretation. An absent row and an unreadable field are named as such rather than filled in:
971
+ * this whole surface exists because "wallet still funding" was asserted over states nobody read.
972
+ *
973
+ * The status carries one qualifier, and it is a classification this repo owns rather than a
974
+ * measurement: membership of `INITIALIZING_STATUSES`, the list whose entire definition is "statuses
975
+ * that resolve on their own, so a bounded poll is the right response" (`senpi/strategy-status.ts`).
976
+ * It is provable by a lookup against a list in this repo, it needs no hedge, and it is the reason
977
+ * waiting — and re-running to keep waiting — is the correct next step on that payload.
978
+ *
979
+ * Nothing here says how long initialization takes. The backend owns that; a figure measured once
980
+ * somewhere else is not something this deploy read, cannot be checked from here, and cannot be
981
+ * acted on — so the only durations on this surface are the wait this job bounded and the record's
982
+ * age off `createdAt`.
983
+ */
984
+ function polledRecordRead(live, nowMs) {
985
+ if (!live) {
986
+ return "the last `strategy_list` read returned no row for it, so its status and age are unread here";
987
+ }
988
+ // The empty-status arm is unreachable through `runDeploy`: an empty status is neither ACTIVE nor
989
+ // initializing, so `pollUntilActive`'s non-initializing guard reports the step `failed` through
990
+ // `buildCreateNotDeployableDetail` before the deadline branch can run, and reconcile refuses the
991
+ // same value at the match seam (`buildNotDeployableRefusal`). Kept anyway: `status` is a string
992
+ // this process read off the backend, this builder is one guard-reorder away from seeing an empty
993
+ // one, and the alternative rendering is the blank in "it reads status and was created 630s ago".
994
+ const status = live.status
995
+ ? `status ${live.status.toUpperCase()}${isInitializing(live.status) ? " (an initializing status this wait is for)" : ""}`
996
+ : "NO STATUS on that read";
997
+ const createdMs = Date.parse(live.createdAt ?? "");
998
+ if (Number.isNaN(createdMs)) {
999
+ return `it reads ${status}, age UNREADABLE (the row carried no parseable createdAt)`;
1000
+ }
1001
+ const ageSec = Math.max(0, Math.round((nowMs - createdMs) / 1000));
1002
+ return `it reads ${status} and was created ${ageSec}s ago`;
1003
+ }
1004
+ /**
1005
+ * Is the NAME route even open for this row — i.e. does it carry the name a re-run will look up?
1006
+ *
1007
+ * Reconcile has two match routes: `strategyName` (case-folded, non-empty on both sides) and a
1008
+ * create key journaled by THIS box. This is the name comparison out of `resolveInstanceBinding`,
1009
+ * and it is a NECESSARY condition for that route, never a sufficient one. Reconcile applies three
1010
+ * further filters this function cannot see, all of them about rows other than the one held here:
1011
+ * it drops `DEAD_STATUSES` before comparing, it refuses when the surviving list carries a foreign
1012
+ * `skillName` stamp (`E_WALLET_OWNED_BY_OTHER_PACKAGE`), and it refuses when more than one
1013
+ * candidate answers to the name (`E_STATE_AMBIGUOUS_WALLETS`). All three are executed and all three
1014
+ * create nothing — so a `false` here means "the name route is closed", while a `true` means only
1015
+ * "it is not closed by the name", which is why the sentence it feeds states its outcome as
1016
+ * conditional rather than as a fact.
1017
+ *
1018
+ * A `false` is not hypothetical: the unnamed create retry sends no `strategyName` at all (it is
1019
+ * `.optional()` in the MCP schema), so the backend names that wallet itself and the row comes back
1020
+ * under a name this package will never derive.
1021
+ */
1022
+ function reconcileCanMatchByName(wantedName, live) {
1023
+ const wanted = wantedName.trim().toLowerCase();
1024
+ return wanted !== "" && (live.strategyName ?? "").trim().toLowerCase() === wanted;
1025
+ }
1026
+ /**
1027
+ * The money invariant, and the executed boundary it holds at.
1028
+ *
1029
+ * `resolveInstanceBinding` matches by NAME first, and a non-empty match ends in `applyCandidates` —
1030
+ * which adopts, waits or refuses and has no create in it. Only `gateOrCreate` sets `needsWallet`,
1031
+ * and it is reached only when NO live row answers to the name this deploy derives. So this is not a
1032
+ * prediction about the row in hand: it is a property of the name route, and it was executed rather
1033
+ * than reasoned — 180 second `runDeploy`s over a live row under that name (status × stamp × sibling
1034
+ * count × address readable/blank × journal present/absent) created nothing, split across `ok` binds
1035
+ * and three different refusal codes, and so did every payload where this row was dead or under
1036
+ * another name while a live row still answered to the derived one.
1037
+ *
1038
+ * WHICH of those answers a re-run gives is exactly what this builder cannot know, so it is not
1039
+ * stated. The create count is the part that spends money, and it is the part that holds.
1040
+ *
1041
+ * `carriesTheName` is the read: this row's own name is the one a re-run looks up. Without a row
1042
+ * (the poll's last read returned none) the invariant still holds — it is about the name, not about
1043
+ * this row — so the clause that asserts something about the row is simply dropped.
1044
+ *
1045
+ * Both halves are scoped to THIS strategy, and that scope is the whole of what the boundary
1046
+ * covers. The command is package-wide: `deploy` runs every sleeve, and a sleeve with no live row
1047
+ * under its own derived name gets a wallet — correct behaviour, and it means a sentence whose
1048
+ * subject is the command is false on any multi-sleeve package. Executed on a two-sleeve package
1049
+ * (`main` 0.6 / `scalp` 0.4, budget 250) whose `main` sleeve renders this very sentence: re-running
1050
+ * exactly the command it names creates `spider-scalp` at `initialBudget: 100` beside a live sibling
1051
+ * row, and again when that row is dead or absent (only an ours-stamped sibling refuses) — while
1052
+ * `spider-main` is bound, not created, on all four. So the promise is stated per strategy: this
1053
+ * strategy is bound or stopped, and no second wallet appears for it.
1054
+ */
1055
+ function noSecondWalletWhileNamedLive(resume, carriesTheName) {
1056
+ const lead = carriesTheName
1057
+ ? `This strategy carries the name a re-run looks up, and while a live strategy still answers to ` +
1058
+ `that name, `
1059
+ : `While a live strategy answers to the name a re-run looks up, `;
1060
+ return (`${lead}re-running ${resume} binds this strategy to what it finds there or stops with a code — ` +
1061
+ `never creating a second wallet for it.`);
1062
+ }
1063
+ /**
1064
+ * The same invariant read from the other side, for a row the name route cannot reach.
1065
+ *
1066
+ * The unnamed create retry sends no `strategyName`, so the backend names that wallet and the name
1067
+ * this package derives will never hit it. The protection above is about that name, and this row is
1068
+ * not under it — executed: names differ, journal absent, and the stamp missing from the read
1069
+ * (starvation; both create calls send `skillName: pkg.id`, `:3252` and `:3294`, so a stampless read
1070
+ * is a producer fault rather than a coin flip) answers `reconcile: ok` with ONE create, a second
1071
+ * funded wallet standing beside this live row. Six such rows executed, across the statuses and
1072
+ * addresses this builder can render.
1073
+ *
1074
+ * Stated as what it CAN do, because whether it does turns on the journal — a file on disk, read by
1075
+ * the next process, which this render path cannot consult. That is what the triage step is for.
1076
+ *
1077
+ * The protection it quotes is scoped per strategy for the same reason the sentence above is: the
1078
+ * command covers every sleeve in the package, so "a re-run funds no second wallet" as a statement
1079
+ * about the COMMAND is false on any multi-sleeve package (executed — see
1080
+ * {@link noSecondWalletWhileNamedLive}).
1081
+ */
1082
+ function secondWalletRiskWhenNameRouteClosed(resume) {
1083
+ return (`A re-run funds no second wallet for a strategy while a live strategy answers to the name it ` +
1084
+ `looks up — this strategy is not under that name, so that does not cover it: re-running ` +
1085
+ `${resume} can fund a SECOND wallet beside this live one.`);
1086
+ }
1087
+ /**
1088
+ * Facts that change what a re-run does — named, with no outcome attached to any of them, and with
1089
+ * no claim that naming them closes the question.
1090
+ *
1091
+ * Each is a fact about rows this process did not read, or about a field it read as absent. Four
1092
+ * rounds attached outcomes to them and each round's enumeration was falsified by the payload beside
1093
+ * the one it was written from, including along these last two axes, which the stated conditions
1094
+ * never named at all. Naming the facts is what this builder can do; naming their outcomes is not.
1095
+ *
1096
+ * A fifth round then attached a COUNT to them ("Four facts decide the answer"), which is a closure
1097
+ * claim and is false on the names-differ arms: the create key journaled on this box decides those,
1098
+ * and this builder takes no journal argument, so it renders the same bytes either way. Executed —
1099
+ * identical row, byte-identical detail: journal absent → one create, journal present → none. The
1100
+ * journal is not listed here for exactly that reason (a fact this render cannot read is not
1101
+ * context, it is a guess); what is dropped instead is the pretence that the list is complete.
1102
+ */
1103
+ const FACTS_THAT_DECIDE = `Facts that change the answer, none of them settled by this read: whether another live strategy ` +
1104
+ `answers to that name, whether one under it carries a different package's stamp, whether this ` +
1105
+ `strategy has died, and whether its wallet address has become readable.`;
1106
+ /**
1107
+ * The system invariant behind every wallet sentence on this surface, and who resolves a breach.
1108
+ *
1109
+ * One wallet per strategy; two is the failure mode. That makes a duplicate recoverable rather than
1110
+ * catastrophic — both wallets are ordinary rows in `strategy_list` — but only for a reader who is
1111
+ * told what the duplicate looks like and that choosing between the two is not this deploy's call.
1112
+ * Closing is the destructive act, so an agent picking the wrong row closes a funded wallet; that is
1113
+ * why this stands immediately before the never-close line and states no outcome of its own.
1114
+ *
1115
+ * The invariant and whose call a breach is hold on every payload. Where to LOOK does not: on the
1116
+ * degraded read that returned no row, pointing at `strategy_list` for the duplicate contradicts the
1117
+ * lead this same detail opens with, and an agent that triages, sees one row, and reads that as "no
1118
+ * duplicate" re-runs — funding a wallet at the full budget. So that clause renders only where a row
1119
+ * was actually read.
1120
+ */
1121
+ function oneWalletPerStrategy(rowWasRead) {
1122
+ // The locator is dropped where the read this detail was built from returned no row. Saying "two
1123
+ // wallets show as two rows in `strategy_list`" beside a lead that just reported no row there
1124
+ // reads as "the list is authoritative and it shows one" — an agent that triages, sees nothing,
1125
+ // and concludes there is no duplicate re-runs, and that re-run funds a wallet at the full budget.
1126
+ // The invariant and whose call it is hold on every payload; only the where-to-look does not.
1127
+ const whoDecides = rowWasRead
1128
+ ? `Two wallets show as two rows in \`strategy_list\`, and which one to keep is the user's call.`
1129
+ : `Which one to keep is the user's call.`;
1130
+ return `One wallet per strategy is the invariant, and two is the failure mode. ${whoDecides}`;
1131
+ }
1132
+ /**
1133
+ * The one certain next step, on every arm, and the one act that must never follow.
1134
+ *
1135
+ * Read-only triage is the only instruction here that is true regardless of what the next read
1136
+ * finds, and close/recreate is the only genuinely destructive act available on this surface — it
1137
+ * throws away a wallet that may already be funded and live.
1138
+ *
1139
+ * `strategy_list` is named alone. `openclaw senpi status` used to stand beside it as an
1140
+ * alternative "to establish those", and it establishes none of them: it renders
1141
+ * `RuntimeHealthStatus` for runtimes installed HERE — runtime id, health, log level, scanners, DSL,
1142
+ * actions — and reads no backend strategy row, so it carries no name, no status, no package stamp
1143
+ * and no wallet address. Executed through the CLI action: on this payload the install step never
1144
+ * ran, so the gateway collects no handles and the whole output is `No running runtime health
1145
+ * status.`; on the richest payload it CAN render, every line is about a local runtime. An agent
1146
+ * that took the cheaper option would have learned nothing and could count the mandatory triage step
1147
+ * as discharged.
1148
+ */
1149
+ function triageThenNeverClose(rowWasRead) {
1150
+ return (`Read-only triage first — \`strategy_list\` — to establish those before re-running anywhere. ` +
1151
+ `${oneWalletPerStrategy(rowWasRead)} NEVER close or recreate to "start clean".`);
1152
+ }
1153
+ /** The name this row actually carries versus the one this deploy asked for, when they differ. */
1154
+ function polledNameMismatchClause(wantedName, live) {
1155
+ const wanted = wantedName.trim();
1156
+ const has = (live.strategyName ?? "").trim();
1157
+ const asked = wanted ? `the "${wanted}" this deploy asked for` : "any name this deploy can look it up by";
1158
+ return has ? `, under the name "${has}", not ${asked}` : `, under no readable name of its own, not ${asked}`;
1159
+ }
1160
+ /**
1161
+ * The bounded wait ran out. What this renders is what it READ, plus the one thing it can promise.
1162
+ *
1163
+ * The poll's success test is ACTIVE **and** a readable wallet address; everything else that is not
1164
+ * ACTIVE is intercepted before the deadline (dead → `failed`, neither-ACTIVE-nor-initializing →
1165
+ * `failed`), and reconcile refuses those statuses one screen earlier. So what lands here is either
1166
+ * a row still on its way to ACTIVE, or a row that already READS ACTIVE whose address could not be
1167
+ * read — plus the degenerate reads (no row at all, unparseable `createdAt`), which keep the
1168
+ * initializing branch's wording because they establish nothing to say instead. The two payloads
1169
+ * differ in what is MISSING, and that is the one place they diverge here: ACTIVE for the first, the
1170
+ * wallet address for the second.
1171
+ *
1172
+ * It states no outcome for a re-run beyond the money invariant, and the reason is structural. This
1173
+ * builder holds ONE row, read by id. How many live rows answer to the name a re-run looks up, what
1174
+ * stamps THOSE rows carry, whether a create key journaled on this box still resolves, and whether
1175
+ * the address becomes readable are facts about things it did not read and must not read here — a
1176
+ * detail that can throw is a detail that sometimes does not render. Four rounds enumerated those
1177
+ * outcomes anyway: the enumeration grew to 16 predicted branches across the four arms and was still
1178
+ * false on six executed payloads, twice along axes its stated conditions never named. Predicting
1179
+ * from a read that cannot see the deciding facts does not converge, so this renders none of it.
1180
+ *
1181
+ * What survives is {@link noSecondWalletWhileNamedLive} / {@link secondWalletRiskWhenNameRouteClosed}
1182
+ * — one class statement about money per arm, scoped to this strategy and executed at its boundary
1183
+ * rather than reasoned — then {@link FACTS_THAT_DECIDE}, which names facts WITHOUT attaching
1184
+ * outcomes to them or claiming to have listed them all, and {@link triageThenNeverClose}, which
1185
+ * carries the only next step that is correct whatever the next read finds, the shape of the failure
1186
+ * this is all about ({@link oneWalletPerStrategy}), and the one act that must never follow. Every
1187
+ * arm ends on those two, because the tail is what a reader in a hurry acts on.
1188
+ */
1189
+ function buildCreatePendingDetail(pkg, budget, waitedSec, live, nowMs, wantedName) {
1190
+ const resume = `\`${resumeCommand(pkg.dir, budget)}\``;
1191
+ const byName = !live || reconcileCanMatchByName(wantedName, live);
1192
+ const nameClause = live && !byName ? polledNameMismatchClause(wantedName, live) : "";
1193
+ const money = byName
1194
+ ? noSecondWalletWhileNamedLive(resume, !!live)
1195
+ : secondWalletRiskWhenNameRouteClosed(resume);
1196
+ const tail = `${money} ${FACTS_THAT_DECIDE} ${triageThenNeverClose(!!live)}`;
1197
+ if (live && live.status.toUpperCase() === "ACTIVE") {
1198
+ return (`after ${waitedSec}s this strategy still has no readable wallet address — ` +
1199
+ `${polledRecordRead(live, nowMs)}${nameClause}: the status this wait was for is already ` +
1200
+ `reached, the wallet address is the field still missing, and an ACTIVE strategy whose ` +
1201
+ `address cannot be read may be funded and live. ${tail}`);
1202
+ }
1203
+ return (`still waiting after ${waitedSec}s for this strategy to read ACTIVE with a wallet address — ` +
1204
+ `${polledRecordRead(live, nowMs)}${nameClause}. ACTIVE is the state this wait is still ` +
1205
+ `missing. ${tail}`);
925
1206
  }
926
1207
  /**
927
1208
  * Live wallets carry this package's stamp, and none of them answered for this instance.
@@ -1222,9 +1503,13 @@ function buildNext(overall, pkg, budget, runs) {
1222
1503
  // A `<runtimeId>` placeholder inside a backticked command is a command an agent pastes
1223
1504
  // literally — and `<` is a shell redirect, so it fails in a way that says nothing about the
1224
1505
  // deploy. With no id to name, name the command that needs none.
1506
+ // Names BOTH things the observe step looks for, because it accepts either: a recorded run
1507
+ // (scheduled) or a fresh liveness post (external). `no verified tick yet` named only the
1508
+ // first, which reads as though a tick were the sole way out of this state — and it is the
1509
+ // mirror of the `verified tick:` prefix the ok side no longer prints for an external row.
1225
1510
  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 ` +
1511
+ ? `no tick and no liveness post observed yet — check in a few minutes: \`openclaw senpi scanner -r ${runtimeId}\``
1512
+ : `no tick and no liveness post observed yet — this report carries no runtime id: list them with ` +
1228
1513
  `\`openclaw senpi runtime list\` and check this package's runtime in a few minutes`;
1229
1514
  }
1230
1515
  }
@@ -2213,9 +2498,13 @@ export async function runDeploy(params, deps) {
2213
2498
  });
2214
2499
  }
2215
2500
  /**
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).
2501
+ * A create call that never throws, so one code path records the outcome.
2502
+ *
2503
+ * `threw` is part of the result, not a detail of how it was produced: it is the difference
2504
+ * between "the backend answered and created nothing" and "nobody knows whether a wallet was
2505
+ * created and funded", and {@link isNameRejection} refuses to retry on the second. Without it a
2506
+ * transport failure is indistinguishable here from a refusal — which is exactly how the retry
2507
+ * guard came to fire only on the class it must not fire on.
2219
2508
  */
2220
2509
  async function callCreate(params) {
2221
2510
  try {
@@ -2232,7 +2521,7 @@ export async function runDeploy(params, deps) {
2232
2521
  // a rejected name) and unchanged by the C2 carve-out, which could only ever journal a key it
2233
2522
  // received; closing it needs an idempotency key on the create tool, not a client-side fix.
2234
2523
  // Until then the failure detail below tells the operator exactly what to check.
2235
- return { success: false, message: errorText(err), aborted: isDeadlineAbort(err) };
2524
+ return { success: false, message: errorText(err), threw: true, aborted: isDeadlineAbort(err) };
2236
2525
  }
2237
2526
  }
2238
2527
  async function createInstance(run) {
@@ -2251,7 +2540,7 @@ export async function runDeploy(params, deps) {
2251
2540
  // and nowhere else — and it is the whole difference between "a re-run adopts it by name" and
2252
2541
  // "no route can ever find it", so the report must not have to hedge about which happened.
2253
2542
  let nameFallbackFired = false;
2254
- if (!result.success && isNameRejection(result.message ?? "")) {
2543
+ if (!result.success && isNameRejection(result)) {
2255
2544
  // The ONE money-moving call in this file with no step boundary ahead of it — every other one
2256
2545
  // is preceded by `boundaryAbandoned()`. It needs its own, because `callCreate` mints a FRESH
2257
2546
  // `MCP_CALL_TIMEOUT_MS` deadline: a retry issued after the soft stop can still be awaiting a
@@ -2357,8 +2646,9 @@ export async function runDeploy(params, deps) {
2357
2646
  }
2358
2647
  // Only INITIALIZING statuses resolve on their own, so only they are worth waiting on (the
2359
2648
  // 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.
2649
+ // whole --max-wait and is then reported `pending` with "re-run; reconciliation waits for it
2650
+ // to reach ACTIVE" — a promise none of them can honour. Dead first: it is a subset with its
2651
+ // own, opposite re-run consequence.
2362
2652
  if (live && isDead(live.status)) {
2363
2653
  record(run, "create", {
2364
2654
  status: "failed",
@@ -2392,8 +2682,22 @@ export async function runDeploy(params, deps) {
2392
2682
  status: "pending",
2393
2683
  // `budget` verbatim, never a per-instance fallback: an adopted instance has no
2394
2684
  // 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 },
2685
+ // `run.strategyName` is the name a re-run's reconcile will look this row up by, so the
2686
+ // detail can check whether that route is even open before promising what it does.
2687
+ detail: buildCreatePendingDetail(pkg, budget, maxWaitSec, live, deps.now(), run.strategyName),
2688
+ evidence: {
2689
+ strategyId,
2690
+ status: live?.status ?? null,
2691
+ createdAt: live?.createdAt ?? null,
2692
+ // The fact the sentence branches on, in the machine-readable half too: a `--json`
2693
+ // reader must not have to diff two prose names to learn the name route is closed.
2694
+ // `strategyName` follows this file's absence rule — a field the backend did not report
2695
+ // is OMITTED, never handed over as `""` — because the prose for that same row says
2696
+ // "under no readable name of its own", and the two readers must not reach different
2697
+ // conclusions about whether a name exists.
2698
+ ...(live?.strategyName?.trim() ? { strategyName: live.strategyName } : {}),
2699
+ ...(live ? { matchableByName: reconcileCanMatchByName(run.strategyName, live) } : {}),
2700
+ },
2397
2701
  });
2398
2702
  return;
2399
2703
  }
@@ -2620,7 +2924,7 @@ export async function runDeploy(params, deps) {
2620
2924
  const observable = runs.filter((run) => run.report.runtimeId && ["ok", "skipped"].includes(run.report.steps.install.status));
2621
2925
  if (!halted() && observable.length > 0) {
2622
2926
  emitPhase("observe", tickWaitSec > 0
2623
- ? `waiting up to ${tickWaitSec}s for one verified scanner tick`
2927
+ ? `waiting up to ${tickWaitSec}s for one scanner tick — or, for an external scanner, one fresh liveness post`
2624
2928
  : "tick observation skipped by --tick-wait 0");
2625
2929
  await span("deploy.observe", { "senpi.deploy.step": "observe" }, async () => {
2626
2930
  for (const run of observable) {
@@ -2672,12 +2976,26 @@ export async function runDeploy(params, deps) {
2672
2976
  }
2673
2977
  const ticked = active.find((row) => isFreshTick(row, sinceMs));
2674
2978
  if (ticked) {
2675
- const via = ticked.scheduleMode === "external"
2676
- ? `alive (external, lastAliveAt fresh)`
2677
- : `lastRunStatus=${ticked.lastRunStatus}`;
2979
+ // Two different things are proven here and the line has to name which one it got.
2980
+ // `isFreshTick` accepts liveness for an external scanner on purpose (see it for why), so
2981
+ // `verified tick:` in front of an external row was a claim its own evidence contradicted:
2982
+ // the production case is `runCount=0 lastRunStatus=null statusesSeen=[]`, because the
2983
+ // scanner's interval is longer than the observe window. Both are legitimate grounds to
2984
+ // call the deploy live; only one of them is a tick.
2985
+ //
2986
+ // Branch on the ROW, never on `scheduleMode`. `isFreshTick`'s external branch short-circuits
2987
+ // on `lastAliveAt` before it reads the run fields, so an external scanner that HAS recorded
2988
+ // a fresh run arrives here too — and a mode-keyed wording printed "not that a run was
2989
+ // recorded" beside `runCount=3`, which is the same verdict-versus-evidence contradiction
2990
+ // inverted. `recordedFreshRun` asks the row what it proves; only when the answer is no does
2991
+ // the line fall back to what liveness alone establishes.
2992
+ const proven = recordedFreshRun(ticked, sinceMs)
2993
+ ? `verified tick: ${ticked.scannerId} lastRunStatus=${ticked.lastRunStatus}`
2994
+ : `verified alive: ${ticked.scannerId} is posting (external, lastAliveAt fresh) — this ` +
2995
+ `proves the scanner is running, not that a run was recorded;`;
2678
2996
  record(run, "observe", {
2679
2997
  status: "ok",
2680
- detail: `verified tick: ${ticked.scannerId} ${via} runCount=${ticked.runCount} ` +
2998
+ detail: `${proven} runCount=${ticked.runCount} ` +
2681
2999
  `signalsProduced=${ticked.signalsProduced} health=${ticked.health}`,
2682
3000
  evidence: {
2683
3001
  scannerId: ticked.scannerId,