agent-relay-server 0.121.4 → 0.122.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +1 -1
- package/package.json +5 -4
- package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
- package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
- package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
- package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
- package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
- package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
- package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
- package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
- package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
- package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
- package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
- package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
- package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
- package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
- package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
- package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
- package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
- package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
- package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
- package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
- package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
- package/public/assets/display-CJzSoTMq.js +3 -0
- package/public/assets/display-CJzSoTMq.js.map +1 -0
- package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
- package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
- package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
- package/public/assets/index-D0xMQQb_.js +25 -0
- package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
- package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
- package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
- package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
- package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
- package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
- package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
- package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
- package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
- package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
- package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
- package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
- package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
- package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
- package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
- package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
- package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
- package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
- package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
- package/public/assets/projects-Dzdf-gkK.js +2 -0
- package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
- package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
- package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
- package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
- package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
- package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
- package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
- package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
- package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
- package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
- package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
- package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
- package/public/assets/store-BPrVuKiv.js.map +1 -0
- package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
- package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
- package/public/assets/teams-DWv6UwFV.js +2 -0
- package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
- package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
- package/public/assets/types-BeJUSfDj.js.map +1 -0
- package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
- package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
- package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
- package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
- package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
- package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
- package/runner/src/adapter.ts +53 -18
- package/runner/src/config.ts +1 -4
- package/src/agent-card-projection.ts +5 -1
- package/src/agent-command-activity.ts +39 -0
- package/src/automations/reconcile.ts +2 -2
- package/src/bus.ts +6 -4
- package/src/cli/workspace.ts +3 -1
- package/src/config.ts +10 -1
- package/src/db/agents.ts +138 -32
- package/src/db/message-reads.ts +50 -0
- package/src/db/messages.ts +48 -9
- package/src/db/migrations.ts +54 -0
- package/src/db/provider-quota-burn.ts +96 -67
- package/src/db/provider-quotas.ts +29 -7
- package/src/db/scheduled-timers.ts +21 -0
- package/src/db/schema.ts +1 -0
- package/src/db/tasks.ts +33 -3
- package/src/db/work-evidence.ts +10 -7
- package/src/event-loop-lag.ts +15 -0
- package/src/gate-resolver.ts +24 -1
- package/src/maintenance/jobs.ts +23 -6
- package/src/managed-policy.ts +35 -5
- package/src/mcp-plan-tools.ts +13 -1
- package/src/project-instructions.ts +36 -2
- package/src/quota-advisory.ts +6 -4
- package/src/quota-band-transition.ts +23 -14
- package/src/routes/agent-sessions.ts +39 -7
- package/src/routes/agents.ts +3 -2
- package/src/routes/tasks.ts +16 -4
- package/src/routes/workspaces.ts +35 -10
- package/src/services/plan-graph.ts +27 -9
- package/src/services/register-agent.ts +12 -1
- package/src/services/send-message.ts +43 -15
- package/src/services/spawn-agent.ts +3 -0
- package/src/spawn-command.ts +8 -0
- package/src/spawn-targets.ts +19 -34
- package/src/sse.ts +25 -3
- package/src/workspace-merge.ts +25 -5
- package/src/workspace-orphans.ts +13 -6
- package/public/assets/display-exQDWum3.js +0 -3
- package/public/assets/display-exQDWum3.js.map +0 -1
- package/public/assets/index-D9OogaB5.js +0 -25
- package/public/assets/projects-DdPNPFJI.js +0 -2
- package/public/assets/prompt-settings-BpZse7-X.js +0 -2
- package/public/assets/store-DKTLubBT.js.map +0 -1
- package/public/assets/teams-B8f2pGJe.js +0 -2
- package/public/assets/types-uVOXgvMT.js.map +0 -1
package/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/bus.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
-
import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent,
|
|
2
|
+
import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, setStatus, validateAgentSession } from "./db";
|
|
3
3
|
import { getOutboxCursor, hasReplayGap, replayEventsSlice, type BusEvent } from "./bus-outbox";
|
|
4
4
|
import { projectAgentStatusData } from "./agent-card-projection";
|
|
5
5
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
@@ -127,9 +127,11 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs(), lagGraceMs = 0
|
|
|
127
127
|
const rows = getDb().query("SELECT id FROM agents WHERE status = 'stale' AND last_seen < ? AND id NOT IN ('user', 'system')").all(cutoff) as Array<{ id: string }>;
|
|
128
128
|
const orphanedTasks: Task[] = [];
|
|
129
129
|
for (const row of rows) {
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
130
|
+
// #1051 — bus-grace staleness is a liveness observation, not a credential fact: the
|
|
131
|
+
// process may still be alive (relay stall / network blip). Mark offline for routing,
|
|
132
|
+
// but do NOT revoke the token — a reconnect accepts the EXISTING token and re-marks
|
|
133
|
+
// live. The #484 restart-strand revoke now fires on instance takeover in registerAgent
|
|
134
|
+
// (a fresh instanceId → epoch bump), never on a heartbeat lapse.
|
|
133
135
|
setStatus(row.id, "offline");
|
|
134
136
|
emitAgentStatusEvent(row.id);
|
|
135
137
|
// #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
|
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
|
|
@@ -505,10 +552,13 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS, lagGraceMs = 0): s
|
|
|
505
552
|
)
|
|
506
553
|
.all(cutoff) as any[];
|
|
507
554
|
for (const row of rows) {
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
555
|
+
// #1051 — liveness ≠ credential validity. A lapsed heartbeat is a transient,
|
|
556
|
+
// recoverable observation (the relay may have stalled, or the network blipped);
|
|
557
|
+
// it marks status only and must NOT void a still-unexpired token. On reconnect the
|
|
558
|
+
// relay accepts the agent's EXISTING token and re-marks it live. The #484 restart-
|
|
559
|
+
// strand invariant (an orphaned prior-instance token must not stay usable) is now
|
|
560
|
+
// enforced on instance takeover instead — see revokeSupersededRuntimeTokens, fired
|
|
561
|
+
// from registerAgent when a fresh process re-registers under a new instanceId.
|
|
512
562
|
closeOpenPairsForAgent(row.id, now);
|
|
513
563
|
}
|
|
514
564
|
return rows.map((r: any) => r.id);
|
|
@@ -582,6 +632,35 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
|
582
632
|
return deleted ? { ok: true } : { ok: false, error: "agent not found" };
|
|
583
633
|
}
|
|
584
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
|
+
|
|
585
664
|
// #484: `reason` is stamped on each revocation so a stale-on-restart revocation
|
|
586
665
|
// (recoverable via orchestrator re-mint for a still-live process) is distinguishable
|
|
587
666
|
// from a hard kill. Default "agent-removed" (hard) — callers that revoke a possibly-
|
|
@@ -589,6 +668,33 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
|
589
668
|
export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now(), reason: TokenRevokeReason = "agent-removed"): string[] {
|
|
590
669
|
const row = getDb().query("SELECT meta FROM agents WHERE id = ?").get(agentId) as { meta?: string } | undefined;
|
|
591
670
|
const jtis = runtimeTokenJtisFromMeta(parseJson(row?.meta ?? "{}", {}));
|
|
671
|
+
return revokeProviderRuntimeTokensByJti(jtis, reason, now);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// #1051 — the #484 restart-strand invariant, re-homed from heartbeat-lapse to instance
|
|
675
|
+
// takeover. Staleness (a missed heartbeat) no longer revokes anything; instead, when a
|
|
676
|
+
// FRESH runner process re-registers under an existing agent id — a new instanceId, which
|
|
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[] {
|
|
690
|
+
const priorJtis = runtimeTokenJtisFromMeta((prior.meta ?? {}) as Record<string, unknown>);
|
|
691
|
+
if (priorJtis.length === 0) return [];
|
|
692
|
+
const keep = new Set(keepJtis);
|
|
693
|
+
const superseded = priorJtis.filter((jti) => !keep.has(jti));
|
|
694
|
+
return revokeProviderRuntimeTokensByJti(superseded, "stale", now);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function revokeProviderRuntimeTokensByJti(jtis: string[], reason: TokenRevokeReason, now: number): string[] {
|
|
592
698
|
if (jtis.length === 0) return [];
|
|
593
699
|
const revokedAt = Math.floor(now / 1000);
|
|
594
700
|
const placeholders = jtis.map(() => "?").join(",");
|
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;
|