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.
Files changed (132) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -4
  3. package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
  4. package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
  7. package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
  8. package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
  10. package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
  12. package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
  13. package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
  14. package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
  15. package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
  16. package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
  17. package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
  18. package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
  20. package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
  21. package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
  22. package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
  23. package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
  24. package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
  25. package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
  26. package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
  27. package/public/assets/display-CJzSoTMq.js +3 -0
  28. package/public/assets/display-CJzSoTMq.js.map +1 -0
  29. package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
  30. package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
  32. package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
  33. package/public/assets/index-D0xMQQb_.js +25 -0
  34. package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
  35. package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
  36. package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
  37. package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
  38. package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
  39. package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
  40. package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
  41. package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
  42. package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
  43. package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
  44. package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
  45. package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
  46. package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
  47. package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
  48. package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
  49. package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
  50. package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
  51. package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
  52. package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
  53. package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
  54. package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
  55. package/public/assets/projects-Dzdf-gkK.js +2 -0
  56. package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
  57. package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
  58. package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
  59. package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
  60. package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
  61. package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
  62. package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
  63. package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
  64. package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
  65. package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
  66. package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
  67. package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
  68. package/public/assets/store-BPrVuKiv.js.map +1 -0
  69. package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
  70. package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
  71. package/public/assets/teams-DWv6UwFV.js +2 -0
  72. package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
  73. package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
  74. package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
  75. package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
  76. package/public/assets/types-BeJUSfDj.js.map +1 -0
  77. package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
  78. package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
  79. package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
  80. package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
  81. package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
  82. package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
  83. package/public/index.html +6 -6
  84. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  85. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
  86. package/runner/src/adapter.ts +53 -18
  87. package/runner/src/config.ts +1 -4
  88. package/src/agent-card-projection.ts +5 -1
  89. package/src/agent-command-activity.ts +39 -0
  90. package/src/automations/reconcile.ts +2 -2
  91. package/src/bus.ts +6 -4
  92. package/src/cli/workspace.ts +3 -1
  93. package/src/config.ts +10 -1
  94. package/src/db/agents.ts +138 -32
  95. package/src/db/message-reads.ts +50 -0
  96. package/src/db/messages.ts +48 -9
  97. package/src/db/migrations.ts +54 -0
  98. package/src/db/provider-quota-burn.ts +96 -67
  99. package/src/db/provider-quotas.ts +29 -7
  100. package/src/db/scheduled-timers.ts +21 -0
  101. package/src/db/schema.ts +1 -0
  102. package/src/db/tasks.ts +33 -3
  103. package/src/db/work-evidence.ts +10 -7
  104. package/src/event-loop-lag.ts +15 -0
  105. package/src/gate-resolver.ts +24 -1
  106. package/src/maintenance/jobs.ts +23 -6
  107. package/src/managed-policy.ts +35 -5
  108. package/src/mcp-plan-tools.ts +13 -1
  109. package/src/project-instructions.ts +36 -2
  110. package/src/quota-advisory.ts +6 -4
  111. package/src/quota-band-transition.ts +23 -14
  112. package/src/routes/agent-sessions.ts +39 -7
  113. package/src/routes/agents.ts +3 -2
  114. package/src/routes/tasks.ts +16 -4
  115. package/src/routes/workspaces.ts +35 -10
  116. package/src/services/plan-graph.ts +27 -9
  117. package/src/services/register-agent.ts +12 -1
  118. package/src/services/send-message.ts +43 -15
  119. package/src/services/spawn-agent.ts +3 -0
  120. package/src/spawn-command.ts +8 -0
  121. package/src/spawn-targets.ts +19 -34
  122. package/src/sse.ts +25 -3
  123. package/src/workspace-merge.ts +25 -5
  124. package/src/workspace-orphans.ts +13 -6
  125. package/public/assets/display-exQDWum3.js +0 -3
  126. package/public/assets/display-exQDWum3.js.map +0 -1
  127. package/public/assets/index-D9OogaB5.js +0 -25
  128. package/public/assets/projects-DdPNPFJI.js +0 -2
  129. package/public/assets/prompt-settings-BpZse7-X.js +0 -2
  130. package/public/assets/store-DKTLubBT.js.map +0 -1
  131. package/public/assets/teams-B8f2pGJe.js +0 -2
  132. package/public/assets/types-uVOXgvMT.js.map +0 -1
@@ -1,6 +1,15 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
- import { isRecord, stringValue, isMechanicalMessageKind, isReportVerbosity, type ReportVerbosity } from "agent-relay-sdk";
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.final === true;
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
- const REPORT_UP_ELISION = "[…mid-work narration trimmed by report-up…]";
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 : `${REPORT_UP_ELISION}\n\n${trimmed.slice(heading.index).trim()}`;
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 `${REPORT_UP_ELISION}\n\n${tail}`;
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 json_extract(payload, '$.session.final') = 1
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)) {
@@ -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, used to scale the recency half-life.
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
- // #760recency half-life as a fraction of the window length. 0.25 ~75min for the 5h
13
- // window (within the issue's ~60–90min trailing-window guidance) and ~42h for the 7d window.
14
- // A burst at the START of the history span decays out as it ages, so an idle stretch pulls the
15
- // observed rate (and burnRatio) back toward 0 instead of freezing the endpoint slope.
16
- const RECENCY_HALFLIFE_FRACTION = 0.25;
17
-
18
- // #798 — idle de-bias: once a provider's utilization has held flat for this long, any residual
19
- // forward-burn rate is a stale-burst artifact (the recency half-life decays it, but for the 7d
20
- // windowhalf-life ~42h far too slowly: codex idle ~24h still projected ~1.07 at reset,
21
- // walking the band boundary tick-to-tick). When idle this long the forward-burn term is collapsed
22
- // so projectedUtilAtReset current utilization. The threshold is absolute (NOT window-scaled): a
23
- // 5h window resets long before reaching it, so this only de-biases the slow-decaying 7d window;
24
- // shorter idle stretches keep the graceful #760 recency decay. The over-target avoid is preserved
25
- // by the position penalty in spawn-targets (a stable, position-based floor), not by this rate.
13
+ // #1072SLOW 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
+ // #1072continuous 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
- // #760recency-weighted consumption rate (utilization-fraction per ms). A weighted slope:
70
- // each consecutive interval contributes its actual Δutilization and Δt, decayed by the age of
71
- // its midpoint with the given half-life. A recent idle interval (large Δt, ~0 Δutil, high
72
- // weight) dominates the denominator and drives the rate toward 0; an old burst is down-weighted.
73
- function recencyWeightedRatePerMs(
80
+ // #1072dt-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
- halfLifeMs: number,
90
+ tauMs: number,
77
91
  ): number {
78
- let weightedDelta = 0;
79
- let weightedDt = 0;
92
+ let r = 0;
80
93
  for (let i = 1; i < samples.length; i++) {
81
- const prev = samples[i - 1]!;
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 midAge = Math.max(0, now - (prev.capturedAt + cur.capturedAt) / 2);
86
- const weight = Math.pow(0.5, midAge / halfLifeMs);
87
- weightedDelta += weight * (cur.utilization - prev.utilization);
88
- weightedDt += weight * dt;
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
- if (weightedDt <= 0) return 0;
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
- // #760 (Defect 1) recency-weighted rate so an old burst decays and idle relaxes toward 0,
152
- // instead of a frozen two-point endpoint slope over the whole span.
153
- //
154
- // Idle is invisible in the raw samples: identical utilization snapshots are deduped, so during
155
- // an idle stretch no new rows arrive and the latest sample stays frozen at the last CHANGE
156
- // (this is why codex sat at burn×6.3 a stale burst slope). We make idle observable by
157
- // appending a synthetic "utilization is still X as of now" point, which adds a recent
158
- // zero-delta interval [lastSample, now] that the recency weighting lets dominate as it grows.
159
- const halfLifeMs = WINDOW_NOMINAL_MS[role] * RECENCY_HALFLIFE_FRACTION;
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 effectiveRate = input.now - lastChangeAt >= IDLE_FORWARD_COLLAPSE_MS ? 0 : observedRatePerMs;
171
- // #760 (Defect 2) — pace against a configurable target (<100%); slack carries the underburn
172
- // signal. The query layer (spawn-targets) may re-derive these against a per-provider target;
173
- // the ingest-time values use the default so direct burn consumers (dashboard) have a number.
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: effectiveRate,
202
+ observedRatePerMs: bandRate,
177
203
  timeToResetMs,
178
204
  target: DEFAULT_QUOTA_TARGET_UTILIZATION,
179
205
  });
180
- if (effectiveRate <= 0) {
181
- // Idle (decayed-to-idle, or #798 idle-collapsed): no projected wall, but slack still flags the
182
- // headroom (positive = underburn) or, when over target, the position the band-floor avoids.
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: pace.projectedUtilAtReset,
189
- slack: pace.slack,
190
- target: pace.target,
215
+ projectedUtilAtReset: trend.projectedUtilAtReset,
216
+ slack: trend.slack,
217
+ target: trend.target,
218
+ confidenceScore: confidence,
191
219
  };
192
220
  }
193
- const timeToWallMs = remainingQuota / effectiveRate;
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: effectiveRate,
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: pace.projectedUtilAtReset,
205
- slack: pace.slack,
206
- target: pace.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
- let providerQuotaAdvisoryHandler: ((input: ProviderQuotaAdvisoryInput) => string) | undefined;
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) => string) | undefined): void {
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 quotaAdvisoryState = providerQuotaAdvisoryHandler?.({
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 (quotaAdvisoryState !== undefined) {
565
- getDb().query("UPDATE provider_quotas SET quota_advisory_state = ? WHERE provider = ? AND account_key = ?").run(quotaAdvisoryState, provider, accountKey);
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
- export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok: boolean; error?: string; task?: Task; event?: TaskEvent } {
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
- const agentId = input.agentId ?? task.claimedBy ?? null;
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: input.agentId ?? "agent-relay",
393
+ source: agentId ?? "agent-relay",
364
394
  type: `status:${input.status}`,
365
395
  severity: task.severity,
366
396
  title: `Task ${input.status}`,
@@ -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
- 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 json_extract(m.payload, '$.responseCapture.final') = 1
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[];