@botbuddy/cli 1.33.1 → 1.33.3
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/package.json +1 -1
- package/src/stack.mjs +120 -13
package/package.json
CHANGED
package/src/stack.mjs
CHANGED
|
@@ -713,6 +713,20 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl =
|
|
|
713
713
|
return { waitSessionId: body.wait_session_id ?? null, cursorStart: body.cursor_start ?? null };
|
|
714
714
|
}
|
|
715
715
|
|
|
716
|
+
// BOT-1827: the abort reason is always one of these four explicit strings —
|
|
717
|
+
// branch on the reason itself rather than inferring "anything but interrupted
|
|
718
|
+
// is a timeout". "matched"/"failed" are only ever set AFTER the SSE loop has
|
|
719
|
+
// already been left via `break` (never from inside the `for await`), so the
|
|
720
|
+
// controller's own teardown can never race a `{ woke: true }` / `{ failed: true }`
|
|
721
|
+
// return the way it did when `ac.abort()` was called before returning from
|
|
722
|
+
// inside the loop.
|
|
723
|
+
function resultForAbortReason(reason) {
|
|
724
|
+
if (reason === "interrupted") return { result: { interrupted: true }, status: "error" };
|
|
725
|
+
// "timeout", or any other/unset reason (e.g. an externally-triggered AbortError
|
|
726
|
+
// with no reason attached) — defensively treated the same as a real deadline.
|
|
727
|
+
return { result: { timeout: true }, status: "timeout" };
|
|
728
|
+
}
|
|
729
|
+
|
|
716
730
|
/**
|
|
717
731
|
* Zero-poll wait for `lease:<leaseId>` to reach a state satisfying `isDone(state)`.
|
|
718
732
|
* Registers a wait_session (visible on /waits), then blocks on the event-stream SSE.
|
|
@@ -751,11 +765,17 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
751
765
|
}
|
|
752
766
|
return result;
|
|
753
767
|
};
|
|
754
|
-
if (ac.signal.aborted)
|
|
768
|
+
if (ac.signal.aborted) {
|
|
769
|
+
const { result, status } = resultForAbortReason(ac.signal.reason);
|
|
770
|
+
return finish(result, status);
|
|
771
|
+
}
|
|
755
772
|
try {
|
|
756
773
|
({ cursorStart, waitSessionId } = await registerLeaseWait(leaseId, Math.max(0, Math.ceil((absoluteDeadlineMs - Date.now()) / 1000)), auth, ac.signal, fetchImpl));
|
|
757
774
|
} catch (err) {
|
|
758
|
-
if (ac.signal.aborted)
|
|
775
|
+
if (ac.signal.aborted) {
|
|
776
|
+
const { result, status } = resultForAbortReason(ac.signal.reason);
|
|
777
|
+
return finish(result, status);
|
|
778
|
+
}
|
|
759
779
|
process.stderr.write(`${yellow("⚠")} stack: wait registration failed (${err?.message ?? err}); the lease will not appear on /waits — parking live-only.\n`);
|
|
760
780
|
}
|
|
761
781
|
const url = new URL(eventStreamBase());
|
|
@@ -767,15 +787,27 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
767
787
|
try {
|
|
768
788
|
res = await fetchImpl(url, { headers: { ...relayAuthHeaders(auth), Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
|
|
769
789
|
} catch (err) {
|
|
770
|
-
if (ac.signal.aborted)
|
|
790
|
+
if (ac.signal.aborted) {
|
|
791
|
+
const { result, status } = resultForAbortReason(ac.signal.reason);
|
|
792
|
+
return finish(result, status);
|
|
793
|
+
}
|
|
771
794
|
return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
|
|
772
795
|
}
|
|
773
796
|
if (res.status === 401 || res.status === 403) return finish({ auth: true }, "error");
|
|
774
797
|
if (!res.ok || !res.body) return finish({ error: `sse responded ${res.status}` }, "error");
|
|
775
798
|
const decoder = new TextDecoder();
|
|
776
799
|
let buf = "";
|
|
800
|
+
// BOT-1827: record a match/failure locally and `break` out of BOTH loops
|
|
801
|
+
// without touching `ac`/`abort()` from inside the `for await`. Aborting the
|
|
802
|
+
// controller while it is still the signal driving the body iterator makes
|
|
803
|
+
// the implicit IteratorClose (triggered by `break`/`return`) reject with an
|
|
804
|
+
// AbortError, which used to land in the `catch` below and override the
|
|
805
|
+
// already-decided `{ woke: true }` / `{ failed: true }` result with a
|
|
806
|
+
// `{ timeout: true }` — finalizing the wait session twice. Deciding first,
|
|
807
|
+
// aborting only AFTER the loop has been left cleanly, removes the race.
|
|
808
|
+
let decided = null;
|
|
777
809
|
try {
|
|
778
|
-
for await (const chunk of res.body) {
|
|
810
|
+
frameLoop: for await (const chunk of res.body) {
|
|
779
811
|
buf += decoder.decode(chunk, { stream: true });
|
|
780
812
|
const { frames, rest } = parseSseFrames(buf);
|
|
781
813
|
buf = rest;
|
|
@@ -784,13 +816,26 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
784
816
|
try { sig = JSON.parse(f.data); } catch { continue; }
|
|
785
817
|
if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
|
|
786
818
|
const st = sig.payload?.state ?? null;
|
|
787
|
-
if (isFailed(st)) {
|
|
788
|
-
if (isDone(st)) {
|
|
819
|
+
if (isFailed(st)) { decided = { reason: "failed", result: { failed: true, state: st }, status: "error" }; break frameLoop; }
|
|
820
|
+
if (isDone(st)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
|
|
789
821
|
}
|
|
790
822
|
}
|
|
791
823
|
} catch (err) {
|
|
792
|
-
|
|
793
|
-
|
|
824
|
+
// A decided result always wins over a teardown error surfaced by leaving
|
|
825
|
+
// the loop (e.g. the body iterator's `return()` rejecting during
|
|
826
|
+
// cleanup) — fall through to the `decided` handling below instead of
|
|
827
|
+
// reporting the teardown failure.
|
|
828
|
+
if (!decided) {
|
|
829
|
+
if (ac.signal.aborted) {
|
|
830
|
+
const { result, status } = resultForAbortReason(ac.signal.reason);
|
|
831
|
+
return finish(result, status);
|
|
832
|
+
}
|
|
833
|
+
return finish({ error: `sse stream: ${err?.message ?? err}` }, "error");
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (decided) {
|
|
837
|
+
ac.abort(decided.reason);
|
|
838
|
+
return finish(decided.result, decided.status);
|
|
794
839
|
}
|
|
795
840
|
// A clean relay EOF is not a timeout: the server intentionally closes on
|
|
796
841
|
// scope changes and proxies can recycle idle streams. Re-arm from a fresh
|
|
@@ -805,7 +850,13 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
805
850
|
|
|
806
851
|
const isActive = (s) => s === "active";
|
|
807
852
|
const nonQueued = (s) => s != null && s !== "queued";
|
|
808
|
-
|
|
853
|
+
// BOT-1822 (Codex round 3 P2): `reap_failed` is a distinct terminal wake
|
|
854
|
+
// (accept_stack_lease_receipt's fenced-reap-failure branch) alongside the real
|
|
855
|
+
// DB states "reaping"/"reaped" — a wait parked on isReaped as its failure
|
|
856
|
+
// predicate must end on it too, or a `reap_failed` wake that arrives while
|
|
857
|
+
// still waiting for activation is silently dropped and the wait parks until
|
|
858
|
+
// its full timeout instead of surfacing the failure immediately.
|
|
859
|
+
const isReaped = (s) => s === "reaping" || s === "reaped" || s === "reap_failed";
|
|
809
860
|
|
|
810
861
|
/** Read-only pressure gate used before local managed-stack provisioning. */
|
|
811
862
|
export async function runLocalExecPreflight(opts, runCommand = runDockerCommand) {
|
|
@@ -1833,8 +1884,46 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1833
1884
|
return cleanupResult = { ok: false, error: release?.error || release?.data?.code || "release failed" };
|
|
1834
1885
|
}
|
|
1835
1886
|
if (release.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
1836
|
-
|
|
1887
|
+
// BOT-1822 (Codex P2 round 2, extended round 3): `reap_failed` is synthetic
|
|
1888
|
+
// (never a real DB state), so a Helper's failed receipt landing in the split
|
|
1889
|
+
// second between this release RPC returning and the wait below registering
|
|
1890
|
+
// cannot be replayed from the spine's initial snapshot the way every other
|
|
1891
|
+
// signal type can — the wake is already behind the cursor, and the initial
|
|
1892
|
+
// snapshot only surfaces the durable `reaping` state, which neither
|
|
1893
|
+
// predicate accepts. Recover the durable failure reason directly:
|
|
1894
|
+
// `stack_leases.error` (which `get_stack_lease` already returns) holds the
|
|
1895
|
+
// most recent failure message regardless of which live signal was or
|
|
1896
|
+
// wasn't seen.
|
|
1897
|
+
const durableReapFailure = (read) => (read?.ok && read.data?.success && read.data.state === "reaping" && read.data.error)
|
|
1898
|
+
? { ok: false, error: `the lease is still reaping and may still hold containers — ${read.data.error}` }
|
|
1899
|
+
: null;
|
|
1900
|
+
// BOT-1822 (Codex round 3 P2): check BEFORE opening the wait, not only after
|
|
1901
|
+
// it times out — a failure already persisted must be reported at once
|
|
1902
|
+
// instead of burning the entire --reap-timeout waiting for a wake that will
|
|
1903
|
+
// never arrive because it predates the wait's registration.
|
|
1904
|
+
const preCheck = durableReapFailure(await api.get(leaseId));
|
|
1905
|
+
if (preCheck) return cleanupResult = preCheck;
|
|
1906
|
+
// BOT-1822 (Codex P2): a signed reap that FAILS on a provisioned (fenced) lease
|
|
1907
|
+
// emits `state: "reap_failed"` — a distinct terminal outcome, never a real DB
|
|
1908
|
+
// state — instead of leaving this wait to park until --reap-timeout on a reap
|
|
1909
|
+
// that already failed. Report that specific outcome instead of the generic
|
|
1910
|
+
// "was not proven" timeout message.
|
|
1911
|
+
const reaped = await wait(leaseId, (state) => state === "reaped", (state) => state === "reap_failed", { timeoutSec: opts.reapTimeout, auth: rpc.auth });
|
|
1837
1912
|
if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
1913
|
+
if (reaped?.failed) return cleanupResult = { ok: false, error: "the signed reap failed and the lease may still hold containers (fenced) — see stack hygiene" };
|
|
1914
|
+
// Belt-and-braces: the same race can still slip between the pre-check above
|
|
1915
|
+
// and the wait registering (or the wait's own live-signal miss), so recheck
|
|
1916
|
+
// once more before falling back to a generic timeout/error message. Mirrors
|
|
1917
|
+
// the provision wait's BOT-1822 recheck (which only ever needed to confirm
|
|
1918
|
+
// failure), but the reap wait can miss EITHER terminal outcome the same
|
|
1919
|
+
// way — BOT-1827 (lease 239af20d): the reap genuinely completed (server
|
|
1920
|
+
// `reaped` live emit) while the zero-poll wait still reported `{ timeout }`,
|
|
1921
|
+
// so a healthy stack was reported as a hard failure. One direct read tells
|
|
1922
|
+
// a real timeout apart from a missed wake in either direction.
|
|
1923
|
+
const recheck = await api.get(leaseId);
|
|
1924
|
+
if (recheck?.ok && recheck.data?.success && recheck.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
1925
|
+
const postCheck = durableReapFailure(recheck);
|
|
1926
|
+
if (postCheck) return cleanupResult = postCheck;
|
|
1838
1927
|
return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
|
|
1839
1928
|
})();
|
|
1840
1929
|
return cleanupPromise;
|
|
@@ -1944,11 +2033,29 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1944
2033
|
}
|
|
1945
2034
|
if (state !== "active") {
|
|
1946
2035
|
activeWaitAbort = new AbortController();
|
|
1947
|
-
|
|
2036
|
+
// BOT-1822 (Codex P2 round 2): a provision failure now emits a live
|
|
2037
|
+
// `reaping` signal (accept_stack_lease_receipt's provision-failed branch),
|
|
2038
|
+
// so this wait must treat it as terminal too — not just `reaped` — or the
|
|
2039
|
+
// live-wake fix is defeated: the run would still burn the FULL
|
|
2040
|
+
// provisionTimeout before the belt-and-braces recheck below ever notices
|
|
2041
|
+
// the lease is no longer provisioning. isReaped (== "reaping" || "reaped")
|
|
2042
|
+
// already covers both; cmdUp's equivalent waits use it for this reason.
|
|
2043
|
+
const active = await wait(leaseId, (s) => s === "active", isReaped, { timeoutSec: provisionTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
|
|
1948
2044
|
activeWaitAbort = null;
|
|
1949
2045
|
if (active?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
|
|
1950
|
-
if (active?.timeout) {
|
|
1951
|
-
|
|
2046
|
+
if (active?.timeout) {
|
|
2047
|
+
// BOT-1822 belt-and-braces: the zero-poll wait can still miss a live
|
|
2048
|
+
// `stack_lease` signal (an older server before this fix, a relay hiccup,
|
|
2049
|
+
// BOT-1816's class of dropped wakes) even though the lease genuinely went
|
|
2050
|
+
// active. One direct read before declaring a timeout tells that apart from
|
|
2051
|
+
// a lease that truly never provisioned, instead of reaping a healthy stack.
|
|
2052
|
+
const recheck = await api.get(leaseId);
|
|
2053
|
+
if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
|
|
2054
|
+
const cleanup = await requestCleanup();
|
|
2055
|
+
return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup };
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
if (active?.failed || active?.error || active?.auth) { const cleanup = await requestCleanup(); return { exitCode: active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: active?.error || `lease ${active?.state ?? "failed"} before reaching active`, cleanup }; }
|
|
1952
2059
|
}
|
|
1953
2060
|
const current = await api.get(leaseId);
|
|
1954
2061
|
if (!current?.ok || !current.data?.success || current.data.state !== "active" || !current.data.connection || typeof current.data.connection !== "object") {
|