agent-relay-server 0.121.4 → 0.122.0
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/docs/openapi.json +1 -1
- package/package.json +5 -4
- package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
- package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
- package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
- package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
- package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
- package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
- package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
- package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
- package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
- package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
- package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
- package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
- package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
- package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
- package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
- package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
- package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
- package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
- package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
- package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
- package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
- package/public/assets/display-CJzSoTMq.js +3 -0
- package/public/assets/display-CJzSoTMq.js.map +1 -0
- package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
- package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
- package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
- package/public/assets/index-D0xMQQb_.js +25 -0
- package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
- package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
- package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
- package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
- package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
- package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
- package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
- package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
- package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
- package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
- package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
- package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
- package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
- package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
- package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
- package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
- package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
- package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
- package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
- package/public/assets/projects-Dzdf-gkK.js +2 -0
- package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
- package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
- package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
- package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
- package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
- package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
- package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
- package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
- package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
- package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
- package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
- package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
- package/public/assets/store-BPrVuKiv.js.map +1 -0
- package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
- package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
- package/public/assets/teams-DWv6UwFV.js +2 -0
- package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
- package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
- package/public/assets/types-BeJUSfDj.js.map +1 -0
- package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
- package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
- package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
- package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
- package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
- package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
- package/runner/src/adapter.ts +53 -18
- package/runner/src/config.ts +1 -4
- package/src/agent-card-projection.ts +5 -1
- package/src/agent-command-activity.ts +39 -0
- package/src/automations/reconcile.ts +2 -2
- package/src/bus.ts +6 -4
- package/src/cli/workspace.ts +3 -1
- package/src/config.ts +10 -1
- package/src/db/agents.ts +138 -32
- package/src/db/message-reads.ts +50 -0
- package/src/db/messages.ts +48 -9
- package/src/db/migrations.ts +54 -0
- package/src/db/provider-quota-burn.ts +96 -67
- package/src/db/provider-quotas.ts +29 -7
- package/src/db/scheduled-timers.ts +21 -0
- package/src/db/schema.ts +1 -0
- package/src/db/tasks.ts +33 -3
- package/src/db/work-evidence.ts +10 -7
- package/src/event-loop-lag.ts +15 -0
- package/src/gate-resolver.ts +24 -1
- package/src/maintenance/jobs.ts +23 -6
- package/src/managed-policy.ts +35 -5
- package/src/mcp-plan-tools.ts +13 -1
- package/src/project-instructions.ts +36 -2
- package/src/quota-advisory.ts +6 -4
- package/src/quota-band-transition.ts +23 -14
- package/src/routes/agent-sessions.ts +39 -7
- package/src/routes/agents.ts +3 -2
- package/src/routes/tasks.ts +16 -4
- package/src/routes/workspaces.ts +35 -10
- package/src/services/plan-graph.ts +27 -9
- package/src/services/register-agent.ts +12 -1
- package/src/services/send-message.ts +43 -15
- package/src/services/spawn-agent.ts +3 -0
- package/src/spawn-command.ts +8 -0
- package/src/spawn-targets.ts +19 -34
- package/src/sse.ts +25 -3
- package/src/workspace-merge.ts +25 -5
- package/src/workspace-orphans.ts +13 -6
- package/public/assets/display-exQDWum3.js +0 -3
- package/public/assets/display-exQDWum3.js.map +0 -1
- package/public/assets/index-D9OogaB5.js +0 -25
- package/public/assets/projects-DdPNPFJI.js +0 -2
- package/public/assets/prompt-settings-BpZse7-X.js +0 -2
- package/public/assets/store-DKTLubBT.js.map +0 -1
- package/public/assets/teams-B8f2pGJe.js +0 -2
- package/public/assets/types-uVOXgvMT.js.map +0 -1
package/src/db/messages.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
isRecord,
|
|
5
|
+
isTurnFinalSession,
|
|
6
|
+
stringValue,
|
|
7
|
+
isMechanicalMessageKind,
|
|
8
|
+
isReportVerbosity,
|
|
9
|
+
type MessagePayload,
|
|
10
|
+
type MessageWorkFinality,
|
|
11
|
+
type ReportVerbosity,
|
|
12
|
+
} from "agent-relay-sdk";
|
|
4
13
|
import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSignalKey } from "../lifecycle-signal-registry";
|
|
5
14
|
import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
|
|
6
15
|
import { parseJson } from "../utils";
|
|
@@ -108,6 +117,19 @@ function sessionStepId(payload: Record<string, unknown> | undefined): string | u
|
|
|
108
117
|
return stringValue(session?.stepId);
|
|
109
118
|
}
|
|
110
119
|
|
|
120
|
+
function normalizeMessagePayload(payload: MessagePayload | undefined): MessagePayload {
|
|
121
|
+
const original = payload ?? {};
|
|
122
|
+
let next: MessagePayload = original;
|
|
123
|
+
for (const key of ["session", "responseCapture"] as const) {
|
|
124
|
+
const value = next[key];
|
|
125
|
+
if (!isTurnFinalSession(value)) continue;
|
|
126
|
+
if (value.turnFinal === true && value.final === true) continue;
|
|
127
|
+
if (next === original) next = { ...original };
|
|
128
|
+
next[key] = { ...value, turnFinal: true, final: true };
|
|
129
|
+
}
|
|
130
|
+
return next;
|
|
131
|
+
}
|
|
132
|
+
|
|
111
133
|
/**
|
|
112
134
|
* Codex session-mirror upsert (#parity): a provider step that emits more than once — a
|
|
113
135
|
* tool's running→completed lifecycle, a reasoning/response row that streams — carries a
|
|
@@ -200,7 +222,7 @@ function responseCaptureMeta(input: Message | SendMessageInput): Record<string,
|
|
|
200
222
|
}
|
|
201
223
|
|
|
202
224
|
function isFinalSessionResponse(session: Record<string, unknown>): boolean {
|
|
203
|
-
return session.type === "response" && session
|
|
225
|
+
return session.type === "response" && isTurnFinalSession(session);
|
|
204
226
|
}
|
|
205
227
|
|
|
206
228
|
function childStreamForSession(session: Record<string, unknown>): ChildStream | null {
|
|
@@ -373,14 +395,19 @@ function childIsBusy(child: AgentCard): boolean {
|
|
|
373
395
|
// narration flood by the legacy tail budget, never slicing the block we keep.
|
|
374
396
|
const REPORT_UP_DISTILL_THRESHOLD = 800;
|
|
375
397
|
const REPORT_UP_TAIL_MAX = 800;
|
|
376
|
-
|
|
398
|
+
// #1058 — distillation only ever runs on a FINAL turn (promoteLineageCapturedResponse gates on
|
|
399
|
+
// isFinalSessionResponse), so the old "[…mid-work narration trimmed…]" label was a lie: it read as
|
|
400
|
+
// if the real report were still coming when THIS is the deliverable. The elision marks that only the
|
|
401
|
+
// LEADING narration was dropped, states plainly that this is the final report, and points at the
|
|
402
|
+
// untrimmed turn via relay_get_message so the coordinator never has to guess whether the child is done.
|
|
403
|
+
const reportUpElision = (messageId: number): string => `[…leading narration trimmed by report-up — this IS the final report; full turn: relay_get_message #${messageId}…]`;
|
|
377
404
|
const REPORT_UP_HEADING = /^#{1,6}\s/m;
|
|
378
405
|
|
|
379
|
-
function distillLineageReportBody(body: string): string {
|
|
406
|
+
function distillLineageReportBody(body: string, messageId: number): string {
|
|
380
407
|
const trimmed = body.trim();
|
|
381
408
|
if (trimmed.length <= REPORT_UP_DISTILL_THRESHOLD) return body;
|
|
382
409
|
const heading = REPORT_UP_HEADING.exec(trimmed);
|
|
383
|
-
if (heading) return heading.index === 0 ? body : `${
|
|
410
|
+
if (heading) return heading.index === 0 ? body : `${reportUpElision(messageId)}\n\n${trimmed.slice(heading.index).trim()}`;
|
|
384
411
|
const blocks = trimmed.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
|
|
385
412
|
const kept: string[] = [];
|
|
386
413
|
let size = 0;
|
|
@@ -394,7 +421,7 @@ function distillLineageReportBody(body: string): string {
|
|
|
394
421
|
// A single oversized trailing block (unsegmented narration) — hard-cap to its own tail.
|
|
395
422
|
if (tail.length > REPORT_UP_TAIL_MAX) tail = tail.slice(tail.length - REPORT_UP_TAIL_MAX).replace(/^\S*\s/, "").trimStart();
|
|
396
423
|
if (tail.length >= trimmed.length) return body; // nothing meaningful trimmed
|
|
397
|
-
return `${
|
|
424
|
+
return `${reportUpElision(messageId)}\n\n${tail}`;
|
|
398
425
|
}
|
|
399
426
|
|
|
400
427
|
// #1037 — a quiet child's turn-final response that arrives while the child is still busy is a
|
|
@@ -451,7 +478,10 @@ export function listRecentFinalResponseCaptures(sinceMs: number, limit = 1000):
|
|
|
451
478
|
WHERE kind = 'session'
|
|
452
479
|
AND created_at >= ?
|
|
453
480
|
AND json_extract(payload, '$.session.type') = 'response'
|
|
454
|
-
AND
|
|
481
|
+
AND (
|
|
482
|
+
json_extract(payload, '$.session.turnFinal') = 1
|
|
483
|
+
OR json_extract(payload, '$.session.final') = 1
|
|
484
|
+
)
|
|
455
485
|
GROUP BY from_agent
|
|
456
486
|
) latest ON latest.latest_id = m.id
|
|
457
487
|
ORDER BY m.id DESC
|
|
@@ -545,7 +575,7 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
545
575
|
kind: "chat",
|
|
546
576
|
// #1037 — distill the turn-final report-up to a concise result; verbose child-stream captures
|
|
547
577
|
// (an explicit subscriber that asked for the raw stream) are forwarded untouched.
|
|
548
|
-
body: stream === "responses" ? distillLineageReportBody(message.body) : message.body,
|
|
578
|
+
body: stream === "responses" ? distillLineageReportBody(message.body, message.id) : message.body,
|
|
549
579
|
replyExpected: false,
|
|
550
580
|
idempotencyKey,
|
|
551
581
|
payload: stream === "responses"
|
|
@@ -560,6 +590,14 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
560
590
|
return result;
|
|
561
591
|
}
|
|
562
592
|
|
|
593
|
+
export function setMessageWorkFinality(messageId: number, finality: MessageWorkFinality): Message | null {
|
|
594
|
+
const message = getMessage(messageId);
|
|
595
|
+
if (!message) return null;
|
|
596
|
+
const payload: MessagePayload = { ...(message.payload ?? {}), finality };
|
|
597
|
+
getDb().query("UPDATE messages SET payload = ? WHERE id = ?").run(JSON.stringify(payload), messageId);
|
|
598
|
+
return getMessage(messageId);
|
|
599
|
+
}
|
|
600
|
+
|
|
563
601
|
// Event time may be queued-then-backfilled, so it can legitimately be older than the
|
|
564
602
|
// receive time — but it must be a sane epoch-ms value. Returns null (column stays NULL, so
|
|
565
603
|
// readers fall back to created_at) for absent/invalid values or anything more than a minute
|
|
@@ -586,7 +624,8 @@ export function sendMessagesBatchWithResult(inputs: SendMessageInput[]): SendMes
|
|
|
586
624
|
|
|
587
625
|
function sendMessageWithResultInternal(input: SendMessageInput, wrapTransaction: boolean): SendMessageResult {
|
|
588
626
|
const now = Date.now();
|
|
589
|
-
const payload = input.payload
|
|
627
|
+
const payload = normalizeMessagePayload(input.payload);
|
|
628
|
+
input.payload = payload;
|
|
590
629
|
const attachmentRefs = cleanAttachmentRefs(payload);
|
|
591
630
|
|
|
592
631
|
if (!getAgent(input.from)) {
|
package/src/db/migrations.ts
CHANGED
|
@@ -148,6 +148,54 @@ function backfillProjectRootRemoteUrls(): void {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
function dualStampLegacyTurnFinality(): void {
|
|
152
|
+
const messagesTable = getDb().query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get();
|
|
153
|
+
if (!messagesTable) return;
|
|
154
|
+
const messageCols = getDb().query("PRAGMA table_info(messages)").all() as Array<{ name: string }>;
|
|
155
|
+
const messageColNames = new Set(messageCols.map((col) => col.name));
|
|
156
|
+
if (!messageColNames.has("payload") || !messageColNames.has("created_at")) return;
|
|
157
|
+
getDb().run(`
|
|
158
|
+
UPDATE messages
|
|
159
|
+
SET payload = json_set(
|
|
160
|
+
payload,
|
|
161
|
+
'$.finality',
|
|
162
|
+
json_object(
|
|
163
|
+
'windowId', 'legacy:' || id,
|
|
164
|
+
'source', 'edge-fallback',
|
|
165
|
+
'groupOrder', 0,
|
|
166
|
+
'markedAt', coalesce(occurred_at, created_at)
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
WHERE kind = 'session'
|
|
170
|
+
AND json_extract(payload, '$.session.final') = 1
|
|
171
|
+
AND json_type(payload, '$.session.turnFinal') IS NULL
|
|
172
|
+
AND json_type(payload, '$.finality') IS NULL
|
|
173
|
+
`);
|
|
174
|
+
getDb().run(`
|
|
175
|
+
UPDATE messages
|
|
176
|
+
SET payload = json_set(payload, '$.session.turnFinal', json('true'))
|
|
177
|
+
WHERE json_extract(payload, '$.session.final') = 1
|
|
178
|
+
AND json_type(payload, '$.session.turnFinal') IS NULL
|
|
179
|
+
`);
|
|
180
|
+
getDb().run(`
|
|
181
|
+
UPDATE messages
|
|
182
|
+
SET payload = json_set(payload, '$.session.final', json('true'))
|
|
183
|
+
WHERE json_extract(payload, '$.session.turnFinal') = 1
|
|
184
|
+
AND json_type(payload, '$.session.final') IS NULL
|
|
185
|
+
`);
|
|
186
|
+
getDb().run(`
|
|
187
|
+
UPDATE messages
|
|
188
|
+
SET payload = json_set(payload, '$.responseCapture.turnFinal', json('true'))
|
|
189
|
+
WHERE json_extract(payload, '$.responseCapture.final') = 1
|
|
190
|
+
AND json_type(payload, '$.responseCapture.turnFinal') IS NULL
|
|
191
|
+
`);
|
|
192
|
+
getDb().run(`
|
|
193
|
+
UPDATE messages
|
|
194
|
+
SET payload = json_set(payload, '$.responseCapture.final', json('true'))
|
|
195
|
+
WHERE json_extract(payload, '$.responseCapture.turnFinal') = 1
|
|
196
|
+
AND json_type(payload, '$.responseCapture.final') IS NULL
|
|
197
|
+
`);
|
|
198
|
+
}
|
|
151
199
|
|
|
152
200
|
// Incremental schema migrations + one-shot backfills, applied after the base DDL
|
|
153
201
|
// in initDb(). Ordering matters: each block guards on the current column/table
|
|
@@ -280,6 +328,7 @@ export function applyMigrations(): void {
|
|
|
280
328
|
if (!colNames.includes("occurred_at")) {
|
|
281
329
|
getDb().run("ALTER TABLE messages ADD COLUMN occurred_at INTEGER");
|
|
282
330
|
}
|
|
331
|
+
dualStampLegacyTurnFinality();
|
|
283
332
|
getDb().query(
|
|
284
333
|
"UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
|
|
285
334
|
).run(Date.now(), CLAIM_LEASE_MS);
|
|
@@ -822,6 +871,11 @@ export function applyMigrations(): void {
|
|
|
822
871
|
if (!providerQuotaCols.includes("quota_advisory_state")) {
|
|
823
872
|
getDb().run("ALTER TABLE provider_quotas ADD COLUMN quota_advisory_state TEXT NOT NULL DEFAULT '{}'");
|
|
824
873
|
}
|
|
874
|
+
// #1074 — persist quota-band hysteresis state so a relay restart doesn't re-fire already-sent
|
|
875
|
+
// band-degradation notifications to the whole fleet.
|
|
876
|
+
if (!providerQuotaCols.includes("quota_band_state")) {
|
|
877
|
+
getDb().run("ALTER TABLE provider_quotas ADD COLUMN quota_band_state TEXT NOT NULL DEFAULT '{}'");
|
|
878
|
+
}
|
|
825
879
|
getDb().run(`
|
|
826
880
|
INSERT INTO provider_quotas (
|
|
827
881
|
provider, account_key, quota_state, source_agent_ids, last_updated_at,
|
|
@@ -1,28 +1,38 @@
|
|
|
1
|
-
import { DEFAULT_QUOTA_TARGET_UTILIZATION, quotaPaceMetrics, quotaWindowMetrics } from "agent-relay-sdk/provider-quota";
|
|
1
|
+
import { DEFAULT_QUOTA_TARGET_UTILIZATION, quotaPaceMetrics, quotaTrendProjection, quotaWindowMetrics } from "agent-relay-sdk/provider-quota";
|
|
2
2
|
import type { ProviderQuotaBurn, ProviderQuotaBurnRole, ProviderQuotaBurnWindow, ProviderQuotaSnapshot, QuotaState } from "../types";
|
|
3
3
|
|
|
4
4
|
const QUOTA_BURN_MIN_SAMPLE_SPAN_MS = 10 * 60_000;
|
|
5
5
|
|
|
6
|
-
// #760 — nominal window length per role,
|
|
6
|
+
// #760 — nominal window length per role. windowStartAt = resetAt − this, and it scales the
|
|
7
|
+
// evidence-confidence horizon below.
|
|
7
8
|
const WINDOW_NOMINAL_MS: Record<ProviderQuotaBurnRole, number> = {
|
|
8
9
|
"5h": 5 * 3_600_000,
|
|
9
10
|
"7d": 7 * 24 * 3_600_000,
|
|
10
11
|
};
|
|
11
12
|
|
|
12
|
-
// #
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
// #1072 — SLOW EWMA time constant (dt-correct). This is the sustained-trend requirement made
|
|
14
|
+
// mechanical: a 15-min onset burst barely moves a 24h-τ average, so a transient spawn-burst spike
|
|
15
|
+
// cannot flip the band — only a trend that holds for hours does. Drives the band projection.
|
|
16
|
+
const EWMA_TAU_SLOW_MS: Record<ProviderQuotaBurnRole, number> = {
|
|
17
|
+
"5h": 2 * 3_600_000,
|
|
18
|
+
"7d": 24 * 3_600_000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// #1072 — continuous trend confidence reaches 1 once the observed span covers this horizon (the
|
|
22
|
+
// slow-EWMA τ). A fresh window (tiny observed span) ⇒ ~0 confidence ⇒ the projected (not the
|
|
23
|
+
// measured) overshoot cannot hard-escalate the band; by the time the span covers τ, a sustained
|
|
24
|
+
// trend is real. Absorbs the #1073 confidence-gate stopgap into the model.
|
|
25
|
+
const CONFIDENCE_FULL_EVIDENCE_MS: Record<ProviderQuotaBurnRole, number> = {
|
|
26
|
+
"5h": 2 * 3_600_000,
|
|
27
|
+
"7d": 24 * 3_600_000,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// #798 — idle collapse, retained for STABILITY (not for the penalty). Once utilization has held
|
|
31
|
+
// flat for this long, the residual slow-EWMA rate is a stale-burst tail: the slow average decays
|
|
32
|
+
// but never reaches exactly 0 in finite time, so a small rate × a multi-day horizon would still
|
|
33
|
+
// creep the projection tick-to-tick (the band walk from #798). Pinning the rate to 0 once idle
|
|
34
|
+
// this long fixes the projection at current utilization so the band is flat across ticks. The
|
|
35
|
+
// threshold is absolute (a 5h window resets long before reaching it, so this only affects 7d).
|
|
26
36
|
const IDLE_FORWARD_COLLAPSE_MS = 6 * 3_600_000;
|
|
27
37
|
const IDLE_UTILIZATION_EPSILON = 1e-9;
|
|
28
38
|
|
|
@@ -63,32 +73,38 @@ function lowConfidenceBurnWindow(role: ProviderQuotaBurnRole, window: QuotaWindo
|
|
|
63
73
|
projectedUtilAtReset: null,
|
|
64
74
|
slack: null,
|
|
65
75
|
target: null,
|
|
76
|
+
confidenceScore: null,
|
|
66
77
|
};
|
|
67
78
|
}
|
|
68
79
|
|
|
69
|
-
// #
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
80
|
+
// #1072 — dt-correct EWMA of the consumption rate (utilization-fraction per ms). Each consecutive
|
|
81
|
+
// interval contributes its mean rate Δutil/Δt, held constant over Δt and decayed by exp(−Δt/τ), so
|
|
82
|
+
// a long idle interval (Δt≫τ, rate ~0) drives the average toward 0 while a short burst barely
|
|
83
|
+
// moves a long-τ average. The EWMA is SEEDED AT 0 (not at the first interval's raw slope) so a
|
|
84
|
+
// sustained trend is required to build the rate up — a single onset interval can never set the
|
|
85
|
+
// full slope. Interval rates are clamped ≥0 (a within-window utilization drop is treated as no
|
|
86
|
+
// consumption, not negative burn).
|
|
87
|
+
function ewmaRatePerMs(
|
|
74
88
|
samples: Array<{ capturedAt: number; utilization: number }>,
|
|
75
89
|
now: number,
|
|
76
|
-
|
|
90
|
+
tauMs: number,
|
|
77
91
|
): number {
|
|
78
|
-
let
|
|
79
|
-
let weightedDt = 0;
|
|
92
|
+
let r = 0;
|
|
80
93
|
for (let i = 1; i < samples.length; i++) {
|
|
81
|
-
const
|
|
82
|
-
const cur = samples[i]!;
|
|
83
|
-
const dt = cur.capturedAt - prev.capturedAt;
|
|
94
|
+
const dt = samples[i]!.capturedAt - samples[i - 1]!.capturedAt;
|
|
84
95
|
if (dt <= 0) continue;
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
const rate = Math.max(0, (samples[i]!.utilization - samples[i - 1]!.utilization) / dt);
|
|
97
|
+
const decay = Math.exp(-dt / tauMs);
|
|
98
|
+
r = decay * r + (1 - decay) * rate;
|
|
99
|
+
}
|
|
100
|
+
// Idle observability: extend to `now` with a zero-rate interval so a deduped idle stretch (no new
|
|
101
|
+
// distinct samples arrive while utilization holds) still decays the average instead of freezing
|
|
102
|
+
// the last observed slope — the same synthetic-tail trick #760 used, applied to the EWMA.
|
|
103
|
+
const last = samples[samples.length - 1];
|
|
104
|
+
if (last && now > last.capturedAt) {
|
|
105
|
+
r *= Math.exp(-(now - last.capturedAt) / tauMs);
|
|
89
106
|
}
|
|
90
|
-
|
|
91
|
-
return Math.max(0, weightedDelta / weightedDt);
|
|
107
|
+
return Math.max(0, r);
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
// #798 — when did utilization last differ from its current value? Scanning back from the newest
|
|
@@ -148,62 +164,75 @@ function deriveProviderQuotaBurnWindow(input: {
|
|
|
148
164
|
const timeToResetMs = resetAt - input.now;
|
|
149
165
|
if (remainingQuota <= 0 || timeToResetMs <= 0) return low;
|
|
150
166
|
|
|
151
|
-
// #
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
const rateSamples = input.now > current.capturedAt
|
|
161
|
-
? [...samples, { capturedAt: input.now, utilization: utilizationNow }]
|
|
162
|
-
: samples;
|
|
163
|
-
const observedRatePerMs = recencyWeightedRatePerMs(rateSamples, input.now, halfLifeMs);
|
|
164
|
-
// #798 — idle de-bias: if utilization has held flat (no change vs. now) for IDLE_FORWARD_COLLAPSE_MS,
|
|
165
|
-
// treat the forward-burn term as a stale-burst artifact and collapse it to 0 so the projection
|
|
166
|
-
// pins to current utilization instead of walking the band boundary. lastChangeAt is the moment
|
|
167
|
-
// utilization last differed from the current value (the synthetic "still X as of now" interval is
|
|
168
|
-
// what makes a deduped idle stretch observable here).
|
|
167
|
+
// #1072 — the SLOW EWMA is the burn rate: a sustained trend is required by construction, so a
|
|
168
|
+
// transient onset burst (100% of a young window's evidence) can't drive the band. The EWMA walks
|
|
169
|
+
// the real sample timeline and extends a zero-rate tail to `now`, so a deduped idle stretch
|
|
170
|
+
// decays the rate instead of freezing the last observed slope (the #760 burn×6.3 stale-slope bug).
|
|
171
|
+
const rSlow = ewmaRatePerMs(samples, input.now, EWMA_TAU_SLOW_MS[role]);
|
|
172
|
+
// #798 idle collapse (stability only): once utilization has held flat ≥ IDLE_FORWARD_COLLAPSE_MS
|
|
173
|
+
// the residual slow rate is a stale-burst tail — pin it to 0 so the projection == current
|
|
174
|
+
// utilization and stays flat tick-to-tick (no band walk). lastChangeAt is when utilization last
|
|
175
|
+
// differed from the current value.
|
|
169
176
|
const lastChangeAt = lastUtilizationChangeAt(samples, utilizationNow);
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
//
|
|
177
|
+
const bandRate = input.now - lastChangeAt >= IDLE_FORWARD_COLLAPSE_MS ? 0 : rSlow;
|
|
178
|
+
|
|
179
|
+
// #1072 — evidence span and continuous trend confidence. evidenceMs is how long we've actually
|
|
180
|
+
// observed this window instance (bounded by both the window's own start and our first sample);
|
|
181
|
+
// it caps the projection horizon and drives the confidence that gates hard-escalation.
|
|
182
|
+
const windowStartAt = resetAt - WINDOW_NOMINAL_MS[role];
|
|
183
|
+
const evidenceMs = Math.max(0, input.now - Math.max(windowStartAt, oldest.capturedAt));
|
|
184
|
+
const confidence = Math.max(0, Math.min(1, evidenceMs / CONFIDENCE_FULL_EVIDENCE_MS[role]));
|
|
185
|
+
|
|
186
|
+
// #1072 — evidence-capped band projection off the slow rate. The query layer (spawn-targets)
|
|
187
|
+
// re-derives slack/penalty against the per-provider target from this (target-independent)
|
|
188
|
+
// projection + confidenceScore; the ingest-time values use the default target so direct burn
|
|
189
|
+
// consumers (dashboard) have a number.
|
|
190
|
+
const trend = quotaTrendProjection({
|
|
191
|
+
utilization: utilizationNow,
|
|
192
|
+
slowRatePerMs: bandRate,
|
|
193
|
+
timeToResetMs,
|
|
194
|
+
evidenceMs,
|
|
195
|
+
confidence,
|
|
196
|
+
target: DEFAULT_QUOTA_TARGET_UTILIZATION,
|
|
197
|
+
});
|
|
198
|
+
// burnRatio (display) paces the same slow rate against the target, so the widget's burn number
|
|
199
|
+
// and the routing band always agree — a bursty spike shows in the raw sparkline, not here.
|
|
174
200
|
const pace = quotaPaceMetrics({
|
|
175
201
|
utilization: utilizationNow,
|
|
176
|
-
observedRatePerMs:
|
|
202
|
+
observedRatePerMs: bandRate,
|
|
177
203
|
timeToResetMs,
|
|
178
204
|
target: DEFAULT_QUOTA_TARGET_UTILIZATION,
|
|
179
205
|
});
|
|
180
|
-
if (
|
|
181
|
-
// Idle (decayed-to-idle, or #798 idle-collapsed): no projected wall, but
|
|
182
|
-
//
|
|
206
|
+
if (bandRate <= 0) {
|
|
207
|
+
// Idle (flat, decayed-to-idle, or #798 idle-collapsed): no projected wall, but the projection
|
|
208
|
+
// still pins to current utilization and slack flags headroom (positive) or the over-target
|
|
209
|
+
// position the penalty avoids (negative).
|
|
183
210
|
return {
|
|
184
211
|
...low,
|
|
185
212
|
confidence: "high",
|
|
186
213
|
burnRatio: 0,
|
|
187
214
|
observedRatePerMs: 0,
|
|
188
|
-
projectedUtilAtReset:
|
|
189
|
-
slack:
|
|
190
|
-
target:
|
|
215
|
+
projectedUtilAtReset: trend.projectedUtilAtReset,
|
|
216
|
+
slack: trend.slack,
|
|
217
|
+
target: trend.target,
|
|
218
|
+
confidenceScore: confidence,
|
|
191
219
|
};
|
|
192
220
|
}
|
|
193
|
-
const timeToWallMs = remainingQuota /
|
|
221
|
+
const timeToWallMs = remainingQuota / bandRate;
|
|
194
222
|
const projectedWallAt = input.now + timeToWallMs;
|
|
195
223
|
return {
|
|
196
224
|
...low,
|
|
197
225
|
confidence: "high",
|
|
198
226
|
burnRatio: pace.burnRatio,
|
|
199
|
-
observedRatePerMs:
|
|
227
|
+
observedRatePerMs: bandRate,
|
|
200
228
|
timeToWallMs,
|
|
201
229
|
projectedWallAt,
|
|
202
230
|
// #760 (Defect 2) — symmetric: negative means the wall lands AFTER reset (headroom left).
|
|
203
231
|
earlyWallMs: resetAt - projectedWallAt,
|
|
204
|
-
projectedUtilAtReset:
|
|
205
|
-
slack:
|
|
206
|
-
target:
|
|
232
|
+
projectedUtilAtReset: trend.projectedUtilAtReset,
|
|
233
|
+
slack: trend.slack,
|
|
234
|
+
target: trend.target,
|
|
235
|
+
confidenceScore: confidence,
|
|
207
236
|
};
|
|
208
237
|
}
|
|
209
238
|
|
|
@@ -26,12 +26,22 @@ export interface ProviderQuotaAdvisoryInput {
|
|
|
26
26
|
quota: QuotaState;
|
|
27
27
|
sourceAgentIds: string[];
|
|
28
28
|
previousAdvisoryState?: string | null;
|
|
29
|
+
// #1074 — serialized quota-band hysteresis state (quota_band_state column). Threaded in so the
|
|
30
|
+
// band-transition pipeline hydrates instead of re-seeding to "normal" on every relay restart.
|
|
31
|
+
previousBandState?: string | null;
|
|
29
32
|
now: number;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
|
|
35
|
+
// #1074 — the handler returns BOTH persisted blobs: the utilization advisory state and the
|
|
36
|
+
// band-transition hysteresis state. Both are written back to their respective columns.
|
|
37
|
+
export interface ProviderQuotaAdvisoryResult {
|
|
38
|
+
advisoryState: string;
|
|
39
|
+
bandState: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let providerQuotaAdvisoryHandler: ((input: ProviderQuotaAdvisoryInput) => ProviderQuotaAdvisoryResult) | undefined;
|
|
33
43
|
|
|
34
|
-
export function setProviderQuotaAdvisoryHandler(handler: ((input: ProviderQuotaAdvisoryInput) =>
|
|
44
|
+
export function setProviderQuotaAdvisoryHandler(handler: ((input: ProviderQuotaAdvisoryInput) => ProviderQuotaAdvisoryResult) | undefined): void {
|
|
35
45
|
providerQuotaAdvisoryHandler = handler;
|
|
36
46
|
}
|
|
37
47
|
|
|
@@ -42,6 +52,7 @@ type ProviderQuotaRow = {
|
|
|
42
52
|
codex_reset_credits?: string | null;
|
|
43
53
|
source_agent_ids?: string | null;
|
|
44
54
|
quota_advisory_state?: string | null;
|
|
55
|
+
quota_band_state?: string | null;
|
|
45
56
|
last_updated_at?: number | null;
|
|
46
57
|
last_attempt_at: number;
|
|
47
58
|
last_error?: string | null;
|
|
@@ -182,6 +193,13 @@ function newestQuotaAdvisoryState(rows: ProviderQuotaRow[]): string {
|
|
|
182
193
|
return row?.quota_advisory_state ?? "{}";
|
|
183
194
|
}
|
|
184
195
|
|
|
196
|
+
function newestQuotaBandState(rows: ProviderQuotaRow[]): string {
|
|
197
|
+
const row = rows
|
|
198
|
+
.filter((candidate) => candidate.quota_band_state)
|
|
199
|
+
.sort((a, b) => b.updated_at - a.updated_at)[0];
|
|
200
|
+
return row?.quota_band_state ?? "{}";
|
|
201
|
+
}
|
|
202
|
+
|
|
185
203
|
function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccountKeys: string[], now: number): number {
|
|
186
204
|
const keys = [...new Set(oldAccountKeys.filter((key) => key && key !== accountKey))];
|
|
187
205
|
if (keys.length === 0) return 0;
|
|
@@ -203,9 +221,9 @@ function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccount
|
|
|
203
221
|
getDb().query(`
|
|
204
222
|
INSERT INTO provider_quotas (
|
|
205
223
|
provider, account_key, quota_state, codex_reset_credits, source_agent_ids, last_updated_at,
|
|
206
|
-
last_attempt_at, last_error, quota_advisory_state, updated_at, created_at
|
|
224
|
+
last_attempt_at, last_error, quota_advisory_state, quota_band_state, updated_at, created_at
|
|
207
225
|
)
|
|
208
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
226
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
209
227
|
ON CONFLICT(provider, account_key) DO UPDATE SET
|
|
210
228
|
quota_state = excluded.quota_state,
|
|
211
229
|
codex_reset_credits = excluded.codex_reset_credits,
|
|
@@ -214,6 +232,7 @@ function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccount
|
|
|
214
232
|
last_attempt_at = excluded.last_attempt_at,
|
|
215
233
|
last_error = excluded.last_error,
|
|
216
234
|
quota_advisory_state = excluded.quota_advisory_state,
|
|
235
|
+
quota_band_state = excluded.quota_band_state,
|
|
217
236
|
updated_at = excluded.updated_at
|
|
218
237
|
`).run(
|
|
219
238
|
provider,
|
|
@@ -225,6 +244,7 @@ function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccount
|
|
|
225
244
|
lastAttemptAt || now,
|
|
226
245
|
newestLastError(rows),
|
|
227
246
|
newestQuotaAdvisoryState(rows),
|
|
247
|
+
newestQuotaBandState(rows),
|
|
228
248
|
updatedAt,
|
|
229
249
|
createdAt,
|
|
230
250
|
);
|
|
@@ -552,17 +572,19 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
|
|
|
552
572
|
recordProviderQuotaUnavailable({ provider, accountKey, error: input.lastError, sourceAgentId: input.sourceAgentId }, now);
|
|
553
573
|
}
|
|
554
574
|
if (hasFreshSuccessfulProviderQuota(input, now) && mergedQuota?.acceptedIncomingWindow) {
|
|
555
|
-
const
|
|
575
|
+
const advisoryResult = providerQuotaAdvisoryHandler?.({
|
|
556
576
|
provider,
|
|
557
577
|
accountKey,
|
|
558
578
|
previousQuota: existingQuota ? publicQuotaState(existingQuota) : undefined,
|
|
559
579
|
quota: publicQuotaState(mergedQuota.quota),
|
|
560
580
|
sourceAgentIds,
|
|
561
581
|
previousAdvisoryState: existing?.quota_advisory_state,
|
|
582
|
+
previousBandState: existing?.quota_band_state,
|
|
562
583
|
now,
|
|
563
584
|
});
|
|
564
|
-
if (
|
|
565
|
-
getDb().query("UPDATE provider_quotas SET quota_advisory_state = ? WHERE provider = ? AND account_key = ?")
|
|
585
|
+
if (advisoryResult !== undefined) {
|
|
586
|
+
getDb().query("UPDATE provider_quotas SET quota_advisory_state = ?, quota_band_state = ? WHERE provider = ? AND account_key = ?")
|
|
587
|
+
.run(advisoryResult.advisoryState, advisoryResult.bandState, provider, accountKey);
|
|
566
588
|
}
|
|
567
589
|
}
|
|
568
590
|
if (!unstableProviderQuotaAccountKey(accountKey)) {
|
|
@@ -185,6 +185,27 @@ export function countActiveScheduledTimers(createdBy: string): number {
|
|
|
185
185
|
return row.count;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// #1004 — a command timer whose background command is still pending/running is active work
|
|
189
|
+
// executing on the creator's behalf (a shell command on an orchestrator), even though its
|
|
190
|
+
// provider turn already ended and reports idle. A prompt-wakeup timer (no command) is just a
|
|
191
|
+
// future alarm and never counts. These back the running-command → busy DISPLAY derivation; the
|
|
192
|
+
// timer/command row is the source of truth, so the signal self-heals when the command settles.
|
|
193
|
+
const RUNNING_COMMAND_TIMER_PREDICATE =
|
|
194
|
+
"enabled = 1 AND command IS NOT NULL AND json_extract(command, '$.status') IN ('pending', 'running')";
|
|
195
|
+
|
|
196
|
+
export function agentHasRunningCommandTimer(createdBy: string): boolean {
|
|
197
|
+
return getDb()
|
|
198
|
+
.query(`SELECT 1 FROM scheduled_timers WHERE created_by = ? AND ${RUNNING_COMMAND_TIMER_PREDICATE} LIMIT 1`)
|
|
199
|
+
.get(createdBy) != null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function agentsWithRunningCommandTimers(): Set<string> {
|
|
203
|
+
const rows = getDb()
|
|
204
|
+
.query(`SELECT DISTINCT created_by FROM scheduled_timers WHERE ${RUNNING_COMMAND_TIMER_PREDICATE}`)
|
|
205
|
+
.all() as Array<{ created_by: string }>;
|
|
206
|
+
return new Set(rows.map((row) => row.created_by));
|
|
207
|
+
}
|
|
208
|
+
|
|
188
209
|
export function listDueScheduledTimerIds(now: number, limit = 25): string[] {
|
|
189
210
|
const rows = getDb().query(`
|
|
190
211
|
SELECT id FROM scheduled_timers
|
package/src/db/schema.ts
CHANGED
|
@@ -366,6 +366,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
366
366
|
codex_reset_credits TEXT,
|
|
367
367
|
source_agent_ids TEXT NOT NULL DEFAULT '[]',
|
|
368
368
|
quota_advisory_state TEXT NOT NULL DEFAULT '{}',
|
|
369
|
+
quota_band_state TEXT NOT NULL DEFAULT '{}',
|
|
369
370
|
last_updated_at INTEGER,
|
|
370
371
|
last_attempt_at INTEGER NOT NULL,
|
|
371
372
|
last_error TEXT,
|
package/src/db/tasks.ts
CHANGED
|
@@ -327,15 +327,45 @@ export function renewTaskClaim(taskId: number, agentId: string, guard?: AgentSes
|
|
|
327
327
|
return { ok: true, task: getTask(taskId)! };
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
-
|
|
330
|
+
/**
|
|
331
|
+
* The AUTHENTICATED principal behind a status write, derived at the transport boundary from the
|
|
332
|
+
* signed token (`ctx.callerAgentId` / scopes) — NEVER from the request body. The ownership fence
|
|
333
|
+
* keys on this, not `input.agentId`, so omitting/spoofing the body field cannot advance another
|
|
334
|
+
* agent's claim (#1063).
|
|
335
|
+
*/
|
|
336
|
+
export interface TaskStatusActor {
|
|
337
|
+
/** The caller's own agent id (`ctx.callerAgentId`). undefined for full-reach/system/multi-agent tokens. */
|
|
338
|
+
agentId?: string;
|
|
339
|
+
/** Full-reach `*`, internal `system`, or a task-admin scope: may advance a task it does not own. */
|
|
340
|
+
privileged?: boolean;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function updateTaskStatus(taskId: number, input: TaskStatusInput, actor?: TaskStatusActor): { ok: boolean; error?: string; conflict?: boolean; task?: Task; event?: TaskEvent } {
|
|
331
344
|
const task = getTask(taskId);
|
|
332
345
|
if (!task) return { ok: false, error: "task not found" };
|
|
333
346
|
// Fence: the claim-lane status updater writes `claimed_by`; deliverable Tasks must never
|
|
334
347
|
// be mutated here. They transition through setTaskStatus (src/db/task-entity.ts), which
|
|
335
348
|
// never touches claimed_by. (#834)
|
|
336
349
|
if (task.kind === DELIVERABLE_TASK_KIND) return { ok: false, error: "deliverable task: use the task-entity status API" };
|
|
350
|
+
// Ownership fence (#1061, hardened #1063): once a task is claimed, only its current owner may
|
|
351
|
+
// advance the status. The acting identity is the AUTHENTICATED caller (`actor.agentId`, resolved
|
|
352
|
+
// from the signed token at the route) — NEVER the client-supplied `input.agentId`. Trusting the
|
|
353
|
+
// body let any task:write holder omit or spoof it and steal another agent's claim (#1063).
|
|
354
|
+
// A privileged caller (full-reach `*`, internal `system`, or a task-admin scope) may advance a
|
|
355
|
+
// task it does not own — the same escape hatch #1043's `ensureCanMintThread` grants. An undefined
|
|
356
|
+
// `actor` is an in-process trusted caller (automations, tests) and is likewise privileged.
|
|
357
|
+
// A null claimed_by still self-claims (fresh status write).
|
|
358
|
+
const privileged = actor?.privileged ?? actor === undefined;
|
|
359
|
+
const callerAgentId = actor?.agentId;
|
|
360
|
+
if (!privileged && task.claimedBy && callerAgentId !== task.claimedBy) {
|
|
361
|
+
return { ok: false, error: `task claimed by ${task.claimedBy}`, conflict: true };
|
|
362
|
+
}
|
|
337
363
|
const now = Date.now();
|
|
338
|
-
|
|
364
|
+
// Effective owner/session identity. A privileged caller may act on behalf of the supplied
|
|
365
|
+
// `input.agentId` (or the existing owner — the automation/system self-claim path); an ordinary
|
|
366
|
+
// caller is bound to its authenticated identity and must NOT fall back to `claimedBy` — that
|
|
367
|
+
// fallback was the #1063 bypass that let a missing `agentId` silently inherit the owner.
|
|
368
|
+
const agentId = privileged ? (input.agentId ?? task.claimedBy ?? null) : (callerAgentId ?? null);
|
|
339
369
|
if (agentId) {
|
|
340
370
|
const session = validateAgentSession(agentId, input);
|
|
341
371
|
if (!session.ok) return { ok: false, error: session.error };
|
|
@@ -360,7 +390,7 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
|
|
|
360
390
|
}
|
|
361
391
|
}
|
|
362
392
|
const event = insertTaskEvent(taskId, {
|
|
363
|
-
source:
|
|
393
|
+
source: agentId ?? "agent-relay",
|
|
364
394
|
type: `status:${input.status}`,
|
|
365
395
|
severity: task.severity,
|
|
366
396
|
title: `Task ${input.status}`,
|
package/src/db/work-evidence.ts
CHANGED
|
@@ -24,13 +24,16 @@ export function hasArtifactCreatedBy(createdBy: string, since: number = 0): bool
|
|
|
24
24
|
|
|
25
25
|
export function listFinalResponseCaptureMessages(from: string, since: number = 0, limit: number = 20): Message[] {
|
|
26
26
|
const safeLimit = Math.min(Math.max(Math.floor(limit), 1), 100);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
const rows = getDb().query(`
|
|
28
|
+
${MSG_SELECT}
|
|
29
|
+
WHERE m.from_agent = ?
|
|
30
|
+
AND m.kind = 'chat'
|
|
31
|
+
AND m.created_at >= ?
|
|
32
|
+
AND json_extract(m.payload, '$.responseCapture.type') = 'response'
|
|
33
|
+
AND (
|
|
34
|
+
json_extract(m.payload, '$.responseCapture.turnFinal') = 1
|
|
35
|
+
OR json_extract(m.payload, '$.responseCapture.final') = 1
|
|
36
|
+
)
|
|
34
37
|
ORDER BY m.id DESC
|
|
35
38
|
LIMIT ?
|
|
36
39
|
`).all(from, since, safeLimit) as any[];
|