agent-relay-server 0.121.5 → 0.122.1
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 +6 -6
- package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
- package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
- package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
- package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
- package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
- package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
- package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
- package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
- package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
- package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
- package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
- package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
- package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
- package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
- package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
- package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
- package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
- package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
- package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
- package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
- package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
- package/public/assets/display-CJzSoTMq.js +3 -0
- package/public/assets/display-CJzSoTMq.js.map +1 -0
- package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
- package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
- package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
- package/public/assets/index-D0xMQQb_.js +25 -0
- package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
- package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
- package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
- package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
- package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
- package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
- package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
- package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
- package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
- package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
- package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
- package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
- package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
- package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
- package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
- package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
- package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
- package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
- package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
- package/public/assets/projects-Dzdf-gkK.js +2 -0
- package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
- package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
- package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
- package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
- package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
- package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
- package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
- package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
- package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
- package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
- package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
- package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
- package/public/assets/store-BPrVuKiv.js.map +1 -0
- package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
- package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
- package/public/assets/teams-DWv6UwFV.js +2 -0
- package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
- package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
- package/public/assets/types-BeJUSfDj.js.map +1 -0
- package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
- package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
- package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
- package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
- package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
- package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
- package/runner/src/adapter.ts +53 -18
- package/runner/src/adapters/claude-delivery.ts +127 -0
- package/runner/src/adapters/claude-quota-harvest.ts +92 -0
- package/runner/src/adapters/claude-session-probe.ts +225 -0
- package/runner/src/adapters/claude-status-detectors.ts +147 -0
- package/runner/src/adapters/claude-transcript-tail.ts +128 -0
- package/runner/src/adapters/claude-transcript.ts +414 -0
- package/runner/src/adapters/claude.ts +672 -0
- package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
- package/runner/src/adapters/codex-client.ts +279 -0
- package/runner/src/adapters/codex.ts +1345 -0
- package/runner/src/attachment-cache.ts +189 -0
- package/runner/src/busy-reconciler.ts +102 -0
- package/runner/src/claim-tracker.ts +140 -0
- package/runner/src/claude-prompt-gates.ts +268 -0
- package/runner/src/config.ts +1 -4
- package/runner/src/context-probe-state.ts +6 -0
- package/runner/src/continuation-archive.ts +179 -0
- package/runner/src/control-server.ts +639 -0
- package/runner/src/index.ts +396 -0
- package/runner/src/launch-assembly.ts +508 -0
- package/runner/src/liveness.ts +50 -0
- package/runner/src/logger.ts +99 -0
- package/runner/src/mcp-outbox.ts +120 -0
- package/runner/src/message-body-config/config.ts +3 -0
- package/runner/src/message-body-config.ts +1 -0
- package/runner/src/native-self-resume.ts +202 -0
- package/runner/src/outbox.ts +342 -0
- package/runner/src/process-meta.ts +9 -0
- package/runner/src/profile-home.ts +260 -0
- package/runner/src/profile-projection.ts +305 -0
- package/runner/src/providers.ts +20 -0
- package/runner/src/provisioning.ts +314 -0
- package/runner/src/quota.ts +40 -0
- package/runner/src/rate-limit.ts +189 -0
- package/runner/src/relay-injection-events.ts +102 -0
- package/runner/src/relay-instructions.ts +60 -0
- package/runner/src/relay-mcp-proxy.ts +383 -0
- package/runner/src/relay-mcp.ts +156 -0
- package/runner/src/reply-obligation-cache.ts +136 -0
- package/runner/src/response-capture-report.ts +63 -0
- package/runner/src/runner-core.ts +2409 -0
- package/runner/src/runner-helpers.ts +435 -0
- package/runner/src/runner-insights.ts +105 -0
- package/runner/src/runner.ts +14 -0
- package/runner/src/session-destroy.ts +22 -0
- package/runner/src/session-insights.ts +118 -0
- package/runner/src/session-scratch.ts +271 -0
- package/runner/src/template-resolver.ts +29 -0
- package/runner/src/version.ts +43 -0
- package/src/agent-card-projection.ts +5 -1
- package/src/agent-command-activity.ts +39 -0
- package/src/automations/reconcile.ts +2 -2
- package/src/cli/workspace.ts +3 -1
- package/src/config.ts +10 -1
- package/src/db/agents.ts +118 -37
- package/src/db/message-reads.ts +50 -0
- package/src/db/messages.ts +48 -9
- package/src/db/migrations.ts +54 -0
- package/src/db/provider-quota-burn.ts +96 -67
- package/src/db/provider-quotas.ts +29 -7
- package/src/db/scheduled-timers.ts +21 -0
- package/src/db/schema.ts +1 -0
- package/src/db/tasks.ts +33 -3
- package/src/db/work-evidence.ts +10 -7
- package/src/event-loop-lag.ts +15 -0
- package/src/gate-resolver.ts +19 -1
- package/src/maintenance/jobs.ts +23 -6
- package/src/managed-policy.ts +5 -12
- package/src/mcp-plan-tools.ts +13 -1
- package/src/project-instructions.ts +36 -2
- package/src/quota-advisory.ts +6 -4
- package/src/quota-band-transition.ts +23 -14
- package/src/routes/agent-sessions.ts +39 -7
- package/src/routes/agents.ts +3 -2
- package/src/routes/tasks.ts +16 -4
- package/src/routes/workspaces.ts +35 -10
- package/src/services/plan-graph.ts +27 -9
- package/src/services/register-agent.ts +12 -12
- package/src/services/send-message.ts +43 -15
- package/src/services/spawn-agent.ts +3 -0
- package/src/spawn-command.ts +8 -0
- package/src/spawn-targets.ts +19 -34
- package/src/sse.ts +25 -3
- package/src/workspace-merge.ts +25 -5
- package/src/workspace-orphans.ts +13 -6
- package/public/assets/display-exQDWum3.js +0 -3
- package/public/assets/display-exQDWum3.js.map +0 -1
- package/public/assets/index-D9OogaB5.js +0 -25
- package/public/assets/projects-DdPNPFJI.js +0 -2
- package/public/assets/prompt-settings-BpZse7-X.js +0 -2
- package/public/assets/store-DKTLubBT.js.map +0 -1
- package/public/assets/teams-B8f2pGJe.js +0 -2
- package/public/assets/types-uVOXgvMT.js.map +0 -1
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// content — a policy switch here, zero schema/composer change. `project_entries.source`
|
|
11
11
|
// is that lever: the included-source set is a policy PARAMETER, and the `vanilla`
|
|
12
12
|
// policy includes `manual`+`promoted` while excluding `ingested`.
|
|
13
|
-
import {
|
|
13
|
+
import { DEFAULT_USER_ID } from "agent-relay-sdk";
|
|
14
|
+
import { resolveProjectEntries, resolveProjectForCwd, resolveScopedAnchor } from "./db";
|
|
14
15
|
import { renderTemplate } from "./prompt-resolver";
|
|
15
16
|
import { PROJECT_ENTRY_SOURCES, type AgentProfile, type Project, type ProjectEntryKind, type ProjectEntrySource, type RelayInjectionCategory, type ResolvedProjectEntry } from "./types";
|
|
16
17
|
|
|
@@ -57,7 +58,7 @@ interface SpawnInstructionInjectionEvent {
|
|
|
57
58
|
detail: Record<string, unknown>;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
interface ComposedSpawnInstructions {
|
|
61
|
+
export interface ComposedSpawnInstructions {
|
|
61
62
|
systemPromptAppend?: string;
|
|
62
63
|
relayInjectionEvents: SpawnInstructionInjectionEvent[];
|
|
63
64
|
}
|
|
@@ -295,6 +296,39 @@ export function composeSpawnInstructions(input: Parameters<typeof composeSpawnIn
|
|
|
295
296
|
return composeSpawnInstructionsWithEvents(input).systemPromptAppend;
|
|
296
297
|
}
|
|
297
298
|
|
|
299
|
+
/**
|
|
300
|
+
* #1052 — the SINGLE home that resolves a profile's spawn-time knowledge world (project
|
|
301
|
+
* preamble for the cwd + scoped {profile|user} memory anchors) and runs
|
|
302
|
+
* `composeSpawnInstructionsWithEvents`. Every path that (re)builds a profile-bearing spawn —
|
|
303
|
+
* fresh managed spawn, managed-policy restart, AND the dashboard "restart Runner/Agent" relaunch
|
|
304
|
+
* for a NON-policy agent — must call this so a restarted agent is onboarded byte-identically to
|
|
305
|
+
* its first launch (role charter + project knowledge + scoped memory). Before #1052-v2 the
|
|
306
|
+
* dashboard-restart path for a dashboard-spawned (non-policy) coordinator called
|
|
307
|
+
* `buildSpawnCommand` WITHOUT this projection, so the relaunched process came back with EMPTY
|
|
308
|
+
* injection logs and silently reverted to provider-default IC behavior.
|
|
309
|
+
*/
|
|
310
|
+
export function composeProfileSpawnInstructions(input: {
|
|
311
|
+
cwd?: string;
|
|
312
|
+
profileName?: string;
|
|
313
|
+
profile?: Pick<AgentProfile, "instructions" | "maxSpawnedAgents" | "projectInstructions">;
|
|
314
|
+
callerSystemPromptAppend?: string;
|
|
315
|
+
/** Operator/user anchor key; defaults to the single-operator default store. */
|
|
316
|
+
operatorId?: string;
|
|
317
|
+
}): ComposedSpawnInstructions {
|
|
318
|
+
const callerProject = input.cwd ? resolveProjectForCwd(input.cwd) ?? undefined : undefined;
|
|
319
|
+
const projectInstructions = input.profile?.projectInstructions;
|
|
320
|
+
const profileAnchor = input.profileName ? resolveScopedAnchor("profile", input.profileName) ?? undefined : undefined;
|
|
321
|
+
const userAnchor = resolveScopedAnchor("user", input.operatorId ?? DEFAULT_USER_ID) ?? undefined;
|
|
322
|
+
return composeSpawnInstructionsWithEvents({
|
|
323
|
+
...(callerProject ? { callerProject } : {}),
|
|
324
|
+
...(projectInstructions ? { projectInstructions } : {}),
|
|
325
|
+
...(profileAnchor ? { profileAnchor } : {}),
|
|
326
|
+
...(userAnchor ? { userAnchor } : {}),
|
|
327
|
+
...(input.profile ? { profile: input.profile } : {}),
|
|
328
|
+
...(input.callerSystemPromptAppend ? { callerSystemPromptAppend: input.callerSystemPromptAppend } : {}),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
298
332
|
/**
|
|
299
333
|
* Parse a spawn-transport's `projectInstructions` wire value into the normalized policy
|
|
300
334
|
* (or undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift.
|
package/src/quota-advisory.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { emitRelayEvent } from "./events";
|
|
|
5
5
|
import { routeNotification } from "./notification-router";
|
|
6
6
|
import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./quota-threshold-config";
|
|
7
7
|
import { parseJson } from "./utils";
|
|
8
|
-
import { setProviderQuotaAdvisoryHandler, type ProviderQuotaAdvisoryInput } from "./db/provider-quotas.ts";
|
|
8
|
+
import { setProviderQuotaAdvisoryHandler, type ProviderQuotaAdvisoryInput, type ProviderQuotaAdvisoryResult } from "./db/provider-quotas.ts";
|
|
9
9
|
import { processBandTransitions } from "./quota-band-transition";
|
|
10
10
|
import type { QuotaState } from "./types";
|
|
11
11
|
|
|
@@ -40,7 +40,7 @@ export function installQuotaAdvisoryHandler(): void {
|
|
|
40
40
|
setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput):
|
|
43
|
+
function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): ProviderQuotaAdvisoryResult {
|
|
44
44
|
const config = effectiveQuotaNotificationConfig().quotaThreshold;
|
|
45
45
|
const state = parseJson<QuotaAdvisoryState>(input.previousAdvisoryState ?? "{}", {});
|
|
46
46
|
const nextState: QuotaAdvisoryState = { ...state };
|
|
@@ -101,8 +101,10 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
// #1074 — thread the persisted band-hysteresis state through the transition pipeline and return
|
|
105
|
+
// the new state so it is written back to quota_band_state, surviving relay restarts.
|
|
106
|
+
const bandState = processBandTransitions(input, input.previousBandState);
|
|
107
|
+
return { advisoryState: JSON.stringify(nextState), bandState };
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
function hasLastQuotaAdvisory(previous: QuotaAdvisoryWindowState | undefined): boolean {
|
|
@@ -38,6 +38,7 @@ import { getSpawnCapableAgentIds } from "./routing-policy-push";
|
|
|
38
38
|
import { getProviderBandInfo, quotaBandFromSignal, windowRecommendedAction } from "./spawn-targets";
|
|
39
39
|
import type { ProviderBandInfo, ProviderBandWindow, QuotaBand } from "./spawn-targets";
|
|
40
40
|
import type { ProviderQuotaAdvisoryInput } from "./db/provider-quotas";
|
|
41
|
+
import { parseJson } from "./utils";
|
|
41
42
|
|
|
42
43
|
const DEBOUNCE_SAMPLES = 3;
|
|
43
44
|
// #798 — EMA time constant for the low-pass. "A few minutes": longer than the ~5-min poll-cadence
|
|
@@ -68,6 +69,13 @@ interface TransitionState {
|
|
|
68
69
|
emaUpdatedAt: number;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
// #1074 — the per-`provider:role` hysteresis state (EMA low-pass, lastNotifiedBand, debounce
|
|
73
|
+
// counters, lastTransitionAt). Serialized to the provider_quotas.quota_band_state DB column so it
|
|
74
|
+
// survives a relay restart: without it every restart re-seeds lastNotifiedBand="normal" and re-fires
|
|
75
|
+
// an already-sent degradation to the whole fleet. Mirrors the previousAdvisoryState round-trip in
|
|
76
|
+
// quota-advisory.ts — state is passed IN and returned OUT, this module holds no cross-call memory.
|
|
77
|
+
type BandTransitionState = Record<string, TransitionState>;
|
|
78
|
+
|
|
71
79
|
// #798 — dt-based EMA blend factor: 1 − e^(−dt/τ). dt = 0 (or first sample) → seed verbatim.
|
|
72
80
|
function emaAlpha(dtMs: number): number {
|
|
73
81
|
if (!(dtMs > 0)) return 1;
|
|
@@ -79,12 +87,6 @@ function emaStep(prev: number | null, value: number, dtMs: number): number {
|
|
|
79
87
|
return prev + emaAlpha(dtMs) * (value - prev);
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
const bandState = new Map<string, TransitionState>();
|
|
83
|
-
|
|
84
|
-
export function _resetBandTransitionStateForTesting(): void {
|
|
85
|
-
bandState.clear();
|
|
86
|
-
}
|
|
87
|
-
|
|
88
90
|
// Test hook: supply a controlled band-info function to exercise hysteresis logic
|
|
89
91
|
// without a full DB setup. Set to null to restore the real implementation.
|
|
90
92
|
let _bandInfoSupplier: ((provider: string, now: number) => ProviderBandInfo | undefined) | null = null;
|
|
@@ -95,15 +97,24 @@ export function _clearBandInfoSupplierForTesting(): void {
|
|
|
95
97
|
_bandInfoSupplier = null;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
|
|
100
|
+
// #1074 — pure state-threading transition step. Takes the previously persisted band state
|
|
101
|
+
// (serialized `quota_band_state`, may be null/empty on a fresh provider), applies the band-transition
|
|
102
|
+
// hysteresis pipeline for this sample, and returns the new serialized state to persist. Holds no
|
|
103
|
+
// module-level memory, so a relay restart that re-hydrates from the DB continues hysteresis seamlessly
|
|
104
|
+
// instead of re-seeding to "normal" and re-firing an already-sent degradation.
|
|
105
|
+
export function processBandTransitions(
|
|
106
|
+
input: ProviderQuotaAdvisoryInput,
|
|
107
|
+
previousBandState?: string | null,
|
|
108
|
+
): string {
|
|
109
|
+
const bandState = parseJson<BandTransitionState>(previousBandState ?? "{}", {});
|
|
99
110
|
const info = (_bandInfoSupplier ?? getProviderBandInfo)(input.provider, input.now);
|
|
100
|
-
if (!info) return;
|
|
111
|
+
if (!info) return JSON.stringify(bandState);
|
|
101
112
|
|
|
102
113
|
for (const window of info.windows) {
|
|
103
114
|
if (window.confidence === "low" || window.burnRatio === null) continue;
|
|
104
115
|
|
|
105
116
|
const key = `${input.provider}:${window.role}`;
|
|
106
|
-
const state: TransitionState = bandState
|
|
117
|
+
const state: TransitionState = bandState[key] ?? {
|
|
107
118
|
lastNotifiedBand: "normal",
|
|
108
119
|
pendingBand: null,
|
|
109
120
|
pendingCount: 0,
|
|
@@ -124,7 +135,7 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
|
|
|
124
135
|
state.emaUpdatedAt = input.now;
|
|
125
136
|
// Persist immediately so the low-pass state survives every early-continue below (the state is
|
|
126
137
|
// then mutated in place by reference — the EMA must carry across ticks to smooth excursions).
|
|
127
|
-
bandState
|
|
138
|
+
bandState[key] = state;
|
|
128
139
|
const smoothedBand = quotaBandFromSignal(emaPenalty, emaSlack, info.quotaBands, info.preferSlackAt);
|
|
129
140
|
|
|
130
141
|
// #756 (layer 2): asymmetric hysteresis — recovery needs penalty HYSTERESIS_MARGIN below the
|
|
@@ -149,7 +160,6 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
|
|
|
149
160
|
// #756: rate-limit recovery — it is not an actionable signal for coordinators.
|
|
150
161
|
const isRecovery = bandRank(effectiveBand) < bandRank(state.lastNotifiedBand);
|
|
151
162
|
if (isRecovery && input.now - state.lastTransitionAt < RECOVERY_MIN_INTERVAL_MS) {
|
|
152
|
-
bandState.set(key, state);
|
|
153
163
|
continue;
|
|
154
164
|
}
|
|
155
165
|
|
|
@@ -158,7 +168,6 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
|
|
|
158
168
|
state.pendingBand = null;
|
|
159
169
|
state.pendingCount = 0;
|
|
160
170
|
state.lastTransitionAt = input.now;
|
|
161
|
-
bandState.set(key, state);
|
|
162
171
|
emitBandTransition({
|
|
163
172
|
provider: input.provider,
|
|
164
173
|
window,
|
|
@@ -169,10 +178,10 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
|
|
|
169
178
|
recommendedAction: windowRecommendedAction(effectiveBand, window.slack),
|
|
170
179
|
now: input.now,
|
|
171
180
|
});
|
|
172
|
-
} else {
|
|
173
|
-
bandState.set(key, state);
|
|
174
181
|
}
|
|
175
182
|
}
|
|
183
|
+
|
|
184
|
+
return JSON.stringify(bandState);
|
|
176
185
|
}
|
|
177
186
|
|
|
178
187
|
// Asymmetric hysteresis: degradation uses the normal (entry) threshold; recovery requires
|
|
@@ -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, sendMessage, withResolvedProvisioning } from "../db";
|
|
5
|
+
import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, readSpawnCallerSystemPromptAppend, resolveAgentOwnership, 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,6 +19,7 @@ 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";
|
|
22
23
|
import { relayToken } from "../config";
|
|
23
24
|
|
|
24
25
|
// 409 when a second human tries to drive an agent another user is already driving (#394). The
|
|
@@ -95,7 +96,7 @@ function managedControlOrchestrator(agent: AgentCard): NonNullable<ReturnType<ty
|
|
|
95
96
|
return candidates.find((orch) => orch.status === "online") ?? null;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
function restartSpawnParamsForAgent(
|
|
99
|
+
export function restartSpawnParamsForAgent(
|
|
99
100
|
agent: AgentCard,
|
|
100
101
|
orchestrator: NonNullable<ReturnType<typeof getOrchestrator>> | null,
|
|
101
102
|
policyName?: string,
|
|
@@ -103,7 +104,15 @@ function restartSpawnParamsForAgent(
|
|
|
103
104
|
opts: { resumeId?: string } = {},
|
|
104
105
|
): Record<string, unknown> | undefined {
|
|
105
106
|
if (!orchestrator) return undefined;
|
|
106
|
-
|
|
107
|
+
// #1065 — mint a FRESH spawnRequestId for the successor, NEVER reuse the predecessor's. Reusing
|
|
108
|
+
// it voided the #1059 instance guard (exitCertificateMatchesInstance keys death-certificate
|
|
109
|
+
// identity on spawnRequestId): a late/duplicate exit report for the dead predecessor carried the
|
|
110
|
+
// same id as the live successor, so the guard treated the stale certificate as a match and
|
|
111
|
+
// false-reaped the running session (#1054/#1059 reopener). Every other respawn path mints fresh
|
|
112
|
+
// (lifecycle-manager, spawn-policy) and the runner documents the invariant (control.ts:284). The
|
|
113
|
+
// predecessor id (`spawnRequestId`) is still used below to recover the #1062 parent brief from the
|
|
114
|
+
// ORIGINAL agent.spawn command — read before the mint so the brief follows the agent, not the id.
|
|
115
|
+
const requestId = generateSpawnRequestId();
|
|
107
116
|
const policy = policyName ? getSpawnPolicy(policyName) : null;
|
|
108
117
|
const requestedBy = opts.resumeId ? "dashboard-resume" : "dashboard-restart";
|
|
109
118
|
if (policy) {
|
|
@@ -124,6 +133,27 @@ function restartSpawnParamsForAgent(
|
|
|
124
133
|
const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
|
|
125
134
|
const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
|
|
126
135
|
const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
|
|
136
|
+
// #1052-v2 — a dashboard-spawned agent has NO spawn policy, so its restart skips the
|
|
137
|
+
// `buildManagedSpawnParams` policy branch above and lands here. Re-project the profile role
|
|
138
|
+
// charter / project knowledge / scoped memory (the SAME projection a fresh spawn gets) so the
|
|
139
|
+
// relaunch is onboarded byte-identically instead of reverting to provider-default IC behavior.
|
|
140
|
+
// #1062 — two remaining fresh-spawn divergences closed here: (1) anchor the user/operator scoped
|
|
141
|
+
// memory to the agent's OWNER via resolveAgentOwnership (a fresh spawn does the same), not the
|
|
142
|
+
// default operator; and (2) re-thread the parent's ORIGINAL caller brief, recovered from the
|
|
143
|
+
// persisted agent.spawn command, so a restarted spawned child keeps its task brief.
|
|
144
|
+
// #1065 — key this on the PREDECESSOR id (`spawnRequestId`), NOT the freshly-minted `requestId`:
|
|
145
|
+
// the caller brief was persisted on the original agent.spawn command, which the successor's fresh
|
|
146
|
+
// id has no row for. Recovering it here re-injects the brief onto the new instance.
|
|
147
|
+
// #1067 — recovery must survive N restarts, not just one. Because #1065 rotates the id every
|
|
148
|
+
// restart and never writes an agent.spawn row for the successor, a naive single-hop lookup found
|
|
149
|
+
// the brief on restart #1 (predecessor = original id) but lost it on restart #2 (predecessor =
|
|
150
|
+
// the fresh id from restart #1, which has no spawn row). Fix: re-persist the RAW brief under the
|
|
151
|
+
// fresh id below (passed through to `callerSystemPromptAppend` on this restart's `restartSpawn`
|
|
152
|
+
// params — the SAME field a fresh agent.spawn stashes it in, see spawn-agent.ts). The reader then
|
|
153
|
+
// recovers it from either the original agent.spawn row OR a later agent.restart row, so each
|
|
154
|
+
// 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 } : {}) });
|
|
127
157
|
const params = buildSpawnCommand({
|
|
128
158
|
provider,
|
|
129
159
|
modelParams: resolvedModel,
|
|
@@ -138,6 +168,12 @@ function restartSpawnParamsForAgent(
|
|
|
138
168
|
approvalMode: approvalMode ?? "guarded",
|
|
139
169
|
permissionMode: approvalMode ?? "guarded",
|
|
140
170
|
providerArgs: providerArgs.length ? providerArgs : undefined,
|
|
171
|
+
...(composed.systemPromptAppend ? { systemPromptAppend: composed.systemPromptAppend } : {}),
|
|
172
|
+
// #1067 — re-persist the RAW brief (pre-composition) under this restart's fresh spawnRequestId so
|
|
173
|
+
// the NEXT restart, whose predecessor id is this fresh id, can recover it. Without this the brief
|
|
174
|
+
// survives only one restart (the original agent.spawn row is keyed on the very first id).
|
|
175
|
+
...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}),
|
|
176
|
+
...(composed.relayInjectionEvents.length ? { relayInjectionEvents: composed.relayInjectionEvents } : {}),
|
|
141
177
|
policyName: policyName || undefined,
|
|
142
178
|
headless: true,
|
|
143
179
|
spawnRequestId: requestId,
|
|
@@ -157,10 +193,6 @@ function restartSpawnParamsForAgent(
|
|
|
157
193
|
return opts.resumeId ? withResumeParams(params, opts.resumeId, agent.id) : params;
|
|
158
194
|
}
|
|
159
195
|
|
|
160
|
-
function spawnRequestIdForRestart(): string {
|
|
161
|
-
return generateSpawnRequestId();
|
|
162
|
-
}
|
|
163
|
-
|
|
164
196
|
function withResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
|
|
165
197
|
return {
|
|
166
198
|
...params,
|
package/src/routes/agents.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, canViewAgentRoute, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, visibleAgentIdsForRequest, type Handler } from "./_shared";
|
|
3
3
|
import { ValidationError, deleteAgent, getAgent, getAgentTimeline, getCrashReport, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
|
|
4
4
|
import { attachBranchState, attachBranchStates } from "../agent-branch-state";
|
|
5
|
+
import { attachCommandActivities, attachCommandActivity } from "../agent-command-activity";
|
|
5
6
|
import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
|
|
6
7
|
import { cleanStringArray } from "../validation";
|
|
7
8
|
import { clearActiveMemories, memoryBroker } from "../memory-service";
|
|
@@ -53,7 +54,7 @@ export const getAgents: Handler = (req) => {
|
|
|
53
54
|
// (visibleAgentIdsForRequest returns every id), so today's list is byte-for-byte unchanged.
|
|
54
55
|
const agents = listAgents({ tag, machine, status });
|
|
55
56
|
const visible = visibleAgentIdsForRequest(req, agents.map((a) => a.id));
|
|
56
|
-
return json(attachIssueRepos(attachBranchStates(agents.filter((a) => visible.has(a.id)))));
|
|
57
|
+
return json(attachCommandActivities(attachIssueRepos(attachBranchStates(agents.filter((a) => visible.has(a.id))))));
|
|
57
58
|
};
|
|
58
59
|
|
|
59
60
|
export const findAgents: Handler = (req) => {
|
|
@@ -69,7 +70,7 @@ export const getAgentById: Handler = (req, params) => {
|
|
|
69
70
|
if (!agent) return error("agent not found", 404);
|
|
70
71
|
// #392: a non-visible agent must look indistinguishable from a missing one (no existence leak).
|
|
71
72
|
if (!canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
|
|
72
|
-
return json(attachIssueRepo(attachBranchState(agent)));
|
|
73
|
+
return json(attachCommandActivity(attachIssueRepo(attachBranchState(agent))));
|
|
73
74
|
};
|
|
74
75
|
|
|
75
76
|
// #636 — "why did it die" data source. The crash report outlives the agent row (separate table),
|
package/src/routes/tasks.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { MAX_BODY_BYTES } from "../config";
|
|
|
3
3
|
import { VALID_TASK_STATUSES, agentSessionStatus, auditEvent, dispatchTaskCallbacks, emitCommand, error, json, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, resolveActorLabel, type Handler } from "./_shared";
|
|
4
4
|
import { getComponentAuth, isFullReachRequest } from "../security";
|
|
5
5
|
import { authContextFromRequest } from "../services/auth-context";
|
|
6
|
-
import { agentIdFromComponent, isTaskDispatchAuthorized, isTaskReadAuthorized } from "../task-authz";
|
|
6
|
+
import { agentIdFromComponent, hasTaskReadAdminScope, isTaskDispatchAuthorized, isTaskReadAuthorized } from "../task-authz";
|
|
7
7
|
import { dispatchShapeArg, pinArg, runTaskDispatch } from "../mcp-dispatch-tools";
|
|
8
|
-
import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTasks, recordTaskEvent, renewTaskClaim, updateTaskStatus } from "../db";
|
|
8
|
+
import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTasks, recordTaskEvent, renewTaskClaim, updateTaskStatus, type TaskStatusActor } from "../db";
|
|
9
9
|
import { readTaskMemoryEnvelope } from "../db/task-memory";
|
|
10
10
|
import {
|
|
11
11
|
addTaskEntityDependency, addTaskEntityRef, assignTaskEntityAgent, createTaskEntity, getTaskEntity,
|
|
@@ -531,9 +531,21 @@ export const patchTaskStatus: Handler = async (req, params) => {
|
|
|
531
531
|
if (id === null) return error("invalid task id");
|
|
532
532
|
const parsed = await parseBody<unknown>(req);
|
|
533
533
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
534
|
+
// Ownership fence (#1063): bind the write to the AUTHENTICATED caller, not the request body.
|
|
535
|
+
// Full-reach/system callers and task-admin scopes bypass (same convention as isTaskDispatchAuthorized
|
|
536
|
+
// / #1043's ensureCanMintThread); an ordinary task:write holder is pinned to its own agent id and
|
|
537
|
+
// can only advance a task it actually owns. An unidentified non-privileged caller is refused rather
|
|
538
|
+
// than silently falling back to the owner id.
|
|
539
|
+
const ctx = authContextFromRequest(req);
|
|
540
|
+
const privileged = ctx.actor.kind === "system" || hasTaskReadAdminScope(ctx.scopes);
|
|
541
|
+
if (!privileged && !ctx.callerAgentId) return error("task status requires an identified caller", 403);
|
|
542
|
+
const actor: TaskStatusActor = privileged ? { privileged: true } : { agentId: ctx.callerAgentId };
|
|
534
543
|
try {
|
|
535
|
-
const result = updateTaskStatus(id, normalizeTaskStatusInput(parsed.body));
|
|
536
|
-
if (!result.ok)
|
|
544
|
+
const result = updateTaskStatus(id, normalizeTaskStatusInput(parsed.body), actor);
|
|
545
|
+
if (!result.ok) {
|
|
546
|
+
const status = result.error === "task not found" ? 404 : result.conflict ? 409 : agentSessionStatus(result.error);
|
|
547
|
+
return error(result.error!, status);
|
|
548
|
+
}
|
|
537
549
|
emitTaskChanged(result.task!, "task.status");
|
|
538
550
|
const capturedMemory = result.task!.status === "done"
|
|
539
551
|
? await captureTaskResultMemory(result.task!, result.task!.result)
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -433,6 +433,12 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
|
|
|
433
433
|
const workspaceId = cleanString(body.workspaceId, "workspaceId", { max: 160 });
|
|
434
434
|
const dryRun = body.dryRun !== false; // safe by default
|
|
435
435
|
const landedOnly = true;
|
|
436
|
+
// #965 — an explicit operator force-discard for a genuinely-abandoned branch: clear even when
|
|
437
|
+
// git state is a conflict / unlanded / unprobeable. The land-safety gate is correct for LIVE
|
|
438
|
+
// work but wrong for a confirmed-abandoned branch, which otherwise left a raw DELETE as the only
|
|
439
|
+
// path. Gated hard on the owner NOT being online (force can never override liveness) and, like
|
|
440
|
+
// every mutation here, on `--execute` (dryRun:false) — the two together are the confirmation.
|
|
441
|
+
const force = body.force === true;
|
|
436
442
|
|
|
437
443
|
const candidates = listWorkspaces().filter((ws) =>
|
|
438
444
|
ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status)
|
|
@@ -455,22 +461,41 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
|
|
|
455
461
|
const landed = gitState?.landed === true;
|
|
456
462
|
// Safe = gone from disk, OR clean tree with no unmerged work (landed/empty).
|
|
457
463
|
const safe = missing || (gitState !== undefined && (dirtyCount ?? 1) === 0 && (!landedOnly || landed || ahead === 0));
|
|
464
|
+
// #965 — a force-discard clears an unsafe (conflict/unlanded/unprobeable) row too, but only
|
|
465
|
+
// for an offline owner. #1066: the `ownerOnline` snapshot above predates the
|
|
466
|
+
// `fetchWorkspaceGitState` await, so it is stale by dispatch time; liveness is re-verified
|
|
467
|
+
// immediately before dispatch below. Force overrides the land-safety gate, NEVER liveness.
|
|
468
|
+
const forced = force && !safe;
|
|
469
|
+
const discardable = safe || force;
|
|
458
470
|
const proof = { ownerStatus: owner?.status ?? "missing", ownerOnline, ahead, behind: gitState?.behind, landed, dirtyCount, missing, gitStateUnavailable: gitState ? undefined : ("unavailable" in fetched ? fetched.unavailable : undefined) };
|
|
459
|
-
const row: Record<string, unknown> = { workspaceId: ws.id, branch: ws.branch, worktreePath: ws.worktreePath, repoRoot: ws.repoRoot, safe, proof };
|
|
460
|
-
if (
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
471
|
+
const row: Record<string, unknown> = { workspaceId: ws.id, branch: ws.branch, worktreePath: ws.worktreePath, repoRoot: ws.repoRoot, safe, discardable, ...(forced ? { forced: true } : {}), proof };
|
|
472
|
+
if (discardable && !dryRun) {
|
|
473
|
+
// #1066 — re-snapshot liveness AFTER the git-state probe, immediately before dispatch. An
|
|
474
|
+
// owner offline at the initial snapshot can reconnect during the probe window (relay lag —
|
|
475
|
+
// #1054), and `force` bypasses buildWorkspaceCleanupCommand's dispatch-time live-owner guard,
|
|
476
|
+
// so this route is the only place that can refuse a just-reconnected owner under force.
|
|
477
|
+
// Force may clear an unsafe/conflict/unlanded row, but it must never nuke a live owner's tree.
|
|
478
|
+
if (isOwnerAlive(ws.ownerAgentId)) {
|
|
479
|
+
row.discardable = false;
|
|
480
|
+
row.cleanupError = `workspace ${ws.id} owner reconnected during git-state probe; force cannot override liveness`;
|
|
481
|
+
row.proof = { ...proof, ownerOnline: true };
|
|
467
482
|
} else {
|
|
468
|
-
|
|
483
|
+
// Owner re-confirmed offline just above; force past buildWorkspaceCleanupCommand's
|
|
484
|
+
// live-owner guard and carry a reason so the host records an intentional force-discard.
|
|
485
|
+
const built = buildWorkspaceCleanupCommand(ws, "cleanup-stale", forced ? { force: true, reason: "operator force-discard: abandoned branch, owner offline" } : {});
|
|
486
|
+
if (built.ok) {
|
|
487
|
+
updateWorkspaceStatus(ws.id, "cleanup_requested", { lastWorkspaceAction: "cleanup-stale", lastWorkspaceActionAt: Date.now(), cleanupProof: { ...proof, forced } });
|
|
488
|
+
emitCommand(built.command);
|
|
489
|
+
row.commandId = built.command.id;
|
|
490
|
+
cleaned.push(ws.id);
|
|
491
|
+
} else {
|
|
492
|
+
row.cleanupError = built.error;
|
|
493
|
+
}
|
|
469
494
|
}
|
|
470
495
|
}
|
|
471
496
|
rows.push(row);
|
|
472
497
|
}
|
|
473
|
-
return json({ dryRun, landedOnly, repoRoot, ...(workspaceId ? { workspaceId } : {}), scanned: candidates.length, eligible: rows.filter((r) => r.
|
|
498
|
+
return json({ dryRun, landedOnly, force, repoRoot, ...(workspaceId ? { workspaceId } : {}), scanned: candidates.length, eligible: rows.filter((r) => r.discardable).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
|
|
474
499
|
};
|
|
475
500
|
|
|
476
501
|
export const postWorkspaceAction: Handler = async (req, params) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isRecord } from "agent-relay-sdk";
|
|
1
|
+
import { hasScope, isRecord } from "agent-relay-sdk";
|
|
2
2
|
import { addTaskDependency, createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, getTaskDetail, listPlansWithBoundOwner, planThreadExists, removeTaskDependency, replacePlan, sendMessage, setPlanArchived, ValidationError } from "../db";
|
|
3
3
|
import { associateIssue, planNodeIssueEntityId, removeAssociation } from "../db/issue-associations";
|
|
4
4
|
import { resolveIssueRefFromString } from "../db/issue-refs";
|
|
@@ -9,6 +9,7 @@ import { issueRepoForAgent } from "../agent-issue-repo";
|
|
|
9
9
|
import { buildTaskGraphView, parseTaskId, planStatusToTaskStatus, projectPlanOverTasks } from "./task-plan-view";
|
|
10
10
|
import { createTaskEntity, setTaskEntityStatus } from "./task-entity";
|
|
11
11
|
import type { AuthContext } from "./auth-context";
|
|
12
|
+
import { ServiceAuthError } from "./errors";
|
|
12
13
|
import type { AgentCard, Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
|
|
13
14
|
|
|
14
15
|
export const PLAN_STATUSES = ["pending", "in-progress", "landed", "blocked"] as const;
|
|
@@ -37,7 +38,7 @@ interface PlanNodePatch {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
interface PlanCreateInput {
|
|
40
|
-
threadId
|
|
41
|
+
threadId?: unknown;
|
|
41
42
|
teamId?: unknown;
|
|
42
43
|
channel?: unknown;
|
|
43
44
|
title?: unknown;
|
|
@@ -269,7 +270,9 @@ function emitPlan(type: "plan.created" | "plan.changed" | "plan.deleted", plan:
|
|
|
269
270
|
// thread server-side by self-threading a lightweight system message (the same sendMessage path the
|
|
270
271
|
// caller would have used). Sent AS the actor when that agent is registered, else the reserved
|
|
271
272
|
// `system` sink, so the mint always passes the sender-registration gate.
|
|
272
|
-
function mintPlanThread(
|
|
273
|
+
function mintPlanThread(ctx: AuthContext, channel: string | undefined): number {
|
|
274
|
+
ensureCanMintThread(ctx);
|
|
275
|
+
const actor = actorId(ctx);
|
|
273
276
|
const sender = getAgent(actor) ? actor : "system";
|
|
274
277
|
const root = sendMessage({
|
|
275
278
|
from: sender,
|
|
@@ -280,24 +283,39 @@ function mintPlanThread(actor: string, channel: string | undefined): number {
|
|
|
280
283
|
return root.threadId ?? root.id;
|
|
281
284
|
}
|
|
282
285
|
|
|
286
|
+
// #1043: the mint inserts a real message row through the db `sendMessage` path, which normally
|
|
287
|
+
// lives behind the `message:send` scope. `plans:write` alone must not be a side-door around it, so
|
|
288
|
+
// gate the mint on `message:send`. Internal `system` principals (empty-scope trusted callers) and
|
|
289
|
+
// full-reach `*` tokens pass through untouched — `hasScope` short-circuits `*`.
|
|
290
|
+
function ensureCanMintThread(ctx: AuthContext): void {
|
|
291
|
+
if (ctx.actor.kind === "system") return;
|
|
292
|
+
if (!hasScope(ctx.scopes, "message:send")) {
|
|
293
|
+
throw new ServiceAuthError("minting a plan thread requires the message:send scope");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
283
297
|
export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
|
|
284
298
|
const channel = cleanString(input.channel, "channel", { max: 120 });
|
|
285
299
|
const actor = actorId(ctx);
|
|
286
|
-
const threadId = input.threadId === undefined || input.threadId === null
|
|
287
|
-
? mintPlanThread(actor, channel)
|
|
288
|
-
: cleanThreadId(input.threadId);
|
|
289
|
-
if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
|
|
290
300
|
const explicitTeamId = cleanString(input.teamId, "teamId", { max: 120 });
|
|
291
301
|
const teamId = explicitTeamId ?? (input.teamId === undefined || input.teamId === null ? getAgent(actor)?.teamId : undefined);
|
|
292
302
|
const nodes = cleanNodes(input.nodes, actor, teamId);
|
|
293
303
|
const edges = cleanEdges(input.edges);
|
|
294
304
|
validateEdges(nodes, edges);
|
|
305
|
+
const title = cleanString(input.title, "title", { max: 240 });
|
|
306
|
+
const objective = cleanString(input.objective, "objective", { max: 4000 });
|
|
307
|
+
// Resolve the thread only AFTER the payload validates (#1043): auto-minting first left an orphan
|
|
308
|
+
// 'Plan thread root' row behind on every rejected create.
|
|
309
|
+
const threadId = input.threadId === undefined || input.threadId === null
|
|
310
|
+
? mintPlanThread(ctx, channel)
|
|
311
|
+
: cleanThreadId(input.threadId);
|
|
312
|
+
if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
|
|
295
313
|
const plan = createPlanRow({
|
|
296
314
|
threadId,
|
|
297
315
|
teamId,
|
|
298
316
|
channel,
|
|
299
|
-
title
|
|
300
|
-
objective
|
|
317
|
+
title,
|
|
318
|
+
objective,
|
|
301
319
|
nodes,
|
|
302
320
|
edges,
|
|
303
321
|
createdBy: actor,
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// status broadcast, the single timeline note, the spawned-child parent-wake, and
|
|
11
11
|
// the first-registration audit row.
|
|
12
12
|
import { errMessage, isReservedAgentId } from "agent-relay-sdk";
|
|
13
|
-
import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, mergeAgentMeta, readSpawnReportVerbosity,
|
|
13
|
+
import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, mergeAgentMeta, readSpawnReportVerbosity, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
|
|
14
14
|
import { emitAgentStatus, emitNewMessage } from "../sse";
|
|
15
15
|
import { noteAgentTimelineEvent } from "../compaction-watch";
|
|
16
16
|
import { agentProviderTimelineEvent } from "../provider-timeline-event";
|
|
@@ -91,18 +91,18 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
|
|
|
91
91
|
// Registration labels are spawn defaults. Runtime /label changes remain authoritative.
|
|
92
92
|
const registrationInput = existing ? { ...input } : input;
|
|
93
93
|
if (existing) delete registrationInput.label;
|
|
94
|
-
const agent = upsertAgent(registrationInput);
|
|
95
|
-
|
|
96
94
|
// #1051/#484 — instance takeover is where the restart-strand invariant fires now that a
|
|
97
|
-
// heartbeat lapse no longer revokes anything. When a FRESH runner process re-registers
|
|
98
|
-
//
|
|
99
|
-
// tokens "stale":
|
|
100
|
-
//
|
|
101
|
-
// is
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
95
|
+
// heartbeat lapse no longer revokes anything. When a FRESH runner process re-registers under
|
|
96
|
+
// this agent id, the epoch bump inside upsertAgent atomically revokes the PRIOR instance's
|
|
97
|
+
// runtime tokens "stale": refused for auth yet re-mintable via the orchestrator (#484). A
|
|
98
|
+
// same-instance reconnect keeps its instanceId, so the epoch is unchanged and the live token
|
|
99
|
+
// is left valid — the whole point of #1051 (in-band recovery after lag without a re-mint).
|
|
100
|
+
// #1057(3) — the ONLY jti spared from that revoke is server-verified: the token that
|
|
101
|
+
// authenticated THIS registration, and only when it resolves to this very agent (a
|
|
102
|
+
// same-credential instance rotation, e.g. a respawn reusing its unexpired env token).
|
|
103
|
+
// Client-sent meta.auth can no longer nominate jtis to keep.
|
|
104
|
+
const verifiedKeepJti = ctx.callerAgentId === input.id ? ctx.component?.jti : undefined;
|
|
105
|
+
const agent = upsertAgent(registrationInput, verifiedKeepJti ? { verifiedKeepJtis: [verifiedKeepJti] } : undefined);
|
|
106
106
|
|
|
107
107
|
// Managed came-up-running reconcile — no-op for non-managed agents (the guard lives
|
|
108
108
|
// in markManagedAgentRunning). Clears backoff, flushes the policy queue, transitions
|
|
@@ -21,12 +21,13 @@
|
|
|
21
21
|
//
|
|
22
22
|
// OUT OF SCOPE (transport-edge, excluded from the parity facet): automatic memory injection
|
|
23
23
|
// (needs request-derived context) and per-transport audit (HTTP domain row vs MCP tool-call row).
|
|
24
|
-
import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
|
|
24
|
+
import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId, isTurnFinalSession } from "agent-relay-sdk";
|
|
25
25
|
import {
|
|
26
26
|
ValidationError,
|
|
27
27
|
flushDeferredLineageReport,
|
|
28
28
|
getAgent,
|
|
29
29
|
getMessage,
|
|
30
|
+
latestInboundMessage,
|
|
30
31
|
listAgents,
|
|
31
32
|
listRecentFinalResponseCaptures,
|
|
32
33
|
promoteLineageCapturedResponse,
|
|
@@ -287,6 +288,14 @@ function emitSendResults(results: DbSendMessageResult[]): void {
|
|
|
287
288
|
emitNewMessages(liveMessages);
|
|
288
289
|
}
|
|
289
290
|
|
|
291
|
+
// #976 — a spawned child (results-only profile) whose turn-final response Relay already promotes to
|
|
292
|
+
// its spawn-parent does not also need to fire a bare ack/report via relay_send_message. Codex workers
|
|
293
|
+
// do this routinely ("Noted.", "Cleared.", "Going forward I'll…", or a duplicate of the final report),
|
|
294
|
+
// and each redundant message wakes the coordinator and burns the context coordinators exist to
|
|
295
|
+
// protect. This ONE home suppresses such acks on BOTH transports (HTTP + MCP) and BOTH providers
|
|
296
|
+
// (codex + claude): a reply-path ack collapses onto its parent; a standalone self→parent ack (no
|
|
297
|
+
// replyTo — the codex relay_send_message leak) collapses onto the latest inbound it answers. A
|
|
298
|
+
// genuine result (final capture, a session-final step, or non-ack body) always passes through.
|
|
290
299
|
function suppressResultsOnlyAck(input: SendMessageInput): Message | null {
|
|
291
300
|
if (input.kind && input.kind !== "chat" && input.kind !== "session") return null;
|
|
292
301
|
const sender = getAgent(input.from);
|
|
@@ -296,21 +305,40 @@ function suppressResultsOnlyAck(input: SendMessageInput): Message | null {
|
|
|
296
305
|
if (!isPassiveAck(input.body)) return null;
|
|
297
306
|
if (isRecord(input.payload?.responseCapture)) return null;
|
|
298
307
|
const session = isRecord(input.payload?.session) ? input.payload.session : undefined;
|
|
299
|
-
if (session
|
|
300
|
-
|
|
308
|
+
if (isTurnFinalSession(session)) return null;
|
|
309
|
+
// Reply-path: collapse onto the parent message being acked.
|
|
310
|
+
if (input.replyTo) return getMessage(input.replyTo);
|
|
311
|
+
// Standalone self→parent path (#976): only a spawned child acking its OWN spawn-parent. Anchor to
|
|
312
|
+
// the latest inbound from that parent so the drop mirrors the reply-path collapse; if there is no
|
|
313
|
+
// prior inbound to anchor to, deliver (never drop a message we can't attribute to a conversation).
|
|
314
|
+
if (sender?.spawnedBy && sender.spawnedBy === input.to) {
|
|
315
|
+
return latestInboundMessage(input.from, sender.spawnedBy);
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
301
318
|
}
|
|
302
319
|
|
|
320
|
+
// Exact-match ceremonial acks (after normalization). Kept an explicit set — high precision, so a
|
|
321
|
+
// real short status ("Cleared the queue", "PASS") never matches, only the bare acknowledgement.
|
|
322
|
+
const PASSIVE_ACK_PHRASES = new Set([
|
|
323
|
+
"noted", "noted standing by",
|
|
324
|
+
"acknowledged", "acknowledged standing by",
|
|
325
|
+
"standing by", "ok standing by", "okay standing by",
|
|
326
|
+
"understood", "understood standing by",
|
|
327
|
+
"cleared", "will do", "will do standing by", "will comply",
|
|
328
|
+
"on it", "got it", "roger", "roger that", "copy", "copy that",
|
|
329
|
+
"sounds good", "ok", "okay", "okay will do", "thanks", "thank you",
|
|
330
|
+
"no problem", "no worries",
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
// A short SELF-REFERENTIAL acknowledgement of an instruction about the agent's OWN reporting
|
|
334
|
+
// behavior ("Going forward, I'll only use Relay mid-task…"). Bounded length + it must name the
|
|
335
|
+
// messaging/reporting channel, so a genuine forward-looking REPORT that happens to open with
|
|
336
|
+
// "going forward" ("going forward the build fails unless we bump node") still passes through.
|
|
337
|
+
const REPORTING_META_ACK = /^(going forward|from now on)\b.{0,160}\b(relay|messages?|acks?|reports?|reporting|pings?|notif\w*)\b/;
|
|
338
|
+
|
|
303
339
|
function isPassiveAck(body: string): boolean {
|
|
304
|
-
const text = body.toLowerCase().replace(/[
|
|
305
|
-
return
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
"acknowledged",
|
|
309
|
-
"acknowledged standing by",
|
|
310
|
-
"standing by",
|
|
311
|
-
"ok standing by",
|
|
312
|
-
"okay standing by",
|
|
313
|
-
"understood",
|
|
314
|
-
"understood standing by",
|
|
315
|
-
].includes(text);
|
|
340
|
+
const text = body.toLowerCase().replace(/[.!,…]+/g, " ").replace(/\s+/g, " ").trim();
|
|
341
|
+
if (!text) return false;
|
|
342
|
+
if (PASSIVE_ACK_PHRASES.has(text)) return true;
|
|
343
|
+
return text.length <= 160 && REPORTING_META_ACK.test(text);
|
|
316
344
|
}
|
|
@@ -647,6 +647,9 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
647
647
|
providerArgs: input.providerArgs ?? [],
|
|
648
648
|
prompt: resolvedPrompt,
|
|
649
649
|
systemPromptAppend,
|
|
650
|
+
// #1062 — stash the RAW caller brief (pre-composition) so a dashboard (non-policy) restart can
|
|
651
|
+
// re-thread it as callerSystemPromptAppend and stay fresh-spawn-identical instead of dropping it.
|
|
652
|
+
...(resolvedSystemPromptAppend ? { callerSystemPromptAppend: resolvedSystemPromptAppend } : {}),
|
|
650
653
|
relayInjectionEvents: composedInstructions.relayInjectionEvents,
|
|
651
654
|
landStrategy: input.landStrategy,
|
|
652
655
|
autoMerge: input.autoMerge,
|