@agent-relay/factory 0.1.72 → 0.1.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/cli/diagnose.d.ts +3 -2
  2. package/dist/cli/diagnose.d.ts.map +1 -1
  3. package/dist/cli/diagnose.js +60 -5
  4. package/dist/cli/diagnose.js.map +1 -1
  5. package/dist/cli/fleet.d.ts.map +1 -1
  6. package/dist/cli/fleet.js +5 -0
  7. package/dist/cli/fleet.js.map +1 -1
  8. package/dist/fleet/control-plane-circuit.d.ts +10 -0
  9. package/dist/fleet/control-plane-circuit.d.ts.map +1 -1
  10. package/dist/fleet/control-plane-circuit.js +9 -1
  11. package/dist/fleet/control-plane-circuit.js.map +1 -1
  12. package/dist/fleet/relay-fleet-client.d.ts +21 -2
  13. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  14. package/dist/fleet/relay-fleet-client.js +97 -17
  15. package/dist/fleet/relay-fleet-client.js.map +1 -1
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/observability/events.d.ts +14 -14
  21. package/dist/orchestrator/dispatch-failure-reason.d.ts +60 -0
  22. package/dist/orchestrator/dispatch-failure-reason.d.ts.map +1 -0
  23. package/dist/orchestrator/dispatch-failure-reason.js +127 -0
  24. package/dist/orchestrator/dispatch-failure-reason.js.map +1 -0
  25. package/dist/orchestrator/factory.d.ts.map +1 -1
  26. package/dist/orchestrator/factory.js +159 -28
  27. package/dist/orchestrator/factory.js.map +1 -1
  28. package/dist/orchestrator/public-health.d.ts.map +1 -1
  29. package/dist/orchestrator/public-health.js +116 -6
  30. package/dist/orchestrator/public-health.js.map +1 -1
  31. package/dist/ports/fleet.d.ts +38 -0
  32. package/dist/ports/fleet.d.ts.map +1 -1
  33. package/dist/ports/index.d.ts +1 -1
  34. package/dist/ports/index.d.ts.map +1 -1
  35. package/dist/types.d.ts +118 -14
  36. package/dist/types.d.ts.map +1 -1
  37. package/package.json +1 -1
@@ -36,6 +36,7 @@ import { createFactoryCloudEventV1, factoryCloudReleaseReasonV1, } from '../obse
36
36
  import { telemetryErrorClass } from '../observability/error-class.js';
37
37
  import { derivedReadinessReconcileState, publicHealthFromHeartbeat, readinessReconcileInFlightMs, } from './public-health.js';
38
38
  import { factorySweepSkipReasonCounts } from './sweep-skip-reason.js';
39
+ import { factoryDispatchFailureReasonCodeForErrorClass, factoryDispatchFailureReasonCounts, } from './dispatch-failure-reason.js';
39
40
  import { boundedRunCostTotal, CostLedger } from '../cost/ledger.js';
40
41
  import { createTicketDispatchDelivery } from '../delivery/ticket-dispatch.js';
41
42
  import { canonicalTrajectorySessionRef, renderTrajectoryPointer, stripTrajectoryPointers, } from '../trajectory.js';
@@ -553,18 +554,30 @@ export class FactoryLoop {
553
554
  #readinessReconcileLastError;
554
555
  #readinessReconcileLastErrorClass;
555
556
  /**
556
- * The last *completed* sweep's arithmetic (#355).
557
+ * The last *enumerating* sweep's arithmetic (#355).
557
558
  *
558
- * Held as one record rather than four fields so it can only ever be replaced
559
+ * Held as one record rather than three fields so it can only ever be replaced
559
560
  * whole: publishing a `dispatched` from one pass beside a `candidates` from
560
561
  * another would be worse than publishing neither, since the whole use of
561
562
  * these numbers is comparing them to each other.
562
563
  *
563
- * `undefined` until a sweep completes, and never initialised to zeroes —
564
+ * `undefined` until a sweep enumerates, and never initialised to zeroes —
564
565
  * "this daemon has not finished a sweep" and "a sweep finished and found
565
566
  * nothing" are the two readings #355 has to tell apart.
566
567
  */
567
568
  #readinessReconcileLastSweep;
569
+ /**
570
+ * Whether the MOST RECENT pass deferred, tracked apart from the counts above
571
+ * (#358 review, CodeRabbit — Major, and right).
572
+ *
573
+ * A deferred pass enumerates nothing and settles in milliseconds, so folding
574
+ * it into the snapshot overwrote the last real sweep's numbers with zeroes.
575
+ * On a container where another process holds the lease for any length of time
576
+ * — the #347/#349 condition — every pass would publish `candidates: 0` and the
577
+ * last actual enumeration would be unrecoverable, destroying the measurement
578
+ * this whole change exists to provide.
579
+ */
580
+ #readinessReconcileLastSweepDeferred;
568
581
  #liveEventQueue = [];
569
582
  #liveEventDrainScheduled = false;
570
583
  #liveEventDrainActive = false;
@@ -1331,9 +1344,11 @@ export class FactoryLoop {
1331
1344
  // cold container it is the first — and for the next interval, only —
1332
1345
  // sweep whose counts exist. Leaving it unrecorded would make a daemon
1333
1346
  // that has completed a full pass still read as "never ran" (#355).
1334
- this.#recordReadinessSweepOutcome(await this.runOnce());
1347
+ const report = await this.runOnce();
1335
1348
  this.#readinessReconcileLastDurationMs = this.#elapsedSince(backfillStartedAtMs);
1336
- this.#readinessReconcileLastCompletedAtMs = this.#clock.now();
1349
+ const completedAtMs = this.#clock.now();
1350
+ this.#readinessReconcileLastCompletedAtMs = completedAtMs;
1351
+ this.#recordReadinessSweepOutcome(report, completedAtMs);
1337
1352
  }
1338
1353
  catch (error) {
1339
1354
  this.#readinessReconcileLastDurationMs = this.#elapsedSince(backfillStartedAtMs);
@@ -1559,20 +1574,28 @@ export class FactoryLoop {
1559
1574
  const report = await this.#runOnceWithReadinessDeadline();
1560
1575
  this.#readinessReconcileConsecutiveFailures = 0;
1561
1576
  this.#readinessReconcileLastDurationMs = this.#elapsedSince(startedAtMs);
1562
- this.#readinessReconcileLastCompletedAtMs = this.#clock.now();
1577
+ const completedAtMs = this.#clock.now();
1578
+ this.#readinessReconcileLastCompletedAtMs = completedAtMs;
1563
1579
  this.#readinessReconcileLastError = undefined;
1564
1580
  this.#readinessReconcileLastErrorClass = undefined;
1565
1581
  // The three integers below have gone to stdout since this loop existed,
1566
1582
  // and stdout does not reach the deployed container's operator (#355).
1567
1583
  // Publishing them is what lets a reader tell a sweep that saw eligible
1568
1584
  // work and rejected it from one that never pulled it at all.
1569
- this.#recordReadinessSweepOutcome(report);
1585
+ this.#recordReadinessSweepOutcome(report, completedAtMs);
1570
1586
  this.#logger.info?.('[factory] periodic readiness reconciliation completed', {
1571
1587
  durationMs: this.#readinessReconcileLastDurationMs,
1572
1588
  candidates: report.pulled.length,
1573
1589
  dispatched: report.dispatched.length,
1574
1590
  skipped: report.skipped.length,
1575
- skipReasons: this.#readinessReconcileLastSweep?.skipReasons,
1591
+ // THIS pass's breakdown, never the retained snapshot (#359 review,
1592
+ // codex P2). Logging the retained one beside a deferred pass's zeroes
1593
+ // produced a line that contradicted its own arithmetic —
1594
+ // `skipped: 0` next to a non-empty breakdown — and this log is what a
1595
+ // local operator reads.
1596
+ skipReasons: factorySweepSkipReasonCounts(report.skipped),
1597
+ dispatchFailures: report.skipped.filter((entry) => entry.code === 'dispatch-failed').length,
1598
+ dispatchFailureReasons: factoryDispatchFailureReasonCounts(report.skipped),
1576
1599
  discoveryDeferred: report.discoveryDeferred,
1577
1600
  });
1578
1601
  }
@@ -1596,6 +1619,10 @@ export class FactoryLoop {
1596
1619
  this.#readinessReconcileLastDurationMs = this.#elapsedSince(startedAtMs);
1597
1620
  this.#readinessReconcileLastFailureAtMs = this.#clock.now();
1598
1621
  this.#readinessReconcileLastError = errorMessage;
1622
+ // This failure is now the latest settled pass. A deferral marker left by
1623
+ // an older pass would falsely describe this one as lease contention when
1624
+ // the timestamps/error below prove that it acquired the lease and failed.
1625
+ this.#readinessReconcileLastSweepDeferred = undefined;
1599
1626
  // The class, unlike the message, is publishable: #295 puts it on the
1600
1627
  // unauthenticated health surface through the same allowlist.
1601
1628
  this.#readinessReconcileLastErrorClass = telemetryErrorClass(error);
@@ -2703,6 +2730,10 @@ export class FactoryLoop {
2703
2730
  continue;
2704
2731
  }
2705
2732
  const recoveredIdentity = recoveredOrphan ? githubIssueRefIdentity(issueRef(issue)) : undefined;
2733
+ // Reset per work unit, and advanced by assignment immediately before
2734
+ // each stage rather than inferred in the catch: the whole value of the
2735
+ // `unclassified-*` codes is that they name the stage honestly (#355).
2736
+ let attemptPhase = 'gate';
2706
2737
  try {
2707
2738
  if (recoveredOrphan) {
2708
2739
  const dispatchBlock = await this.#dispatchBlockReason(issue);
@@ -2723,8 +2754,10 @@ export class FactoryLoop {
2723
2754
  });
2724
2755
  continue;
2725
2756
  }
2757
+ attemptPhase = 'triage';
2726
2758
  const decision = await this.triageIssue(issue);
2727
2759
  triaged.push(decision);
2760
+ attemptPhase = 'dispatch';
2728
2761
  const result = await this.dispatch(decision, { dryRun });
2729
2762
  // A completed dispatch — even one that parks or escalates the issue —
2730
2763
  // proves the pipeline still works, so the fuse below starts over.
@@ -2825,6 +2858,11 @@ export class FactoryLoop {
2825
2858
  issue: issueRef(issue),
2826
2859
  reason: perItemDispatchSkipReason(error),
2827
2860
  code: 'dispatch-failed',
2861
+ // The publishable half of the same classification. `reason` is the
2862
+ // operator's sentence and stays off the health surface; this token
2863
+ // is what tells a reader watching `dispatch-failed: 5` which of
2864
+ // five very different bugs they are looking at (#355).
2865
+ failureCode: perItemDispatchFailureCode(error, attemptPhase),
2828
2866
  });
2829
2867
  continue;
2830
2868
  }
@@ -4488,6 +4526,9 @@ export class FactoryLoop {
4488
4526
  })) ?? [],
4489
4527
  counters: { ...this.#counters },
4490
4528
  fleetControlPlane: this.#fleetControlPlane.status(),
4529
+ // Optional on the port: a backend with no socket omits it, and an absent
4530
+ // value stays absent rather than being invented as healthy.
4531
+ ...(this.#fleet.fleetConnectStatus ? { fleetConnect: this.#fleet.fleetConnectStatus() } : {}),
4491
4532
  slackDegraded: this.#slackDegraded,
4492
4533
  slackDegradedReason: this.#slackDegradedReason,
4493
4534
  eventListener: this.#eventListenerStatus(),
@@ -4519,16 +4560,37 @@ export class FactoryLoop {
4519
4560
  *
4520
4561
  * Only successful passes reach here: a pass that threw has no report, and
4521
4562
  * inventing zeroes for it would publish "found nothing" for a sweep that
4522
- * never got to look. The previous pass's numbers stay put instead, dated by
4523
- * `lastCompletedAtMs`, which is the honest reading.
4563
+ * never got to look. The previous enumerating pass's numbers stay put
4564
+ * instead, dated by `lastEnumeratedAtMs`, which is the honest reading.
4565
+ *
4566
+ * A deferred pass gets the same treatment for the same reason. It settles
4567
+ * successfully, and `lastCompletedAtMs` moves — deliberately, because the
4568
+ * #295/#296 stall derivation reads that timestamp against `lastStartedAtMs`,
4569
+ * and freezing it would report a functioning daemon as a hung one after ten
4570
+ * intervals of deferring correctly to another owner. But it enumerated
4571
+ * nothing, so its zeroes are not a measurement of anything and must not
4572
+ * replace one. Only the marker is recorded.
4524
4573
  */
4525
- #recordReadinessSweepOutcome(report) {
4574
+ #recordReadinessSweepOutcome(report, completedAtMs) {
4575
+ if (report.discoveryDeferred) {
4576
+ this.#readinessReconcileLastSweepDeferred = report.discoveryDeferred;
4577
+ return;
4578
+ }
4579
+ this.#readinessReconcileLastSweepDeferred = undefined;
4526
4580
  this.#readinessReconcileLastSweep = {
4527
4581
  candidates: report.pulled.length,
4528
4582
  dispatched: report.dispatched.length,
4529
4583
  skipped: report.skipped.length,
4530
4584
  skipReasons: factorySweepSkipReasonCounts(report.skipped),
4531
- ...(report.discoveryDeferred ? { discoveryDeferred: report.discoveryDeferred } : {}),
4585
+ // Counted from the same entries `skipReasons` counts, so the parts sum to
4586
+ // `skipReasons['dispatch-failed']` by construction rather than by a
4587
+ // second traversal agreeing with the first.
4588
+ dispatchFailures: report.skipped.filter((entry) => entry.code === 'dispatch-failed').length,
4589
+ dispatchFailureReasons: factoryDispatchFailureReasonCounts(report.skipped),
4590
+ // The caller's completion stamp, not a fresh clock read: on a pass that
4591
+ // enumerated, `lastEnumeratedAtMs` and `lastCompletedAtMs` describe the
4592
+ // same instant and must not drift apart by a tick.
4593
+ enumeratedAtMs: completedAtMs,
4532
4594
  };
4533
4595
  }
4534
4596
  #readinessReconcileStatus() {
@@ -4616,11 +4678,21 @@ export class FactoryLoop {
4616
4678
  ...(Object.keys(this.#readinessReconcileLastSweep.skipReasons).length > 0
4617
4679
  ? { skipReasons: { ...this.#readinessReconcileLastSweep.skipReasons } }
4618
4680
  : {}),
4619
- ...(this.#readinessReconcileLastSweep.discoveryDeferred
4620
- ? { discoveryDeferred: this.#readinessReconcileLastSweep.discoveryDeferred }
4681
+ // Unconditional, unlike the breakdown below it: a zero here is the
4682
+ // fact "this sweep attempted dispatches and none of them failed",
4683
+ // which no other field on this surface can express.
4684
+ dispatchFailures: this.#readinessReconcileLastSweep.dispatchFailures,
4685
+ ...(Object.keys(this.#readinessReconcileLastSweep.dispatchFailureReasons).length > 0
4686
+ ? { dispatchFailureReasons: { ...this.#readinessReconcileLastSweep.dispatchFailureReasons } }
4621
4687
  : {}),
4688
+ lastEnumeratedAtMs: this.#readinessReconcileLastSweep.enumeratedAtMs,
4622
4689
  }
4623
4690
  : {}),
4691
+ // Independent of the trio: a daemon whose FIRST pass deferred has no
4692
+ // counts to publish and still needs to say why.
4693
+ ...(this.#readinessReconcileLastSweepDeferred
4694
+ ? { discoveryDeferred: this.#readinessReconcileLastSweepDeferred }
4695
+ : {}),
4624
4696
  ...(this.#readinessReconcileLastError ? { lastError: this.#readinessReconcileLastError } : {}),
4625
4697
  ...(this.#readinessReconcileLastErrorClass
4626
4698
  ? { lastErrorClass: this.#readinessReconcileLastErrorClass }
@@ -7453,6 +7525,9 @@ export class FactoryLoop {
7453
7525
  readinessReconcile: this.#readinessReconcileStatus(),
7454
7526
  dispatchCapacity: this.#dispatchCapacityStatus(),
7455
7527
  fleetControlPlane: this.#fleetControlPlane.status(),
7528
+ // Optional on the port: a backend with no socket omits it, and an absent
7529
+ // value stays absent rather than being invented as healthy.
7530
+ ...(this.#fleet.fleetConnectStatus ? { fleetConnect: this.#fleet.fleetConnectStatus() } : {}),
7456
7531
  };
7457
7532
  // The deployed container serves `/healthz` straight out of this file and
7458
7533
  // has no redaction logic of its own, so publish the already-safe view here
@@ -18271,21 +18346,28 @@ const githubIssueIndexRepoRoots = (path) => {
18271
18346
  `/github/repos/${owner}__${repo}/issues`,
18272
18347
  ];
18273
18348
  };
18274
- const relayfileOverload = (error) => {
18275
- const flat = asRecord(error) ?? {};
18349
+ /** How far to follow wrapped provider failures without trusting an unbounded chain. */
18350
+ const RELAYFILE_OVERLOAD_CAUSE_DEPTH = 4;
18351
+ const relayfileOverload = (error, depth = 0) => {
18352
+ if (depth > RELAYFILE_OVERLOAD_CAUSE_DEPTH)
18353
+ return undefined;
18354
+ const flat = asRecord(error);
18355
+ if (!flat)
18356
+ return undefined;
18276
18357
  const response = asRecord(flat.response) ?? {};
18277
18358
  const data = asRecord(flat.data) ?? asRecord(response.data) ?? {};
18278
18359
  const details = asRecord(flat.details) ?? asRecord(data.details) ?? {};
18279
18360
  const statusValue = flat.status ?? flat.statusCode ?? response.status ?? response.statusCode;
18280
18361
  const status = typeof statusValue === 'number' ? statusValue : Number(statusValue);
18281
- if (status !== 429)
18282
- return undefined;
18283
- const retryValue = flat.retryAfterSeconds ?? details.retryAfterSeconds ?? data.retryAfterSeconds;
18284
- const parsedRetry = typeof retryValue === 'number' ? retryValue : Number(retryValue);
18285
- const retryAfterSeconds = Number.isFinite(parsedRetry) && parsedRetry >= 0 ? parsedRetry : undefined;
18286
- const reason = stringValue(flat.reason) ?? stringValue(details.reason) ?? stringValue(data.reason) ??
18287
- stringValue(flat.code) ?? stringValue(data.code) ?? 'rate_limited';
18288
- return { status, reason, ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }) };
18362
+ if (status === 429) {
18363
+ const retryValue = flat.retryAfterSeconds ?? details.retryAfterSeconds ?? data.retryAfterSeconds;
18364
+ const parsedRetry = typeof retryValue === 'number' ? retryValue : Number(retryValue);
18365
+ const retryAfterSeconds = Number.isFinite(parsedRetry) && parsedRetry >= 0 ? parsedRetry : undefined;
18366
+ const reason = stringValue(flat.reason) ?? stringValue(details.reason) ?? stringValue(data.reason) ??
18367
+ stringValue(flat.code) ?? stringValue(data.code) ?? 'rate_limited';
18368
+ return { status, reason, ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }) };
18369
+ }
18370
+ return relayfileOverload(flat.cause, depth + 1);
18289
18371
  };
18290
18372
  /**
18291
18373
  * relayfile's overload reason codes, allowlisted.
@@ -18804,13 +18886,14 @@ const PASS_FATAL_CAUSE_DEPTH = 4;
18804
18886
  * classification has to follow the cause chain rather than trust the outermost
18805
18887
  * type.
18806
18888
  */
18807
- const wrapsErrorOfType = (error, type, depth = 0) => {
18889
+ const findWrappedErrorOfType = (error, type, depth = 0) => {
18808
18890
  if (depth > PASS_FATAL_CAUSE_DEPTH || !(error instanceof Error))
18809
- return false;
18891
+ return undefined;
18810
18892
  if (error instanceof type)
18811
- return true;
18812
- return wrapsErrorOfType(error.cause, type, depth + 1);
18893
+ return error;
18894
+ return findWrappedErrorOfType(error.cause, type, depth + 1);
18813
18895
  };
18896
+ const wrapsErrorOfType = (error, type) => findWrappedErrorOfType(error, type) !== undefined;
18814
18897
  /**
18815
18898
  * How many *unclassified* per-item failures without an intervening successful
18816
18899
  * dispatch end the pass. Named per-item conditions (a lifecycle claim refusal,
@@ -18875,6 +18958,54 @@ const perItemDispatchSkipReason = (error) => {
18875
18958
  }
18876
18959
  return `dispatch failed (${telemetryErrorClass(error)})`;
18877
18960
  };
18961
+ const UNCLASSIFIED_PHASE_CODES = {
18962
+ gate: 'unclassified-gate',
18963
+ triage: 'unclassified-triage',
18964
+ dispatch: 'unclassified-dispatch',
18965
+ };
18966
+ /**
18967
+ * Why a dispatch attempt failed, as a code from the published vocabulary (#355).
18968
+ *
18969
+ * The sibling of `perItemDispatchSkipReason`: that returns the operator's
18970
+ * sentence, this returns the one token that may cross onto the unauthenticated
18971
+ * health surface, and both are recorded at the skip site from the same thrown
18972
+ * value. The token is never parsed back out of the sentence — a reworded
18973
+ * message would silently empty a bucket, and this vocabulary is what an
18974
+ * operator reads when the daemon's stdout does not reach them.
18975
+ *
18976
+ * Ordered most specific first. Every branch follows the bounded cause chain,
18977
+ * because `contextualError` and the control-plane guard both rethrow wrapped.
18978
+ * `relayfileOverload` is also the loop's shedding predicate, so widening it at
18979
+ * the source keeps the health code, skip counter, fuse, and durable overload
18980
+ * ratchet on one verdict instead of merely relabelling the published bucket.
18981
+ */
18982
+ const perItemDispatchFailureCode = (error, phase) => {
18983
+ if (relayfileOverload(error) !== undefined)
18984
+ return 'relayfile-overloaded';
18985
+ if (wrapsErrorOfType(error, LiveDispatchStateChangedError))
18986
+ return 'live-state-changed';
18987
+ if (wrapsErrorOfType(error, LatePlacementReleasedError))
18988
+ return 'late-placement-released';
18989
+ const refused = findWrappedErrorOfType(error, DispatchLifecycleClaimRefusedError);
18990
+ if (refused)
18991
+ return refused.refusal === 'terminal' ? 'lifecycle-terminal' : 'lifecycle-owned-elsewhere';
18992
+ if (wrapsErrorOfType(error, FleetControlPlaneCircuitOpenError))
18993
+ return 'control-plane-open';
18994
+ // The class-name tail of the vocabulary; see its own doc comment for why
18995
+ // these five are not `instanceof`. Walked down the cause chain like the
18996
+ // branches above, because `contextualError` wraps in a plain `Error` and
18997
+ // reading only the outermost name would miss every wrapped spawn failure.
18998
+ // `telemetryErrorClass` is the same allowlist that guards every other
18999
+ // identifier leaving this process, so a hostile `name` cannot invent a key
19000
+ // here either — it collapses to `Error`, which the map does not hold.
19001
+ for (let cursor = error, depth = 0; cursor instanceof Error && depth <= PASS_FATAL_CAUSE_DEPTH; depth += 1) {
19002
+ const named = factoryDispatchFailureReasonCodeForErrorClass(telemetryErrorClass(cursor));
19003
+ if (named)
19004
+ return named;
19005
+ cursor = cursor.cause;
19006
+ }
19007
+ return UNCLASSIFIED_PHASE_CODES[phase];
19008
+ };
18878
19009
  const triageEscalationQuestion = (decision, issue) => {
18879
19010
  const routedRepos = decision.routes.map((route) => route.repo).filter(Boolean);
18880
19011
  const subject = issue?.title?.trim() || decision.issue.key;