@agent-relay/factory 0.1.65 → 0.1.66
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/dist/cli/diagnose.d.ts.map +1 -1
- package/dist/cli/diagnose.js +35 -0
- package/dist/cli/diagnose.js.map +1 -1
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +10 -0
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +65 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +28 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/orchestrator/batch-tracker.d.ts +7 -0
- package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
- package/dist/orchestrator/batch-tracker.js +1 -0
- package/dist/orchestrator/batch-tracker.js.map +1 -1
- package/dist/orchestrator/factory.d.ts +18 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +410 -36
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/public-health.d.ts.map +1 -1
- package/dist/orchestrator/public-health.js +120 -1
- package/dist/orchestrator/public-health.js.map +1 -1
- package/dist/ports/state.d.ts +9 -0
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/state/dispatch-lifecycle-slot.d.ts +45 -0
- package/dist/state/dispatch-lifecycle-slot.d.ts.map +1 -0
- package/dist/state/dispatch-lifecycle-slot.js +67 -0
- package/dist/state/dispatch-lifecycle-slot.js.map +1 -0
- package/dist/state/file-state-store.d.ts.map +1 -1
- package/dist/state/file-state-store.js +7 -17
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/state/in-memory-state-store.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.js +6 -17
- package/dist/state/in-memory-state-store.js.map +1 -1
- package/dist/state/watch-state-document.js +1 -0
- package/dist/state/watch-state-document.js.map +1 -1
- package/dist/triage/schema.d.ts +14 -14
- package/dist/types.d.ts +74 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ import { factoryGithubIssueCommentDraftName, isFactoryGithubIssueCommentDraftNam
|
|
|
9
9
|
import { VerificationPipeline } from '../environments/verification-pipeline.js';
|
|
10
10
|
import { factoryWorktreeIssueSlug, factoryWorktreePath } from '../git/agent-worktree.js';
|
|
11
11
|
import { InMemoryStateStore } from '../state/in-memory-state-store.js';
|
|
12
|
+
import { dispatchHandedOffToBabysitters, dispatchLifecycleOccupiesSlot, dispatchPhaseOccupiesSlot, } from '../state/dispatch-lifecycle-slot.js';
|
|
12
13
|
import { containsExplicitIssueReference, containsIssueKey, factoryBranchBelongsToIssue } from '../issue-key-match.js';
|
|
13
14
|
import { normalizeLogger, normalizeLogValue, setSafeErrorStack, stringifyLogValue } from '../logging.js';
|
|
14
15
|
import { isInFactoryScope } from '../safety/factory-scope.js';
|
|
@@ -141,9 +142,28 @@ const STOP_TEARDOWN_TIMEOUT_MS = 2_500;
|
|
|
141
142
|
const DISPATCH_LIFECYCLE_LEASE_MS = 5 * 60_000;
|
|
142
143
|
const DISPATCH_LIFECYCLE_RENEW_MS = 60_000;
|
|
143
144
|
const DISPATCH_LIFECYCLE_RETRY_MS = 1_000;
|
|
145
|
+
/**
|
|
146
|
+
* Ceiling on the durable capacity-wait re-arm (#303).
|
|
147
|
+
*
|
|
148
|
+
* The retry was a flat 1 Hz with no bound at all. When the batch was wedged,
|
|
149
|
+
* every queued issue re-read the shared state document once a second forever —
|
|
150
|
+
* production measured 1477 state GETs in 111 s — and the timers could not even
|
|
151
|
+
* keep up, so they coalesced into a continuous spin against the serialized
|
|
152
|
+
* store. Waiting for capacity is legitimate and must not be abandoned (a real
|
|
153
|
+
* multi-hour run holds the slot honestly), so what is bounded is the *rate*.
|
|
154
|
+
*/
|
|
155
|
+
const DISPATCH_LIFECYCLE_RETRY_MAX_MS = 30_000;
|
|
156
|
+
/** Rate limit for the capacity-wait warning once the backoff has capped. */
|
|
157
|
+
const DISPATCH_LIFECYCLE_CAPACITY_WAIT_LOG_MS = 60_000;
|
|
144
158
|
const DISPATCH_WRITEBACK_MAX_ATTEMPTS = 3;
|
|
145
159
|
const DISPATCH_WRITEBACK_RETRY_MS = 250;
|
|
146
160
|
const HELD_PAST_DEADLINE_RELEASE_REASON = 'held-past-deadline';
|
|
161
|
+
/**
|
|
162
|
+
* Release reason for a lifecycle that took a batch slot and never placed an
|
|
163
|
+
* agent (#303). Deliberately distinct from `held-past-deadline`: that one
|
|
164
|
+
* means a team ran and never finished, this one means no team ever existed.
|
|
165
|
+
*/
|
|
166
|
+
const AGENTLESS_SLOT_PAST_DEADLINE_RELEASE_REASON = 'agentless-slot-past-deadline';
|
|
147
167
|
const HELD_DEADLINE_OVERDUE_RETRY_MS = 1_000;
|
|
148
168
|
const STARTUP_AGENT_EXIT_DRAIN_TIMEOUT_MS = 30_000;
|
|
149
169
|
const RECONCILED_AGENT_EXIT_CONCURRENCY = 4;
|
|
@@ -417,7 +437,17 @@ export class FactoryLoop {
|
|
|
417
437
|
#dispatchLifecycleRetryTimers = new Map();
|
|
418
438
|
#dispatchLifecycleDrives = new Set();
|
|
419
439
|
#abandonedDispatchReasons = new Map();
|
|
420
|
-
|
|
440
|
+
/**
|
|
441
|
+
* Live batch-capacity waits, keyed by issue (#303).
|
|
442
|
+
*
|
|
443
|
+
* Replaces a `Set` of "already logged" keys. That set made the wait a
|
|
444
|
+
* one-shot log and nothing else: after the first line, an outage in which
|
|
445
|
+
* every issue was stuck behind a wedged slot was indistinguishable from an
|
|
446
|
+
* idle Factory on every operator surface. The wait now carries its own start
|
|
447
|
+
* instant and attempt count, which is what both the escalating warning and
|
|
448
|
+
* `status().dispatchCapacity` are derived from.
|
|
449
|
+
*/
|
|
450
|
+
#dispatchLifecycleCapacityWaits = new Map();
|
|
421
451
|
#dispatchLifecycleOwnershipWaitLogged = new Set();
|
|
422
452
|
#dispatchClaimStatuses = new Map();
|
|
423
453
|
#localReleaseCheckpoints = new Map();
|
|
@@ -1010,6 +1040,7 @@ export class FactoryLoop {
|
|
|
1010
1040
|
clearTimeout(timer);
|
|
1011
1041
|
this.#dispatchLifecycleRetryTimers.clear();
|
|
1012
1042
|
this.#abandonedDispatchReasons.clear();
|
|
1043
|
+
this.#dispatchLifecycleCapacityWaits.clear();
|
|
1013
1044
|
this.#dispatchLifecycleOwnershipWaitLogged.clear();
|
|
1014
1045
|
if (this.#completionSweepTimer)
|
|
1015
1046
|
clearTimeout(this.#completionSweepTimer);
|
|
@@ -4037,6 +4068,14 @@ export class FactoryLoop {
|
|
|
4037
4068
|
if (!await this.#saveDispatchLifecycle(record, 'dispatching')) {
|
|
4038
4069
|
throw new Error(`Dispatch lifecycle ownership lost immediately before spawning ${dispatchDecision.issue.key}`);
|
|
4039
4070
|
}
|
|
4071
|
+
// That save stamped `slotHeldSinceAtMs`: the row occupies a batch slot from
|
|
4072
|
+
// here on. Arm its deadline before the first await rather than after a
|
|
4073
|
+
// placement succeeds — `#fleet.spawn` deliberately carries no mutation
|
|
4074
|
+
// timeout, so a first attempt that hangs in an otherwise idle process would
|
|
4075
|
+
// otherwise hold the slot with no timer that can ever fire. That is the
|
|
4076
|
+
// exact shape of #303, reached through the fresh dispatch path instead of
|
|
4077
|
+
// the durable one (#303 review, CodeRabbit).
|
|
4078
|
+
this.#scheduleHeldAgentDeadline(record);
|
|
4040
4079
|
if (!dryRun)
|
|
4041
4080
|
await this.#ensureGithubAgentQuestionWatch(record, liveIssue);
|
|
4042
4081
|
const spawnedForReaperHandoff = [];
|
|
@@ -4228,6 +4267,7 @@ export class FactoryLoop {
|
|
|
4228
4267
|
slackDegradedReason: this.#slackDegradedReason,
|
|
4229
4268
|
eventListener: this.#eventListenerStatus(),
|
|
4230
4269
|
readinessReconcile: this.#readinessReconcileStatus(),
|
|
4270
|
+
dispatchCapacity: this.#dispatchCapacityStatus(),
|
|
4231
4271
|
heldAgents: batch?.inFlight.flatMap((record) => heldAgentsForRecord(record, nowMs, this.#config.dispatch.agentHoldTimeoutMs, this.#config.terminalState)) ?? [],
|
|
4232
4272
|
};
|
|
4233
4273
|
}
|
|
@@ -4644,7 +4684,7 @@ export class FactoryLoop {
|
|
|
4644
4684
|
if (timer)
|
|
4645
4685
|
clearTimeout(timer);
|
|
4646
4686
|
this.#dispatchLifecycleRetryTimers.delete(key);
|
|
4647
|
-
this.#
|
|
4687
|
+
this.#dispatchLifecycleCapacityWaits.delete(key);
|
|
4648
4688
|
clearedKeys.add(key);
|
|
4649
4689
|
this.#increment('dispatchLifecycleGithubAliasesCollapsed');
|
|
4650
4690
|
}
|
|
@@ -4702,10 +4742,67 @@ export class FactoryLoop {
|
|
|
4702
4742
|
}, DISPATCH_LIFECYCLE_RENEW_MS);
|
|
4703
4743
|
this.#dispatchLifecycleRenewTimer.unref?.();
|
|
4704
4744
|
}
|
|
4745
|
+
/**
|
|
4746
|
+
* The wall-clock deadline that can free this record's batch slot, if any.
|
|
4747
|
+
*
|
|
4748
|
+
* Two clocks, never both. `heldSinceAtMs` is stamped by the first successful
|
|
4749
|
+
* placement and bounds a team that ran and never reached a terminal state.
|
|
4750
|
+
* A record that has no `heldSinceAtMs` never had a placement at all, and
|
|
4751
|
+
* before #303 that made it permanently unreapable: `#scheduleHeldAgentDeadline`
|
|
4752
|
+
* armed no timer and `#sweepHeldAgentDeadlines` skipped it, so a row that
|
|
4753
|
+
* reached `dispatching` and lost its process held the only batch slot
|
|
4754
|
+
* forever. Such a row is definitionally stuck — nothing but a placement can
|
|
4755
|
+
* move it, and no placement is coming — so it gets the much shorter
|
|
4756
|
+
* `agentlessHoldTimeoutMs` anchored on when it took the slot.
|
|
4757
|
+
*
|
|
4758
|
+
* The anchor is deliberately not `agents.size === 0`: `recordPlanned` writes
|
|
4759
|
+
* the spec before the spawn returns, so a process that died mid-spawn leaves
|
|
4760
|
+
* an agent entry with no result and still no placement.
|
|
4761
|
+
*/
|
|
4762
|
+
/**
|
|
4763
|
+
* Does this in-flight record hold a `batchSize` slot right now?
|
|
4764
|
+
*
|
|
4765
|
+
* The same predicate the state stores apply to the durable row, asked of the
|
|
4766
|
+
* in-memory one. Phase alone is not it: once every implementer repo has been
|
|
4767
|
+
* handed to a babysitter, admission stops counting the lifecycle, so
|
|
4768
|
+
* reporting it as an occupant would name slots that are not blocking
|
|
4769
|
+
* anything (#303 review, codex).
|
|
4770
|
+
*/
|
|
4771
|
+
#recordOccupiesSlot(record) {
|
|
4772
|
+
return dispatchPhaseOccupiesSlot(record.lifecyclePhase) && !dispatchHandedOffToBabysitters(record.decision.implementers, [...record.agents.values()].map((tracked) => ({ releasedAtMs: tracked.releasedAtMs, spec: tracked.spec })));
|
|
4773
|
+
}
|
|
4774
|
+
#holdDeadline(record) {
|
|
4775
|
+
if (record.dryRun)
|
|
4776
|
+
return undefined;
|
|
4777
|
+
if (record.heldSinceAtMs !== undefined) {
|
|
4778
|
+
const timeoutMs = this.#config.dispatch.agentHoldTimeoutMs;
|
|
4779
|
+
return {
|
|
4780
|
+
kind: 'agents',
|
|
4781
|
+
sinceAtMs: record.heldSinceAtMs,
|
|
4782
|
+
timeoutMs,
|
|
4783
|
+
dueAtMs: record.heldSinceAtMs + timeoutMs,
|
|
4784
|
+
};
|
|
4785
|
+
}
|
|
4786
|
+
// Only a row that is actually holding a slot is worth reaping; a `queued`
|
|
4787
|
+
// or `waiting-for-human` row costs nothing and may wait indefinitely.
|
|
4788
|
+
if (record.slotHeldSinceAtMs === undefined || !this.#recordOccupiesSlot(record)) {
|
|
4789
|
+
return undefined;
|
|
4790
|
+
}
|
|
4791
|
+
const timeoutMs = this.#config.dispatch.agentlessHoldTimeoutMs;
|
|
4792
|
+
return {
|
|
4793
|
+
kind: 'agentless',
|
|
4794
|
+
sinceAtMs: record.slotHeldSinceAtMs,
|
|
4795
|
+
timeoutMs,
|
|
4796
|
+
dueAtMs: record.slotHeldSinceAtMs + timeoutMs,
|
|
4797
|
+
};
|
|
4798
|
+
}
|
|
4705
4799
|
#scheduleHeldAgentDeadline(record) {
|
|
4706
|
-
if (this.#stopping
|
|
4800
|
+
if (this.#stopping)
|
|
4707
4801
|
return;
|
|
4708
|
-
const
|
|
4802
|
+
const deadline = this.#holdDeadline(record);
|
|
4803
|
+
if (!deadline)
|
|
4804
|
+
return;
|
|
4805
|
+
const dueAtMs = deadline.dueAtMs;
|
|
4709
4806
|
if (this.#heldAgentDeadlineTimer &&
|
|
4710
4807
|
this.#heldAgentDeadlineDueAtMs !== undefined &&
|
|
4711
4808
|
this.#heldAgentDeadlineDueAtMs <= dueAtMs)
|
|
@@ -4745,14 +4842,16 @@ export class FactoryLoop {
|
|
|
4745
4842
|
}
|
|
4746
4843
|
async #sweepHeldAgentDeadlines() {
|
|
4747
4844
|
const nowMs = this.#clock.now();
|
|
4748
|
-
const timeoutMs = this.#config.dispatch.agentHoldTimeoutMs;
|
|
4749
4845
|
for (const record of [...(await this.#batch()).inFlight]) {
|
|
4750
|
-
const
|
|
4751
|
-
if (
|
|
4752
|
-
heldSinceAtMs === undefined ||
|
|
4753
|
-
record.agents.size === 0 ||
|
|
4754
|
-
nowMs - heldSinceAtMs < timeoutMs)
|
|
4846
|
+
const deadline = this.#holdDeadline(record);
|
|
4847
|
+
if (!deadline || nowMs < deadline.dueAtMs)
|
|
4755
4848
|
continue;
|
|
4849
|
+
// The durable row wins the classification when there is one. The
|
|
4850
|
+
// in-memory record can lag a placement made in another process, and
|
|
4851
|
+
// reading the stale one would relabel a team that did run as
|
|
4852
|
+
// never-placed — which also excludes its agents from the release below,
|
|
4853
|
+
// leaking live workers (#303 review, CodeRabbit).
|
|
4854
|
+
let effective = deadline;
|
|
4756
4855
|
const key = issueKey(record.issue);
|
|
4757
4856
|
if (this.#abandonedDispatchReasons.has(key))
|
|
4758
4857
|
continue;
|
|
@@ -4766,26 +4865,42 @@ export class FactoryLoop {
|
|
|
4766
4865
|
await this.#finishDurableRelease(record, lifecycle.releaseReason);
|
|
4767
4866
|
continue;
|
|
4768
4867
|
}
|
|
4868
|
+
// Re-derive against the durable row before tearing anything down. The
|
|
4869
|
+
// in-memory record can be a beat behind a placement that just
|
|
4870
|
+
// succeeded in this process or a takeover in another, and the
|
|
4871
|
+
// never-placed deadline exists precisely to catch rows nothing is
|
|
4872
|
+
// moving — it must not be what ends a dispatch that just started
|
|
4873
|
+
// moving (#303 must-not-fire).
|
|
4874
|
+
const durable = this.#holdDeadline(inFlightRecordFromLifecycle(lifecycle));
|
|
4875
|
+
if (!durable || this.#clock.now() < durable.dueAtMs)
|
|
4876
|
+
continue;
|
|
4877
|
+
effective = durable;
|
|
4769
4878
|
if (!await this.#assertDispatchLifecycleOwner(record))
|
|
4770
4879
|
continue;
|
|
4771
4880
|
}
|
|
4772
|
-
const
|
|
4881
|
+
const agentless = effective.kind === 'agentless';
|
|
4882
|
+
const heldForMs = Math.max(0, this.#clock.now() - effective.sinceAtMs);
|
|
4773
4883
|
const details = {
|
|
4774
4884
|
issue: record.issue.key,
|
|
4775
4885
|
heldForMs,
|
|
4776
|
-
holdTimeoutMs: timeoutMs,
|
|
4886
|
+
holdTimeoutMs: effective.timeoutMs,
|
|
4777
4887
|
waitingForTerminalState: this.#config.terminalState,
|
|
4778
|
-
reason: HELD_PAST_DEADLINE_RELEASE_REASON,
|
|
4888
|
+
reason: agentless ? AGENTLESS_SLOT_PAST_DEADLINE_RELEASE_REASON : HELD_PAST_DEADLINE_RELEASE_REASON,
|
|
4779
4889
|
agents: [...record.agents.keys()].sort(),
|
|
4890
|
+
...(agentless ? { phase: record.lifecyclePhase } : {}),
|
|
4780
4891
|
};
|
|
4781
|
-
this.#logger.warn?.(
|
|
4782
|
-
|
|
4892
|
+
this.#logger.warn?.(agentless
|
|
4893
|
+
? '[factory] releasing a dispatch lifecycle that never placed an agent'
|
|
4894
|
+
: '[factory] releasing agents held past deadline', details);
|
|
4895
|
+
await this.#abandonStuckDispatch(record, details.reason);
|
|
4783
4896
|
const lifecycle = this.#usesDurableDispatchLifecycle()
|
|
4784
4897
|
? await this.#state.getDispatchLifecycle(this.#workspaceId, key)
|
|
4785
4898
|
: undefined;
|
|
4786
4899
|
if (!lifecycle || isTerminalDispatchLifecycle(lifecycle)) {
|
|
4787
|
-
this.#increment('heldPastDeadlineReleases');
|
|
4788
|
-
this.#logger.warn?.(
|
|
4900
|
+
this.#increment(agentless ? 'agentlessSlotPastDeadlineReleases' : 'heldPastDeadlineReleases');
|
|
4901
|
+
this.#logger.warn?.(agentless
|
|
4902
|
+
? '[factory] released a dispatch lifecycle that never placed an agent'
|
|
4903
|
+
: '[factory] released agents held past deadline', details);
|
|
4789
4904
|
}
|
|
4790
4905
|
}
|
|
4791
4906
|
}
|
|
@@ -5156,6 +5271,14 @@ export class FactoryLoop {
|
|
|
5156
5271
|
}
|
|
5157
5272
|
async #saveDispatchLifecycle(record, phase, pullRequest, releaseReason, releasedAgentNames = new Set(), telemetry = {}) {
|
|
5158
5273
|
record.lifecyclePhase = phase;
|
|
5274
|
+
// Mirror what the store stamps, so the reaper's never-placed clock is
|
|
5275
|
+
// readable from the in-memory record between durable reads (#303). The
|
|
5276
|
+
// store still owns the authoritative value; this uses the same predicate so
|
|
5277
|
+
// the two cannot disagree.
|
|
5278
|
+
if (this.#recordOccupiesSlot(record))
|
|
5279
|
+
record.slotHeldSinceAtMs ??= this.#clock.now();
|
|
5280
|
+
else
|
|
5281
|
+
record.slotHeldSinceAtMs = undefined;
|
|
5159
5282
|
if (record.dryRun || !this.#usesDurableDispatchLifecycle())
|
|
5160
5283
|
return true;
|
|
5161
5284
|
if (isTerminalDispatchPhase(phase))
|
|
@@ -5223,6 +5346,20 @@ export class FactoryLoop {
|
|
|
5223
5346
|
if (isTerminalDispatchLifecycle(lifecycle)) {
|
|
5224
5347
|
this.#dispatchLifecycleEpochs.delete(key);
|
|
5225
5348
|
}
|
|
5349
|
+
// Wake the capacity waiters exactly when this write gives a slot back —
|
|
5350
|
+
// occupied before, not occupied after — and never otherwise (#303
|
|
5351
|
+
// review, cubic).
|
|
5352
|
+
//
|
|
5353
|
+
// Not "when it goes terminal". `releasing` already does not occupy a
|
|
5354
|
+
// slot, so a normal completion frees it one save *before* `complete`,
|
|
5355
|
+
// and a babysitter handoff frees it without ever going terminal at all.
|
|
5356
|
+
// Keying on the terminal save alone therefore both fires for rows that
|
|
5357
|
+
// freed nothing (a `queued` row abandoned at startup) and misses the
|
|
5358
|
+
// writes that actually freed something. The occupancy transition is the
|
|
5359
|
+
// event; the phase is only a proxy for it.
|
|
5360
|
+
if (previous && dispatchLifecycleOccupiesSlot(previous) && !dispatchLifecycleOccupiesSlot(lifecycle)) {
|
|
5361
|
+
this.#resetDispatchCapacityBackoff();
|
|
5362
|
+
}
|
|
5226
5363
|
return true;
|
|
5227
5364
|
});
|
|
5228
5365
|
}
|
|
@@ -5236,7 +5373,130 @@ export class FactoryLoop {
|
|
|
5236
5373
|
resolve(phase);
|
|
5237
5374
|
this.#dispatchTerminalWaiters.delete(key);
|
|
5238
5375
|
}
|
|
5239
|
-
|
|
5376
|
+
/**
|
|
5377
|
+
* Re-arm delay for a capacity wait: 1 s doubling to a 30 s ceiling (#303).
|
|
5378
|
+
*
|
|
5379
|
+
* Only the capacity path backs off. An ownership wait is already bounded by
|
|
5380
|
+
* `DISPATCH_LIFECYCLE_LEASE_MS`, and every other failure is a real error
|
|
5381
|
+
* whose fast retry is the recovery. A capacity wait has no bound at all —
|
|
5382
|
+
* it ends when some other lifecycle terminates, which may be hours away or,
|
|
5383
|
+
* before this fix, never.
|
|
5384
|
+
*/
|
|
5385
|
+
#capacityRetryDelayMs(attempts) {
|
|
5386
|
+
return Math.min(DISPATCH_LIFECYCLE_RETRY_MS * 2 ** Math.max(0, attempts - 1), DISPATCH_LIFECYCLE_RETRY_MAX_MS);
|
|
5387
|
+
}
|
|
5388
|
+
/** Issue keys currently holding a `batchSize` slot, for operator surfaces. */
|
|
5389
|
+
#dispatchSlotOccupants() {
|
|
5390
|
+
return (this.#batchView?.inFlight ?? [])
|
|
5391
|
+
.filter((record) => !record.dryRun && this.#recordOccupiesSlot(record))
|
|
5392
|
+
.map((record) => ({
|
|
5393
|
+
issue: record.issue.key,
|
|
5394
|
+
...(record.lifecyclePhase ? { phase: record.lifecyclePhase } : {}),
|
|
5395
|
+
agents: record.agents.size,
|
|
5396
|
+
// Specs, not workers: `recordPlanned` writes an entry before the spawn
|
|
5397
|
+
// returns, so `agents > 0` is not proof of a placement (#303 review).
|
|
5398
|
+
placedAgents: [...record.agents.values()].filter((tracked) => tracked.result !== undefined).length,
|
|
5399
|
+
...(record.heldSinceAtMs !== undefined
|
|
5400
|
+
? { heldForMs: Math.max(0, this.#clock.now() - record.heldSinceAtMs) }
|
|
5401
|
+
: {}),
|
|
5402
|
+
...(record.slotHeldSinceAtMs !== undefined
|
|
5403
|
+
? { slotHeldForMs: Math.max(0, this.#clock.now() - record.slotHeldSinceAtMs) }
|
|
5404
|
+
: {}),
|
|
5405
|
+
}))
|
|
5406
|
+
.sort((left, right) => left.issue.localeCompare(right.issue));
|
|
5407
|
+
}
|
|
5408
|
+
/**
|
|
5409
|
+
* Batch occupancy as an operator-readable fact (#303).
|
|
5410
|
+
*
|
|
5411
|
+
* Before this, a full batch was visible only as the *absence* of dispatch:
|
|
5412
|
+
* `readinessReconcile` stayed green, `consecutiveFailures` stayed 0, and the
|
|
5413
|
+
* one capacity log had fired hours earlier. Publishing occupancy is what
|
|
5414
|
+
* turns "nothing is being dispatched" into a question an operator can answer
|
|
5415
|
+
* without reading the state document.
|
|
5416
|
+
*/
|
|
5417
|
+
#dispatchCapacityStatus() {
|
|
5418
|
+
const nowMs = this.#clock.now();
|
|
5419
|
+
const occupants = this.#dispatchSlotOccupants();
|
|
5420
|
+
const waits = [...this.#dispatchLifecycleCapacityWaits.entries()];
|
|
5421
|
+
const longestWaitMs = waits.length === 0
|
|
5422
|
+
? undefined
|
|
5423
|
+
: Math.max(...waits.map(([, wait]) => Math.max(0, nowMs - wait.sinceAtMs)));
|
|
5424
|
+
return {
|
|
5425
|
+
batchSize: this.#config.batchSize,
|
|
5426
|
+
active: occupants.length,
|
|
5427
|
+
waiting: waits.length,
|
|
5428
|
+
waitWarnMs: this.#config.dispatch.capacityWaitWarnMs,
|
|
5429
|
+
agentlessHoldTimeoutMs: this.#config.dispatch.agentlessHoldTimeoutMs,
|
|
5430
|
+
...(longestWaitMs !== undefined ? { longestWaitMs } : {}),
|
|
5431
|
+
...(occupants.length > 0 ? { occupants } : {}),
|
|
5432
|
+
...(waits.length > 0
|
|
5433
|
+
? {
|
|
5434
|
+
waitingIssues: waits
|
|
5435
|
+
.sort(([, left], [, right]) => left.sinceAtMs - right.sinceAtMs)
|
|
5436
|
+
.map(([key]) => key),
|
|
5437
|
+
}
|
|
5438
|
+
: {}),
|
|
5439
|
+
};
|
|
5440
|
+
}
|
|
5441
|
+
#recordDispatchCapacityWait(record, key) {
|
|
5442
|
+
const nowMs = this.#clock.now();
|
|
5443
|
+
let wait = this.#dispatchLifecycleCapacityWaits.get(key);
|
|
5444
|
+
if (!wait) {
|
|
5445
|
+
wait = { record, sinceAtMs: nowMs, attempts: 0 };
|
|
5446
|
+
this.#dispatchLifecycleCapacityWaits.set(key, wait);
|
|
5447
|
+
this.#increment('dispatchLifecycleCapacityWaits');
|
|
5448
|
+
}
|
|
5449
|
+
wait.record = record;
|
|
5450
|
+
wait.attempts += 1;
|
|
5451
|
+
const retryMs = this.#capacityRetryDelayMs(wait.attempts);
|
|
5452
|
+
const waitedMs = Math.max(0, nowMs - wait.sinceAtMs);
|
|
5453
|
+
// Escalate on every backoff step, then once a minute after the delay
|
|
5454
|
+
// caps. The old behaviour logged once per key and went silent forever,
|
|
5455
|
+
// which is what made a 14-hour dispatch outage look like an idle Factory.
|
|
5456
|
+
const stepChanged = wait.lastLoggedRetryMs !== retryMs;
|
|
5457
|
+
const overdue = wait.lastLoggedAtMs === undefined ||
|
|
5458
|
+
nowMs - wait.lastLoggedAtMs >= DISPATCH_LIFECYCLE_CAPACITY_WAIT_LOG_MS;
|
|
5459
|
+
if (stepChanged || overdue) {
|
|
5460
|
+
wait.lastLoggedAtMs = nowMs;
|
|
5461
|
+
wait.lastLoggedRetryMs = retryMs;
|
|
5462
|
+
this.#logger.warn?.('[factory] durable dispatch is queued for batch capacity; retries remain active', {
|
|
5463
|
+
issue: record.issue.key,
|
|
5464
|
+
retryMs,
|
|
5465
|
+
attempts: wait.attempts,
|
|
5466
|
+
waitedMs,
|
|
5467
|
+
batchSize: this.#config.batchSize,
|
|
5468
|
+
occupiedBy: this.#dispatchSlotOccupants().map((occupant) => occupant.issue),
|
|
5469
|
+
});
|
|
5470
|
+
}
|
|
5471
|
+
return retryMs;
|
|
5472
|
+
}
|
|
5473
|
+
/**
|
|
5474
|
+
* Put every capacity waiter back on the fast path, because a slot just freed.
|
|
5475
|
+
*
|
|
5476
|
+
* The backoff exists to stop a storm of retries asking a question whose
|
|
5477
|
+
* answer is not changing. When a lifecycle reaches a terminal phase the
|
|
5478
|
+
* answer *has* changed, so parking a waiter behind a 30 s timer would trade
|
|
5479
|
+
* the storm for latency — and for a slot released by another process, that
|
|
5480
|
+
* timer is the only signal this one gets (#303 review follow-up).
|
|
5481
|
+
*
|
|
5482
|
+
* The wait's `sinceAtMs` is deliberately untouched: the issue really has
|
|
5483
|
+
* been waiting that long, and the escalating warning should keep saying so.
|
|
5484
|
+
*/
|
|
5485
|
+
#resetDispatchCapacityBackoff() {
|
|
5486
|
+
if (this.#stopping || this.#dispatchLifecycleCapacityWaits.size === 0)
|
|
5487
|
+
return;
|
|
5488
|
+
for (const [key, wait] of this.#dispatchLifecycleCapacityWaits) {
|
|
5489
|
+
wait.attempts = 0;
|
|
5490
|
+
const timer = this.#dispatchLifecycleRetryTimers.get(key);
|
|
5491
|
+
if (!timer)
|
|
5492
|
+
continue;
|
|
5493
|
+
clearTimeout(timer);
|
|
5494
|
+
this.#dispatchLifecycleRetryTimers.delete(key);
|
|
5495
|
+
this.#scheduleDispatchLifecycleRetry(wait.record);
|
|
5496
|
+
}
|
|
5497
|
+
this.#increment('dispatchCapacityBackoffResets');
|
|
5498
|
+
}
|
|
5499
|
+
#scheduleDispatchLifecycleRetry(record, delayMs = DISPATCH_LIFECYCLE_RETRY_MS) {
|
|
5240
5500
|
const key = issueKey(record.issue);
|
|
5241
5501
|
if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
|
|
5242
5502
|
return;
|
|
@@ -5244,23 +5504,17 @@ export class FactoryLoop {
|
|
|
5244
5504
|
this.#dispatchLifecycleRetryTimers.delete(key);
|
|
5245
5505
|
const drive = this.#driveDispatchLifecycle(key)
|
|
5246
5506
|
.then(() => {
|
|
5247
|
-
this.#
|
|
5507
|
+
this.#dispatchLifecycleCapacityWaits.delete(key);
|
|
5248
5508
|
this.#dispatchLifecycleOwnershipWaitLogged.delete(key);
|
|
5249
5509
|
})
|
|
5250
5510
|
.catch((error) => {
|
|
5511
|
+
let nextDelayMs = DISPATCH_LIFECYCLE_RETRY_MS;
|
|
5251
5512
|
if (error instanceof DispatchLifecycleCapacityError) {
|
|
5252
5513
|
this.#dispatchLifecycleOwnershipWaitLogged.delete(key);
|
|
5253
|
-
|
|
5254
|
-
this.#dispatchLifecycleCapacityWaitLogged.add(key);
|
|
5255
|
-
this.#increment('dispatchLifecycleCapacityWaits');
|
|
5256
|
-
this.#logger.warn?.('[factory] durable dispatch is queued for batch capacity; retries remain active', {
|
|
5257
|
-
issue: record.issue.key,
|
|
5258
|
-
retryMs: DISPATCH_LIFECYCLE_RETRY_MS,
|
|
5259
|
-
});
|
|
5260
|
-
}
|
|
5514
|
+
nextDelayMs = this.#recordDispatchCapacityWait(record, key);
|
|
5261
5515
|
}
|
|
5262
5516
|
else if (error instanceof DispatchLifecycleOwnedElsewhereError) {
|
|
5263
|
-
this.#
|
|
5517
|
+
this.#dispatchLifecycleCapacityWaits.delete(key);
|
|
5264
5518
|
if (!this.#dispatchLifecycleOwnershipWaitLogged.has(key)) {
|
|
5265
5519
|
this.#dispatchLifecycleOwnershipWaitLogged.add(key);
|
|
5266
5520
|
this.#increment('dispatchLifecycleOwnershipWaits');
|
|
@@ -5274,18 +5528,18 @@ export class FactoryLoop {
|
|
|
5274
5528
|
}
|
|
5275
5529
|
}
|
|
5276
5530
|
else {
|
|
5277
|
-
this.#
|
|
5531
|
+
this.#dispatchLifecycleCapacityWaits.delete(key);
|
|
5278
5532
|
this.#dispatchLifecycleOwnershipWaitLogged.delete(key);
|
|
5279
5533
|
this.#logger.warn?.('[factory] durable dispatch lifecycle retry failed', {
|
|
5280
5534
|
issue: record.issue.key,
|
|
5281
5535
|
error: describeError(error).errorMessage,
|
|
5282
5536
|
});
|
|
5283
5537
|
}
|
|
5284
|
-
this.#scheduleDispatchLifecycleRetry(record);
|
|
5538
|
+
this.#scheduleDispatchLifecycleRetry(record, nextDelayMs);
|
|
5285
5539
|
})
|
|
5286
5540
|
.finally(() => this.#dispatchLifecycleDrives.delete(drive));
|
|
5287
5541
|
this.#dispatchLifecycleDrives.add(drive);
|
|
5288
|
-
},
|
|
5542
|
+
}, delayMs);
|
|
5289
5543
|
this.#dispatchLifecycleRetryTimers.set(key, timer);
|
|
5290
5544
|
}
|
|
5291
5545
|
#scheduleReleaseRetry(record, reason) {
|
|
@@ -6801,6 +7055,7 @@ export class FactoryLoop {
|
|
|
6801
7055
|
registryPath,
|
|
6802
7056
|
eventListener: this.#eventListenerStatus(),
|
|
6803
7057
|
readinessReconcile: this.#readinessReconcileStatus(),
|
|
7058
|
+
dispatchCapacity: this.#dispatchCapacityStatus(),
|
|
6804
7059
|
fleetControlPlane: this.#fleetControlPlane.status(),
|
|
6805
7060
|
};
|
|
6806
7061
|
// The deployed container serves `/healthz` straight out of this file and
|
|
@@ -7231,10 +7486,12 @@ export class FactoryLoop {
|
|
|
7231
7486
|
}
|
|
7232
7487
|
return [...handoffs.values()];
|
|
7233
7488
|
}
|
|
7234
|
-
async #teardownFailedDispatchWorktrees(handoffs, releaseReason = 'dispatch failed') {
|
|
7489
|
+
async #teardownFailedDispatchWorktrees(handoffs, releaseReason = 'dispatch failed', opts = {}) {
|
|
7235
7490
|
if (!this.#worktrees || !handoffs.some((handoff) => handoff.worktree))
|
|
7236
7491
|
return false;
|
|
7237
|
-
const failed = await this.#releaseAndTerminateAgents(handoffs
|
|
7492
|
+
const failed = await this.#releaseAndTerminateAgents(handoffs
|
|
7493
|
+
.filter((handoff) => !opts.skipNeverPlacedAgents || handoff.tracked.result !== undefined)
|
|
7494
|
+
.map((handoff) => [handoff.name, handoff.tracked]), releaseReason, 'completion');
|
|
7238
7495
|
if (failed.length > 0)
|
|
7239
7496
|
return false;
|
|
7240
7497
|
try {
|
|
@@ -7410,6 +7667,17 @@ export class FactoryLoop {
|
|
|
7410
7667
|
: 'agent_spawn_failed',
|
|
7411
7668
|
});
|
|
7412
7669
|
}
|
|
7670
|
+
// The never-placed deadline can fire while this spawn is in flight — that
|
|
7671
|
+
// is the whole point of arming it before the first await, and it makes a
|
|
7672
|
+
// late `spawn` result newly reachable (#303 review, cubic). By now the
|
|
7673
|
+
// reaper may have fenced, released and terminalized the lifecycle, so this
|
|
7674
|
+
// placement belongs to nothing: recording it would attach a live worker to
|
|
7675
|
+
// a record the reaper has finished with, and nothing downstream would ever
|
|
7676
|
+
// release it. Hand it straight to teardown instead.
|
|
7677
|
+
if (!await this.#dispatchLifecycleStillOwned(record)) {
|
|
7678
|
+
await this.#releaseOrphanedLatePlacement(record, spec, result);
|
|
7679
|
+
throw new LatePlacementReleasedError(record.issue.key, result.name ?? spec.name);
|
|
7680
|
+
}
|
|
7413
7681
|
record.heldSinceAtMs ??= this.#clock.now();
|
|
7414
7682
|
batch.recordSpawn(record, spec, invocationId, result);
|
|
7415
7683
|
if (!await this.#saveDispatchLifecycle(record, 'dispatching')) {
|
|
@@ -7421,6 +7689,68 @@ export class FactoryLoop {
|
|
|
7421
7689
|
await this.#reportAgent(record, spawned, 'agent.spawned');
|
|
7422
7690
|
return { name: result.name };
|
|
7423
7691
|
}
|
|
7692
|
+
/**
|
|
7693
|
+
* Is this process still the owner of a lifecycle that is not already done?
|
|
7694
|
+
*
|
|
7695
|
+
* Cheap local checks first — a pending abandon reason, or a dropped epoch,
|
|
7696
|
+
* both of which the reaper sets before anything durable is re-read — then the
|
|
7697
|
+
* durable row, which is authoritative when another owner terminalized it.
|
|
7698
|
+
*/
|
|
7699
|
+
async #dispatchLifecycleStillOwned(record) {
|
|
7700
|
+
const key = issueKey(record.issue);
|
|
7701
|
+
if (this.#abandonedDispatchReasons.has(key))
|
|
7702
|
+
return false;
|
|
7703
|
+
if (!this.#usesDurableDispatchLifecycle())
|
|
7704
|
+
return true;
|
|
7705
|
+
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
7706
|
+
if (epoch === undefined)
|
|
7707
|
+
return false;
|
|
7708
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
7709
|
+
if (!lifecycle || isTerminalDispatchLifecycle(lifecycle))
|
|
7710
|
+
return false;
|
|
7711
|
+
// A live row is not the same as *our* row. Another owner can reclaim an
|
|
7712
|
+
// expired lease and leave it nonterminal, and the cached epoch here would
|
|
7713
|
+
// still say we hold it (#303 review, cubic). This mirrors exactly what
|
|
7714
|
+
// `saveDispatchLifecycle` will accept — owner, epoch and an unexpired
|
|
7715
|
+
// lease — so a placement is recorded only when the write that follows can
|
|
7716
|
+
// actually land. Otherwise the save fails and the worker leaks through the
|
|
7717
|
+
// generic ownership-lost path instead of orphan cleanup.
|
|
7718
|
+
const lease = lifecycle.lease;
|
|
7719
|
+
return lease !== undefined &&
|
|
7720
|
+
lease.owner === this.#dispatchLifecycleOwner &&
|
|
7721
|
+
lease.epoch === epoch &&
|
|
7722
|
+
lease.leaseUntilMs > this.#clock.now();
|
|
7723
|
+
}
|
|
7724
|
+
/**
|
|
7725
|
+
* Tear down a placement that landed after its lifecycle was already released.
|
|
7726
|
+
*
|
|
7727
|
+
* Deliberately not routed through `#abandonStuckDispatch`: that record is
|
|
7728
|
+
* terminal and its batch entry is gone, so there is nothing left to abandon.
|
|
7729
|
+
* The only thing that still exists is a live worker on the fleet.
|
|
7730
|
+
*/
|
|
7731
|
+
async #releaseOrphanedLatePlacement(record, spec, result) {
|
|
7732
|
+
const name = result.name ?? spec.name;
|
|
7733
|
+
this.#increment('lateSpawnPlacementsReleased');
|
|
7734
|
+
this.#logger.warn?.('[factory] releasing an agent that finished spawning after its dispatch was released', {
|
|
7735
|
+
issue: record.issue.key,
|
|
7736
|
+
agent: name,
|
|
7737
|
+
role: spec.role,
|
|
7738
|
+
});
|
|
7739
|
+
this.#fleet.markAgentTerminal?.(name, 'dispatch-released-before-placement');
|
|
7740
|
+
try {
|
|
7741
|
+
await this.#fleet.release(name, 'dispatch-released-before-placement');
|
|
7742
|
+
}
|
|
7743
|
+
catch (error) {
|
|
7744
|
+
// The reaper handoff owns anything this could not clean up; failing here
|
|
7745
|
+
// would only replace a released worker with an unreleased one.
|
|
7746
|
+
this.#increment('lateSpawnPlacementReleaseFailures');
|
|
7747
|
+
this.#logger.warn?.('[factory] failed to release a late placement; leaving it to the orphan reaper', {
|
|
7748
|
+
issue: record.issue.key,
|
|
7749
|
+
agent: name,
|
|
7750
|
+
error: describeError(error).errorMessage,
|
|
7751
|
+
});
|
|
7752
|
+
}
|
|
7753
|
+
}
|
|
7424
7754
|
async #handleAgentExit(name, reason) {
|
|
7425
7755
|
if (this.#stopping) {
|
|
7426
7756
|
return;
|
|
@@ -8475,7 +8805,14 @@ export class FactoryLoop {
|
|
|
8475
8805
|
this.#scheduleAbandonedDispatchRetry(record, reason);
|
|
8476
8806
|
return;
|
|
8477
8807
|
}
|
|
8478
|
-
|
|
8808
|
+
// A never-placed record carries specs, not workers: `recordPlanned` writes
|
|
8809
|
+
// the spec before the spawn returns, so a dispatch that died mid-spawn
|
|
8810
|
+
// leaves a name the broker never issued. Releasing one fails, which fails
|
|
8811
|
+
// the whole cleanup and re-arms the abandon retry forever — turning the
|
|
8812
|
+
// #303 reap into a second, quieter wedge. Their worktrees are still torn
|
|
8813
|
+
// down below.
|
|
8814
|
+
const neverPlaced = reason === AGENTLESS_SLOT_PAST_DEADLINE_RELEASE_REASON;
|
|
8815
|
+
const agents = [...record.agents].filter(([, tracked]) => !neverPlaced || tracked.result !== undefined);
|
|
8479
8816
|
for (const [agentName, tracked] of agents) {
|
|
8480
8817
|
if (!heldPastDeadline && tracked.spec.role === 'implementer')
|
|
8481
8818
|
continue;
|
|
@@ -8495,7 +8832,7 @@ export class FactoryLoop {
|
|
|
8495
8832
|
const failed = await this.#releaseAndTerminateAgents(nonWorktreeAgents, agentReleaseReason, 'completion');
|
|
8496
8833
|
cleanupComplete = failed.length === 0;
|
|
8497
8834
|
}
|
|
8498
|
-
cleanupComplete = await this.#teardownFailedDispatchWorktrees(worktreeHandoffs, agentReleaseReason) && cleanupComplete;
|
|
8835
|
+
cleanupComplete = await this.#teardownFailedDispatchWorktrees(worktreeHandoffs, agentReleaseReason, { skipNeverPlacedAgents: neverPlaced }) && cleanupComplete;
|
|
8499
8836
|
}
|
|
8500
8837
|
else if (agents.length > 0) {
|
|
8501
8838
|
const failed = await this.#releaseAndTerminateAgents(agents, agentReleaseReason, 'completion');
|
|
@@ -17817,6 +18154,7 @@ const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequ
|
|
|
17817
18154
|
...(releaseReason ? { releaseReason } : {}),
|
|
17818
18155
|
...(cost ? { cost: structuredClone(cost) } : {}),
|
|
17819
18156
|
...(record.heldSinceAtMs !== undefined ? { heldSinceAtMs: record.heldSinceAtMs } : {}),
|
|
18157
|
+
...(record.slotHeldSinceAtMs !== undefined ? { slotHeldSinceAtMs: record.slotHeldSinceAtMs } : {}),
|
|
17820
18158
|
updatedAtMs,
|
|
17821
18159
|
});
|
|
17822
18160
|
const inFlightRecordFromLifecycle = (lifecycle) => ({
|
|
@@ -17833,9 +18171,14 @@ const inFlightRecordFromLifecycle = (lifecycle) => ({
|
|
|
17833
18171
|
invocationIds: new Set(lifecycle.invocationIds),
|
|
17834
18172
|
result: lifecycle.result ? structuredClone(lifecycle.result) : undefined,
|
|
17835
18173
|
...(lifecycle.dispatchClaim ? { dispatchClaim: { ...lifecycle.dispatchClaim } } : {}),
|
|
17836
|
-
heldSinceAtMs: lifecycle.heldSinceAtMs ?? (
|
|
18174
|
+
heldSinceAtMs: lifecycle.heldSinceAtMs ?? (
|
|
18175
|
+
// A live placement the durable row predates the `heldSinceAtMs` field for.
|
|
18176
|
+
// `tracked.result` is what distinguishes a placement from a spec that
|
|
18177
|
+
// `recordPlanned` wrote and no spawn ever answered (#303).
|
|
18178
|
+
lifecycle.agents.some((agent) => agent.releasedAtMs === undefined && agent.tracked.result !== undefined)
|
|
17837
18179
|
? lifecycle.updatedAtMs
|
|
17838
18180
|
: undefined),
|
|
18181
|
+
slotHeldSinceAtMs: lifecycle.slotHeldSinceAtMs,
|
|
17839
18182
|
lifecyclePhase: lifecycle.phase,
|
|
17840
18183
|
});
|
|
17841
18184
|
const dispatchResultFromLifecycle = (lifecycle) => lifecycle.result ? structuredClone(lifecycle.result) : {
|
|
@@ -17871,6 +18214,29 @@ export class LiveDispatchStateChangedError extends Error {
|
|
|
17871
18214
|
this.issueKey = issueKey;
|
|
17872
18215
|
}
|
|
17873
18216
|
}
|
|
18217
|
+
/**
|
|
18218
|
+
* A placement that finished spawning after its dispatch had been released.
|
|
18219
|
+
*
|
|
18220
|
+
* The never-placed deadline (#303) can terminalize a lifecycle while
|
|
18221
|
+
* `#fleet.spawn` is still in flight; the worker is released and the dispatch
|
|
18222
|
+
* unwinds. That is a known, named, self-healing race — the issue returns to the
|
|
18223
|
+
* queue and is re-dispatched — so it must be classified rather than counted as
|
|
18224
|
+
* an unexplained fault. It fires precisely under slow spawns, which is the
|
|
18225
|
+
* condition the deadline exists for, so a degraded fleet produces it
|
|
18226
|
+
* repeatedly; left unclassified, five in a row would trip
|
|
18227
|
+
* `UNCLASSIFIED_DISPATCH_FAILURE_LIMIT` and abort the whole readiness pass,
|
|
18228
|
+
* turning a bounded slot into a stopped sweep (#303 review, factory-lead).
|
|
18229
|
+
*/
|
|
18230
|
+
export class LatePlacementReleasedError extends Error {
|
|
18231
|
+
issueKey;
|
|
18232
|
+
agentName;
|
|
18233
|
+
constructor(issueKey, agentName) {
|
|
18234
|
+
super(`Dispatch lifecycle for ${issueKey} was released while ${agentName} was still spawning`);
|
|
18235
|
+
this.name = 'LatePlacementReleasedError';
|
|
18236
|
+
this.issueKey = issueKey;
|
|
18237
|
+
this.agentName = agentName;
|
|
18238
|
+
}
|
|
18239
|
+
}
|
|
17874
18240
|
/** Whether a thrown value is a {@link LiveDispatchStateChangedError}. */
|
|
17875
18241
|
export function isLiveDispatchStateChangedError(error) {
|
|
17876
18242
|
return error instanceof LiveDispatchStateChangedError;
|
|
@@ -17905,6 +18271,12 @@ const UNCLASSIFIED_DISPATCH_FAILURE_LIMIT = 5;
|
|
|
17905
18271
|
*/
|
|
17906
18272
|
const isClassifiedPerItemDispatchFailure = (error) => error instanceof LiveDispatchStateChangedError ||
|
|
17907
18273
|
error instanceof DispatchLifecycleClaimRefusedError ||
|
|
18274
|
+
// #303: the never-placed deadline released this dispatch while its spawn was
|
|
18275
|
+
// still in flight. Named, expected and self-healing — the issue goes back to
|
|
18276
|
+
// the queue — and it recurs under exactly the slow-spawn conditions the
|
|
18277
|
+
// deadline exists for, so leaving it unclassified would let a degraded fleet
|
|
18278
|
+
// trip the pass-abort fuse. Its own counters keep it visible.
|
|
18279
|
+
error instanceof LatePlacementReleasedError ||
|
|
17908
18280
|
// Relayfile shedding one operation is a state of the dependency, not an
|
|
17909
18281
|
// unexplained fault, and it has its own fuse — see #297 and
|
|
17910
18282
|
// DISCOVERY_OVERLOAD_PER_SWEEP_LIMIT.
|
|
@@ -17939,6 +18311,8 @@ const perItemDispatchSkipReason = (error) => {
|
|
|
17939
18311
|
return `relayfile overloaded (${relayfileOverloadReasonLabel(overload.reason)})`;
|
|
17940
18312
|
if (error instanceof LiveDispatchStateChangedError)
|
|
17941
18313
|
return 'live state changed during dispatch';
|
|
18314
|
+
if (error instanceof LatePlacementReleasedError)
|
|
18315
|
+
return 'dispatch released while its agent was still spawning';
|
|
17942
18316
|
if (error instanceof DispatchLifecycleClaimRefusedError) {
|
|
17943
18317
|
return error.refusal === 'terminal'
|
|
17944
18318
|
? 'dispatch lifecycle already terminal'
|