agent-relay-runner 0.127.4 → 0.127.5
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 +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +6 -0
- package/src/control-server.ts +87 -21
- package/src/mcp-outbox.ts +54 -4
- package/src/pending-permission-store.ts +148 -0
- package/src/runner-core.ts +24 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.127.
|
|
3
|
+
"version": "0.127.5",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"agent-relay-providers": "0.104.4",
|
|
26
|
-
"agent-relay-sdk": "0.2.
|
|
26
|
+
"agent-relay-sdk": "0.2.120",
|
|
27
27
|
"callmux": "0.23.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
package/src/adapter.ts
CHANGED
|
@@ -146,11 +146,17 @@ export interface ProviderPermissionDecisionInput {
|
|
|
146
146
|
approvalId: string;
|
|
147
147
|
decision: ProviderPermissionDecision;
|
|
148
148
|
reason?: string;
|
|
149
|
+
decisionReason?: ProviderPermissionDecisionReason;
|
|
149
150
|
// For "answer" decisions (Claude AskUserQuestion): maps each question's text
|
|
150
151
|
// to the chosen option label(s). Multi-select labels are comma-joined.
|
|
151
152
|
answers?: Record<string, string>;
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
export interface ProviderPermissionDecisionReason {
|
|
156
|
+
kind: "timeout" | "dismissed" | "answered";
|
|
157
|
+
message?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
154
160
|
export interface ProviderAdapter {
|
|
155
161
|
provider: string;
|
|
156
162
|
spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
|
package/src/control-server.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
2
|
import type { InteractivePrompt, Message, ReplyObligation } from "agent-relay-sdk";
|
|
3
3
|
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
4
|
-
import type { ProviderPermissionDecisionInput, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
4
|
+
import type { ProviderPermissionDecisionInput, ProviderPermissionDecisionReason, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
5
5
|
import { obligationRequiresExplicitReply } from "./reply-obligation-cache";
|
|
6
6
|
import { relayReplyActionText } from "./relay-instructions";
|
|
7
7
|
import { logger, parseLogLevel, LOG_LEVELS } from "./logger";
|
|
8
8
|
import { buildRateLimitProviderState } from "./rate-limit";
|
|
9
|
+
import { bestEffortDeletePendingPermissionRequest, bestEffortListPendingPermissionRequests, bestEffortUpsertPendingPermissionRequest, type PendingPermissionRequestRecord, type PendingPermissionStore } from "./pending-permission-store";
|
|
9
10
|
|
|
10
11
|
// The AskUserQuestion / PermissionRequest hook (runner/plugins/claude/hooks/hooks.json)
|
|
11
12
|
// has a 900s Claude-side timeout. Resolve the server-side wait with a margin UNDER that
|
|
@@ -16,6 +17,8 @@ const PERMISSION_WAIT_MS = 880_000;
|
|
|
16
17
|
// agent can tell "nobody answered in time" (retriable) from "the human said no" (#693).
|
|
17
18
|
const PERMISSION_TIMEOUT_REASON =
|
|
18
19
|
"No response received within ~15 minutes (timeout, not a dismissal). Re-ask if you still need a decision.";
|
|
20
|
+
const PERMISSION_STOP_REASON = "The runner restarted while this permission request was pending; the original hook is no longer connected.";
|
|
21
|
+
const RESOLVED_APPROVAL_LIMIT = 1024;
|
|
19
22
|
|
|
20
23
|
// A hook that failed in a way it could not handle itself reports here so the
|
|
21
24
|
// failure is never silent (#198 item 5). Phase 1 logs it FATAL to the per-agent
|
|
@@ -57,6 +60,7 @@ interface ControlServerOptions {
|
|
|
57
60
|
// stable, provider-native turn identifier used instead of a minted random UUID.
|
|
58
61
|
onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void>;
|
|
59
62
|
onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
|
|
63
|
+
onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
|
|
60
64
|
// A provider UserPromptSubmit hook hands over the prompt the human typed
|
|
61
65
|
// directly into the session (web terminal / TUI) so the runner can mirror it
|
|
62
66
|
// into the dashboard chat and start tailing the turn transcript for reasoning.
|
|
@@ -75,6 +79,8 @@ interface ControlServerOptions {
|
|
|
75
79
|
// Test seam: how long the permission hook waits for a dashboard decision before
|
|
76
80
|
// resolving as a timeout. Defaults to PERMISSION_WAIT_MS (margin under the 900s hook).
|
|
77
81
|
permissionWaitMs?: number;
|
|
82
|
+
instanceId?: string;
|
|
83
|
+
pendingPermissionStore?: PendingPermissionStore;
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
export interface ResolvedPermissionPrompt {
|
|
@@ -90,6 +96,7 @@ export interface ResolvedPermissionPrompt {
|
|
|
90
96
|
decisionLabel: string;
|
|
91
97
|
occurredAt: number;
|
|
92
98
|
resolvedAt: number;
|
|
99
|
+
decisionReason: ProviderPermissionDecisionReason;
|
|
93
100
|
questions?: unknown[];
|
|
94
101
|
answers?: Record<string, string>;
|
|
95
102
|
}
|
|
@@ -103,6 +110,7 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
103
110
|
// is an idempotent no-op instead of a silent command failure (#693).
|
|
104
111
|
const resolvedApprovals = new Set<string>();
|
|
105
112
|
let server!: Server<MonitorSocketData>;
|
|
113
|
+
recoverStoredPermissionRequests(options);
|
|
106
114
|
|
|
107
115
|
server = Bun.serve<MonitorSocketData>({
|
|
108
116
|
hostname: "127.0.0.1",
|
|
@@ -194,6 +202,15 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
194
202
|
for (const pending of pendingDeliveries.values()) clearTimeout(pending.timer);
|
|
195
203
|
pendingDeliveries.clear();
|
|
196
204
|
for (const pending of pendingPermissionRequests.values()) clearTimeout(pending.timer);
|
|
205
|
+
for (const [approvalId, pending] of pendingPermissionRequests.entries()) {
|
|
206
|
+
const decision = withDecisionReason({
|
|
207
|
+
approvalId,
|
|
208
|
+
decision: "deny",
|
|
209
|
+
reason: PERMISSION_STOP_REASON,
|
|
210
|
+
}, "dismissed");
|
|
211
|
+
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
|
|
212
|
+
pending.resolve(decision);
|
|
213
|
+
}
|
|
197
214
|
pendingPermissionRequests.clear();
|
|
198
215
|
server.stop(true);
|
|
199
216
|
},
|
|
@@ -219,9 +236,9 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
219
236
|
}
|
|
220
237
|
clearTimeout(pending.timer);
|
|
221
238
|
pendingPermissionRequests.delete(input.approvalId);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
pending.resolve(input);
|
|
239
|
+
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, input.approvalId);
|
|
240
|
+
recordResolvedApproval(resolvedApprovals, input.approvalId);
|
|
241
|
+
pending.resolve(withDecisionReason(input, input.decision === "deny" || input.decision === "abort" ? "dismissed" : "answered"));
|
|
225
242
|
options.onStatus({
|
|
226
243
|
status: "busy",
|
|
227
244
|
reason: "provider-turn",
|
|
@@ -238,6 +255,16 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
238
255
|
};
|
|
239
256
|
}
|
|
240
257
|
|
|
258
|
+
export function recordResolvedApproval(resolvedApprovals: Set<string>, approvalId: string, limit = RESOLVED_APPROVAL_LIMIT): void {
|
|
259
|
+
resolvedApprovals.delete(approvalId);
|
|
260
|
+
resolvedApprovals.add(approvalId);
|
|
261
|
+
while (resolvedApprovals.size > limit) {
|
|
262
|
+
const oldest = resolvedApprovals.values().next().value;
|
|
263
|
+
if (!oldest) break;
|
|
264
|
+
resolvedApprovals.delete(oldest);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
241
268
|
function replyObligationSummary(obligations: ReplyObligation[]): Record<string, unknown> {
|
|
242
269
|
const obligation = obligations[0];
|
|
243
270
|
if (!obligation) return { pending: false, count: 0 };
|
|
@@ -265,10 +292,38 @@ function replyObligationStopDecision(obligations: ReplyObligation[]): Record<str
|
|
|
265
292
|
};
|
|
266
293
|
}
|
|
267
294
|
|
|
295
|
+
function recoverStoredPermissionRequests(options: ControlServerOptions): void {
|
|
296
|
+
const store = options.pendingPermissionStore;
|
|
297
|
+
if (!store) return;
|
|
298
|
+
for (const record of bestEffortListPendingPermissionRequests(store)) {
|
|
299
|
+
bestEffortDeletePendingPermissionRequest(store, record.approvalId);
|
|
300
|
+
const decisionReason: ProviderPermissionDecisionReason = {
|
|
301
|
+
kind: Date.now() >= record.hookDeadlineAt ? "timeout" : "dismissed",
|
|
302
|
+
message: Date.now() >= record.hookDeadlineAt ? PERMISSION_TIMEOUT_REASON : PERMISSION_STOP_REASON,
|
|
303
|
+
};
|
|
304
|
+
options.onPermissionAbandoned?.({ ...record, reason: decisionReason.message ?? PERMISSION_STOP_REASON, decisionReason });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function withDecisionReason(input: ProviderPermissionDecisionInput, kind: ProviderPermissionDecisionReason["kind"]): ProviderPermissionDecisionInput {
|
|
309
|
+
if (input.decisionReason) return input;
|
|
310
|
+
return {
|
|
311
|
+
...input,
|
|
312
|
+
decisionReason: {
|
|
313
|
+
kind,
|
|
314
|
+
...(input.reason ? { message: input.reason } : {}),
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function decisionReasonMessage(decision: ProviderPermissionDecisionInput, fallback: string): string {
|
|
320
|
+
return decision.reason || decision.decisionReason?.message || fallback;
|
|
321
|
+
}
|
|
322
|
+
|
|
268
323
|
async function handlePermissionRequest(
|
|
269
324
|
req: Request,
|
|
270
|
-
|
|
271
|
-
|
|
325
|
+
options: ControlServerOptions,
|
|
326
|
+
pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
|
|
272
327
|
): Promise<Response> {
|
|
273
328
|
const occurredAt = Date.now();
|
|
274
329
|
const body = await req.json().catch(() => null);
|
|
@@ -291,6 +346,15 @@ async function handlePermissionRequest(
|
|
|
291
346
|
}
|
|
292
347
|
const approvalId = crypto.randomUUID();
|
|
293
348
|
const view = claudePermissionApprovalView(approvalId, body, reasoningSettled);
|
|
349
|
+
const waitMs = options.permissionWaitMs ?? PERMISSION_WAIT_MS;
|
|
350
|
+
const hookDeadlineAt = occurredAt + waitMs;
|
|
351
|
+
bestEffortUpsertPendingPermissionRequest(options.pendingPermissionStore, {
|
|
352
|
+
approvalId,
|
|
353
|
+
view,
|
|
354
|
+
hookDeadlineAt,
|
|
355
|
+
ownerInstanceId: options.instanceId ?? "",
|
|
356
|
+
occurredAt,
|
|
357
|
+
});
|
|
294
358
|
options.onStatus({
|
|
295
359
|
status: "busy",
|
|
296
360
|
reason: "provider-turn",
|
|
@@ -308,12 +372,13 @@ async function handlePermissionRequest(
|
|
|
308
372
|
const decision = await new Promise<ProviderPermissionDecisionInput>((resolve) => {
|
|
309
373
|
const timer = setTimeout(() => {
|
|
310
374
|
pendingPermissionRequests.delete(approvalId);
|
|
311
|
-
|
|
312
|
-
|
|
375
|
+
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
|
|
376
|
+
resolve(withDecisionReason({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON }, "timeout"));
|
|
377
|
+
}, waitMs);
|
|
313
378
|
pendingPermissionRequests.set(approvalId, { resolve, timer });
|
|
314
379
|
});
|
|
315
380
|
|
|
316
|
-
if (decision.
|
|
381
|
+
if (decision.decisionReason?.kind !== "timeout" && options.onPermissionResolved) {
|
|
317
382
|
await Promise.resolve(options.onPermissionResolved(claudeResolvedPermissionPrompt(view, decision, body, occurredAt))).catch(() => {});
|
|
318
383
|
}
|
|
319
384
|
|
|
@@ -404,12 +469,12 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
|
|
|
404
469
|
};
|
|
405
470
|
}
|
|
406
471
|
return {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
472
|
+
hookSpecificOutput: {
|
|
473
|
+
hookEventName: "PreToolUse",
|
|
474
|
+
permissionDecision: "deny",
|
|
475
|
+
permissionDecisionReason: decisionReasonMessage(decision, "Dismissed from Agent Relay dashboard"),
|
|
476
|
+
},
|
|
477
|
+
};
|
|
413
478
|
}
|
|
414
479
|
const hookEventName = "PermissionRequest";
|
|
415
480
|
// AskUserQuestion can be gated by the PermissionRequest hook instead of PreToolUse
|
|
@@ -443,12 +508,12 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
|
|
|
443
508
|
return {
|
|
444
509
|
hookSpecificOutput: {
|
|
445
510
|
hookEventName,
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
511
|
+
decision: {
|
|
512
|
+
behavior: "deny",
|
|
513
|
+
message: decisionReasonMessage(decision, "Denied from Agent Relay dashboard"),
|
|
514
|
+
interrupt: decision.decision === "abort",
|
|
515
|
+
},
|
|
516
|
+
},
|
|
452
517
|
};
|
|
453
518
|
}
|
|
454
519
|
|
|
@@ -474,6 +539,7 @@ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: Provi
|
|
|
474
539
|
approvalId: decision.approvalId,
|
|
475
540
|
decision: decision.decision,
|
|
476
541
|
decisionLabel,
|
|
542
|
+
decisionReason: decision.decisionReason ?? { kind: decision.decision === "deny" || decision.decision === "abort" ? "dismissed" : "answered", ...(decision.reason ? { message: decision.reason } : {}) },
|
|
477
543
|
occurredAt,
|
|
478
544
|
resolvedAt: Date.now(),
|
|
479
545
|
...(questions ? { questions } : {}),
|
package/src/mcp-outbox.ts
CHANGED
|
@@ -68,8 +68,15 @@ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Pro
|
|
|
68
68
|
logger.warn("outbox", `dropping event with unknown kind: ${record.kind}`);
|
|
69
69
|
} catch (error) {
|
|
70
70
|
if (isHttpStatusError(error, 409)) {
|
|
71
|
+
const reason = errMessage(error);
|
|
71
72
|
if (record.kind === "session-message-batch") {
|
|
72
|
-
|
|
73
|
+
const messages = sessionBatchMessages(record);
|
|
74
|
+
logger.error("outbox", `relay reported duplicate session batch; acking row seq=${record.seq} batchKey=${record.idempotencyKey} itemKeys=${sessionBatchItemKeys(record).join(",")} error=${reason}`);
|
|
75
|
+
await emitMirrorDropNotice(input, messages, messages.length, `duplicate idempotency key: ${reason}`, "duplicate-batch");
|
|
76
|
+
} else if (record.kind === "session-message") {
|
|
77
|
+
const message = record.payload as SendMessageInput;
|
|
78
|
+
logger.error("outbox", `relay reported duplicate session message; acking row seq=${record.seq} key=${record.idempotencyKey} error=${reason}`);
|
|
79
|
+
await emitMirrorDropNotice(input, [message], 1, `duplicate idempotency key: ${reason}`, "duplicate-message");
|
|
73
80
|
}
|
|
74
81
|
return;
|
|
75
82
|
}
|
|
@@ -79,14 +86,20 @@ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Pro
|
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
async function deliverSessionBatchItems(input: RunnerOutboxDelivery, messages: SendMessageInput[]): Promise<void> {
|
|
89
|
+
const dropped: Array<{ message: SendMessageInput; reason: string }> = [];
|
|
82
90
|
for (const message of messages) {
|
|
83
91
|
try {
|
|
84
92
|
await input.http.sendMessage(message);
|
|
85
93
|
} catch (error) {
|
|
86
94
|
if (!isDeterministicValidationError(error)) throw error;
|
|
87
|
-
|
|
95
|
+
const reason = errMessage(error);
|
|
96
|
+
logger.error("outbox", `dropping invalid session batch item key=${message.idempotencyKey ?? "(none)"} error=${reason}`);
|
|
97
|
+
dropped.push({ message, reason });
|
|
88
98
|
}
|
|
89
99
|
}
|
|
100
|
+
for (const item of dropped) {
|
|
101
|
+
await emitMirrorDropNotice(input, [item.message], 1, item.reason, "invalid-item");
|
|
102
|
+
}
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
function isDeterministicValidationError(error: unknown): boolean {
|
|
@@ -95,10 +108,47 @@ function isDeterministicValidationError(error: unknown): boolean {
|
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
function sessionBatchItemKeys(record: OutboxRecord): string[] {
|
|
98
|
-
const messages =
|
|
111
|
+
const messages = sessionBatchMessages(record);
|
|
112
|
+
return messages.map((message, index) => message.idempotencyKey ?? `${record.idempotencyKey}:${index}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sessionBatchMessages(record: OutboxRecord): SendMessageInput[] {
|
|
116
|
+
return Array.isArray((record.payload as { messages?: unknown }).messages)
|
|
99
117
|
? (record.payload as { messages: SendMessageInput[] }).messages
|
|
100
118
|
: [];
|
|
101
|
-
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function emitMirrorDropNotice(input: RunnerOutboxDelivery, messages: SendMessageInput[], count: number, reason: string, suffix: string): Promise<void> {
|
|
122
|
+
if (count <= 0) return;
|
|
123
|
+
const anchor = messages[0];
|
|
124
|
+
const from = typeof anchor?.from === "string" && anchor.from ? anchor.from : "system";
|
|
125
|
+
const to = typeof anchor?.to === "string" && anchor.to ? anchor.to : "user";
|
|
126
|
+
const occurredAt = typeof anchor?.occurredAt === "number" ? anchor.occurredAt : input.record.occurredAt;
|
|
127
|
+
const body = `${count} steps could not be mirrored: ${reason}`;
|
|
128
|
+
const idempotencyKey = `session-mirror-drop:${input.record.idempotencyKey}:${suffix}:${shortHash(body)}`;
|
|
129
|
+
logger.error("outbox", `session mirror drop notice seq=${input.record.seq} count=${count} reason=${reason} noticeKey=${idempotencyKey}`);
|
|
130
|
+
try {
|
|
131
|
+
await input.http.sendMessage({
|
|
132
|
+
from,
|
|
133
|
+
to,
|
|
134
|
+
kind: "session",
|
|
135
|
+
body,
|
|
136
|
+
idempotencyKey,
|
|
137
|
+
occurredAt,
|
|
138
|
+
payload: { session: { type: "notice", origin: "provider", label: "mirror-drop", stepId: idempotencyKey } },
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
logger.error("outbox", `failed to emit session mirror drop notice seq=${input.record.seq}; acking original row anyway: ${errMessage(error)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function shortHash(value: string): string {
|
|
146
|
+
let h = 0x811c9dc5;
|
|
147
|
+
for (let i = 0; i < value.length; i++) {
|
|
148
|
+
h ^= value.charCodeAt(i);
|
|
149
|
+
h = Math.imul(h, 0x01000193);
|
|
150
|
+
}
|
|
151
|
+
return (h >>> 0).toString(36);
|
|
102
152
|
}
|
|
103
153
|
|
|
104
154
|
export async function deliverBufferedMcpCall(input: {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { InteractivePrompt } from "agent-relay-sdk";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
7
|
+
import { runnerOutboxDirFromEnv } from "./config";
|
|
8
|
+
import { logger } from "./logger";
|
|
9
|
+
import { errMessage } from "agent-relay-sdk";
|
|
10
|
+
|
|
11
|
+
export interface PendingPermissionRequestRecord {
|
|
12
|
+
approvalId: string;
|
|
13
|
+
view: InteractivePrompt;
|
|
14
|
+
hookDeadlineAt: number;
|
|
15
|
+
ownerInstanceId: string;
|
|
16
|
+
occurredAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PendingPermissionRow {
|
|
20
|
+
approval_id: string;
|
|
21
|
+
view: string;
|
|
22
|
+
hook_deadline_at: number;
|
|
23
|
+
owner_instance_id: string;
|
|
24
|
+
occurred_at: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class PendingPermissionStore {
|
|
28
|
+
private db?: Database;
|
|
29
|
+
readonly path: string;
|
|
30
|
+
|
|
31
|
+
constructor(options: { agentId: string; dir?: string }) {
|
|
32
|
+
const dir = options.dir ?? runnerOutboxDirFromEnv() ?? join(tmpdir(), "agent-relay-outbox");
|
|
33
|
+
this.path = options.dir === ":memory:" ? ":memory:" : join(dir, `pending-permissions-${safeName(options.agentId)}.sqlite`);
|
|
34
|
+
try {
|
|
35
|
+
if (this.path !== ":memory:") mkdirSync(dirname(this.path), { recursive: true });
|
|
36
|
+
const db = new Database(this.path, { create: true });
|
|
37
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
38
|
+
db.exec("PRAGMA busy_timeout = 2000");
|
|
39
|
+
db.exec(`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS pending_permission_requests (
|
|
41
|
+
approval_id TEXT PRIMARY KEY,
|
|
42
|
+
view TEXT NOT NULL,
|
|
43
|
+
hook_deadline_at INTEGER NOT NULL,
|
|
44
|
+
owner_instance_id TEXT NOT NULL,
|
|
45
|
+
occurred_at INTEGER NOT NULL,
|
|
46
|
+
created_at INTEGER NOT NULL
|
|
47
|
+
)
|
|
48
|
+
`);
|
|
49
|
+
this.db = db;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
logger.error("pending-permission-store", `disabled after open failed path=${this.path}: ${errMessage(error)}`);
|
|
52
|
+
this.db = undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
upsert(record: PendingPermissionRequestRecord): void {
|
|
57
|
+
if (!this.db) return;
|
|
58
|
+
try {
|
|
59
|
+
this.db.query(`
|
|
60
|
+
INSERT INTO pending_permission_requests (approval_id, view, hook_deadline_at, owner_instance_id, occurred_at, created_at)
|
|
61
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
62
|
+
ON CONFLICT(approval_id) DO UPDATE SET
|
|
63
|
+
view = excluded.view,
|
|
64
|
+
hook_deadline_at = excluded.hook_deadline_at,
|
|
65
|
+
owner_instance_id = excluded.owner_instance_id,
|
|
66
|
+
occurred_at = excluded.occurred_at
|
|
67
|
+
`).run(record.approvalId, JSON.stringify(record.view), record.hookDeadlineAt, record.ownerInstanceId, record.occurredAt, Date.now());
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logger.error("pending-permission-store", `upsert failed approvalId=${record.approvalId}; continuing in-memory only: ${errMessage(error)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
delete(approvalId: string): void {
|
|
74
|
+
if (!this.db) return;
|
|
75
|
+
try {
|
|
76
|
+
this.db.query("DELETE FROM pending_permission_requests WHERE approval_id = ?").run(approvalId);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
logger.error("pending-permission-store", `delete failed approvalId=${approvalId}; continuing in-memory only: ${errMessage(error)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
list(): PendingPermissionRequestRecord[] {
|
|
83
|
+
if (!this.db) return [];
|
|
84
|
+
try {
|
|
85
|
+
const rows = this.db.query("SELECT * FROM pending_permission_requests ORDER BY occurred_at ASC").all() as PendingPermissionRow[];
|
|
86
|
+
return rows.map((row) => ({
|
|
87
|
+
approvalId: row.approval_id,
|
|
88
|
+
view: safeParseView(row.view),
|
|
89
|
+
hookDeadlineAt: row.hook_deadline_at,
|
|
90
|
+
ownerInstanceId: row.owner_instance_id,
|
|
91
|
+
occurredAt: row.occurred_at,
|
|
92
|
+
}));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logger.error("pending-permission-store", `list failed; continuing without persisted approvals: ${errMessage(error)}`);
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
close(): void {
|
|
100
|
+
try { this.db?.close(); } catch { /* already closed */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function bestEffortListPendingPermissionRequests(store: PendingPermissionStore | undefined): PendingPermissionRequestRecord[] {
|
|
105
|
+
try {
|
|
106
|
+
return store?.list() ?? [];
|
|
107
|
+
} catch (error) {
|
|
108
|
+
logger.error("pending-permission-store", `list failed; continuing without persisted approvals: ${errMessage(error)}`);
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function bestEffortUpsertPendingPermissionRequest(store: PendingPermissionStore | undefined, record: PendingPermissionRequestRecord): void {
|
|
114
|
+
try {
|
|
115
|
+
store?.upsert(record);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
logger.error("pending-permission-store", `upsert failed approvalId=${record.approvalId}; continuing in-memory only: ${errMessage(error)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function bestEffortDeletePendingPermissionRequest(store: PendingPermissionStore | undefined, approvalId: string): void {
|
|
122
|
+
try {
|
|
123
|
+
store?.delete(approvalId);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logger.error("pending-permission-store", `delete failed approvalId=${approvalId}; continuing in-memory only: ${errMessage(error)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function safeName(value: string): string {
|
|
130
|
+
return sanitizeFsName(value, { replacement: "_", maxLen: 180, fallback: "agent" });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function safeParseView(json: string): InteractivePrompt {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(json);
|
|
136
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as InteractivePrompt;
|
|
137
|
+
} catch {
|
|
138
|
+
// Fall through to a valid placeholder so recovery can still emit a notice and clear the row.
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
id: "unknown",
|
|
142
|
+
kind: "tool",
|
|
143
|
+
title: "Recovered permission request",
|
|
144
|
+
body: "The original permission request could not be decoded.",
|
|
145
|
+
choices: [],
|
|
146
|
+
reasoningSettled: false,
|
|
147
|
+
};
|
|
148
|
+
}
|
package/src/runner-core.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { deliverBufferedMcpCall as deliverBufferedMcpOutboxCall, deliverRunnerOu
|
|
|
29
29
|
import { RunnerInsights } from "./runner-insights";
|
|
30
30
|
import { BusyReconciler } from "./busy-reconciler";
|
|
31
31
|
import { publishCapturedResponse } from "./response-capture-report";
|
|
32
|
+
import { PendingPermissionStore, type PendingPermissionRequestRecord } from "./pending-permission-store";
|
|
32
33
|
import { runnerLaunchInjectionSessionEvents, type RunnerRelayInjectionEvent } from "./relay-injection-events";
|
|
33
34
|
import { providerTerminalSession, providerTerminalSocket } from "./process-meta";
|
|
34
35
|
import { capsFromEnv, logLevelFromEnv, mcpProxyEnabledFromEnv, orchestratorBaseDirFromEnv, orchestratorUrlFromEnv, registrationTimeoutMsFromEnv, runnerInfoFileFromEnv, runnerLogFileFromEnv, runnerOutboxDirWithInfoFallback, sessionDebugEnabled, tagsFromEnv, workspaceModeFromEnv } from "./config";
|
|
@@ -191,6 +192,7 @@ export class AgentRunner {
|
|
|
191
192
|
// their own FIFO queue stops one transient trace failure from head-of-line blocking real-message
|
|
192
193
|
// delivery (and vice versa); its backoff is capped low since a stale live-mirror frame is cheap.
|
|
193
194
|
private readonly sessionOutbox: Outbox;
|
|
195
|
+
private readonly pendingPermissionStore: PendingPermissionStore;
|
|
194
196
|
private sessionBatchSeq = 0;
|
|
195
197
|
private readonly insights: RunnerInsights;
|
|
196
198
|
private readonly busyReconciler: BusyReconciler;
|
|
@@ -333,6 +335,7 @@ export class AgentRunner {
|
|
|
333
335
|
maxBackoffMs: 5_000,
|
|
334
336
|
maxAttempts: 6,
|
|
335
337
|
});
|
|
338
|
+
this.pendingPermissionStore = new PendingPermissionStore({ agentId: this.agentId, dir: outboxDir });
|
|
336
339
|
this.insights = new RunnerInsights({
|
|
337
340
|
agentId: this.agentId,
|
|
338
341
|
cwd: options.cwd,
|
|
@@ -436,9 +439,12 @@ export class AgentRunner {
|
|
|
436
439
|
// round-trip. The snapshot is kept warm by the background refresh below (#196).
|
|
437
440
|
onReplyObligations: () => Promise.resolve(this.obligationCache.get()),
|
|
438
441
|
onSessionTurn: (input) => this.publishSessionTurn(input), onPermissionResolved: (input) => this.publishSessionEvent({ from: this.agentId, to: "user", body: input.body, occurredAt: input.occurredAt, session: { type: "prompt-resolution", origin: "provider", label: input.decisionLabel, ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}), stepId: `prompt-resolution:${input.approvalId}`, promptResolution: input } }),
|
|
442
|
+
onPermissionAbandoned: (input) => this.publishPermissionAbandonedNotice(input),
|
|
439
443
|
onUserPrompt: (input) => this.handleUserPrompt(input),
|
|
440
444
|
onSessionBoundary: (input) => this.handleSessionBoundary(input),
|
|
441
445
|
onHookFatal: (report) => this.reportHookFatal(report),
|
|
446
|
+
instanceId: this.options.instanceId,
|
|
447
|
+
pendingPermissionStore: this.pendingPermissionStore,
|
|
442
448
|
});
|
|
443
449
|
this.startMcpProxy();
|
|
444
450
|
this.writeRunnerInfoFile();
|
|
@@ -507,7 +513,7 @@ export class AgentRunner {
|
|
|
507
513
|
this.busyReconciler.disarm();
|
|
508
514
|
this.stopReasoningTail();
|
|
509
515
|
this.obligationCache.stop();
|
|
510
|
-
this.outbox.close(); this.sessionOutbox.close();
|
|
516
|
+
this.outbox.close(); this.sessionOutbox.close(); this.pendingPermissionStore.close();
|
|
511
517
|
this.proxy?.stop();
|
|
512
518
|
this.control?.stop();
|
|
513
519
|
await this.bus.close();
|
|
@@ -2037,6 +2043,23 @@ export class AgentRunner {
|
|
|
2037
2043
|
});
|
|
2038
2044
|
}
|
|
2039
2045
|
|
|
2046
|
+
private publishPermissionAbandonedNotice(input: PendingPermissionRequestRecord & { reason: string }): void {
|
|
2047
|
+
const title = input.view.title || "permission request";
|
|
2048
|
+
this.publishSessionEvent({
|
|
2049
|
+
from: this.agentId,
|
|
2050
|
+
to: "user",
|
|
2051
|
+
body: `Permission request denied after runner restart: ${title}. ${input.reason}`,
|
|
2052
|
+
occurredAt: Date.now(),
|
|
2053
|
+
session: {
|
|
2054
|
+
type: "notice",
|
|
2055
|
+
origin: "provider",
|
|
2056
|
+
label: "permission-denied",
|
|
2057
|
+
stepId: `permission-abandoned:${input.approvalId}`,
|
|
2058
|
+
...(this.currentTurnId ? { turnId: this.currentTurnId } : {}),
|
|
2059
|
+
},
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2040
2063
|
// #286: a discreet, durable chat marker when a usage/rate-limit hold begins, via
|
|
2041
2064
|
// the same session-mirror lane as the compaction notice. Outbound session event
|
|
2042
2065
|
// (NOT an inbound message) so it shows in the dashboard chat WITHOUT waking a turn
|