@dorokuma/herdsman 0.9.0 → 0.11.4

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 (111) hide show
  1. package/dist/src/agent-history/antigravity-proto.js +1 -1
  2. package/dist/src/agent-history/antigravity-proto.js.map +1 -1
  3. package/dist/src/agent-history/antigravity-reader.d.ts +13 -1
  4. package/dist/src/agent-history/antigravity-reader.d.ts.map +1 -1
  5. package/dist/src/agent-history/antigravity-reader.js +22 -1
  6. package/dist/src/agent-history/antigravity-reader.js.map +1 -1
  7. package/dist/src/agent-history/discovery.d.ts +2 -1
  8. package/dist/src/agent-history/discovery.d.ts.map +1 -1
  9. package/dist/src/agent-history/discovery.js +24 -4
  10. package/dist/src/agent-history/discovery.js.map +1 -1
  11. package/dist/src/agent-history/readers.d.ts +15 -1
  12. package/dist/src/agent-history/readers.d.ts.map +1 -1
  13. package/dist/src/agent-history/readers.js +77 -31
  14. package/dist/src/agent-history/readers.js.map +1 -1
  15. package/dist/src/agent-history/service.d.ts +4 -2
  16. package/dist/src/agent-history/service.d.ts.map +1 -1
  17. package/dist/src/agent-history/service.js +22 -22
  18. package/dist/src/agent-history/service.js.map +1 -1
  19. package/dist/src/agent-history/source-fingerprint.d.ts +7 -0
  20. package/dist/src/agent-history/source-fingerprint.d.ts.map +1 -0
  21. package/dist/src/agent-history/source-fingerprint.js +26 -0
  22. package/dist/src/agent-history/source-fingerprint.js.map +1 -0
  23. package/dist/src/agent-history/text.d.ts.map +1 -1
  24. package/dist/src/agent-history/text.js +5 -0
  25. package/dist/src/agent-history/text.js.map +1 -1
  26. package/dist/src/cli/herdsman.d.ts.map +1 -1
  27. package/dist/src/cli/herdsman.js +31 -9
  28. package/dist/src/cli/herdsman.js.map +1 -1
  29. package/dist/src/daemon/agent-event-reconciler.d.ts +7 -0
  30. package/dist/src/daemon/agent-event-reconciler.d.ts.map +1 -1
  31. package/dist/src/daemon/agent-event-reconciler.js +39 -5
  32. package/dist/src/daemon/agent-event-reconciler.js.map +1 -1
  33. package/dist/src/daemon/herdr-session-watch-manager.d.ts +1 -0
  34. package/dist/src/daemon/herdr-session-watch-manager.d.ts.map +1 -1
  35. package/dist/src/daemon/herdr-session-watch-manager.js +176 -55
  36. package/dist/src/daemon/herdr-session-watch-manager.js.map +1 -1
  37. package/dist/src/daemon/observability-server.d.ts +3 -0
  38. package/dist/src/daemon/observability-server.d.ts.map +1 -1
  39. package/dist/src/daemon/observability-server.js +64 -9
  40. package/dist/src/daemon/observability-server.js.map +1 -1
  41. package/dist/src/daemon/process-manager.d.ts +41 -2
  42. package/dist/src/daemon/process-manager.d.ts.map +1 -1
  43. package/dist/src/daemon/process-manager.js +340 -19
  44. package/dist/src/daemon/process-manager.js.map +1 -1
  45. package/dist/src/daemon/service.d.ts +46 -0
  46. package/dist/src/daemon/service.d.ts.map +1 -1
  47. package/dist/src/daemon/service.js +171 -12
  48. package/dist/src/daemon/service.js.map +1 -1
  49. package/dist/src/db/agent-events.d.ts +27 -0
  50. package/dist/src/db/agent-events.d.ts.map +1 -1
  51. package/dist/src/db/agent-events.js +157 -26
  52. package/dist/src/db/agent-events.js.map +1 -1
  53. package/dist/src/db/agent-orchestrator-scopes.d.ts +8 -0
  54. package/dist/src/db/agent-orchestrator-scopes.d.ts.map +1 -1
  55. package/dist/src/db/agent-orchestrator-scopes.js +13 -0
  56. package/dist/src/db/agent-orchestrator-scopes.js.map +1 -1
  57. package/dist/src/db/agents.d.ts +44 -0
  58. package/dist/src/db/agents.d.ts.map +1 -1
  59. package/dist/src/db/agents.js +71 -0
  60. package/dist/src/db/agents.js.map +1 -1
  61. package/dist/src/db/client.d.ts +1 -0
  62. package/dist/src/db/client.d.ts.map +1 -1
  63. package/dist/src/db/client.js +12 -0
  64. package/dist/src/db/client.js.map +1 -1
  65. package/dist/src/db/schema.d.ts +391 -0
  66. package/dist/src/db/schema.d.ts.map +1 -1
  67. package/dist/src/db/schema.js +34 -0
  68. package/dist/src/db/schema.js.map +1 -1
  69. package/dist/src/db/status-event-plans.d.ts +62 -0
  70. package/dist/src/db/status-event-plans.d.ts.map +1 -0
  71. package/dist/src/db/status-event-plans.js +147 -0
  72. package/dist/src/db/status-event-plans.js.map +1 -0
  73. package/dist/src/herdr/pane-identity-resolver.d.ts +4 -0
  74. package/dist/src/herdr/pane-identity-resolver.d.ts.map +1 -1
  75. package/dist/src/herdr/pane-identity-resolver.js +13 -1
  76. package/dist/src/herdr/pane-identity-resolver.js.map +1 -1
  77. package/dist/src/herdr/socket-client.d.ts +2 -0
  78. package/dist/src/herdr/socket-client.d.ts.map +1 -1
  79. package/dist/src/herdr/socket-client.js +64 -20
  80. package/dist/src/herdr/socket-client.js.map +1 -1
  81. package/dist/src/observability/agent-context-service.d.ts.map +1 -1
  82. package/dist/src/observability/agent-context-service.js +3 -6
  83. package/dist/src/observability/agent-context-service.js.map +1 -1
  84. package/dist/src/observability/agent-index-service.d.ts +47 -2
  85. package/dist/src/observability/agent-index-service.d.ts.map +1 -1
  86. package/dist/src/observability/agent-index-service.js +1075 -103
  87. package/dist/src/observability/agent-index-service.js.map +1 -1
  88. package/dist/src/observability/agent-orchestrator-service.d.ts.map +1 -1
  89. package/dist/src/observability/agent-orchestrator-service.js +34 -12
  90. package/dist/src/observability/agent-orchestrator-service.js.map +1 -1
  91. package/dist/src/observability/contracts.d.ts +1 -1
  92. package/dist/src/observability/contracts.d.ts.map +1 -1
  93. package/dist/src/observability/contracts.js.map +1 -1
  94. package/dist/src/observability/interactive-pi.d.ts +1 -1
  95. package/dist/src/observability/interactive-pi.d.ts.map +1 -1
  96. package/dist/src/observability/interactive-pi.js +1 -1
  97. package/dist/src/observability/interactive-pi.js.map +1 -1
  98. package/dist/src/observability/turn-completion.d.ts +2 -1
  99. package/dist/src/observability/turn-completion.d.ts.map +1 -1
  100. package/dist/src/observability/turn-completion.js +10 -1
  101. package/dist/src/observability/turn-completion.js.map +1 -1
  102. package/dist/src/shared/unix-peercred.d.ts +14 -0
  103. package/dist/src/shared/unix-peercred.d.ts.map +1 -0
  104. package/dist/src/shared/unix-peercred.js +144 -0
  105. package/dist/src/shared/unix-peercred.js.map +1 -0
  106. package/drizzle/0008_pane_tombstones.sql +9 -0
  107. package/drizzle/0009_high_lake.sql +24 -0
  108. package/drizzle/meta/0008_snapshot.json +823 -0
  109. package/drizzle/meta/0009_snapshot.json +975 -0
  110. package/drizzle/meta/_journal.json +14 -0
  111. package/package.json +24 -22
@@ -1,21 +1,59 @@
1
1
  import { safeAllowedSessionPath } from "../agent-history/discovery.js";
2
2
  import { createAgentHistoryService } from "../agent-history/service.js";
3
+ import { hasNonEmptyAssistantMessage } from "../db/agent-events.js";
4
+ import { runSqliteTransaction } from "../db/client.js";
3
5
  import { normalizeHerdrSessionSnapshot } from "../herdr/session-snapshot.js";
4
6
  import { HerdrSocketClient } from "../herdr/socket-client.js";
5
7
  import { AgentContextService } from "../observability/agent-context-service.js";
6
8
  import { parseAgentStatus, } from "../observability/contracts.js";
7
9
  import { TURN_SIGNAL_WAIT_MS } from "../observability/turn-completion.js";
10
+ export function historyHasAdvanced(current, baseline, options) {
11
+ if (current.lastAssistantMessage?.ref &&
12
+ current.lastAssistantMessage.ref !== (baseline?.lastAssistantMessage?.ref ?? null)) {
13
+ return true;
14
+ }
15
+ if (current.lastAssistantMessage?.text &&
16
+ current.lastAssistantMessage.text !== (baseline?.lastAssistantMessage?.text ?? null)) {
17
+ return true;
18
+ }
19
+ if (options?.requireAssistantChange) {
20
+ return false;
21
+ }
22
+ return current.messageCount > (baseline?.messageCount ?? 0);
23
+ }
24
+ export const PLAN_WAITING_HISTORY = "PLAN_WAITING_HISTORY";
25
+ export class PlanWaitingHistoryError extends Error {
26
+ constructor() {
27
+ super(PLAN_WAITING_HISTORY);
28
+ this.name = "PlanWaitingHistoryError";
29
+ }
30
+ }
31
+ /**
32
+ * Sentinel returned by #appendStatusEvents when the plan must be CANCELLED
33
+ * rather than skipped (skip still maps to completed in #runPlanRow). The agent
34
+ * row is gone, so appending would create an undeliverable dangling event.
35
+ */
36
+ const PLAN_CANCELLED = Symbol("herdsman.status-event-plan-cancelled");
8
37
  export class AgentIndexService {
38
+ #activeWaiters = new Map();
39
+ #clearRetry;
9
40
  #clientFactory;
10
41
  #context;
11
42
  #stores;
12
43
  #mutationEpochBySession = new Map();
13
44
  #pendingPiSessionRefs = new Map();
45
+ #planTailByAgent = new Map();
14
46
  #refreshInFlightBySession = new Map();
47
+ #scheduleRetry;
15
48
  #sessionOperationTail = new Map();
49
+ #sleep;
16
50
  #turnCompletions;
51
+ #waitingHistoryTimers = new Map();
17
52
  constructor(options) {
53
+ this.#clearRetry = options.clearRetry ?? ((timer) => clearTimeout(timer));
18
54
  this.#clientFactory = options.clientFactory ?? ((input) => new HerdrSocketClient(input));
55
+ this.#scheduleRetry = options.scheduleRetry ?? ((cb, delay) => setTimeout(cb, delay));
56
+ this.#sleep = options.sleep ?? sleep;
19
57
  this.#stores = options.stores;
20
58
  this.#turnCompletions = options.turnCompletions;
21
59
  if (options.context) {
@@ -38,12 +76,26 @@ export class AgentIndexService {
38
76
  });
39
77
  }
40
78
  }
79
+ refreshHerdrSessionFast(input) {
80
+ this.#incrementMutationEpoch(input.herdrSessionName);
81
+ return this.#enqueueSessionOperation(input.herdrSessionName, () => this.#refreshHerdrSessionNow(input));
82
+ }
41
83
  refreshHerdrSession(input) {
42
84
  const epoch = this.#mutationEpochBySession.get(input.herdrSessionName) ?? 0;
43
85
  const existing = this.#refreshInFlightBySession.get(input.herdrSessionName);
44
86
  if (existing?.epoch === epoch)
45
87
  return existing.promise;
46
- const promise = this.#enqueueSessionOperation(input.herdrSessionName, () => this.#refreshHerdrSessionNow(input));
88
+ const promise = this.#enqueueSessionOperation(input.herdrSessionName, () => this.#refreshHerdrSessionNow(input)).then(async (intermediate) => {
89
+ const statusEvents = await Promise.all(intermediate.statusEventPlans.map((plan) => this.executeStatusEventPlan(plan)));
90
+ return {
91
+ agents: intermediate.agents,
92
+ contextChangedScopes: intermediate.contextChangedScopes,
93
+ events: [
94
+ ...intermediate.events,
95
+ ...statusEvents.filter((event) => event !== undefined),
96
+ ],
97
+ };
98
+ });
47
99
  this.#refreshInFlightBySession.set(input.herdrSessionName, { epoch, promise });
48
100
  const clear = () => {
49
101
  if (this.#refreshInFlightBySession.get(input.herdrSessionName)?.promise === promise) {
@@ -53,10 +105,530 @@ export class AgentIndexService {
53
105
  void promise.then(clear, clear);
54
106
  return promise;
55
107
  }
56
- handleHerdrEvent(input) {
108
+ handleHerdrEventFast(input) {
57
109
  this.#incrementMutationEpoch(input.herdrSessionName);
58
110
  return this.#enqueueSessionOperation(input.herdrSessionName, () => this.#handleHerdrEventNow(input));
59
111
  }
112
+ async handleHerdrEvent(input) {
113
+ const intermediate = await this.handleHerdrEventFast(input);
114
+ const statusEvents = await Promise.all(intermediate.statusEventPlans.map((plan) => this.executeStatusEventPlan(plan)));
115
+ return {
116
+ contextChangedScopes: intermediate.contextChangedScopes,
117
+ events: [
118
+ ...intermediate.events,
119
+ ...statusEvents.filter((event) => event !== undefined),
120
+ ],
121
+ };
122
+ }
123
+ stopWaitingHistoryRetries() {
124
+ for (const timer of this.#waitingHistoryTimers.values()) {
125
+ this.#clearRetry(timer);
126
+ }
127
+ this.#waitingHistoryTimers.clear();
128
+ }
129
+ async drainInFlightPlans() {
130
+ const tails = Array.from(this.#planTailByAgent.values());
131
+ await Promise.allSettled(tails);
132
+ }
133
+ #clearWaitingTimer(planId) {
134
+ const existing = this.#waitingHistoryTimers.get(planId);
135
+ if (existing !== undefined) {
136
+ this.#clearRetry(existing);
137
+ this.#waitingHistoryTimers.delete(planId);
138
+ }
139
+ }
140
+ #scheduleWaitingHistoryRetry(planId, plan) {
141
+ this.#clearWaitingTimer(planId);
142
+ const timer = this.#scheduleRetry(async () => {
143
+ try {
144
+ const store = this.#stores.statusEventPlans;
145
+ if (!store)
146
+ return;
147
+ const current = store.get(planId);
148
+ if (current.status !== "pending" || current.lastError !== PLAN_WAITING_HISTORY) {
149
+ this.#waitingHistoryTimers.delete(planId);
150
+ return;
151
+ }
152
+ await this.#enqueueAgentPlan(plan.agent.id, () => this.#retryWaitingPlanRow(planId, plan));
153
+ }
154
+ catch (error) {
155
+ console.debug("Herdsman waiting history retry callback error", error);
156
+ }
157
+ }, 10_000);
158
+ this.#waitingHistoryTimers.set(planId, timer);
159
+ }
160
+ async #retryWaitingPlanRow(planId, plan) {
161
+ const store = this.#stores.statusEventPlans;
162
+ if (!store)
163
+ return;
164
+ let current;
165
+ try {
166
+ current = store.get(planId);
167
+ }
168
+ catch {
169
+ this.#waitingHistoryTimers.delete(planId);
170
+ return;
171
+ }
172
+ if (current.status !== "pending" || current.lastError !== PLAN_WAITING_HISTORY) {
173
+ this.#waitingHistoryTimers.delete(planId);
174
+ return;
175
+ }
176
+ let retryPlan;
177
+ try {
178
+ const refreshed = await this.#context.refreshAgent({
179
+ agent: plan.agent,
180
+ forceRefresh: true,
181
+ identityChanged: false,
182
+ });
183
+ retryPlan = {
184
+ ...plan,
185
+ attempts: current.attempts,
186
+ compactHistory: refreshed.snapshot.compactHistory,
187
+ };
188
+ }
189
+ catch (err) {
190
+ console.warn("Herdsman failed to refresh agent before retry plan row, keeping waiting", {
191
+ planId,
192
+ agentId: plan.agent.id,
193
+ error: err,
194
+ });
195
+ store.markRetry(planId, new PlanWaitingHistoryError());
196
+ const updated = store.get(planId);
197
+ if (updated.status === "pending") {
198
+ this.#scheduleWaitingHistoryRetry(planId, plan);
199
+ }
200
+ else if (updated.status === "failed") {
201
+ console.warn("Herdsman status event plan failed after max attempts", {
202
+ planId,
203
+ agentId: plan.agent.id,
204
+ attempts: updated.attempts,
205
+ from: plan.from,
206
+ to: plan.to,
207
+ herdrSessionName: plan.agent.herdrSessionName,
208
+ });
209
+ this.#appendPlanFailedEvent({
210
+ agent: plan.agent,
211
+ attempts: updated.attempts,
212
+ compactHistory: plan.compactHistory,
213
+ from: plan.from,
214
+ planId,
215
+ reason: updated.lastError,
216
+ to: plan.to,
217
+ });
218
+ }
219
+ return;
220
+ }
221
+ await this.#runPlanRow(current, retryPlan);
222
+ }
223
+ async executeStatusEventPlan(plan) {
224
+ if (plan.from === plan.to)
225
+ return undefined;
226
+ if (!this.#stores.statusEventPlans) {
227
+ return this.#enqueueAgentPlan(plan.agent.id, async () => {
228
+ try {
229
+ const event = await this.#appendStatusEvents(plan);
230
+ return event === PLAN_CANCELLED ? undefined : event;
231
+ }
232
+ catch (error) {
233
+ if (error instanceof PlanWaitingHistoryError) {
234
+ return undefined;
235
+ }
236
+ throw error;
237
+ }
238
+ });
239
+ }
240
+ const inserted = plan.planId !== undefined
241
+ ? this.#stores.statusEventPlans.get(plan.planId)
242
+ : this.#stores.statusEventPlans.insertPending({
243
+ agent: plan.agent,
244
+ from: plan.from,
245
+ herdrEventKey: plan.herdrEventKey ?? null,
246
+ to: plan.to,
247
+ ...(plan.compactHistory ? { compactHistory: plan.compactHistory } : {}),
248
+ });
249
+ return this.#enqueueAgentPlan(plan.agent.id, () => this.#runPlanRow(inserted, plan));
250
+ }
251
+ async drainPendingPlans() {
252
+ if (!this.#stores.statusEventPlans)
253
+ return;
254
+ this.#stores.statusEventPlans.resetRunningToPending();
255
+ const rows = this.#stores.statusEventPlans.listUnfinished();
256
+ const tasks = rows.map((row) => {
257
+ try {
258
+ return this.#enqueueAgentPlan(row.agentId, () => this.#drainPlanRow(row));
259
+ }
260
+ catch (_error) {
261
+ // A row whose agent cannot even be resolved must never take down the
262
+ // daemon boot drain (that failure mode used to boot-loop systemd).
263
+ try {
264
+ this.#stores.statusEventPlans.markCancelled(row.id);
265
+ }
266
+ catch {
267
+ // The row may already be gone; the drain must continue regardless.
268
+ }
269
+ console.warn("Herdsman cancelling status event plan for missing agent", {
270
+ agentId: row.agentId,
271
+ herdrSessionName: row.herdrSessionName,
272
+ paneId: row.paneId,
273
+ from: row.fromStatus,
274
+ to: row.toStatus,
275
+ });
276
+ return Promise.resolve();
277
+ }
278
+ });
279
+ // Individual rows may reject (after markRetry) but the drain itself never
280
+ // rejects: every row is either drained, cancelled, or retried.
281
+ await Promise.allSettled(tasks);
282
+ await this.#backfillFailedPlanEvents();
283
+ }
284
+ async #backfillFailedPlanEvents() {
285
+ const store = this.#stores.statusEventPlans;
286
+ if (!store)
287
+ return;
288
+ for (const row of store.listFailed()) {
289
+ const existing = this.#stores.sqlite
290
+ .prepare("select 1 from agent_events where herdr_session_name = ? and idempotency_key = ?")
291
+ .get(row.herdrSessionName, `agent.failed:plan:${row.id}`);
292
+ if (existing)
293
+ continue;
294
+ let agent;
295
+ try {
296
+ agent = this.#stores.agents.get(row.agentId);
297
+ }
298
+ catch {
299
+ agent = this.#minimalAgentFromLatestEvent(row.agentId, row.herdrSessionName);
300
+ if (!agent) {
301
+ console.warn("Herdsman skipping agent.failed backfill; no agent row or events", {
302
+ agentId: row.agentId,
303
+ herdrSessionName: row.herdrSessionName,
304
+ planId: row.id,
305
+ });
306
+ continue;
307
+ }
308
+ }
309
+ this.#appendPlanFailedEvent({
310
+ agent,
311
+ attempts: row.attempts,
312
+ compactHistory: row.compactHistory,
313
+ from: row.fromStatus,
314
+ planId: row.id,
315
+ reason: row.lastError,
316
+ to: row.toStatus,
317
+ });
318
+ }
319
+ }
320
+ #minimalAgentFromLatestEvent(agentId, herdrSessionName) {
321
+ const latest = this.#stores.sqlite
322
+ .prepare(`select pane_id, pane_generation, terminal_id, workspace_id, herdr_session_name
323
+ from agent_events where agent_id = ? order by id desc limit 1`)
324
+ .get(agentId);
325
+ if (!latest?.pane_id || !latest.workspace_id)
326
+ return undefined;
327
+ return {
328
+ agent: null,
329
+ agentSession: null,
330
+ agentStatus: "unknown",
331
+ cwd: null,
332
+ firstSeenAt: new Date(0),
333
+ focused: false,
334
+ foregroundCwd: null,
335
+ herdrSessionName: latest.herdr_session_name || herdrSessionName,
336
+ id: agentId,
337
+ lastSeenAt: new Date(0),
338
+ name: null,
339
+ paneId: latest.pane_id,
340
+ paneRevision: null,
341
+ ...(latest.pane_generation === null ? {} : { paneGeneration: latest.pane_generation }),
342
+ tabId: null,
343
+ terminalId: latest.terminal_id,
344
+ workspaceId: latest.workspace_id,
345
+ };
346
+ }
347
+ async #drainPlanRow(row) {
348
+ let agent;
349
+ try {
350
+ agent =
351
+ this.#stores.agents.findByPane({
352
+ herdrSessionName: row.herdrSessionName,
353
+ paneId: row.paneId,
354
+ paneGeneration: row.paneGeneration,
355
+ }) ?? this.#stores.agents.get(row.agentId);
356
+ }
357
+ catch {
358
+ agent = undefined;
359
+ }
360
+ if (!agent) {
361
+ this.#stores.statusEventPlans.markCancelled(row.id);
362
+ console.warn("Herdsman cancelling status event plan for missing agent", {
363
+ agentId: row.agentId,
364
+ herdrSessionName: row.herdrSessionName,
365
+ paneId: row.paneId,
366
+ from: row.fromStatus,
367
+ to: row.toStatus,
368
+ });
369
+ return;
370
+ }
371
+ let activeCompact = row.compactHistory;
372
+ try {
373
+ const refreshed = await this.#context.refreshAgent({
374
+ agent,
375
+ forceRefresh: true,
376
+ identityChanged: false,
377
+ });
378
+ activeCompact = refreshed.snapshot.compactHistory;
379
+ }
380
+ catch (err) {
381
+ this.#stores.statusEventPlans.markRetry(row.id, new PlanWaitingHistoryError());
382
+ const updated = this.#stores.statusEventPlans.get(row.id);
383
+ if (updated.status === "pending") {
384
+ console.warn("Herdsman failed to refresh agent during drain plan row, keeping waiting", {
385
+ planId: row.id,
386
+ agentId: agent.id,
387
+ error: err,
388
+ });
389
+ this.#scheduleWaitingHistoryRetry(row.id, {
390
+ agent,
391
+ compactHistory: row.compactHistory,
392
+ from: row.fromStatus,
393
+ to: row.toStatus,
394
+ ...(row.herdrEventKey ? { herdrEventKey: row.herdrEventKey } : {}),
395
+ });
396
+ }
397
+ else if (updated.status === "failed") {
398
+ this.#clearWaitingTimer(row.id);
399
+ console.warn("Herdsman plan marked failed", {
400
+ planId: row.id,
401
+ agentId: agent.id,
402
+ from: row.fromStatus,
403
+ to: row.toStatus,
404
+ reason: updated.lastError,
405
+ });
406
+ this.#appendPlanFailedEvent({
407
+ agent,
408
+ attempts: updated.attempts,
409
+ compactHistory: row.compactHistory,
410
+ from: row.fromStatus,
411
+ planId: row.id,
412
+ reason: updated.lastError,
413
+ to: row.toStatus,
414
+ });
415
+ }
416
+ return;
417
+ }
418
+ const plan = {
419
+ agent,
420
+ attempts: row.attempts,
421
+ compactHistory: activeCompact,
422
+ from: row.fromStatus,
423
+ to: row.toStatus,
424
+ ...(row.herdrEventKey ? { herdrEventKey: row.herdrEventKey } : {}),
425
+ };
426
+ await this.#runPlanRow(row, plan);
427
+ }
428
+ #enqueueAgentPlan(agentId, operation) {
429
+ const previous = this.#planTailByAgent.get(agentId) ?? Promise.resolve();
430
+ const result = previous.then(operation, operation);
431
+ const tail = result.then(() => undefined, () => undefined);
432
+ this.#planTailByAgent.set(agentId, tail);
433
+ void tail.finally(() => {
434
+ if (this.#planTailByAgent.get(agentId) === tail) {
435
+ this.#planTailByAgent.delete(agentId);
436
+ }
437
+ });
438
+ return result;
439
+ }
440
+ async #runPlanRow(row, plan) {
441
+ const store = this.#stores.statusEventPlans;
442
+ if (!store)
443
+ return undefined;
444
+ const current = store.get(row.id);
445
+ if (current.status === "completed" ||
446
+ current.status === "cancelled" ||
447
+ current.status === "failed") {
448
+ this.#clearWaitingTimer(row.id);
449
+ return undefined;
450
+ }
451
+ store.markRunning(row.id);
452
+ let activePlan = {
453
+ ...plan,
454
+ attempts: row.attempts,
455
+ };
456
+ if (row.attempts > 0) {
457
+ try {
458
+ const refreshed = await this.#context.refreshAgent({
459
+ agent: plan.agent,
460
+ forceRefresh: true,
461
+ identityChanged: false,
462
+ });
463
+ activePlan = {
464
+ ...plan,
465
+ attempts: row.attempts,
466
+ compactHistory: refreshed.snapshot.compactHistory,
467
+ };
468
+ }
469
+ catch (err) {
470
+ console.warn("Herdsman failed to refresh agent before running retry row, keeping waiting", {
471
+ planId: row.id,
472
+ agentId: plan.agent.id,
473
+ error: err,
474
+ });
475
+ store.markRetry(row.id, new PlanWaitingHistoryError());
476
+ const updated = store.get(row.id);
477
+ if (updated.status === "pending") {
478
+ this.#scheduleWaitingHistoryRetry(row.id, plan);
479
+ }
480
+ else {
481
+ this.#clearWaitingTimer(row.id);
482
+ if (updated.status === "failed") {
483
+ console.warn("Herdsman status event plan failed after max attempts", {
484
+ planId: row.id,
485
+ agentId: row.agentId,
486
+ attempts: updated.attempts,
487
+ from: row.fromStatus,
488
+ to: row.toStatus,
489
+ herdrSessionName: row.herdrSessionName,
490
+ });
491
+ return this.#appendPlanFailedEvent({
492
+ agent: plan.agent,
493
+ attempts: updated.attempts,
494
+ compactHistory: plan.compactHistory,
495
+ from: row.fromStatus,
496
+ planId: row.id,
497
+ reason: updated.lastError,
498
+ to: row.toStatus,
499
+ });
500
+ }
501
+ }
502
+ return undefined;
503
+ }
504
+ }
505
+ try {
506
+ const event = await this.#appendStatusEvents(activePlan);
507
+ if (event === PLAN_CANCELLED) {
508
+ this.#clearWaitingTimer(row.id);
509
+ store.markCancelled(row.id);
510
+ return undefined;
511
+ }
512
+ if (event === undefined &&
513
+ this.#stores.agents.isPaneClosed({
514
+ herdrSessionName: plan.agent.herdrSessionName,
515
+ paneId: plan.agent.paneId,
516
+ paneGeneration: plan.agent.paneGeneration ?? null,
517
+ })) {
518
+ this.#clearWaitingTimer(row.id);
519
+ store.markCancelled(row.id);
520
+ return undefined;
521
+ }
522
+ this.#clearWaitingTimer(row.id);
523
+ store.markCompleted(row.id);
524
+ return event;
525
+ }
526
+ catch (error) {
527
+ if (error instanceof PlanWaitingHistoryError) {
528
+ store.markRetry(row.id, error);
529
+ const updated = store.get(row.id);
530
+ if (updated.status === "pending") {
531
+ this.#scheduleWaitingHistoryRetry(row.id, plan);
532
+ }
533
+ else {
534
+ this.#clearWaitingTimer(row.id);
535
+ if (updated.status === "failed") {
536
+ console.warn("Herdsman status event plan failed after max attempts", {
537
+ planId: row.id,
538
+ agentId: row.agentId,
539
+ attempts: updated.attempts,
540
+ from: row.fromStatus,
541
+ to: row.toStatus,
542
+ herdrSessionName: row.herdrSessionName,
543
+ });
544
+ return this.#appendPlanFailedEvent({
545
+ agent: plan.agent,
546
+ attempts: updated.attempts,
547
+ compactHistory: activePlan.compactHistory,
548
+ from: row.fromStatus,
549
+ planId: row.id,
550
+ reason: updated.lastError,
551
+ to: row.toStatus,
552
+ });
553
+ }
554
+ }
555
+ return undefined;
556
+ }
557
+ const err = error instanceof Error ? error : undefined;
558
+ const isPaneClosed = this.#stores.agents.isPaneClosed({
559
+ herdrSessionName: plan.agent.herdrSessionName,
560
+ paneId: plan.agent.paneId,
561
+ paneGeneration: plan.agent.paneGeneration ?? null,
562
+ }) ||
563
+ err?.name === "AbortError" ||
564
+ err?.message?.includes("aborted");
565
+ if (isPaneClosed) {
566
+ this.#clearWaitingTimer(row.id);
567
+ store.markCancelled(row.id);
568
+ return undefined;
569
+ }
570
+ this.#clearWaitingTimer(row.id);
571
+ store.markRetry(row.id, error);
572
+ const updated = store.get(row.id);
573
+ if (updated.status === "failed") {
574
+ console.warn("Herdsman status event plan failed after max attempts", {
575
+ planId: row.id,
576
+ agentId: row.agentId,
577
+ attempts: updated.attempts,
578
+ from: row.fromStatus,
579
+ to: row.toStatus,
580
+ herdrSessionName: row.herdrSessionName,
581
+ });
582
+ return this.#appendPlanFailedEvent({
583
+ agent: plan.agent,
584
+ attempts: updated.attempts,
585
+ compactHistory: activePlan.compactHistory,
586
+ from: row.fromStatus,
587
+ planId: row.id,
588
+ reason: updated.lastError,
589
+ to: row.toStatus,
590
+ });
591
+ }
592
+ throw error;
593
+ }
594
+ }
595
+ async #waitForHistoryAdvance(input) {
596
+ const maxAttempts = input.maxAttempts ?? 8;
597
+ let refreshed = input.initial
598
+ ? { snapshot: { compactHistory: input.initial } }
599
+ : await this.#context.refreshAgent({
600
+ agent: input.agent,
601
+ forceRefresh: true,
602
+ identityChanged: false,
603
+ });
604
+ for (let attempt = 0; attempt < maxAttempts &&
605
+ !historyHasAdvanced(refreshed.snapshot.compactHistory, input.baseline, {
606
+ requireAssistantChange: input.requireAssistantChange,
607
+ }) &&
608
+ !input.controller.signal.aborted; attempt += 1) {
609
+ await this.#sleep(500, input.controller.signal);
610
+ if (input.controller.signal.aborted ||
611
+ this.#stores.agents.isPaneClosed({
612
+ herdrSessionName: input.agent.herdrSessionName,
613
+ paneId: input.agent.paneId,
614
+ paneGeneration: input.agent.paneGeneration ?? null,
615
+ })) {
616
+ console.debug("Herdsman skipping status event generation because wait was aborted during history refresh", {
617
+ aborted: input.controller.signal.aborted,
618
+ agentId: input.agent.id,
619
+ herdrSessionName: input.agent.herdrSessionName,
620
+ paneId: input.agent.paneId,
621
+ });
622
+ return undefined;
623
+ }
624
+ refreshed = await this.#context.refreshAgent({
625
+ agent: input.agent,
626
+ forceRefresh: true,
627
+ identityChanged: false,
628
+ });
629
+ }
630
+ return refreshed.snapshot.compactHistory;
631
+ }
60
632
  registerPiSessionRef(input) {
61
633
  this.#incrementMutationEpoch(input.herdrSessionName);
62
634
  return this.#enqueueSessionOperation(input.herdrSessionName, () => this.#registerPiSessionRefNow(input));
@@ -76,15 +648,7 @@ export class AgentIndexService {
76
648
  refreshEpoch,
77
649
  mutationEpoch,
78
650
  });
79
- return { agents: previous, contextChangedScopes: [], events: [] };
80
- }
81
- if (mutationEpoch !== refreshEpoch) {
82
- console.warn("Herdsman discarding stale Herdr session refresh", {
83
- sessionName: input.herdrSessionName,
84
- refreshEpoch,
85
- mutationEpoch,
86
- });
87
- return { agents: previous, contextChangedScopes: [], events: [] };
651
+ return { agents: previous, contextChangedScopes: [], events: [], statusEventPlans: [] };
88
652
  }
89
653
  const revisionByPane = new Map(snapshot.panes.flatMap((pane) => {
90
654
  const value = record(pane);
@@ -93,6 +657,20 @@ export class AgentIndexService {
93
657
  return paneId && revision !== undefined ? [[paneId, revision]] : [];
94
658
  }));
95
659
  const snapshotAgents = snapshot.agents.map((agent) => withPaneRevision(agent, revisionByPane));
660
+ // A live pane that reports a pane_generation overrides any generation-less
661
+ // (legacy) close: drop the legacy tombstone before consulting
662
+ // isPaneClosed so the generation-carrying agent is indexed instead of
663
+ // being swallowed by the phantom close. Generation-scoped tombstones stay.
664
+ for (const agent of snapshotAgents) {
665
+ const paneId = stringValue(agent.pane_id) ?? stringValue(agent.paneId);
666
+ if (paneId && paneGenerationOf(agent)) {
667
+ this.#stores.agents.clearLegacyPaneTombstone({
668
+ herdrSessionName: input.herdrSessionName,
669
+ paneId,
670
+ });
671
+ }
672
+ }
673
+ const liveSnapshotAgents = snapshotAgents.filter((agent) => !this.#isClosedPaneAgent(input.herdrSessionName, agent));
96
674
  this.#stores.herdrSessions.upsertRunning({
97
675
  name: input.herdrSessionName,
98
676
  sessionDir: input.sessionDir,
@@ -103,7 +681,7 @@ export class AgentIndexService {
103
681
  workspaces: snapshot.workspaces.map(record),
104
682
  });
105
683
  const indexedAgents = this.#stores.agents.replaceForSession({
106
- agents: snapshotAgents,
684
+ agents: liveSnapshotAgents,
107
685
  herdrSessionName: input.herdrSessionName,
108
686
  });
109
687
  const agents = indexedAgents.map((agent) => {
@@ -122,6 +700,7 @@ export class AgentIndexService {
122
700
  });
123
701
  const scopes = new Map();
124
702
  const events = [];
703
+ const statusEventPlans = [];
125
704
  const currentIds = new Set(agents.map((agent) => agent.id));
126
705
  for (const prior of previous) {
127
706
  if (!currentIds.has(prior.id))
@@ -157,14 +736,12 @@ export class AgentIndexService {
157
736
  addScope(scopes, scopeOf(agent));
158
737
  }
159
738
  if (prior && prior.agentStatus !== agent.agentStatus) {
160
- const event = await this.#appendStatusEvents({
739
+ statusEventPlans.push({
161
740
  agent,
162
741
  compactHistory: refreshed?.compactHistory ?? this.#context.getAgentSnapshot(agent.id)?.compactHistory,
163
742
  from: prior.agentStatus,
164
743
  to: agent.agentStatus,
165
744
  });
166
- if (event)
167
- events.push(event);
168
745
  addScope(scopes, scopeOf(agent));
169
746
  }
170
747
  }
@@ -172,6 +749,7 @@ export class AgentIndexService {
172
749
  agents,
173
750
  contextChangedScopes: sortedScopes(scopes),
174
751
  events,
752
+ statusEventPlans,
175
753
  };
176
754
  }
177
755
  finally {
@@ -182,9 +760,14 @@ export class AgentIndexService {
182
760
  const event = record(input.event);
183
761
  const paneId = stringValue(event.pane_id) ?? stringValue(event.paneId);
184
762
  if (!paneId)
185
- return { contextChangedScopes: [], events: [] };
763
+ return { contextChangedScopes: [], events: [], statusEventPlans: [] };
186
764
  if (event.type === "pane.closed") {
187
765
  const closedGeneration = paneGenerationFromEvent(event);
766
+ this.#abortPendingWaiters({
767
+ herdrSessionName: input.herdrSessionName,
768
+ paneId,
769
+ paneGeneration: closedGeneration,
770
+ });
188
771
  this.#stores.agentEvents.invalidatePane({
189
772
  herdrSessionName: input.herdrSessionName,
190
773
  paneId,
@@ -204,7 +787,7 @@ export class AgentIndexService {
204
787
  });
205
788
  const scopes = new Map();
206
789
  const closedTerminalId = stringValue(event.terminal_id) ?? stringValue(event.terminalId);
207
- if (closedGeneration && closedTerminalId) {
790
+ if (closedTerminalId) {
208
791
  for (const agent of retired) {
209
792
  const scope = scopeOf(agent);
210
793
  const change = this.#stores.agentOrchestratorScopes?.releaseIfOwnerIdentity({
@@ -223,10 +806,12 @@ export class AgentIndexService {
223
806
  return {
224
807
  contextChangedScopes: sortedScopes(scopes),
225
808
  events: [],
809
+ statusEventPlans: [],
226
810
  };
227
811
  }
228
- if (event.type !== "pane.agent_status_changed")
229
- return { contextChangedScopes: [], events: [] };
812
+ if (event.type !== "pane.agent_status_changed") {
813
+ return { contextChangedScopes: [], events: [], statusEventPlans: [] };
814
+ }
230
815
  const eventGeneration = paneGenerationFromEvent(event);
231
816
  let agent = this.#stores.agents.findByPane({
232
817
  herdrSessionName: input.herdrSessionName,
@@ -235,6 +820,27 @@ export class AgentIndexService {
235
820
  });
236
821
  let recovered;
237
822
  if (!agent) {
823
+ if (eventGeneration) {
824
+ // A generation-scoped tombstone still closes its own generation.
825
+ if (this.#stores.agents.hasGenerationScopedTombstone({
826
+ herdrSessionName: input.herdrSessionName,
827
+ paneId,
828
+ paneGeneration: eventGeneration,
829
+ })) {
830
+ return { contextChangedScopes: [], events: [], statusEventPlans: [] };
831
+ }
832
+ // No generation-scoped close: a live generation-carrying status
833
+ // overrides a generation-less close, so recover the pane (the refresh
834
+ // below clears the legacy tombstone only when the agent is actually
835
+ // present in the snapshot).
836
+ }
837
+ else if (this.#stores.agents.isPaneClosed({
838
+ herdrSessionName: input.herdrSessionName,
839
+ paneId,
840
+ paneGeneration: null,
841
+ })) {
842
+ return { contextChangedScopes: [], events: [], statusEventPlans: [] };
843
+ }
238
844
  recovered = await this.#refreshHerdrSessionNow(input);
239
845
  agent = this.#stores.agents.findByPane({
240
846
  herdrSessionName: input.herdrSessionName,
@@ -244,19 +850,17 @@ export class AgentIndexService {
244
850
  }
245
851
  if (!agent) {
246
852
  return recovered
247
- ? { contextChangedScopes: recovered.contextChangedScopes, events: recovered.events }
248
- : { contextChangedScopes: [], events: [] };
853
+ ? {
854
+ contextChangedScopes: recovered.contextChangedScopes,
855
+ events: recovered.events,
856
+ statusEventPlans: recovered.statusEventPlans,
857
+ }
858
+ : { contextChangedScopes: [], events: [], statusEventPlans: [] };
249
859
  }
250
860
  const from = recovered ? "unknown" : agent.agentStatus;
251
861
  const to = parseAgentStatus(event.agent_status);
252
862
  const herdrEventKey = herdrInputIdempotencyKey(input.herdrSessionName, paneId, event, to);
253
- const updated = this.#stores.agents.updateStatus({
254
- agentStatus: to,
255
- herdrSessionName: input.herdrSessionName,
256
- paneId,
257
- paneGeneration: eventGeneration,
258
- });
259
- const current = updated ?? { ...agent, agentStatus: to };
863
+ const current = { ...agent, agentStatus: to };
260
864
  const refreshed = await this.#context.refreshAgent({ agent: current, identityChanged: false });
261
865
  const scopes = new Map();
262
866
  for (const scope of recovered?.contextChangedScopes ?? [])
@@ -264,21 +868,44 @@ export class AgentIndexService {
264
868
  if (refreshed.changed || from !== to)
265
869
  addScope(scopes, scopeOf(current));
266
870
  const events = [...(recovered?.events ?? [])];
267
- const equivalent = events.some((candidate) => candidate.agentId === current.id &&
268
- candidate.type === statusEventType(to) &&
269
- candidate.payload.to === to);
270
- if (!equivalent) {
271
- const statusEvent = await this.#appendStatusEvents({
272
- agent: current,
871
+ const statusEventPlans = [...(recovered?.statusEventPlans ?? [])];
872
+ const equivalent = statusEventPlans.some((candidate) => candidate.agent.id === current.id && candidate.to === to);
873
+ const statusInput = {
874
+ agentStatus: to,
875
+ herdrSessionName: input.herdrSessionName,
876
+ paneId,
877
+ paneGeneration: eventGeneration,
878
+ };
879
+ if (from === to) {
880
+ this.#stores.agents.updateStatus(statusInput);
881
+ }
882
+ else if (!equivalent) {
883
+ const inserted = runSqliteTransaction(this.#stores.sqlite, () => {
884
+ const updated = this.#stores.agents.updateStatus(statusInput) ?? current;
885
+ const plan = this.#stores.statusEventPlans.insertPending({
886
+ agent: updated,
887
+ from,
888
+ herdrEventKey: herdrEventKey ?? null,
889
+ to,
890
+ ...(refreshed.snapshot.compactHistory
891
+ ? { compactHistory: refreshed.snapshot.compactHistory }
892
+ : {}),
893
+ });
894
+ return { plan, updated };
895
+ });
896
+ statusEventPlans.push({
897
+ agent: inserted.updated,
273
898
  compactHistory: refreshed.snapshot.compactHistory,
274
899
  from,
275
- herdrEventKey,
900
+ ...(herdrEventKey ? { herdrEventKey } : {}),
901
+ planId: inserted.plan.id,
276
902
  to,
277
903
  });
278
- if (statusEvent)
279
- events.push(statusEvent);
280
904
  }
281
- return { contextChangedScopes: sortedScopes(scopes), events };
905
+ else {
906
+ this.#stores.agents.updateStatus(statusInput);
907
+ }
908
+ return { contextChangedScopes: sortedScopes(scopes), events, statusEventPlans };
282
909
  }
283
910
  async #registerPiSessionRefNow(input) {
284
911
  const key = terminalSessionKey(input.herdrSessionName, input.terminalId);
@@ -320,89 +947,392 @@ export class AgentIndexService {
320
947
  });
321
948
  return result;
322
949
  }
950
+ #isClosedPaneAgent(herdrSessionName, agent) {
951
+ const paneId = stringValue(agent.pane_id) ?? stringValue(agent.paneId);
952
+ if (!paneId)
953
+ return false;
954
+ return this.#stores.agents.isPaneClosed({
955
+ herdrSessionName,
956
+ paneId,
957
+ paneGeneration: paneGenerationOf(agent),
958
+ });
959
+ }
960
+ #registerActiveWaiter(agent, controller) {
961
+ const key = `${agent.herdrSessionName}\0${agent.paneId}\0${agent.paneGeneration ?? ""}`;
962
+ let set = this.#activeWaiters.get(key);
963
+ if (!set) {
964
+ set = new Set();
965
+ this.#activeWaiters.set(key, set);
966
+ }
967
+ set.add(controller);
968
+ return () => {
969
+ set.delete(controller);
970
+ if (set.size === 0 && this.#activeWaiters.get(key) === set) {
971
+ this.#activeWaiters.delete(key);
972
+ }
973
+ };
974
+ }
975
+ #abortPendingWaiters(input) {
976
+ for (const [key, controllers] of this.#activeWaiters) {
977
+ const [sessionName, paneId, paneGen] = key.split("\0");
978
+ if (sessionName === input.herdrSessionName && paneId === input.paneId) {
979
+ if (!input.paneGeneration || !paneGen || input.paneGeneration === paneGen) {
980
+ for (const controller of controllers) {
981
+ controller.abort();
982
+ }
983
+ controllers.clear();
984
+ this.#activeWaiters.delete(key);
985
+ }
986
+ }
987
+ }
988
+ }
323
989
  async #appendStatusEvents(input) {
990
+ // Invariant: compactHistory is always populated as an object before calling #appendStatusEvents; !input.compactHistory check below is defensive and unreachable in normal operation.
324
991
  if (input.from === input.to || !input.compactHistory)
325
992
  return undefined;
326
- const latest = this.#stores.agentEvents.latestStatusTransition(input.agent.id, input.agent.herdrSessionName);
327
- if (latest && statusTransitionMatches(latest, input.from, input.to))
328
- return undefined;
329
- const observationId = `transition:${latest?.id ?? 0}`;
330
- let compactHistory = input.compactHistory;
331
- if (this.#turnCompletions !== undefined &&
332
- (input.to === "done" || input.to === "blocked") &&
333
- input.agent.agent === "pi" &&
334
- input.agent.terminalId !== null) {
335
- const turn = await this.#turnCompletions.waitForSignal({
993
+ if (this.#stores.agents.isPaneClosed({
994
+ herdrSessionName: input.agent.herdrSessionName,
995
+ paneId: input.agent.paneId,
996
+ paneGeneration: input.agent.paneGeneration ?? null,
997
+ })) {
998
+ console.debug("Herdsman skipping status event generation because pane is closed", {
999
+ agentId: input.agent.id,
336
1000
  herdrSessionName: input.agent.herdrSessionName,
337
- recordedAfterMs: Date.now() - TURN_SIGNAL_WAIT_MS,
338
- terminalId: input.agent.terminalId,
1001
+ paneId: input.agent.paneId,
339
1002
  });
340
- if (turn.received) {
341
- let refreshed = await this.#context.refreshAgent({
342
- agent: input.agent,
343
- identityChanged: false,
344
- forceRefresh: true,
345
- });
346
- for (let attempt = 0; attempt < 3 && refreshed.snapshot.compactHistory.lastAssistantMessage === null; attempt += 1) {
347
- await new Promise((resolve) => setTimeout(resolve, 300));
348
- refreshed = await this.#context.refreshAgent({
349
- agent: input.agent,
350
- identityChanged: false,
351
- forceRefresh: true,
1003
+ return undefined;
1004
+ }
1005
+ const latest = this.#stores.agentEvents.latestStatusTransition(input.agent.id, input.agent.herdrSessionName);
1006
+ let skipStatusChanged = false;
1007
+ if (latest && statusTransitionMatches(latest, input.from, input.to)) {
1008
+ const hasTerminalAfter = this.#stores.agentEvents.hasTerminalEventAfter(input.agent.id, input.agent.herdrSessionName, latest.id);
1009
+ if (!hasTerminalAfter) {
1010
+ if (statusEventType(input.to) === undefined) {
1011
+ console.debug("Herdsman skipping duplicate status transition event", {
1012
+ agentId: input.agent.id,
1013
+ from: input.from,
1014
+ to: input.to,
352
1015
  });
1016
+ return undefined;
353
1017
  }
354
- compactHistory = refreshed.snapshot.compactHistory;
355
- console.log(`Herdsman emitted pi agent.${input.to} after turn completion signal (confirmed=${turn.confirmed})`, {
356
- agentId: input.agent.id,
1018
+ skipStatusChanged = true;
1019
+ }
1020
+ if (hasTerminalAfter && !input.herdrEventKey) {
1021
+ const lastTerminal = this.#stores.agentEvents.latestTerminalEvent(input.agent.id, input.agent.herdrSessionName);
1022
+ if (sameTerminalAssistantContent(input.compactHistory, lastTerminal?.compactHistory, input.agent.agent)) {
1023
+ if ((input.attempts ?? 0) > 0) {
1024
+ throw new PlanWaitingHistoryError();
1025
+ }
1026
+ console.debug("Herdsman skipping legacy duplicate status transition event with identical history ref", {
1027
+ agentId: input.agent.id,
1028
+ from: input.from,
1029
+ to: input.to,
1030
+ });
1031
+ return undefined;
1032
+ }
1033
+ }
1034
+ }
1035
+ const observationId = `transition:${latest?.id ?? 0}`;
1036
+ const controller = new AbortController();
1037
+ const unregister = this.#registerActiveWaiter(input.agent, controller);
1038
+ try {
1039
+ let compactHistory = input.compactHistory;
1040
+ let payloadExtra = {};
1041
+ if (this.#turnCompletions !== undefined &&
1042
+ (input.to === "done" || input.to === "blocked") &&
1043
+ input.agent.terminalId !== null &&
1044
+ input.agent.agent === "pi") {
1045
+ const isRetry = (input.attempts ?? 0) > 0;
1046
+ const latestTerminal = this.#stores.agentEvents.latestTerminalEvent(input.agent.id, input.agent.herdrSessionName);
1047
+ const baseline = isRetry && latestTerminal?.compactHistory
1048
+ ? latestTerminal.compactHistory
1049
+ : input.compactHistory;
1050
+ const isAlreadyEmittedInRetry = isRetry &&
1051
+ latestTerminal?.compactHistory &&
1052
+ sameTerminalAssistantContent(input.compactHistory, latestTerminal.compactHistory, "pi");
1053
+ const turn = await this.#turnCompletions.waitForSignal({
357
1054
  herdrSessionName: input.agent.herdrSessionName,
1055
+ recordedAfterMs: Date.now() - TURN_SIGNAL_WAIT_MS,
1056
+ signal: controller.signal,
358
1057
  terminalId: input.agent.terminalId,
359
1058
  });
360
- }
361
- else {
362
- for (let attempt = 0; attempt < 3 && compactHistory.lastAssistantMessage === null; attempt += 1) {
363
- await new Promise((resolve) => setTimeout(resolve, 300));
364
- const refreshed = await this.#context.refreshAgent({
1059
+ if (controller.signal.aborted ||
1060
+ this.#stores.agents.isPaneClosed({
1061
+ herdrSessionName: input.agent.herdrSessionName,
1062
+ paneId: input.agent.paneId,
1063
+ paneGeneration: input.agent.paneGeneration ?? null,
1064
+ })) {
1065
+ console.debug("Herdsman skipping status event generation because turn wait was aborted or pane closed", {
1066
+ aborted: controller.signal.aborted,
1067
+ agentId: input.agent.id,
1068
+ herdrSessionName: input.agent.herdrSessionName,
1069
+ paneId: input.agent.paneId,
1070
+ });
1071
+ return undefined;
1072
+ }
1073
+ if (turn?.received) {
1074
+ const fresh = (await this.#context.refreshAgent({
365
1075
  agent: input.agent,
366
- identityChanged: false,
367
1076
  forceRefresh: true,
1077
+ identityChanged: false,
1078
+ })).snapshot.compactHistory;
1079
+ if (!isAlreadyEmittedInRetry && hasNonEmptyAssistantMessage(fresh)) {
1080
+ compactHistory = fresh;
1081
+ }
1082
+ else {
1083
+ const advanced = await this.#waitForHistoryAdvance({
1084
+ agent: input.agent,
1085
+ baseline,
1086
+ controller,
1087
+ initial: fresh,
1088
+ maxAttempts: 8,
1089
+ requireAssistantChange: isRetry,
1090
+ });
1091
+ if (advanced === undefined)
1092
+ return undefined;
1093
+ if (isRetry &&
1094
+ !historyHasAdvanced(advanced, baseline, { requireAssistantChange: true })) {
1095
+ throw new PlanWaitingHistoryError();
1096
+ }
1097
+ compactHistory = advanced;
1098
+ }
1099
+ console.log(`Herdsman emitted pi agent.${input.to} after turn completion signal (confirmed=${turn.confirmed})`, {
1100
+ agentId: input.agent.id,
1101
+ herdrSessionName: input.agent.herdrSessionName,
1102
+ terminalId: input.agent.terminalId,
1103
+ });
1104
+ }
1105
+ else if (!controller.signal.aborted) {
1106
+ const advanced = await this.#waitForHistoryAdvance({
1107
+ agent: input.agent,
1108
+ baseline,
1109
+ controller,
1110
+ maxAttempts: 8,
1111
+ requireAssistantChange: isRetry,
368
1112
  });
369
- compactHistory = refreshed.snapshot.compactHistory;
1113
+ if (advanced === undefined)
1114
+ return undefined;
1115
+ if (isRetry &&
1116
+ !historyHasAdvanced(advanced, baseline, { requireAssistantChange: true })) {
1117
+ throw new PlanWaitingHistoryError();
1118
+ }
1119
+ if (!isRetry &&
1120
+ !historyHasAdvanced(advanced, input.compactHistory, { requireAssistantChange: true })) {
1121
+ compactHistory = { ...advanced, lastAssistantMessage: null };
1122
+ payloadExtra = { noAdvance: true, staleSnapshot: false };
1123
+ }
1124
+ else {
1125
+ compactHistory = advanced;
1126
+ }
1127
+ console.warn(`Herdsman emitted pi agent.${input.to} without a turn completion signal after ${TURN_SIGNAL_WAIT_MS}ms`, {
1128
+ agentId: input.agent.id,
1129
+ herdrSessionName: input.agent.herdrSessionName,
1130
+ terminalId: input.agent.terminalId,
1131
+ });
1132
+ }
1133
+ }
1134
+ else if (input.agent.agent !== "pi" && (input.to === "idle" || input.to === "done")) {
1135
+ // Note: blocked is an interactive intermediate state, exempt from ready gate and empty delivery gate.
1136
+ const isReadyNonPi = (compact) => {
1137
+ if (!compact || !hasNonEmptyAssistantMessage(compact))
1138
+ return false;
1139
+ const latestTerminal = this.#stores.agentEvents.latestTerminalEvent(input.agent.id, input.agent.herdrSessionName);
1140
+ if (compact.source === "antigravity-sqlite" || input.agent.agent === "agy") {
1141
+ const prevRef = latestTerminal?.compactHistory?.lastAssistantMessage?.ref ?? null;
1142
+ const currentRef = compact.lastAssistantMessage?.ref ?? null;
1143
+ return currentRef !== null && currentRef !== prevRef;
1144
+ }
1145
+ if (!latestTerminal)
1146
+ return true;
1147
+ const currentMsg = compact.lastAssistantMessage;
1148
+ const prevMsg = latestTerminal.compactHistory?.lastAssistantMessage;
1149
+ const refChanged = (currentMsg?.ref ?? null) !== (prevMsg?.ref ?? null);
1150
+ const textChanged = (currentMsg?.text ?? "") !== (prevMsg?.text ?? "");
1151
+ const same = sameTerminalAssistantContent(compact, latestTerminal.compactHistory, input.agent.agent);
1152
+ return !same || refChanged || textChanged;
1153
+ };
1154
+ const latestTerminalForSkip = this.#stores.agentEvents.latestTerminalEvent(input.agent.id, input.agent.herdrSessionName);
1155
+ if (input.agent.agent !== "agy" &&
1156
+ compactHistory?.source !== "antigravity-sqlite" &&
1157
+ input.herdrEventKey &&
1158
+ latestTerminalForSkip &&
1159
+ hasNonEmptyAssistantMessage(compactHistory)) {
1160
+ const currentMsg = compactHistory.lastAssistantMessage;
1161
+ const prevMsg = latestTerminalForSkip.compactHistory?.lastAssistantMessage;
1162
+ const refChanged = (currentMsg?.ref ?? null) !== (prevMsg?.ref ?? null);
1163
+ const textChanged = (currentMsg?.text ?? "") !== (prevMsg?.text ?? "");
1164
+ const same = sameTerminalAssistantContent(compactHistory, latestTerminalForSkip.compactHistory, input.agent.agent);
1165
+ if (same && !refChanged && !textChanged) {
1166
+ return undefined;
1167
+ }
1168
+ }
1169
+ if (!isReadyNonPi(compactHistory)) {
1170
+ for (let attempt = 0; attempt < 8; attempt += 1) {
1171
+ await this.#sleep(500, controller.signal);
1172
+ if (controller.signal.aborted ||
1173
+ this.#stores.agents.isPaneClosed({
1174
+ herdrSessionName: input.agent.herdrSessionName,
1175
+ paneId: input.agent.paneId,
1176
+ paneGeneration: input.agent.paneGeneration ?? null,
1177
+ })) {
1178
+ console.debug("Herdsman skipping status event generation because wait was aborted or pane closed", {
1179
+ aborted: controller.signal.aborted,
1180
+ agentId: input.agent.id,
1181
+ herdrSessionName: input.agent.herdrSessionName,
1182
+ paneId: input.agent.paneId,
1183
+ });
1184
+ return undefined;
1185
+ }
1186
+ try {
1187
+ const refreshed = await this.#context.refreshAgent({
1188
+ agent: input.agent,
1189
+ forceRefresh: true,
1190
+ identityChanged: false,
1191
+ });
1192
+ compactHistory = refreshed.snapshot.compactHistory;
1193
+ }
1194
+ catch (err) {
1195
+ console.debug("Herdsman failed to refresh agent during ready wait", err);
1196
+ }
1197
+ if (isReadyNonPi(compactHistory)) {
1198
+ break;
1199
+ }
1200
+ }
370
1201
  }
371
- console.warn(`Herdsman emitted pi agent.${input.to} without a turn completion signal after ${TURN_SIGNAL_WAIT_MS}ms`, {
1202
+ if (!isReadyNonPi(compactHistory)) {
1203
+ throw new PlanWaitingHistoryError();
1204
+ }
1205
+ }
1206
+ if (controller.signal.aborted ||
1207
+ this.#stores.agents.isPaneClosed({
1208
+ herdrSessionName: input.agent.herdrSessionName,
1209
+ paneId: input.agent.paneId,
1210
+ paneGeneration: input.agent.paneGeneration ?? null,
1211
+ })) {
1212
+ console.debug("Herdsman skipping status event generation because turn wait was aborted or pane closed before append", {
1213
+ aborted: controller.signal.aborted,
372
1214
  agentId: input.agent.id,
373
1215
  herdrSessionName: input.agent.herdrSessionName,
374
- terminalId: input.agent.terminalId,
1216
+ paneId: input.agent.paneId,
375
1217
  });
1218
+ return undefined;
376
1219
  }
377
- }
378
- const lastEvent = this.#appendAndAckSelfEvent({
379
- agentId: input.agent.id,
380
- compactHistory,
381
- herdrSessionName: input.agent.herdrSessionName,
382
- idempotencyKey: idempotencyKey("agent.status.changed", input.agent, input.from, input.to, `${observationId}:${input.herdrEventKey ?? "legacy"}:agent.status.changed`),
383
- paneId: input.agent.paneId,
384
- paneGeneration: input.agent.paneGeneration ?? null,
385
- payload: payload(input.agent, input.from, input.to),
386
- terminalId: input.agent.terminalId,
387
- type: "agent.status.changed",
388
- workspaceId: input.agent.workspaceId,
389
- });
390
- const statusType = statusEventType(input.to);
391
- if (statusType) {
392
- return this.#appendAndAckSelfEvent({
393
- agentId: input.agent.id,
394
- compactHistory,
1220
+ const currentAgent = this.#stores.agents.findByPane({
395
1221
  herdrSessionName: input.agent.herdrSessionName,
396
- idempotencyKey: idempotencyKey(statusType, input.agent, input.from, input.to, `${observationId}:${input.herdrEventKey ?? "legacy"}:${statusType}`),
397
1222
  paneId: input.agent.paneId,
398
1223
  paneGeneration: input.agent.paneGeneration ?? null,
399
- payload: payload(input.agent, input.from, input.to),
400
- terminalId: input.agent.terminalId,
401
- type: statusType,
402
- workspaceId: input.agent.workspaceId,
403
1224
  });
1225
+ if (!currentAgent) {
1226
+ // The agent row is gone: appending would create a dangling event with
1227
+ // a dead agent_id that only the reconciler could sweep. Cancel the
1228
+ // plan instead of marking it completed (the skip->completed branch
1229
+ // must stay reserved for genuine skips).
1230
+ console.debug("Herdsman cancelling terminal status plan because agent row is gone", {
1231
+ agentId: input.agent.id,
1232
+ herdrSessionName: input.agent.herdrSessionName,
1233
+ paneId: input.agent.paneId,
1234
+ from: input.from,
1235
+ to: input.to,
1236
+ });
1237
+ return PLAN_CANCELLED;
1238
+ }
1239
+ let targetAgent;
1240
+ if (currentAgent.agentStatus !== input.to) {
1241
+ if (input.to === "done" ||
1242
+ input.to === "blocked" ||
1243
+ (input.to === "idle" && currentAgent.agent === "agy")) {
1244
+ console.info("Herdsman emitting terminal status event after subsequent status change", {
1245
+ agentId: input.agent.id,
1246
+ current: currentAgent.agentStatus,
1247
+ expected: input.to,
1248
+ herdrSessionName: input.agent.herdrSessionName,
1249
+ paneId: input.agent.paneId,
1250
+ });
1251
+ targetAgent = currentAgent;
1252
+ }
1253
+ else {
1254
+ console.debug("Herdsman skipping status event generation due to status mismatch", {
1255
+ agentId: input.agent.id,
1256
+ current: currentAgent.agentStatus,
1257
+ expected: input.to,
1258
+ herdrSessionName: input.agent.herdrSessionName,
1259
+ paneId: input.agent.paneId,
1260
+ });
1261
+ return undefined;
1262
+ }
1263
+ }
1264
+ else {
1265
+ targetAgent = currentAgent;
1266
+ }
1267
+ const eventPayload = { ...payload(targetAgent, input.from, input.to), ...payloadExtra };
1268
+ const lastEvent = skipStatusChanged
1269
+ ? undefined
1270
+ : this.#appendAndAckSelfEvent({
1271
+ agentId: targetAgent.id,
1272
+ compactHistory,
1273
+ herdrSessionName: targetAgent.herdrSessionName,
1274
+ idempotencyKey: idempotencyKey("agent.status.changed", targetAgent, input.from, input.to, `${observationId}:${input.herdrEventKey ?? "legacy"}:agent.status.changed`),
1275
+ paneId: targetAgent.paneId,
1276
+ paneGeneration: targetAgent.paneGeneration ?? null,
1277
+ payload: eventPayload,
1278
+ terminalId: targetAgent.terminalId,
1279
+ type: "agent.status.changed",
1280
+ workspaceId: targetAgent.workspaceId,
1281
+ });
1282
+ const statusType = statusEventType(input.to);
1283
+ if (statusType) {
1284
+ return this.#appendAndAckSelfEvent({
1285
+ agentId: targetAgent.id,
1286
+ compactHistory,
1287
+ herdrSessionName: targetAgent.herdrSessionName,
1288
+ idempotencyKey: idempotencyKey(statusType, targetAgent, input.from, input.to, `${observationId}:${input.herdrEventKey ?? "legacy"}:${statusType}`),
1289
+ paneId: targetAgent.paneId,
1290
+ paneGeneration: targetAgent.paneGeneration ?? null,
1291
+ payload: eventPayload,
1292
+ terminalId: targetAgent.terminalId,
1293
+ type: statusType,
1294
+ workspaceId: targetAgent.workspaceId,
1295
+ });
1296
+ }
1297
+ return lastEvent;
404
1298
  }
405
- return lastEvent;
1299
+ finally {
1300
+ unregister();
1301
+ }
1302
+ }
1303
+ #appendPlanFailedEvent(input) {
1304
+ let agent = input.agent;
1305
+ try {
1306
+ agent = this.#stores.agents.get(input.agent.id);
1307
+ }
1308
+ catch {
1309
+ const stitched = this.#minimalAgentFromLatestEvent(input.agent.id, input.agent.herdrSessionName);
1310
+ if (stitched)
1311
+ agent = stitched;
1312
+ }
1313
+ return this.#appendAndAckSelfEvent({
1314
+ agentId: agent.id,
1315
+ compactHistory: input.compactHistory ?? null,
1316
+ herdrSessionName: agent.herdrSessionName,
1317
+ idempotencyKey: `agent.failed:plan:${input.planId}`,
1318
+ paneId: agent.paneId,
1319
+ paneGeneration: agent.paneGeneration ?? null,
1320
+ payload: {
1321
+ agent: agent.agent,
1322
+ attempts: input.attempts,
1323
+ from: input.from,
1324
+ herdrSessionName: agent.herdrSessionName,
1325
+ name: agent.name,
1326
+ paneId: agent.paneId,
1327
+ reason: input.reason ?? "unknown",
1328
+ terminalId: agent.terminalId,
1329
+ to: input.to,
1330
+ workspaceId: agent.workspaceId,
1331
+ },
1332
+ terminalId: agent.terminalId,
1333
+ type: "agent.failed",
1334
+ workspaceId: agent.workspaceId,
1335
+ });
406
1336
  }
407
1337
  #appendAndAckSelfEvent(input) {
408
1338
  const event = this.#stores.agentEvents.append(input);
@@ -425,6 +1355,12 @@ function withPaneRevision(agent, revisionByPane) {
425
1355
  const revision = paneId ? revisionByPane.get(paneId) : undefined;
426
1356
  return revision === undefined ? raw : { ...raw, revision };
427
1357
  }
1358
+ function paneGenerationOf(agent) {
1359
+ return (stringValue(agent.pane_generation) ??
1360
+ stringValue(agent.paneGeneration) ??
1361
+ stringValue(agent.creation_id) ??
1362
+ stringValue(agent.creationId));
1363
+ }
428
1364
  function matchingPrior(agent, previousByTerminal, previousByPane) {
429
1365
  const terminalMatch = agent.terminalId ? previousByTerminal.get(agent.terminalId) : undefined;
430
1366
  const generationMatches = !agent.paneGeneration ||
@@ -495,12 +1431,11 @@ function payload(agent, from, to) {
495
1431
  function idempotencyKey(type, agent, from, to, observationId) {
496
1432
  return `${type}:${agent.herdrSessionName}:${agent.paneId}:${from}:${to}:${observationId}`;
497
1433
  }
498
- function herdrInputIdempotencyKey(sessionName, paneId, event, targetStatus) {
1434
+ function herdrInputIdempotencyKey(sessionName, paneId, event, _targetStatus) {
499
1435
  const eventId = stringValue(event.id) ?? stringValue(event.event_id) ?? stringValue(event.eventId);
500
- if (eventId)
501
- return `${sessionName}:${paneId}:${String(event.type)}:${eventId}`;
502
- const revision = integerValue(event.revision) ?? integerValue(event.pane_revision) ?? "unknown";
503
- return `${sessionName}:${paneId}:${revision}:${targetStatus}`;
1436
+ if (!eventId)
1437
+ return undefined;
1438
+ return `${sessionName}:${paneId}:${String(event.type)}:${eventId}`;
504
1439
  }
505
1440
  function statusTransitionMatches(event, from, to) {
506
1441
  const eventPayload = event.payload;
@@ -521,4 +1456,41 @@ function stringValue(value) {
521
1456
  function integerValue(value) {
522
1457
  return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
523
1458
  }
1459
+ function sleep(ms, signal) {
1460
+ if (signal?.aborted)
1461
+ return Promise.resolve();
1462
+ return new Promise((resolve) => {
1463
+ let timer;
1464
+ const onAbort = () => {
1465
+ if (timer !== undefined)
1466
+ clearTimeout(timer);
1467
+ resolve();
1468
+ };
1469
+ timer = setTimeout(() => {
1470
+ signal?.removeEventListener("abort", onAbort);
1471
+ resolve();
1472
+ }, ms);
1473
+ signal?.addEventListener("abort", onAbort, { once: true });
1474
+ });
1475
+ }
1476
+ function sameTerminalAssistantContent(current, previous, agentKind) {
1477
+ const currentMsg = current?.lastAssistantMessage;
1478
+ const prevMsg = previous?.lastAssistantMessage;
1479
+ if (!currentMsg || !prevMsg)
1480
+ return false;
1481
+ if (agentKind === "agy" || current?.source === "antigravity-sqlite") {
1482
+ if (currentMsg.ref !== null && prevMsg.ref !== null) {
1483
+ return currentMsg.ref === prevMsg.ref;
1484
+ }
1485
+ return Boolean(currentMsg.text && currentMsg.text === prevMsg.text);
1486
+ }
1487
+ // Non-agy (pi, claude, codex, grok, opencode):
1488
+ if (currentMsg.ref !== null && prevMsg.ref !== null) {
1489
+ return currentMsg.ref === prevMsg.ref;
1490
+ }
1491
+ if (currentMsg.text.length > 0 && prevMsg.text.length > 0) {
1492
+ return currentMsg.text === prevMsg.text;
1493
+ }
1494
+ return false;
1495
+ }
524
1496
  //# sourceMappingURL=agent-index-service.js.map