agent-relay-server 0.124.0 → 0.126.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 +9 -5
- package/public/assets/{MessageBubble-SQK8-Mzp.js → MessageBubble-B6YgulnX.js} +2 -2
- package/public/assets/{MessageBubble-SQK8-Mzp.js.map → MessageBubble-B6YgulnX.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CS-aow4S.js → PlanGraphPanel--DNxHn1T.js} +2 -2
- package/public/assets/{PlanGraphPanel-CS-aow4S.js.map → PlanGraphPanel--DNxHn1T.js.map} +1 -1
- package/public/assets/{activity-W31XvhC2.js → activity-Fo5aLYD6.js} +2 -2
- package/public/assets/{activity-W31XvhC2.js.map → activity-Fo5aLYD6.js.map} +1 -1
- package/public/assets/agent-profiles-C3p0xclG.js +2 -0
- package/public/assets/{agent-profiles-DXysA9To.js.map → agent-profiles-C3p0xclG.js.map} +1 -1
- package/public/assets/{agent-type-icon-BxUopALO.js → agent-type-icon-BmBR-9CH.js} +2 -2
- package/public/assets/{agent-type-icon-BxUopALO.js.map → agent-type-icon-BmBR-9CH.js.map} +1 -1
- package/public/assets/{agents-B5GRNRnN.js → agents-_GBQyF5b.js} +2 -2
- package/public/assets/{agents-B5GRNRnN.js.map → agents-_GBQyF5b.js.map} +1 -1
- package/public/assets/{analytics-DqAuUAwX.js → analytics-BZtRycyL.js} +2 -2
- package/public/assets/{analytics-DqAuUAwX.js.map → analytics-BZtRycyL.js.map} +1 -1
- package/public/assets/{automation-DYlgHYcb.js → automation-Cf_Ewi9f.js} +2 -2
- package/public/assets/{automation-DYlgHYcb.js.map → automation-Cf_Ewi9f.js.map} +1 -1
- package/public/assets/{badge-XEru6wto.js → badge-BCvp7LK3.js} +2 -2
- package/public/assets/{badge-XEru6wto.js.map → badge-BCvp7LK3.js.map} +1 -1
- package/public/assets/{branch-state-badge-Mf0Ow77K.js → branch-state-badge-Cscjqf-E.js} +2 -2
- package/public/assets/{branch-state-badge-Mf0Ow77K.js.map → branch-state-badge-Cscjqf-E.js.map} +1 -1
- package/public/assets/{button-C3CQSr4p.js → button-DyHjsHY3.js} +2 -2
- package/public/assets/{button-C3CQSr4p.js.map → button-DyHjsHY3.js.map} +1 -1
- package/public/assets/{card-C5XTIPvq.js → card-BoqYxpVW.js} +2 -2
- package/public/assets/{card-C5XTIPvq.js.map → card-BoqYxpVW.js.map} +1 -1
- package/public/assets/{channels-DVRkVeky.js → channels-DAtOnA6o.js} +2 -2
- package/public/assets/{channels-DVRkVeky.js.map → channels-DAtOnA6o.js.map} +1 -1
- package/public/assets/chat-BdbIWZpQ.js +2 -0
- package/public/assets/chat-BdbIWZpQ.js.map +1 -0
- package/public/assets/{connectors-Cqubkw9C.js → connectors-B2grC-hl.js} +2 -2
- package/public/assets/{connectors-Cqubkw9C.js.map → connectors-B2grC-hl.js.map} +1 -1
- package/public/assets/{copy-button-Bil2CjzT.js → copy-button-C67FUG3E.js} +2 -2
- package/public/assets/{copy-button-Bil2CjzT.js.map → copy-button-C67FUG3E.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/{dist-V3wu3BHw.js → dist-BD4Xc13v.js} +2 -2
- package/public/assets/{dist-V3wu3BHw.js.map → dist-BD4Xc13v.js.map} +1 -1
- package/public/assets/{dist-C5z05Vkz.js → dist-BFDfbfNC.js} +2 -2
- package/public/assets/{dist-C5z05Vkz.js.map → dist-BFDfbfNC.js.map} +1 -1
- package/public/assets/{dist-CjyfQThq.js → dist-BJKxqIQd.js} +2 -2
- package/public/assets/{dist-CjyfQThq.js.map → dist-BJKxqIQd.js.map} +1 -1
- package/public/assets/{dist-B8tD9ZcR.js → dist-BNB3fTO-.js} +2 -2
- package/public/assets/{dist-B8tD9ZcR.js.map → dist-BNB3fTO-.js.map} +1 -1
- package/public/assets/{dist-CFtFIhu3.js → dist-CHKra2JV.js} +2 -2
- package/public/assets/{dist-CFtFIhu3.js.map → dist-CHKra2JV.js.map} +1 -1
- package/public/assets/{dist-r8wyUTkK.js → dist-CQMQ5iku.js} +2 -2
- package/public/assets/{dist-r8wyUTkK.js.map → dist-CQMQ5iku.js.map} +1 -1
- package/public/assets/{dist-DKmTXfpP.js → dist-C_VOBSaK.js} +2 -2
- package/public/assets/{dist-DKmTXfpP.js.map → dist-C_VOBSaK.js.map} +1 -1
- package/public/assets/{dist-CGaHpxnN.js → dist-D5w04z0l.js} +2 -2
- package/public/assets/{dist-CGaHpxnN.js.map → dist-D5w04z0l.js.map} +1 -1
- package/public/assets/{dist-4VwhnLtj.js → dist-DG75bvPp.js} +2 -2
- package/public/assets/{dist-4VwhnLtj.js.map → dist-DG75bvPp.js.map} +1 -1
- package/public/assets/{dist-CrUl2aQp.js → dist-G8YBD-wp.js} +2 -2
- package/public/assets/{dist-CrUl2aQp.js.map → dist-G8YBD-wp.js.map} +1 -1
- package/public/assets/{dist-BJaV2BD4.js → dist-f6MW74eX.js} +2 -2
- package/public/assets/{dist-BJaV2BD4.js.map → dist-f6MW74eX.js.map} +1 -1
- package/public/assets/{es2015-5DTJe35n.js → es2015-a6fysEVz.js} +2 -2
- package/public/assets/{es2015-5DTJe35n.js.map → es2015-a6fysEVz.js.map} +1 -1
- package/public/assets/{formatted-body-impl-L1EMnNW6.js → formatted-body-impl-gOLBKeyW.js} +2 -2
- package/public/assets/{formatted-body-impl-L1EMnNW6.js.map → formatted-body-impl-gOLBKeyW.js.map} +1 -1
- package/public/assets/formatted-body-jjhFXTap.js +3 -0
- package/public/assets/{formatted-body-BYQ-SPtj.js.map → formatted-body-jjhFXTap.js.map} +1 -1
- package/public/assets/index-CgH1BZwq.js +26 -0
- package/public/assets/index-CgH1BZwq.js.map +1 -0
- package/public/assets/index-DHzXsQot.css +2 -0
- package/public/assets/{input-CXMbJ848.js → input-Rc4oWv4q.js} +2 -2
- package/public/assets/{input-CXMbJ848.js.map → input-Rc4oWv4q.js.map} +1 -1
- package/public/assets/{insights-DwZxq1Dn.js → insights-CTW85An9.js} +2 -2
- package/public/assets/{insights-DwZxq1Dn.js.map → insights-CTW85An9.js.map} +1 -1
- package/public/assets/{integrations-9G5TJnnU.js → integrations-9c6IdRU4.js} +2 -2
- package/public/assets/{integrations-9G5TJnnU.js.map → integrations-9c6IdRU4.js.map} +1 -1
- package/public/assets/{lib-BcUW1l-W.js → lib-BZ6WPg2t.js} +2 -2
- package/public/assets/{lib-BcUW1l-W.js.map → lib-BZ6WPg2t.js.map} +1 -1
- package/public/assets/lucide-react-NXHPIMsX.js +10 -0
- package/public/assets/lucide-react-NXHPIMsX.js.map +1 -0
- package/public/assets/{maintenance-COcmUDnf.js → maintenance-D5PEygZz.js} +2 -2
- package/public/assets/{maintenance-COcmUDnf.js.map → maintenance-D5PEygZz.js.map} +1 -1
- package/public/assets/{managed-agents-D9-dgMeX.js → managed-agents-pkpAzZy8.js} +2 -2
- package/public/assets/{managed-agents-D9-dgMeX.js.map → managed-agents-pkpAzZy8.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DH372bL-.js → markdown-preview-impl-DMzFE-Lo.js} +2 -2
- package/public/assets/{markdown-preview-impl-DH372bL-.js.map → markdown-preview-impl-DMzFE-Lo.js.map} +1 -1
- package/public/assets/{memory-B9hRK8sk.js → memory-BnquVYQz.js} +2 -2
- package/public/assets/{memory-B9hRK8sk.js.map → memory-BnquVYQz.js.map} +1 -1
- package/public/assets/{messages-BHkHlXzF.js → messages-DgxLlndC.js} +2 -2
- package/public/assets/{messages-BHkHlXzF.js.map → messages-DgxLlndC.js.map} +1 -1
- package/public/assets/{orchestrators-CXy3SDSn.js → orchestrators-BaWxeAlK.js} +2 -2
- package/public/assets/{orchestrators-CXy3SDSn.js.map → orchestrators-BaWxeAlK.js.map} +1 -1
- package/public/assets/{overview-D2yRyCQq.js → overview-Kvs-j2gg.js} +2 -2
- package/public/assets/{overview-D2yRyCQq.js.map → overview-Kvs-j2gg.js.map} +1 -1
- package/public/assets/{pairs-5tXdHylI.js → pairs-eDzbqcER.js} +2 -2
- package/public/assets/{pairs-5tXdHylI.js.map → pairs-eDzbqcER.js.map} +1 -1
- package/public/assets/{projects-CquHTMo1.js → projects-iDkxAGBW.js} +2 -2
- package/public/assets/{projects-CquHTMo1.js.map → projects-iDkxAGBW.js.map} +1 -1
- package/public/assets/{prompt-settings-Bn5mmi9w.js → prompt-settings-CjcqBG_r.js} +2 -2
- package/public/assets/{prompt-settings-Bn5mmi9w.js.map → prompt-settings-CjcqBG_r.js.map} +1 -1
- package/public/assets/{react-dom-BQICbUuF.js → react-dom-8yaD9iJG.js} +2 -2
- package/public/assets/{react-dom-BQICbUuF.js.map → react-dom-8yaD9iJG.js.map} +1 -1
- package/public/assets/{registry-C3sB7qv5.js → registry-C1DSnjjg.js} +2 -2
- package/public/assets/{registry-C3sB7qv5.js.map → registry-C1DSnjjg.js.map} +1 -1
- package/public/assets/{security-CmD-YE-_.js → security-VbEe_jwm.js} +2 -2
- package/public/assets/{security-CmD-YE-_.js.map → security-VbEe_jwm.js.map} +1 -1
- package/public/assets/{select-C0UlhK2k.js → select-CVfwgfKl.js} +2 -2
- package/public/assets/{select-C0UlhK2k.js.map → select-CVfwgfKl.js.map} +1 -1
- package/public/assets/{settings-DrqMG5nG.js → settings-ujA9hZW1.js} +4 -4
- package/public/assets/{settings-DrqMG5nG.js.map → settings-ujA9hZW1.js.map} +1 -1
- package/public/assets/store-D-Frk_EC.js +9 -0
- package/public/assets/store-D-Frk_EC.js.map +1 -0
- package/public/assets/{task-chip-BHM-e3I8.js → task-chip-CeujtXYT.js} +2 -2
- package/public/assets/{task-chip-BHM-e3I8.js.map → task-chip-CeujtXYT.js.map} +1 -1
- package/public/assets/{tasks-ChQjtXHu.js → tasks-BtbKaa-b.js} +2 -2
- package/public/assets/{tasks-ChQjtXHu.js.map → tasks-BtbKaa-b.js.map} +1 -1
- package/public/assets/{teams-itjNlDLp.js → teams-CrVDQ99v.js} +2 -2
- package/public/assets/{teams-itjNlDLp.js.map → teams-CrVDQ99v.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-B9M6s2vB.js → terminal-viewer-impl-Aays_e7-.js} +2 -2
- package/public/assets/{terminal-viewer-impl-B9M6s2vB.js.map → terminal-viewer-impl-Aays_e7-.js.map} +1 -1
- package/public/assets/{types-CRptbSYh.js → types-BqeSkWrv.js} +2 -2
- package/public/assets/types-BqeSkWrv.js.map +1 -0
- package/public/assets/{use-keyboard-viewport-CAa7IQTm.js → use-keyboard-viewport-cGEmrGE7.js} +2 -2
- package/public/assets/{use-keyboard-viewport-CAa7IQTm.js.map → use-keyboard-viewport-cGEmrGE7.js.map} +1 -1
- package/public/assets/{user-avatar-kYPfLgXp.js → user-avatar-qbo-B3dQ.js} +2 -2
- package/public/assets/{user-avatar-kYPfLgXp.js.map → user-avatar-qbo-B3dQ.js.map} +1 -1
- package/public/assets/{work-queue-Kw0uMkEF.js → work-queue-DDSGZO4Z.js} +2 -2
- package/public/assets/{work-queue-Kw0uMkEF.js.map → work-queue-DDSGZO4Z.js.map} +1 -1
- package/public/assets/{workspaces-CR6f7G0R.js → workspaces-CoDz-iZd.js} +2 -2
- package/public/assets/{workspaces-CR6f7G0R.js.map → workspaces-CoDz-iZd.js.map} +1 -1
- package/public/index.html +26 -26
- 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 +33 -31
- 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 +108 -46
- package/runner/src/runner-helpers.ts +31 -0
- 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 +150 -43
- package/src/maintenance/jobs.ts +8 -2
- package/src/mcp/tools-workspace.ts +4 -0
- package/src/routes/agent-sessions.ts +10 -7
- package/src/routes/agents.ts +0 -4
- package/src/routes/orchestrator.ts +5 -2
- package/src/routes/settings.ts +31 -2
- package/src/services/restart-instructions.ts +36 -0
- 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/src/settings/registry.ts +14 -3
- 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/formatted-body-BYQ-SPtj.js +0 -3
- 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
- package/public/assets/lucide-react-CoU_Xts2.js +0 -10
- package/public/assets/lucide-react-CoU_Xts2.js.map +0 -1
- package/public/assets/store-zFLzd0Jd.js +0 -9
- package/public/assets/store-zFLzd0Jd.js.map +0 -1
- package/public/assets/types-CRptbSYh.js.map +0 -1
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,126 @@ 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.
|
|
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);
|
|
436
447
|
}
|
|
437
448
|
|
|
438
|
-
|
|
439
|
-
deferredLineageReports.delete(childId);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/** Test seam: clear the #1037 deferred report-up hold between tests. */
|
|
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
|
+
const held: Array<{ original: Message; session: Record<string, unknown> }> = [];
|
|
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
|
+
held.push({ original, session });
|
|
492
|
+
}
|
|
493
|
+
const selected = source === "edge-fallback" ? selectEdgeFallbackLineageReports(held) : held;
|
|
494
|
+
let groupOrder = 0;
|
|
495
|
+
for (const { original, session } of selected) {
|
|
496
|
+
const finality: MessageWorkFinality = { windowId, source, groupOrder, markedAt };
|
|
497
|
+
const result = forwardLineageStreamCapture(original, session, "responses", child, parent, finality);
|
|
498
|
+
if (!result) continue;
|
|
499
|
+
groupOrder++;
|
|
500
|
+
forwarded.push(result);
|
|
501
|
+
const finalizedOriginal = setMessageWorkFinality(original.id, finality);
|
|
502
|
+
if (finalizedOriginal) finalizedOriginals.push(finalizedOriginal);
|
|
503
|
+
}
|
|
504
|
+
return { forwarded, finalizedOriginals };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function selectEdgeFallbackLineageReports<T extends { original: Message }>(held: T[]): T[] {
|
|
508
|
+
if (held.length <= 1) return held;
|
|
509
|
+
let bestIndex = -1;
|
|
510
|
+
let bestScore = -Infinity;
|
|
511
|
+
for (let i = 0; i < held.length; i++) {
|
|
512
|
+
const score = edgeFallbackReportScore(held[i]!.original.body);
|
|
513
|
+
if (score > bestScore || (score === bestScore && i > bestIndex)) {
|
|
514
|
+
bestScore = score;
|
|
515
|
+
bestIndex = i;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return bestScore > 0 && bestIndex >= 0 ? [held[bestIndex]!] : [held.at(-1)!];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const EDGE_FALLBACK_REPORT_BOUNDARY = /(^|\n)(#{1,6}\s|\*\*(summary|what changed|rca|fix|tests?|gates?|verification|risk)\b|(?:summary|what changed|rca|fix|tests?|gates?|verification|risk):)/i;
|
|
522
|
+
const EDGE_FALLBACK_REPORT_STRUCTURE = /(^|\n)\s*[-*]\s+\S|`[^`]+`|\b(typecheck|bun test|tests? pass(?:ed)?|gates? green|rca|verified|implemented)\b/i;
|
|
523
|
+
const EDGE_FALLBACK_GENERIC_CODA = /^(?:done|landed(?: on main)?|committed|merged|ready|no further action|nothing further|all set)\b|(?:\bno further action needed\b|\bnothing further needed\b)$/i;
|
|
524
|
+
const EDGE_FALLBACK_NARRATION = /^(?:i(?:'|’)ll|i(?:'|’)m|let me|checking|running|reading|found\b|now\b)/i;
|
|
525
|
+
|
|
526
|
+
function edgeFallbackReportScore(body: string): number {
|
|
527
|
+
const trimmed = body.trim();
|
|
528
|
+
if (!trimmed) return -10;
|
|
529
|
+
if (trimmed.length <= 180 && EDGE_FALLBACK_GENERIC_CODA.test(trimmed) && !EDGE_FALLBACK_REPORT_BOUNDARY.test(trimmed)) return -5;
|
|
530
|
+
let score = 0;
|
|
531
|
+
if (trimmed.length >= 120) score += 2;
|
|
532
|
+
if (EDGE_FALLBACK_REPORT_BOUNDARY.test(trimmed)) score += 6;
|
|
533
|
+
if (EDGE_FALLBACK_REPORT_STRUCTURE.test(trimmed)) score += 3;
|
|
534
|
+
if (EDGE_FALLBACK_NARRATION.test(trimmed)) score -= 3;
|
|
535
|
+
return score;
|
|
459
536
|
}
|
|
460
537
|
|
|
461
538
|
/**
|
|
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).
|
|
539
|
+
* #1040/#1041 — the persisted turn-final response captures needed to rebuild the in-memory held
|
|
540
|
+
* group after a relay restart. Bounded by a recency window (a report is only ever held while its
|
|
541
|
+
* work window is open, so it is inherently recent). The caller re-runs promotion per capture, which
|
|
542
|
+
* is idempotent: an already-forwarded capture is a no-op (idempotency key), and everything else
|
|
543
|
+
* re-enters the held ring exactly as it stood pre-restart, awaiting the next work-finality signal.
|
|
471
544
|
*/
|
|
472
545
|
export function listRecentFinalResponseCaptures(sinceMs: number, limit = 1000): Message[] {
|
|
473
546
|
const rows = getDb().query(`
|
|
@@ -529,17 +602,47 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
529
602
|
// #1037 verbose: forward every child stream without an explicit per-stream subscription.
|
|
530
603
|
if (stream !== "responses" && verbosity !== "verbose" && !childStreamSubscribed({ parentId: parent, child, stream })) return null;
|
|
531
604
|
|
|
532
|
-
// #
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
|
|
605
|
+
// #1084 — a `responses`-stream turn-final is TURN-final, not WORK-final: hold it (see
|
|
606
|
+
// deferLineageReport above) rather than forwarding, regardless of the child's busy/idle state —
|
|
607
|
+
// only an explicit work-finality signal (flushWorkFinalReports) delivers it upstream. `verbose` is
|
|
608
|
+
// an explicit opt-in firehose (a caller that asked for every turn) and bypasses the hold, matching
|
|
609
|
+
// its pre-#1084 behavior.
|
|
610
|
+
if (stream === "responses" && verbosity !== "verbose") {
|
|
536
611
|
deferLineageReport(child.id, message.id);
|
|
537
612
|
return null;
|
|
538
613
|
}
|
|
539
614
|
|
|
615
|
+
return forwardLineageStreamCapture(message, session, stream, child, parent);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Forward one captured child-stream event to its spawner as a `chat` report-up. Shared by the
|
|
620
|
+
* `verbose` immediate bypass (no `finality`) and `flushWorkFinalReports` (tags the forwarded report
|
|
621
|
+
* — and, via the caller, the original capture — with the closing work window's `finality`).
|
|
622
|
+
*/
|
|
623
|
+
function forwardLineageStreamCapture(
|
|
624
|
+
message: Message,
|
|
625
|
+
session: Record<string, unknown>,
|
|
626
|
+
stream: ChildStream,
|
|
627
|
+
child: AgentCard,
|
|
628
|
+
parent: string,
|
|
629
|
+
finality?: MessageWorkFinality,
|
|
630
|
+
): { message: Message; created: boolean } | null {
|
|
540
631
|
const idempotencyKey = lineageStreamReportKey(message, session, stream);
|
|
541
632
|
const existing = findMessageByIdempotencyKey(message.from, idempotencyKey);
|
|
542
|
-
if (existing)
|
|
633
|
+
if (existing) {
|
|
634
|
+
// #1084 MEDIUM (Lens-3 retroactive-finality) fix-forward — an already-forwarded report (e.g. a
|
|
635
|
+
// `verbose` bypass, or a duplicate suppressed on an earlier attempt) must still pick up the
|
|
636
|
+
// finality tag when its window later closes, or its dashboard bubble never upgrades from
|
|
637
|
+
// ordinary mid-turn narration to a closing message. Only patch when it isn't already tagged —
|
|
638
|
+
// an existing tag reflects an earlier, equally-valid window close and must not be overwritten.
|
|
639
|
+
if (finality && !hasWorkFinality(existing.payload)) {
|
|
640
|
+
const updatedPayload: MessagePayload = { ...(existing.payload ?? {}), finality };
|
|
641
|
+
getDb().query("UPDATE messages SET payload = ? WHERE id = ?").run(JSON.stringify(updatedPayload), existing.id);
|
|
642
|
+
return { message: getMessage(existing.id) ?? existing, created: false };
|
|
643
|
+
}
|
|
644
|
+
return { message: existing, created: false };
|
|
645
|
+
}
|
|
543
646
|
if (stream === "responses") {
|
|
544
647
|
const turnId = captureTurnId(session);
|
|
545
648
|
if (turnId) {
|
|
@@ -559,12 +662,17 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
559
662
|
since: message.createdAt - LINEAGE_REPORT_DUPLICATE_WINDOW_MS,
|
|
560
663
|
});
|
|
561
664
|
if (duplicate && !responseCaptureMeta(duplicate)) {
|
|
665
|
+
const updatedPayload: MessagePayload = {
|
|
666
|
+
...(duplicate.payload ?? {}),
|
|
667
|
+
responseCapture: { ...session },
|
|
668
|
+
...(finality ? { finality } : {}),
|
|
669
|
+
};
|
|
562
670
|
getDb().query(`
|
|
563
671
|
UPDATE messages
|
|
564
672
|
SET idempotency_key = coalesce(idempotency_key, ?),
|
|
565
673
|
payload = ?
|
|
566
674
|
WHERE id = ?
|
|
567
|
-
`).run(idempotencyKey, JSON.stringify(
|
|
675
|
+
`).run(idempotencyKey, JSON.stringify(updatedPayload), duplicate.id);
|
|
568
676
|
return { message: getMessage(duplicate.id) ?? duplicate, created: false };
|
|
569
677
|
}
|
|
570
678
|
}
|
|
@@ -579,12 +687,11 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
579
687
|
replyExpected: false,
|
|
580
688
|
idempotencyKey,
|
|
581
689
|
payload: stream === "responses"
|
|
582
|
-
? { responseCapture: { ...session } }
|
|
690
|
+
? { responseCapture: { ...session }, ...(finality ? { finality } : {}) }
|
|
583
691
|
: { childStreamCapture: { stream, ...session } },
|
|
584
692
|
occurredAt: message.occurredAt ?? message.createdAt,
|
|
585
693
|
});
|
|
586
694
|
if (stream === "responses" && result.created) {
|
|
587
|
-
clearDeferredLineageReport(child.id);
|
|
588
695
|
stampReportUpLifecycleSignal(parent, child);
|
|
589
696
|
}
|
|
590
697
|
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
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { RELAY_TOKEN_HEADER, isRecord, isSpawnProvider } from "agent-relay-sdk";
|
|
3
3
|
import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeAgentRoute, authorizeRoute, canViewAgentRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, requestUserId, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
|
|
4
4
|
import { arbitrateDrive, currentDriver, drivingMessage, releaseDrive, type DriveArbitration } from "../agent-drive-arbiter";
|
|
5
|
-
import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta,
|
|
5
|
+
import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage, withResolvedProvisioning } from "../db";
|
|
6
6
|
import { announcePresence, viewersForAgent } from "../dashboard-presence";
|
|
7
7
|
import { buildManagedSpawnParams } from "../managed-policy";
|
|
8
8
|
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
|
|
@@ -19,8 +19,8 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../servic
|
|
|
19
19
|
import { type AgentCard, type SpawnApprovalMode } from "../types";
|
|
20
20
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
21
21
|
import { getManifest } from "agent-relay-providers";
|
|
22
|
-
import { composeProfileSpawnInstructions } from "../project-instructions";
|
|
23
22
|
import { relayToken } from "../config";
|
|
23
|
+
import { restartInstructionParamsForAgent } from "../services/restart-instructions";
|
|
24
24
|
|
|
25
25
|
// 409 when a second human tries to drive an agent another user is already driving (#394). The
|
|
26
26
|
// blocked driver learns who holds the wheel so the UI can show "X is driving" + offer "request".
|
|
@@ -133,6 +133,7 @@ export function restartSpawnParamsForAgent(
|
|
|
133
133
|
const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
|
|
134
134
|
const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
|
|
135
135
|
const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
|
|
136
|
+
const projectedInstructions = restartInstructionParamsForAgent(agent, { spawnRequestId, cwd }) ?? {};
|
|
136
137
|
// #1052-v2 — a dashboard-spawned agent has NO spawn policy, so its restart skips the
|
|
137
138
|
// `buildManagedSpawnParams` policy branch above and lands here. Re-project the profile role
|
|
138
139
|
// charter / project knowledge / scoped memory (the SAME projection a fresh spawn gets) so the
|
|
@@ -152,8 +153,6 @@ export function restartSpawnParamsForAgent(
|
|
|
152
153
|
// params — the SAME field a fresh agent.spawn stashes it in, see spawn-agent.ts). The reader then
|
|
153
154
|
// recovers it from either the original agent.spawn row OR a later agent.restart row, so each
|
|
154
155
|
// generation has its own recoverable copy and the brief follows the agent across every rotation.
|
|
155
|
-
const callerAppend = readSpawnCallerSystemPromptAppend(spawnRequestId);
|
|
156
|
-
const composed = composeProfileSpawnInstructions({ cwd, operatorId: resolveAgentOwnership(agent.id).ownerUserId, ...(profileName ? { profileName } : {}), ...(agentProfile ? { profile: agentProfile } : {}), ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}) });
|
|
157
156
|
const params = buildSpawnCommand({
|
|
158
157
|
provider,
|
|
159
158
|
modelParams: resolvedModel,
|
|
@@ -168,12 +167,12 @@ export function restartSpawnParamsForAgent(
|
|
|
168
167
|
approvalMode: approvalMode ?? "guarded",
|
|
169
168
|
permissionMode: approvalMode ?? "guarded",
|
|
170
169
|
providerArgs: providerArgs.length ? providerArgs : undefined,
|
|
171
|
-
...(
|
|
170
|
+
...(typeof projectedInstructions.systemPromptAppend === "string" ? { systemPromptAppend: projectedInstructions.systemPromptAppend } : {}),
|
|
172
171
|
// #1067 — re-persist the RAW brief (pre-composition) under this restart's fresh spawnRequestId so
|
|
173
172
|
// the NEXT restart, whose predecessor id is this fresh id, can recover it. Without this the brief
|
|
174
173
|
// survives only one restart (the original agent.spawn row is keyed on the very first id).
|
|
175
|
-
...(
|
|
176
|
-
...(
|
|
174
|
+
...(typeof projectedInstructions.callerSystemPromptAppend === "string" ? { callerSystemPromptAppend: projectedInstructions.callerSystemPromptAppend } : {}),
|
|
175
|
+
...(Array.isArray(projectedInstructions.relayInjectionEvents) ? { relayInjectionEvents: projectedInstructions.relayInjectionEvents } : {}),
|
|
177
176
|
policyName: policyName || undefined,
|
|
178
177
|
headless: true,
|
|
179
178
|
spawnRequestId: requestId,
|
|
@@ -298,6 +297,9 @@ export const postAgentAction: Handler = async (req, params) => {
|
|
|
298
297
|
const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
|
|
299
298
|
const metaSpawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
|
|
300
299
|
const resumeId = action === "resume" ? latestResumeIdForAgent(agent) : undefined;
|
|
300
|
+
const restartInstructions = action === "restart" || action === "resume"
|
|
301
|
+
? restartInstructionParamsForAgent(agent, { spawnRequestId: metaSpawnRequestId, cwd: metaString(agent.meta, "cwd") ?? orchestrator?.baseDir })
|
|
302
|
+
: undefined;
|
|
301
303
|
if (action === "resume" && !orchestrator) return error("no online orchestrator available to resume agent", 409);
|
|
302
304
|
if (action === "resume" && !resumeId) return error("no resume id recorded for agent", 422);
|
|
303
305
|
const denied = authorizeRoute(req, {
|
|
@@ -319,6 +321,7 @@ export const postAgentAction: Handler = async (req, params) => {
|
|
|
319
321
|
...(metaPolicyName ? { policyName: metaPolicyName } : {}),
|
|
320
322
|
...(metaSpawnRequestId ? { spawnRequestId: metaSpawnRequestId } : {}),
|
|
321
323
|
...(action === "restart" || action === "resume" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId, { resumeId }) } : {}),
|
|
324
|
+
...(restartInstructions ?? {}),
|
|
322
325
|
...(action === "compact" ? compactMemoryParams(agent) : {}),
|
|
323
326
|
...(resumeId ? { claudeResumeId: resumeId } : {}),
|
|
324
327
|
requestedBy: "dashboard",
|
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;
|
|
@@ -4,7 +4,7 @@ import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, par
|
|
|
4
4
|
import { notifyAgentOffline } from "../agent-lifecycle-events";
|
|
5
5
|
import { diagnoseAgentDeath } from "../agent-death-diagnosis";
|
|
6
6
|
import { VERSION } from "../config";
|
|
7
|
-
import { ValidationError, deleteOrchestrator, getOrchestrator, listOrchestrators, orchestratorHeartbeat, recordAgentExitDiagnostics, setOrchestratorUpgradeState, updateManagedAgents, upsertOrchestrator } from "../db";
|
|
7
|
+
import { ValidationError, deleteOrchestrator, getAgent, getOrchestrator, listOrchestrators, orchestratorHeartbeat, recordAgentExitDiagnostics, setOrchestratorUpgradeState, updateManagedAgents, upsertOrchestrator } from "../db";
|
|
8
8
|
import { auditEvent, authAuditMetadata, authorizeRoute, cleanJsonArray, cleanSafeNumber, dashboardAttribution, emitCommand, error, isRootCredentialRequest, json, parseBody, spawnRequestId, type Handler } from "./_shared";
|
|
9
9
|
import { cleanMeta, cleanString, cleanStringArray, cleanWorkspaceMetadata, optionalEnum } from "../validation";
|
|
10
10
|
import { createCommand, updateCommand } from "../commands-db";
|
|
@@ -13,6 +13,7 @@ import { getLifecycleManager } from "../lifecycle-manager";
|
|
|
13
13
|
import { issueOrchestratorRuntimeToken } from "../runtime-tokens";
|
|
14
14
|
import { isOrchestratorRegistrationAuthorized } from "../security";
|
|
15
15
|
import { type ManagedAgent, type ManagedSessionExitDiagnostics, type OrchestratorRuntimeInput, type RegisterOrchestratorInput, type SpawnApprovalMode, type SpawnProvider } from "../types";
|
|
16
|
+
import { restartSpawnParamsForAgent } from "./agent-sessions";
|
|
16
17
|
|
|
17
18
|
function cleanProtocolVersion(value: unknown): number | undefined {
|
|
18
19
|
if (value === undefined) return undefined;
|
|
@@ -533,13 +534,15 @@ export const postOrchestratorAction: Handler = async (req, params) => {
|
|
|
533
534
|
resource: { orchestratorId: orch.id, agentId, policyName, spawnRequestId },
|
|
534
535
|
});
|
|
535
536
|
if (denied) return denied;
|
|
537
|
+
const agent = action === "restart" && agentId ? getAgent(agentId) : undefined;
|
|
538
|
+
const restartSpawn = agent ? restartSpawnParamsForAgent(agent, orch, policyName, spawnRequestId) : undefined;
|
|
536
539
|
|
|
537
540
|
const command = createCommand({
|
|
538
541
|
type: action === "restart" ? "agent.restart" : "agent.shutdown",
|
|
539
542
|
source: "system",
|
|
540
543
|
target: orch.agentId,
|
|
541
544
|
correlationId: spawnRequestId,
|
|
542
|
-
params: { action, agentId, policyName, spawnRequestId, sessionName, tmuxSession, reason, requestedBy: "dashboard", requestedAt: Date.now(), orchestratorId: orch.id },
|
|
545
|
+
params: { action, agentId, policyName, spawnRequestId, sessionName, tmuxSession, reason, ...(restartSpawn ? { restartSpawn } : {}), requestedBy: "dashboard", requestedAt: Date.now(), orchestratorId: orch.id },
|
|
543
546
|
});
|
|
544
547
|
emitCommand(command);
|
|
545
548
|
auditEvent({
|
package/src/routes/settings.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { isRequestAuthorizedFor } from "../security";
|
|
|
10
10
|
import { cleanString } from "../validation";
|
|
11
11
|
import { emitConfigChanged } from "../sse";
|
|
12
12
|
import { error, json, normalizeConfigPathParam, parseBody, requestUserId, type Handler } from "./_shared";
|
|
13
|
-
import { DEFAULT_USER_ID, isRecord, type ConfigEntry, type SettingDescriptor, type SettingEntry } from "agent-relay-sdk";
|
|
13
|
+
import { DEFAULT_USER_ID, isRecord, type ConfigEntry, type JSONSchema, type SettingDescriptor, type SettingEntry } from "agent-relay-sdk";
|
|
14
14
|
|
|
15
15
|
type SettingEnvelope = SettingEntry;
|
|
16
16
|
|
|
@@ -91,7 +91,10 @@ async function putSetting(req: Request, namespaceRaw: string | undefined, keyRaw
|
|
|
91
91
|
if (!validation.valid) return json({ error: "invalid setting value", details: validation.errors }, 400);
|
|
92
92
|
if (descriptor.tier === "user") {
|
|
93
93
|
const userId = requestUserId(req);
|
|
94
|
-
const
|
|
94
|
+
const valueToStore = descriptor.mergeMapKeys && isRecord(parsed.body.value)
|
|
95
|
+
? mergeMapKeysIntoExisting(getUserSetting(userId, namespace, key)?.value, parsed.body.value, descriptor.schema)
|
|
96
|
+
: parsed.body.value;
|
|
97
|
+
const userEntry = setUserSetting(userId, namespace, key, valueToStore, updatedBy);
|
|
95
98
|
emitConfigChanged(userEntry.namespace, userEntry.key, userEntry.version);
|
|
96
99
|
return json(settingEnvelope(req, descriptor, userEntry), userEntry.version === 1 ? 201 : 200);
|
|
97
100
|
}
|
|
@@ -156,3 +159,29 @@ function mergeSettingDefaults(defaults: unknown, value: unknown): unknown {
|
|
|
156
159
|
}
|
|
157
160
|
return merged;
|
|
158
161
|
}
|
|
162
|
+
|
|
163
|
+
// A `mergeMapKeys` descriptor (#1124) is a dictionary keyed by caller-chosen ids (e.g. one row
|
|
164
|
+
// per conversation). A PUT only ever knows its own device's changed keys, never every other
|
|
165
|
+
// device's, so overwriting the whole stored value like a normal setting would erase them — this
|
|
166
|
+
// merges just the incoming keys on top of whatever is already stored instead. The merged result
|
|
167
|
+
// is then capped at the schema's `maxProperties` (when set) so per-key merges from unboundedly
|
|
168
|
+
// many devices can't grow the row forever; the entry with the OLDEST `updatedAt` is evicted first
|
|
169
|
+
// (missing/non-numeric `updatedAt`, e.g. a pre-#1124 row, sorts as oldest: 0).
|
|
170
|
+
function mergeMapKeysIntoExisting(existing: unknown, incoming: Record<string, unknown>, schema: JSONSchema): Record<string, unknown> {
|
|
171
|
+
const merged: Record<string, unknown> = isRecord(existing) ? { ...existing } : {};
|
|
172
|
+
for (const [key, item] of Object.entries(incoming)) merged[key] = item;
|
|
173
|
+
|
|
174
|
+
const cap = typeof schema.maxProperties === "number" ? schema.maxProperties : undefined;
|
|
175
|
+
const entries = Object.entries(merged);
|
|
176
|
+
if (cap !== undefined && entries.length > cap) {
|
|
177
|
+
entries.sort((a, b) => entryUpdatedAt(a[1]) - entryUpdatedAt(b[1]));
|
|
178
|
+
const overflow = entries.length - cap;
|
|
179
|
+
for (let i = 0; i < overflow; i += 1) delete merged[entries[i]![0]];
|
|
180
|
+
}
|
|
181
|
+
return merged;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function entryUpdatedAt(value: unknown): number {
|
|
185
|
+
if (isRecord(value) && typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt)) return value.updatedAt;
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isSpawnProvider } from "agent-relay-sdk";
|
|
2
|
+
import { getAgentProfile } from "../config-store";
|
|
3
|
+
import { readSpawnCallerSystemPromptAppend, resolveAgentOwnership, withResolvedProvisioning } from "../db";
|
|
4
|
+
import { composeProfileSpawnInstructions } from "../project-instructions";
|
|
5
|
+
import { applyDefaultSharedMcp } from "../shared-mcp";
|
|
6
|
+
import type { AgentCard } from "../types";
|
|
7
|
+
|
|
8
|
+
function metaString(meta: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
9
|
+
const value = meta?.[key];
|
|
10
|
+
return typeof value === "string" ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function restartInstructionParamsForAgent(
|
|
14
|
+
agent: AgentCard,
|
|
15
|
+
opts: { spawnRequestId?: string; cwd?: string } = {},
|
|
16
|
+
): Record<string, unknown> | undefined {
|
|
17
|
+
const provider = metaString(agent.meta, "provider");
|
|
18
|
+
if (!isSpawnProvider(provider)) return undefined;
|
|
19
|
+
const cwd = opts.cwd ?? metaString(agent.meta, "cwd");
|
|
20
|
+
const profileName = metaString(agent.meta, "profile");
|
|
21
|
+
const resolvedProfile = withResolvedProvisioning(profileName ? getAgentProfile(profileName)?.value : undefined, provider);
|
|
22
|
+
const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
|
|
23
|
+
const callerAppend = readSpawnCallerSystemPromptAppend(opts.spawnRequestId);
|
|
24
|
+
const composed = composeProfileSpawnInstructions({
|
|
25
|
+
...(cwd ? { cwd } : {}),
|
|
26
|
+
operatorId: resolveAgentOwnership(agent.id).ownerUserId,
|
|
27
|
+
...(profileName ? { profileName } : {}),
|
|
28
|
+
...(agentProfile ? { profile: agentProfile } : {}),
|
|
29
|
+
...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}),
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
...(composed.systemPromptAppend ? { systemPromptAppend: composed.systemPromptAppend } : {}),
|
|
33
|
+
...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}),
|
|
34
|
+
...(composed.relayInjectionEvents.length ? { relayInjectionEvents: composed.relayInjectionEvents } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId, isTurnFinalSession } from "agent-relay-sdk";
|
|
25
25
|
import {
|
|
26
26
|
ValidationError,
|
|
27
|
-
flushDeferredLineageReport,
|
|
28
27
|
getAgent,
|
|
29
28
|
getMessage,
|
|
30
29
|
latestInboundMessage,
|
|
@@ -228,28 +227,18 @@ function prepareSendMessage(input: SendMessageInput, ctx: AuthContext, opts: Sen
|
|
|
228
227
|
return { receipt, suppressedParent: suppressResultsOnlyAck(input) };
|
|
229
228
|
}
|
|
230
229
|
|
|
231
|
-
/**
|
|
232
|
-
* #1037 — the child transitioned out of busy (idle/awaiting-input). Deliver its deferred turn-final
|
|
233
|
-
* report-up (a quiet child's last turn, held back so mid-work narration never reached the spawner)
|
|
234
|
-
* and fan it out over SSE. No-op when nothing was deferred. Driven off the bus turn-end edge.
|
|
235
|
-
*/
|
|
236
|
-
export function flushIdleLineageReport(childId: string): void {
|
|
237
|
-
const promoted = flushDeferredLineageReport(childId);
|
|
238
|
-
if (promoted?.created) emitNewMessages([promoted.message]);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
230
|
/** How far back the startup sweep re-scans for held-but-lost turn-final captures (#1040). A report
|
|
242
|
-
* is only ever
|
|
231
|
+
* is only ever held while its work window is open, so any capture lost across a restart is recent;
|
|
243
232
|
* a day is a generous ceiling that still bounds the scan. */
|
|
244
233
|
const LINEAGE_STARTUP_SWEEP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
245
234
|
|
|
246
235
|
/**
|
|
247
|
-
* #1040 — startup re-promotion sweep.
|
|
248
|
-
*
|
|
249
|
-
* promotion for recent final response captures:
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
236
|
+
* #1040/#1084 — startup re-promotion sweep. The held-turn-final group (db/messages.ts) is an
|
|
237
|
+
* in-memory Map, so a relay restart drops it. The captures are persisted, though, so re-run
|
|
238
|
+
* promotion for recent final response captures: this just REBUILDS the held group (an
|
|
239
|
+
* already-forwarded capture is a no-op via its idempotency key; a `verbose` child's capture is
|
|
240
|
+
* delivered immediately, as it always is). Delivery to the spawner still only ever happens via an
|
|
241
|
+
* explicit work-finality signal (markAgentWorkFinal) — this sweep never flushes on its own.
|
|
253
242
|
*/
|
|
254
243
|
export function sweepDeferredLineageReportsOnStartup(now: number = Date.now()): { promoted: number; scanned: number } {
|
|
255
244
|
const captures = listRecentFinalResponseCaptures(now - LINEAGE_STARTUP_SWEEP_WINDOW_MS);
|
|
@@ -37,6 +37,7 @@ import { createCommand } from "../commands-db";
|
|
|
37
37
|
import { emitCommandEvent } from "../command-events";
|
|
38
38
|
import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
|
|
39
39
|
import { emitActivityEvent, emitAgentStatus } from "../sse";
|
|
40
|
+
import { markAgentWorkFinal } from "./work-finality";
|
|
40
41
|
import { stringValue } from "agent-relay-sdk";
|
|
41
42
|
import type { AuthContext } from "./auth-context";
|
|
42
43
|
import type { AgentCard, Command } from "../types";
|
|
@@ -211,6 +212,11 @@ export function shutdownAgent(input: ShutdownAgentInput, ctx: AuthContext): Shut
|
|
|
211
212
|
},
|
|
212
213
|
});
|
|
213
214
|
emitCommandEvent(command, "command.requested");
|
|
215
|
+
// #1084 — an explicit shutdown is a platform-owned teardown signal: close the target's work
|
|
216
|
+
// window now rather than let a held final group die silently with the process. `edge-fallback`
|
|
217
|
+
// is the closest fit in the closed source enum — there is no cleaner workspace-ready/task-done
|
|
218
|
+
// signal backing an arbitrary teardown (which may target an agent with neither).
|
|
219
|
+
if (targetAgentId) markAgentWorkFinal(targetAgentId, "edge-fallback");
|
|
214
220
|
|
|
215
221
|
// Lifecycle indicator — uniform across transports. The MCP path previously skipped
|
|
216
222
|
// this, so an MCP-initiated shutdown left the agent with no "shutting down" signal in
|
|
@@ -44,6 +44,7 @@ import { emitRelayEvent } from "../events";
|
|
|
44
44
|
import { routeNotification } from "../notification-router";
|
|
45
45
|
import { isAgentExitDoneWhen, stageSatisfiesDoneWhen } from "./task-done-when";
|
|
46
46
|
import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
|
|
47
|
+
import { markAgentWorkFinal } from "./work-finality";
|
|
47
48
|
|
|
48
49
|
type CreateTaskEntityInput = CreateDeliverableTaskInput & {
|
|
49
50
|
issueRefs?: string[];
|
|
@@ -140,6 +141,9 @@ export function setTaskEntityStatus(id: number, input: SetTaskStatusInput): Task
|
|
|
140
141
|
});
|
|
141
142
|
}
|
|
142
143
|
if (isTerminalTaskStatus(detail.status)) removeSubscriptionsForTask(detail.id);
|
|
144
|
+
// #1084 — a task reaching `done` is an explicit, platform-owned completion signal for its
|
|
145
|
+
// owning agent's work window: close it, forwarding only the held final group.
|
|
146
|
+
if (before?.status !== "done" && detail.status === "done") markAgentWorkFinal(detail.ownerAgentId, "task-done");
|
|
143
147
|
return detail;
|
|
144
148
|
}
|
|
145
149
|
|