@botbuddy/cli 1.33.2 → 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 +63 -10
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
|
|
@@ -1868,8 +1913,16 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1868
1913
|
if (reaped?.failed) return cleanupResult = { ok: false, error: "the signed reap failed and the lease may still hold containers (fenced) — see stack hygiene" };
|
|
1869
1914
|
// Belt-and-braces: the same race can still slip between the pre-check above
|
|
1870
1915
|
// and the wait registering (or the wait's own live-signal miss), so recheck
|
|
1871
|
-
// once more before falling back to a generic timeout/error message.
|
|
1872
|
-
|
|
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);
|
|
1873
1926
|
if (postCheck) return cleanupResult = postCheck;
|
|
1874
1927
|
return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
|
|
1875
1928
|
})();
|