agent-relay-server 0.121.5 → 0.122.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -4
  3. package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
  4. package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
  7. package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
  8. package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
  10. package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
  12. package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
  13. package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
  14. package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
  15. package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
  16. package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
  17. package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
  18. package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
  20. package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
  21. package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
  22. package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
  23. package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
  24. package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
  25. package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
  26. package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
  27. package/public/assets/display-CJzSoTMq.js +3 -0
  28. package/public/assets/display-CJzSoTMq.js.map +1 -0
  29. package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
  30. package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
  32. package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
  33. package/public/assets/index-D0xMQQb_.js +25 -0
  34. package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
  35. package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
  36. package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
  37. package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
  38. package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
  39. package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
  40. package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
  41. package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
  42. package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
  43. package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
  44. package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
  45. package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
  46. package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
  47. package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
  48. package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
  49. package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
  50. package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
  51. package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
  52. package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
  53. package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
  54. package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
  55. package/public/assets/projects-Dzdf-gkK.js +2 -0
  56. package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
  57. package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
  58. package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
  59. package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
  60. package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
  61. package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
  62. package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
  63. package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
  64. package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
  65. package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
  66. package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
  67. package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
  68. package/public/assets/store-BPrVuKiv.js.map +1 -0
  69. package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
  70. package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
  71. package/public/assets/teams-DWv6UwFV.js +2 -0
  72. package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
  73. package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
  74. package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
  75. package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
  76. package/public/assets/types-BeJUSfDj.js.map +1 -0
  77. package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
  78. package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
  79. package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
  80. package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
  81. package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
  82. package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
  83. package/public/index.html +6 -6
  84. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  85. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
  86. package/runner/src/adapter.ts +53 -18
  87. package/runner/src/config.ts +1 -4
  88. package/src/agent-card-projection.ts +5 -1
  89. package/src/agent-command-activity.ts +39 -0
  90. package/src/automations/reconcile.ts +2 -2
  91. package/src/cli/workspace.ts +3 -1
  92. package/src/config.ts +10 -1
  93. package/src/db/agents.ts +118 -37
  94. package/src/db/message-reads.ts +50 -0
  95. package/src/db/messages.ts +48 -9
  96. package/src/db/migrations.ts +54 -0
  97. package/src/db/provider-quota-burn.ts +96 -67
  98. package/src/db/provider-quotas.ts +29 -7
  99. package/src/db/scheduled-timers.ts +21 -0
  100. package/src/db/schema.ts +1 -0
  101. package/src/db/tasks.ts +33 -3
  102. package/src/db/work-evidence.ts +10 -7
  103. package/src/event-loop-lag.ts +15 -0
  104. package/src/gate-resolver.ts +24 -1
  105. package/src/maintenance/jobs.ts +23 -6
  106. package/src/managed-policy.ts +5 -12
  107. package/src/mcp-plan-tools.ts +13 -1
  108. package/src/project-instructions.ts +36 -2
  109. package/src/quota-advisory.ts +6 -4
  110. package/src/quota-band-transition.ts +23 -14
  111. package/src/routes/agent-sessions.ts +39 -7
  112. package/src/routes/agents.ts +3 -2
  113. package/src/routes/tasks.ts +16 -4
  114. package/src/routes/workspaces.ts +35 -10
  115. package/src/services/plan-graph.ts +27 -9
  116. package/src/services/register-agent.ts +12 -12
  117. package/src/services/send-message.ts +43 -15
  118. package/src/services/spawn-agent.ts +3 -0
  119. package/src/spawn-command.ts +8 -0
  120. package/src/spawn-targets.ts +19 -34
  121. package/src/sse.ts +25 -3
  122. package/src/workspace-merge.ts +25 -5
  123. package/src/workspace-orphans.ts +13 -6
  124. package/public/assets/display-exQDWum3.js +0 -3
  125. package/public/assets/display-exQDWum3.js.map +0 -1
  126. package/public/assets/index-D9OogaB5.js +0 -25
  127. package/public/assets/projects-DdPNPFJI.js +0 -2
  128. package/public/assets/prompt-settings-BpZse7-X.js +0 -2
  129. package/public/assets/store-DKTLubBT.js.map +0 -1
  130. package/public/assets/teams-B8f2pGJe.js +0 -2
  131. package/public/assets/types-uVOXgvMT.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  import { getArtifactStorage } from "../artifact-storage";
2
2
  import { deriveAgentBusyHealth } from "../agent-busy-health";
3
3
  import { expireStaleBusAgents } from "../bus";
4
- import { eventLoopLagMaxMs } from "../event-loop-lag";
4
+ import { eventLoopLagGraceMs } from "../event-loop-lag";
5
5
  import { pruneOutbox } from "../bus-outbox";
6
6
  import { expireCommands } from "../commands-db";
7
7
  import { getRetentionConfig } from "../retention-config-store";
@@ -22,6 +22,7 @@ import {
22
22
  releaseExpiredClaims,
23
23
  releaseExpiredDriveLeases,
24
24
  releaseOrphanedTasks,
25
+ revokeOrphanedRuntimeTokens,
25
26
  runDbMaintenanceAsync,
26
27
  sweepArtifacts,
27
28
  } from "../db";
@@ -278,7 +279,7 @@ export const definitions: MaintenanceJobDefinition[] = [
278
279
  intervalMs: REAP_INTERVAL_MS,
279
280
  runOnStart: true,
280
281
  handler() {
281
- const expired = expireStaleBusAgents(undefined, eventLoopLagMaxMs());
282
+ const expired = expireStaleBusAgents(undefined, eventLoopLagGraceMs());
282
283
  for (const id of expired.agentIds) {
283
284
  getLifecycleManager().onAgentDisappeared(id);
284
285
  // #636/#659 — only after the stale reconnect grace expires do we turn a bare
@@ -301,11 +302,13 @@ export const definitions: MaintenanceJobDefinition[] = [
301
302
  intervalMs: REAP_INTERVAL_MS,
302
303
  runOnStart: true,
303
304
  handler() {
304
- // #1048 — gate staleness on the relay's OWN event-loop health. eventLoopLagMaxMs()
305
- // is side-effect-free (does not consume the /health poller's spike max). During a
306
- // relay self-stall this grace keeps live agents whose heartbeats we simply couldn't
305
+ // #1048/#1055 — gate staleness on the relay's OWN event-loop health.
306
+ // eventLoopLagGraceMs() is side-effect-free (does not consume the /health poller's
307
+ // spike max) and sums the retained window's lag, so BOTH one contiguous stall and
308
+ // sustained non-contiguous overload (many sub-TTL stalls whose backlog sums past the
309
+ // TTL) extend the grace. Keeps live agents whose heartbeats we simply couldn't
307
310
  // process from being reaped as dead.
308
- const lagGraceMs = eventLoopLagMaxMs();
311
+ const lagGraceMs = eventLoopLagGraceMs();
309
312
  const reapedAgentIds = reapStaleAgents(STALE_TTL_MS, lagGraceMs);
310
313
  for (const id of reapedAgentIds) {
311
314
  emitAgentStatus(id);
@@ -336,6 +339,20 @@ export const definitions: MaintenanceJobDefinition[] = [
336
339
  return { prunedAgentIds };
337
340
  },
338
341
  },
342
+ {
343
+ id: "orphan-token-revoker",
344
+ title: "Orphaned runtime-token revoker",
345
+ description: "Revoke ('stale', re-mintable) the runtime tokens of agents whose process the orchestrator confirmed dead and that have not been heard from since.",
346
+ intervalMs: REAP_INTERVAL_MS,
347
+ runOnStart: false,
348
+ handler() {
349
+ // #1057(1) — same lag discipline as the stale reaper (#1048/#1055): while the relay's own
350
+ // event loop was stalled, a live replacement's heartbeats may still be unprocessed, so the
351
+ // grace widens by the observed lag instead of misreading the backlog as post-mortem silence.
352
+ const revokedOrphanTokens = revokeOrphanedRuntimeTokens(STALE_TTL_MS, eventLoopLagGraceMs());
353
+ return { revokedOrphanTokens };
354
+ },
355
+ },
339
356
  {
340
357
  id: "orphaned-session-reaper",
341
358
  title: "Orphaned session reaper",
@@ -1,8 +1,7 @@
1
1
  import { getManifest } from "agent-relay-providers";
2
- import { DEFAULT_USER_ID } from "agent-relay-sdk";
3
2
  import { getAgentProfile } from "./config-store";
4
- import { resolveProjectForCwd, resolveScopedAnchor, withResolvedProvisioning } from "./db";
5
- import { composeSpawnInstructionsWithEvents } from "./project-instructions";
3
+ import { withResolvedProvisioning } from "./db";
4
+ import { composeProfileSpawnInstructions } from "./project-instructions";
6
5
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
7
6
  import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
8
7
  import { applyDefaultSharedMcp } from "./shared-mcp";
@@ -58,16 +57,10 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
58
57
  // keepalive prompt folds in as the caller append → one ordered `--append-system-prompt` block,
59
58
  // no double-inject. The projection is deterministic (sorted + deduped), so re-running it on a
60
59
  // native-resume relaunch reproduces a byte-identical block rather than stacking a second copy.
61
- const callerProject = resolveProjectForCwd(policy.cwd) ?? undefined;
62
- const projectInstructions = agentProfile?.projectInstructions;
63
- const profileAnchor = policy.profile ? resolveScopedAnchor("profile", policy.profile) ?? undefined : undefined;
64
- const userAnchor = resolveScopedAnchor("user", DEFAULT_USER_ID) ?? undefined;
65
60
  const alwaysOnAppend = managedPolicyAppendSystemPrompt(policy);
66
- const composed = composeSpawnInstructionsWithEvents({
67
- ...(callerProject ? { callerProject } : {}),
68
- ...(projectInstructions ? { projectInstructions } : {}),
69
- ...(profileAnchor ? { profileAnchor } : {}),
70
- ...(userAnchor ? { userAnchor } : {}),
61
+ const composed = composeProfileSpawnInstructions({
62
+ cwd: policy.cwd,
63
+ ...(policy.profile ? { profileName: policy.profile } : {}),
71
64
  ...(agentProfile ? { profile: agentProfile } : {}),
72
65
  ...(alwaysOnAppend ? { callerSystemPromptAppend: alwaysOnAppend } : {}),
73
66
  });
@@ -1,5 +1,6 @@
1
- import { McpNotFoundError } from "./mcp-errors";
1
+ import { McpAuthError, McpNotFoundError } from "./mcp-errors";
2
2
  import { ValidationError } from "./db";
3
+ import { ServiceAuthError } from "./services/errors";
3
4
  import { authContextFromMcp } from "./services/auth-context";
4
5
  import {
5
6
  addPlanEdge,
@@ -126,6 +127,17 @@ function stringArg(value: unknown, field: string): string {
126
127
  }
127
128
 
128
129
  export function relayPlanTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
130
+ try {
131
+ return dispatchPlanTool(auth, name, args);
132
+ } catch (e) {
133
+ // A service-layer auth failure (e.g. #1043's message:send gate on the auto-mint) is an auth
134
+ // error on the wire — map it like the messaging tools do so the MCP edge returns 403.
135
+ if (e instanceof ServiceAuthError) throw new McpAuthError(e.message);
136
+ throw e;
137
+ }
138
+ }
139
+
140
+ function dispatchPlanTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
129
141
  const ctx = authContextFromMcp(auth);
130
142
  if (name === "relay_plan_create") return createPlan(args as unknown as Parameters<typeof createPlan>[0], ctx) as unknown as Record<string, unknown>;
131
143
  if (name === "relay_plan_add_node") {
@@ -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 { resolveProjectEntries } from "./db";
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.
@@ -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): string {
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
- processBandTransitions(input);
105
- return JSON.stringify(nextState);
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
- export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void {
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.get(key) ?? {
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.set(key, state);
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
- const requestId = spawnRequestId ?? spawnRequestIdForRestart();
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,
@@ -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),
@@ -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) return error(result.error!, result.error === "task not found" ? 404 : agentSessionStatus(result.error));
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)
@@ -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 (safe && !dryRun) {
461
- const built = buildWorkspaceCleanupCommand(ws, "cleanup-stale");
462
- if (built.ok) {
463
- updateWorkspaceStatus(ws.id, "cleanup_requested", { lastWorkspaceAction: "cleanup-stale", lastWorkspaceActionAt: Date.now(), cleanupProof: proof });
464
- emitCommand(built.command);
465
- row.commandId = built.command.id;
466
- cleaned.push(ws.id);
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
- row.cleanupError = built.error;
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.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
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: unknown;
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(actor: string, channel: string | undefined): number {
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: cleanString(input.title, "title", { max: 240 }),
300
- objective: cleanString(input.objective, "objective", { max: 4000 }),
317
+ title,
318
+ objective,
301
319
  nodes,
302
320
  edges,
303
321
  createdBy: actor,