@dorokuma/herdsman 0.12.1 → 0.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -13
- package/dist/src/cli/herdsman.d.ts +14 -1
- package/dist/src/cli/herdsman.d.ts.map +1 -1
- package/dist/src/cli/herdsman.js +49 -65
- package/dist/src/cli/herdsman.js.map +1 -1
- package/dist/src/daemon/agent-event-reconciler.d.ts.map +1 -1
- package/dist/src/daemon/agent-event-reconciler.js +53 -2
- package/dist/src/daemon/agent-event-reconciler.js.map +1 -1
- package/dist/src/daemon/herdr-session-watch-manager.d.ts +13 -1
- package/dist/src/daemon/herdr-session-watch-manager.d.ts.map +1 -1
- package/dist/src/daemon/herdr-session-watch-manager.js +52 -16
- package/dist/src/daemon/herdr-session-watch-manager.js.map +1 -1
- package/dist/src/daemon/observability-server.d.ts.map +1 -1
- package/dist/src/daemon/observability-server.js +16 -1
- package/dist/src/daemon/observability-server.js.map +1 -1
- package/dist/src/daemon/process-manager.d.ts +50 -49
- package/dist/src/daemon/process-manager.d.ts.map +1 -1
- package/dist/src/daemon/process-manager.js +246 -204
- package/dist/src/daemon/process-manager.js.map +1 -1
- package/dist/src/daemon/service-supervision.d.ts +68 -0
- package/dist/src/daemon/service-supervision.d.ts.map +1 -0
- package/dist/src/daemon/service-supervision.js +127 -0
- package/dist/src/daemon/service-supervision.js.map +1 -0
- package/dist/src/daemon/service.d.ts +35 -0
- package/dist/src/daemon/service.d.ts.map +1 -1
- package/dist/src/daemon/service.js +185 -27
- package/dist/src/daemon/service.js.map +1 -1
- package/dist/src/db/agent-events.d.ts +15 -0
- package/dist/src/db/agent-events.d.ts.map +1 -1
- package/dist/src/db/agent-events.js +180 -54
- package/dist/src/db/agent-events.js.map +1 -1
- package/dist/src/db/agents.js +1 -1
- package/dist/src/db/agents.js.map +1 -1
- package/dist/src/db/status-event-plans.d.ts +1 -1
- package/dist/src/db/status-event-plans.d.ts.map +1 -1
- package/dist/src/db/status-event-plans.js +5 -4
- package/dist/src/db/status-event-plans.js.map +1 -1
- package/dist/src/herdr/session-list.d.ts +11 -0
- package/dist/src/herdr/session-list.d.ts.map +1 -1
- package/dist/src/herdr/session-list.js +13 -0
- package/dist/src/herdr/session-list.js.map +1 -1
- package/dist/src/herdr/socket-client.d.ts +8 -0
- package/dist/src/herdr/socket-client.d.ts.map +1 -1
- package/dist/src/herdr/socket-client.js +43 -0
- package/dist/src/herdr/socket-client.js.map +1 -1
- package/dist/src/observability/agent-index-service.d.ts +15 -0
- package/dist/src/observability/agent-index-service.d.ts.map +1 -1
- package/dist/src/observability/agent-index-service.js +499 -135
- package/dist/src/observability/agent-index-service.js.map +1 -1
- package/dist/src/observability/agent-orchestrator-service.d.ts.map +1 -1
- package/dist/src/observability/agent-orchestrator-service.js +5 -1
- package/dist/src/observability/agent-orchestrator-service.js.map +1 -1
- package/dist/src/observability/contracts.d.ts +1 -0
- package/dist/src/observability/contracts.d.ts.map +1 -1
- package/dist/src/observability/contracts.js +8 -0
- package/dist/src/observability/contracts.js.map +1 -1
- package/drizzle/0010_legacy_degraded_cleanup.sql +1 -0
- package/drizzle/meta/0010_snapshot.json +975 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +3 -6
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
|
390
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
876
|
+
return undefined;
|
|
661
877
|
}
|
|
662
878
|
if (updated.status === "pending") {
|
|
663
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1378
|
-
|
|
1379
|
-
degraded
|
|
1380
|
-
|
|
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
|
-
|
|
1651
|
-
|
|
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
|
|
2005
|
+
return { agent: this.#stores.agents.get(agent.id), agentRowPresent: true };
|
|
1654
2006
|
}
|
|
1655
2007
|
catch {
|
|
1656
|
-
const stitched = this.#minimalAgentFromLatestEvent(
|
|
1657
|
-
|
|
1658
|
-
|
|
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
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
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:
|
|
2067
|
+
agentId: originalAgentId,
|
|
1704
2068
|
attempts: input.attempts,
|
|
1705
2069
|
from: input.from,
|
|
1706
2070
|
herdrSessionName: agent.herdrSessionName,
|