@senpi/runtime 3.0.65 → 3.0.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/event-derivation.d.ts.map +1 -1
- package/dist/actions/event-derivation.js +2 -0
- package/dist/actions/event-derivation.js.map +1 -1
- package/dist/cli/deploy-render.d.ts +3 -1
- package/dist/cli/deploy-render.d.ts.map +1 -1
- package/dist/cli/deploy-render.js +233 -11
- package/dist/cli/deploy-render.js.map +1 -1
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +9 -7
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/senpi-commands.js +4 -4
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/deploy/deadlines.d.ts +3 -3
- package/dist/deploy/deadlines.js +3 -3
- package/dist/deploy/job.d.ts +16 -0
- package/dist/deploy/job.d.ts.map +1 -1
- package/dist/deploy/job.js +7 -1
- package/dist/deploy/job.js.map +1 -1
- package/dist/deploy/orchestrator.d.ts +2 -1
- package/dist/deploy/orchestrator.d.ts.map +1 -1
- package/dist/deploy/orchestrator.js +402 -84
- package/dist/deploy/orchestrator.js.map +1 -1
- package/dist/deploy/proof-gate.d.ts.map +1 -1
- package/dist/deploy/proof-gate.js +8 -3
- package/dist/deploy/proof-gate.js.map +1 -1
- package/dist/deploy/register.js +1 -1
- package/dist/health/types.d.ts +6 -0
- package/dist/health/types.d.ts.map +1 -1
- package/dist/health/types.js.map +1 -1
- package/dist/index.d.ts +16 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +234 -21
- package/dist/index.js.map +1 -1
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +7 -1
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/scanners/engine/engine.d.ts +5 -0
- package/dist/scanners/engine/engine.d.ts.map +1 -1
- package/dist/scanners/engine/engine.js +1 -0
- package/dist/scanners/engine/engine.js.map +1 -1
- package/dist/scanners/runtime-module.d.ts.map +1 -1
- package/dist/scanners/runtime-module.js +5 -0
- package/dist/scanners/runtime-module.js.map +1 -1
- package/dist/senpi/client.d.ts.map +1 -1
- package/dist/senpi/client.js +72 -5
- package/dist/senpi/client.js.map +1 -1
- package/dist/senpi/types.d.ts +18 -0
- package/dist/senpi/types.d.ts.map +1 -1
- package/dist/telemetry/event-builders.d.ts +12 -0
- package/dist/telemetry/event-builders.d.ts.map +1 -1
- package/dist/telemetry/event-builders.js +2 -0
- package/dist/telemetry/event-builders.js.map +1 -1
- package/dist/telemetry/event-store.d.ts +6 -0
- package/dist/telemetry/event-store.d.ts.map +1 -1
- package/dist/telemetry/event-store.js +6 -6
- package/dist/telemetry/event-store.js.map +1 -1
- package/dist/telemetry/live-telemetry.d.ts +153 -0
- package/dist/telemetry/live-telemetry.d.ts.map +1 -0
- package/dist/telemetry/live-telemetry.js +471 -0
- package/dist/telemetry/live-telemetry.js.map +1 -0
- package/dist/telemetry/scanner-stats.d.ts +136 -0
- package/dist/telemetry/scanner-stats.d.ts.map +1 -0
- package/dist/telemetry/scanner-stats.js +304 -0
- package/dist/telemetry/scanner-stats.js.map +1 -0
- package/dist/telemetry/strategy-topology.d.ts +99 -0
- package/dist/telemetry/strategy-topology.d.ts.map +1 -0
- package/dist/telemetry/strategy-topology.js +93 -0
- package/dist/telemetry/strategy-topology.js.map +1 -0
- package/dist/telemetry/telemetry-publisher.d.ts +63 -0
- package/dist/telemetry/telemetry-publisher.d.ts.map +1 -0
- package/dist/telemetry/telemetry-publisher.js +229 -0
- package/dist/telemetry/telemetry-publisher.js.map +1 -0
- package/dist/telemetry/timeline-buffer.d.ts +95 -0
- package/dist/telemetry/timeline-buffer.d.ts.map +1 -0
- package/dist/telemetry/timeline-buffer.js +237 -0
- package/dist/telemetry/timeline-buffer.js.map +1 -0
- package/dist/telemetry/tracing.d.ts +9 -0
- package/dist/telemetry/tracing.d.ts.map +1 -1
- package/dist/telemetry/tracing.js +36 -0
- package/dist/telemetry/tracing.js.map +1 -1
- package/dist/utils/event-catalog.d.ts +8 -0
- package/dist/utils/event-catalog.d.ts.map +1 -1
- package/dist/utils/event-catalog.js.map +1 -1
- package/dist/utils/ulid.d.ts +9 -0
- package/dist/utils/ulid.d.ts.map +1 -0
- package/dist/utils/ulid.js +43 -0
- package/dist/utils/ulid.js.map +1 -0
- package/dist/validate/proof.js +4 -1
- package/dist/validate/proof.js.map +1 -1
- package/dist/validate/run.d.ts.map +1 -1
- package/dist/validate/run.js +4 -1
- package/dist/validate/run.js.map +1 -1
- package/openclaw.plugin.json +1 -1
- 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",
|
|
40
|
-
* {@link isNameRejection}. Spelled out one per entry rather than packed into a character
|
|
41
|
-
* (`SERR05[568]`) because the reader who arrives here arrives holding a code: a grep for the
|
|
42
|
-
* string they were handed has to land on this list.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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"
|
|
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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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(
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
257
|
-
*
|
|
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 "
|
|
786
|
-
* creating a second wallet", and on this input neither half holds. It
|
|
787
|
-
* filters dead statuses on BOTH match routes, so a re-run creates and funds a fresh
|
|
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
|
|
815
|
-
*
|
|
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
|
-
|
|
859
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
|
1227
|
-
: `no
|
|
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
|
|
2217
|
-
*
|
|
2218
|
-
*
|
|
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
|
|
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 "
|
|
2361
|
-
//
|
|
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
|
-
|
|
2396
|
-
|
|
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
|
|
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
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
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:
|
|
2998
|
+
detail: `${proven} runCount=${ticked.runCount} ` +
|
|
2681
2999
|
`signalsProduced=${ticked.signalsProduced} health=${ticked.health}`,
|
|
2682
3000
|
evidence: {
|
|
2683
3001
|
scannerId: ticked.scannerId,
|