@dorokuma/herdsman 0.12.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +6 -13
  2. package/dist/src/cli/herdsman.d.ts +14 -1
  3. package/dist/src/cli/herdsman.d.ts.map +1 -1
  4. package/dist/src/cli/herdsman.js +49 -65
  5. package/dist/src/cli/herdsman.js.map +1 -1
  6. package/dist/src/daemon/agent-event-reconciler.d.ts.map +1 -1
  7. package/dist/src/daemon/agent-event-reconciler.js +53 -2
  8. package/dist/src/daemon/agent-event-reconciler.js.map +1 -1
  9. package/dist/src/daemon/herdr-session-watch-manager.d.ts +13 -1
  10. package/dist/src/daemon/herdr-session-watch-manager.d.ts.map +1 -1
  11. package/dist/src/daemon/herdr-session-watch-manager.js +52 -16
  12. package/dist/src/daemon/herdr-session-watch-manager.js.map +1 -1
  13. package/dist/src/daemon/observability-server.d.ts.map +1 -1
  14. package/dist/src/daemon/observability-server.js +16 -1
  15. package/dist/src/daemon/observability-server.js.map +1 -1
  16. package/dist/src/daemon/process-manager.d.ts +50 -49
  17. package/dist/src/daemon/process-manager.d.ts.map +1 -1
  18. package/dist/src/daemon/process-manager.js +246 -204
  19. package/dist/src/daemon/process-manager.js.map +1 -1
  20. package/dist/src/daemon/service-supervision.d.ts +68 -0
  21. package/dist/src/daemon/service-supervision.d.ts.map +1 -0
  22. package/dist/src/daemon/service-supervision.js +127 -0
  23. package/dist/src/daemon/service-supervision.js.map +1 -0
  24. package/dist/src/daemon/service.d.ts +35 -0
  25. package/dist/src/daemon/service.d.ts.map +1 -1
  26. package/dist/src/daemon/service.js +185 -27
  27. package/dist/src/daemon/service.js.map +1 -1
  28. package/dist/src/db/agent-events.d.ts +15 -0
  29. package/dist/src/db/agent-events.d.ts.map +1 -1
  30. package/dist/src/db/agent-events.js +180 -54
  31. package/dist/src/db/agent-events.js.map +1 -1
  32. package/dist/src/db/agents.js +1 -1
  33. package/dist/src/db/agents.js.map +1 -1
  34. package/dist/src/db/status-event-plans.d.ts +1 -1
  35. package/dist/src/db/status-event-plans.d.ts.map +1 -1
  36. package/dist/src/db/status-event-plans.js +5 -4
  37. package/dist/src/db/status-event-plans.js.map +1 -1
  38. package/dist/src/herdr/session-list.d.ts +11 -0
  39. package/dist/src/herdr/session-list.d.ts.map +1 -1
  40. package/dist/src/herdr/session-list.js +13 -0
  41. package/dist/src/herdr/session-list.js.map +1 -1
  42. package/dist/src/herdr/socket-client.d.ts +8 -0
  43. package/dist/src/herdr/socket-client.d.ts.map +1 -1
  44. package/dist/src/herdr/socket-client.js +43 -0
  45. package/dist/src/herdr/socket-client.js.map +1 -1
  46. package/dist/src/observability/agent-index-service.d.ts +15 -0
  47. package/dist/src/observability/agent-index-service.d.ts.map +1 -1
  48. package/dist/src/observability/agent-index-service.js +499 -135
  49. package/dist/src/observability/agent-index-service.js.map +1 -1
  50. package/dist/src/observability/agent-orchestrator-service.d.ts.map +1 -1
  51. package/dist/src/observability/agent-orchestrator-service.js +5 -1
  52. package/dist/src/observability/agent-orchestrator-service.js.map +1 -1
  53. package/dist/src/observability/contracts.d.ts +1 -0
  54. package/dist/src/observability/contracts.d.ts.map +1 -1
  55. package/dist/src/observability/contracts.js +8 -0
  56. package/dist/src/observability/contracts.js.map +1 -1
  57. package/drizzle/0010_legacy_degraded_cleanup.sql +1 -0
  58. package/drizzle/meta/0010_snapshot.json +975 -0
  59. package/drizzle/meta/_journal.json +7 -0
  60. package/package.json +3 -6
  61. package/README.ja.md +0 -153
@@ -6,7 +6,7 @@ import { runSqliteTransaction } from "../db/client.js";
6
6
  import { normalizeHerdrSessionSnapshot } from "../herdr/session-snapshot.js";
7
7
  import { HerdrSocketClient } from "../herdr/socket-client.js";
8
8
  import { AgentContextService } from "../observability/agent-context-service.js";
9
- import { parseAgentStatus, } from "../observability/contracts.js";
9
+ import { parseAgentStatus, VALID_AGENT_EVENT_TYPES, } from "../observability/contracts.js";
10
10
  import { TURN_SIGNAL_WAIT_MS } from "../observability/turn-completion.js";
11
11
  export function historyHasAdvanced(current, baseline, options) {
12
12
  if (current.lastAssistantMessage?.ref &&
@@ -33,6 +33,17 @@ export class PlanWaitingHistoryError extends Error {
33
33
  this.name = "PlanWaitingHistoryError";
34
34
  }
35
35
  }
36
+ /**
37
+ * Invariant for the waiting-history retry ring: only a genuine
38
+ * `PlanWaitingHistoryError` may (re)arm the 10s timer. It is the single signal
39
+ * that the agent history legitimately has not advanced yet. Synthetic retry
40
+ * reasons such as `degraded` are still recorded on the row, but they never
41
+ * authorize another spin: the pending row is drained by the next refresh
42
+ * instead, so a degraded round cannot keep the ring alive on its own.
43
+ */
44
+ function retryRingAuthorized(error) {
45
+ return error instanceof PlanWaitingHistoryError;
46
+ }
36
47
  /**
37
48
  * Sentinel returned by #appendStatusEvents when the plan must be CANCELLED
38
49
  * rather than skipped (skip still maps to completed in #runPlanRow). The agent
@@ -51,18 +62,23 @@ export class AgentIndexService {
51
62
  #refreshInFlightBySession = new Map();
52
63
  #scheduleRetry;
53
64
  #sessionOperationTail = new Map();
65
+ #shutdownSignal;
54
66
  #sleep;
55
67
  #turnCompletions;
56
68
  #waitingHistoryTimers = new Map();
57
69
  #now;
70
+ #onAgentEvent;
58
71
  constructor(options) {
59
72
  this.#clearRetry = options.clearRetry ?? ((timer) => clearTimeout(timer));
60
73
  this.#clientFactory = options.clientFactory ?? ((input) => new HerdrSocketClient(input));
61
74
  this.#scheduleRetry = options.scheduleRetry ?? ((cb, delay) => setTimeout(cb, delay));
62
75
  this.#sleep = options.sleep ?? sleep;
76
+ this.#shutdownSignal = options.shutdownSignal;
77
+ this.#shutdownSignal?.addEventListener("abort", () => this.abortPendingWaits(), { once: true });
63
78
  this.#now = options.now ?? (() => Date.now());
64
79
  this.#stores = options.stores;
65
80
  this.#turnCompletions = options.turnCompletions;
81
+ this.#onAgentEvent = options.onAgentEvent ?? (() => { });
66
82
  if (options.context) {
67
83
  this.#context = options.context;
68
84
  }
@@ -133,6 +149,21 @@ export class AgentIndexService {
133
149
  }
134
150
  this.#waitingHistoryTimers.clear();
135
151
  }
152
+ /**
153
+ * Aborts every in-flight status wait (history advance, turn completion,
154
+ * readiness polling). Called on shutdown through the shutdown signal; the
155
+ * waiters treat an abort exactly like a closed pane, so no event is appended
156
+ * for a half-observed transition and the plan row stays retryable.
157
+ */
158
+ abortPendingWaits() {
159
+ for (const [key, controllers] of this.#activeWaiters) {
160
+ for (const controller of controllers) {
161
+ controller.abort();
162
+ }
163
+ controllers.clear();
164
+ this.#activeWaiters.delete(key);
165
+ }
166
+ }
136
167
  async drainInFlightPlans() {
137
168
  const tails = Array.from(this.#planTailByAgent.values());
138
169
  await Promise.allSettled(tails);
@@ -159,7 +190,29 @@ export class AgentIndexService {
159
190
  await this.#enqueueAgentPlan(plan.agent.id, () => this.#retryWaitingPlanRow(planId, plan));
160
191
  }
161
192
  catch (error) {
162
- console.debug("Herdsman waiting history retry callback error", error);
193
+ // A failed retry callback used to be a silent `console.debug`; the row it
194
+ // was supposed to advance can then sit in `pending` forever with no
195
+ // signal at all, so the failure is reported as a warning with the row
196
+ // identity attached.
197
+ console.warn("Herdsman waiting history retry callback failed", {
198
+ planId,
199
+ agentId: plan.agent.id,
200
+ herdrSessionName: plan.agent.herdrSessionName,
201
+ paneId: plan.agent.paneId,
202
+ error,
203
+ });
204
+ }
205
+ finally {
206
+ const store = this.#stores.statusEventPlans;
207
+ if (store) {
208
+ const current = store.get(planId);
209
+ if (current.status === "completed" ||
210
+ current.status === "cancelled" ||
211
+ current.status === "failed" ||
212
+ current.status === "discarded") {
213
+ this.#clearWaitingTimer(planId);
214
+ }
215
+ }
163
216
  }
164
217
  }, 10_000);
165
218
  this.#waitingHistoryTimers.set(planId, timer);
@@ -195,13 +248,20 @@ export class AgentIndexService {
195
248
  agentId: plan.agent.id,
196
249
  error: err,
197
250
  });
198
- const updated = store.markRetry(planId, new PlanWaitingHistoryError());
251
+ const currentPlan = store.get(planId);
252
+ const retryError = currentPlan.lastError === "degraded"
253
+ ? new Error("degraded")
254
+ : new PlanWaitingHistoryError();
255
+ const updated = store.markRetry(planId, retryError);
199
256
  if (!updated) {
200
257
  this.#clearWaitingTimer(planId);
201
258
  return;
202
259
  }
203
260
  if (updated.status === "pending") {
204
- this.#scheduleWaitingHistoryRetry(planId, plan);
261
+ // Invariant: a synthetic retry reason never re-arms the ring (see
262
+ // retryRingAuthorized); the pending row waits for the next refresh.
263
+ if (retryRingAuthorized(retryError))
264
+ this.#scheduleWaitingHistoryRetry(planId, plan);
205
265
  }
206
266
  else if (updated.status === "discarded") {
207
267
  this.#clearWaitingTimer(planId);
@@ -218,6 +278,7 @@ export class AgentIndexService {
218
278
  attempts: updated.attempts,
219
279
  compactHistory: plan.compactHistory,
220
280
  from: plan.from,
281
+ planCreatedAt: currentPlan.createdAt.getTime(),
221
282
  planId,
222
283
  reason: updated.lastError,
223
284
  to: plan.to,
@@ -233,19 +294,26 @@ export class AgentIndexService {
233
294
  to: plan.to,
234
295
  herdrSessionName: plan.agent.herdrSessionName,
235
296
  });
236
- this.#appendPlanFailedEvent({
297
+ const failedEvent = this.#appendPlanFailedEvent({
237
298
  agent: plan.agent,
238
299
  attempts: updated.attempts,
239
300
  compactHistory: plan.compactHistory,
240
301
  from: plan.from,
302
+ planCreatedAt: currentPlan.createdAt.getTime(),
241
303
  planId,
242
304
  reason: updated.lastError,
243
305
  to: plan.to,
244
306
  });
307
+ if (VALID_AGENT_EVENT_TYPES.has(failedEvent.type)) {
308
+ this.#onAgentEvent?.(failedEvent);
309
+ }
245
310
  }
246
311
  return;
247
312
  }
248
- await this.#runPlanRow(current, retryPlan);
313
+ const event = await this.#runPlanRow(current, retryPlan);
314
+ if (event && VALID_AGENT_EVENT_TYPES.has(event.type)) {
315
+ this.#onAgentEvent?.(event);
316
+ }
249
317
  }
250
318
  async executeStatusEventPlan(plan) {
251
319
  if (plan.from === plan.to)
@@ -273,7 +341,14 @@ export class AgentIndexService {
273
341
  to: plan.to,
274
342
  ...(plan.compactHistory ? { compactHistory: plan.compactHistory } : {}),
275
343
  });
276
- return this.#enqueueAgentPlan(plan.agent.id, () => this.#runPlanRow(inserted, plan));
344
+ const result = await this.#enqueueAgentPlan(plan.agent.id, () => this.#runPlanRow(inserted, plan));
345
+ // Publication stays with the caller: every caller forwards this return value
346
+ // (`HerdrSessionWatchManager#submitPlan` -> `publishAgentEvent`, or the
347
+ // non-fast refresh/handle wrappers putting it into `events`). Pushing here as
348
+ // well would write the same `agent.event` twice on the socket, breaking the
349
+ // "exactly once" delivery contract. The retry-exhausted failed branch in
350
+ // `#retryWaitingPlanRow` keeps its own push because no caller sees its result.
351
+ return result;
277
352
  }
278
353
  async drainPendingPlans() {
279
354
  if (!this.#stores.statusEventPlans)
@@ -304,8 +379,18 @@ export class AgentIndexService {
304
379
  }
305
380
  });
306
381
  // Individual rows may reject (after markRetry) but the drain itself never
307
- // rejects: every row is either drained, cancelled, or retried.
308
- await Promise.allSettled(tasks);
382
+ // rejects: every row is either drained, cancelled, or retried. A rejected
383
+ // row is still an anomaly (its own drain path threw), so it is reported
384
+ // instead of being silently folded into the settled results.
385
+ const results = await Promise.allSettled(tasks);
386
+ const rejected = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
387
+ if (rejected.length > 0) {
388
+ console.error("Herdsman status event plan drain rejected rows", {
389
+ rejected: rejected.length,
390
+ total: results.length,
391
+ errors: rejected.map((reason) => reason instanceof Error ? reason.message : String(reason)),
392
+ });
393
+ }
309
394
  await this.#backfillFailedPlanEvents();
310
395
  await this.#backfillDiscardedPlanEvents();
311
396
  }
@@ -313,37 +398,62 @@ export class AgentIndexService {
313
398
  const store = this.#stores.statusEventPlans;
314
399
  if (!store)
315
400
  return;
401
+ let backfilled = 0;
402
+ let skipped = 0;
316
403
  for (const row of store.listFailed()) {
317
- if (row.lastError === PLAN_WAITING_HISTORY)
318
- continue;
319
- const existing = this.#stores.sqlite
320
- .prepare("select 1 from agent_events where herdr_session_name = ? and idempotency_key = ?")
321
- .get(row.herdrSessionName, `agent.failed:plan:${row.id}`);
322
- if (existing)
323
- continue;
324
- let agent;
404
+ // Per-row isolation: one unresolvable or unwritable row must not abort the
405
+ // whole backfill round. The legacy loop had no guard, so the first
406
+ // `FOREIGN KEY constraint failed` threw out of `drainPendingPlans` and no
407
+ // later row was ever attempted.
325
408
  try {
326
- agent = this.#stores.agents.get(row.agentId);
327
- }
328
- catch {
329
- agent = this.#minimalAgentFromLatestEvent(row.agentId, row.herdrSessionName);
409
+ if (row.lastError === PLAN_WAITING_HISTORY) {
410
+ skipped += 1;
411
+ continue;
412
+ }
413
+ const existing = this.#stores.sqlite
414
+ .prepare("select 1 from agent_events where herdr_session_name = ? and idempotency_key = ?")
415
+ .get(row.herdrSessionName, `agent.failed:plan:${row.id}`);
416
+ if (existing) {
417
+ skipped += 1;
418
+ continue;
419
+ }
420
+ const agent = this.#resolveBackfillAgent(row);
330
421
  if (!agent) {
331
- console.warn("Herdsman skipping agent.failed backfill; no agent row or events", {
332
- agentId: row.agentId,
333
- herdrSessionName: row.herdrSessionName,
334
- planId: row.id,
335
- });
422
+ skipped += 1;
336
423
  continue;
337
424
  }
425
+ const event = this.#appendPlanFailedEvent({
426
+ agent,
427
+ attempts: row.attempts,
428
+ compactHistory: row.compactHistory,
429
+ from: row.fromStatus,
430
+ planCreatedAt: row.createdAt.getTime(),
431
+ planId: row.id,
432
+ reason: row.lastError,
433
+ to: row.toStatus,
434
+ });
435
+ // Stock terminal results are ingested silently: the row is written, then
436
+ // immediately acknowledged/non-deliverable, so it stays queryable without
437
+ // waking the current owner with a long-past failure.
438
+ this.#stores.agentEvents.markAcked(event.id);
439
+ backfilled += 1;
338
440
  }
339
- this.#appendPlanFailedEvent({
340
- agent,
341
- attempts: row.attempts,
342
- compactHistory: row.compactHistory,
343
- from: row.fromStatus,
344
- planId: row.id,
345
- reason: row.lastError,
346
- to: row.toStatus,
441
+ catch (error) {
442
+ skipped += 1;
443
+ console.warn("Herdsman skipping agent.failed backfill row", {
444
+ agentId: row.agentId,
445
+ herdrSessionName: row.herdrSessionName,
446
+ paneId: row.paneId,
447
+ planId: row.id,
448
+ error: error instanceof Error ? error.message : String(error),
449
+ });
450
+ }
451
+ }
452
+ if (backfilled > 0 || skipped > 0) {
453
+ console.info("Herdsman backfilled agent.failed rows", {
454
+ backfilled,
455
+ skipped,
456
+ total: backfilled + skipped,
347
457
  });
348
458
  }
349
459
  }
@@ -351,43 +461,110 @@ export class AgentIndexService {
351
461
  const store = this.#stores.statusEventPlans;
352
462
  if (!store)
353
463
  return;
464
+ let backfilled = 0;
465
+ let skipped = 0;
354
466
  for (const row of store.listDiscarded()) {
355
- const existing = this.#stores.sqlite
356
- .prepare("select 1 from agent_events where herdr_session_name = ? and idempotency_key = ?")
357
- .get(row.herdrSessionName, `agent.discarded:plan:${row.id}`);
358
- if (existing)
359
- continue;
360
- let agent;
361
467
  try {
362
- agent = this.#stores.agents.get(row.agentId);
363
- }
364
- catch {
365
- agent = this.#minimalAgentFromLatestEvent(row.agentId, row.herdrSessionName);
468
+ const existing = this.#stores.sqlite
469
+ .prepare("select 1 from agent_events where herdr_session_name = ? and idempotency_key = ?")
470
+ .get(row.herdrSessionName, `agent.discarded:plan:${row.id}`);
471
+ if (existing) {
472
+ skipped += 1;
473
+ continue;
474
+ }
475
+ const agent = this.#resolveBackfillAgent(row);
366
476
  if (!agent) {
367
- console.warn("Herdsman skipping agent.discarded backfill; no agent row or events", {
368
- agentId: row.agentId,
369
- herdrSessionName: row.herdrSessionName,
370
- planId: row.id,
371
- });
477
+ skipped += 1;
372
478
  continue;
373
479
  }
480
+ const event = this.#appendPlanDiscardedEvent({
481
+ agent,
482
+ attempts: row.attempts,
483
+ compactHistory: row.compactHistory,
484
+ from: row.fromStatus,
485
+ planCreatedAt: row.createdAt.getTime(),
486
+ planId: row.id,
487
+ reason: row.lastError,
488
+ to: row.toStatus,
489
+ });
490
+ // Same silent-ingest contract as the failed backfill.
491
+ this.#stores.agentEvents.markAcked(event.id);
492
+ backfilled += 1;
374
493
  }
375
- this.#appendPlanDiscardedEvent({
376
- agent,
377
- attempts: row.attempts,
378
- compactHistory: row.compactHistory,
379
- from: row.fromStatus,
380
- planId: row.id,
381
- reason: row.lastError,
382
- to: row.toStatus,
494
+ catch (error) {
495
+ skipped += 1;
496
+ console.warn("Herdsman skipping agent.discarded backfill row", {
497
+ agentId: row.agentId,
498
+ herdrSessionName: row.herdrSessionName,
499
+ paneId: row.paneId,
500
+ planId: row.id,
501
+ error: error instanceof Error ? error.message : String(error),
502
+ });
503
+ }
504
+ }
505
+ if (backfilled > 0 || skipped > 0) {
506
+ console.info("Herdsman backfilled agent.discarded rows", {
507
+ backfilled,
508
+ skipped,
509
+ total: backfilled + skipped,
383
510
  });
384
511
  }
385
512
  }
386
- #minimalAgentFromLatestEvent(agentId, herdrSessionName) {
513
+ /**
514
+ * Resolves the metadata a terminal plan outcome may use to reach an
515
+ * orchestrator: the live `agents` row when it still exists, otherwise a
516
+ * minimal record stitched from the pane's own event history.
517
+ */
518
+ #resolveBackfillAgent(row) {
519
+ try {
520
+ return this.#stores.agents.get(row.agentId);
521
+ }
522
+ catch {
523
+ const stitched = this.#minimalAgentFromLatestEvent({
524
+ agentId: row.agentId,
525
+ createdBefore: row.createdAt.getTime(),
526
+ herdrSessionName: row.herdrSessionName,
527
+ paneGeneration: row.paneGeneration,
528
+ paneId: row.paneId,
529
+ });
530
+ if (!stitched) {
531
+ console.warn("Herdsman skipping terminal event backfill; no agent row or pane events", {
532
+ agentId: row.agentId,
533
+ herdrSessionName: row.herdrSessionName,
534
+ paneId: row.paneId,
535
+ planId: row.id,
536
+ });
537
+ }
538
+ return stitched;
539
+ }
540
+ }
541
+ /**
542
+ * Reconstructs the minimum agent metadata a terminal event needs from the
543
+ * pane's own event history.
544
+ *
545
+ * The lookup is keyed by `(herdr_session_name, pane_id)` plus the plan's
546
+ * `pane_generation`, and is bounded by `createdBefore` (the plan row's
547
+ * `created_at`). Matching on `agent_id` alone cannot work here: physical
548
+ * deletion of the `agents` row runs `ON DELETE SET NULL` over every event, so
549
+ * the legacy stitch always came back empty, and a pane-id-only match would
550
+ * happily stitch an old failure onto a newer instance of the same pane. The
551
+ * generation and time bounds are what keep the two apart.
552
+ */
553
+ #minimalAgentFromLatestEvent(input) {
554
+ const generationClause = input.paneGeneration === null ? "pane_generation is null" : "pane_generation = ?";
555
+ const params = [
556
+ input.herdrSessionName,
557
+ input.paneId,
558
+ input.createdBefore,
559
+ ];
560
+ if (input.paneGeneration !== null)
561
+ params.push(input.paneGeneration);
387
562
  const latest = this.#stores.sqlite
388
563
  .prepare(`select pane_id, pane_generation, terminal_id, workspace_id, herdr_session_name
389
- from agent_events where agent_id = ? order by id desc limit 1`)
390
- .get(agentId);
564
+ from agent_events
565
+ where herdr_session_name = ? and pane_id = ? and created_at <= ? and ${generationClause}
566
+ order by id desc limit 1`)
567
+ .get(...params);
391
568
  if (!latest?.pane_id || !latest.workspace_id)
392
569
  return undefined;
393
570
  return {
@@ -398,8 +575,8 @@ export class AgentIndexService {
398
575
  firstSeenAt: new Date(0),
399
576
  focused: false,
400
577
  foregroundCwd: null,
401
- herdrSessionName: latest.herdr_session_name || herdrSessionName,
402
- id: agentId,
578
+ herdrSessionName: latest.herdr_session_name || input.herdrSessionName,
579
+ id: input.agentId,
403
580
  lastSeenAt: new Date(0),
404
581
  name: null,
405
582
  paneId: latest.pane_id,
@@ -416,16 +593,18 @@ export class AgentIndexService {
416
593
  return;
417
594
  let current;
418
595
  try {
419
- current = store.get(row.id);
596
+ current = runSqliteTransaction(this.#stores.sqlite, () => {
597
+ const candidate = store.get(row.id);
598
+ if (candidate.status !== "pending")
599
+ return null;
600
+ return candidate;
601
+ });
420
602
  }
421
603
  catch {
422
604
  this.#clearWaitingTimer(row.id);
423
605
  return;
424
606
  }
425
- if (current.status === "completed" ||
426
- current.status === "cancelled" ||
427
- current.status === "failed" ||
428
- current.status === "discarded") {
607
+ if (!current) {
429
608
  this.#clearWaitingTimer(row.id);
430
609
  return;
431
610
  }
@@ -462,7 +641,8 @@ export class AgentIndexService {
462
641
  activeCompact = refreshed.snapshot.compactHistory;
463
642
  }
464
643
  catch (err) {
465
- const updated = store.markRetry(current.id, new PlanWaitingHistoryError());
644
+ const retryError = current.lastError === "degraded" ? new Error("degraded") : new PlanWaitingHistoryError();
645
+ const updated = store.markRetry(current.id, retryError);
466
646
  if (!updated) {
467
647
  this.#clearWaitingTimer(current.id);
468
648
  return;
@@ -473,13 +653,18 @@ export class AgentIndexService {
473
653
  agentId: agent.id,
474
654
  error: err,
475
655
  });
476
- this.#scheduleWaitingHistoryRetry(current.id, {
477
- agent,
478
- compactHistory: current.compactHistory,
479
- from: current.fromStatus,
480
- to: current.toStatus,
481
- ...(current.herdrEventKey ? { herdrEventKey: current.herdrEventKey } : {}),
482
- });
656
+ // Invariant: the ring is only re-armed by a genuine
657
+ // PlanWaitingHistoryError; a degraded row stays pending for the next
658
+ // refresh to drain instead of spinning this callback again.
659
+ if (retryRingAuthorized(retryError)) {
660
+ this.#scheduleWaitingHistoryRetry(current.id, {
661
+ agent,
662
+ compactHistory: current.compactHistory,
663
+ from: current.fromStatus,
664
+ to: current.toStatus,
665
+ ...(current.herdrEventKey ? { herdrEventKey: current.herdrEventKey } : {}),
666
+ });
667
+ }
483
668
  }
484
669
  else if (updated.status === "discarded") {
485
670
  this.#clearWaitingTimer(current.id);
@@ -495,6 +680,7 @@ export class AgentIndexService {
495
680
  attempts: updated.attempts,
496
681
  compactHistory: current.compactHistory,
497
682
  from: current.fromStatus,
683
+ planCreatedAt: current.createdAt.getTime(),
498
684
  planId: current.id,
499
685
  reason: updated.lastError,
500
686
  to: current.toStatus,
@@ -509,15 +695,19 @@ export class AgentIndexService {
509
695
  to: current.toStatus,
510
696
  reason: updated.lastError,
511
697
  });
512
- this.#appendPlanFailedEvent({
698
+ const failedEvent = this.#appendPlanFailedEvent({
513
699
  agent,
514
700
  attempts: updated.attempts,
515
701
  compactHistory: current.compactHistory,
516
702
  from: current.fromStatus,
703
+ planCreatedAt: current.createdAt.getTime(),
517
704
  planId: current.id,
518
705
  reason: updated.lastError,
519
706
  to: current.toStatus,
520
707
  });
708
+ if (VALID_AGENT_EVENT_TYPES.has(failedEvent.type)) {
709
+ this.#onAgentEvent?.(failedEvent);
710
+ }
521
711
  }
522
712
  return;
523
713
  }
@@ -529,7 +719,10 @@ export class AgentIndexService {
529
719
  to: current.toStatus,
530
720
  ...(current.herdrEventKey ? { herdrEventKey: current.herdrEventKey } : {}),
531
721
  };
532
- await this.#runPlanRow(current, plan);
722
+ const event = await this.#runPlanRow(current, plan);
723
+ if (event && VALID_AGENT_EVENT_TYPES.has(event.type)) {
724
+ this.#onAgentEvent?.(event);
725
+ }
533
726
  }
534
727
  #enqueueAgentPlan(agentId, operation) {
535
728
  const previous = this.#planTailByAgent.get(agentId) ?? Promise.resolve();
@@ -555,10 +748,16 @@ export class AgentIndexService {
555
748
  this.#clearWaitingTimer(row.id);
556
749
  return undefined;
557
750
  }
558
- store.markRunning(row.id);
751
+ if (!store.markRunning(row.id)) {
752
+ this.#clearWaitingTimer(row.id);
753
+ return undefined;
754
+ }
559
755
  let activePlan = {
560
756
  ...plan,
561
757
  attempts: row.attempts,
758
+ // Row correlation for observability (see `#logDegradedRelease`): the plan
759
+ // id is only known here, where the store row is in hand.
760
+ planId: row.id,
562
761
  };
563
762
  if (row.attempts > 0) {
564
763
  try {
@@ -571,6 +770,7 @@ export class AgentIndexService {
571
770
  ...plan,
572
771
  attempts: row.attempts,
573
772
  compactHistory: refreshed.snapshot.compactHistory,
773
+ planId: row.id,
574
774
  };
575
775
  }
576
776
  catch (err) {
@@ -579,13 +779,22 @@ export class AgentIndexService {
579
779
  agentId: plan.agent.id,
580
780
  error: err,
581
781
  });
582
- const updated = store.markRetry(row.id, new PlanWaitingHistoryError());
782
+ // Keep the degraded reason when the plan was already degraded by an
783
+ // earlier round: markRetry decides discarded (silent) vs failed (wakes)
784
+ // from this message, so passing PLAN_WAITING_HISTORY here would let a
785
+ // degraded plan exhaust silently.
786
+ const retryError = row.lastError === "degraded" ? new Error("degraded") : new PlanWaitingHistoryError();
787
+ const updated = store.markRetry(row.id, retryError);
583
788
  if (!updated) {
584
789
  this.#clearWaitingTimer(row.id);
585
790
  return undefined;
586
791
  }
587
792
  if (updated.status === "pending") {
588
- this.#scheduleWaitingHistoryRetry(row.id, plan);
793
+ // Invariant: only a genuine PlanWaitingHistoryError authorizes the
794
+ // ring; a degraded pre-refresh round records the synthetic reason and
795
+ // waits for the next refresh instead.
796
+ if (retryRingAuthorized(retryError))
797
+ this.#scheduleWaitingHistoryRetry(row.id, plan);
589
798
  }
590
799
  else {
591
800
  this.#clearWaitingTimer(row.id);
@@ -603,6 +812,7 @@ export class AgentIndexService {
603
812
  attempts: updated.attempts,
604
813
  compactHistory: plan.compactHistory,
605
814
  from: row.fromStatus,
815
+ planCreatedAt: row.createdAt.getTime(),
606
816
  planId: row.id,
607
817
  reason: updated.lastError,
608
818
  to: row.toStatus,
@@ -622,6 +832,7 @@ export class AgentIndexService {
622
832
  attempts: updated.attempts,
623
833
  compactHistory: plan.compactHistory,
624
834
  from: row.fromStatus,
835
+ planCreatedAt: row.createdAt.getTime(),
625
836
  planId: row.id,
626
837
  reason: updated.lastError,
627
838
  to: row.toStatus,
@@ -655,12 +866,22 @@ export class AgentIndexService {
655
866
  eventPayload !== null &&
656
867
  eventPayload.degraded === true;
657
868
  if (isDegraded) {
658
- const updated = store.markRetry(row.id, new Error("degraded"));
869
+ if (event) {
870
+ this.#stores.agentEvents.invalidateById(event.id, "degraded_retry");
871
+ Object.assign(event, { status: "invalidated", deliverable: 0 });
872
+ }
873
+ const degradedError = new Error("degraded");
874
+ const updated = store.markRetry(row.id, degradedError);
659
875
  if (!updated) {
660
- return event;
876
+ return undefined;
661
877
  }
662
878
  if (updated.status === "pending") {
663
- this.#scheduleWaitingHistoryRetry(row.id, plan);
879
+ // Invariant: `degraded` is a synthetic reason, so it never authorizes
880
+ // the retry ring; the row stays pending and the next refresh
881
+ // re-drains it.
882
+ if (retryRingAuthorized(degradedError))
883
+ this.#scheduleWaitingHistoryRetry(row.id, plan);
884
+ return undefined;
664
885
  }
665
886
  else {
666
887
  this.#clearWaitingTimer(row.id);
@@ -670,6 +891,7 @@ export class AgentIndexService {
670
891
  attempts: updated.attempts,
671
892
  compactHistory: activePlan.compactHistory,
672
893
  from: row.fromStatus,
894
+ planCreatedAt: row.createdAt.getTime(),
673
895
  planId: row.id,
674
896
  reason: updated.lastError,
675
897
  to: row.toStatus,
@@ -681,26 +903,34 @@ export class AgentIndexService {
681
903
  attempts: updated.attempts,
682
904
  compactHistory: activePlan.compactHistory,
683
905
  from: row.fromStatus,
906
+ planCreatedAt: row.createdAt.getTime(),
684
907
  planId: row.id,
685
908
  reason: updated.lastError,
686
909
  to: row.toStatus,
687
910
  });
688
911
  }
689
912
  }
690
- return event;
913
+ return undefined;
691
914
  }
692
915
  store.markCompleted(row.id);
693
916
  return event;
694
917
  }
695
918
  catch (error) {
696
919
  if (error instanceof PlanWaitingHistoryError) {
697
- const updated = store.markRetry(row.id, error);
920
+ const retryError = row.lastError === "degraded" ? new Error("degraded") : error;
921
+ const updated = store.markRetry(row.id, retryError);
698
922
  if (!updated) {
699
923
  this.#clearWaitingTimer(row.id);
700
924
  return undefined;
701
925
  }
702
926
  if (updated.status === "pending") {
703
- this.#scheduleWaitingHistoryRetry(row.id, plan);
927
+ // Invariant: only a genuine PlanWaitingHistoryError authorizes the
928
+ // ring. A row that already carries the synthetic `degraded` reason is
929
+ // recorded as degraded again and waits for the next refresh instead of
930
+ // re-arming the 10s timer, so this catch cannot be used as a back door
931
+ // that lets a degraded round keep the ring alive.
932
+ if (retryRingAuthorized(retryError))
933
+ this.#scheduleWaitingHistoryRetry(row.id, plan);
704
934
  }
705
935
  else {
706
936
  this.#clearWaitingTimer(row.id);
@@ -718,6 +948,7 @@ export class AgentIndexService {
718
948
  attempts: updated.attempts,
719
949
  compactHistory: activePlan.compactHistory,
720
950
  from: row.fromStatus,
951
+ planCreatedAt: row.createdAt.getTime(),
721
952
  planId: row.id,
722
953
  reason: updated.lastError,
723
954
  to: row.toStatus,
@@ -737,6 +968,7 @@ export class AgentIndexService {
737
968
  attempts: updated.attempts,
738
969
  compactHistory: activePlan.compactHistory,
739
970
  from: row.fromStatus,
971
+ planCreatedAt: row.createdAt.getTime(),
740
972
  planId: row.id,
741
973
  reason: updated.lastError,
742
974
  to: row.toStatus,
@@ -759,10 +991,15 @@ export class AgentIndexService {
759
991
  return undefined;
760
992
  }
761
993
  this.#clearWaitingTimer(row.id);
762
- const updated = store.markRetry(row.id, error);
994
+ const retryError = row.lastError === "degraded" ? new Error("degraded") : error;
995
+ const updated = store.markRetry(row.id, retryError);
763
996
  if (!updated) {
764
997
  return undefined;
765
998
  }
999
+ // Invariant: only a genuine PlanWaitingHistoryError re-arms the retry ring,
1000
+ // and that case was handled above. On this generic path `retryError` is
1001
+ // either the synthetic `degraded` reason or an unrelated failure, so no
1002
+ // spin is scheduled: a still-pending row is drained by the next refresh.
766
1003
  if (updated.status === "discarded") {
767
1004
  console.info("Herdsman status event plan discarded after max attempts", {
768
1005
  planId: row.id,
@@ -777,6 +1014,7 @@ export class AgentIndexService {
777
1014
  attempts: updated.attempts,
778
1015
  compactHistory: activePlan.compactHistory,
779
1016
  from: row.fromStatus,
1017
+ planCreatedAt: row.createdAt.getTime(),
780
1018
  planId: row.id,
781
1019
  reason: updated.lastError,
782
1020
  to: row.toStatus,
@@ -796,6 +1034,7 @@ export class AgentIndexService {
796
1034
  attempts: updated.attempts,
797
1035
  compactHistory: activePlan.compactHistory,
798
1036
  from: row.fromStatus,
1037
+ planCreatedAt: row.createdAt.getTime(),
799
1038
  planId: row.id,
800
1039
  reason: updated.lastError,
801
1040
  to: row.toStatus,
@@ -806,10 +1045,12 @@ export class AgentIndexService {
806
1045
  }
807
1046
  async #waitForHistoryAdvance(input) {
808
1047
  const maxAttempts = input.maxAttempts ?? 8;
809
- const baseDelay = 500;
810
- const maxDelay = 16000;
811
- const maxTotalMs = 30000;
812
- let delay = baseDelay;
1048
+ // Fixed 200ms interval, no exponential backoff: the history cache either
1049
+ // advances shortly after the turn signal or it does not, and a doubling
1050
+ // delay only pushed the first deliverable terminal event tens of seconds
1051
+ // away. The whole window is hard bounded by maxTotalMs.
1052
+ const baseDelay = 200;
1053
+ const maxTotalMs = 1500;
813
1054
  let refreshed = input.initial
814
1055
  ? { snapshot: { compactHistory: input.initial } }
815
1056
  : await this.#context.refreshAgent({
@@ -824,7 +1065,13 @@ export class AgentIndexService {
824
1065
  }) &&
825
1066
  !input.controller.signal.aborted &&
826
1067
  this.#now() - start < maxTotalMs; attempt += 1) {
827
- await this.#sleep(delay, input.controller.signal);
1068
+ // Clamp the last step to the remaining budget so the total wait never
1069
+ // exceeds maxTotalMs even though the loop condition is checked before
1070
+ // sleeping.
1071
+ const remaining = maxTotalMs - (this.#now() - start);
1072
+ if (remaining <= 0)
1073
+ break;
1074
+ await this.#sleep(Math.min(baseDelay, remaining), input.controller.signal);
828
1075
  if (input.controller.signal.aborted) {
829
1076
  console.debug("Herdsman retrying status event plan because wait was aborted during history refresh", {
830
1077
  agentId: input.agent.id,
@@ -850,7 +1097,6 @@ export class AgentIndexService {
850
1097
  forceRefresh: true,
851
1098
  identityChanged: false,
852
1099
  });
853
- delay = Math.min(delay * 2, maxDelay);
854
1100
  }
855
1101
  return refreshed.snapshot.compactHistory;
856
1102
  }
@@ -1205,8 +1451,23 @@ export class AgentIndexService {
1205
1451
  paneGeneration: paneGenerationOf(agent),
1206
1452
  });
1207
1453
  }
1454
+ /**
1455
+ * Distinguishes the two reasons an in-flight wait can be aborted. A closed pane
1456
+ * makes the transition moot, so the plan is skipped; a daemon shutdown leaves
1457
+ * the pane alive, so the plan must stay retryable instead of being completed
1458
+ * without ever emitting the transition (it is drained on the next start).
1459
+ */
1460
+ #throwIfShutdownAbort(controller) {
1461
+ if (controller.signal.aborted && this.#shutdownSignal?.aborted) {
1462
+ throw new PlanWaitingHistoryError();
1463
+ }
1464
+ }
1208
1465
  #registerActiveWaiter(agent, controller) {
1209
1466
  const key = `${agent.herdrSessionName}\0${agent.paneId}\0${agent.paneGeneration ?? ""}`;
1467
+ // A wait that starts after shutdown already aborted must not sleep at all:
1468
+ // abort it up front so the caller short-circuits like a closed pane.
1469
+ if (this.#shutdownSignal?.aborted)
1470
+ controller.abort();
1210
1471
  let set = this.#activeWaiters.get(key);
1211
1472
  if (!set) {
1212
1473
  set = new Set();
@@ -1234,10 +1495,36 @@ export class AgentIndexService {
1234
1495
  }
1235
1496
  }
1236
1497
  }
1498
+ /**
1499
+ * Observability for a degraded release. The emitted status event carries the
1500
+ * specific `degradedReason`, but the plan row itself only stores the synthetic
1501
+ * `degraded` marker (`row.lastError`), so without this line the cause is
1502
+ * invisible in the daemon log. It cannot ride along in the failed-event payload
1503
+ * either: for most append sites only `row.lastError` is at hand, so handing them
1504
+ * the specific reason would mean persisting a new plan field (schema change).
1505
+ * That is not universal — at least three call sites in this file already have the
1506
+ * specific reason in their own call stack (`:1074` the status event payload's
1507
+ * `degradedReason`, `:1136` the `PlanWaitingHistoryError`, `:1203` the generic
1508
+ * `Error`), so routing the reason into the payload does not necessarily require a
1509
+ * schema change. Log-only on purpose — no behaviour change (see
1510
+ * .agents/notes/20260930-terminal-event-delivery-open-items.md A1).
1511
+ *
1512
+ * `plan.planId` is the row correlation that already exists on
1513
+ * `StatusEventPlan` (see `executeStatusEventPlan`); it is `null` for the
1514
+ * row-less in-memory plans executed without the SQLite store.
1515
+ */
1516
+ #logDegradedRelease(plan, degradedReason) {
1517
+ console.warn("Herdsman emitted degraded status event", {
1518
+ agentId: plan.agent.id,
1519
+ attempts: plan.attempts ?? 0,
1520
+ degradedReason,
1521
+ herdrSessionName: plan.agent.herdrSessionName,
1522
+ paneId: plan.agent.paneId,
1523
+ planId: plan.planId ?? null,
1524
+ });
1525
+ }
1237
1526
  async #appendStatusEvents(input) {
1238
1527
  // Invariant: compactHistory is always populated as an object before calling #appendStatusEvents; !input.compactHistory check below is defensive and unreachable in normal operation.
1239
- if (input.from === input.to || !input.compactHistory)
1240
- return undefined;
1241
1528
  if (this.#stores.agents.isPaneClosed({
1242
1529
  herdrSessionName: input.agent.herdrSessionName,
1243
1530
  paneId: input.agent.paneId,
@@ -1266,7 +1553,7 @@ export class AgentIndexService {
1266
1553
  skipStatusChanged = true;
1267
1554
  }
1268
1555
  if (hasTerminalAfter && !input.herdrEventKey) {
1269
- const isAgyHistory = input.agent.agent === "agy" || input.compactHistory.source === "antigravity-sqlite";
1556
+ const isAgyHistory = input.agent.agent === "agy" || input.compactHistory?.source === "antigravity-sqlite";
1270
1557
  // For an agy/antigravity pane only a terminal event that already
1271
1558
  // represented a completed turn may count as the delivered baseline: an
1272
1559
  // `unknown -> idle` startup row would otherwise make the `working -> done`
@@ -1325,6 +1612,7 @@ export class AgentIndexService {
1325
1612
  herdrSessionName: input.agent.herdrSessionName,
1326
1613
  paneId: input.agent.paneId,
1327
1614
  });
1615
+ this.#throwIfShutdownAbort(controller);
1328
1616
  return undefined;
1329
1617
  }
1330
1618
  if (turn?.received) {
@@ -1349,7 +1637,40 @@ export class AgentIndexService {
1349
1637
  const expectedText = turn.expectedText?.trim();
1350
1638
  const lastAssistantText = fresh.lastAssistantMessage?.text?.trim() ?? "";
1351
1639
  const textMatches = !expectedText || lastAssistantText.endsWith(expectedText);
1352
- if (!isAlreadyEmittedInRetry &&
1640
+ // A client-confirmed turn is a deliverable terminal state: `confirmed`
1641
+ // means Pi verified the final assistant message is on disk, so a
1642
+ // confirmed round is delivered straight through and is never marked
1643
+ // degraded (which is what made #runPlanRow run
1644
+ // invalidateById(..., "degraded_retry") on already-written content).
1645
+ const confirmedTerminal = turn.confirmed === true;
1646
+ const degradeOrRelease = (degradedReason) => {
1647
+ if (confirmedTerminal) {
1648
+ // A confirmed round stays non-degraded (see above), but the reason
1649
+ // is no longer swallowed: this path used to release silently with
1650
+ // an emptied body, so an empty `agent.done` could only be
1651
+ // diagnosed by reading the pane by hand. `degraded: true` must
1652
+ // stay off here — #runPlanRow would `invalidateById(...,
1653
+ // "degraded_retry")` the row it has already written.
1654
+ console.warn("Herdsman released a confirmed pi status event with no deliverable text", {
1655
+ agentId: input.agent.id,
1656
+ degradedReason,
1657
+ herdrSessionName: input.agent.herdrSessionName,
1658
+ paneId: input.agent.paneId,
1659
+ planId: input.planId ?? null,
1660
+ terminalId: input.agent.terminalId,
1661
+ });
1662
+ return { staleSnapshot: false };
1663
+ }
1664
+ this.#logDegradedRelease(input, degradedReason);
1665
+ return { degraded: true, degradedReason, staleSnapshot: false };
1666
+ };
1667
+ if (confirmedTerminal &&
1668
+ freshIsTerminal &&
1669
+ textMatches &&
1670
+ hasNonEmptyAssistantMessage(fresh)) {
1671
+ compactHistory = fresh;
1672
+ }
1673
+ else if (!isAlreadyEmittedInRetry &&
1353
1674
  freshIsTerminal &&
1354
1675
  textMatches &&
1355
1676
  hasNonEmptyAssistantMessage(fresh) &&
@@ -1372,33 +1693,49 @@ export class AgentIndexService {
1372
1693
  !historyHasAdvanced(advanced, baseline, { requireAssistantChange: true })) {
1373
1694
  throw new PlanWaitingHistoryError();
1374
1695
  }
1696
+ // `historyHasAdvanced(..., { requireAssistantChange: true })` compares
1697
+ // the assistant ref/text against the plan baseline. That baseline can
1698
+ // hold this round's answer (a plan row's `compact_history_json` is
1699
+ // written once at plan creation and never updated, and creation
1700
+ // measured 131ms/308ms after the final assistant message reached
1701
+ // disk). It can also hold the answer of a *previous* round — the same
1702
+ // frozen baseline is exactly what the daemon last delivered — so a
1703
+ // round that writes nothing new would otherwise re-release the
1704
+ // previous round's body as if it were this one's. `latestTerminal` is
1705
+ // the latest non-invalidated terminal row read before this round's row
1706
+ // is written: usually the last delivery, possibly an undelivered idle.
1707
+ const staleBaselineDuplicate = sameTerminalAssistantContent(advanced, latestTerminal?.compactHistory, "pi");
1708
+ // A confirmed turn carrying a non-empty terminal assistant message is
1709
+ // trusted as-is, so `no_advance_from_input` stays reserved for rounds
1710
+ // with genuinely no deliverable assistant text (empty or non-terminal
1711
+ // tail).
1712
+ const confirmedDeliverable = confirmedTerminal &&
1713
+ isTerminalAssistant(advanced) &&
1714
+ hasNonEmptyAssistantMessage(advanced) &&
1715
+ !staleBaselineDuplicate;
1375
1716
  if (!isRetry &&
1376
1717
  !historyHasAdvanced(advanced, input.compactHistory, { requireAssistantChange: true })) {
1377
- compactHistory = { ...advanced, lastAssistantMessage: null };
1378
- payloadExtra = {
1379
- degraded: true,
1380
- degradedReason: "no_advance_from_input",
1381
- staleSnapshot: false,
1382
- };
1718
+ if (confirmedDeliverable) {
1719
+ // Trust the already-in-place answer: release it as a non-stale
1720
+ // snapshot and without a `degraded` marker.
1721
+ compactHistory = advanced;
1722
+ payloadExtra = { staleSnapshot: false };
1723
+ }
1724
+ else {
1725
+ compactHistory = { ...advanced, lastAssistantMessage: null };
1726
+ payloadExtra = degradeOrRelease(staleBaselineDuplicate ? "stale_baseline_duplicate" : "no_advance_from_input");
1727
+ }
1383
1728
  }
1384
1729
  else {
1385
1730
  const advancedText = advanced.lastAssistantMessage?.text?.trim() ?? "";
1386
1731
  const advancedMatchesExpected = !expectedText || advancedText.endsWith(expectedText);
1387
1732
  if (!advancedMatchesExpected) {
1388
1733
  compactHistory = { ...advanced, lastAssistantMessage: null };
1389
- payloadExtra = {
1390
- degraded: true,
1391
- degradedReason: "expected_text_mismatch",
1392
- staleSnapshot: false,
1393
- };
1734
+ payloadExtra = degradeOrRelease("expected_text_mismatch");
1394
1735
  }
1395
- else if (!isTerminalAssistant(advanced)) {
1736
+ else if (!isTerminalAssistant(advanced) || !hasNonEmptyAssistantMessage(advanced)) {
1396
1737
  compactHistory = { ...advanced, lastAssistantMessage: null };
1397
- payloadExtra = {
1398
- degraded: true,
1399
- degradedReason: "non_terminal_assistant",
1400
- staleSnapshot: false,
1401
- };
1738
+ payloadExtra = degradeOrRelease("non_terminal_assistant");
1402
1739
  }
1403
1740
  else {
1404
1741
  compactHistory = advanced;
@@ -1428,14 +1765,16 @@ export class AgentIndexService {
1428
1765
  if (!isRetry &&
1429
1766
  !historyHasAdvanced(advanced, input.compactHistory, { requireAssistantChange: true })) {
1430
1767
  compactHistory = { ...advanced, lastAssistantMessage: null };
1768
+ this.#logDegradedRelease(input, "no_advance_from_input");
1431
1769
  payloadExtra = {
1432
1770
  degraded: true,
1433
1771
  degradedReason: "no_advance_from_input",
1434
1772
  staleSnapshot: false,
1435
1773
  };
1436
1774
  }
1437
- else if (!isTerminalAssistant(advanced)) {
1775
+ else if (!isTerminalAssistant(advanced) || !hasNonEmptyAssistantMessage(advanced)) {
1438
1776
  compactHistory = { ...advanced, lastAssistantMessage: null };
1777
+ this.#logDegradedRelease(input, "non_terminal_assistant");
1439
1778
  payloadExtra = {
1440
1779
  degraded: true,
1441
1780
  degradedReason: "non_terminal_assistant",
@@ -1493,6 +1832,7 @@ export class AgentIndexService {
1493
1832
  compactHistory?.source !== "antigravity-sqlite" &&
1494
1833
  input.herdrEventKey &&
1495
1834
  latestTerminalForSkip &&
1835
+ compactHistory &&
1496
1836
  hasNonEmptyAssistantMessage(compactHistory)) {
1497
1837
  const currentMsg = compactHistory.lastAssistantMessage;
1498
1838
  const prevMsg = latestTerminalForSkip.compactHistory?.lastAssistantMessage;
@@ -1518,6 +1858,7 @@ export class AgentIndexService {
1518
1858
  herdrSessionName: input.agent.herdrSessionName,
1519
1859
  paneId: input.agent.paneId,
1520
1860
  });
1861
+ this.#throwIfShutdownAbort(controller);
1521
1862
  return undefined;
1522
1863
  }
1523
1864
  try {
@@ -1562,6 +1903,7 @@ export class AgentIndexService {
1562
1903
  herdrSessionName: input.agent.herdrSessionName,
1563
1904
  paneId: input.agent.paneId,
1564
1905
  });
1906
+ this.#throwIfShutdownAbort(controller);
1565
1907
  return undefined;
1566
1908
  }
1567
1909
  const currentAgent = this.#stores.agents.findByPane({
@@ -1616,7 +1958,7 @@ export class AgentIndexService {
1616
1958
  ? undefined
1617
1959
  : this.#appendAndAckSelfEvent({
1618
1960
  agentId: targetAgent.id,
1619
- compactHistory,
1961
+ compactHistory: compactHistory ?? null,
1620
1962
  herdrSessionName: targetAgent.herdrSessionName,
1621
1963
  idempotencyKey: idempotencyKey("agent.status.changed", targetAgent, input.from, input.to, `${observationId}:${input.herdrEventKey ?? "legacy"}:agent.status.changed`),
1622
1964
  paneId: targetAgent.paneId,
@@ -1630,7 +1972,7 @@ export class AgentIndexService {
1630
1972
  if (statusType) {
1631
1973
  return this.#appendAndAckSelfEvent({
1632
1974
  agentId: targetAgent.id,
1633
- compactHistory,
1975
+ compactHistory: compactHistory ?? null,
1634
1976
  herdrSessionName: targetAgent.herdrSessionName,
1635
1977
  idempotencyKey: idempotencyKey(statusType, targetAgent, input.from, input.to, `${observationId}:${input.herdrEventKey ?? "legacy"}:${statusType}`),
1636
1978
  paneId: targetAgent.paneId,
@@ -1647,18 +1989,42 @@ export class AgentIndexService {
1647
1989
  unregister();
1648
1990
  }
1649
1991
  }
1650
- #appendPlanFailedEvent(input) {
1651
- let agent = input.agent;
1992
+ /**
1993
+ * Resolves the metadata a terminal plan outcome is attributed to and whether
1994
+ * the event may keep its `agent_id` foreign key.
1995
+ *
1996
+ * A live `agents` row wins because it carries the current pane generation.
1997
+ * Once the row is gone (pane retired / physically deleted) the plan's own
1998
+ * pane/session/generation are the authority, refined by the newest event of
1999
+ * the same pane + generation that predates the plan, so an old failure is
2000
+ * never stitched onto a newer instance of the same pane. The event is still
2001
+ * written in that case, but as an orphan (`agentRowPresent: false`).
2002
+ */
2003
+ #resolvePlanOutcomeAgent(agent, planCreatedAt) {
1652
2004
  try {
1653
- agent = this.#stores.agents.get(input.agent.id);
2005
+ return { agent: this.#stores.agents.get(agent.id), agentRowPresent: true };
1654
2006
  }
1655
2007
  catch {
1656
- const stitched = this.#minimalAgentFromLatestEvent(input.agent.id, input.agent.herdrSessionName);
1657
- if (stitched)
1658
- agent = stitched;
2008
+ const stitched = this.#minimalAgentFromLatestEvent({
2009
+ agentId: agent.id,
2010
+ // The plan row's own creation time is the upper bound: anything newer on
2011
+ // the same pane belongs to a later instance.
2012
+ createdBefore: planCreatedAt ?? Date.now(),
2013
+ herdrSessionName: agent.herdrSessionName,
2014
+ paneGeneration: agent.paneGeneration ?? null,
2015
+ paneId: agent.paneId,
2016
+ });
2017
+ return { agent: stitched ?? agent, agentRowPresent: false };
1659
2018
  }
2019
+ }
2020
+ #appendPlanFailedEvent(input) {
2021
+ const originalAgentId = input.agent.id;
2022
+ const resolved = this.#resolvePlanOutcomeAgent(input.agent, input.planCreatedAt);
2023
+ const agent = resolved.agent;
2024
+ const fallbackOutcome = input.fallbackOutcome ??
2025
+ ((input.to === "done" || input.to === "blocked") && input.reason === "degraded");
1660
2026
  return this.#appendAndAckSelfEvent({
1661
- agentId: agent.id,
2027
+ agentId: resolved.agentRowPresent ? agent.id : null,
1662
2028
  compactHistory: input.compactHistory ?? null,
1663
2029
  herdrSessionName: agent.herdrSessionName,
1664
2030
  idempotencyKey: `agent.failed:plan:${input.planId}`,
@@ -1666,7 +2032,11 @@ export class AgentIndexService {
1666
2032
  paneGeneration: agent.paneGeneration ?? null,
1667
2033
  payload: {
1668
2034
  agent: agent.agent,
2035
+ // The original id survives in the payload even when the row had to be
2036
+ // written as an orphan, so the failure stays traceable to its agent.
2037
+ agentId: originalAgentId,
1669
2038
  attempts: input.attempts,
2039
+ fallbackOutcome,
1670
2040
  from: input.from,
1671
2041
  herdrSessionName: agent.herdrSessionName,
1672
2042
  name: agent.name,
@@ -1682,17 +2052,11 @@ export class AgentIndexService {
1682
2052
  });
1683
2053
  }
1684
2054
  #appendPlanDiscardedEvent(input) {
1685
- let agent = input.agent;
1686
- try {
1687
- agent = this.#stores.agents.get(input.agent.id);
1688
- }
1689
- catch {
1690
- const stitched = this.#minimalAgentFromLatestEvent(input.agent.id, input.agent.herdrSessionName);
1691
- if (stitched)
1692
- agent = stitched;
1693
- }
2055
+ const originalAgentId = input.agent.id;
2056
+ const resolved = this.#resolvePlanOutcomeAgent(input.agent, input.planCreatedAt);
2057
+ const agent = resolved.agent;
1694
2058
  return this.#appendAndAckSelfEvent({
1695
- agentId: agent.id,
2059
+ agentId: resolved.agentRowPresent ? agent.id : null,
1696
2060
  compactHistory: input.compactHistory ?? null,
1697
2061
  herdrSessionName: agent.herdrSessionName,
1698
2062
  idempotencyKey: `agent.discarded:plan:${input.planId}`,
@@ -1700,7 +2064,7 @@ export class AgentIndexService {
1700
2064
  paneGeneration: agent.paneGeneration ?? null,
1701
2065
  payload: {
1702
2066
  agent: agent.agent,
1703
- agentId: agent.id,
2067
+ agentId: originalAgentId,
1704
2068
  attempts: input.attempts,
1705
2069
  from: input.from,
1706
2070
  herdrSessionName: agent.herdrSessionName,