oira666_pi-limits-wait 0.5.6 → 0.5.8
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/README.md +86 -1
- package/package.json +1 -1
- package/src/constants.ts +10 -0
- package/src/index.ts +27 -1
- package/src/state.ts +4 -0
- package/src/status-json.ts +126 -0
- package/src/stream.ts +30 -3
- package/src/telemetry.ts +3 -0
- package/src/ui.ts +174 -20
package/README.md
CHANGED
|
@@ -55,6 +55,89 @@ Liveliness messages include the retry reason, the model, the remaining wait, and
|
|
|
55
55
|
|
|
56
56
|
Interactive (TUI) behavior is unchanged: retry notices remain `warning`-level chat notifications with the live countdown, and no duplicate error notification is added because the terminal error event is already rendered in the transcript.
|
|
57
57
|
|
|
58
|
+
### Structured status channel
|
|
59
|
+
|
|
60
|
+
Since 0.5.7 the extension publishes a **second, machine-readable** `setStatus` entry beside the prose one, under its own key:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
{"type":"extension_ui_request","id":"<uuid>","method":"setStatus",
|
|
64
|
+
"statusKey":"oira666.pi-limits-wait.json","statusText":"<minified JSON>"}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The prose key `oira666.pi-limits-wait` is **unchanged** in text, key, cadence and clearing semantics, so a host that only knows 0.5.6 is unaffected. The JSON key is published on exactly the same schedule as the prose notice (immediately when a wait starts, then every `PI_LIMITS_WAIT_LIVELINESS_INTERVAL` seconds while waiting) and only in non-interactive (`rpc`/`json`) hosts. A blank/absent `statusText` under this key is the end-of-window marker.
|
|
68
|
+
|
|
69
|
+
`statusText` is one minified JSON line:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{"v":1,"ext":"0.5.7","event":"wait","waitId":"wait-mfk3z1-7","periodId":"period-3","reason":"rate-limit","message":"⏳ pi-limits-wait: rate limited on anthropic/claude-sonnet-4-5; still alive, waiting 12m 04s before the next retry. Why: HTTP 429 retry-after-ms 724000","error":"HTTP 429 retry-after-ms 724000","model":{"provider":"anthropic","id":"claude-sonnet-4-5"},"plannedDurationMs":1800000,"plannedDeadline":1786000000000,"startedAt":1785998200000,"remainingMs":724000,"attempt":null,"maxAttempts":null,"livelinessIntervalMs":15000,"controlFile":"/tmp/ram-pi-.../limits-wait.control"}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
End of a wait (published once, immediately before both keys are cleared):
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{"v":1,"ext":"0.5.7","event":"wait_end","waitId":"wait-mfk3z1-7","reason":"rate-limit","message":"…","error":"…","plannedDurationMs":1800000,"plannedDeadline":1786000000000,"startedAt":1785998200000,"remainingMs":0,"attempt":null,"maxAttempts":null,"livelinessIntervalMs":15000,"controlFile":"…","outcome":"skipped","actualElapsedMs":41233}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Terminal give-up (the extension has stopped retrying and surfaces the final error):
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{"v":1,"ext":"0.5.7","event":"give_up","waitId":null,"reason":null,"message":"anthropic/claude-sonnet-4-5 failed and pi-limits-wait is not retrying: …","error":"…","plannedDurationMs":null,"plannedDeadline":null,"startedAt":null,"remainingMs":null,"attempt":null,"maxAttempts":null,"livelinessIntervalMs":15000,"controlFile":"…"}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Field | Type | Meaning |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `v` | `1` | Payload schema version. Bumped only on a breaking change. |
|
|
90
|
+
| `ext` | string | Extension version that produced the frame, e.g. `"0.5.7"`. |
|
|
91
|
+
| `event` | `"wait"` \| `"wait_end"` \| `"give_up"` | Liveliness tick, end of a wait, or terminal give-up. |
|
|
92
|
+
| `waitId` | string \| null | Correlates every frame of one wait. `null` only for `give_up`. |
|
|
93
|
+
| `periodId` | string (optional) | Retry-period id, when the wait belongs to one. |
|
|
94
|
+
| `reason` | `"rate-limit"` \| `"overloaded"` \| `"authentication"` \| `"model-frozen"` \| `"network"` \| `"retry"` \| null | `null` only for `give_up`. |
|
|
95
|
+
| `message` | string | The same human text published on the prose channel (≤500 chars, single line). |
|
|
96
|
+
| `error` | string \| null | Normalised originating provider error (≤500 chars). |
|
|
97
|
+
| `model` | `{provider,id}` (optional) | Model the wait applies to, when known. |
|
|
98
|
+
| `plannedDurationMs` | number \| null | Planned wait length. |
|
|
99
|
+
| `plannedDeadline` | number \| null | Epoch ms when the retry is planned. |
|
|
100
|
+
| `startedAt` | number \| null | Epoch ms when the wait began. |
|
|
101
|
+
| `remainingMs` | number \| null | `max(0, plannedDeadline - now)` at publish time; `0` on `wait_end`. |
|
|
102
|
+
| `attempt` | number \| null | 1-based extension-created attempt. Only the unknown-error path counts attempts; classified waits send `null`. |
|
|
103
|
+
| `maxAttempts` | number \| null | `PI_LIMITS_WAIT_MAX_RETRY + 1`, or `null` when not applicable. |
|
|
104
|
+
| `livelinessIntervalMs` | number | Publishing interval, so a consumer can compute staleness itself. |
|
|
105
|
+
| `controlFile` | string \| null | The path this process polls for skip tokens, or `null` when disabled. |
|
|
106
|
+
| `outcome` | `"waited"` \| `"skipped"` \| `"aborted"` (optional) | `wait_end` only. |
|
|
107
|
+
| `actualElapsedMs` | number (optional) | `wait_end` only. |
|
|
108
|
+
|
|
109
|
+
Set `PI_LIMITS_WAIT_STATUS_JSON=false` to suppress this channel entirely and restore exact 0.5.6 wire behavior. `PI_LIMITS_WAIT_DISABLE_ALL_WAITING=true` also suppresses it (nothing is published and no control file is polled).
|
|
110
|
+
|
|
111
|
+
The payload type is exported for TypeScript hosts:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import {
|
|
115
|
+
LIVELINESS_JSON_STATUS_KEY,
|
|
116
|
+
type LimitsWaitStatusPayload,
|
|
117
|
+
} from "oira666_pi-limits-wait";
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Skipping a wait from the host
|
|
121
|
+
|
|
122
|
+
In `rpc`/`json` mode there is no terminal, so the interactive "press Enter to retry now" affordance is unavailable. Instead, set `PI_LIMITS_WAIT_CONTROL_FILE` to an absolute path (one per pi process; the extension echoes it back as `controlFile` in every structured frame). While — and only while — a wait is in progress, the extension polls that path once per second from the countdown ticker it already runs. Signals are deliberately **not** used: `SIGUSR2` with no handler installed terminates the process, and a signal cannot carry a `waitId`.
|
|
123
|
+
|
|
124
|
+
Write a token to make the extension stop waiting and retry immediately:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{"action":"skip","waitId":"wait-mfk3z1-7","issuedAt":1785998240123,"by":"ramcore"}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- `action` must be `"skip"`; anything else is ignored.
|
|
131
|
+
- `waitId` must equal the current wait's `waitId` (from the structured frame), or the wildcard `"*"` for "skip whichever wait is current".
|
|
132
|
+
- `issuedAt` is epoch ms and must be `>= startedAt` of the current wait.
|
|
133
|
+
- Writers should write a temp file and rename it into place (atomic move) so a partial read is impossible.
|
|
134
|
+
|
|
135
|
+
The extension consumes a token by atomically renaming the canonical control path to a unique `.consumed-<waitId>-<random>` path in the same directory, then reading and validating that claimed file. It removes only the unique claimed path. A writer may therefore rename a newer token onto the canonical path immediately after the claim without cleanup deleting that replacement. An accepted claim settles the wait with `outcome: "skipped"` — the exact same path the Enter key uses, so the retry proceeds normally and a `wait_end` frame with `"outcome":"skipped"` is published. Polls are serialized; a slow filesystem operation cannot overlap the next one-second tick.
|
|
136
|
+
|
|
137
|
+
Two content guards ensure a claimed leftover token can never skip a **later** legitimate wait: (a) `waitId` equality — a token written for wait *N* cannot match wait *N+1*'s freshly generated id; (b) `issuedAt >= startedAt` — a `"*"` token issued before this wait began is rejected. Claimed malformed, stale, and non-matching files are discarded, while any newer token at the canonical path is left untouched. The extension deliberately does not unlink the canonical path at wait start, because that would race with a token written microseconds earlier.
|
|
138
|
+
|
|
139
|
+
A missing or unclaimable control path is silently ignored. If `PI_LIMITS_WAIT_CONTROL_FILE` is unset (or all waiting is disabled), no filesystem access happens at all.
|
|
140
|
+
|
|
58
141
|
## Wait event contract
|
|
59
142
|
|
|
60
143
|
Every positive extension-managed wait emits public telemetry on Pi's shared event bus. Import the channel constant and payload union from the package entry point:
|
|
@@ -163,7 +246,9 @@ The extension rereads these files before every LLM call, so changes made during
|
|
|
163
246
|
| `PI_LIMITS_WAIT_DISABLE_ALL_WAITING` | `false` | Master kill switch for this extension's error handling, retries, fallbacks, freezing, waiting, notifications, telemetry, and retry summaries. Set to `true` (also accepts `1`, `yes`, `on`) to leave only Anthropic OAuth system-prompt cleaning enabled; requests continue through Pi's normal runtime, including its normal Anthropic headers. This takes precedence over every setting below. |
|
|
164
247
|
| `PI_LIMITS_WAIT_DEFAULT_WAITING` | `true` | Whether unclassified/unknown provider errors enter the wait-and-retry cycle. Set to `false` (also accepts `0`, `no`, `off`) to surface the error immediately. Rate-limit, overload, authentication, and network retry behavior is unchanged. |
|
|
165
248
|
| `PI_LIMITS_WAIT_MAX_RETRY` | `999999` | Maximum extension-created retries for an unclassified/unknown provider error. The initial failed request and provider-internal retries do not count. HTTP 400/403 remain eligible. Must be a non-negative integer. |
|
|
166
|
-
| `PI_LIMITS_WAIT_LIVELINESS_INTERVAL` | `15` | Seconds between liveliness notifications published while waiting in non-interactive (`rpc`/`json`) modes. Must be a non-negative integer; values below 1 second are clamped to 1 second. Ignored in TUI mode. |
|
|
249
|
+
| `PI_LIMITS_WAIT_LIVELINESS_INTERVAL` | `15` | Seconds between liveliness notifications published while waiting in non-interactive (`rpc`/`json`) modes. Must be a non-negative integer; values below 1 second are clamped to 1 second. Ignored in TUI mode. Also drives the structured status channel and is reported inside it as `livelinessIntervalMs`. |
|
|
250
|
+
| `PI_LIMITS_WAIT_STATUS_JSON` | `true` | Whether the machine-readable status channel (`statusKey` `oira666.pi-limits-wait.json`) is published in non-interactive modes. Set to `false` (also accepts `0`, `no`, `off`) to restore exact 0.5.6 wire behavior. The prose channel is never affected. |
|
|
251
|
+
| `PI_LIMITS_WAIT_CONTROL_FILE` | *(unset)* | Absolute path of the out-of-band control file polled once per second **while waiting**, used by the host to skip the current wait and retry immediately. Unset disables the feature and all filesystem access. See [Skipping a wait from the host](#skipping-a-wait-from-the-host). |
|
|
167
252
|
| `PI_LIMITS_WAIT_RETRY_INTERVAL` | `5` | Seconds to wait between unclassified/unknown provider-error retries. Must be a non-negative integer (values above 2147483 are capped to Node's maximum reliable timer delay). This does not affect model-limit wait intervals. |
|
|
168
253
|
| `PI_LIMITS_WAIT_FREEZING_ENABLED` | `true` | When a model keeps failing with a non-retryable error, it is normally "frozen" for 10 minutes and skipped in favour of other configured models. Set this to `false` (also accepts `0`, `no`, `off`) to disable freezing entirely: the extension will instead try each configured candidate once (after the bounded retries) and then surface the error, never blocking on a long "model-frozen" wait. Useful for non-interactive / subagent runs where no one can press Enter to skip. |
|
|
169
254
|
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -35,6 +35,12 @@ export const DEFAULT_UNKNOWN_ERROR_RETRY_INTERVAL_MS = 5 * 1_000; // 5 seconds
|
|
|
35
35
|
export const LIVELINESS_STATUS_KEY = "oira666.pi-limits-wait";
|
|
36
36
|
export const DEFAULT_LIVELINESS_INTERVAL_MS = 15 * 1_000; // 15 seconds
|
|
37
37
|
|
|
38
|
+
/** Machine-readable sibling of LIVELINESS_STATUS_KEY. Compact JSON, never prose. */
|
|
39
|
+
export const LIVELINESS_JSON_STATUS_KEY = "oira666.pi-limits-wait.json";
|
|
40
|
+
/** Payload schema version for LIVELINESS_JSON_STATUS_KEY. Bump only on a breaking change. */
|
|
41
|
+
export const LIVELINESS_JSON_VERSION = 1;
|
|
42
|
+
export const EXTENSION_VERSION = "0.5.7";
|
|
43
|
+
|
|
38
44
|
export const SETTINGS_FILE_NAME = "limits-wait.json";
|
|
39
45
|
export const FALLBACK_MODELS_KEY = "fallback-models";
|
|
40
46
|
export const DISABLE_ALL_WAITING_ENV_VAR = "PI_LIMITS_WAIT_DISABLE_ALL_WAITING";
|
|
@@ -43,3 +49,7 @@ export const DEFAULT_WAITING_ENV_VAR = "PI_LIMITS_WAIT_DEFAULT_WAITING";
|
|
|
43
49
|
export const MAX_RETRY_ENV_VAR = "PI_LIMITS_WAIT_MAX_RETRY";
|
|
44
50
|
export const RETRY_INTERVAL_ENV_VAR = "PI_LIMITS_WAIT_RETRY_INTERVAL";
|
|
45
51
|
export const LIVELINESS_INTERVAL_ENV_VAR = "PI_LIMITS_WAIT_LIVELINESS_INTERVAL";
|
|
52
|
+
/** Set to false/0/no/off to suppress the structured status channel. */
|
|
53
|
+
export const STATUS_JSON_ENV_VAR = "PI_LIMITS_WAIT_STATUS_JSON";
|
|
54
|
+
/** Absolute path of the out-of-band control file polled while waiting. Unset => no control. */
|
|
55
|
+
export const CONTROL_FILE_ENV_VAR = "PI_LIMITS_WAIT_CONTROL_FILE";
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,26 @@ export {
|
|
|
31
31
|
export {
|
|
32
32
|
LIMITS_WAIT_EVENT_CHANNEL,
|
|
33
33
|
} from "./telemetry.js";
|
|
34
|
+
export {
|
|
35
|
+
CONTROL_FILE_ENV_VAR,
|
|
36
|
+
EXTENSION_VERSION,
|
|
37
|
+
LIVELINESS_JSON_STATUS_KEY,
|
|
38
|
+
LIVELINESS_JSON_VERSION,
|
|
39
|
+
LIVELINESS_STATUS_KEY,
|
|
40
|
+
STATUS_JSON_ENV_VAR,
|
|
41
|
+
} from "./constants.js";
|
|
42
|
+
export {
|
|
43
|
+
clearStatusJson,
|
|
44
|
+
controlFilePath,
|
|
45
|
+
publishStatusJson,
|
|
46
|
+
statusJsonEnabled,
|
|
47
|
+
statusPayload,
|
|
48
|
+
} from "./status-json.js";
|
|
49
|
+
export type {
|
|
50
|
+
LimitsWaitStatusContext,
|
|
51
|
+
LimitsWaitStatusEvent,
|
|
52
|
+
LimitsWaitStatusPayload,
|
|
53
|
+
} from "./status-json.js";
|
|
34
54
|
export type {
|
|
35
55
|
LimitsWaitEndEventPayload,
|
|
36
56
|
LimitsWaitEventPayload,
|
|
@@ -83,11 +103,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
83
103
|
const retrySummaries = new RetrySummaryCoordinator(pi);
|
|
84
104
|
const releaseInterception = installModelRuntimeInterception(() => retrySummaries.createStream());
|
|
85
105
|
|
|
86
|
-
pi.on("model_select", (event) => {
|
|
106
|
+
pi.on("model_select", (event, ctx) => {
|
|
107
|
+
state.sharedCtx = ctx;
|
|
87
108
|
if (consumeExpectedModelSelection(event.model)) return;
|
|
88
109
|
if (isInternalSyntheticModel(event.model)) return;
|
|
89
110
|
state.primaryModel = event.model;
|
|
90
111
|
state.primaryThinkingLevel = pi.getThinkingLevel();
|
|
112
|
+
state.userModelSelectionGeneration++;
|
|
113
|
+
// A user-selected model takes precedence over a pending retry. Wake every
|
|
114
|
+
// wait so its stream can adopt the new primary model immediately.
|
|
115
|
+
for (const skip of [...state.activeWaitSkips]) skip();
|
|
91
116
|
});
|
|
92
117
|
|
|
93
118
|
pi.on("before_agent_start", (_event, ctx) => {
|
|
@@ -120,6 +145,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
120
145
|
state.ambientStatusCleanup?.();
|
|
121
146
|
state.modelStatusCleanup?.();
|
|
122
147
|
state.expectedModelSelections.clear();
|
|
148
|
+
state.activeWaitSkips.clear();
|
|
123
149
|
state.sharedCtx = undefined;
|
|
124
150
|
state.extensionApi = undefined;
|
|
125
151
|
});
|
package/src/state.ts
CHANGED
|
@@ -16,6 +16,8 @@ export const state = {
|
|
|
16
16
|
primaryModel: undefined as Model<Api> | undefined,
|
|
17
17
|
primaryThinkingLevel: undefined as ModelThinkingLevel | undefined,
|
|
18
18
|
expectedModelSelections: new Map<string, Set<symbol>>(),
|
|
19
|
+
userModelSelectionGeneration: 0,
|
|
20
|
+
activeWaitSkips: new Set<() => void>(),
|
|
19
21
|
settingsSignature: undefined as string | undefined,
|
|
20
22
|
rateLimitMemory: new Map<string, RateLimitMemory>(),
|
|
21
23
|
nonRetryableFailureMemory: new Map<string, NonRetryableFailureMemory>(),
|
|
@@ -56,6 +58,8 @@ export function resetRuntimeStateForTests(models: FallbackModel[], ctx?: Extensi
|
|
|
56
58
|
state.rateLimitMemory.clear();
|
|
57
59
|
state.nonRetryableFailureMemory.clear();
|
|
58
60
|
state.expectedModelSelections.clear();
|
|
61
|
+
state.userModelSelectionGeneration = 0;
|
|
62
|
+
state.activeWaitSkips.clear();
|
|
59
63
|
state.ambientStatusCleanup?.();
|
|
60
64
|
state.modelStatusCleanup?.();
|
|
61
65
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTROL_FILE_ENV_VAR,
|
|
3
|
+
LIVELINESS_JSON_STATUS_KEY,
|
|
4
|
+
LIVELINESS_JSON_VERSION,
|
|
5
|
+
EXTENSION_VERSION,
|
|
6
|
+
STATUS_JSON_ENV_VAR,
|
|
7
|
+
} from "./constants.js";
|
|
8
|
+
import { state } from "./state.js";
|
|
9
|
+
import type { LimitsWaitModel, LimitsWaitWaitOutcome } from "./telemetry.js";
|
|
10
|
+
import type { RetryReason } from "./types.js";
|
|
11
|
+
import { allWaitingDisabled, envBoolean, formatErrorDetail, isNonInteractiveHost } from "./ui.js";
|
|
12
|
+
|
|
13
|
+
export type LimitsWaitStatusEvent = "wait" | "wait_end" | "give_up";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Machine-readable sibling of the human liveliness notice. Serialised as one
|
|
17
|
+
* minified JSON line under LIVELINESS_JSON_STATUS_KEY. Field order matches the
|
|
18
|
+
* documented wire shape; consumers must parse, not pattern-match.
|
|
19
|
+
*/
|
|
20
|
+
export type LimitsWaitStatusPayload = {
|
|
21
|
+
v: typeof LIVELINESS_JSON_VERSION;
|
|
22
|
+
ext: string;
|
|
23
|
+
event: LimitsWaitStatusEvent;
|
|
24
|
+
waitId: string | null;
|
|
25
|
+
periodId?: string;
|
|
26
|
+
reason: RetryReason | null;
|
|
27
|
+
message: string;
|
|
28
|
+
error: string | null;
|
|
29
|
+
model?: LimitsWaitModel;
|
|
30
|
+
plannedDurationMs: number | null;
|
|
31
|
+
plannedDeadline: number | null;
|
|
32
|
+
startedAt: number | null;
|
|
33
|
+
remainingMs: number | null;
|
|
34
|
+
attempt: number | null;
|
|
35
|
+
maxAttempts: number | null;
|
|
36
|
+
livelinessIntervalMs: number;
|
|
37
|
+
controlFile: string | null;
|
|
38
|
+
outcome?: LimitsWaitWaitOutcome;
|
|
39
|
+
actualElapsedMs?: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Everything about a wait that does not change between publishes. */
|
|
43
|
+
export type LimitsWaitStatusContext = Omit<
|
|
44
|
+
LimitsWaitStatusPayload,
|
|
45
|
+
"v" | "ext" | "event" | "message" | "remainingMs" | "outcome" | "actualElapsedMs"
|
|
46
|
+
>;
|
|
47
|
+
|
|
48
|
+
/** The structured channel is opt-out; the master kill switch always wins. */
|
|
49
|
+
export function statusJsonEnabled(): boolean {
|
|
50
|
+
if (allWaitingDisabled()) return false;
|
|
51
|
+
return envBoolean(process.env[STATUS_JSON_ENV_VAR], true);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Absolute path of the out-of-band control file, or undefined when disabled. */
|
|
55
|
+
export function controlFilePath(): string | undefined {
|
|
56
|
+
if (allWaitingDisabled()) return undefined;
|
|
57
|
+
return process.env[CONTROL_FILE_ENV_VAR]?.trim() || undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function statusPayload(
|
|
61
|
+
context: LimitsWaitStatusContext,
|
|
62
|
+
event: LimitsWaitStatusEvent,
|
|
63
|
+
message: string,
|
|
64
|
+
remainingMs: number | null,
|
|
65
|
+
extra?: { outcome?: LimitsWaitWaitOutcome; actualElapsedMs?: number },
|
|
66
|
+
): LimitsWaitStatusPayload {
|
|
67
|
+
return {
|
|
68
|
+
v: LIVELINESS_JSON_VERSION,
|
|
69
|
+
ext: EXTENSION_VERSION,
|
|
70
|
+
event,
|
|
71
|
+
waitId: context.waitId,
|
|
72
|
+
...(context.periodId ? { periodId: context.periodId } : {}),
|
|
73
|
+
reason: context.reason,
|
|
74
|
+
message: formatErrorDetail(message),
|
|
75
|
+
error: context.error,
|
|
76
|
+
...(context.model ? { model: context.model } : {}),
|
|
77
|
+
plannedDurationMs: context.plannedDurationMs,
|
|
78
|
+
plannedDeadline: context.plannedDeadline,
|
|
79
|
+
startedAt: context.startedAt,
|
|
80
|
+
remainingMs,
|
|
81
|
+
attempt: context.attempt,
|
|
82
|
+
maxAttempts: context.maxAttempts,
|
|
83
|
+
livelinessIntervalMs: context.livelinessIntervalMs,
|
|
84
|
+
controlFile: context.controlFile,
|
|
85
|
+
...(extra?.outcome ? { outcome: extra.outcome } : {}),
|
|
86
|
+
...(extra?.actualElapsedMs === undefined ? {} : { actualElapsedMs: extra.actualElapsedMs }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let statusJsonActive = false;
|
|
91
|
+
|
|
92
|
+
/** No-op unless the host is non-interactive and the channel is enabled. */
|
|
93
|
+
export function publishStatusJson(payload: LimitsWaitStatusPayload): void {
|
|
94
|
+
const ctx = state.sharedCtx;
|
|
95
|
+
if (!ctx || !isNonInteractiveHost() || !statusJsonEnabled()) return;
|
|
96
|
+
try {
|
|
97
|
+
ctx.ui.setStatus(LIVELINESS_JSON_STATUS_KEY, JSON.stringify(payload));
|
|
98
|
+
statusJsonActive = true;
|
|
99
|
+
} catch { /* UI unavailable */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Clear a status this instance published, even if its enable flags changed meanwhile. */
|
|
103
|
+
export function clearStatusJson(): void {
|
|
104
|
+
if (!statusJsonActive) return;
|
|
105
|
+
const ctx = state.sharedCtx;
|
|
106
|
+
if (!ctx) return;
|
|
107
|
+
try {
|
|
108
|
+
ctx.ui.setStatus(LIVELINESS_JSON_STATUS_KEY, undefined);
|
|
109
|
+
statusJsonActive = false;
|
|
110
|
+
} catch { /* retain ownership so a later cleanup can retry */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Accept an out-of-band skip token only for the wait it was issued for.
|
|
115
|
+
* A claimed token is accepted only when waitId equality (or the explicit "*"
|
|
116
|
+
* wildcard) and issuedAt >= this wait's startedAt both hold. The caller
|
|
117
|
+
* discards every claimed file, valid or invalid, without touching a replacement
|
|
118
|
+
* at the canonical path.
|
|
119
|
+
*/
|
|
120
|
+
export function isSkipToken(token: unknown, waitId: string, startedAt: number): boolean {
|
|
121
|
+
if (!token || typeof token !== "object") return false;
|
|
122
|
+
const candidate = token as { action?: unknown; waitId?: unknown; issuedAt?: unknown };
|
|
123
|
+
if (candidate.action !== "skip") return false;
|
|
124
|
+
if (candidate.waitId !== waitId && candidate.waitId !== "*") return false;
|
|
125
|
+
return typeof candidate.issuedAt === "number" && candidate.issuedAt >= startedAt;
|
|
126
|
+
}
|
package/src/stream.ts
CHANGED
|
@@ -198,6 +198,7 @@ export function streamWithLimitsRetry(
|
|
|
198
198
|
: { model, reasoningEffort: state.primaryThinkingLevel };
|
|
199
199
|
const nonRetryableAttempts = new Map<string, number>();
|
|
200
200
|
const triedModels = new Set<string>();
|
|
201
|
+
let observedUserModelSelection = state.userModelSelectionGeneration;
|
|
201
202
|
|
|
202
203
|
const prepareAttemptForPi = async (): Promise<boolean> => {
|
|
203
204
|
const originalKey = modelKey(model);
|
|
@@ -214,14 +215,24 @@ export function streamWithLimitsRetry(
|
|
|
214
215
|
return switchPiModel(attempt, signal, canSelect);
|
|
215
216
|
};
|
|
216
217
|
|
|
217
|
-
const managedWait = async (
|
|
218
|
-
|
|
218
|
+
const managedWait = async (
|
|
219
|
+
reason: RetryReason,
|
|
220
|
+
waitMs: number,
|
|
221
|
+
error: string,
|
|
222
|
+
counters?: { attempt: number; maxAttempts: number },
|
|
223
|
+
) => {
|
|
224
|
+
// The selection may have happened after this attempt started but before
|
|
225
|
+
// its failure reached us, when there was no active wait to wake.
|
|
226
|
+
if (observedUserModelSelection !== state.userModelSelectionGeneration) return "skipped" as const;
|
|
227
|
+
return waitForRetry(reason, waitMs, signal, {
|
|
219
228
|
periodId: retryPeriod?.periodId,
|
|
220
229
|
model: attempt.model,
|
|
221
230
|
error,
|
|
222
231
|
events: retryPeriod?.events,
|
|
232
|
+
...(counters ?? {}),
|
|
223
233
|
onEnd: (elapsedMs) => retryPeriod?.recordWait(elapsedMs),
|
|
224
234
|
});
|
|
235
|
+
};
|
|
225
236
|
|
|
226
237
|
const flush = (buffer: AssistantMessageEvent[]) => {
|
|
227
238
|
for (const event of buffer) output.push(event);
|
|
@@ -244,6 +255,17 @@ export function streamWithLimitsRetry(
|
|
|
244
255
|
};
|
|
245
256
|
|
|
246
257
|
while (true) {
|
|
258
|
+
if (observedUserModelSelection !== state.userModelSelectionGeneration) {
|
|
259
|
+
observedUserModelSelection = state.userModelSelectionGeneration;
|
|
260
|
+
const selected = state.primaryModel ?? state.sharedCtx?.model;
|
|
261
|
+
if (selected) {
|
|
262
|
+
// User model selection always supersedes the retry/fallback attempt
|
|
263
|
+
// chosen before the wait. Subsequent retries start from that model.
|
|
264
|
+
attempt = configuredAttempt(selected);
|
|
265
|
+
triedModels.clear();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
247
269
|
if (signal?.aborted) {
|
|
248
270
|
pushAbort("Operation aborted before provider attempt.");
|
|
249
271
|
return;
|
|
@@ -524,6 +546,7 @@ export function streamWithLimitsRetry(
|
|
|
524
546
|
// to non-interactive hosts for this request.
|
|
525
547
|
notifyFinalFailure(
|
|
526
548
|
`${formatModel(attempt.model)} failed and pi-limits-wait is not retrying: ${formatErrorDetail(failureMessage, Number.MAX_SAFE_INTEGER)}`,
|
|
549
|
+
failureMessage,
|
|
527
550
|
);
|
|
528
551
|
if (!committed) {
|
|
529
552
|
if (buffer.length > 0) flush(buffer);
|
|
@@ -549,7 +572,10 @@ export function streamWithLimitsRetry(
|
|
|
549
572
|
if (attempts < state.nonRetryableMaxAttempts) {
|
|
550
573
|
notifyRetryingAfterError(attempt.model, state.nonRetryableRetryDelayMs, failureMessage);
|
|
551
574
|
retryPeriod?.beginRetry(failureMessage);
|
|
552
|
-
const wait = await managedWait("retry", state.nonRetryableRetryDelayMs, failureMessage
|
|
575
|
+
const wait = await managedWait("retry", state.nonRetryableRetryDelayMs, failureMessage, {
|
|
576
|
+
attempt: attempts,
|
|
577
|
+
maxAttempts: state.nonRetryableMaxAttempts,
|
|
578
|
+
});
|
|
553
579
|
if (wait === "aborted" || signal?.aborted) {
|
|
554
580
|
pushAbort("Request aborted while retrying after error.");
|
|
555
581
|
return;
|
|
@@ -646,6 +672,7 @@ export function streamWithLimitsRetry(
|
|
|
646
672
|
if (failure.stopReason === "error") {
|
|
647
673
|
notifyFinalFailure(
|
|
648
674
|
`${formatModel(model)} failed and pi-limits-wait is not retrying: ${formatErrorDetail(errorMessageWithCauses(error), Number.MAX_SAFE_INTEGER)}`,
|
|
675
|
+
errorMessageWithCauses(error),
|
|
649
676
|
);
|
|
650
677
|
} else clearLivelinessStatus();
|
|
651
678
|
failure.errorMessage = errorMessageWithCauses(error);
|
package/src/telemetry.ts
CHANGED
|
@@ -54,6 +54,9 @@ export type WaitTelemetryOptions = {
|
|
|
54
54
|
model?: Pick<Model<Api>, "provider" | "id">;
|
|
55
55
|
error?: string;
|
|
56
56
|
events?: EventBus;
|
|
57
|
+
/** Extension-created attempt counters, when the call site tracks them. */
|
|
58
|
+
attempt?: number;
|
|
59
|
+
maxAttempts?: number;
|
|
57
60
|
onEnd?: (actualElapsedMs: number, outcome: LimitsWaitWaitOutcome) => void;
|
|
58
61
|
};
|
|
59
62
|
|
package/src/ui.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, rename, unlink } from "node:fs/promises";
|
|
1
3
|
import {
|
|
2
4
|
DEFAULT_LIVELINESS_INTERVAL_MS,
|
|
3
5
|
DEFAULT_UNKNOWN_ERROR_MAX_RETRIES,
|
|
@@ -11,6 +13,14 @@ import {
|
|
|
11
13
|
RETRY_INTERVAL_ENV_VAR,
|
|
12
14
|
} from "./constants.js";
|
|
13
15
|
import { state } from "./state.js";
|
|
16
|
+
import {
|
|
17
|
+
clearStatusJson,
|
|
18
|
+
controlFilePath,
|
|
19
|
+
isSkipToken,
|
|
20
|
+
publishStatusJson,
|
|
21
|
+
statusPayload,
|
|
22
|
+
type LimitsWaitStatusContext,
|
|
23
|
+
} from "./status-json.js";
|
|
14
24
|
import {
|
|
15
25
|
createWaitId,
|
|
16
26
|
emitWaitTelemetry,
|
|
@@ -46,7 +56,7 @@ export function reasonLabel(reason: RetryReason): string {
|
|
|
46
56
|
return "Rate limited";
|
|
47
57
|
}
|
|
48
58
|
|
|
49
|
-
function envBoolean(raw: string | undefined, defaultValue: boolean): boolean {
|
|
59
|
+
export function envBoolean(raw: string | undefined, defaultValue: boolean): boolean {
|
|
50
60
|
if (raw === undefined) return defaultValue;
|
|
51
61
|
const value = raw.trim().toLowerCase();
|
|
52
62
|
return !(value === "false" || value === "0" || value === "no" || value === "off");
|
|
@@ -128,19 +138,38 @@ export function clearLivelinessStatus(): void {
|
|
|
128
138
|
const ctx = state.sharedCtx;
|
|
129
139
|
if (!ctx || !isNonInteractiveHost()) return;
|
|
130
140
|
try { ctx.ui.setStatus(LIVELINESS_STATUS_KEY, undefined); } catch { /* UI unavailable */ }
|
|
141
|
+
clearStatusJson();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Structured-channel context for an event that happens outside a wait. */
|
|
145
|
+
function standaloneStatusContext(error?: string): LimitsWaitStatusContext {
|
|
146
|
+
return {
|
|
147
|
+
waitId: null,
|
|
148
|
+
reason: null,
|
|
149
|
+
error: error ? formatErrorDetail(error) : null,
|
|
150
|
+
plannedDurationMs: null,
|
|
151
|
+
plannedDeadline: null,
|
|
152
|
+
startedAt: null,
|
|
153
|
+
attempt: null,
|
|
154
|
+
maxAttempts: null,
|
|
155
|
+
livelinessIntervalMs: livelinessIntervalMs(),
|
|
156
|
+
controlFile: controlFilePath() ?? null,
|
|
157
|
+
};
|
|
131
158
|
}
|
|
132
159
|
|
|
133
160
|
/**
|
|
134
161
|
* Terminal failure notice: the extension has stopped retrying. This is the only
|
|
135
162
|
* place where an error-level notification is emitted.
|
|
136
163
|
*/
|
|
137
|
-
export function notifyFinalFailure(message: string): void {
|
|
164
|
+
export function notifyFinalFailure(message: string, error?: string): void {
|
|
138
165
|
const ctx = state.sharedCtx;
|
|
139
166
|
if (!ctx) return;
|
|
140
167
|
clearLivelinessStatus();
|
|
141
168
|
if (!isNonInteractiveHost()) return;
|
|
142
169
|
// In TUI the terminal error event is already rendered in the transcript.
|
|
143
170
|
try { ctx.ui.notify(message, "error"); } catch { /* UI unavailable */ }
|
|
171
|
+
publishStatusJson(statusPayload(standaloneStatusContext(error ?? message), "give_up", message, null));
|
|
172
|
+
clearStatusJson();
|
|
144
173
|
}
|
|
145
174
|
|
|
146
175
|
function livelinessText(
|
|
@@ -161,25 +190,28 @@ function livelinessText(
|
|
|
161
190
|
* Returns a cleanup function.
|
|
162
191
|
*/
|
|
163
192
|
function startLivelinessReporting(
|
|
193
|
+
context: LimitsWaitStatusContext,
|
|
164
194
|
reason: RetryReason,
|
|
165
195
|
deadline: number,
|
|
166
196
|
model?: Pick<LimitsWaitModel, "provider" | "id">,
|
|
167
197
|
error?: string,
|
|
168
|
-
): () => void {
|
|
198
|
+
): (clearStatus?: boolean) => void {
|
|
169
199
|
if (!isNonInteractiveHost()) return () => undefined;
|
|
170
200
|
|
|
171
201
|
const report = () => {
|
|
172
202
|
const remaining = Math.max(0, deadline - Date.now());
|
|
173
|
-
|
|
203
|
+
const message = livelinessText(reason, remaining, model, error);
|
|
204
|
+
notifyLiveliness(message);
|
|
205
|
+
publishStatusJson(statusPayload(context, "wait", message, remaining));
|
|
174
206
|
};
|
|
175
207
|
report();
|
|
176
208
|
const ticker = setInterval(() => {
|
|
177
209
|
if (Date.now() >= deadline) return;
|
|
178
210
|
report();
|
|
179
211
|
}, livelinessIntervalMs());
|
|
180
|
-
return () => {
|
|
212
|
+
return (clearStatus = true) => {
|
|
181
213
|
clearInterval(ticker);
|
|
182
|
-
clearLivelinessStatus();
|
|
214
|
+
if (clearStatus) clearLivelinessStatus();
|
|
183
215
|
};
|
|
184
216
|
}
|
|
185
217
|
|
|
@@ -227,6 +259,41 @@ export function clearModelStatus(): void {
|
|
|
227
259
|
state.modelStatusCleanup?.();
|
|
228
260
|
}
|
|
229
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Atomically claim the canonical control path before inspecting its contents.
|
|
264
|
+
* The writer may immediately replace the canonical path; cleanup only ever
|
|
265
|
+
* touches the unique claimed path in the same directory.
|
|
266
|
+
*/
|
|
267
|
+
export async function claimControlFile(controlFile: string, waitId: string): Promise<string | undefined> {
|
|
268
|
+
const claimedFile = `${controlFile}.consumed-${waitId}-${randomUUID()}`;
|
|
269
|
+
try {
|
|
270
|
+
await rename(controlFile, claimedFile);
|
|
271
|
+
return claimedFile;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Coalesce timer ticks while an asynchronous filesystem poll is still running. */
|
|
279
|
+
export function createSerializedAsyncRunner<T>(task: () => Promise<T>): {
|
|
280
|
+
run: () => Promise<T>;
|
|
281
|
+
pending: () => Promise<T> | undefined;
|
|
282
|
+
} {
|
|
283
|
+
let inFlight: Promise<T> | undefined;
|
|
284
|
+
return {
|
|
285
|
+
run: () => {
|
|
286
|
+
if (inFlight) return inFlight;
|
|
287
|
+
const current = task().finally(() => {
|
|
288
|
+
if (inFlight === current) inFlight = undefined;
|
|
289
|
+
});
|
|
290
|
+
inFlight = current;
|
|
291
|
+
return current;
|
|
292
|
+
},
|
|
293
|
+
pending: () => inFlight,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
230
297
|
export function waitForRetry(
|
|
231
298
|
reason: RetryReason,
|
|
232
299
|
waitMs: number,
|
|
@@ -253,41 +320,116 @@ export function waitForRetry(
|
|
|
253
320
|
};
|
|
254
321
|
emitWaitTelemetry(events, { ...common, phase: "start" } satisfies LimitsWaitWaitStartPayload);
|
|
255
322
|
|
|
323
|
+
// Resolved once per wait so a /reload picks up a new value.
|
|
324
|
+
const controlFile = controlFilePath();
|
|
325
|
+
const statusContext: LimitsWaitStatusContext = {
|
|
326
|
+
waitId,
|
|
327
|
+
...(telemetry?.periodId ? { periodId: telemetry.periodId } : {}),
|
|
328
|
+
reason,
|
|
329
|
+
error: telemetry?.error ? formatErrorDetail(telemetry.error) : null,
|
|
330
|
+
...(model ? { model } : {}),
|
|
331
|
+
plannedDurationMs: waitMs,
|
|
332
|
+
plannedDeadline,
|
|
333
|
+
startedAt,
|
|
334
|
+
attempt: telemetry?.attempt ?? null,
|
|
335
|
+
maxAttempts: telemetry?.maxAttempts ?? null,
|
|
336
|
+
livelinessIntervalMs: livelinessIntervalMs(),
|
|
337
|
+
controlFile: controlFile ?? null,
|
|
338
|
+
};
|
|
339
|
+
|
|
256
340
|
return new Promise((resolve) => {
|
|
257
341
|
const ctx = state.sharedCtx;
|
|
258
342
|
let done = false;
|
|
259
|
-
const stopLiveliness = startLivelinessReporting(reason, plannedDeadline, model, telemetry?.error);
|
|
343
|
+
const stopLiveliness = startLivelinessReporting(statusContext, reason, plannedDeadline, model, telemetry?.error);
|
|
260
344
|
let unsubInput: (() => void) | undefined;
|
|
261
345
|
let ticker: ReturnType<typeof setInterval> | undefined;
|
|
262
346
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
263
347
|
|
|
264
348
|
const onAbort = () => settle("aborted");
|
|
265
349
|
|
|
350
|
+
const skipWait = () => settle("skipped");
|
|
351
|
+
|
|
266
352
|
function cleanup() {
|
|
267
353
|
if (ticker) clearInterval(ticker);
|
|
268
354
|
if (timer) clearTimeout(timer);
|
|
269
|
-
|
|
355
|
+
state.activeWaitSkips.delete(skipWait);
|
|
356
|
+
try { stopLiveliness(false); } catch { /* UI unavailable */ }
|
|
270
357
|
signal?.removeEventListener("abort", onAbort);
|
|
271
358
|
try { unsubInput?.(); } catch { /* UI unavailable */ }
|
|
272
359
|
try { ctx?.ui.setWorkingMessage(); } catch { /* UI unavailable */ }
|
|
273
360
|
try { clearAmbientRetryStatus(); } catch { /* UI unavailable */ }
|
|
274
361
|
}
|
|
275
362
|
|
|
276
|
-
|
|
363
|
+
/**
|
|
364
|
+
* Out-of-band "retry now": atomically move the observed canonical path to
|
|
365
|
+
* a unique consumed path before reading it. Invalid claims are discarded;
|
|
366
|
+
* cleanup can therefore never unlink a newer token renamed into place.
|
|
367
|
+
*/
|
|
368
|
+
let pendingClaim: Promise<void> | undefined;
|
|
369
|
+
async function pollControlFile(): Promise<boolean> {
|
|
370
|
+
if (!controlFile || done) return false;
|
|
371
|
+
let finishClaim!: () => void;
|
|
372
|
+
const claimBarrier = new Promise<void>((resolveClaim) => { finishClaim = resolveClaim; });
|
|
373
|
+
pendingClaim = claimBarrier;
|
|
374
|
+
const claimedFile = await claimControlFile(controlFile, waitId).finally(() => {
|
|
375
|
+
finishClaim();
|
|
376
|
+
if (pendingClaim === claimBarrier) pendingClaim = undefined;
|
|
377
|
+
});
|
|
378
|
+
if (!claimedFile) return false;
|
|
379
|
+
|
|
380
|
+
let accepted = false;
|
|
381
|
+
try {
|
|
382
|
+
const raw = await readFile(claimedFile, "utf8");
|
|
383
|
+
const token: unknown = JSON.parse(raw);
|
|
384
|
+
accepted = isSkipToken(token, waitId, startedAt);
|
|
385
|
+
} catch { /* unreadable and malformed claims are discarded */ }
|
|
386
|
+
|
|
387
|
+
await unlink(claimedFile).catch(() => undefined);
|
|
388
|
+
return accepted;
|
|
389
|
+
}
|
|
390
|
+
const controlPoll = createSerializedAsyncRunner(pollControlFile);
|
|
391
|
+
|
|
392
|
+
function settle(requestedOutcome: LimitsWaitWaitOutcome) {
|
|
277
393
|
if (done) return;
|
|
278
394
|
done = true;
|
|
395
|
+
const pendingPoll = controlPoll.pending();
|
|
396
|
+
const claimBarrier = pendingClaim;
|
|
279
397
|
cleanup();
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
398
|
+
|
|
399
|
+
void (async () => {
|
|
400
|
+
// Do not let wait N resolve (and wait N+1 begin) while N can still
|
|
401
|
+
// rename the canonical path. A token claimed at the deadline wins over
|
|
402
|
+
// the timer. Explicit skip/abort only drain the bounded claim phase;
|
|
403
|
+
// later reads and unique-path cleanup cannot affect a replacement.
|
|
404
|
+
let acceptedAtSettlement = false;
|
|
405
|
+
if (requestedOutcome === "waited") {
|
|
406
|
+
acceptedAtSettlement = await pendingPoll?.catch(() => false) ?? false;
|
|
407
|
+
} else {
|
|
408
|
+
await claimBarrier?.catch(() => undefined);
|
|
409
|
+
}
|
|
410
|
+
const outcome = acceptedAtSettlement ? "skipped" : requestedOutcome;
|
|
411
|
+
const endedAt = Date.now();
|
|
412
|
+
const actualElapsedMs = Math.max(0, endedAt - startedAt);
|
|
413
|
+
// Published before status cleanup so the consumer learns the outcome
|
|
414
|
+
// first and the cleared key stays the end-of-window marker.
|
|
415
|
+
publishStatusJson(statusPayload(
|
|
416
|
+
statusContext,
|
|
417
|
+
"wait_end",
|
|
418
|
+
livelinessText(reason, Math.max(0, plannedDeadline - endedAt), model, telemetry?.error),
|
|
419
|
+
0,
|
|
420
|
+
{ outcome, actualElapsedMs },
|
|
421
|
+
));
|
|
422
|
+
try { stopLiveliness(); } catch { /* UI unavailable */ }
|
|
423
|
+
try { telemetry?.onEnd?.(actualElapsedMs, outcome); } catch { /* observational callback */ }
|
|
424
|
+
emitWaitTelemetry(events, {
|
|
425
|
+
...common,
|
|
426
|
+
phase: "end",
|
|
427
|
+
endedAt,
|
|
428
|
+
actualElapsedMs,
|
|
429
|
+
outcome,
|
|
430
|
+
} satisfies LimitsWaitWaitEndPayload);
|
|
431
|
+
resolve(outcome);
|
|
432
|
+
})();
|
|
291
433
|
}
|
|
292
434
|
|
|
293
435
|
if (signal?.aborted) {
|
|
@@ -295,10 +437,18 @@ export function waitForRetry(
|
|
|
295
437
|
return;
|
|
296
438
|
}
|
|
297
439
|
|
|
440
|
+
state.activeWaitSkips.add(skipWait);
|
|
441
|
+
|
|
298
442
|
try {
|
|
299
443
|
const unsubscribe = ctx?.ui.onTerminalInput((data) => {
|
|
300
444
|
if (done) return undefined;
|
|
301
445
|
if (data === "\r" || data === "\n") {
|
|
446
|
+
// Enter belongs to the editor whenever it contains text. In
|
|
447
|
+
// particular, slash-command autocomplete needs Enter to confirm and
|
|
448
|
+
// then execute a command; do not steal either press from it.
|
|
449
|
+
let editorText = "";
|
|
450
|
+
try { editorText = ctx?.ui.getEditorText() ?? ""; } catch { /* UI unavailable */ }
|
|
451
|
+
if (editorText.length > 0) return undefined;
|
|
302
452
|
settle("skipped");
|
|
303
453
|
return { consume: true };
|
|
304
454
|
}
|
|
@@ -326,6 +476,10 @@ export function waitForRetry(
|
|
|
326
476
|
tick();
|
|
327
477
|
if (done) return;
|
|
328
478
|
ticker = setInterval(() => {
|
|
479
|
+
const poll = controlPoll.run();
|
|
480
|
+
void poll.then((accepted) => {
|
|
481
|
+
if (accepted && !done) settle("skipped");
|
|
482
|
+
});
|
|
329
483
|
if (Date.now() >= plannedDeadline) settle("waited");
|
|
330
484
|
else tick();
|
|
331
485
|
}, 1_000);
|