agent-relay-server 0.121.0 → 0.121.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +1 -1
- package/package.json +5 -4
- package/public/assets/{MessageBubble-BaPGFJxq.js → MessageBubble-CLqxCG9b.js} +2 -2
- package/public/assets/{MessageBubble-BaPGFJxq.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BXCVfmln.js → PlanGraphPanel-BkkToFEb.js} +2 -2
- package/public/assets/{PlanGraphPanel-BXCVfmln.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
- package/public/assets/{activity-D_shwAZt.js → activity-D-ocGQGs.js} +2 -2
- package/public/assets/{activity-D_shwAZt.js.map → activity-D-ocGQGs.js.map} +1 -1
- package/public/assets/{agent-profiles-BrfVz5IV.js → agent-profiles-CRPgYTyb.js} +2 -2
- package/public/assets/{agent-profiles-BrfVz5IV.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
- package/public/assets/{agents-B5w-Z9Ic.js → agents-Chw5E_2q.js} +2 -2
- package/public/assets/{agents-B5w-Z9Ic.js.map → agents-Chw5E_2q.js.map} +1 -1
- package/public/assets/{analytics-Bm3D12UN.js → analytics-BmNDG0hR.js} +2 -2
- package/public/assets/{analytics-Bm3D12UN.js.map → analytics-BmNDG0hR.js.map} +1 -1
- package/public/assets/{automation-DOTL8BFs.js → automation-D7g90kTv.js} +2 -2
- package/public/assets/{automation-DOTL8BFs.js.map → automation-D7g90kTv.js.map} +1 -1
- package/public/assets/{branch-state-badge-BlqeJZ5-.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
- package/public/assets/{branch-state-badge-BlqeJZ5-.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
- package/public/assets/{channels-BoiKfK6Q.js → channels-CLwPeEPi.js} +2 -2
- package/public/assets/{channels-BoiKfK6Q.js.map → channels-CLwPeEPi.js.map} +1 -1
- package/public/assets/{chat-PeU3iOaa.js → chat-Cgj7VKzc.js} +2 -2
- package/public/assets/{chat-PeU3iOaa.js.map → chat-Cgj7VKzc.js.map} +1 -1
- package/public/assets/{connectors-BvV8Hee2.js → connectors-Br6fiTny.js} +2 -2
- package/public/assets/{connectors-BvV8Hee2.js.map → connectors-Br6fiTny.js.map} +1 -1
- package/public/assets/{formatted-body-C1FgzlT1.js → formatted-body-CmkuD23M.js} +3 -3
- package/public/assets/{formatted-body-C1FgzlT1.js.map → formatted-body-CmkuD23M.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BTpU1xH4.js → formatted-body-impl-DbdFmbwW.js} +2 -2
- package/public/assets/{formatted-body-impl-BTpU1xH4.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
- package/public/assets/{index-DZEahmyc.js → index-D9OogaB5.js} +6 -6
- package/public/assets/{index-DZEahmyc.js.map → index-D9OogaB5.js.map} +1 -1
- package/public/assets/{insights-BqmgSpQm.js → insights-qvaPqWCV.js} +2 -2
- package/public/assets/{insights-BqmgSpQm.js.map → insights-qvaPqWCV.js.map} +1 -1
- package/public/assets/{integrations-CuT92zQi.js → integrations-DYEGSqq_.js} +2 -2
- package/public/assets/{integrations-CuT92zQi.js.map → integrations-DYEGSqq_.js.map} +1 -1
- package/public/assets/{maintenance-lM7FeXVC.js → maintenance-C0HIEB-J.js} +2 -2
- package/public/assets/{maintenance-lM7FeXVC.js.map → maintenance-C0HIEB-J.js.map} +1 -1
- package/public/assets/{managed-agents-DaPNv3yY.js → managed-agents-DdS7aI38.js} +2 -2
- package/public/assets/{managed-agents-DaPNv3yY.js.map → managed-agents-DdS7aI38.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-Wia3Ho1s.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
- package/public/assets/{markdown-preview-impl-Wia3Ho1s.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
- package/public/assets/{memory-B1v_zD-M.js → memory-CSwx7bz8.js} +2 -2
- package/public/assets/{memory-B1v_zD-M.js.map → memory-CSwx7bz8.js.map} +1 -1
- package/public/assets/{messages-BNOx3yK6.js → messages-Be9CmvDv.js} +2 -2
- package/public/assets/{messages-BNOx3yK6.js.map → messages-Be9CmvDv.js.map} +1 -1
- package/public/assets/{orchestrators-DWFy4asl.js → orchestrators-BgtvvHvo.js} +2 -2
- package/public/assets/{orchestrators-DWFy4asl.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
- package/public/assets/{overview-C_12cFPM.js → overview-BvhbPWtF.js} +2 -2
- package/public/assets/{overview-C_12cFPM.js.map → overview-BvhbPWtF.js.map} +1 -1
- package/public/assets/{pairs-DIc7gX83.js → pairs-CpXSOMx0.js} +2 -2
- package/public/assets/{pairs-DIc7gX83.js.map → pairs-CpXSOMx0.js.map} +1 -1
- package/public/assets/{projects-DPjvJUzT.js → projects-DdPNPFJI.js} +2 -2
- package/public/assets/{projects-DPjvJUzT.js.map → projects-DdPNPFJI.js.map} +1 -1
- package/public/assets/{prompt-settings-CtumNgG8.js → prompt-settings-BpZse7-X.js} +2 -2
- package/public/assets/{prompt-settings-CtumNgG8.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
- package/public/assets/{registry-DzUhgMq9.js → registry-pb1gTZEg.js} +2 -2
- package/public/assets/{registry-DzUhgMq9.js.map → registry-pb1gTZEg.js.map} +1 -1
- package/public/assets/{security-BJ3_u0uY.js → security-BgcX1n9D.js} +2 -2
- package/public/assets/{security-BJ3_u0uY.js.map → security-BgcX1n9D.js.map} +1 -1
- package/public/assets/{select-AWvlctyQ.js → select-DZPVpKPv.js} +2 -2
- package/public/assets/{select-AWvlctyQ.js.map → select-DZPVpKPv.js.map} +1 -1
- package/public/assets/{settings-5ofqi-bN.js → settings-hd5kzdRB.js} +2 -2
- package/public/assets/{settings-5ofqi-bN.js.map → settings-hd5kzdRB.js.map} +1 -1
- package/public/assets/store-DKTLubBT.js +9 -0
- package/public/assets/store-DKTLubBT.js.map +1 -0
- package/public/assets/{tasks-BEE9bBf7.js → tasks-C5d2LU5E.js} +2 -2
- package/public/assets/{tasks-BEE9bBf7.js.map → tasks-C5d2LU5E.js.map} +1 -1
- package/public/assets/{teams-C27H_Gwo.js → teams-B8f2pGJe.js} +2 -2
- package/public/assets/{teams-C27H_Gwo.js.map → teams-B8f2pGJe.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-WD2TboA7.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
- package/public/assets/{terminal-viewer-impl-WD2TboA7.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-CtmPK2hK.js → work-queue-9imc7aNK.js} +2 -2
- package/public/assets/{work-queue-CtmPK2hK.js.map → work-queue-9imc7aNK.js.map} +1 -1
- package/public/assets/{workspaces-B26EKv_H.js → workspaces-CLYc3yF3.js} +2 -2
- package/public/assets/{workspaces-B26EKv_H.js.map → workspaces-CLYc3yF3.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
- package/src/agent-issue-repo.ts +10 -3
- package/src/agent-lifecycle-events.ts +23 -1
- package/src/agent-ref.ts +18 -3
- package/src/bus-outbox.ts +44 -1
- package/src/bus.ts +34 -7
- package/src/channel-target.ts +4 -0
- package/src/cli/steward.ts +15 -14
- package/src/commands-db.ts +63 -9
- package/src/config.ts +50 -0
- package/src/db/agents.ts +6 -2
- package/src/db/claim-sql.ts +16 -0
- package/src/db/continuations.ts +44 -3
- package/src/db/message-reads.ts +34 -4
- package/src/db/messages.ts +128 -4
- package/src/db/pairs.ts +1 -0
- package/src/db/schema.ts +6 -0
- package/src/event-loop-lag.ts +10 -1
- package/src/gate-resolver.ts +124 -0
- package/src/lifecycle-manager.ts +41 -10
- package/src/lifecycle-signal-registry.ts +87 -0
- package/src/maintenance/constants.ts +5 -1
- package/src/maintenance/jobs.ts +36 -4
- package/src/maintenance/workspaces.ts +75 -44
- package/src/managed-policy-escalation.ts +68 -3
- package/src/mcp/tools-spawn.ts +16 -6
- package/src/mcp-plan-tools.ts +2 -3
- package/src/notification-settings.ts +11 -0
- package/src/notification-types.ts +4 -0
- package/src/operator-alarm.ts +37 -0
- package/src/routes/agents.ts +4 -0
- package/src/routes/orchestrator-proxy.ts +13 -1
- package/src/routes/orchestrator.ts +4 -0
- package/src/routes/workspaces.ts +25 -7
- package/src/security.ts +35 -0
- package/src/self-resume.ts +185 -21
- package/src/server.ts +8 -0
- package/src/services/plan-graph.ts +42 -12
- package/src/services/register-agent.ts +5 -1
- package/src/services/send-message.ts +36 -0
- package/src/services/spawn-agent.ts +21 -2
- package/src/services/task-semantic-events.ts +17 -1
- package/src/spawn-command.ts +3 -0
- package/src/spawn-targets.ts +26 -1
- package/src/sse.ts +13 -5
- package/src/workspace-auto-merge.ts +90 -34
- package/src/workspace-escalation.ts +52 -3
- package/src/workspace-human-escalation.ts +68 -0
- package/src/workspace-merge.ts +16 -0
- package/src/workspace-orphans.ts +11 -6
- package/src/workspace-phase.ts +22 -1
- package/public/assets/store-D5et8685.js +0 -9
- package/public/assets/store-D5et8685.js.map +0 -1
package/src/db/message-reads.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
|
|
3
|
+
import { isRecord, stringValue, isMechanicalMessageKind, isReportVerbosity, type ReportVerbosity } from "agent-relay-sdk";
|
|
4
4
|
import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
|
|
5
5
|
import { parseJson } from "../utils";
|
|
6
6
|
import { isLiveIsolatedWorkspace } from "../workspace-phase";
|
|
@@ -19,7 +19,7 @@ import { sendMessageWithResult } from "./messages.ts";
|
|
|
19
19
|
import { deleteArtifactLinksForEntity } from "./artifacts.ts";
|
|
20
20
|
import { getDb, getDbPath, runDbMaintenanceWorker, timedQuery } from "./connection.ts";
|
|
21
21
|
import { legacyChannelTargets, isChannelAgentId } from "./delivery.ts";
|
|
22
|
-
import { messageTaskClosed } from "./claim-sql.ts";
|
|
22
|
+
import { claimableHandledNoTask, messageTaskClosed } from "./claim-sql.ts";
|
|
23
23
|
import { MSG_SELECT, rowToMessage } from "./mappers.ts";
|
|
24
24
|
import type {
|
|
25
25
|
AgentCard,
|
|
@@ -306,7 +306,11 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
306
306
|
targetClauses.push(freshFanoutTargetClause(`tag:${tag}`, agent, params));
|
|
307
307
|
}
|
|
308
308
|
for (const cap of agentCaps) {
|
|
309
|
+
// A capability fan-out is accepted at send time under both `cap:` and its `capability:` alias
|
|
310
|
+
// (planSend/FANOUT_PREFIXES + the SDK matcher). Poll for BOTH stored forms, or a message sent
|
|
311
|
+
// to `capability:X` gets a delivered receipt yet is invisible to every poll (#1022 F3).
|
|
309
312
|
targetClauses.push(freshFanoutTargetClause(`cap:${cap}`, agent, params));
|
|
313
|
+
targetClauses.push(freshFanoutTargetClause(`capability:${cap}`, agent, params));
|
|
310
314
|
}
|
|
311
315
|
if (agentLabel) {
|
|
312
316
|
targetClauses.push(freshFanoutTargetClause(`label:${agentLabel}`, agent, params));
|
|
@@ -338,7 +342,9 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
338
342
|
// pruned/deleted its message claims are released to NULL (see pruneOfflineAgents/deleteAgent),
|
|
339
343
|
// so without it a finished task's message resurrects as fresh work on the next restart (which
|
|
340
344
|
// mints a new agent id, so message_reads no longer dedups it) — the automation re-delivery bug.
|
|
341
|
-
|
|
345
|
+
// `claimableHandledNoTask` closes the same gap for claimables with NO linked task: once the
|
|
346
|
+
// holder handled one, its lease expired and it got re-offered to everyone forever (#1022 F6).
|
|
347
|
+
conditions.push(`(claimable = 0 OR claimed_by = ? OR ((claimed_by IS NULL OR claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT ${messageTaskClosed("m.id")} AND NOT ${claimableHandledNoTask("m.id")}))`);
|
|
342
348
|
params.push(query.for, Date.now());
|
|
343
349
|
|
|
344
350
|
if (query.sinceId !== undefined) {
|
|
@@ -594,6 +600,30 @@ export function seedSpawnPromptMirror(
|
|
|
594
600
|
}
|
|
595
601
|
}
|
|
596
602
|
|
|
603
|
+
/**
|
|
604
|
+
* #1037 — the per-spawn report-up verbosity a spawner chose, read from the persisted `agent.spawn`
|
|
605
|
+
* command params by spawnRequestId. Mirrors seedSpawnPromptMirror: at first registration the relay
|
|
606
|
+
* has both the resolved child id and the spawn command, so registration stamps this onto child meta
|
|
607
|
+
* where the lineage report-up delivery gate reads it. Returns undefined when absent/unparseable
|
|
608
|
+
* (the gate then defaults to `quiet`).
|
|
609
|
+
*/
|
|
610
|
+
export function readSpawnReportVerbosity(spawnRequestId: string | undefined): ReportVerbosity | undefined {
|
|
611
|
+
if (!spawnRequestId) return undefined;
|
|
612
|
+
try {
|
|
613
|
+
const row = getDb().query(`
|
|
614
|
+
SELECT params FROM commands
|
|
615
|
+
WHERE type = 'agent.spawn' AND correlation_id = ?
|
|
616
|
+
ORDER BY created_at DESC
|
|
617
|
+
LIMIT 1
|
|
618
|
+
`).get(spawnRequestId) as { params: string } | undefined;
|
|
619
|
+
const params = row ? parseJson<unknown>(row.params, {}) : {};
|
|
620
|
+
if (!isRecord(params)) return undefined;
|
|
621
|
+
return isReportVerbosity(params.reportVerbosity) ? params.reportVerbosity : undefined;
|
|
622
|
+
} catch {
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
597
627
|
export function markRead(messageId: number, agentId: string): boolean {
|
|
598
628
|
const exists = getDb().query("SELECT 1 FROM messages WHERE id = ?").get(messageId);
|
|
599
629
|
if (!exists) return false;
|
|
@@ -607,7 +637,7 @@ export function markRead(messageId: number, agentId: string): boolean {
|
|
|
607
637
|
delivery_next_retry_at = NULL,
|
|
608
638
|
delivery_poison_reason = NULL,
|
|
609
639
|
delivery_updated_at = ?
|
|
610
|
-
WHERE id = ? AND (to_target = ? OR resolved_to_agent = ? OR to_target = 'broadcast' OR to_target LIKE 'tag:%' OR to_target LIKE 'cap:%' OR to_target LIKE 'label:%' OR to_target LIKE 'team:%')
|
|
640
|
+
WHERE id = ? AND (to_target = ? OR resolved_to_agent = ? OR to_target = 'broadcast' OR to_target LIKE 'tag:%' OR to_target LIKE 'cap:%' OR to_target LIKE 'capability:%' OR to_target LIKE 'label:%' OR to_target LIKE 'team:%')
|
|
611
641
|
`).run(Date.now(), messageId, agentId, agentId);
|
|
612
642
|
return true;
|
|
613
643
|
}
|
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
|
-
import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
|
|
3
|
+
import { isRecord, stringValue, isMechanicalMessageKind, isReportVerbosity, type ReportVerbosity } from "agent-relay-sdk";
|
|
4
|
+
import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSignalKey } from "../lifecycle-signal-registry";
|
|
4
5
|
import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
|
|
5
6
|
import { parseJson } from "../utils";
|
|
6
7
|
import { isLiveIsolatedWorkspace } from "../workspace-phase";
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
type RuntimeContracts,
|
|
14
15
|
} from "../contracts";
|
|
15
16
|
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS, DEFAULT_FANOUT_TTL_SECONDS } from "../config";
|
|
16
|
-
import { matchAgents } from "../agent-ref";
|
|
17
|
+
import { matchAgents, messageMatchesAgent } from "../agent-ref";
|
|
17
18
|
import { isFanoutTarget } from "../channel-target";
|
|
18
19
|
import { getAgent, validateAgentSession } from "./agents.ts";
|
|
19
20
|
import { cleanAttachmentRefs, linkAttachmentRefs, validateAttachmentRefs } from "./artifacts.ts";
|
|
@@ -26,6 +27,7 @@ import { getMessage } from "./message-queries.ts";
|
|
|
26
27
|
import { childStreamSubscribed, type ChildStream } from "./notification-subscriptions.ts";
|
|
27
28
|
import { runningAgentForPolicy } from "./policy-runtime.ts";
|
|
28
29
|
import { getTask, releaseExpiredClaims } from "./tasks.ts";
|
|
30
|
+
import { deliverableTaskIdsBySpawnRequestId } from "./task-entity.ts";
|
|
29
31
|
import { insertTaskEvent, claimTaskWhere, CLAIM_TASK_KIND } from "./task-core.ts";
|
|
30
32
|
import type {
|
|
31
33
|
AgentCard,
|
|
@@ -334,6 +336,105 @@ function findRecentExplicitLineageDuplicate(input: {
|
|
|
334
336
|
return row ? rowToMessage(row) : null;
|
|
335
337
|
}
|
|
336
338
|
|
|
339
|
+
/** Per-spawn report-up verbosity (#1037), read from the child's meta; defaults to `quiet`. */
|
|
340
|
+
function reportVerbosityOf(child: AgentCard): ReportVerbosity {
|
|
341
|
+
const value = child.meta?.reportVerbosity;
|
|
342
|
+
return isReportVerbosity(value) ? value : "quiet";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The child is mid-turn (not yet idle/awaiting-input). The authoritative "still working" signal. */
|
|
346
|
+
function childIsBusy(child: AgentCard): boolean {
|
|
347
|
+
return child.status === "busy";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// #1037 — a quiet child's turn-final response that arrives while the child is still busy is a
|
|
351
|
+
// mid-task yield, not the end of its work. Instead of shipping it upstream (mid-work narration) or
|
|
352
|
+
// dropping it (a genuine last turn whose idle status simply hasn't landed yet), we HOLD the latest
|
|
353
|
+
// one per child and flush it when the child transitions to idle (see flushDeferredLineageReport,
|
|
354
|
+
// driven off the bus turn-end edge). Coalescing on childId means only the final turn's report lands.
|
|
355
|
+
const deferredLineageReports = new Map<string, number>();
|
|
356
|
+
|
|
357
|
+
function deferLineageReport(childId: string, messageId: number): void {
|
|
358
|
+
deferredLineageReports.set(childId, messageId);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function clearDeferredLineageReport(childId: string): void {
|
|
362
|
+
deferredLineageReports.delete(childId);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Test seam: clear the #1037 deferred report-up hold between tests. */
|
|
366
|
+
export function __resetDeferredLineageReports(): void {
|
|
367
|
+
deferredLineageReports.clear();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* #1037 — flush a child's deferred turn-final report to its spawner. Called when the child
|
|
372
|
+
* transitions out of busy (idle/awaiting-input): re-runs the promotion, which now passes the idle
|
|
373
|
+
* gate and delivers. No-op when nothing was deferred. Returns the promoted message, or null.
|
|
374
|
+
*/
|
|
375
|
+
export function flushDeferredLineageReport(childId: string): { message: Message; created: boolean } | null {
|
|
376
|
+
const messageId = deferredLineageReports.get(childId);
|
|
377
|
+
if (messageId === undefined) return null;
|
|
378
|
+
deferredLineageReports.delete(childId);
|
|
379
|
+
const message = getMessage(messageId);
|
|
380
|
+
if (!message) return null;
|
|
381
|
+
return promoteLineageCapturedResponse(message);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* #1040/#1041 — the persisted turn-final response captures needed to rebuild the in-memory
|
|
386
|
+
* deferred-report hold after a relay restart. Bounded by a recency window (a report is only ever
|
|
387
|
+
* held while its child was mid-turn, so it is inherently recent). Grouped to the single newest
|
|
388
|
+
* (highest-id) final capture per from_agent child *before* the row cap is applied, so that: (a) an
|
|
389
|
+
* older mid-task final that #1037 coalesced away — persisted but never given an idempotency key —
|
|
390
|
+
* is never resurrected alongside the real verdict, and (b) a chatty child's own history can't push
|
|
391
|
+
* another child's final out of the cap. The caller re-runs promotion, which is idempotent: an
|
|
392
|
+
* already-delivered capture is a no-op, an idle child's lost report is delivered, and a still-busy
|
|
393
|
+
* child simply re-defers (holding the newest one, matching the live #1037 coalescing contract).
|
|
394
|
+
*/
|
|
395
|
+
export function listRecentFinalResponseCaptures(sinceMs: number, limit = 1000): Message[] {
|
|
396
|
+
const rows = getDb().query(`
|
|
397
|
+
${MSG_SELECT}
|
|
398
|
+
INNER JOIN (
|
|
399
|
+
SELECT from_agent, MAX(id) AS latest_id
|
|
400
|
+
FROM messages
|
|
401
|
+
WHERE kind = 'session'
|
|
402
|
+
AND created_at >= ?
|
|
403
|
+
AND json_extract(payload, '$.session.type') = 'response'
|
|
404
|
+
AND json_extract(payload, '$.session.final') = 1
|
|
405
|
+
GROUP BY from_agent
|
|
406
|
+
) latest ON latest.latest_id = m.id
|
|
407
|
+
ORDER BY m.id DESC
|
|
408
|
+
LIMIT ?
|
|
409
|
+
`).all(sinceMs, limit) as any[];
|
|
410
|
+
return rows
|
|
411
|
+
.map(rowToMessage)
|
|
412
|
+
.filter((message) => {
|
|
413
|
+
const session = sessionMeta(message);
|
|
414
|
+
return session ? isFinalSessionResponse(session) : false;
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Deliverable task ids this child is executing, from its direct meta.taskId + spawnRequestId link. */
|
|
419
|
+
function childTaskIds(child: AgentCard): number[] {
|
|
420
|
+
const ids = new Set<number>();
|
|
421
|
+
const direct = child.meta?.taskId;
|
|
422
|
+
const directNum = typeof direct === "string" ? Number(direct) : direct;
|
|
423
|
+
if (typeof directNum === "number" && Number.isSafeInteger(directNum) && directNum > 0) ids.add(directNum);
|
|
424
|
+
const spawnRequestId = stringValue(child.meta?.spawnRequestId);
|
|
425
|
+
if (spawnRequestId) for (const id of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) ids.add(id);
|
|
426
|
+
return [...ids];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// #1032b — a delivered report-up is a first-class lifecycle signal to the spawner. Stamp it so a
|
|
430
|
+
// redundant "Task status pulse" for the same child/task is suppressed rather than double-waking.
|
|
431
|
+
// #1038 — a report-up conveys "the child is progressing", so it is state-keyed to `working`: it
|
|
432
|
+
// supersedes only a redundant `working` pulse, never a later distinct milestone (committed/landed/done).
|
|
433
|
+
function stampReportUpLifecycleSignal(parent: string, child: AgentCard): void {
|
|
434
|
+
stampLifecycleSignal(parent, childSignalKey(child.id));
|
|
435
|
+
for (const taskId of childTaskIds(child)) stampLifecycleSignal(parent, taskSignalKey(taskId, LIFECYCLE_STATE_WORKING));
|
|
436
|
+
}
|
|
437
|
+
|
|
337
438
|
export function promoteLineageCapturedResponse(message: Message): { message: Message; created: boolean } | null {
|
|
338
439
|
if (message.kind !== "session") return null;
|
|
339
440
|
const session = sessionMeta(message);
|
|
@@ -344,7 +445,17 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
344
445
|
const child = getAgent(message.from);
|
|
345
446
|
const parent = child?.spawnedBy;
|
|
346
447
|
if (!parent) return null;
|
|
347
|
-
|
|
448
|
+
const verbosity = reportVerbosityOf(child);
|
|
449
|
+
// #1037 verbose: forward every child stream without an explicit per-stream subscription.
|
|
450
|
+
if (stream !== "responses" && verbosity !== "verbose" && !childStreamSubscribed({ parentId: parent, child, stream })) return null;
|
|
451
|
+
|
|
452
|
+
// #1037 — the report-up delivery gate. In `quiet` (default), a turn-final response that arrives
|
|
453
|
+
// while the child is still busy is mid-task narration: defer it (flushed on the idle transition)
|
|
454
|
+
// rather than waking the spawner mid-work. `normal`/`verbose` forward every turn-final at once.
|
|
455
|
+
if (stream === "responses" && verbosity === "quiet" && childIsBusy(child)) {
|
|
456
|
+
deferLineageReport(child.id, message.id);
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
348
459
|
|
|
349
460
|
const idempotencyKey = lineageStreamReportKey(message, session, stream);
|
|
350
461
|
const existing = findMessageByIdempotencyKey(message.from, idempotencyKey);
|
|
@@ -378,7 +489,7 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
378
489
|
}
|
|
379
490
|
}
|
|
380
491
|
|
|
381
|
-
|
|
492
|
+
const result = sendMessageWithResult({
|
|
382
493
|
from: message.from,
|
|
383
494
|
to: parent,
|
|
384
495
|
kind: "chat",
|
|
@@ -390,6 +501,11 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
390
501
|
: { childStreamCapture: { stream, ...session } },
|
|
391
502
|
occurredAt: message.occurredAt ?? message.createdAt,
|
|
392
503
|
});
|
|
504
|
+
if (stream === "responses" && result.created) {
|
|
505
|
+
clearDeferredLineageReport(child.id);
|
|
506
|
+
stampReportUpLifecycleSignal(parent, child);
|
|
507
|
+
}
|
|
508
|
+
return result;
|
|
393
509
|
}
|
|
394
510
|
|
|
395
511
|
// Event time may be queued-then-backfilled, so it can legitimately be older than the
|
|
@@ -621,6 +737,14 @@ export function claimMessage(messageId: number, agentId: string, guard?: AgentSe
|
|
|
621
737
|
// A same-agent re-claim is allowed (idempotent lease refresh, see claimMessageRow); only a
|
|
622
738
|
// claim held by a *different* agent blocks (#261).
|
|
623
739
|
if (msg.claimedBy && msg.claimedBy !== agentId) return { ok: false, error: `already claimed by ${msg.claimedBy}` };
|
|
740
|
+
// The claimer must actually be a recipient — the same predicate that gates live delivery (bus/sse
|
|
741
|
+
// push) and poll. Without it an unrelated agent could claim a claimable addressed directly to
|
|
742
|
+
// someone else, and `pollMessages`/`messageMatchesAgent` then hid it from the rightful recipient,
|
|
743
|
+
// so the message vanished for its intended owner (#1022 F7). Checked AFTER the claimedBy guard so
|
|
744
|
+
// a genuine "already claimed" contention still reports that precise reason; a fan-out claimable
|
|
745
|
+
// (tag:/cap:/broadcast/…) still matches every eligible pool member, and the holder's own re-claim
|
|
746
|
+
// still matches (claimedBy is null or itself here, so the ownership short-circuit never fires).
|
|
747
|
+
if (!messageMatchesAgent(msg, agentId, agent)) return { ok: false, error: "message is not addressed to this agent" };
|
|
624
748
|
|
|
625
749
|
try {
|
|
626
750
|
return getDb().transaction(() => {
|
package/src/db/pairs.ts
CHANGED
|
@@ -205,6 +205,7 @@ export function createPair(input: CreatePairInput): {
|
|
|
205
205
|
|
|
206
206
|
const resolved = resolvePairTarget(input.target, input.from);
|
|
207
207
|
if (!resolved.ok) return resolved;
|
|
208
|
+
if (resolved.agent.id === input.from) return { ok: false, code: "invalid", error: "cannot pair with yourself" };
|
|
208
209
|
|
|
209
210
|
const now = Date.now();
|
|
210
211
|
const ttlMs = Math.min(Math.max(input.ttlMs ?? DEFAULT_PAIR_TTL_MS, 10_000), MAX_PAIR_TTL_MS);
|
package/src/db/schema.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
|
|
16
16
|
import { matchAgents } from "../agent-ref";
|
|
17
17
|
import { applyConnectionPragmas, getDb, setDb } from "./connection.ts";
|
|
18
|
+
import { __resetDeferredLineageReports } from "./messages.ts";
|
|
19
|
+
import { __resetLifecycleSignals } from "../lifecycle-signal-registry";
|
|
18
20
|
import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
|
|
19
21
|
import { applyMigrations } from "./migrations.ts";
|
|
20
22
|
import { PROMPT_TEMPLATES_SCHEMA_SQL, seedPromptTemplatesIfNeeded } from "./prompt-templates-schema";
|
|
@@ -92,6 +94,10 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
92
94
|
const conn = new Database(path, { create: true });
|
|
93
95
|
applyConnectionPragmas(conn);
|
|
94
96
|
setDb(conn, path);
|
|
97
|
+
// A fresh DB is a fresh relay: drop in-memory report-up state keyed by the OLD db's message ids /
|
|
98
|
+
// agent lineage so it can never misfire against reused ids (#1037/#1032b, and per-test isolation).
|
|
99
|
+
__resetDeferredLineageReports();
|
|
100
|
+
__resetLifecycleSignals();
|
|
95
101
|
|
|
96
102
|
getDb().run(`
|
|
97
103
|
CREATE TABLE IF NOT EXISTS agents (
|
package/src/event-loop-lag.ts
CHANGED
|
@@ -43,9 +43,18 @@ export function recordEventLoopLagSample(lagMs: number, sampledAt: number = Date
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// Max lag over the retained ring buffer. SIDE-EFFECT-FREE: unlike
|
|
47
|
+
// getEventLoopLagSnapshot it does NOT reset maxSinceLastReadMs, so a caller that
|
|
48
|
+
// only needs the recent-lag ceiling (the stale reaper's grace, #1048) can read it
|
|
49
|
+
// without stealing the spike the /health poller owns.
|
|
50
|
+
export function eventLoopLagMaxMs(): number {
|
|
47
51
|
let maxMs = 0;
|
|
48
52
|
for (let i = 0; i < sampleCount; i += 1) maxMs = Math.max(maxMs, samples[i] ?? 0);
|
|
53
|
+
return maxMs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getEventLoopLagSnapshot(): EventLoopLagSnapshot {
|
|
57
|
+
const maxMs = eventLoopLagMaxMs();
|
|
49
58
|
const maxSinceLastRead = maxSinceLastReadMs;
|
|
50
59
|
maxSinceLastReadMs = 0;
|
|
51
60
|
return {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// #1036 — resolve the validation/land gate commands for a repo WITHOUT hardcoding
|
|
2
|
+
// `bun test`. agent-relay is a bun repo, and that assumption used to leak into every
|
|
3
|
+
// foreign repo the steward operated on: running `bun test` in a Jest-only repo
|
|
4
|
+
// produced bogus failures and refused a genuinely-green branch. Derive the gate,
|
|
5
|
+
// never assume the home-repo runner.
|
|
6
|
+
//
|
|
7
|
+
// Priority order:
|
|
8
|
+
// 1. Project-config pin — the repo's own `.agent-relay/land-gates.json` (#892) wins
|
|
9
|
+
// outright. A project that declared gates knows better than any heuristic.
|
|
10
|
+
// 2. Derive from `package.json` — run the repo's DECLARED scripts via its detected
|
|
11
|
+
// package manager (from `packageManager` field or lockfile). A repo whose
|
|
12
|
+
// `scripts.test` is `jest` gets `<pm> run test`, never `bun test`.
|
|
13
|
+
// 3. Default — the legacy bun heuristic, only when there's nothing to derive from
|
|
14
|
+
// (no repo path, or no readable package.json).
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { loadRepoLandGates } from "./land-gates";
|
|
18
|
+
|
|
19
|
+
export interface StewardCheck {
|
|
20
|
+
command: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
|
|
26
|
+
|
|
27
|
+
interface PackageInfo {
|
|
28
|
+
pm: PackageManager;
|
|
29
|
+
scripts: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Detect the repo's package manager from its `packageManager` field, then its
|
|
33
|
+
* lockfile. Defaults to npm — the ecosystem default when nothing else is declared. */
|
|
34
|
+
export function detectPackageManager(repoRoot: string, packageManagerField?: unknown): PackageManager {
|
|
35
|
+
if (typeof packageManagerField === "string") {
|
|
36
|
+
const name = packageManagerField.split("@")[0]?.trim().toLowerCase();
|
|
37
|
+
if (name === "bun" || name === "pnpm" || name === "yarn" || name === "npm") return name;
|
|
38
|
+
}
|
|
39
|
+
if (existsSync(join(repoRoot, "bun.lock")) || existsSync(join(repoRoot, "bun.lockb"))) return "bun";
|
|
40
|
+
if (existsSync(join(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
|
|
41
|
+
if (existsSync(join(repoRoot, "yarn.lock"))) return "yarn";
|
|
42
|
+
if (existsSync(join(repoRoot, "package-lock.json"))) return "npm";
|
|
43
|
+
return "npm";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read `package.json` scripts + detect the package manager. Returns null when there
|
|
47
|
+
* is no readable/parseable package.json (→ caller falls back to the default). */
|
|
48
|
+
function readPackageInfo(repoRoot: string): PackageInfo | null {
|
|
49
|
+
let raw: string;
|
|
50
|
+
try {
|
|
51
|
+
raw = readFileSync(join(repoRoot, "package.json"), "utf8");
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(raw);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
62
|
+
const record = parsed as Record<string, unknown>;
|
|
63
|
+
const rawScripts = record.scripts;
|
|
64
|
+
const scripts: Record<string, string> = {};
|
|
65
|
+
if (typeof rawScripts === "object" && rawScripts !== null) {
|
|
66
|
+
for (const [k, v] of Object.entries(rawScripts as Record<string, unknown>)) {
|
|
67
|
+
if (typeof v === "string") scripts[k] = v;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { pm: detectPackageManager(repoRoot, record.packageManager), scripts };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Suggest validation gate commands from a workspace's changed files, deriving the
|
|
75
|
+
* runner from the repo (never hardcoding `bun test`) when `repoRoot` is known.
|
|
76
|
+
*
|
|
77
|
+
* @param files changed file paths (drive which checks are relevant)
|
|
78
|
+
* @param repoRoot the repo/worktree to derive gates from; omit → legacy bun default
|
|
79
|
+
*/
|
|
80
|
+
export function resolveStewardChecks(files: string[], repoRoot?: string): StewardCheck[] {
|
|
81
|
+
const has = (re: RegExp) => files.some((f) => re.test(f));
|
|
82
|
+
|
|
83
|
+
// 1. Project-config pin: repo-authored land gates override every heuristic.
|
|
84
|
+
if (repoRoot) {
|
|
85
|
+
try {
|
|
86
|
+
const gates = loadRepoLandGates(repoRoot);
|
|
87
|
+
if (gates.length)
|
|
88
|
+
return gates.map((g) => ({ command: g.command, reason: `pinned land gate "${g.name}"`, ...(g.cwd ? { cwd: g.cwd } : {}) }));
|
|
89
|
+
} catch {
|
|
90
|
+
// A malformed land-gates.json is caught by the real land-gate runner (#902);
|
|
91
|
+
// for a mere check suggestion, fall through to derivation rather than throw.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const checks: StewardCheck[] = [];
|
|
96
|
+
|
|
97
|
+
// 2. Derive from package.json: run the repo's DECLARED scripts via its package manager.
|
|
98
|
+
const pkg = repoRoot ? readPackageInfo(repoRoot) : null;
|
|
99
|
+
if (pkg) {
|
|
100
|
+
const runScript = (name: string) => `${pkg.pm} run ${name}`;
|
|
101
|
+
if (has(/\.(ts|tsx|mts|cts)$/) && pkg.scripts.typecheck) {
|
|
102
|
+
checks.push({ command: runScript("typecheck"), reason: "TypeScript files changed" });
|
|
103
|
+
}
|
|
104
|
+
if (pkg.scripts.test) {
|
|
105
|
+
if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: runScript("test"), reason: "test files changed" });
|
|
106
|
+
else if (files.length) checks.push({ command: runScript("test"), reason: "repo default" });
|
|
107
|
+
} else if (pkg.pm === "bun") {
|
|
108
|
+
// bun runs tests with no declared script; a missing scripts.test doesn't mean "no tests".
|
|
109
|
+
if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
|
|
110
|
+
else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
|
|
111
|
+
}
|
|
112
|
+
if (has(/(^|\/)dashboard\//) && pkg.scripts["build:dashboard"]) {
|
|
113
|
+
checks.push({ command: runScript("build:dashboard"), reason: "dashboard sources changed" });
|
|
114
|
+
}
|
|
115
|
+
return checks;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 3. Default (last resort): no package.json to derive from — legacy bun heuristic.
|
|
119
|
+
if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
|
|
120
|
+
if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
|
|
121
|
+
else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
|
|
122
|
+
if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
|
|
123
|
+
return checks;
|
|
124
|
+
}
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator,
|
|
|
5
5
|
import { getManagedAgentState, listSpawnPolicies, updateManagedAgentState, upsertManagedAgentState } from "./config-store";
|
|
6
6
|
import { emitRelayEvent } from "./events";
|
|
7
7
|
import { alwaysReloadTags, reconcileLifecycleContextPressure, requestAgentCompact } from "./lifecycle-context-pressure";
|
|
8
|
-
import { escalateOfflineOrchestrator } from "./managed-policy-escalation";
|
|
8
|
+
import { escalateManagedSpawnFailure, escalateOfflineOrchestrator } from "./managed-policy-escalation";
|
|
9
9
|
import { markManagedAgentRunning, emitManagedState } from "./services/managed-running";
|
|
10
10
|
import { authContextFromSystem } from "./services/auth-context";
|
|
11
11
|
import { emitCommandEvent } from "./command-events";
|
|
@@ -31,6 +31,13 @@ interface LifecycleManagerOptions {
|
|
|
31
31
|
now?: () => number;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// #1021 A1 — the outcome of trying to wake an on-demand policy via a queued message. A
|
|
35
|
+
// caller (e.g. wakeRepoSteward) must be able to tell "a live agent will handle this" from
|
|
36
|
+
// "nothing spawned" so it never claims a hand-off that didn't happen.
|
|
37
|
+
export type PolicySpawnOutcome =
|
|
38
|
+
| { woke: true; via: "spawned" | "already-active"; command?: Command }
|
|
39
|
+
| { woke: false; reason: string };
|
|
40
|
+
|
|
34
41
|
export class LifecycleManager {
|
|
35
42
|
private timer?: Timer;
|
|
36
43
|
private readonly tickMs: number;
|
|
@@ -69,12 +76,19 @@ export class LifecycleManager {
|
|
|
69
76
|
}
|
|
70
77
|
}
|
|
71
78
|
|
|
72
|
-
onMessageForPolicy(policyName: string):
|
|
79
|
+
onMessageForPolicy(policyName: string): PolicySpawnOutcome {
|
|
73
80
|
const policy = this.loadPolicies().find((item) => item.name === policyName);
|
|
74
|
-
if (!policy
|
|
81
|
+
if (!policy) return { woke: false, reason: `no spawn policy '${policyName}'` };
|
|
82
|
+
if (policy.mode !== "on-demand") return { woke: false, reason: `spawn policy '${policyName}' is not on-demand` };
|
|
83
|
+
if (policy.enabled === false) return { woke: false, reason: `spawn policy '${policyName}' is disabled` };
|
|
75
84
|
const state = getManagedAgentState(policy.name);
|
|
76
|
-
|
|
77
|
-
|
|
85
|
+
// A live (or coming-up / draining) agent will pick up the queued message — that IS a wake.
|
|
86
|
+
if (state && ["starting", "running", "stopping"].includes(state.status)) return { woke: true, via: "already-active" };
|
|
87
|
+
// #1021 A1 — spawnAgent returns null when the pinned host is offline; that is a spawn
|
|
88
|
+
// FAILURE, not a success. Propagate it so callers don't stamp a false hand-off.
|
|
89
|
+
const command = this.spawnAgent(policy, "message-trigger");
|
|
90
|
+
if (!command) return { woke: false, reason: `steward spawn failed for '${policyName}' (owning host offline)` };
|
|
91
|
+
return { woke: true, via: "spawned", command };
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
// NOTE: the bus registration path used to call `onAgentRegistered` here for the
|
|
@@ -136,7 +150,7 @@ export class LifecycleManager {
|
|
|
136
150
|
(state.tmuxSession && agent.tmuxSession === state.tmuxSession) ||
|
|
137
151
|
(agent.policyName === policy.name && (!state.spawnRequestId || agent.spawnRequestId === state.spawnRequestId))
|
|
138
152
|
));
|
|
139
|
-
this.markPolicyFailure(policy, state, exited?.lastError ?? "orchestrator session disappeared", exited);
|
|
153
|
+
this.markPolicyFailure(policy, state, exited?.lastError ?? "orchestrator session disappeared", { exited });
|
|
140
154
|
} else if (state.status === "stopping" && !reported) {
|
|
141
155
|
const next = updateManagedAgentState(policy.name, {
|
|
142
156
|
status: "stopped",
|
|
@@ -322,7 +336,10 @@ export class LifecycleManager {
|
|
|
322
336
|
|
|
323
337
|
if (state.status === "starting") {
|
|
324
338
|
if (state.lastSpawnAt && this.now() - state.lastSpawnAt > START_TIMEOUT_MS) {
|
|
325
|
-
|
|
339
|
+
// #1021 A3 — a never-registered spawn has no exit diagnostics, so isFastFailStorm
|
|
340
|
+
// can never trip and it would back off/retry forever in silence. Flag it so the
|
|
341
|
+
// escalation fires once it has failed repeatedly.
|
|
342
|
+
this.markPolicyFailure(policy, state, "spawn timed out before registration", { registerTimeout: true });
|
|
326
343
|
}
|
|
327
344
|
return;
|
|
328
345
|
}
|
|
@@ -410,22 +427,33 @@ export class LifecycleManager {
|
|
|
410
427
|
this.emitState(state);
|
|
411
428
|
}
|
|
412
429
|
|
|
413
|
-
private markPolicyFailure(
|
|
430
|
+
private markPolicyFailure(
|
|
431
|
+
policy: SpawnPolicy,
|
|
432
|
+
state: ManagedAgentState,
|
|
433
|
+
error: string,
|
|
434
|
+
opts: { exited?: ManagedSessionExitDiagnostics; registerTimeout?: boolean } = {},
|
|
435
|
+
): void {
|
|
414
436
|
if (state.status === "stopping") return;
|
|
415
437
|
// Read the terminal reason from the adapter-surfaced typed provider state rather
|
|
416
438
|
// than re-parsing raw provider log text (#745 / #725 §4.4 leak #13).
|
|
417
|
-
const agentId = state.agentId || exited?.agentId;
|
|
439
|
+
const agentId = state.agentId || opts.exited?.agentId;
|
|
418
440
|
const modelUnavailable = agentId ? agentTerminalFailureMessage(getAgent(agentId)) : null;
|
|
419
441
|
if (modelUnavailable) {
|
|
420
442
|
this.markFailed(policy, state, modelUnavailable);
|
|
421
443
|
return;
|
|
422
444
|
}
|
|
423
445
|
const failures = state.consecutiveFailures + 1;
|
|
424
|
-
if (isFastFailStorm(failures, exited)) {
|
|
446
|
+
if (isFastFailStorm(failures, opts.exited)) {
|
|
425
447
|
this.markFailed(policy, state, error);
|
|
426
448
|
return;
|
|
427
449
|
}
|
|
428
450
|
this.markBackoff(policy, state, error, failures);
|
|
451
|
+
// #1021 A3 — a register-never spawn never reaches markFailed (no exit diagnostics), so
|
|
452
|
+
// without this its escalation would never fire. Escalate once it has failed repeatedly.
|
|
453
|
+
if (opts.registerTimeout && failures >= MAX_FAST_FAILURES) {
|
|
454
|
+
const next = getManagedAgentState(policy.name);
|
|
455
|
+
if (next) escalateManagedSpawnFailure(policy, next, { phase: "register-timeout", error });
|
|
456
|
+
}
|
|
429
457
|
}
|
|
430
458
|
|
|
431
459
|
private markBackoff(policy: SpawnPolicy, state: ManagedAgentState, error: string, failures = state.consecutiveFailures + 1): void {
|
|
@@ -465,6 +493,9 @@ export class LifecycleManager {
|
|
|
465
493
|
lastError: error,
|
|
466
494
|
});
|
|
467
495
|
this.emitState(next);
|
|
496
|
+
// #1021 A3 — a failed policy stops retrying (reconcilePolicy returns on `failed`
|
|
497
|
+
// forever). Tell the coordinator/owner instead of leaving only a dashboard state.
|
|
498
|
+
escalateManagedSpawnFailure(policy, next, { phase: "failed", error });
|
|
468
499
|
}
|
|
469
500
|
|
|
470
501
|
private backoffDelay(policy: SpawnPolicy, state: ManagedAgentState | null): number {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// #1032b — cross-subsystem "a first-class lifecycle event already told this spawner about this
|
|
2
|
+
// subject" registry. A spawned child's state reaches its spawner through TWO independent lanes:
|
|
3
|
+
// 1. First-class lifecycle notifications — agent.ready, the lineage report-up (turn-final
|
|
4
|
+
// response), agent.exited, branch.landed/merge.
|
|
5
|
+
// 2. "Task status pulse" digests emitted from task transitions (working / committed-on-branch /
|
|
6
|
+
// landed-on-main / done / …).
|
|
7
|
+
// When lane 1 has just conveyed the state, the lane-2 pulse is redundant noise that wakes the
|
|
8
|
+
// spawner a second time for the same fact. This registry is the shared memory: lane-1 emitters
|
|
9
|
+
// STAMP `(recipient, subjectKey)`; the pulse enqueue/flush path CHECKS it and suppresses.
|
|
10
|
+
//
|
|
11
|
+
// In-memory + short-TTL by design: it only needs to catch the near-simultaneous double-notify
|
|
12
|
+
// (both lanes fire within seconds of the same underlying event). A relay restart wiping it merely
|
|
13
|
+
// means a pulse that would have been suppressed is delivered once — never a lost signal.
|
|
14
|
+
|
|
15
|
+
const DEFAULT_LIFECYCLE_SIGNAL_TTL_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
interface SignalEntry {
|
|
18
|
+
at: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const signals = new Map<string, SignalEntry>();
|
|
22
|
+
|
|
23
|
+
// Delimiter between recipient and subject key. A pipe can't occur in an agent id or a subject key
|
|
24
|
+
// (subject keys are `task:<n>` / `child:<id>` and ids are dot/dash/colon-only), so the composite is
|
|
25
|
+
// unambiguous. Kept a plain visible character so the source stays grep-friendly.
|
|
26
|
+
const KEY_DELIMITER = " || ";
|
|
27
|
+
|
|
28
|
+
function compositeKey(recipient: string, subjectKey: string): string {
|
|
29
|
+
return `${recipient}${KEY_DELIMITER}${subjectKey}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// #1038 — the semantic state a child-progress lifecycle signal (a report-up / agent.ready) conveys:
|
|
33
|
+
// "the child is actively working this task". Suppression is state-keyed on this, so a progress
|
|
34
|
+
// signal supersedes only a redundant `working` pulse — a later DISTINCT milestone pulse (committed /
|
|
35
|
+
// landed / done) for the same task still reaches the spawner within the TTL instead of being dropped.
|
|
36
|
+
export const LIFECYCLE_STATE_WORKING = "working";
|
|
37
|
+
|
|
38
|
+
/** Subject key for a task-scoped lifecycle signal, keyed by semantic state so a signal about one
|
|
39
|
+
* state cannot suppress a pulse for a distinct state (#1038). The stamp and the check MUST pass the
|
|
40
|
+
* same `state` (the pulse's transition) to line up; omitting it keeps the legacy subject-only key. */
|
|
41
|
+
export function taskSignalKey(taskId: number | string, state?: string): string {
|
|
42
|
+
return state ? `task:${taskId}::${state}` : `task:${taskId}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Subject key for a child-agent-scoped lifecycle signal (report-up / ready / exit about a child). */
|
|
46
|
+
export function childSignalKey(childId: string): string {
|
|
47
|
+
return `child:${childId}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Record that a first-class lifecycle event just conveyed `subjectKey`'s state to `recipient`. */
|
|
51
|
+
export function stampLifecycleSignal(recipient: string, subjectKey: string, now: number = Date.now()): void {
|
|
52
|
+
const rec = recipient.trim();
|
|
53
|
+
if (!rec || !subjectKey) return;
|
|
54
|
+
signals.set(compositeKey(rec, subjectKey), { at: now });
|
|
55
|
+
pruneExpired(now);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True when a lifecycle event stamped `subjectKey` for `recipient` within the TTL window. */
|
|
59
|
+
export function hasRecentLifecycleSignal(
|
|
60
|
+
recipient: string,
|
|
61
|
+
subjectKey: string,
|
|
62
|
+
now: number = Date.now(),
|
|
63
|
+
ttlMs: number = DEFAULT_LIFECYCLE_SIGNAL_TTL_MS,
|
|
64
|
+
): boolean {
|
|
65
|
+
const rec = recipient.trim();
|
|
66
|
+
if (!rec || !subjectKey) return false;
|
|
67
|
+
const key = compositeKey(rec, subjectKey);
|
|
68
|
+
const entry = signals.get(key);
|
|
69
|
+
if (!entry) return false;
|
|
70
|
+
if (now - entry.at > ttlMs) {
|
|
71
|
+
signals.delete(key);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pruneExpired(now: number, ttlMs: number = DEFAULT_LIFECYCLE_SIGNAL_TTL_MS): void {
|
|
78
|
+
if (signals.size < 512) return; // amortized cleanup only when the map grows
|
|
79
|
+
for (const [key, entry] of signals) {
|
|
80
|
+
if (now - entry.at > ttlMs) signals.delete(key);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Reset seam: clear all recorded lifecycle signals (per-test / on a fresh relay DB). */
|
|
85
|
+
export function __resetLifecycleSignals(): void {
|
|
86
|
+
signals.clear();
|
|
87
|
+
}
|
|
@@ -30,7 +30,11 @@ export { DAY_MS, REAP_INTERVAL_MS, TERMINAL_WORKSPACE_STATUSES };
|
|
|
30
30
|
|
|
31
31
|
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
32
32
|
export const SCHEDULER_TICK_MS = 10_000;
|
|
33
|
-
|
|
33
|
+
// #1025 A6 — `merge_planned` is included so a merge whose command/result was lost (target
|
|
34
|
+
// host gone, POST dropped) can't wedge forever off every strand path: the in-flight
|
|
35
|
+
// watchdog + pending-command expiry are the fast recovery, and workspace-gc escalation is
|
|
36
|
+
// the last-line backstop. Without it a stuck `merge_planned` was escalation-blind.
|
|
37
|
+
export const STRANDABLE_STATUSES = new Set<WorkspaceStatus>([...READY_TO_LAND_STATUSES, "conflict", "merge_planned"]);
|
|
34
38
|
export const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
|
|
35
39
|
export const LANDED_RECONCILE_STATUSES = new Set<WorkspaceStatus>(["merge_planned", "review_requested", "conflict"]);
|
|
36
40
|
|