agent-relay-server 0.124.0 → 0.125.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 +2 -2
- package/public/assets/{MessageBubble-SQK8-Mzp.js → MessageBubble-BYeYFteG.js} +2 -2
- package/public/assets/{MessageBubble-SQK8-Mzp.js.map → MessageBubble-BYeYFteG.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CS-aow4S.js → PlanGraphPanel-iAXvkZKW.js} +2 -2
- package/public/assets/{PlanGraphPanel-CS-aow4S.js.map → PlanGraphPanel-iAXvkZKW.js.map} +1 -1
- package/public/assets/{activity-W31XvhC2.js → activity-Du9SRJt5.js} +2 -2
- package/public/assets/{activity-W31XvhC2.js.map → activity-Du9SRJt5.js.map} +1 -1
- package/public/assets/agent-profiles-DXDQnrlH.js +2 -0
- package/public/assets/{agent-profiles-DXysA9To.js.map → agent-profiles-DXDQnrlH.js.map} +1 -1
- package/public/assets/{agent-type-icon-BxUopALO.js → agent-type-icon-B3i0rndK.js} +2 -2
- package/public/assets/{agent-type-icon-BxUopALO.js.map → agent-type-icon-B3i0rndK.js.map} +1 -1
- package/public/assets/{agents-B5GRNRnN.js → agents-CWOdKrxP.js} +2 -2
- package/public/assets/{agents-B5GRNRnN.js.map → agents-CWOdKrxP.js.map} +1 -1
- package/public/assets/{analytics-DqAuUAwX.js → analytics-YcY59Hv9.js} +2 -2
- package/public/assets/{analytics-DqAuUAwX.js.map → analytics-YcY59Hv9.js.map} +1 -1
- package/public/assets/{automation-DYlgHYcb.js → automation-CItTse7F.js} +2 -2
- package/public/assets/{automation-DYlgHYcb.js.map → automation-CItTse7F.js.map} +1 -1
- package/public/assets/{branch-state-badge-Mf0Ow77K.js → branch-state-badge-QjTktTYj.js} +2 -2
- package/public/assets/{branch-state-badge-Mf0Ow77K.js.map → branch-state-badge-QjTktTYj.js.map} +1 -1
- package/public/assets/{channels-DVRkVeky.js → channels-BSFTsbhT.js} +2 -2
- package/public/assets/{channels-DVRkVeky.js.map → channels-BSFTsbhT.js.map} +1 -1
- package/public/assets/chat-CRzFSFWe.js +2 -0
- package/public/assets/chat-CRzFSFWe.js.map +1 -0
- package/public/assets/{connectors-Cqubkw9C.js → connectors-C7d5dlYz.js} +2 -2
- package/public/assets/{connectors-Cqubkw9C.js.map → connectors-C7d5dlYz.js.map} +1 -1
- package/public/assets/{display-GFbGUG0O.js → display-aTnwsqQh.js} +3 -3
- package/public/assets/{display-GFbGUG0O.js.map → display-aTnwsqQh.js.map} +1 -1
- package/public/assets/{formatted-body-BYQ-SPtj.js → formatted-body-BuLfVMeD.js} +3 -3
- package/public/assets/{formatted-body-BYQ-SPtj.js.map → formatted-body-BuLfVMeD.js.map} +1 -1
- package/public/assets/{formatted-body-impl-L1EMnNW6.js → formatted-body-impl-DO-8giS6.js} +2 -2
- package/public/assets/{formatted-body-impl-L1EMnNW6.js.map → formatted-body-impl-DO-8giS6.js.map} +1 -1
- package/public/assets/index-C9X_0QBt.css +2 -0
- package/public/assets/index-De1tR7J4.js +26 -0
- package/public/assets/index-De1tR7J4.js.map +1 -0
- package/public/assets/{insights-DwZxq1Dn.js → insights-Ci3VuA5Y.js} +2 -2
- package/public/assets/{insights-DwZxq1Dn.js.map → insights-Ci3VuA5Y.js.map} +1 -1
- package/public/assets/{integrations-9G5TJnnU.js → integrations-BboxWQQD.js} +2 -2
- package/public/assets/{integrations-9G5TJnnU.js.map → integrations-BboxWQQD.js.map} +1 -1
- package/public/assets/{maintenance-COcmUDnf.js → maintenance-CO1miJIJ.js} +2 -2
- package/public/assets/{maintenance-COcmUDnf.js.map → maintenance-CO1miJIJ.js.map} +1 -1
- package/public/assets/{managed-agents-D9-dgMeX.js → managed-agents-r4oYK3Fm.js} +2 -2
- package/public/assets/{managed-agents-D9-dgMeX.js.map → managed-agents-r4oYK3Fm.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DH372bL-.js → markdown-preview-impl-5CCM9iyL.js} +2 -2
- package/public/assets/{markdown-preview-impl-DH372bL-.js.map → markdown-preview-impl-5CCM9iyL.js.map} +1 -1
- package/public/assets/{memory-B9hRK8sk.js → memory-BJJS07W-.js} +2 -2
- package/public/assets/{memory-B9hRK8sk.js.map → memory-BJJS07W-.js.map} +1 -1
- package/public/assets/{messages-BHkHlXzF.js → messages-RT_dfEsL.js} +2 -2
- package/public/assets/{messages-BHkHlXzF.js.map → messages-RT_dfEsL.js.map} +1 -1
- package/public/assets/{orchestrators-CXy3SDSn.js → orchestrators-8hyl8hW1.js} +2 -2
- package/public/assets/{orchestrators-CXy3SDSn.js.map → orchestrators-8hyl8hW1.js.map} +1 -1
- package/public/assets/{overview-D2yRyCQq.js → overview-CUJ9QmW_.js} +2 -2
- package/public/assets/{overview-D2yRyCQq.js.map → overview-CUJ9QmW_.js.map} +1 -1
- package/public/assets/{pairs-5tXdHylI.js → pairs-CzkzMGZq.js} +2 -2
- package/public/assets/{pairs-5tXdHylI.js.map → pairs-CzkzMGZq.js.map} +1 -1
- package/public/assets/{projects-CquHTMo1.js → projects-CAuLWBU5.js} +2 -2
- package/public/assets/{projects-CquHTMo1.js.map → projects-CAuLWBU5.js.map} +1 -1
- package/public/assets/{prompt-settings-Bn5mmi9w.js → prompt-settings-BnB90R7F.js} +2 -2
- package/public/assets/{prompt-settings-Bn5mmi9w.js.map → prompt-settings-BnB90R7F.js.map} +1 -1
- package/public/assets/{registry-C3sB7qv5.js → registry-B8TInqru.js} +2 -2
- package/public/assets/{registry-C3sB7qv5.js.map → registry-B8TInqru.js.map} +1 -1
- package/public/assets/{security-CmD-YE-_.js → security-DG_VVZUW.js} +2 -2
- package/public/assets/{security-CmD-YE-_.js.map → security-DG_VVZUW.js.map} +1 -1
- package/public/assets/{select-C0UlhK2k.js → select-BJ8G38_V.js} +2 -2
- package/public/assets/{select-C0UlhK2k.js.map → select-BJ8G38_V.js.map} +1 -1
- package/public/assets/{settings-DrqMG5nG.js → settings-CK_T9z5w.js} +4 -4
- package/public/assets/{settings-DrqMG5nG.js.map → settings-CK_T9z5w.js.map} +1 -1
- package/public/assets/{store-zFLzd0Jd.js → store-Dl9Z8YUS.js} +4 -4
- package/public/assets/{store-zFLzd0Jd.js.map → store-Dl9Z8YUS.js.map} +1 -1
- package/public/assets/{tasks-ChQjtXHu.js → tasks-Briui9Mp.js} +2 -2
- package/public/assets/{tasks-ChQjtXHu.js.map → tasks-Briui9Mp.js.map} +1 -1
- package/public/assets/{teams-itjNlDLp.js → teams-Ck4EVV3R.js} +2 -2
- package/public/assets/{teams-itjNlDLp.js.map → teams-Ck4EVV3R.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-B9M6s2vB.js → terminal-viewer-impl-66ZjVN1v.js} +2 -2
- package/public/assets/{terminal-viewer-impl-B9M6s2vB.js.map → terminal-viewer-impl-66ZjVN1v.js.map} +1 -1
- package/public/assets/{types-CRptbSYh.js → types-BqeSkWrv.js} +2 -2
- package/public/assets/{types-CRptbSYh.js.map → types-BqeSkWrv.js.map} +1 -1
- package/public/assets/{user-avatar-kYPfLgXp.js → user-avatar-BOy9o2xK.js} +2 -2
- package/public/assets/{user-avatar-kYPfLgXp.js.map → user-avatar-BOy9o2xK.js.map} +1 -1
- package/public/assets/{work-queue-Kw0uMkEF.js → work-queue-CnghFRG2.js} +2 -2
- package/public/assets/{work-queue-Kw0uMkEF.js.map → work-queue-CnghFRG2.js.map} +1 -1
- package/public/assets/{workspaces-CR6f7G0R.js → workspaces-BzmQnzSm.js} +2 -2
- package/public/assets/{workspaces-CR6f7G0R.js.map → workspaces-BzmQnzSm.js.map} +1 -1
- package/public/index.html +7 -7
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/hooks/relay-status.sh +11 -1
- package/runner/plugins/claude/hooks/stop.sh +6 -1
- package/runner/plugins/claude/hooks/user-prompt-submit.sh +6 -1
- package/runner/src/adapter.ts +5 -1
- package/runner/src/adapters/claude-transcript.ts +52 -58
- package/runner/src/adapters/codex-agent-message-capture.ts +8 -36
- package/runner/src/adapters/codex.ts +30 -12
- package/runner/src/control-server.ts +9 -4
- package/runner/src/response-capture-report.ts +4 -1
- package/runner/src/runner-core.ts +75 -33
- package/src/agent-lifecycle-events.ts +8 -0
- package/src/bus.ts +0 -24
- package/src/db/agents.ts +15 -1
- package/src/db/messages.ts +114 -43
- package/src/maintenance/jobs.ts +8 -2
- package/src/mcp/tools-workspace.ts +4 -0
- package/src/routes/agents.ts +0 -4
- package/src/services/send-message.ts +7 -18
- package/src/services/shutdown-agent.ts +6 -0
- package/src/services/task-entity.ts +4 -0
- package/src/services/transient-agent-reaper.ts +40 -1
- package/src/services/work-finality.ts +25 -0
- package/public/assets/agent-profiles-DXysA9To.js +0 -2
- package/public/assets/chat-Clz6qOtn.js +0 -2
- package/public/assets/chat-Clz6qOtn.js.map +0 -1
- package/public/assets/index-0uIGXT14.js +0 -25
- package/public/assets/index-0uIGXT14.js.map +0 -1
- package/public/assets/index-DOJ7KidA.css +0 -2
|
@@ -16,7 +16,7 @@ import { readRunnerContextProbeState } from "./context-probe-state";
|
|
|
16
16
|
import { startControlServer, type ControlServer, type ResolvedPermissionPrompt } from "./control-server";
|
|
17
17
|
import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureReplyRoute } from "./reply-obligation-cache";
|
|
18
18
|
import { Outbox, type OutboxRecord } from "./outbox";
|
|
19
|
-
import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry,
|
|
19
|
+
import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, extractAssistantTurnForPrompt, countTranscriptEntries, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
|
|
20
20
|
import { IncrementalClaudeTranscriptTail } from "./adapters/claude-transcript-tail";
|
|
21
21
|
import { getManifest } from "agent-relay-providers";
|
|
22
22
|
import { profileUsesProviderHostGlobals } from "./profile-home";
|
|
@@ -1291,6 +1291,10 @@ export class AgentRunner {
|
|
|
1291
1291
|
}
|
|
1292
1292
|
if (status === "busy" && reason === "provider-turn") {
|
|
1293
1293
|
if (!this.currentTurnId) {
|
|
1294
|
+
// For Claude, `update.id` carries the native prompt_id the UserPromptSubmit
|
|
1295
|
+
// hook reported (relay-status.sh → /status) — the same id the Stop hook and
|
|
1296
|
+
// transcript use for this turn. Falls back to a random mint only when no
|
|
1297
|
+
// native id was supplied (e.g. a provider/path that doesn't report one).
|
|
1294
1298
|
this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
|
|
1295
1299
|
this.currentTurnStartedAt = Date.now();
|
|
1296
1300
|
this.compactionMidTurn = false;
|
|
@@ -1423,7 +1427,7 @@ export class AgentRunner {
|
|
|
1423
1427
|
// isPreFlush: true (#435/#499) — mid-turn PreToolUse boundary. Drain the live
|
|
1424
1428
|
// tail and force-flush session messages before showing the blocking control;
|
|
1425
1429
|
// the entry cursor lets Stop capture skip already-emitted text.
|
|
1426
|
-
private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void> {
|
|
1430
|
+
private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean }): Promise<void> {
|
|
1427
1431
|
if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
|
|
1428
1432
|
|
|
1429
1433
|
if (input.isPreFlush) {
|
|
@@ -1443,7 +1447,13 @@ export class AgentRunner {
|
|
|
1443
1447
|
return;
|
|
1444
1448
|
}
|
|
1445
1449
|
|
|
1446
|
-
|
|
1450
|
+
// Claude's Stop hook reports its own native `prompt_id` — the same UUID the
|
|
1451
|
+
// transcript stamps on every entry of this turn (extractAssistantTurnForPrompt /
|
|
1452
|
+
// resolveEntryPromptId in claude-transcript.ts). It's the authoritative turn
|
|
1453
|
+
// identifier; currentTurnId should already equal it (seeded from the same
|
|
1454
|
+
// prompt_id at turn start — see setProviderStatus / handleUserPrompt), but this
|
|
1455
|
+
// prefers the freshest source so finalization is correct even if the two drift.
|
|
1456
|
+
const turnId = input.promptId ?? this.currentTurnId;
|
|
1447
1457
|
this.stopReasoningTail();
|
|
1448
1458
|
// Optional correlation for threading + obligation clearing — never a capture gate.
|
|
1449
1459
|
let replyToMessageId: number | undefined, replyTarget = "user";
|
|
@@ -1464,32 +1474,41 @@ export class AgentRunner {
|
|
|
1464
1474
|
const flushCount = this.narrativeFlushEntryCount;
|
|
1465
1475
|
this.narrativeFlushEntryCount = 0;
|
|
1466
1476
|
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1469
|
-
//
|
|
1470
|
-
//
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
//
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
if (
|
|
1477
|
+
// PRIMARY capture source: the Stop hook's own last_assistant_message. Claude
|
|
1478
|
+
// reports it synchronously in the hook payload itself, so there is no
|
|
1479
|
+
// transcript file-read race to retry against — this text is authoritative and
|
|
1480
|
+
// complete the moment the hook fires. The transcript is read at most ONCE
|
|
1481
|
+
// (never retried/looped) and only to enrich "full" mode with the turn's
|
|
1482
|
+
// intermediate narration; if that single read comes up empty (the file simply
|
|
1483
|
+
// hasn't caught up yet) the hook payload text still carries the turn.
|
|
1484
|
+
let body = extractHookAssistantMessage(input.lastAssistantMessage);
|
|
1485
|
+
if (this.options.providerConfig.chatCaptureMode === "full" && input.transcriptPath) {
|
|
1486
|
+
let jsonl: string | undefined;
|
|
1487
|
+
try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
|
|
1488
|
+
if (jsonl !== undefined && transcriptLooksComplete(jsonl)) {
|
|
1489
|
+
// Deterministic slicing via prompt_id→parentUuid chaining when the native id is
|
|
1490
|
+
// known; only fall back to the legacy "last real user prompt" heuristic when that
|
|
1491
|
+
// deterministic slice comes up EMPTY, not merely when turnId is falsy — an old
|
|
1492
|
+
// Claude (<2.1.196) mints a truthy random turnId that never resolves via promptId
|
|
1493
|
+
// chaining, so branching on truthiness alone left this fallback unreachable and
|
|
1494
|
+
// silently dropped intermediate narration in full mode (#1086 review, Finding F1).
|
|
1495
|
+
let full = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
|
|
1496
|
+
if (!full) full = flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl);
|
|
1497
|
+
if (full) body = full;
|
|
1487
1498
|
}
|
|
1488
1499
|
}
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
|
|
1492
|
-
|
|
1500
|
+
// Last-resort safety net for the default "final" mode (#1086 review, Finding F2):
|
|
1501
|
+
// last_assistant_message is an undocumented Claude hook field. If a future Claude
|
|
1502
|
+
// version renames or drops it, do ONE (never retried — same one-shot discipline as the
|
|
1503
|
+
// "full" mode enrichment above) transcript read rather than silently losing every
|
|
1504
|
+
// capture in the mode nearly every provider config runs in.
|
|
1505
|
+
if (!body && this.options.providerConfig.chatCaptureMode === "final" && input.transcriptPath) {
|
|
1506
|
+
let jsonl: string | undefined;
|
|
1507
|
+
try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
|
|
1508
|
+
if (jsonl !== undefined) {
|
|
1509
|
+
const fallback = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
|
|
1510
|
+
body = fallback || (flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl));
|
|
1511
|
+
}
|
|
1493
1512
|
}
|
|
1494
1513
|
// A pure tool-use turn with no closing text is fine to skip — its reasoning and
|
|
1495
1514
|
// tool steps already carried the visibility into chat.
|
|
@@ -1499,7 +1518,18 @@ export class AgentRunner {
|
|
|
1499
1518
|
}
|
|
1500
1519
|
|
|
1501
1520
|
this.sessionLog(`response captured for turn ${turnId ?? "?"} (${body.length} chars${replyToMessageId ? `, replyTo #${replyToMessageId}` : ", no replyTo"})`);
|
|
1502
|
-
await publishCapturedResponse({
|
|
1521
|
+
await publishCapturedResponse({
|
|
1522
|
+
publishSessionEvent: (event) => this.publishSessionEvent(event),
|
|
1523
|
+
outbox: this.sessionOutbox,
|
|
1524
|
+
provider: this.options.provider,
|
|
1525
|
+
from: this.agentId,
|
|
1526
|
+
to: replyTarget,
|
|
1527
|
+
body,
|
|
1528
|
+
replyTo: replyToMessageId,
|
|
1529
|
+
// A Stop-hook-driven capture is by construction a definitive, provider-native
|
|
1530
|
+
// turn completion — Claude's own lifecycle hook fired, not a reconstructed guess.
|
|
1531
|
+
session: { type: "response", origin: "provider", final: true, confidence: "definitive", endedBy: "completed", ...(turnId ? { turnId } : {}) },
|
|
1532
|
+
});
|
|
1503
1533
|
// The agent's reply may have cleared an obligation — refresh the snapshot so the next
|
|
1504
1534
|
// turn-end doesn't re-prompt for a message already answered (#196).
|
|
1505
1535
|
if (replyToMessageId) this.obligationCache.markDirty();
|
|
@@ -1609,10 +1639,15 @@ export class AgentRunner {
|
|
|
1609
1639
|
// it into the dashboard chat so both surfaces stay in sync, and kick off reasoning
|
|
1610
1640
|
// tailing for the turn. Skips prompts the runner itself injected (chat box, relay
|
|
1611
1641
|
// deliveries) so those aren't double-posted.
|
|
1612
|
-
private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
|
|
1642
|
+
private async handleUserPrompt(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void> {
|
|
1613
1643
|
if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
|
|
1614
1644
|
if (!this.currentTurnId) {
|
|
1615
|
-
|
|
1645
|
+
// Claude's own prompt_id (from the UserPromptSubmit hook payload) is the
|
|
1646
|
+
// native, stable turn identifier — the same id the Stop hook reports and the
|
|
1647
|
+
// transcript stamps on every entry of this turn. Falls back to a random mint
|
|
1648
|
+
// only for the rare case prompt_id wasn't available (see the ZS base schema
|
|
1649
|
+
// note: absent until the very first user input of the process lifetime).
|
|
1650
|
+
this.currentTurnId = input.promptId ?? crypto.randomUUID();
|
|
1616
1651
|
this.currentTurnStartedAt = Date.now();
|
|
1617
1652
|
}
|
|
1618
1653
|
const text = input.prompt.trim();
|
|
@@ -1739,6 +1774,14 @@ export class AgentRunner {
|
|
|
1739
1774
|
// Dashboard prompt injection is already answered by this captured App Server
|
|
1740
1775
|
// response. User obligations still belong to the session mirror; spawner
|
|
1741
1776
|
// obligations (#413) route to the spawn-parent with replyTo to clear the debt.
|
|
1777
|
+
// #1083: a turn-final capture is ALWAYS mirrored, regardless of reply route — an
|
|
1778
|
+
// autonomous Codex turn with no pending prompt and no eligible obligation used to
|
|
1779
|
+
// hit `if (!route) return` here and vanish before ever reaching publishCapturedResponse,
|
|
1780
|
+
// so it was neither shown in the dashboard nor eligible for report-up. The route only
|
|
1781
|
+
// decides whether an explicit reply/obligation-clear also happens; when there's none,
|
|
1782
|
+
// replyToMessageId stays undefined and the capture still lands as a plain session-mirror
|
|
1783
|
+
// response (enqueueCapturedResponseReport's own `!replyTo` gate then correctly skips
|
|
1784
|
+
// report-up — there's no spawner obligation to report up against).
|
|
1742
1785
|
let replyToMessageId: number | undefined, replyTarget = "user";
|
|
1743
1786
|
const pendingPrompt = this.pendingPromptMessageId;
|
|
1744
1787
|
if (pendingPrompt) {
|
|
@@ -1746,10 +1789,9 @@ export class AgentRunner {
|
|
|
1746
1789
|
this.pendingPromptMessageId = undefined;
|
|
1747
1790
|
} else {
|
|
1748
1791
|
const route = responseCaptureReplyRoute(this.obligationCache.get(), this.currentTurnStartedAt, { suppressUser: true });
|
|
1749
|
-
if (
|
|
1750
|
-
replyToMessageId = route.replyToMessageId; replyTarget = route.to;
|
|
1792
|
+
if (route) { replyToMessageId = route.replyToMessageId; replyTarget = route.to; }
|
|
1751
1793
|
}
|
|
1752
|
-
await publishCapturedResponse({ publishSessionEvent: (sessionEvent) => this.publishSessionEvent(sessionEvent), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}) } });
|
|
1794
|
+
await publishCapturedResponse({ publishSessionEvent: (sessionEvent) => this.publishSessionEvent(sessionEvent), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}), ...(event.confidence ? { confidence: event.confidence } : {}), ...(event.endedBy ? { endedBy: event.endedBy } : {}) } });
|
|
1753
1795
|
if (replyToMessageId) this.obligationCache.markDirty();
|
|
1754
1796
|
return;
|
|
1755
1797
|
}
|
|
@@ -4,6 +4,7 @@ import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSign
|
|
|
4
4
|
import { deliverableTaskIdsBySpawnRequestId, getAgent, markAgentReadyNotified, recordCrashReport, recordTerminalEvent } from "./db";
|
|
5
5
|
import { crashReportFor } from "./agent-death-diagnosis";
|
|
6
6
|
import { renderTemplate } from "./prompt-resolver";
|
|
7
|
+
import { markAgentWorkFinal } from "./services/work-finality";
|
|
7
8
|
import type { AgentCard, AgentDeathDiagnosis } from "./types";
|
|
8
9
|
|
|
9
10
|
/** One-line cause tag appended to a death notification body, e.g. " · why: Clean exit (exit code 0)". */
|
|
@@ -187,6 +188,13 @@ export function notifyAgentOffline(childId: string, reason: string, diagnosis?:
|
|
|
187
188
|
const child = getAgent(childId);
|
|
188
189
|
const parent = child?.spawnedBy;
|
|
189
190
|
if (!child || !parent) return;
|
|
191
|
+
// #1084 crash-final-loss fix-forward — this is the platform confirming the child is gone for
|
|
192
|
+
// good (past the reconnect/stale grace, or an explicit exit-diagnostics report). A genuine final
|
|
193
|
+
// report captured+held in the ring before the crash must go out now or it is lost forever: no
|
|
194
|
+
// later signal (relay_workspace_ready, task→done) is coming from a process that no longer exists.
|
|
195
|
+
// Idempotent (flushWorkFinalReports no-ops once the ring is empty), so a redundant call from a
|
|
196
|
+
// second offline classification of the same agent is harmless.
|
|
197
|
+
markAgentWorkFinal(childId, "edge-fallback");
|
|
190
198
|
// Classify from the live set OR the durable readiness stamp. The `delete` always runs (cleanup of
|
|
191
199
|
// the in-memory set); the persisted stamp covers a child that became ready before a relay restart.
|
|
192
200
|
const wasReady = readyFired.delete(childId) || everReady(child);
|
package/src/bus.ts
CHANGED
|
@@ -15,7 +15,6 @@ import { agentProviderTimelineEvent, auditRunnerTimelineEvent } from "./provider
|
|
|
15
15
|
import { ServiceAuthError } from "./services/errors";
|
|
16
16
|
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
|
|
17
17
|
import { flushQueuedDirectMessages } from "./services/managed-running";
|
|
18
|
-
import { flushIdleLineageReport } from "./services/send-message";
|
|
19
18
|
import { isAgentUnavailable } from "./db/agent-predicates";
|
|
20
19
|
import { applyCommandToRecipe } from "./recipe-runner";
|
|
21
20
|
import { enqueueContinuationInjectionAfterSelfResume } from "./self-resume";
|
|
@@ -106,10 +105,6 @@ export function busHandleClose(ws: BusWebSocket): void {
|
|
|
106
105
|
if (!conn.transportReconnect) {
|
|
107
106
|
setStatus(conn.agentId, "stale", { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
108
107
|
emitAgentStatusEvent(conn.agentId);
|
|
109
|
-
// #1040 — a WS close is a busy→exit edge (the runner crashed/dropped before its stop-hook
|
|
110
|
-
// status frame). Flush any held turn-final report-up so it still reaches the spawner instead
|
|
111
|
-
// of dying with the socket. No-op when nothing was deferred.
|
|
112
|
-
flushIdleLineageReport(conn.agentId);
|
|
113
108
|
}
|
|
114
109
|
}
|
|
115
110
|
}
|
|
@@ -134,9 +129,6 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs(), lagGraceMs = 0
|
|
|
134
129
|
// (a fresh instanceId → epoch bump), never on a heartbeat lapse.
|
|
135
130
|
setStatus(row.id, "offline");
|
|
136
131
|
emitAgentStatusEvent(row.id);
|
|
137
|
-
// #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
|
|
138
|
-
// clean idle transition. Flush any held turn-final report-up before the agent ages out.
|
|
139
|
-
flushIdleLineageReport(row.id);
|
|
140
132
|
const tasks = orphanTasksForAgent(row.id);
|
|
141
133
|
for (const task of tasks) {
|
|
142
134
|
orphanedTasks.push(task);
|
|
@@ -172,16 +164,6 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
172
164
|
heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
|
|
173
165
|
if (frame.payload.status) {
|
|
174
166
|
setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
175
|
-
// #1040 — a heartbeat frame can carry the busy→idle transition instead of a `status`
|
|
176
|
-
// frame. Flush the held report-up on THIS edge too, else `before.status` is already
|
|
177
|
-
// non-busy at the next status frame → the flush never fires → the child deadlocks idle
|
|
178
|
-
// with its verdict held and the coordinator waits forever.
|
|
179
|
-
// #1037 (re-regression) — gate on a genuine at-rest `idle`, NOT merely "not busy". A
|
|
180
|
-
// real turn-end always reports `idle`; a transient non-idle status (a stray `online`,
|
|
181
|
-
// a malformed frame) is NOT the end of the child's work and must not flush its held
|
|
182
|
-
// mid-work capture upstream. Terminal exit edges (WS-close/stale sweep) keep their
|
|
183
|
-
// unconditional flush for the #1040 delivery guarantee — this only tightens the LIVE edge.
|
|
184
|
-
if (current?.status === "busy" && frame.payload.status === "idle") flushIdleLineageReport(conn.agentId);
|
|
185
167
|
}
|
|
186
168
|
emitAgentStatusEvent(conn.agentId);
|
|
187
169
|
// #416 — flush queued messages if the agent transitioned from unavailable to available.
|
|
@@ -226,12 +208,6 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
226
208
|
emitAgentStatusEvent(conn.agentId);
|
|
227
209
|
// #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
|
|
228
210
|
probeWorkspaceOnTurnEnd(before, after);
|
|
229
|
-
// #1037 — the same busy→idle turn-end edge flushes a quiet child's deferred turn-final
|
|
230
|
-
// report-up to its spawner (held back while the child was mid-work). Gate on a genuine
|
|
231
|
-
// at-rest `idle`: a real turn-end reports `idle`, whereas a transient non-idle status
|
|
232
|
-
// (stray `online`, reconnect) is not the child going to rest and must not flush its held
|
|
233
|
-
// mid-work capture. Terminal exit edges keep the #1040 unconditional flush.
|
|
234
|
-
if (before?.status === "busy" && after && after.status === "idle") flushIdleLineageReport(conn.agentId);
|
|
235
211
|
// #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
|
|
236
212
|
// marks the offline edge with `terminalProviderExit`; the owning service turns that into a
|
|
237
213
|
// terminal event + #636 diagnosis (and clears the hold on a genuine resume).
|
package/src/db/agents.ts
CHANGED
|
@@ -566,7 +566,20 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS, lagGraceMs = 0): s
|
|
|
566
566
|
|
|
567
567
|
export { settleSingleTargetOnDemandTasks } from "./task-maintenance.ts";
|
|
568
568
|
|
|
569
|
-
export function pruneOfflineAgents(
|
|
569
|
+
export function pruneOfflineAgents(
|
|
570
|
+
maxOfflineMs: number = DAY_MS,
|
|
571
|
+
opts?: {
|
|
572
|
+
/**
|
|
573
|
+
* #1084 crash-final-loss fix-forward — defense-in-depth for a held work-final ring that
|
|
574
|
+
* somehow survived to prune time (the primary flush is `notifyAgentOffline`, fired when the
|
|
575
|
+
* agent first goes offline). Called per pruned agent id BEFORE its row is deleted, so a
|
|
576
|
+
* caller can flush any still-held final report while `getAgent(id)` can still resolve it —
|
|
577
|
+
* kept a plain callback (not a direct import) so this db-layer module stays free of
|
|
578
|
+
* service/SSE-layer dependencies.
|
|
579
|
+
*/
|
|
580
|
+
beforeDelete?: (agentId: string) => void;
|
|
581
|
+
},
|
|
582
|
+
): string[] {
|
|
570
583
|
const cutoff = Date.now() - maxOfflineMs;
|
|
571
584
|
return getDb().transaction(() => {
|
|
572
585
|
const rows = getDb()
|
|
@@ -597,6 +610,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
597
610
|
|
|
598
611
|
for (const row of rows) {
|
|
599
612
|
// Offline past the prune horizon and being deleted — a hard kill, not recoverable.
|
|
613
|
+
opts?.beforeDelete?.(row.id);
|
|
600
614
|
revokeRuntimeTokensForAgent(row.id, now, "pruned");
|
|
601
615
|
closeOpenPairsForAgent(row.id, now);
|
|
602
616
|
removeSubscriptionsForAgent(row.id);
|
package/src/db/messages.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import {
|
|
4
|
+
hasWorkFinality,
|
|
4
5
|
isRecord,
|
|
5
6
|
isTurnFinalSession,
|
|
6
7
|
stringValue,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
isReportVerbosity,
|
|
9
10
|
type MessagePayload,
|
|
10
11
|
type MessageWorkFinality,
|
|
12
|
+
type MessageWorkFinalitySource,
|
|
11
13
|
type ReportVerbosity,
|
|
12
14
|
} from "agent-relay-sdk";
|
|
13
15
|
import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSignalKey } from "../lifecycle-signal-registry";
|
|
@@ -364,11 +366,6 @@ function reportVerbosityOf(child: AgentCard): ReportVerbosity {
|
|
|
364
366
|
return isReportVerbosity(value) ? value : "quiet";
|
|
365
367
|
}
|
|
366
368
|
|
|
367
|
-
/** The child is mid-turn (not yet idle/awaiting-input). The authoritative "still working" signal. */
|
|
368
|
-
function childIsBusy(child: AgentCard): boolean {
|
|
369
|
-
return child.status === "busy";
|
|
370
|
-
}
|
|
371
|
-
|
|
372
369
|
// #1037 (re-regression) — the report-up must reach the spawner as a CONCISE result, not the full
|
|
373
370
|
// turn transcript. In `chatCaptureMode: "full"` (and for rambling multi-block recaps) the captured
|
|
374
371
|
// turn-final body is the ENTIRE turn's narration — every "Let me read X… now Y… now Z…" segment
|
|
@@ -424,50 +421,90 @@ function distillLineageReportBody(body: string, messageId: number): string {
|
|
|
424
421
|
return `${reportUpElision(messageId)}\n\n${tail}`;
|
|
425
422
|
}
|
|
426
423
|
|
|
427
|
-
// #
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
424
|
+
// #1084 — a turn-final response is TURN-final (this provider turn ended), not WORK-final (the
|
|
425
|
+
// agent's work is done). Forwarding every one upstream is the false-final flood (#1078/#1058/#1032/
|
|
426
|
+
// #976): a child idling briefly between two subtasks looks identical, on the wire, to a child that
|
|
427
|
+
// just finished. So a `responses`-stream turn-final is never delivered on capture — it is HELD per
|
|
428
|
+
// child in a small ring (the last few closing turns; see LINEAGE_FINAL_GROUP_CAP) until an explicit
|
|
429
|
+
// work-finality signal closes the window (see flushWorkFinalReports in ../services/work-finality.ts,
|
|
430
|
+
// anchored to relay_workspace_ready / task→done / agent teardown-reap — never a busy→idle bus edge).
|
|
431
|
+
const LINEAGE_FINAL_GROUP_CAP = 3;
|
|
432
|
+
// #1084 MEDIUM fix-forward — the ring above is capped by COUNT, not recency: after 5 steer cycles
|
|
433
|
+
// a 3-slot ring holds whatever is still sitting there regardless of age, so a close could flush up
|
|
434
|
+
// to 2 stale mid-work narrations from earlier cycles alongside (or instead of) the real closing
|
|
435
|
+
// message(s). `flushWorkFinalReports` gates each held entry on its age *relative to the close*, so
|
|
436
|
+
// only captures from the completing sequence — not leftover narration from a much earlier cycle —
|
|
437
|
+
// ride along in the delivered group. Generous relative to LINEAGE_REPORT_DUPLICATE_WINDOW_MS (2min)
|
|
438
|
+
// so a genuinely multi-turn closing sequence is never mistaken for stale narration.
|
|
439
|
+
const LINEAGE_FINAL_GROUP_RECENCY_MS = 10 * 60 * 1000;
|
|
440
|
+
const deferredLineageReports = new Map<string, number[]>();
|
|
433
441
|
|
|
434
442
|
function deferLineageReport(childId: string, messageId: number): void {
|
|
435
|
-
deferredLineageReports.
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
deferredLineageReports.delete(childId);
|
|
443
|
+
const queue = deferredLineageReports.get(childId) ?? [];
|
|
444
|
+
queue.push(messageId);
|
|
445
|
+
while (queue.length > LINEAGE_FINAL_GROUP_CAP) queue.shift();
|
|
446
|
+
deferredLineageReports.set(childId, queue);
|
|
440
447
|
}
|
|
441
448
|
|
|
442
|
-
/** Test seam: clear the
|
|
449
|
+
/** Test seam: clear the held work-final group between tests. */
|
|
443
450
|
export function __resetDeferredLineageReports(): void {
|
|
444
451
|
deferredLineageReports.clear();
|
|
445
452
|
}
|
|
446
453
|
|
|
454
|
+
export interface WorkFinalFlushResult {
|
|
455
|
+
/** The chat report(s) forwarded (or found already-forwarded) to the spawner, oldest first. */
|
|
456
|
+
forwarded: Array<{ message: Message; created: boolean }>;
|
|
457
|
+
/** The original session-mirror captures retroactively tagged `payload.finality` (for the
|
|
458
|
+
* `message.finalized` SSE upgrade of already-rendered bubbles). */
|
|
459
|
+
finalizedOriginals: Message[];
|
|
460
|
+
}
|
|
461
|
+
|
|
447
462
|
/**
|
|
448
|
-
* #
|
|
449
|
-
*
|
|
450
|
-
*
|
|
463
|
+
* #1084 — close a child's work window: flush its held turn-final group to its spawner as ONE
|
|
464
|
+
* work-final unit sharing a fresh `windowId`, tagged with `source` (the explicit signal that closed
|
|
465
|
+
* the window) and an incrementing `groupOrder`. No-op when nothing was held. This is the ONLY path
|
|
466
|
+
* that delivers a `responses`-stream capture to the spawner (besides the `verbose` bypass in
|
|
467
|
+
* promoteLineageCapturedResponse) — call it from the finality service, never from a bus status edge.
|
|
451
468
|
*/
|
|
452
|
-
export function
|
|
453
|
-
const
|
|
454
|
-
if (
|
|
469
|
+
export function flushWorkFinalReports(childId: string, source: MessageWorkFinalitySource): WorkFinalFlushResult {
|
|
470
|
+
const queue = deferredLineageReports.get(childId);
|
|
471
|
+
if (!queue || queue.length === 0) return { forwarded: [], finalizedOriginals: [] };
|
|
455
472
|
deferredLineageReports.delete(childId);
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
473
|
+
const child = getAgent(childId);
|
|
474
|
+
const parent = child?.spawnedBy;
|
|
475
|
+
if (!child || !parent) return { forwarded: [], finalizedOriginals: [] };
|
|
476
|
+
|
|
477
|
+
const windowId = randomUUID();
|
|
478
|
+
const markedAt = Date.now();
|
|
479
|
+
const recencyCutoff = markedAt - LINEAGE_FINAL_GROUP_RECENCY_MS;
|
|
480
|
+
const forwarded: Array<{ message: Message; created: boolean }> = [];
|
|
481
|
+
const finalizedOriginals: Message[] = [];
|
|
482
|
+
let groupOrder = 0;
|
|
483
|
+
for (const messageId of queue) {
|
|
484
|
+
const original = getMessage(messageId);
|
|
485
|
+
if (!original) continue;
|
|
486
|
+
// #1084 MEDIUM — drop stale mid-work narration the count-capped ring is still holding from an
|
|
487
|
+
// earlier steer cycle; only captures from the completing sequence ride along in this group.
|
|
488
|
+
if ((original.occurredAt ?? original.createdAt) < recencyCutoff) continue;
|
|
489
|
+
const session = sessionMeta(original);
|
|
490
|
+
if (!session) continue;
|
|
491
|
+
const finality: MessageWorkFinality = { windowId, source, groupOrder, markedAt };
|
|
492
|
+
const result = forwardLineageStreamCapture(original, session, "responses", child, parent, finality);
|
|
493
|
+
if (!result) continue;
|
|
494
|
+
groupOrder++;
|
|
495
|
+
forwarded.push(result);
|
|
496
|
+
const finalizedOriginal = setMessageWorkFinality(messageId, finality);
|
|
497
|
+
if (finalizedOriginal) finalizedOriginals.push(finalizedOriginal);
|
|
498
|
+
}
|
|
499
|
+
return { forwarded, finalizedOriginals };
|
|
459
500
|
}
|
|
460
501
|
|
|
461
502
|
/**
|
|
462
|
-
* #1040/#1041 — the persisted turn-final response captures needed to rebuild the in-memory
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
* is never resurrected alongside the real verdict, and (b) a chatty child's own history can't push
|
|
468
|
-
* another child's final out of the cap. The caller re-runs promotion, which is idempotent: an
|
|
469
|
-
* already-delivered capture is a no-op, an idle child's lost report is delivered, and a still-busy
|
|
470
|
-
* child simply re-defers (holding the newest one, matching the live #1037 coalescing contract).
|
|
503
|
+
* #1040/#1041 — the persisted turn-final response captures needed to rebuild the in-memory held
|
|
504
|
+
* group after a relay restart. Bounded by a recency window (a report is only ever held while its
|
|
505
|
+
* work window is open, so it is inherently recent). The caller re-runs promotion per capture, which
|
|
506
|
+
* is idempotent: an already-forwarded capture is a no-op (idempotency key), and everything else
|
|
507
|
+
* re-enters the held ring exactly as it stood pre-restart, awaiting the next work-finality signal.
|
|
471
508
|
*/
|
|
472
509
|
export function listRecentFinalResponseCaptures(sinceMs: number, limit = 1000): Message[] {
|
|
473
510
|
const rows = getDb().query(`
|
|
@@ -529,17 +566,47 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
529
566
|
// #1037 verbose: forward every child stream without an explicit per-stream subscription.
|
|
530
567
|
if (stream !== "responses" && verbosity !== "verbose" && !childStreamSubscribed({ parentId: parent, child, stream })) return null;
|
|
531
568
|
|
|
532
|
-
// #
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
|
|
569
|
+
// #1084 — a `responses`-stream turn-final is TURN-final, not WORK-final: hold it (see
|
|
570
|
+
// deferLineageReport above) rather than forwarding, regardless of the child's busy/idle state —
|
|
571
|
+
// only an explicit work-finality signal (flushWorkFinalReports) delivers it upstream. `verbose` is
|
|
572
|
+
// an explicit opt-in firehose (a caller that asked for every turn) and bypasses the hold, matching
|
|
573
|
+
// its pre-#1084 behavior.
|
|
574
|
+
if (stream === "responses" && verbosity !== "verbose") {
|
|
536
575
|
deferLineageReport(child.id, message.id);
|
|
537
576
|
return null;
|
|
538
577
|
}
|
|
539
578
|
|
|
579
|
+
return forwardLineageStreamCapture(message, session, stream, child, parent);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Forward one captured child-stream event to its spawner as a `chat` report-up. Shared by the
|
|
584
|
+
* `verbose` immediate bypass (no `finality`) and `flushWorkFinalReports` (tags the forwarded report
|
|
585
|
+
* — and, via the caller, the original capture — with the closing work window's `finality`).
|
|
586
|
+
*/
|
|
587
|
+
function forwardLineageStreamCapture(
|
|
588
|
+
message: Message,
|
|
589
|
+
session: Record<string, unknown>,
|
|
590
|
+
stream: ChildStream,
|
|
591
|
+
child: AgentCard,
|
|
592
|
+
parent: string,
|
|
593
|
+
finality?: MessageWorkFinality,
|
|
594
|
+
): { message: Message; created: boolean } | null {
|
|
540
595
|
const idempotencyKey = lineageStreamReportKey(message, session, stream);
|
|
541
596
|
const existing = findMessageByIdempotencyKey(message.from, idempotencyKey);
|
|
542
|
-
if (existing)
|
|
597
|
+
if (existing) {
|
|
598
|
+
// #1084 MEDIUM (Lens-3 retroactive-finality) fix-forward — an already-forwarded report (e.g. a
|
|
599
|
+
// `verbose` bypass, or a duplicate suppressed on an earlier attempt) must still pick up the
|
|
600
|
+
// finality tag when its window later closes, or its dashboard bubble never upgrades from
|
|
601
|
+
// ordinary mid-turn narration to a closing message. Only patch when it isn't already tagged —
|
|
602
|
+
// an existing tag reflects an earlier, equally-valid window close and must not be overwritten.
|
|
603
|
+
if (finality && !hasWorkFinality(existing.payload)) {
|
|
604
|
+
const updatedPayload: MessagePayload = { ...(existing.payload ?? {}), finality };
|
|
605
|
+
getDb().query("UPDATE messages SET payload = ? WHERE id = ?").run(JSON.stringify(updatedPayload), existing.id);
|
|
606
|
+
return { message: getMessage(existing.id) ?? existing, created: false };
|
|
607
|
+
}
|
|
608
|
+
return { message: existing, created: false };
|
|
609
|
+
}
|
|
543
610
|
if (stream === "responses") {
|
|
544
611
|
const turnId = captureTurnId(session);
|
|
545
612
|
if (turnId) {
|
|
@@ -559,12 +626,17 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
559
626
|
since: message.createdAt - LINEAGE_REPORT_DUPLICATE_WINDOW_MS,
|
|
560
627
|
});
|
|
561
628
|
if (duplicate && !responseCaptureMeta(duplicate)) {
|
|
629
|
+
const updatedPayload: MessagePayload = {
|
|
630
|
+
...(duplicate.payload ?? {}),
|
|
631
|
+
responseCapture: { ...session },
|
|
632
|
+
...(finality ? { finality } : {}),
|
|
633
|
+
};
|
|
562
634
|
getDb().query(`
|
|
563
635
|
UPDATE messages
|
|
564
636
|
SET idempotency_key = coalesce(idempotency_key, ?),
|
|
565
637
|
payload = ?
|
|
566
638
|
WHERE id = ?
|
|
567
|
-
`).run(idempotencyKey, JSON.stringify(
|
|
639
|
+
`).run(idempotencyKey, JSON.stringify(updatedPayload), duplicate.id);
|
|
568
640
|
return { message: getMessage(duplicate.id) ?? duplicate, created: false };
|
|
569
641
|
}
|
|
570
642
|
}
|
|
@@ -579,12 +651,11 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
579
651
|
replyExpected: false,
|
|
580
652
|
idempotencyKey,
|
|
581
653
|
payload: stream === "responses"
|
|
582
|
-
? { responseCapture: { ...session } }
|
|
654
|
+
? { responseCapture: { ...session }, ...(finality ? { finality } : {}) }
|
|
583
655
|
: { childStreamCapture: { stream, ...session } },
|
|
584
656
|
occurredAt: message.occurredAt ?? message.createdAt,
|
|
585
657
|
});
|
|
586
658
|
if (stream === "responses" && result.created) {
|
|
587
|
-
clearDeferredLineageReport(child.id);
|
|
588
659
|
stampReportUpLifecycleSignal(parent, child);
|
|
589
660
|
}
|
|
590
661
|
return result;
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import { routeNotification } from "../notification-router";
|
|
49
49
|
import { pruneExpiredTokenRecords } from "../token-db";
|
|
50
50
|
import { reapTransientAgents } from "../services/transient-agent-reaper";
|
|
51
|
+
import { markAgentWorkFinal } from "../services/work-finality";
|
|
51
52
|
import { reapUnregisteredSpawnCommands } from "../services/spawn-registration-watchdog";
|
|
52
53
|
import { resumeRateLimitedAgents } from "../services/rate-limit-resume";
|
|
53
54
|
import { collectCodexWhamQuota } from "../codex-wham-quota";
|
|
@@ -334,7 +335,12 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
334
335
|
intervalMs: REAP_INTERVAL_MS,
|
|
335
336
|
runOnStart: false,
|
|
336
337
|
handler() {
|
|
337
|
-
|
|
338
|
+
// #1084 crash-final-loss fix-forward — defense-in-depth: `notifyAgentOffline` is the primary
|
|
339
|
+
// flush point when an agent first goes offline, so this is normally a no-op by the time an
|
|
340
|
+
// agent reaches prune horizon; kept here in case any offline transition ever bypasses it.
|
|
341
|
+
const prunedAgentIds = pruneOfflineAgents(OFFLINE_PRUNE_MS, {
|
|
342
|
+
beforeDelete: (id) => markAgentWorkFinal(id, "edge-fallback"),
|
|
343
|
+
});
|
|
338
344
|
for (const id of prunedAgentIds) emitAgentRemoved(id);
|
|
339
345
|
return { prunedAgentIds };
|
|
340
346
|
},
|
|
@@ -361,7 +367,7 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
361
367
|
runOnStart: false,
|
|
362
368
|
handler: () => reapOrphanedSessions(),
|
|
363
369
|
},
|
|
364
|
-
{ id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => reapTransientAgents() },
|
|
370
|
+
{ id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed; also flushes a persistent agent's held work-final report once it has settled idle (#1084 HIGH fix-forward).", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => reapTransientAgents() },
|
|
365
371
|
{
|
|
366
372
|
id: "lineage-report-startup-sweep",
|
|
367
373
|
title: "Lineage report startup sweep",
|
|
@@ -5,6 +5,7 @@ import { AUTO_MERGE_POLICIES } from "../workspace-merge";
|
|
|
5
5
|
import { describeWorkspacePhase, landReceipt, readyContract } from "../workspace-phase";
|
|
6
6
|
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "../workspace-actions";
|
|
7
7
|
import { refreshWorkspaceGitState } from "../workspace-probe";
|
|
8
|
+
import { markAgentWorkFinal } from "../services/work-finality";
|
|
8
9
|
import { optionalEnum } from "../validation";
|
|
9
10
|
import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord } from "../types";
|
|
10
11
|
import type { McpAuthContext, ToolDefinition } from "./types";
|
|
@@ -218,6 +219,9 @@ export async function relayWorkspaceMutation(auth: McpAuthContext, action: Works
|
|
|
218
219
|
if (action === "ready") {
|
|
219
220
|
payload.contract = readyContract(result.workspace);
|
|
220
221
|
payload.guidance = describeWorkspacePhase(result.workspace);
|
|
222
|
+
// #1084 — relay_workspace_ready is an explicit, platform-owned completion signal: the caller
|
|
223
|
+
// is declaring its work window closed. Close it, forwarding only the held final group.
|
|
224
|
+
markAgentWorkFinal(result.workspace.ownerAgentId ?? callerAgentId(auth), "workspace-ready");
|
|
221
225
|
}
|
|
222
226
|
return payload;
|
|
223
227
|
}
|
package/src/routes/agents.ts
CHANGED
|
@@ -10,7 +10,6 @@ import { emitAgentRemoved, emitAgentStatus } from "../sse";
|
|
|
10
10
|
import { notifyAgentReady } from "../agent-lifecycle-events";
|
|
11
11
|
import { bindSpawnedTaskToReadyAgent } from "../services/register-agent";
|
|
12
12
|
import { flushQueuedDirectMessages } from "../services/managed-running";
|
|
13
|
-
import { flushIdleLineageReport } from "../services/send-message";
|
|
14
13
|
import { isAgentUnavailable } from "../db/agent-predicates";
|
|
15
14
|
import { getCompactionWatch } from "../compaction-watch";
|
|
16
15
|
import { isRecord } from "agent-relay-sdk";
|
|
@@ -200,9 +199,6 @@ export const patchAgentStatus: Handler = async (req, params) => {
|
|
|
200
199
|
const before = getAgent(params.id!);
|
|
201
200
|
if (!setStatus(params.id!, body.status as (typeof VALID_AGENT_STATUSES)[number], guard)) return error("agent not found", 404);
|
|
202
201
|
auditAgentStateTransition(params.id!, before, getAgent(params.id!));
|
|
203
|
-
// #1040 — the HTTP status PATCH is also a busy→idle edge (a runner without a live bus socket).
|
|
204
|
-
// Flush any held turn-final report-up so it reaches the spawner on this path too.
|
|
205
|
-
if (before?.status === "busy" && body.status !== "busy") flushIdleLineageReport(params.id!);
|
|
206
202
|
} catch (e) {
|
|
207
203
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
208
204
|
throw e;
|