agent-relay-server 0.121.5 → 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/cli/workspace.ts +3 -1
- package/src/config.ts +10 -1
- package/src/db/agents.ts +118 -37
- 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 +5 -12
- 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 -12
- 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/runner/src/adapter.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { AgentProfile, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
|
|
2
|
-
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { isRecord } from "agent-relay-sdk/types/guards";
|
|
3
3
|
import type { LivenessInputs } from "./liveness";
|
|
4
4
|
import type { SessionEvent } from "./session-insights";
|
|
5
|
-
import { messageBodyMaxCharsFromEnv } from "./config";
|
|
5
|
+
import { messageBodyMaxCharsFromEnv } from "./message-body-config";
|
|
6
6
|
import { relayReplyReminderText } from "./relay-instructions";
|
|
7
7
|
import { renderRunnerTemplate } from "./template-resolver";
|
|
8
8
|
|
|
@@ -250,6 +250,53 @@ export function providerMessageBodyMaxChars(): number {
|
|
|
250
250
|
return DEFAULT_PROVIDER_MESSAGE_BODY_MAX_CHARS;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
export interface DeliveredBodyPreview {
|
|
254
|
+
body: string;
|
|
255
|
+
truncated: boolean;
|
|
256
|
+
/** Retrieval-guidance lines (empty when not truncated). Call sites may append provider extras. */
|
|
257
|
+
guidance: string[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// #1058 — one home for the delivered-body cap. Head-only truncation silently ate the TAIL, which is
|
|
261
|
+
// exactly where a report-up final puts its verdict / SHIP recommendation. Preserve BOTH ends: keep a
|
|
262
|
+
// head slice for opening context and a tail slice for the conclusion, eliding the middle and pointing
|
|
263
|
+
// at the untrimmed body. The tail is favored (it holds the verdict). Slices snap to whitespace so we
|
|
264
|
+
// don't deliver a half-word at either seam. Grep before adding another truncation site — this is it.
|
|
265
|
+
export function previewDeliveredBody(
|
|
266
|
+
rawBody: string,
|
|
267
|
+
messageId: number,
|
|
268
|
+
maxChars: number = providerMessageBodyMaxChars(),
|
|
269
|
+
): DeliveredBodyPreview {
|
|
270
|
+
if (rawBody.length <= maxChars) return { body: rawBody, truncated: false, guidance: [] };
|
|
271
|
+
const fetchLines = [
|
|
272
|
+
`Read full: agent-relay get-message ${messageId}`,
|
|
273
|
+
`Body only: agent-relay get-message ${messageId} --body`,
|
|
274
|
+
];
|
|
275
|
+
const marker = (elided: number) => `[… ${elided} chars elided from the middle — full report: relay_get_message #${messageId} …]`;
|
|
276
|
+
// Reserve room for the marker and its two blank-line joins; upper-bound the marker length by using
|
|
277
|
+
// the (always-larger) full body length so the reserved budget can never underflow the real marker.
|
|
278
|
+
const budget = maxChars - marker(rawBody.length).length - 4;
|
|
279
|
+
if (budget < 200) {
|
|
280
|
+
// Cap too small to keep a useful head — front-truncate, preserving the tail/verdict whole.
|
|
281
|
+
const tail = rawBody.slice(rawBody.length - maxChars).replace(/^\S*\s+/, "").trimStart();
|
|
282
|
+
return {
|
|
283
|
+
body: tail,
|
|
284
|
+
truncated: true,
|
|
285
|
+
guidance: [`[truncated: showing last ${tail.length} of ${rawBody.length} chars — head trimmed, tail/verdict preserved]`, ...fetchLines],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const headChars = Math.floor(budget * 0.35);
|
|
289
|
+
const tailChars = budget - headChars;
|
|
290
|
+
const head = rawBody.slice(0, headChars).replace(/\s+\S*$/, "").trimEnd();
|
|
291
|
+
const tail = rawBody.slice(rawBody.length - tailChars).replace(/^\S*\s+/, "").trimStart();
|
|
292
|
+
const elided = rawBody.length - head.length - tail.length;
|
|
293
|
+
return {
|
|
294
|
+
body: `${head}\n\n${marker(elided)}\n\n${tail}`,
|
|
295
|
+
truncated: true,
|
|
296
|
+
guidance: [`[truncated: showing first ${head.length} + last ${tail.length} of ${rawBody.length} chars — tail/verdict preserved]`, ...fetchLines],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
253
300
|
function attachmentRefs(message: Message): Record<string, unknown>[] {
|
|
254
301
|
const payloadRefs = message.payload?.attachments;
|
|
255
302
|
const topLevelRefs = (message as Message & { attachments?: unknown }).attachments;
|
|
@@ -375,23 +422,11 @@ export function providerMessageText(messages: Message[]): string {
|
|
|
375
422
|
const subject = message.subject ? `Subject: ${message.subject}\n` : "";
|
|
376
423
|
const isMemoryContext = isMemoryInjection(message);
|
|
377
424
|
const canReferenceMessage = isPersistedRelayMessage(message) && !isMemoryContext && !isReactionNotification(message);
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
body: message.body.slice(0, maxChars),
|
|
382
|
-
truncated: true,
|
|
383
|
-
}
|
|
384
|
-
: {
|
|
385
|
-
body: message.body,
|
|
386
|
-
truncated: false,
|
|
387
|
-
};
|
|
425
|
+
const preview = canReferenceMessage
|
|
426
|
+
? previewDeliveredBody(message.body, message.id, maxChars)
|
|
427
|
+
: { body: message.body, truncated: false, guidance: [] };
|
|
388
428
|
const truncationGuidance = preview.truncated
|
|
389
|
-
? [
|
|
390
|
-
`[truncated: showing first ${maxChars} of ${message.body.length} chars]`,
|
|
391
|
-
`Read full: agent-relay get-message ${message.id}`,
|
|
392
|
-
`Body only: agent-relay get-message ${message.id} --body`,
|
|
393
|
-
`Long reply CLI fallback: agent-relay /reply ${message.id} --body-file response.md`,
|
|
394
|
-
].join("\n")
|
|
429
|
+
? [...preview.guidance, `Long reply CLI fallback: agent-relay /reply ${message.id} --body-file response.md`].join("\n")
|
|
395
430
|
: undefined;
|
|
396
431
|
if (isMemoryContext) {
|
|
397
432
|
return [
|
package/runner/src/config.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { DEFAULT_RELAY_URL, RELAY_TOKEN_HEADER, errMessage, stringValue } from "
|
|
|
6
6
|
import type { SettingEntry, WorkspaceMetadata } from "agent-relay-sdk";
|
|
7
7
|
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
8
8
|
import { getManifest } from "agent-relay-providers";
|
|
9
|
+
export { messageBodyMaxCharsFromEnv } from "./message-body-config";
|
|
9
10
|
|
|
10
11
|
interface ProviderConfig {
|
|
11
12
|
command: string;
|
|
@@ -241,10 +242,6 @@ export function providerHomeRootFromEnv(): string {
|
|
|
241
242
|
return process.env.AGENT_RELAY_PROVIDER_HOME_ROOT || join(homedir(), ".agent-relay", "provider-homes");
|
|
242
243
|
}
|
|
243
244
|
|
|
244
|
-
export function messageBodyMaxCharsFromEnv(): string | undefined {
|
|
245
|
-
return process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
245
|
export function loadProviderConfig(provider: string, home = agentRelayHome()): LoadedProviderConfig {
|
|
249
246
|
const path = join(providersDir(home), `${provider}.json`);
|
|
250
247
|
const raw = readJson(path);
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { isRecord } from "agent-relay-sdk";
|
|
10
10
|
import { attachBranchState } from "./agent-branch-state";
|
|
11
11
|
import { deriveAgentBusyHealth } from "./agent-busy-health";
|
|
12
|
+
import { attachCommandActivity } from "./agent-command-activity";
|
|
12
13
|
import { attachIssueRepo } from "./agent-issue-repo";
|
|
13
14
|
import type { AgentCard } from "./types";
|
|
14
15
|
|
|
@@ -28,8 +29,11 @@ const OMITTED_STATUS_WORKSPACE_KEYS = new Set([
|
|
|
28
29
|
/** Enrich an AgentCard with the derived fields the dashboard expects on every surface. */
|
|
29
30
|
export function projectAgentCard(agent: AgentCard): AgentCard {
|
|
30
31
|
const projected = attachIssueRepo(attachBranchState(agent));
|
|
32
|
+
// busy-health derives from the RAW provider status, so read it before the #1004 command-busy
|
|
33
|
+
// overlay — a running background command must not make the agent look stuck-busy.
|
|
31
34
|
const busyHealth = deriveAgentBusyHealth(projected);
|
|
32
|
-
|
|
35
|
+
const enriched = busyHealth ? { ...projected, meta: { ...(projected.meta ?? {}), busyHealth } } : projected;
|
|
36
|
+
return attachCommandActivity(enriched);
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
function projectStatusMeta(meta: AgentCard["meta"]): AgentCard["meta"] | undefined {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// #1004 — the single home for the running-command → busy DISPLAY rule.
|
|
2
|
+
//
|
|
3
|
+
// A background command scheduled via relay_schedule_timer runs on the creating agent's behalf
|
|
4
|
+
// (on an orchestrator). The provider reports that agent `idle` the instant its own turn ends,
|
|
5
|
+
// so the still-running command is invisible on the dashboard — it reads idle while a real shell
|
|
6
|
+
// command (e.g. a release) executes. Reflect it as `busy` for the command's lifetime.
|
|
7
|
+
//
|
|
8
|
+
// Every agent-card serialization boundary routes through here — the SSE projection
|
|
9
|
+
// (agent-card-projection.ts) AND the REST reads (routes/agents.ts) — mirroring how
|
|
10
|
+
// attachBranchState/attachIssueRepo enrich at those same boundaries, so live ticks and polls
|
|
11
|
+
// agree. A prompt-wakeup timer (no command) is a plain future alarm and never triggers this.
|
|
12
|
+
//
|
|
13
|
+
// Override only a resting `idle`/`online`: never mask a genuine offline/stale/error, and never
|
|
14
|
+
// fabricate work the provider itself already reports (busy stays busy). The busy-ness is derived
|
|
15
|
+
// live from the timer/command rows (the source of truth via agentHasRunningCommandTimer), not a
|
|
16
|
+
// duplicated status flag — so it self-heals when the command settles and can't leak a
|
|
17
|
+
// permanently-busy agent. It is a display overlay only: the stored provider status the reconcilers
|
|
18
|
+
// read (getAgent/listAgents) is untouched, so stuck-busy health and lifecycle gating are unaffected.
|
|
19
|
+
import { agentHasRunningCommandTimer, agentsWithRunningCommandTimers } from "./db";
|
|
20
|
+
import type { AgentCard } from "./types";
|
|
21
|
+
|
|
22
|
+
function applyCommandBusy(agent: AgentCard, running: boolean): AgentCard {
|
|
23
|
+
if (!running) return agent;
|
|
24
|
+
if (agent.status !== "idle" && agent.status !== "online") return agent;
|
|
25
|
+
return { ...agent, status: "busy" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Overlay running-command busy on a single card (SSE projection + REST get-by-id). */
|
|
29
|
+
export function attachCommandActivity(agent: AgentCard): AgentCard {
|
|
30
|
+
return applyCommandBusy(agent, agentHasRunningCommandTimer(agent.id));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Batch variant for list endpoints: one query, then map (REST get-many). */
|
|
34
|
+
export function attachCommandActivities(agents: AgentCard[]): AgentCard[] {
|
|
35
|
+
if (agents.length === 0) return agents;
|
|
36
|
+
const running = agentsWithRunningCommandTimers();
|
|
37
|
+
if (running.size === 0) return agents;
|
|
38
|
+
return agents.map((agent) => applyCommandBusy(agent, running.has(agent.id)));
|
|
39
|
+
}
|
|
@@ -202,7 +202,7 @@ function maybeFailNeverReadyOfflineTarget(run: AutomationRun, task: Task, now: n
|
|
|
202
202
|
automationTargetNeverReady: true,
|
|
203
203
|
agentId,
|
|
204
204
|
},
|
|
205
|
-
});
|
|
205
|
+
}, { privileged: true });
|
|
206
206
|
const updatedTask = taskResult.task ?? getTask(task.id) ?? task;
|
|
207
207
|
updateRun(run.id, {
|
|
208
208
|
status: "failed",
|
|
@@ -248,7 +248,7 @@ function maybeTimeoutRuntimeBudget(run: AutomationRun, task: Task, now: number):
|
|
|
248
248
|
maxRuntimeMs: budget.maxRuntimeMs,
|
|
249
249
|
deadlineAt: deadline,
|
|
250
250
|
},
|
|
251
|
-
});
|
|
251
|
+
}, { privileged: true });
|
|
252
252
|
const updatedTask = taskResult.task ?? getTask(task.id) ?? task;
|
|
253
253
|
const command = requestOnDemandAgentShutdown(run, updatedTask, now, "runtime-budget-timeout");
|
|
254
254
|
updateRun(run.id, {
|
package/src/cli/workspace.ts
CHANGED
|
@@ -150,7 +150,9 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
if (action === "cleanup-stale") {
|
|
153
|
-
|
|
153
|
+
// #965 — --force discards an abandoned offline-owner worktree even when its git state is a
|
|
154
|
+
// conflict/unlanded (the land-safety gate is right for live work, wrong for a dead branch).
|
|
155
|
+
const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute, force, ...(idExplicit && id ? { workspaceId: id } : {}) });
|
|
154
156
|
console.log(JSON.stringify(result, null, 2));
|
|
155
157
|
return;
|
|
156
158
|
}
|
package/src/config.ts
CHANGED
|
@@ -65,7 +65,16 @@ export const STUCK_BUSY_STARTUP_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_STAR
|
|
|
65
65
|
export const STUCK_BUSY_RECONNECT_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_RECONNECT_MS", STUCK_BUSY_THRESHOLD_MS);
|
|
66
66
|
export const STUCK_BUSY_SHUTDOWN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_SHUTDOWN_MS", STUCK_BUSY_THRESHOLD_MS);
|
|
67
67
|
export const EVENT_LOOP_LAG_SAMPLE_MS = envPositiveInt("AGENT_RELAY_EVENT_LOOP_LAG_SAMPLE_MS", 1_000);
|
|
68
|
-
|
|
68
|
+
// #1055 invariant: retain STRICTLY MORE than one reaper interval of lag history. The
|
|
69
|
+
// stale/bus reaper reads the cumulative lag grace once per REAP_INTERVAL_MS; if the ring
|
|
70
|
+
// held only one interval's worth (the old 60×1s == 60s == REAP_INTERVAL coincidence),
|
|
71
|
+
// the oldest stall samples could rotate out the instant before the first post-recovery
|
|
72
|
+
// reaper run and collapse the grace exactly when it's needed. Keep a half-interval margin
|
|
73
|
+
// (1.5× reaper interval) so recent overload always survives to the next reaper tick.
|
|
74
|
+
export const EVENT_LOOP_LAG_WINDOW_SAMPLES = envPositiveInt(
|
|
75
|
+
"AGENT_RELAY_EVENT_LOOP_LAG_WINDOW_SAMPLES",
|
|
76
|
+
Math.ceil((REAP_INTERVAL_MS / EVENT_LOOP_LAG_SAMPLE_MS) * 1.5),
|
|
77
|
+
);
|
|
69
78
|
export const EVENT_LOOP_LAG_WARN_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_WARN_MS", 250);
|
|
70
79
|
export const EVENT_LOOP_LAG_LOG_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_LOG_MS", EVENT_LOOP_LAG_WARN_MS);
|
|
71
80
|
// #713 — provider-token silence ALONE is not a wedge signal for Claude (long turns, extended
|
package/src/db/agents.ts
CHANGED
|
@@ -145,7 +145,7 @@ function tagsWithProvider(input: RegisterAgentInput): string[] {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
|
|
148
|
-
export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
148
|
+
export function upsertAgent(input: RegisterAgentInput, opts?: { verifiedKeepJtis?: string[] }): AgentCard {
|
|
149
149
|
const now = Date.now();
|
|
150
150
|
const kind = inferAgentKind(input);
|
|
151
151
|
if (kind === "channel") {
|
|
@@ -190,35 +190,48 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
190
190
|
last_seen = $now
|
|
191
191
|
`);
|
|
192
192
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
193
|
+
// #1057(2) — the takeover revoke fires on the SAME predicate as the epoch bump, by observing
|
|
194
|
+
// the bump itself (prior.epoch !== next.epoch) rather than re-deriving the SQL's instance-
|
|
195
|
+
// transition condition. The old guard in registerAgent required a truthy prior instanceId, so a
|
|
196
|
+
// NULL→instanceId takeover bumped the epoch yet skipped the revoke, stranding the prior token
|
|
197
|
+
// until the 24h prune. #1057(4) — write + revoke share one transaction: a revoke failure rolls
|
|
198
|
+
// the epoch bump back, so registration fails loudly with no committed bump / un-revoked token.
|
|
199
|
+
const agent = getDb().transaction(() => {
|
|
200
|
+
const prior = getAgent(input.id);
|
|
201
|
+
stmt.run({
|
|
202
|
+
$id: input.id,
|
|
203
|
+
$name: input.name,
|
|
204
|
+
$kind: kind,
|
|
205
|
+
$label: input.label ?? null,
|
|
206
|
+
$labelProvided: labelProvided ? 1 : 0,
|
|
207
|
+
$tags: JSON.stringify(tags),
|
|
208
|
+
$machine: input.machine ?? null,
|
|
209
|
+
$rig: input.rig ?? null,
|
|
210
|
+
$capabilities: JSON.stringify(input.capabilities ?? []),
|
|
211
|
+
$ready: input.ready ? 1 : 0,
|
|
212
|
+
$readyProvided: readyProvided ? 1 : 0,
|
|
213
|
+
$status: input.status ?? "idle",
|
|
214
|
+
$instanceId: input.instanceId ?? null,
|
|
215
|
+
$instanceProvided: instanceProvided ? 1 : 0,
|
|
216
|
+
$initialEpoch: instanceProvided ? 1 : 0,
|
|
217
|
+
$providerCapabilities: input.providerCapabilities ? JSON.stringify(input.providerCapabilities) : null,
|
|
218
|
+
$contextState: contextState ? JSON.stringify(contextState) : null,
|
|
219
|
+
$quotaState: input.quota ? JSON.stringify(input.quota) : null,
|
|
220
|
+
$meta: JSON.stringify(input.meta ?? {}),
|
|
221
|
+
$lifecycle: input.lifecycle ?? "persistent",
|
|
222
|
+
$lifecycleProvided: lifecycleProvided ? 1 : 0,
|
|
223
|
+
$spawnedBy: input.spawnedBy ?? null,
|
|
224
|
+
$teamId: input.teamId ?? null,
|
|
225
|
+
$now: now,
|
|
226
|
+
});
|
|
227
|
+
const next = getAgent(input.id)!;
|
|
228
|
+
if (prior && next.epoch !== prior.epoch) {
|
|
229
|
+
revokeSupersededRuntimeTokens(prior, opts?.verifiedKeepJtis ?? [], now);
|
|
230
|
+
}
|
|
231
|
+
return next;
|
|
232
|
+
})();
|
|
219
233
|
if (contextState) recordContextSnapshot(input.id, contextState, now);
|
|
220
234
|
|
|
221
|
-
const agent = getAgent(input.id)!;
|
|
222
235
|
seedContinuationObjectiveFromSpawnPrompt(agent.id, stringValue(agent.meta?.spawnRequestId), DEFAULT_SELF_RESUME_CONFIG, now);
|
|
223
236
|
if (agent.kind === "channel") upsertChannelForAgent(agent);
|
|
224
237
|
evaluatePoolBindings();
|
|
@@ -363,15 +376,49 @@ export function clearAgentMetaKeys(id: string, keys: string[]): boolean {
|
|
|
363
376
|
return getDb().query("UPDATE agents SET meta = ? WHERE id = ?").run(JSON.stringify(meta), id).changes > 0;
|
|
364
377
|
}
|
|
365
378
|
|
|
379
|
+
/**
|
|
380
|
+
* #1059 — decide whether a process-exit certificate belongs to an agent row's CURRENT
|
|
381
|
+
* instance. A late/duplicate orchestrator exit report for a SUPERSEDED session (the agent id
|
|
382
|
+
* re-registered as a fresh instance after the report was generated) carries the prior session's
|
|
383
|
+
* `spawnRequestId`; the live successor's meta carries the new one. When both are known and
|
|
384
|
+
* differ, the certificate is for a dead predecessor and must never mark the successor gone.
|
|
385
|
+
* Unknown on either side (legacy / unmanaged sessions that never carried a spawnRequestId) can't
|
|
386
|
+
* be disproven, so it is treated as matching — preserving the pre-#1059 fast-exit path.
|
|
387
|
+
*
|
|
388
|
+
* The single home for the death-certificate/instance identity check, shared by the stamp site
|
|
389
|
+
* (recordAgentExitDiagnostics, below) and the reaper's death predicate (isOwnerProcessConfirmedGone
|
|
390
|
+
* in workspace-merge.ts) so the two can't drift.
|
|
391
|
+
*/
|
|
392
|
+
export function exitCertificateMatchesInstance(
|
|
393
|
+
currentMeta: Record<string, unknown> | undefined,
|
|
394
|
+
certificate: { spawnRequestId?: unknown } | undefined,
|
|
395
|
+
): boolean {
|
|
396
|
+
const current = currentMeta ? stringValue(currentMeta.spawnRequestId) : undefined;
|
|
397
|
+
const cert = certificate ? stringValue(certificate.spawnRequestId) : undefined;
|
|
398
|
+
if (current && cert && current !== cert) return false;
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
|
|
366
402
|
export function recordAgentExitDiagnostics(id: string, diagnostics: ManagedSessionExitDiagnostics): AgentCard | null {
|
|
367
403
|
const agent = getAgent(id);
|
|
368
404
|
if (!agent) return null;
|
|
405
|
+
// #1059 — drop a stale/duplicate exit report for a SUPERSEDED instance. The agent id
|
|
406
|
+
// re-registered as a fresh session (new spawnRequestId) after this report was generated;
|
|
407
|
+
// stamping its predecessor's certificate here would poison the LIVE successor's row (false
|
|
408
|
+
// offline flip + false death notification via the caller + a certificate the dead-owner reaper
|
|
409
|
+
// would later act on — the #1054 reopener). Returning null makes the caller skip both the status
|
|
410
|
+
// emit and notifyAgentOffline, exactly as for an absent row.
|
|
411
|
+
if (!exitCertificateMatchesInstance(isRecord(agent.meta) ? agent.meta : undefined, diagnostics)) return null;
|
|
369
412
|
const now = Date.now();
|
|
370
413
|
const merged = {
|
|
371
414
|
...(agent.meta ?? {}),
|
|
372
415
|
lastError: diagnostics.lastError,
|
|
373
416
|
lastExit: diagnostics,
|
|
374
417
|
lastExitAt: diagnostics.detectedAt || now,
|
|
418
|
+
// #1057(1) — RELAY-clock receipt of the process-confirmed death. The orphan-token sweep
|
|
419
|
+
// (revokeOrphanedRuntimeTokens) compares this against last_seen (also relay clock); the
|
|
420
|
+
// orchestrator-clock detectedAt in lastExitAt is not comparable across hosts.
|
|
421
|
+
lastExitReceivedAt: now,
|
|
375
422
|
};
|
|
376
423
|
// #636 — do NOT clobber last_seen with `now`: it is the agent's genuine last heartbeat, and the
|
|
377
424
|
// death diagnosis derives the real heartbeat gap from it (the orchestrator exit path diagnoses
|
|
@@ -585,6 +632,35 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
|
585
632
|
return deleted ? { ok: true } : { ok: false, error: "agent not found" };
|
|
586
633
|
}
|
|
587
634
|
|
|
635
|
+
// #1057(1) — close the orphan-token window #1051 left open. A dead runner with no replacement
|
|
636
|
+
// used to keep its token valid until the 24h offline prune (~720× the pre-#1051 ~2min horizon).
|
|
637
|
+
// This sweep revokes ("stale", re-mintable — #484) the runtime tokens of agents whose process the
|
|
638
|
+
// orchestrator CONFIRMED dead (meta.lastExit, the #1054 signal — never a liveness inference, so
|
|
639
|
+
// the #1051 decouple holds) once a full grace has passed with no heartbeat since the relay
|
|
640
|
+
// received the report. The two guards make a late exit report harmless to a live replacement:
|
|
641
|
+
// - a replacement registration replaces meta wholesale (no lastExitReceivedAt) → excluded;
|
|
642
|
+
// - a replacement that got the late report stamped ONTO its row keeps heartbeating, pushing
|
|
643
|
+
// last_seen past the receipt (heartbeat also flips it off 'offline') → excluded.
|
|
644
|
+
// `lagGraceMs` mirrors the stale reaper's #1048/#1055 discipline: while the relay's own event
|
|
645
|
+
// loop was stalled, a live claimant's heartbeats may not have been processed yet, so the window
|
|
646
|
+
// widens by the observed lag rather than misreading the backlog as post-mortem silence.
|
|
647
|
+
export function revokeOrphanedRuntimeTokens(graceMs: number = STALE_TTL_MS, lagGraceMs = 0, now = Date.now()): Array<{ agentId: string; jtis: string[] }> {
|
|
648
|
+
const cutoff = now - graceMs - Math.max(0, lagGraceMs);
|
|
649
|
+
const rows = getDb()
|
|
650
|
+
.query("SELECT id, meta, last_seen FROM agents WHERE status = 'offline' AND meta LIKE '%lastExitReceivedAt%' AND id NOT IN ('user', 'system')")
|
|
651
|
+
.all() as Array<{ id: string; meta: string | null; last_seen: number }>;
|
|
652
|
+
const revoked: Array<{ agentId: string; jtis: string[] }> = [];
|
|
653
|
+
for (const row of rows) {
|
|
654
|
+
const meta = parseJson<Record<string, unknown>>(row.meta ?? "{}", {});
|
|
655
|
+
const receivedAt = typeof meta.lastExitReceivedAt === "number" ? meta.lastExitReceivedAt : undefined;
|
|
656
|
+
if (receivedAt === undefined || receivedAt > cutoff) continue;
|
|
657
|
+
if (row.last_seen > receivedAt) continue;
|
|
658
|
+
const jtis = revokeProviderRuntimeTokensByJti(runtimeTokenJtisFromMeta(meta), "stale", now);
|
|
659
|
+
if (jtis.length > 0) revoked.push({ agentId: row.id, jtis });
|
|
660
|
+
}
|
|
661
|
+
return revoked;
|
|
662
|
+
}
|
|
663
|
+
|
|
588
664
|
// #484: `reason` is stamped on each revocation so a stale-on-restart revocation
|
|
589
665
|
// (recoverable via orchestrator re-mint for a still-live process) is distinguishable
|
|
590
666
|
// from a hard kill. Default "agent-removed" (hard) — callers that revoke a possibly-
|
|
@@ -598,17 +674,22 @@ export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now(), r
|
|
|
598
674
|
// #1051 — the #484 restart-strand invariant, re-homed from heartbeat-lapse to instance
|
|
599
675
|
// takeover. Staleness (a missed heartbeat) no longer revokes anything; instead, when a
|
|
600
676
|
// FRESH runner process re-registers under an existing agent id — a new instanceId, which
|
|
601
|
-
// bumps the agent epoch
|
|
602
|
-
//
|
|
603
|
-
// orchestrator (#484), so a live process that merely
|
|
604
|
-
// while an orphaned prior-instance token can no
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
677
|
+
// bumps the agent epoch — the PRIOR instance's runtime tokens are revoked "stale" here,
|
|
678
|
+
// atomically with the bump (fired from inside upsertAgent). "stale" (not a hard kill)
|
|
679
|
+
// keeps them re-mintable via the orchestrator (#484), so a live process that merely
|
|
680
|
+
// rotated instances still self-heals, while an orphaned prior-instance token can no
|
|
681
|
+
// longer authenticate a request directly. A same-instance reconnect (relay-restart / lag
|
|
682
|
+
// recovery) keeps its instanceId, does not bump the epoch, and so never reaches this
|
|
683
|
+
// path — its token stays valid untouched.
|
|
684
|
+
// #1057(3) — `keepJtis` must be SERVER-verified (the jti of the token that authenticated the
|
|
685
|
+
// registration, resolved to this very agent — see registerAgent). It is never derived from the
|
|
686
|
+
// incoming client meta: a takeover caller could otherwise list the prior jti in meta.auth.jtis
|
|
687
|
+
// and spare it from revocation. The incoming instance's own token needs no keep entry — only
|
|
688
|
+
// PRIOR jtis are ever revoked, so a fresh token is structurally never collateral.
|
|
689
|
+
export function revokeSupersededRuntimeTokens(prior: AgentCard, keepJtis: string[] = [], now = Date.now()): string[] {
|
|
609
690
|
const priorJtis = runtimeTokenJtisFromMeta((prior.meta ?? {}) as Record<string, unknown>);
|
|
610
691
|
if (priorJtis.length === 0) return [];
|
|
611
|
-
const keep = new Set(
|
|
692
|
+
const keep = new Set(keepJtis);
|
|
612
693
|
const superseded = priorJtis.filter((jti) => !keep.has(jti));
|
|
613
694
|
return revokeProviderRuntimeTokensByJti(superseded, "stale", now);
|
|
614
695
|
}
|
package/src/db/message-reads.ts
CHANGED
|
@@ -523,6 +523,19 @@ export function listPendingReplyObligations(agentId: string, limit = 20): ReplyO
|
|
|
523
523
|
*
|
|
524
524
|
* Returns the inbound message id this outbound answers, or null when nothing should be auto-linked.
|
|
525
525
|
*/
|
|
526
|
+
/**
|
|
527
|
+
* #976 — the most recent message `fromId` addressed to `recipientId` (direct target OR resolved
|
|
528
|
+
* recipient). Used to anchor a suppressed standalone self→parent ack onto the conversation it
|
|
529
|
+
* belongs to, so a bare ack collapses onto the inbound it answers — mirroring the reply-path
|
|
530
|
+
* collapse (which returns the parent message being acked). Returns null when no such inbound exists.
|
|
531
|
+
*/
|
|
532
|
+
export function latestInboundMessage(recipientId: string, fromId: string): Message | null {
|
|
533
|
+
const sql = `${MSG_SELECT} WHERE m.from_agent = ? AND (m.to_target = ? OR m.resolved_to_agent = ?) ORDER BY m.id DESC LIMIT 1`;
|
|
534
|
+
const rows = timedQuery("latestInboundMessage", () =>
|
|
535
|
+
getDb().query(sql).all(fromId, recipientId, recipientId) as any[]);
|
|
536
|
+
return rows.length ? rowToMessage(rows[0]) : null;
|
|
537
|
+
}
|
|
538
|
+
|
|
526
539
|
export function channelObligationAnsweredBy(
|
|
527
540
|
agentId: string,
|
|
528
541
|
channelAgentId: string,
|
|
@@ -624,6 +637,43 @@ export function readSpawnReportVerbosity(spawnRequestId: string | undefined): Re
|
|
|
624
637
|
}
|
|
625
638
|
}
|
|
626
639
|
|
|
640
|
+
/**
|
|
641
|
+
* #1062 — the RAW caller/parent brief a spawner supplied, read from the persisted `agent.spawn`
|
|
642
|
+
* command params by spawnRequestId. Mirrors {@link readSpawnReportVerbosity}: the dashboard
|
|
643
|
+
* (non-policy) restart path recovers this and re-threads it as `callerSystemPromptAppend` so a
|
|
644
|
+
* restarted spawned child keeps its parent's task brief while the rest of the projection
|
|
645
|
+
* (project/profile/scoped memory) is re-composed fresh. Returns undefined when absent/unparseable
|
|
646
|
+
* (pre-#1062 spawns never persisted it → the restart simply re-projects without a caller brief).
|
|
647
|
+
*
|
|
648
|
+
* #1067 — recovers across ANY number of dashboard restarts, not just one. #1065 rotates the
|
|
649
|
+
* spawnRequestId on every restart and never writes an agent.spawn row for the successor, so keying
|
|
650
|
+
* only on the original agent.spawn row lost the brief from the 2nd restart onward (the predecessor
|
|
651
|
+
* id was then the fresh id, which has no spawn row). The dashboard restart path re-persists the raw
|
|
652
|
+
* brief under each fresh id on the restart command's `restartSpawn.callerSystemPromptAppend`
|
|
653
|
+
* (agent-sessions.ts), so this lookup also matches an `agent.restart` row whose rotated id equals
|
|
654
|
+
* the query id — every generation has its own recoverable copy and the brief follows the agent.
|
|
655
|
+
*/
|
|
656
|
+
export function readSpawnCallerSystemPromptAppend(spawnRequestId: string | undefined): string | undefined {
|
|
657
|
+
if (!spawnRequestId) return undefined;
|
|
658
|
+
try {
|
|
659
|
+
const row = getDb().query(`
|
|
660
|
+
SELECT coalesce(
|
|
661
|
+
json_extract(params, '$.callerSystemPromptAppend'),
|
|
662
|
+
json_extract(params, '$.restartSpawn.callerSystemPromptAppend')
|
|
663
|
+
) AS append
|
|
664
|
+
FROM commands
|
|
665
|
+
WHERE (type = 'agent.spawn' AND correlation_id = ?)
|
|
666
|
+
OR (type = 'agent.restart' AND json_extract(params, '$.restartSpawn.spawnRequestId') = ?)
|
|
667
|
+
ORDER BY created_at DESC
|
|
668
|
+
LIMIT 1
|
|
669
|
+
`).get(spawnRequestId, spawnRequestId) as { append: string | null } | undefined;
|
|
670
|
+
const append = row?.append;
|
|
671
|
+
return typeof append === "string" && append.trim() ? append : undefined;
|
|
672
|
+
} catch {
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
627
677
|
export function markRead(messageId: number, agentId: string): boolean {
|
|
628
678
|
const exists = getDb().query("SELECT 1 FROM messages WHERE id = ?").get(messageId);
|
|
629
679
|
if (!exists) return false;
|
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)) {
|