agent-relay-server 0.119.9 → 0.119.11

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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.119.9",
5
+ "version": "0.119.11",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
@@ -9440,6 +9440,34 @@
9440
9440
  ]
9441
9441
  }
9442
9442
  },
9443
+ "/api/tasks/reconcile-auto-scaffolded": {
9444
+ "post": {
9445
+ "operationId": "postTaskReconcileAutoScaffolded",
9446
+ "summary": "Post Task Reconcile Auto Scaffolded",
9447
+ "tags": [
9448
+ "Tasks"
9449
+ ],
9450
+ "responses": {
9451
+ "200": {
9452
+ "description": "Success",
9453
+ "content": {
9454
+ "application/json": {}
9455
+ }
9456
+ }
9457
+ },
9458
+ "security": [
9459
+ {
9460
+ "bearerAuth": []
9461
+ },
9462
+ {
9463
+ "tokenHeader": []
9464
+ },
9465
+ {
9466
+ "tokenQuery": []
9467
+ }
9468
+ ]
9469
+ }
9470
+ },
9443
9471
  "/api/tasks/{id}": {
9444
9472
  "get": {
9445
9473
  "operationId": "getTaskById",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.119.9",
3
+ "version": "0.119.11",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "CONTRIBUTING.md"
38
38
  ],
39
39
  "dependencies": {
40
- "agent-relay-channels-host": "0.119.9",
40
+ "agent-relay-channels-host": "0.119.11",
41
41
  "agent-relay-providers": "0.104.3",
42
42
  "agent-relay-sdk": "0.2.106",
43
43
  "ajv": "^8.20.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.119.9",
4
+ "version": "0.119.11",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -229,6 +229,8 @@ export function applyMigrations(): void {
229
229
  const colNames = cols.map((c: any) => c.name);
230
230
  if (!colNames.includes("thread_id")) {
231
231
  getDb().run("ALTER TABLE messages ADD COLUMN thread_id INTEGER");
232
+ }
233
+ if (!colNames.includes("reply_to")) {
232
234
  getDb().run("ALTER TABLE messages ADD COLUMN reply_to INTEGER REFERENCES messages(id)");
233
235
  }
234
236
  if (!colNames.includes("reply_expected")) {
@@ -281,6 +283,10 @@ export function applyMigrations(): void {
281
283
  "UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
282
284
  ).run(Date.now(), CLAIM_LEASE_MS);
283
285
  getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
286
+ // (reply_to, from_agent) powers the reply-obligation NOT EXISTS subquery in
287
+ // listPendingReplyObligations. It must stay migration-owned because reply_to is
288
+ // an ALTER-added column on legacy DBs (#963).
289
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_reply_to ON messages(reply_to, from_agent)");
284
290
  getDb().run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
285
291
  getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_delivery_status ON messages(delivery_status)");
286
292
  getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_claimed_expires ON messages(claimed_by, claim_expires_at, id) WHERE claimed_by IS NOT NULL");
package/src/db/schema.ts CHANGED
@@ -163,14 +163,10 @@ export function initDb(path: string = "agent-relay.db"): Database {
163
163
  -- queries (delivery, polling, etc.) are served identically.
164
164
  -- idx_msg_resolved_to_id is created in applyMigrations() AFTER the
165
165
  -- resolved_to_agent ALTER — never inline here (same hazard as #483/#559).
166
+ -- idx_msg_reply_to is also migration-owned because reply_to was added by ALTER.
166
167
  CREATE INDEX IF NOT EXISTS idx_msg_to_id ON messages(to_target, id);
167
168
  CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(created_at);
168
169
  CREATE INDEX IF NOT EXISTS idx_msg_channel ON messages(channel);
169
- -- (reply_to, from_agent) powers the reply-obligation NOT EXISTS subquery in
170
- -- listPendingReplyObligations. Without it that subquery full-scans messages
171
- -- per candidate row (O(n^2)): ~8.6s at 4k rows, which blew the 5s Stop-hook
172
- -- timeout and wedged turns in "busy" (#199).
173
- CREATE INDEX IF NOT EXISTS idx_msg_reply_to ON messages(reply_to, from_agent);
174
170
  CREATE TABLE IF NOT EXISTS scheduled_timers (
175
171
  id TEXT PRIMARY KEY,
176
172
  target TEXT NOT NULL,
@@ -597,7 +593,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
597
593
  cleaned_at INTEGER
598
594
  );
599
595
  CREATE INDEX IF NOT EXISTS idx_workspaces_repo_status ON workspaces(repo_root, status);
600
- CREATE INDEX IF NOT EXISTS idx_workspaces_orchestrator ON workspaces(orchestrator_id);
596
+ -- idx_workspaces_orchestrator created in applyMigrations() AFTER orchestrator_id ALTER (#949)
601
597
  CREATE INDEX IF NOT EXISTS idx_workspaces_owner_agent ON workspaces(owner_agent_id);
602
598
  CREATE INDEX IF NOT EXISTS idx_workspaces_policy ON workspaces(owner_policy_name);
603
599
 
@@ -45,7 +45,7 @@ import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, get
45
45
  import { getSettingRoute, getSettingsNamespaceRoute, getSettingsRoute, putSettingRoute, putSettingsDefaultRoute } from "./settings";
46
46
  import { postNotificationPreviewRoute } from "./notifications";
47
47
  import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
48
- import { getTaskById, getTaskEntities, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim, postTaskCreate, getTaskDetailRoute, getTaskMemoryRoute, getTaskHistoryRoute, patchTaskEntity, postTaskEntityStatus, postTaskEntityStage, postTaskEntityGate, postTaskDependency, deleteTaskDependency, postTaskRef, deleteTaskRef, postTaskAssign, postTaskComment, getTaskCloseBridge, postTaskDispatch } from "./tasks";
48
+ import { getTaskById, getTaskEntities, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim, postTaskCreate, postTaskReconcileAutoScaffolded, getTaskDetailRoute, getTaskMemoryRoute, getTaskHistoryRoute, patchTaskEntity, postTaskEntityStatus, postTaskEntityStage, postTaskEntityGate, postTaskDependency, deleteTaskDependency, postTaskRef, deleteTaskRef, postTaskAssign, postTaskComment, getTaskCloseBridge, postTaskDispatch } from "./tasks";
49
49
  import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
50
50
  import { postTeamDissolveByIdRoute } from "./teams";
51
51
  import { deleteTeamTemplateRoute, getTeamTemplateRoute, getTeamTemplatesRoute, postTeamTemplateRoute, putTeamTemplateRoute } from "./team-templates";
@@ -353,6 +353,7 @@ const routes: Route[] = [
353
353
  route("GET", "/api/task-entities", getTaskEntities),
354
354
  route("GET", "/api/tasks", getTasks),
355
355
  route("POST", "/api/tasks", postTaskCreate),
356
+ route("POST", "/api/tasks/reconcile-auto-scaffolded", postTaskReconcileAutoScaffolded),
356
357
  route("GET", "/api/tasks/:id", getTaskById),
357
358
  route("GET", "/api/tasks/:id/detail", getTaskDetailRoute),
358
359
  route("GET", "/api/tasks/:id/memory", getTaskMemoryRoute),
@@ -9,7 +9,7 @@ import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTa
9
9
  import { readTaskMemoryEnvelope } from "../db/task-memory";
10
10
  import {
11
11
  addTaskEntityDependency, addTaskEntityRef, assignTaskEntityAgent, createTaskEntity, getTaskEntity,
12
- listTaskEntitySummaries, recordTaskEntityGate, removeTaskEntityDependency, removeTaskEntityRef, setTaskEntityStage, setTaskEntityStatus, taskEntityHistory, updateTaskEntity,
12
+ listTaskEntitySummaries, reconcileAutoScaffoldedTaskReaps, recordTaskEntityGate, removeTaskEntityDependency, removeTaskEntityRef, setTaskEntityStage, setTaskEntityStatus, taskEntityHistory, updateTaskEntity,
13
13
  } from "../services/task-entity";
14
14
  import { CommentThroughError, commentOnTaskThrough, taskCloseBridge } from "../services/task-issue-weave";
15
15
  import { captureTaskResultMemory, injectMemoryForTaskClaim } from "../memory-service";
@@ -86,6 +86,24 @@ export const getTaskEntities: Handler = (req) => {
86
86
  }));
87
87
  };
88
88
 
89
+ export const postTaskReconcileAutoScaffolded: Handler = async (req) => {
90
+ const parsed = await parseBody<Record<string, unknown>>(req);
91
+ if (!parsed.ok) return error(parsed.error, parsed.status);
92
+ const body = parsed.body ?? {};
93
+ if (!isRecord(body)) return error("JSON object body required");
94
+ const limit = body.limit === undefined ? undefined : Number(body.limit);
95
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 500)) return error("limit must be an integer between 1 and 500");
96
+ try {
97
+ return json(reconcileAutoScaffoldedTaskReaps({
98
+ dryRun: body.dryRun !== false,
99
+ limit,
100
+ actor: actorOf(req),
101
+ }));
102
+ } catch (e) {
103
+ return entityError(e);
104
+ }
105
+ };
106
+
89
107
  // --- #834 durable Task entity (kind='deliverable') HTTP surface ---
90
108
 
91
109
  export const postTaskCreate: Handler = async (req) => {
@@ -15,6 +15,7 @@ export function doneWhenTargetStage(doneWhen: string | undefined): TaskDeliverab
15
15
  }
16
16
 
17
17
  export function stageSatisfiesDoneWhen(stage: TaskDeliverableStage, doneWhen: string | undefined): boolean {
18
+ if (isAgentExitDoneWhen(doneWhen)) return false;
18
19
  const current = STAGE_INDEX.get(stage) ?? 0;
19
20
  const target = STAGE_INDEX.get(doneWhenTargetStage(doneWhen)) ?? STAGE_INDEX.get("landed-on-main")!;
20
21
  return current >= target;
@@ -11,9 +11,11 @@ import {
11
11
  deliverableTaskIdsBySpawnRequestId,
12
12
  getAgent,
13
13
  getTaskDetail,
14
+ listAgents,
14
15
  listDeliverableTaskSummaries,
15
16
  listDeliverableTasks,
16
17
  listTaskEvents,
18
+ listWorkspaces,
17
19
  markTaskAgentReaped,
18
20
  recordGate,
19
21
  recordTaskDispatchDecision,
@@ -32,8 +34,9 @@ import {
32
34
  type SetTaskStatusInput,
33
35
  type UpdateDeliverableTaskInput,
34
36
  } from "../db";
37
+ import { findSpawnCommandByRequestId } from "../commands-db";
35
38
  import { emitTaskChanged } from "../sse";
36
- import type { Task, TaskDetail, TaskEvent, TaskGateState, TaskListItem } from "../types";
39
+ import type { CommandStatus, Task, TaskDetail, TaskEvent, TaskGateState, TaskListItem, TaskStatus, WorkspaceRecord } from "../types";
37
40
  import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
38
41
  import { emitRelayEvent } from "../events";
39
42
  import { routeNotification } from "../notification-router";
@@ -190,6 +193,146 @@ export function taskEntityHistory(id: number): TaskEvent[] {
190
193
  return listTaskEvents(id);
191
194
  }
192
195
 
196
+ const AUTO_SCAFFOLD_RECONCILE_STATUSES: TaskStatus[] = ["in_progress"];
197
+ const AUTO_SCAFFOLD_RECONCILE_MIN_AGE_MS = 15 * 60_000;
198
+ const SPAWN_IN_FLIGHT_STATUSES: CommandStatus[] = ["pending", "accepted", "running"];
199
+ const SPAWN_TERMINAL_STATUSES: CommandStatus[] = ["succeeded", "failed", "timed_out", "rejected", "canceled"];
200
+ const LANDED_STAGES = new Set(["landed-on-main", "deploying", "deployed"]);
201
+
202
+ function hasLiveTaskOwner(task: Pick<Task, "ownerAgentId">): boolean {
203
+ const owner = task.ownerAgentId?.trim();
204
+ if (!owner) return false;
205
+ const agent = getAgent(owner);
206
+ return Boolean(agent && agent.status !== "offline");
207
+ }
208
+
209
+ function isAutoScaffoldedAgentExitTask(task: Pick<Task, "source" | "doneWhen">): boolean {
210
+ return task.source === "spawn" && isAgentExitDoneWhen(task.doneWhen);
211
+ }
212
+
213
+ function latestTaskActivityAt(task: Pick<Task, "createdAt" | "updatedAt" | "lastSeenAt">): number {
214
+ return Math.max(task.createdAt, task.updatedAt, task.lastSeenAt);
215
+ }
216
+
217
+ function spawnRequestIdsForTask(taskId: number): string[] {
218
+ return listTaskEvents(taskId)
219
+ .filter((event) => event.type === "agent.spawn_requested")
220
+ .map((event) => typeof event.metadata.spawnRequestId === "string" ? event.metadata.spawnRequestId.trim() : "")
221
+ .filter(Boolean);
222
+ }
223
+
224
+ function hasLiveSpawnAgent(spawnRequestId: string): boolean {
225
+ return listAgents().some((agent) => agent.meta?.spawnRequestId === spawnRequestId && agent.status !== "offline");
226
+ }
227
+
228
+ function ownerlessSpawnTerminal(taskId: number): { ok: boolean; reason?: string } {
229
+ const spawnRequestIds = spawnRequestIdsForTask(taskId);
230
+ if (spawnRequestIds.length === 0) return { ok: false, reason: "ownerless-no-spawn-request" };
231
+
232
+ let sawTerminal = false;
233
+ for (const spawnRequestId of spawnRequestIds) {
234
+ if (hasLiveSpawnAgent(spawnRequestId)) return { ok: false, reason: "live-spawn-agent" };
235
+ if (findSpawnCommandByRequestId(spawnRequestId, SPAWN_IN_FLIGHT_STATUSES)) {
236
+ return { ok: false, reason: "spawn-in-flight" };
237
+ }
238
+ if (findSpawnCommandByRequestId(spawnRequestId, SPAWN_TERMINAL_STATUSES)) {
239
+ sawTerminal = true;
240
+ continue;
241
+ }
242
+ return { ok: false, reason: "spawn-state-unknown" };
243
+ }
244
+ return sawTerminal ? { ok: true } : { ok: false, reason: "ownerless-no-spawn-request" };
245
+ }
246
+
247
+ function workspaceLanded(workspace: WorkspaceRecord): boolean {
248
+ return workspace.status === "merged" || typeof workspace.metadata.mergedAt === "number";
249
+ }
250
+
251
+ function landedEvidenceFor(task: Task, workspaces: WorkspaceRecord[]): string | undefined {
252
+ if (task.deliverableStage && LANDED_STAGES.has(task.deliverableStage)) return `stage:${task.deliverableStage}`;
253
+ const owner = task.ownerAgentId?.trim();
254
+ const branch = task.branch?.trim();
255
+ const workspace = workspaces.find((ws) => workspaceLanded(ws) && (
256
+ (owner && ws.ownerAgentId === owner) ||
257
+ (branch && ws.branch === branch)
258
+ ));
259
+ if (!workspace) return undefined;
260
+ return `workspace:${workspace.id}:${workspace.status}`;
261
+ }
262
+
263
+ function candidateAutoScaffoldedTasks(limit: number): Task[] {
264
+ const byId = new Map<number, Task>();
265
+ for (const status of AUTO_SCAFFOLD_RECONCILE_STATUSES) {
266
+ for (const task of listDeliverableTasks({ status, limit })) {
267
+ if (isAutoScaffoldedAgentExitTask(task)) byId.set(task.id, task);
268
+ }
269
+ }
270
+ return [...byId.values()].sort((a, b) => a.id - b.id);
271
+ }
272
+
273
+ export interface ReconcileAutoScaffoldedTaskReapsOptions {
274
+ dryRun?: boolean;
275
+ limit?: number;
276
+ actor?: string;
277
+ now?: number;
278
+ minAgeMs?: number;
279
+ }
280
+
281
+ export interface ReconcileAutoScaffoldedTaskReapsResult {
282
+ dryRun: boolean;
283
+ scanned: number;
284
+ completed: number[];
285
+ canceled: number[];
286
+ skipped: Array<{ taskId: number; reason: string }>;
287
+ }
288
+
289
+ export function reconcileAutoScaffoldedTaskReaps(opts: ReconcileAutoScaffoldedTaskReapsOptions = {}): ReconcileAutoScaffoldedTaskReapsResult {
290
+ const dryRun = opts.dryRun !== false;
291
+ const limit = Math.min(Math.max(opts.limit ?? 500, 1), 500);
292
+ const workspaces = listWorkspaces();
293
+ const completed: number[] = [];
294
+ const canceled: number[] = [];
295
+ const skipped: Array<{ taskId: number; reason: string }> = [];
296
+ const actor = opts.actor ?? "task-reconcile";
297
+ const now = opts.now ?? Date.now();
298
+ const minAgeMs = Math.max(opts.minAgeMs ?? AUTO_SCAFFOLD_RECONCILE_MIN_AGE_MS, 0);
299
+
300
+ for (const task of candidateAutoScaffoldedTasks(limit)) {
301
+ if (latestTaskActivityAt(task) > now - minAgeMs) {
302
+ skipped.push({ taskId: task.id, reason: "recent-task" });
303
+ continue;
304
+ }
305
+ if (hasLiveTaskOwner(task)) {
306
+ skipped.push({ taskId: task.id, reason: "live-owner" });
307
+ continue;
308
+ }
309
+ if (!task.ownerAgentId?.trim()) {
310
+ const ownerless = ownerlessSpawnTerminal(task.id);
311
+ if (!ownerless.ok) {
312
+ skipped.push({ taskId: task.id, reason: ownerless.reason ?? "ownerless-spawn-active" });
313
+ continue;
314
+ }
315
+ }
316
+ const landedEvidence = landedEvidenceFor(task, workspaces);
317
+ const status = landedEvidence ? "done" : "canceled";
318
+ if (!dryRun) {
319
+ if (task.ownerAgentId && !task.reapedAt) {
320
+ markTaskAgentReaped(task.id, { agentId: task.ownerAgentId, reason: landedEvidence ? "reconciled-landed-worker" : "reconciled-reaped-without-land" });
321
+ }
322
+ setTaskEntityStatus(task.id, {
323
+ status,
324
+ note: landedEvidence
325
+ ? `auto-scaffolded worker already landed (${landedEvidence}); reconciled doneWhen=agent-exit`
326
+ : "auto-scaffolded worker is no longer live and no landed workspace evidence was found; canceled",
327
+ actor,
328
+ });
329
+ }
330
+ (status === "done" ? completed : canceled).push(task.id);
331
+ }
332
+
333
+ return { dryRun, scanned: completed.length + canceled.length + skipped.length, completed, canceled, skipped };
334
+ }
335
+
193
336
  // #889 — finalize bound tasks for a reaped agent whose doneWhen is "agent-exit" / "reaped".
194
337
  // Called by the transient reaper after requesting shutdown so ephemeral run-and-exit workers
195
338
  // don't leave their auto-scaffolded Task stuck in_progress forever.
@@ -208,8 +351,10 @@ export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnReque
208
351
  if (!isAgentExitDoneWhen(task.doneWhen)) continue;
209
352
  if (opts.producedWork === false) {
210
353
  try {
211
- setTaskEntityStatus(taskId, { status: "open", note: "worker reaped without producing work; task reopened for retry", actor: "task-lifecycle" });
354
+ if (!task.reapedAt) markTaskAgentReaped(taskId, { agentId, reason: opts.reason ?? "agent-exit" });
355
+ setTaskEntityStatus(taskId, { status: "canceled", note: "worker reaped without landing or producing work; task canceled", actor: "task-lifecycle" });
212
356
  notifyEmptyTransientReap(agentId, task, opts);
357
+ finalized.push(taskId);
213
358
  } catch {
214
359
  // best-effort; failures must not surface into the reaper scan
215
360
  }
@@ -247,7 +392,7 @@ function notifyEmptyTransientReap(agentId: string, task: TaskDetail, opts: { spa
247
392
  recipients,
248
393
  message: {
249
394
  subject: "Worker reaped without producing work",
250
- body: `Worker \`${agentId}\` was reaped without producing work for Task #${task.id} (${task.title}). The task was reopened for retry instead of being marked done.`,
395
+ body: `Worker \`${agentId}\` was reaped without producing work for Task #${task.id} (${task.title}). The task was canceled instead of being marked done.`,
251
396
  payload,
252
397
  idempotencyKey: `task:${task.id}:empty-reap:${agentId}:${opts.reason ?? "agent-exit"}`,
253
398
  replyExpected: false,
@@ -30,13 +30,13 @@ import {
30
30
  getTaskDetail,
31
31
  markTaskAgentReaped,
32
32
  } from "../db";
33
- import { setTaskEntityStage } from "./task-entity";
33
+ import { setTaskEntityStage, setTaskEntityStatus } from "./task-entity";
34
34
  import { postTaskMilestoneComment, taskCloseBridge, type TaskCloseBridgeEntry } from "./task-issue-weave";
35
35
  import { requestTransientAgentShutdown } from "./transient-agent-reaper";
36
36
  import { emitRelayEvent } from "../events";
37
37
  import { routeNotification } from "../notification-router";
38
38
  import { resolveCoordinatorAgentId } from "../steward-identity";
39
- import { stageSatisfiesDoneWhen } from "./task-done-when";
39
+ import { isAgentExitDoneWhen, stageSatisfiesDoneWhen } from "./task-done-when";
40
40
  import { emitTaskSemanticNotification, type TaskSemanticTransition } from "./task-semantic-events";
41
41
  import type { IssueRef, TaskDeliverableStage, TaskGateState, WorkspaceRecord } from "../types";
42
42
  import type { IssueTrackerAdapter } from "../issue-tracker";
@@ -253,6 +253,16 @@ function finalizeReap(outcomes: TaskLandOutcome[], reap: ReapResult, log: LogFn)
253
253
  if (reap.reaped && reap.agentId) {
254
254
  try {
255
255
  markTaskAgentReaped(outcome.taskId, { agentId: reap.agentId, reason: "deliverable-landed" });
256
+ const task = getTaskDetail(outcome.taskId);
257
+ if (task && task.status !== "done" && isAgentExitDoneWhen(task.doneWhen)) {
258
+ setTaskEntityStatus(outcome.taskId, {
259
+ status: "done",
260
+ note: "doneWhen=agent-exit satisfied by worker reap after land",
261
+ actor: "task-lifecycle",
262
+ });
263
+ outcome.doneWhenSatisfied = true;
264
+ outcome.statusAdvanced = true;
265
+ }
256
266
  } catch (error) {
257
267
  log(`task reap marker failed for task ${outcome.taskId}: ${errMessage(error)}`);
258
268
  }