agent-relay-runner 0.88.1 → 0.88.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/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +4 -0
- package/src/adapters/claude.ts +16 -16
- package/src/adapters/codex.ts +21 -0
- package/src/claim-tracker.ts +31 -0
- package/src/control-server.ts +7 -0
- package/src/rate-limit.ts +32 -0
- package/src/runner-core.ts +92 -6
package/package.json
CHANGED
package/src/adapter.ts
CHANGED
|
@@ -25,6 +25,10 @@ export interface ProviderStatusEvent {
|
|
|
25
25
|
label?: string;
|
|
26
26
|
role?: string;
|
|
27
27
|
parentId?: string;
|
|
28
|
+
// OS pids of the process(es) this busy work tracks (#650). When supplied, the
|
|
29
|
+
// runner reconciles them against actual liveness and clears the busy marker if
|
|
30
|
+
// every pid is dead — recovering from a tracked process killed out-of-band.
|
|
31
|
+
pids?: number[];
|
|
28
32
|
providerState?: Record<string, unknown>;
|
|
29
33
|
metadata?: Record<string, unknown>;
|
|
30
34
|
}
|
package/src/adapters/claude.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { SessionEvent } from "../session-insights";
|
|
|
12
12
|
import { prepareClaudeProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
|
|
13
13
|
import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
|
|
14
14
|
import { claudeProviderMessageText } from "./claude-delivery";
|
|
15
|
-
import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
|
|
15
|
+
import { buildRateLimitProviderState, gatedClaudeRateLimitStatus, parseClaudeRateLimitPane } from "../rate-limit";
|
|
16
16
|
|
|
17
17
|
export class ClaudeAdapter implements ProviderAdapter {
|
|
18
18
|
readonly provider = "claude";
|
|
@@ -27,6 +27,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
27
27
|
// #286: true while a usage-limit modal is being held, so the pane watcher dismisses
|
|
28
28
|
// it + emits the hold once (not every 2s tick). Cleared when the modal leaves the pane.
|
|
29
29
|
private rateLimitReported = false;
|
|
30
|
+
private rateLimitPaneDetections = 0;
|
|
30
31
|
|
|
31
32
|
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
|
|
32
33
|
this.statusCb = cb;
|
|
@@ -369,25 +370,24 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
369
370
|
this.statusCb(status);
|
|
370
371
|
return;
|
|
371
372
|
}
|
|
372
|
-
// #286: the subscription usage-limit modal fires no hook, so detect it
|
|
373
|
-
// pane.
|
|
374
|
-
//
|
|
375
|
-
const rateLimit =
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
this.rateLimitReported = false;
|
|
373
|
+
// #286/#655: the subscription usage-limit modal fires no hook, so detect it
|
|
374
|
+
// from the pane. Act only after consecutive non-busy detections; a live turn
|
|
375
|
+
// mentioning quota/reset text must not be escaped or held.
|
|
376
|
+
const rateLimit = gatedClaudeRateLimitStatus(pane, {
|
|
377
|
+
consecutiveDetections: this.rateLimitPaneDetections,
|
|
378
|
+
reported: this.rateLimitReported,
|
|
379
|
+
}, { sessionName, busy: claudePaneIsBusy(pane) });
|
|
380
|
+
this.rateLimitPaneDetections = rateLimit.state.consecutiveDetections;
|
|
381
|
+
this.rateLimitReported = rateLimit.state.reported;
|
|
382
|
+
if (rateLimit.status) {
|
|
383
|
+
this.dismissRateLimitModal(sessionName, socketName);
|
|
384
|
+
this.statusCb(rateLimit.status);
|
|
385
385
|
}
|
|
386
386
|
}, 2000);
|
|
387
387
|
}
|
|
388
388
|
|
|
389
|
-
// Send Escape to dismiss the usage-limit modal — the "wait" choice,
|
|
390
|
-
//
|
|
389
|
+
// Send Escape to dismiss the confirmed usage-limit modal — the "wait" choice,
|
|
390
|
+
// which returns the agent to a normal idle prompt. Best-effort:
|
|
391
391
|
// a failed send-keys must never wedge the pane watcher.
|
|
392
392
|
private dismissRateLimitModal(sessionName: string, socketName?: string): void {
|
|
393
393
|
try {
|
package/src/adapters/codex.ts
CHANGED
|
@@ -50,6 +50,9 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
50
50
|
// Active turn id for the main thread, captured from turn/started so an interrupt
|
|
51
51
|
// can target the in-flight turn. Cleared on turn/completed.
|
|
52
52
|
private activeTurnId?: string;
|
|
53
|
+
// #653: monotonic id for a turn we synthesize from live item activity when a real
|
|
54
|
+
// turn/started never reached us — so a working Codex agent isn't reported `idle`.
|
|
55
|
+
private syntheticTurnSeq = 0;
|
|
53
56
|
private readonly agentMessageCapture = new CodexAgentMessageCapture();
|
|
54
57
|
private readonly itemTextBuffers = new Map<string, string>();
|
|
55
58
|
private readonly itemTextBufferTypes = new Map<string, string>();
|
|
@@ -578,8 +581,26 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
578
581
|
return [...this.sessionEvents];
|
|
579
582
|
}
|
|
580
583
|
|
|
584
|
+
// #653: Codex busy-state normally flips on turn/started, but the app-server can stream
|
|
585
|
+
// item activity (which already drives the dashboard chat mirror) before — or without —
|
|
586
|
+
// a turn/started reaching us, leaving a visibly-working agent reported `idle` (a reap
|
|
587
|
+
// hazard). Derive busy from that SAME live activity: any in-flight item event with no
|
|
588
|
+
// active turn means a turn IS running we missed, so synthesize one and report busy.
|
|
589
|
+
// turn/completed → finishMainTurn clears it; we never synthesize on a closing
|
|
590
|
+
// item.completed, so a legitimately ended turn is not re-flipped to busy.
|
|
591
|
+
private ensureMainTurnActive(turnId?: string): void {
|
|
592
|
+
if (this.activeTurnId) return;
|
|
593
|
+
this.activeTurnId = turnId ?? `codex-synth-turn-${++this.syntheticTurnSeq}`;
|
|
594
|
+
this.statusCb({ status: "busy", reason: "provider-turn", id: this.activeTurnId });
|
|
595
|
+
}
|
|
596
|
+
|
|
581
597
|
private handleCodexItemDelta(method: string, params: Record<string, unknown> | undefined): void {
|
|
582
598
|
if (!method.includes("item/") && !method.includes("item.")) return;
|
|
599
|
+
// Live item stream (start/delta/progress) = a turn in flight. A closing
|
|
600
|
+
// item.completed is handled by handleCodexItem and must not synthesize a turn.
|
|
601
|
+
if (!method.includes("/completed") && !method.includes(".completed")) {
|
|
602
|
+
this.ensureMainTurnActive(stringValue(params?.turnId));
|
|
603
|
+
}
|
|
583
604
|
const item = isRecord(params?.item) ? params.item : undefined;
|
|
584
605
|
const itemId = codexItemId(params) ?? codexItemId(item);
|
|
585
606
|
const type = codexItemTypeFromMethod(method) ?? stringValue(item?.type);
|
package/src/claim-tracker.ts
CHANGED
|
@@ -17,6 +17,10 @@ interface WorkRecord {
|
|
|
17
17
|
role?: string;
|
|
18
18
|
parentId?: string;
|
|
19
19
|
metadata?: Record<string, unknown>;
|
|
20
|
+
// OS pids of the underlying process(es) this work tracks (#650). When present,
|
|
21
|
+
// a periodic liveness reconcile clears the record once every pid is dead — so a
|
|
22
|
+
// tracked process killed out-of-band (no exit event) can't strand the agent busy.
|
|
23
|
+
pids?: number[];
|
|
20
24
|
startedAt: number;
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -40,6 +44,7 @@ export class ClaimTracker {
|
|
|
40
44
|
role: details.role ?? existing?.role,
|
|
41
45
|
parentId: details.parentId ?? existing?.parentId,
|
|
42
46
|
metadata: details.metadata ?? existing?.metadata,
|
|
47
|
+
pids: details.pids ?? existing?.pids,
|
|
43
48
|
startedAt: existing?.startedAt ?? Date.now(),
|
|
44
49
|
});
|
|
45
50
|
return before !== this.currentStatus();
|
|
@@ -106,4 +111,30 @@ export class ClaimTracker {
|
|
|
106
111
|
activeWork(): WorkRecord[] {
|
|
107
112
|
return [...this.work.values()];
|
|
108
113
|
}
|
|
114
|
+
|
|
115
|
+
/** True iff any active work carries pids the liveness reconcile can check (#650). */
|
|
116
|
+
hasLivenessTrackedWork(): boolean {
|
|
117
|
+
for (const item of this.work.values()) {
|
|
118
|
+
if (item.pids && item.pids.length > 0) return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reconcile pid-bearing work against actual process liveness (#650): drop any
|
|
125
|
+
* record whose tracked pids are ALL dead — its underlying process died out-of-band
|
|
126
|
+
* (killed/crashed/orphaned) with no exit event to clear the busy marker. Records
|
|
127
|
+
* with no pids are never touched (no liveness signal to act on). Returns the removed
|
|
128
|
+
* records so the caller can emit the matching completion/abort + republish status.
|
|
129
|
+
*/
|
|
130
|
+
reconcileLiveness(isAlive: (pid: number) => boolean): WorkRecord[] {
|
|
131
|
+
const removed: WorkRecord[] = [];
|
|
132
|
+
for (const [key, item] of this.work) {
|
|
133
|
+
if (!item.pids || item.pids.length === 0) continue;
|
|
134
|
+
if (item.pids.some(isAlive)) continue;
|
|
135
|
+
this.work.delete(key);
|
|
136
|
+
removed.push(item);
|
|
137
|
+
}
|
|
138
|
+
return removed;
|
|
139
|
+
}
|
|
109
140
|
}
|
package/src/control-server.ts
CHANGED
|
@@ -446,6 +446,12 @@ async function handleStatus(req: Request, options: ControlServerOptions): Promis
|
|
|
446
446
|
const isWorkKind = (item: unknown): item is "provider-turn" | "subagent" | "background-script" =>
|
|
447
447
|
item === "provider-turn" || item === "subagent" || item === "background-script";
|
|
448
448
|
const clear = Array.isArray(body?.clear) ? body.clear.filter(isWorkKind) : undefined;
|
|
449
|
+
// #650: optional pids the runner reconciles against process liveness so a tracked
|
|
450
|
+
// command/shell killed out-of-band clears the busy marker instead of stranding it.
|
|
451
|
+
const pidsInput = Array.isArray((body as { pids?: unknown })?.pids) ? (body as { pids: unknown[] }).pids : undefined;
|
|
452
|
+
const pids = pidsInput
|
|
453
|
+
?.map((p) => (typeof p === "number" ? p : Number(p)))
|
|
454
|
+
.filter((p) => Number.isInteger(p) && p > 0);
|
|
449
455
|
const timeline = statusTimelineEvent(body);
|
|
450
456
|
const update: ProviderStatusEvent = {
|
|
451
457
|
status,
|
|
@@ -454,6 +460,7 @@ async function handleStatus(req: Request, options: ControlServerOptions): Promis
|
|
|
454
460
|
...(clear?.length ? { clear: [...new Set(clear)] } : {}),
|
|
455
461
|
...(timeline ? { timeline } : {}),
|
|
456
462
|
...(typeof body?.id === "string" ? { id: body.id } : {}),
|
|
463
|
+
...(pids?.length ? { pids } : {}),
|
|
457
464
|
...(typeof body?.label === "string" ? { label: body.label } : {}),
|
|
458
465
|
...(typeof body?.role === "string" ? { role: body.role } : {}),
|
|
459
466
|
...(typeof body?.parentId === "string" ? { parentId: body.parentId } : {}),
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// #286 — shared rate-limit hold construction + Claude pane detection.
|
|
2
|
+
import type { ProviderStatusUpdate } from "./adapter";
|
|
2
3
|
//
|
|
3
4
|
// Two detection arms feed the SAME provider-neutral `blocked` hold:
|
|
4
5
|
// 1. The StopFailure hook (clean API-error turn-end: API-429/auth/billing) → control-server.
|
|
@@ -94,6 +95,37 @@ export function parseClaudeRateLimitPane(text: string, nowMs: number = Date.now(
|
|
|
94
95
|
return null;
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
export interface ClaudeRateLimitPaneGateState {
|
|
99
|
+
consecutiveDetections: number;
|
|
100
|
+
reported: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function gatedClaudeRateLimitStatus(
|
|
104
|
+
text: string,
|
|
105
|
+
state: ClaudeRateLimitPaneGateState,
|
|
106
|
+
options: { sessionName?: string; busy?: boolean } = {},
|
|
107
|
+
): { status: ProviderStatusUpdate | null; state: ClaudeRateLimitPaneGateState } {
|
|
108
|
+
const parsed = parseClaudeRateLimitPane(text);
|
|
109
|
+
if (!parsed) return { status: null, state: { consecutiveDetections: 0, reported: false } };
|
|
110
|
+
if (options.busy) return { status: null, state: { consecutiveDetections: 0, reported: state.reported } };
|
|
111
|
+
const nextState = { consecutiveDetections: state.consecutiveDetections + 1, reported: state.reported };
|
|
112
|
+
if (nextState.reported || nextState.consecutiveDetections < 2) return { status: null, state: nextState };
|
|
113
|
+
nextState.reported = true;
|
|
114
|
+
return {
|
|
115
|
+
status: {
|
|
116
|
+
status: "idle",
|
|
117
|
+
clear: ["subagent"],
|
|
118
|
+
providerState: buildRateLimitProviderState({
|
|
119
|
+
errorType: "session_limit",
|
|
120
|
+
...(parsed.resetAt ? { resetAt: parsed.resetAt } : {}),
|
|
121
|
+
message: parsed.message,
|
|
122
|
+
source: options.sessionName ? `claude-pane:${options.sessionName}` : "claude-pane",
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
state: nextState,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
97
129
|
// The clean limit line containing the match offset, stripped of box-drawing chrome.
|
|
98
130
|
function limitLineAt(text: string, index: number): string {
|
|
99
131
|
const start = text.lastIndexOf("\n", index) + 1;
|
package/src/runner-core.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
|
|
6
6
|
import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
|
|
7
7
|
import { contextStateFromProbeMetrics, quotaStateFromProbeMetrics } from "agent-relay-sdk/context-probe";
|
|
8
|
+
import { isPidAlive } from "agent-relay-sdk/process-utils";
|
|
8
9
|
import { providerAttachmentText, providerMessageText } from "./adapter";
|
|
9
10
|
import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
10
11
|
import { messagesWithCachedAttachments } from "./attachment-cache";
|
|
@@ -109,6 +110,8 @@ interface ActiveTaskClaim {
|
|
|
109
110
|
|
|
110
111
|
const CLAIM_RENEW_INTERVAL_MS = 5 * 60 * 1000;
|
|
111
112
|
const HTTP_LIVENESS_INTERVAL_MS = 20_000;
|
|
113
|
+
// #650: how often to reconcile pid-bearing busy work against actual process liveness.
|
|
114
|
+
const BUSY_LIVENESS_RECONCILE_MS = 5_000;
|
|
112
115
|
const HTTP_LIVENESS_LOG_INTERVAL_MS = 5 * 60 * 1000;
|
|
113
116
|
const TOKEN_RENEW_RETRY_MS = 60_000;
|
|
114
117
|
// Debounce reactive token recovery so a burst of 401-ing calls in the same window
|
|
@@ -204,6 +207,7 @@ export class AgentRunner {
|
|
|
204
207
|
private drainTimer?: Timer;
|
|
205
208
|
private claimRenewTimer?: Timer;
|
|
206
209
|
private httpLivenessTimer?: Timer;
|
|
210
|
+
private livenessReconcileTimer?: Timer;
|
|
207
211
|
private httpLivenessInFlight = false;
|
|
208
212
|
private httpLivenessAuthFailed = false;
|
|
209
213
|
private httpLivenessLastLog?: { key: string; at: number };
|
|
@@ -264,6 +268,7 @@ export class AgentRunner {
|
|
|
264
268
|
// providerState; it clears when a new turn starts (the relay's resume message
|
|
265
269
|
// wakes one) so the blocked badge lifts on its own.
|
|
266
270
|
private rateLimitHold?: Record<string, unknown>;
|
|
271
|
+
private pendingRateLimitHold?: Record<string, unknown>;
|
|
267
272
|
// Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
|
|
268
273
|
// from the Claude transcript into chat as discreet session events.
|
|
269
274
|
private reasoningTail?: ReasoningTailState;
|
|
@@ -471,6 +476,7 @@ export class AgentRunner {
|
|
|
471
476
|
this.httpLivenessTimer = undefined;
|
|
472
477
|
if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
|
|
473
478
|
this.tokenRenewTimer = undefined;
|
|
479
|
+
this.disarmBusyLivenessReconcile();
|
|
474
480
|
this.busyReconciler.disarm();
|
|
475
481
|
this.stopReasoningTail();
|
|
476
482
|
this.obligationCache.stop();
|
|
@@ -1150,16 +1156,28 @@ export class AgentRunner {
|
|
|
1150
1156
|
...(update.timeline.metadata ? { metadata: update.timeline.metadata } : {}),
|
|
1151
1157
|
};
|
|
1152
1158
|
}
|
|
1159
|
+
let deferredRateLimitHold = false;
|
|
1153
1160
|
if (typeof update !== "string" && update.providerState) {
|
|
1154
|
-
const ps = update.providerState as { state?: unknown; reason?: unknown };
|
|
1155
|
-
this.providerBlocked = ps.state === "blocked";
|
|
1161
|
+
const ps = update.providerState as { state?: unknown; reason?: unknown; source?: unknown };
|
|
1156
1162
|
// A rate-limit hold persists across the idle the turn ends on; any other
|
|
1157
1163
|
// providerState supersedes it (clears a stale hold).
|
|
1158
1164
|
if (ps.state === "blocked" && ps.reason === "rate_limit") {
|
|
1159
|
-
const
|
|
1160
|
-
this.
|
|
1161
|
-
|
|
1165
|
+
const paneRateLimitHold = typeof ps.source === "string" && ps.source.startsWith("claude-pane");
|
|
1166
|
+
if (status === "idle" && reason === "provider-turn" && paneRateLimitHold && this.claims.activeWork().some((work) => work.kind === "provider-turn")) {
|
|
1167
|
+
this.pendingRateLimitHold = update.providerState;
|
|
1168
|
+
this.providerBlocked = false;
|
|
1169
|
+
deferredRateLimitHold = true;
|
|
1170
|
+
this.sessionLog(`rate-limit hold deferred until active turn finishes (turn ${this.currentTurnId ?? "?"})`);
|
|
1171
|
+
} else {
|
|
1172
|
+
this.providerBlocked = true;
|
|
1173
|
+
this.pendingRateLimitHold = undefined;
|
|
1174
|
+
const fresh = !this.rateLimitHold;
|
|
1175
|
+
this.rateLimitHold = update.providerState;
|
|
1176
|
+
if (fresh) this.publishRateLimitNotice(update.providerState);
|
|
1177
|
+
}
|
|
1162
1178
|
} else {
|
|
1179
|
+
this.providerBlocked = ps.state === "blocked";
|
|
1180
|
+
this.pendingRateLimitHold = undefined;
|
|
1163
1181
|
this.rateLimitHold = undefined;
|
|
1164
1182
|
}
|
|
1165
1183
|
} else if (status === "idle") {
|
|
@@ -1167,6 +1185,13 @@ export class AgentRunner {
|
|
|
1167
1185
|
}
|
|
1168
1186
|
// Forward progress (a real turn) lifts the hold so the blocked badge clears.
|
|
1169
1187
|
if (status === "busy") this.rateLimitHold = undefined;
|
|
1188
|
+
if (deferredRateLimitHold) {
|
|
1189
|
+
if (typeof update !== "string") {
|
|
1190
|
+
for (const kind of update.clear ?? []) this.claims.clearWorkKind(kind);
|
|
1191
|
+
}
|
|
1192
|
+
this.publishStatus();
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1170
1195
|
if (typeof update !== "string" && status === "error") {
|
|
1171
1196
|
const terminalReason = typeof update.metadata?.terminalFailureReason === "string"
|
|
1172
1197
|
? update.metadata.terminalFailureReason
|
|
@@ -1227,15 +1252,30 @@ export class AgentRunner {
|
|
|
1227
1252
|
if (status === "busy" || status === "idle") this.terminalProviderExit = undefined;
|
|
1228
1253
|
if (status === "busy") {
|
|
1229
1254
|
this.claims.clearTerminalStatus();
|
|
1230
|
-
|
|
1255
|
+
// #650: liveness-track the pids a reporter explicitly supplied; otherwise, for a
|
|
1256
|
+
// provider-turn, fall back to the managed provider process itself. If that process
|
|
1257
|
+
// dies out-of-band with no exit event reaching the runner (crash/orphan/external
|
|
1258
|
+
// kill), the reconcile clears the stuck turn → idle. A dead pid means the turn is
|
|
1259
|
+
// definitively over, so this can never falsely clear a live turn (#653's hazard).
|
|
1260
|
+
const reportedPids = typeof update === "string" ? undefined : update.pids;
|
|
1261
|
+
const livenessPids = reportedPids?.length
|
|
1262
|
+
? reportedPids
|
|
1263
|
+
: reason === "provider-turn" && typeof this.process?.pid === "number" && this.process.pid > 0
|
|
1264
|
+
? [this.process.pid]
|
|
1265
|
+
: undefined;
|
|
1266
|
+
this.claims.startWork(reason, id, typeof update === "string" ? (livenessPids ? { pids: livenessPids } : {}) : {
|
|
1231
1267
|
label: update.label,
|
|
1232
1268
|
role: update.role,
|
|
1233
1269
|
parentId: update.parentId,
|
|
1270
|
+
...(livenessPids ? { pids: livenessPids } : {}),
|
|
1234
1271
|
metadata: {
|
|
1235
1272
|
...(update.metadata ?? {}),
|
|
1236
1273
|
...(update.providerState ? { providerState: update.providerState } : {}),
|
|
1237
1274
|
},
|
|
1238
1275
|
});
|
|
1276
|
+
// A busy report with liveness-trackable pids arms the periodic reconcile (self-disarms
|
|
1277
|
+
// once no pid-bearing work remains, so idle/untracked agents pay nothing).
|
|
1278
|
+
if (livenessPids?.length) this.armBusyLivenessReconcile();
|
|
1239
1279
|
if (reason === "provider-turn") {
|
|
1240
1280
|
for (const claim of this.activeTaskClaims.values()) claim.observedProviderBusy = true;
|
|
1241
1281
|
}
|
|
@@ -1253,6 +1293,14 @@ export class AgentRunner {
|
|
|
1253
1293
|
if (status === "idle" && this.pendingInitialPrompt && !this.deliveringInitialPrompt) {
|
|
1254
1294
|
void this.attemptInitialPromptDelivery(this.pendingInitialPrompt, INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS);
|
|
1255
1295
|
}
|
|
1296
|
+
if (status === "idle" && this.pendingRateLimitHold && !this.claims.activeWork().some((work) => work.kind === "provider-turn")) {
|
|
1297
|
+
const hold = this.pendingRateLimitHold;
|
|
1298
|
+
this.pendingRateLimitHold = undefined;
|
|
1299
|
+
this.providerBlocked = true;
|
|
1300
|
+
const fresh = !this.rateLimitHold;
|
|
1301
|
+
this.rateLimitHold = hold;
|
|
1302
|
+
if (fresh) this.publishRateLimitNotice(hold);
|
|
1303
|
+
}
|
|
1256
1304
|
this.publishStatus();
|
|
1257
1305
|
}
|
|
1258
1306
|
|
|
@@ -1662,6 +1710,44 @@ export class AgentRunner {
|
|
|
1662
1710
|
this.publishStatus();
|
|
1663
1711
|
}
|
|
1664
1712
|
|
|
1713
|
+
// #650: reconcile pid-bearing busy work against actual process liveness. A tracked
|
|
1714
|
+
// command/shell that dies out-of-band (killed by the operator, crashes, orphaned)
|
|
1715
|
+
// sends no exit event, so its busy marker would otherwise never clear and the agent
|
|
1716
|
+
// shows `busy` forever — blocking transient auto-reap and (for Claude) holding quota.
|
|
1717
|
+
// The timer self-disarms once no pid-bearing work remains; it is armed lazily when a
|
|
1718
|
+
// busy report carrying pids arrives, so idle/non-tracked agents pay nothing.
|
|
1719
|
+
private armBusyLivenessReconcile(): void {
|
|
1720
|
+
if (this.livenessReconcileTimer || this.stopped) return;
|
|
1721
|
+
this.livenessReconcileTimer = setInterval(() => this.runBusyLivenessReconcile(), BUSY_LIVENESS_RECONCILE_MS);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
private disarmBusyLivenessReconcile(): void {
|
|
1725
|
+
if (this.livenessReconcileTimer) clearInterval(this.livenessReconcileTimer);
|
|
1726
|
+
this.livenessReconcileTimer = undefined;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
private runBusyLivenessReconcile(): void {
|
|
1730
|
+
if (this.stopped) {
|
|
1731
|
+
this.disarmBusyLivenessReconcile();
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
const removed = this.claims.reconcileLiveness(isPidAlive);
|
|
1735
|
+
if (removed.length > 0) {
|
|
1736
|
+
this.sessionLog(`cleared ${removed.length} stuck busy work item(s) whose tracked process died out-of-band: ${removed.map((w) => `${w.kind}:${w.id}`).join(", ")}`);
|
|
1737
|
+
// A cleared provider-turn must also reset the turn bookkeeping so a later idle
|
|
1738
|
+
// doesn't double-end it and the reasoning tail stops.
|
|
1739
|
+
if (removed.some((w) => w.kind === "provider-turn")) {
|
|
1740
|
+
this.currentTurnId = undefined;
|
|
1741
|
+
this.currentTurnStartedAt = undefined;
|
|
1742
|
+
this.compactionMidTurn = false;
|
|
1743
|
+
this.busyReconciler.disarm();
|
|
1744
|
+
this.stopReasoningTail();
|
|
1745
|
+
}
|
|
1746
|
+
this.publishStatus();
|
|
1747
|
+
}
|
|
1748
|
+
if (!this.claims.hasLivenessTrackedWork()) this.disarmBusyLivenessReconcile();
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1665
1751
|
// --- Turn-step tailer (item 5) ------------------------------------------------------
|
|
1666
1752
|
// Tail the in-flight turn's Claude transcript and surface new narration/reasoning/tool
|
|
1667
1753
|
// steps as session events, in transcript order. `narration` (the agent's intermediate
|